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
- Owner rights on the GitHub organisation (some steps need GitHub Enterprise Cloud, as noted).
- GitHub CLI (
gh) 2.40 or newer andjq. Authenticate with the scopes you'll need:
gh auth login
gh auth refresh -h github.com -s admin:org,read:audit_log,repo,workflow
- A place to record findings. You'll need a list of affected repositories and secrets for rotation.
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:
- SAML SSO-authorised tokens and SSH keys (Enterprise Cloud): list and revoke them from the API.
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"
- Fine-grained personal access tokens: go to Organization settings > Personal access tokens > Active tokens, filter by owner and revoke. The REST endpoint for listing these tokens only accepts GitHub App tokens.
- The user's own session: have the affected user revoke all their classic and fine-grained tokens, sign out other sessions, and check their machine for an infostealer. Stolen tokens usually come from infected developer laptops or leaked CI logs.
- GitHub Apps or OAuth apps: if the push came through an app, suspend the installation and rotate its private key or client secret.
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:
- Read-only
GITHUB_TOKENby default. Workflows that need more should ask for it explicitly withpermissions:.
gh api -X PUT "orgs/$ORG/actions/permissions/workflow" \
-f default_workflow_permissions=read -F can_approve_pull_request_reviews=false
- Move deployment secrets into environments with required reviewers and deployment branches limited to protected branches. A workflow on a freshly pushed
ci-syncbranch then can't read them. - Replace long-lived cloud keys with OpenID Connect (OIDC) federation, with the trust policy limited to specific repositories, branches or environments.
- Restrict personal access tokens in Organization settings > Personal access tokens: require approval for fine-grained tokens and block classic tokens where you can.
- Limit self-hosted runner groups to selected repositories and workflows, and use ephemeral runners.
- Add a repository ruleset that restricts branch creation for non-maintainers, or at least alert on new branches that add or change files under
.github/workflows/.
Verification
- Re-run Steps 1 and 2. Both output files should be empty or contain only branches and runs you can explain.
gh api orgs/$ORG/actions/permissions/workflowreturns"default_workflow_permissions": "read".- On a test repository, push a branch with a workflow that references an environment secret. The job should stop and wait for approval or be refused, without printing the secret.
Troubleshooting
HTTP 404on the audit log endpoint. The account must be an organisation owner, the token needsread:audit_log, and Git events need Enterprise Cloud.- Some repositories are skipped silently. The loops send errors to
/dev/null. Remove2>/dev/nullto see permission errors, for example on repositories where your account isn't an admin. - Too many repositories to scan quickly. Run Step 2 only on repositories that have secrets, or use
gh search code "secrets." --owner $ORGto find workflows that reference them.
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.
