Triggered by a Fork

A few years ago, working on a Node.js service, I reviewed a GitHub Actions workflow. The YAML looked fine at first glance. Trigger on pull request, run the build, run the tests, gate the merge. Standard stuff. But then I noticed the trigger type. It was set to `pull_request_target`. That meant any fork could submit a PR, and their code would execute with our repository permissions. I flagged it. The team changed it to `pull_request`. We moved on. I forgot about it.
I remembered it last week. The Tanstack supply chain attack landed, and the shape of the mistake was instantly familiar. Same trigger. Same trust assumption. Different project, much bigger blast radius. This time, nobody caught it.
Tanstack publishes dozens of packages in the React ecosystem. Their release process used GitHub Actions and npm's trusted publishing feature. When a pull request merged, a workflow would request a short-lived publish token from npm. GitHub would sign a statement confirming which workflow was running, in which repository, on which branch. npm would check that statement against an allow list and hand over a token that expired in minutes. No long-lived secrets to steal. No passwords to phish. It looked airtight.
The flaw was in the workflow trigger. The team used `pull_request_target`. That option tells GitHub Actions to run the workflow in the context of the main repository with the main repository permissions, even when the pull request comes from an external fork. I flagged that same trigger years ago. One YAML line. That is all it takes.
The attacker forked the Tanstack repository, created a pull request, and closed it. Nobody reviewed it. Nobody even saw it. But just creating the PR was enough to trigger the workflow. The `pull_request_target` setting meant the attacker's code executed with the main repository permissions. It wrote a poisoned file into the shared CI cache, the same cache GitHub Actions uses to reuse dependencies between jobs. Later, when a legitimate pull request merged into main, the poisoned cache entry fed into the workflow. It extracted the OIDC token from the GitHub Actions runner and published eighty-four compromised packages. The publishing itself took under six minutes.
Here is what makes this sting. Every compromised package was signed, verified, and shipped through npm's trusted publishing feature. The feature built specifically to prevent supply chain attacks became the delivery mechanism. The attacker did not steal a token. They hijacked the legitimate token issuance flow. The security was real. The trust assumption underneath it was wrong.
From there, the worm did what worms do. It scanned infected machines for npm publishing tokens. When it found them, it used those tokens to publish new poisoned packages under new names. A Tanstack problem became an everybody problem. The first wave of victims included maintainers at several AI and automation companies. Within hours, their compromised packages appeared on npm. Through their Python SDKs, the worm jumped to PyPI. By the next morning, security researchers were tracking hundreds of poisoned versions across nearly two hundred packages.
The worm kept getting smarter. It forged commits signed by a popular AI coding assistant's GitHub app, so its malicious activity blended in with the automated commits maintainers were already used to seeing. It embedded itself into VS Code and AI coding tool configuration directories, so even after a developer uninstalled the bad packages, the worm would re-execute the next time they opened their editor. It ran as a daemonized process, fully detached from the terminal, checking repeatedly whether the stolen token was still valid. You could not simply uninstall and move on. Cleanup required full system isolation.
Since that review, I have asked the same question before every workflow ships. What context does this run in, and who controls the code? The answer separates `pull_request_target` from `pull_request`. The first runs forked code with your permissions. The second runs forked code with the fork's permissions. The syntax changes between Jenkins, GitLab CI, Azure DevOps, and GitHub Actions. The trust assumption does not. If a trigger lets code you did not write execute with tokens you control, you have a problem. Short-lived tokens do not fix it. Signed commits do not fix it. Trusted publishing does not fix it. The trigger is the gate, and if the gate opens for anyone, the rest does not matter.
If you consume packages, switch to pnpm version eleven. It ships with minimum release age, which refuses packages published less than twenty-four hours ago, and block exotic subdependencies, which closes the tarball smuggling vector. These features are on by default and would have stopped this exact worm from reaching most developers.
But the real fix is on the maintainer side. Go audit your workflow triggers. I caught mine. The Tanstack team missed theirs. The only difference was one line review. Go check yours today.
Disclaimer: All content reflects my personal views only and does not represent the positions, strategies, or opinions of any entity I am or have been associated with.


