Hunting Rogue CI Branches After a GitHub Token Theft: Finding Secret-Dumping Runs, Revoking Tokens, Rotating Secrets and Locking Down Actions

2026-11-09 · By H.A

Hunting Rogue CI Branches After a GitHub Token Theft: Finding Secret-Dumping Runs, Revoking Tokens, Rotating Secrets and Locking Down Actions

An incident-response walkthrough with gh CLI for finding ci-sync style branches and runs that print CI secrets, revoking the stolen tokens, rotating exposed secrets and hardening GitHub Actions.

A stolen GitHub token with write access is enough to steal every CI secret its repositories can reach. The attacker pushes a new branch with a workflow that prints the secrets, encoded so GitHub's log masking doesn't catch them, then reads the run log. Researchers tracking the Gheist operation this month counted more than 13,500 such ci-sync branches, with private repositories and self-hosted runners among those hit. This tutorial is an incident-response walkthrough for a GitHub organisation: find rogue branches and runs, read and remove the leaked logs, cut off the tokens, rotate what was exposed, and change settings so a stolen token is worth much less next time.

Prerequisites

gh auth login
gh auth refresh -h github.com -s admin:org,read:audit_log,repo,workflow

Step 1: Find suspicious branches across every repository

Start with the known branch name, then widen the search. Branch names are cheap for an attacker to change.

ORG=your-org
gh repo list "$ORG" --limit 2000 --json nameWithOwner -q '.[].nameWithOwner' > repos.txt

while read -r r; do
  gh api "repos/$r/branches" --paginate -q '.[].name' 2>/dev/null |
    grep -iE '^(ci-sync|ci_sync|sync-ci)' | sed "s|^|$r  |"
done < repos.txt | tee suspicious-branches.txt

For each hit, find out who pushed it and when:

gh api "repos/OWNER/REPO/branches/ci-sync" \
  -q '{sha: .commit.sha, author: .commit.commit.author, committer: .commit.commit.committer}'

Commit author fields can be forged, so treat them as a lead, not proof. The audit log in Step 3 shows which account and token actually pushed.

Step 2: Find workflow runs from branches nobody owns

The more reliable signal is a run on a non-default branch that was triggered by push and that nobody on the team recognises:

while read -r r; do
  def=$(gh repo view "$r" --json defaultBranchRef -q .defaultBranchRef.name)
  gh run list -R "$r" --limit 200 \
    --json databaseId,headBranch,event,workflowName,createdAt,conclusion \
    -q ".[] | select(.event==\"push\" and .headBranch!=\"$def\") |
         \"$r \(.databaseId) \(.headBranch) \(.workflowName) \(.createdAt)\""
done < repos.txt | tee offbranch-runs.txt

Look at every run whose workflow name or branch you don't recognise. Read its log and search for long Base32 or Base64 blobs, which is how the secret-dumping workflows get around masking:

gh run view RUN_ID -R OWNER/REPO --log | grep -nE '[A-Z2-7]{40,}={0,6}|[A-Za-z0-9+/]{60,}={0,2}'

If you find encoded output, assume every secret that workflow could read is compromised. That means repository secrets, organisation secrets shared with the repo, and environment secrets if the job named an environment.

Step 3: Use the audit log to identify the token and actor

On GitHub Enterprise Cloud, the organisation audit log can include Git events, so you can see who pushed:

gh api "orgs/$ORG/audit-log?include=git&phrase=action:git.push+repo:$ORG/REPO&per_page=100" \
  --paginate | jq -c '.[] | {t: ."@timestamp", actor, action, repo, prog: .programmatic_access_type}'

Also look for other activity by the same account, such as new deploy keys, workflow changes and repository creation:

gh api "orgs/$ORG/audit-log?phrase=actor:SUSPECT_USER&per_page=100" --paginate |
  jq -c '.[] | {t: ."@timestamp", action, repo}'

The programmatic_access_type field shows whether the push came from a personal access token, an OAuth app or a GitHub App. That tells you what to revoke.

Step 4: Remove the leaked logs and the rogue branches

Do this after you've saved evidence: run IDs, timestamps and a hash of each log. Then delete the runs so nobody else can read them, and delete the branches:

gh run view RUN_ID -R OWNER/REPO --log > evidence/RUN_ID.log && sha256sum evidence/RUN_ID.log
gh run delete RUN_ID -R OWNER/REPO
gh api -X DELETE "repos/OWNER/REPO/git/refs/heads/ci-sync"

Deleting logs doesn't make the secrets safe again. It only stops further readers. Rotation (Step 6) is still required.

Step 5: Cut off the stolen credentials

What you can revoke depends on the token type:

gh api "orgs/$ORG/credential-authorizations" --paginate |
  jq -r '.[] | "\(.credential_id) \(.login) \(.credential_type) \(.credential_accessed_at)"'
gh api -X DELETE "orgs/$ORG/credential-authorizations/CREDENTIAL_ID"

Step 6: Rotate every exposed secret

Build the list of what the workflow could reach:

gh secret list -R OWNER/REPO
gh secret list -R OWNER/REPO --env production
gh secret list --org "$ORG"

Rotate each secret at its source first, whether that's a cloud key, a registry token or a database password. Then update GitHub:

gh secret set AWS_SECRET_ACCESS_KEY -R OWNER/REPO < new-secret.txt

Afterwards, check each provider's logs for use of the old credential between the rogue run and the rotation.

If self-hosted runners ran the rogue workflow, treat those hosts as compromised. Rebuild them from a clean image instead of cleaning them up.

Step 7: Make the next stolen token worth less

These changes do the most to limit what a stolen token can do:

gh api -X PUT "orgs/$ORG/actions/permissions/workflow" \
  -f default_workflow_permissions=read -F can_approve_pull_request_reviews=false

Verification

Troubleshooting

Wrapping Up

A token-based secret dump finishes within minutes, so how bad it gets depends on what was already in place: secrets locked to environments and protected branches, OIDC instead of static keys, and a read-only default token. Run the hunt now even if you've seen no alert. A five-minute scan for off-branch push runs is cheap, and finding one weeks later is not.

REEF SENTINEL