# Gheist: Stolen GitHub Tokens Are Dumping Actions Secrets Past Log Masking. Hunt for ci-sync Branches

URL: https://www.devopsness.com/blog/github-actions-gheist-stolen-tokens-ci-sync-secrets
Author: Kiril Urbonas
Published: 2026-11-08
Updated: 2026-11-08
Categories: DevOps
Tags: GitHub Actions, Supply Chain, Secrets, Security, News

Summary: An automated tool is using stolen GitHub tokens to plant ci-sync branches whose workflow prints every secret base32-encoded, grabs the log and deletes the run. How to hunt for it across an org, what to rotate, and the controls that stop the next one.

---
# Gheist: Stolen GitHub Tokens Are Dumping Actions Secrets Past Log Masking. Hunt for ci-sync Branches

If any token in your GitHub org could have been stolen, search every repository for `ci-sync-` branches today. An automated tool Wiz calls Gheist pushes a workflow that prints all secrets base32-encoded, so log masking misses them, then downloads the log and deletes the run. Private and GitHub Enterprise Server repositories are the exposed ones.

> [!VERIFIED]
> Campaign details, indicators and dates against Wiz Research's Gheist report (October 25, 2026); masking, OIDC subject and audit log behavior against GitHub's Secure use reference, OpenID Connect reference and organization audit log events docs. Checked November 8, 2026.

## What happened, in dates

Wiz Research has tracked the activity since September 15, 2026 and published its report on October 25. By then it had recovered more than 13,500 planted branches from more than 850 compromised accounts, from personal projects to private enterprise repositories. The name comes from the `gheist` user agent the tool sends with its API calls. Where Wiz could trace the tokens, they came from infostealer logs and from tokens exposed in earlier supply-chain compromises such as the March Trivy compromise.

The pattern is the same everywhere. For each repository a token can reach, the tool creates a branch named `ci-sync-` plus six random characters and commits one file, `.github/workflows/sync-check.yml`, with the message `ci: sync config`. The commit is unsigned and authored as the token's owner. The push triggers the workflow, which sets `permissions: {}` (that limits `GITHUB_TOKEN`, not the secrets context) and pipes both `env` and `toJSON(secrets)` through `base32 -w 120`. Where a repository has deployment environments, one variant adds a job matrix that runs once per environment, so environment-scoped production secrets print too.

The tool then pulls the run log through the GitHub API and, in Wiz's words, "deletes the run seconds later." There is no command-and-control server: the secrets leave through GitHub itself, so there is no domain or IP to block.

Two dates change the risk picture. Since July 28, 2026, GitHub holds potentially malicious workflow runs in public repositories until a collaborator approves them, and every public Gheist run Wiz checked sat at `action_required`. That hold does not cover private repositories or GitHub Enterprise Server, where audit logs showed runs completing and being deleted. On October 19 the tool changed its payload, and hours later a `sync-host` job with `runs-on: self-hosted` appeared on repositories that had a self-hosted runner registered. It searches the host for 31 token formats, private keys, `.env` files and Terraform state, reads `/proc/*/environ`, mounts the host filesystem through the Docker socket, and asks for cloud instance credentials and the Kubernetes service account token. Wiz found the job buggy, for now.

## Am I affected? Hunt across the org

Wiz gives the branch pattern as `^ci-sync-[0-9a-z]{6}$` (two variants run in parallel: one uses hex suffixes, the other, first seen September 28, base36; the regex covers both). The first loop uses the matching-refs endpoint, one prefix query per repository instead of paging through every branch:

```bash
# 1. ci-sync branches in every repo of an org (empty repos return 409, hidden by 2>/dev/null)
$ ORG=your-org
$ for r in $(gh repo list "$ORG" --limit 5000 --json nameWithOwner -q '.[].nameWithOwner'); do
>   gh api "repos/$r/git/matching-refs/heads/ci-sync-" -q '.[].ref' 2>/dev/null \
>     | sed 's|^refs/heads/||' | grep -E '^ci-sync-[0-9a-z]{6}$' | sed "s|^|$r: |"
> done

# 2. In a local clone: the commit message, and Wiz's published workflow blob SHAs
$ git fetch --all --prune
$ git log --all --oneline --grep='^ci: sync config$'
$ git log --all --oneline --find-object=f3da2b8b357dcda02fd455d314be2a95007516a6

# 3. Deleted runs and the gheist user agent (org owner, GitHub Enterprise Cloud, read:audit_log)
$ gh api "orgs/$ORG/audit-log?phrase=action:workflows.delete_workflow_run&per_page=100" --paginate \
>   -q '.[] | [(.["@timestamp"]/1000 | todate), .actor, .repo, .user_agent] | @tsv' | grep -i gheist

# 4. AWS: OIDC role assumptions from a ci-sync branch (90 days, current region only)
$ aws cloudtrail lookup-events --start-time 2026-09-15 \
>   --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
>   --query 'Events[].CloudTrailEvent' --output text | grep -o 'ci-sync-[0-9a-z]\{6\}' | sort | uniq -c
```

The blob SHA in check 2 is the plain variant; Wiz lists three more (`d09535857a741bbb976bdc084a9a021713133291`, `1dc5af9cce950aad1eb1546ecce8565b117540c4`, `c665b778b2d822453293003e7ca0bbb2cb08e39d`), and the environment-matrix files differ per repository, so the commit message search matters more. Rerun check 4 in every region your runners call STS in.

| Indicator | Value | Where to look |
|---|---|---|
| Branch | `ci-sync-` plus 6 characters | Branches API, local clones |
| Commit | `ci: sync config`, only `sync-check.yml` | `git log --all` |
| Audit log | `workflows.delete_workflow_run`, user agent `gheist` | Org audit log |
| Runner files | `.gdmp_pat`, `.gdmp_patg`, `.gdmp_sweep`, `gdmp-pivot` | Self-hosted runner hosts, job logs |

## What to do now

**Found a branch?** Revoke the token that pushed the commit (the author is the token's owner) and check that person's machines for an infostealer. Then rotate every repository, organization and environment secret the repository could read. The `workflows.prepared_workflow_job` audit event lists the secrets provided to each job, which tells you exactly what one run received. Deleting the branch or the run does not undo anything: the attacker already has the log.

**Check the cloud side.** Wiz saw the same enterprise organization's cloud credentials used hours later to enumerate AWS Secrets Manager, and CloudTrail showing workflows on attacker branches assuming AWS roles. Treat any role a `ci-sync` run assumed as used.

**Self-hosted runners.** If a `ci-sync` branch ran on one, treat the host and its instance role as compromised, and rebuild it rather than clean it.

**Public repositories.** Do not approve a held `sync-check` run. That approval is the only thing between the payload and your secrets.

## Where this breaks

Log masking was never a control against someone who can edit a workflow. GitHub's Secure use reference says "automatic redaction is not guaranteed" and that anyone with write access can read every secret in the repository, so a stolen token with push rights is the whole attack.

The OIDC check has a blind spot. GitHub's default subject is `repo:ORG/REPO:ref:refs/heads/BRANCH` only when the job has no environment; a job that references an environment gets `repo:ORG/REPO:environment:NAME` instead, and the branch never appears. Gheist's environment matrix produces exactly those jobs, so for environment-bound roles, correlate role assumptions by time against the deleted runs from check 3. Repositories created, renamed, transferred or opted in after July 15, 2026 use an ID-based subject (`repo:OWNER@OWNER-ID/REPO@REPO-ID:...`); the branch grep still matches, but trust policies must match it.

The audit log API needs GitHub Enterprise Cloud, the log keeps 180 days, and the REST endpoint defaults to three months without a `created:` qualifier. Wiz also does not report whether the self-hosted variant ran successfully in any private repository, so absence of evidence is not a clean bill for runner hosts.

## Questions people ask

### Does GitHub Actions log masking protect my secrets?

Only from accidents. Masking replaces a secret's exact value in log output; base32, base64 or any other transform produces a different string that prints in full. GitHub's own guidance says redaction is not guaranteed and that a transformed value must be registered separately. Anyone who can push a workflow file can read the secrets, which is why the defense is limiting who can push workflows, not masking.

### Does deleting the workflow run remove the leaked secrets?

No. Gheist downloads the log before it deletes the run, so by the time the run disappears the encoded secrets are already off GitHub. Deleting the `ci-sync` branch or the run removes evidence, not exposure. Rotate every secret the repository could read, including organization and environment secrets, and revoke the token that pushed the commit.

### Are public GitHub repositories safe from Gheist?

Mostly, so far. Since July 28, 2026, GitHub holds potentially malicious workflow runs in public repositories until a collaborator approves them, and every public Gheist run Wiz checked was stuck at `action_required`. The risk is someone approving one. Private repositories and GitHub Enterprise Server have no such hold, which is where Wiz saw runs complete.

## The decision, concretely

- **Using classic personal access tokens for automation?** Move to GitHub Apps or short-lived fine-grained tokens with an expiry; a stolen token that expires in days limits the window.
- **Can anyone with write access change `.github/workflows/`?** On private and internal repositories, a push ruleset with **Restrict file paths** on `.github/workflows/**` blocks those pushes for everyone outside the bypass list.
- **Production secrets in environments?** Add required reviewers and limit deployment branches to selected branches, since environment secrets are released only after those rules pass. On private repositories, required reviewers need GitHub Enterprise.
- **OIDC roles trusting `repo:ORG/*`?** Pin the subject to `ref:refs/heads/main` or a named environment, as covered in [our OIDC federation guide for AWS](/blog/oidc-federation-github-actions-aws).

## The call we'd make

Run the four hunt checks today on every org you own, and treat one hit as a full secret rotation for that repository, not a branch cleanup. Then close the door: a push ruleset on workflow paths and environment reviewers stop this tool even with a valid stolen token. The caveat: Gheist is evolving and the self-hosted variant goes after the runner host, so the [GitHub Actions hardening steps from the Trivy compromise](/blog/github-actions-security-hardening) matter as much as the hunt.

## Sources

- [Wiz Research: Gheist, attempted scaled abuse of stolen GitHub tokens stumbles](https://www.wiz.io/blog/gheist-attempted-scaled-abuse-of-stolen-github-tokens-stumbles)
- [GitHub Changelog: GitHub Actions holds potentially malicious workflows for approval (July 28, 2026)](https://github.blog/changelog/2026-07-28-github-actions-holds-potentially-malicious-workflows-for-approval/)
- [GitHub Docs: Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use)
- [GitHub Docs: OpenID Connect reference](https://docs.github.com/en/actions/reference/security/oidc)
- [GitHub Docs: Audit log events for your organization](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization)

