Look at the three biggest CI/CD incidents of 2026 and you’ll find three different entry points and one destination. Megalodon walked in through stolen personal access tokens. TanStack was breached by a pull request from a fork that nobody had to approve. GhostAction came back in October with the same stolen-token play it ran a year ago. Different doors, same room: a GitHub Actions runner with your secrets in its environment, an OIDC token in its memory, and a network path to the internet.
If you run infrastructure, that room is probably the most privileged compute you own that nobody on your team has threat-modeled. It can publish packages, assume cloud roles, push to production branches, and read every org secret you mapped into it for convenience. It also executes whatever YAML a write-access token can commit.
This is a field guide in two halves. First, how the attackers actually got in, using only what the incident reports establish. Second, an ordered hardening plan. The order matters: the first three items kill most of what we saw this year, and they cost an afternoon.
Three Doors, One Room
Door 1: Stolen tokens and the “security scan” workflow (GhostAction)
GhostAction has been running since September 2025. The original wave compromised 817 public repositories across 327 users and exfiltrated at least 3,325 secrets. GitGuardian’s October update describes roughly 1,900 compromised GitHub accounts and attempts against more than 30,000 repositories, with the biggest push on October 8-9. (The 30,000 figure counts repositories targeted, not confirmed breached, and sources disagree on whether 1,900 covers the whole campaign or just the latest wave.)
There is no exploit here. The operators get write access through personal access tokens or credentials pulled from infostealer logs, then commit a new workflow with a name like security-audit.yml or github_actions_security.yml. The workflow collects every secret mapped into the job, greps the full Git history for cloud keys, registry tokens and AI provider API keys, and ships it all over plain HTTP to 193.32.204.199. GitHub began blocking that workflow on October 9, which reportedly neutered the later waves. Everything exfiltrated before that is valid until someone rotates it.
On October 19 the operators were also reported phishing open-source developers with a fake font file that delivers a stealer, which tells you where the next batch of tokens comes from. The campaign is a loop: stealer, token, workflow, secrets, more access, more stealer lures.
Door 2: Mass commits from forged “CI bots” (Megalodon)
On May 18, between 11:36 and 17:48 UTC, an automated campaign pushed 5,718 commits to 5,561 repositories. Authors were forged as build-bot, auto-ci, ci-bot and pipeline-bot, using throwaway accounts with random eight-character usernames. Hudson Rock found that more than a third of affected usernames matched machines already known to be infected by infostealers. Initial access was developers’ laptops, not GitHub.
The payloads came in two flavors. SysDiag added a workflow triggered on push and pull_request, so it ran on nearly every event. Optimize-Build replaced an existing workflow with a workflow_dispatch trigger: a dormant backdoor the attacker fires later through the API, when you’ve stopped looking. Both carried a base64 bash payload that scraped environment variables, /proc/*/environ, cloud metadata (IMDSv2), SSH keys, Docker and Kubernetes configs, and the one that matters most: the Actions OIDC request URL and token. The reported haul on the attacker’s ingest server by May 21 was 575,352 files and 449 GB.
Door 3: pull_request_target plus cache poisoning (TanStack, Grafana)
This is the one that requires no stolen credential at all. In the TanStack compromise (CVE-2026-45321, CVSS 9.6), the attacker forked the repo, opened a PR that triggered a pull_request_target workflow, and used the foothold to poison the Actions cache that the release pipeline later restored. When a legitimate release ran on main, it executed attacker-controlled binaries during pnpm install. Those binaries read the runner worker’s memory, extracted the OIDC token used for npm trusted publishing, and published 84 malicious versions across 42 packages. The packages carried valid SLSA Build Level 3 provenance, because they were, in fact, built by the legitimate pipeline.
Grafana’s May incident was the same primitive in a plainer form: a recently enabled workflow on pull_request_target ran fork-supplied steps with base-repo secrets, and the extortion crew CoinbaseCartel walked out with source from five private repositories. Grafana caught it with a canary token, not a SIEM alert.
What the three share
| Step | GhostAction | Megalodon | TanStack / Grafana |
|---|---|---|---|
| Entry | Stolen PAT | Stolen PAT / deploy key | Fork PR, no credential |
| Persistence | New workflow file | New or replaced workflow | Poisoned cache |
| Loot | Secrets, git history | Env, IMDS, OIDC token | OIDC token, repo secrets |
| What stopped it | Platform block | Nothing structural | Canary (Grafana) |
Every row ends in the same asset. So the hardening plan is about shrinking what a runner can reach and who can change what it runs.
The Hardening Plan, In Order
1. Make the default token read-only, everywhere
Set this at the org level and again in each workflow. Anything that needs more declares it per job.
| |
An empty top-level permissions block means a hijacked job gets a GITHUB_TOKEN that can do almost nothing. Add scopes at the job level, and give id-token: write only to the single job that actually authenticates to a cloud or registry. In the TanStack chain, the blast radius was whatever the runner’s OIDC token could do; it should have been available to one narrowly scoped publish job.
2. Kill pull_request_target on anything that touches fork code
Audit every repo:
| |
Each hit is either a metadata-only workflow (labeling, commenting) or a vulnerability. The rule is simple: a pull_request_target workflow must never check out the PR head, run a script from the PR, or call a local action from the PR. If a workflow needs to build contributor code, use plain pull_request (no secrets, read-only token) to build and test, and hand artifacts to a separate workflow_run job that does the privileged work on reviewed output only. Static analysis catches the classic mistakes; run zizmor or an equivalent linter in CI against your own workflows, since it flags pull_request_target misuse, template injection, and unpinned actions in one pass.
3. Put the workflows directory behind review
GhostAction and Megalodon both work by committing a file. Make that expensive.
| |
Then enable branch protection or a ruleset on the default branch that requires CODEOWNER review, blocks force-pushes, and applies to admins. The reporting on Megalodon says weak branch protection was what made repositories viable targets. Also use a ruleset to restrict who can create tags and branches that can trigger privileged workflows. A workflow_dispatch backdoor is only dangerous if its trigger event is reachable from a branch the attacker can write.
4. Retire classic PATs
Both mass campaigns ride on long-lived, broad-scope tokens that infostealers harvest from developer laptops. In the org settings:
- Disable classic personal access tokens, or require approval for fine-grained ones.
- Prefer GitHub Apps with per-repo installation tokens for automation. They expire in an hour and don’t live on laptops.
- Set a maximum token lifetime and enforce SSO authorization for tokens against the org.
- Cross-reference your developers against infostealer-exposure feeds. A third of Megalodon’s victims were already infected; that data was purchasable before the attack.
5. Scope secrets to environments with approvals, not to the org
Org-level secrets “available to all repositories” are what make a single workflow commit catastrophic. Move deployment credentials into environments with required reviewers and branch restrictions:
| |
A malicious workflow on a feature branch then hits an approval gate for the production environment instead of silently receiving its secrets. It doesn’t stop a determined insider, but it converts “silent exfiltration” into “a page for a human.”
6. Constrain what the OIDC token is worth
Cloud trust policies are where OIDC theft becomes either a nuisance or a breach. The default mistake is trusting the entire repo. Pin the subject claim to the exact ref and workflow:
| |
With the environment subject and job_workflow_ref pinned, a token minted by a rogue workflow on another branch doesn’t satisfy the trust policy. Keep the role’s permissions to the one action the pipeline needs. Note that this would not have saved TanStack’s npm publish, where the stolen token came from the legitimate release workflow itself. For that case, the answer is step 7.
7. Don’t let caches cross the trust boundary
The TanStack chain depended on a fork-written cache being restored by a privileged release job. Rules of thumb:
- Release and publish workflows should not restore caches at all. Pay the extra minutes for a clean install from a lockfile with
--frozen-lockfile(or the equivalent). - Never share cache keys between
pull_request/fork contexts and release contexts. - Treat
actions/cacherestore as untrusted input in any job holdingid-token: write.
A related lesson from the TanStack write-up: SLSA provenance says where an artifact was built, not whether the build was clean. If your consumers gate on provenance alone, a compromised pipeline passes. Gate on provenance plus a publisher allowlist and a minimum package age.
8. Pin actions to commit SHAs and enforce it
Mutable tags are how an action compromise propagates to everyone. TeamPCP, the actor behind Megalodon, has also been tied to the Trivy and Checkmarx KICS action compromises. Pin by full SHA and let Dependabot or Renovate bump it with review:
| |
GitHub’s org-level policy can require actions to be pinned to a full-length commit SHA and can allowlist which actions run. Turn both on. Fork the handful of third-party actions you can’t live without into your org and review them once.
9. Lock down the runner’s egress
All three campaigns exfiltrated over the network, GhostAction on plain HTTP to a bare IP. A runner that only needs github.com, your registry and your artifact store has no business talking to anything else. Options, in rising cost:
- Use a runtime hardening agent (StepSecurity’s Harden-Runner is the common one) in
auditmode, then flip toblockwith an allowlist. - Run self-hosted runners in a network segment with default-deny egress and a logging proxy.
- Make runners ephemeral, one job per VM or container, so a poisoned runner can’t persist or leak state to the next job.
Block IMDS from build jobs unless they need it. Megalodon’s payload explicitly queried cloud metadata endpoints; enforcing IMDSv2 with a hop limit of 1 on self-hosted runner instances stops containerized jobs from reaching it.
Detection: What to Alert On
Prevention fails. These are cheap, high-signal detections from the audit log and your egress data.
1. Workflow file changes outside your normal path. Alert on any push that adds or modifies .github/workflows/* where the committer is not a human with interactive-session context, or where the author name matches a bot pattern you don’t operate. Watch for the specific names from this year: build-bot, auto-ci, ci-bot, pipeline-bot, and files named security-audit.yml, github_actions_security.yml, or anything calling itself SysDiag or Optimize-Build.
| |
2. Dormant triggers appearing. A pre-existing workflow that gains workflow_dispatch is exactly the Optimize-Build shape. Diff trigger blocks on every workflow change and alert on additions.
3. Egress to bare IPs from runners. Plain HTTP or TLS to an IP literal from a CI job is almost never legitimate. Known indicators from this year’s reporting: 193.32.204.199 (GhostAction) and 216.126.225.129:8443 (Megalodon). Treat those as retro-hunt seeds, not a durable blocklist; infrastructure rotates.
4. Canary credentials in the CI environment. This is the only control that caught a breach in these incidents. Seed plausible fake cloud keys and registry tokens in runner env, .env.example, and Terraform. Make them look real: matching prefix, length, and surrounding context. A trip means someone enumerated secrets they shouldn’t have, and it fires regardless of which door they used.
5. Unusual OIDC token exchanges. Alert when your cloud provider sees AssumeRoleWithWebIdentity from a sub claim you don’t expect, or from a workflow ref that isn’t release.yml@refs/heads/main.
If You Think You Were Hit
Order matters because the attacker’s tokens are still alive while you investigate.
- Rotate first, investigate second. Assume every secret available to a suspicious workflow run is burned: Actions secrets (repo, environment, org), registry tokens, cloud keys, AI provider keys. GhostAction scans Git history, so rotate anything that ever lived in a commit too.
- Revoke the entry credential. Kill the compromised developer’s PATs, SSH keys and sessions, and re-image the machine. Rotating the repo secret while the stealer is still on the laptop just hands the attacker the new one.
- Re-issue OIDC trust. Tighten the trust policy (step 6) before re-enabling the role.
- Inventory what you published. Check your registries for versions published in the window. If the pipeline could publish, assume it did something you didn’t intend.
- Look for the dormant backdoor. Search every branch of every repo, not just the default branch, for workflow files added or edited in the window. Megalodon’s
workflow_dispatchvariant waits for a signal.
Takeaways
- The runner is a privileged identity, not a build tool. Threat-model it like a production service account: least privilege, short-lived credentials, constrained egress.
- The cheapest wins are the first three. Read-only default token, no
pull_request_targeton fork code, and CODEOWNERS on.github/workflows/close the entry points behind Megalodon, GhostAction, and the TanStack/Grafana pattern respectively. - Stolen developer credentials are the real root cause for two of the three campaigns. Your CI hardening is only as good as your endpoint hygiene, so treat infostealer exposure as a CI risk metric.
- Provenance is not a clean bill of health. TanStack’s malicious releases were validly attested. Gate on more than one signal.
- Assume prevention will leak, and plant canaries. The one team in these incidents that detected the breach early did it with a fake credential.
None of this is exotic. The pull_request_target hazard has been documented since 2021. The industry doesn’t lack advisories; it lacks the afternoon someone spends applying them. Spend it this week, before the next wave arrives with a better filename.