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.

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_targetandissue_commenttriggers that run untrusted-event workflows with full secret access${{ }}template expansion that does textual substitution into shell scripts with no quotingGITHUB_TOKENdefaulting 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_targetwith 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: writeto 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.