Your CI Pipeline Is the Attack Surface. GitHub's Defaults Made It That Way.

tj-actions hit 23,000 repos. nx exfiltrated 5,000. elementary-data went from a comment by a two-day-old account to a malicious PyPI wheel in ten minutes. Different payloads, same five GitHub Actions defaults. Here's the chain - annotated.

7 min read 1530 words

Your CI Pipeline Is the Attack Surface

Pick any open source supply chain attack from the last 18 months and trace it back. You will end up reading a .github/workflows YAML file.

Ultralytics shipping a crypto miner to PyPI. The nx packages that turned thousands of developer machines into credential harvesters. tj-actions leaking secrets from 23,000 repositories. Trivy compromised twice in three weeks. A two-day-old GitHub account leaving a comment on an old pull request and pushing a malicious package to PyPI ten minutes later, with no maintainer awake, without anyone clicking anything.

Different payloads. Different victims. The same platform features, behaving exactly as documented.


The Chain That Started It All

The earliest link is spotbugs, November 2024. A workflow using the pull_request_target trigger checked out and built code from an untrusted fork. That trigger exists to let workflows label PRs from forks, and to do that job it runs in the context of the base repository with full secret access and a write-scoped token. Combine it with a checkout of the fork’s code and you have just handed a stranger code execution inside your trust boundary.

A malicious PR lifted a maintainer’s PAT. That PAT had access to reviewdog. Four months later, the same actor used it to seed the tj-actions/changed-files compromise (CVE-2025-30066). The attacker pushed a malicious commit to reviewdog/action-setup and moved the v1 tag to point at it. tj-actions/changed-files referenced reviewdog by tag. 23,000 downstream repositories referenced changed-files by tag. Every one of them ran a memory scraper that dumped runner secrets into public build logs. CISA put out an advisory. The original target was Coinbase.

The platform feature at fault: action versions are git refs in someone else’s repository, force-pushable by anyone with write access to that repository, resolved by default through a moving tag rather than a content hash.


The Same Trigger, Used Again and Again

A month after spotbugs, Ultralytics was hit through the same pull_request_target trigger. The fork PR could not reach publishing credentials directly, so instead it poisoned a GitHub Actions cache entry. When the legitimate release workflow later restored that cache, it executed the payload while building wheels. Two versions of ultralytics reached PyPI with a miner inside.

The cache is keyed by branch and shared down to children. The pull_request_target job runs as the default branch. Nothing in the UI or the API tells you that an entry was written by a job processing untrusted input.

In August 2025, the nx build system was hit through a pull_request_target workflow that interpolated the pull request title into a shell step. The ${{ github.event.pull_request.title }} template syntax expands before the shell sees the script. A PR with a command substitution in the title becomes code. Because of the trigger, that code ran with an npm publishing token in scope. The malicious nx releases went after AI coding assistant credentials and used them to exfiltrate over five thousand private repositories.

By early 2026, attackers stopped finding these one at a time and started running campaigns. The prt-scan operation spent six weeks across March and April opening hundreds of pull requests against repositories with pull_request_target misconfigurations, rotating through throwaway accounts, using generated diffs that looked like plausible contributions until the workflow fired.

Then Trivy’s action repository was compromised through the same trigger in late February 2026. Aqua cleaned up, but credential rotation was not atomic. Three weeks later the same actor used the harvested tokens to force-push 76 of 77 historical version tags. Users pinned to an old “known-good” @0.x.y ran the credential stealer anyway.


The One That Requires No Maintainer at All

Last week a GitHub account two days old left a comment on an old elementary-data pull request. The repository had a workflow listening on issue_comment that echoed ${{ github.event.comment.body }} into bash. The comment closed the echo string and curled a stager. Because the workflow had no permissions: block, the default write-scoped GITHUB_TOKEN applied. The stager pushed a commit with a forged github-actions[bot] author, dispatched the existing release workflow, and put a credential-stealing wheel on PyPI and a matching image on GHCR within ten minutes.

No maintainer accepted a PR. No one clicked a button. No one was awake.

Andrew Nesbitt, who wrote the post that documents this chain, notes that the elementary-data workflow had been flagged High/High by zizmor in his scans three weeks before the attack. The vulnerability was visible. Nobody looked.


Why This Is a Structural Problem, Not a Maintenance Problem

None of the maintainers above were doing anything unusual. Their workflows look like the examples in GitHub’s own documentation. That is the problem.

The defaults GitHub ships were chosen for a private-repo enterprise CI tool. They were never rethought for anonymous forks, drive-by pull requests, or public repositories that publish packages millions of people install.

The five features that keep recurring across every incident:

  • pull_request_target and issue_comment triggers that run untrusted-event workflows with full secret access
  • ${{ }} template expansion that does textual substitution into shell scripts with no quoting
  • GITHUB_TOKEN defaulting to write scope on any repository created before February 2023
  • Action versions that are mutable git refs, re-resolved on every run against tags anyone with write access can move
  • A cache that crosses trust boundaries with no visibility into who wrote it

None of these are bugs. GitHub has not announced plans to remove any of them.


The Package Registry Problem Nobody Is Talking About

PyPI, npm, RubyGems, and crates.io have all adopted OIDC-based trusted publishing from CI. The intention was sound: get long-lived API tokens out of repository secrets. That is a real improvement.

The side effect is that the integrity guarantee of those registries is now roughly as strong as the GitHub Actions workflow that holds id-token: write.

A decade of work hardening package managers with lockfiles, 2FA mandates, signatures, and provenance attestations now concentrates trust on one CI platform that has none of those properties itself. The overwhelming majority of OIDC publishes to the big registries come from GitHub-hosted runners. An attacker who wants to get something malicious onto PyPI or npm today is, more often than not, reading workflow files. Not phishing maintainers.

91% of PyPI packages that use third-party actions reference at least one by mutable tag. Two thirds have no permissions: block on at least one workflow. A year after tj-actions, hundreds of packages still point at it by tag.


What GitHub Is Doing About It

GitHub published a security roadmap last month. It contains real fixes: a workflow lockfile that pins action dependencies to SHAs, policy controls that can ban pull_request_target, secrets scoped to specific workflows, an egress firewall on hosted runners.

Everything is opt-in. Everything is “public preview in three to six months.” The lockfile arrives roughly three years after the issue requesting it was closed as not planned.

GitHub’s stated reason for not flipping defaults is that it would break existing workflows. That is true. It is also, as Nesbitt points out, precisely the argument for doing it. The existing workflows are what keeps going wrong. Opt-in security gets adopted by the projects already paying attention and ignored by the long tail of maintainers who reasonably assume the platform defaults are safe.

The people who would be inconvenienced by a defaults flip and the people currently getting compromised are largely different populations. GitHub could treat public repositories publishing to package registries as a different risk category from private enterprise repos. It has not done that.


What to Do Before GitHub Decides to Act

Run zizmor. Four lines of YAML, catches the full audit surface across dangerous triggers, cache poisoning, template injection, unpinned actions, and excessive permissions. The elementary-data attack was in its results three weeks before the incident.

Beyond that:

  • Pin every action to a full commit SHA, not a tag. Tags move. SHAs do not.
  • Add permissions: {} at the top of every workflow file, then grant only what the job actually needs.
  • Never use pull_request_target with a checkout of the fork’s code. If you need both behaviors, split them into separate workflows with an explicit approval gate between them.
  • Treat ${{ github.event.* }} as untrusted input. If it comes from a PR title, branch name, issue body, or comment, it will eventually be a shell script.
  • If your workflow publishes to PyPI, npm, or any registry with OIDC trusted publishing, scope id-token: write to only the job that needs it and add environment protection rules that require manual approval.

The roadmap features GitHub is shipping will help the projects that implement them. The attack surface for everyone else stays exactly where it is.


Sources:

Written by Nirav Joshi · Fullstack and Blockchain Developer

Newsletter

Want the next post like this?

Subscribe for occasional emails when I publish something worth your time.