Wiz's write-up of Gheist reads like a campaign that failed. In public repos, it did. In private repos it worked, and the reason is one line: in CI, anyone who can push a branch can run code with your secrets. The only thing that saved the public repos was a human standing between the push and the run.
What Happened
Since mid-September, someone has been pushing with stolen GitHub tokens. Wiz says the ones it could trace fit infostealer logs and earlier compromises such as Trivy in March. More than 13,500 branches across more than 850 accounts, each named ci-sync- plus six random characters, each adding one file: .github/workflows/sync-check.yml, triggered on push.
The workflow does one thing. It prints toJSON(secrets) through base32, which GitHub's log masking doesn't catch. Where the repo has deployment environments, it adds an environment matrix so those secrets print too. Then the tool pulls the run log through GitHub's API and deletes the run seconds later. As Wiz describes it, there is no attacker domain to block -- the exfiltration channel is GitHub's own API.
Since July 28, GitHub has held potentially malicious workflow runs in public repositories until a collaborator approves them. Wiz: "The control worked, and the malicious payloads never ran." But the hold "doesn't apply to private repositories or GitHub Enterprise Server." There, the runs completed and were deleted, and in CloudTrail Wiz found organizations' own workflows assuming AWS roles from the planted branches. In one enterprise, cloud credentials from the same org were later used to enumerate AWS Secrets Manager.
Write Access Is a Credential
None of this needed a GitHub bug. GitHub's own guidance says it plainly: "Any user with write access to your repository has read access to all secrets configured in your repository." A token that can push is that user. The masking isn't a boundary either -- redaction "largely relies on finding an exact match for the specific secret value," so any transform walks around it.
So the class is: CI treats every branch as the deploy pipeline. Repository secrets, environments without rules, and cloud roles that trust the whole repo don't ask which branch is running. Gheist needed no exploit, only a push.
Public repos had a gate between push and execute, and the same payload, from the same tokens, did nothing. The fix is to put that gate in front of the things worth stealing, in private repos too.
The Control
Production credentials shouldn't be reachable from a branch nobody reviewed. On AWS with GitHub OIDC, that's the trust policy's sub condition. This is the common version:
"Condition":
repo:acme/api:* matches any branch, any pull request, any environment. A ci-sync-x7k2qp branch gets the role. This is the one you want:
"Condition":
Then give the production environment a deployment-branch rule for main and required reviewers. A planted branch can name the environment, but it can't deploy to it, so the job fails before it gets the environment's secrets or the token. Wiz's own advice lands in the same place: restrict OIDC trust to protected branches or environments, and "Require reviewers on environments with production secrets." Move long-lived secrets into those environments, and the matrix trick prints nothing worth having.
The Tradeoff
This costs you speed. Every deploy now waits on a branch rule, and required reviewers put a person in the loop -- the same person teams keep trying to remove. Repo-level secrets stay readable by any push, so anything left there should be something you'd accept losing. And none of this helps if the stolen token belongs to someone who can approve their own deployments, or push to main. Then the attacker is the reviewer.
The deeper failure, as I see it, is agency. GitHub gave public repos a gate and the attack bounced off it. Private repos can build their own with environments, branch rules and a scoped sub, and Wiz's CloudTrail evidence says some of them hadn't. That's rarely a missing feature. It's the "it won't happen to us" default, kept because the wide policy works and nothing has gone wrong yet.
Check your trust policies today. If the sub ends in *, every branch is production.