Two of 2026's cleanest npm supply-chain compromises both passed provenance verification. Sit with that for a second. The malicious @tanstack/* and @asyncapi/* packages carried valid Sigstore and SLSA attestations, shipped through legitimate OIDC trusted publishing, and returned a green check to anyone who bothered to verify. No stolen npm token. No hijacked maintainer account. I have told readers of this site to adopt trusted publishing, and I still would. It did not stop either attack, and pretending it did is how the next team gets hit.
The signature was real. The source wasn't.
On May 11, 2026, between 19:20 and 19:26 UTC, an attacker published 84 malicious versions across 42 @tanstack/* packages, per the maintainers' own postmortem. Two months later, on July 14 (07:10 to 08:30 UTC), four @asyncapi/* packages shipped five trojanized releases: @asyncapi/generator 3.3.1, generator-helpers 1.1.1, generator-components 0.7.1, and specs 6.11.2, with a combined weekly download count in the low millions, per Chainguard and StepSecurity. Both sets carried attestations pointing at the genuine source repo.
Chainguard wrote the sentence worth taping to your monitor: "Provenance proves where a package came from, not that its source was trustworthy at the time." Verifying the attestation on any poisoned release would have passed. That is the whole problem. Provenance is an authenticity control, not a statement about intent. It is the same failure mode I dug into with A2A agent cards that are signed but lying: a valid signature wrapped around a hostile payload is still, cryptographically, a valid signature.
If your mental model was "provenance verifies, therefore safe," these two incidents just retired it.
pull_request_target is the door both attackers walked through
Here is the part that should worry you more than the malware itself: both compromises opened with the same first move. A pull_request_target workflow that checks out and effectively runs untrusted fork code.
That trigger is the classic "Pwn Request." Unlike a plain pull_request event, pull_request_target runs in the context of the base repo, with the base repo's secrets and a write-scoped GITHUB_TOKEN. The catch is that the code being tested comes from the fork. So the attacker controls the script, and your repo hands that script its secrets. In the AsyncAPI case, per Chainguard and Wiz, that combination let the attacker lift a service account's privileged personal access token and then trigger the real release pipeline from a legitimate position inside the org.
The detail that should make every platform lead wince: Chainguard reports the flaw "had already been flagged internally fifty-eight days before the attack, with a fix sitting unmerged." The vulnerability was known. The PR just sat there. That gap between spotting a CI foot-gun and actually merging the fix is exactly where these attacks live, and it is a management problem as much as a technical one.
Then they read the OIDC token out of runner memory
TanStack's chain went a step further, and it is the part that quietly breaks a lot of assumptions. From the pull_request_target foothold, the attacker used GitHub Actions cache poisoning across the fork-to-base trust boundary, then extracted the OIDC token from the runner process at runtime.
The postmortem is precise about why that works: the runner "mints [the OIDC token] lazily, in memory, when id-token: write is set." So an attacker-controlled build step that runs before publish just reads the token out of the process and publishes directly. The trusted-publisher binding was never broken. It never needed to be. The maintainers are blunt that the attacker "never stole an npm credential, never compromised a maintainer account, never bypassed branch protection, and never broke the OIDC trusted-publisher binding." Every control held. Malware shipped anyway.
This is the honest way to frame what OIDC trusted publishing does and does not do, and it is the same discipline I applied to killing static AWS keys with GitHub Actions OIDC. OIDC removes a stealable long-lived secret and binds publishing to a specific workflow. Real, worth having. What it cannot do is vouch for the contents of the run that mints the token. Influence any step before publish and the token is minted for you, the registry accepts the push, and provenance faithfully records the compromised build. Containment is not prevention. Naming that distinction is the whole game.
The payload waits for import, not install
Most npm supply-chain defenses assume the malware fires during npm install, in a postinstall hook. This one doesn't.
Per Microsoft's write-up, the AsyncAPI payload executes on package import, not on install. That single design choice sidesteps install-script allowlisting entirely, the exact defense I walked through in the npm 12 allowScripts lockdown. Your allowScripts gate never sees it, because there is no install script to gate. The code runs the first time something calls require() or import on the package, which in a dependency of a dependency can be anywhere in your app or build.
From there it pulled a botnet framework (reported as "Miasma") from IPFS, with multi-channel command and control spread across HTTP, Nostr, IPFS, BitTorrent, libp2p, and Ethereum. That spread is deliberate. Block one channel and five remain. The one defense that still bites here is time: dependency cooldowns delay any brand-new version from reaching your build, which buys the hours you need for detection to catch up. Install-script allowlisting buys you nothing against an import-time payload.
So is provenance just theater now?
No, and it is worth answering the objection head-on rather than strawmanning it. Provenance still kills the entire stolen-long-lived-token attack class, which was the most common npm compromise for years. It still gives you a transparency-log audit trail. It still lets you pin trust to a named workflow instead of a person's laptop. StepSecurity's researcher flagged the TanStack packages within roughly 20 to 26 minutes, and public attestations plus transparency logs are part of why anomalies surface that fast.
The mistake is not adopting provenance. The mistake is reading a green attestation as proof the build was benign, then moving supply-chain risk to the bottom of the backlog. TanStack had 2FA, no long-lived publish tokens, and OIDC scoped to a specific workflow and branch. All of it held. The malware shipped through the front door you left propped open in CI.
What to audit before your next release
Do these in order. Each one traces to a specific failure above.
-
Grep every repo that can publish for
pull_request_targetandworkflow_run. If a workflow with either trigger checks out PR head code and has access to secrets orid-token: write, that is the exact hole both attacks used. Split it: run untrusted build and test on plainpull_request(no secrets), and do privileged publish only on a trusted trigger. Never run fork-controlled code in a job that holds publish scope. - Pin every action to a full commit SHA, not a tag. Floating tags let a re-tagged or compromised action slip a new step into your build. SHA pinning removes that lever. Both postmortems land on this.
-
Gate the publish job. Use GitHub environment protection with required reviewers on the release environment, set
persist-credentials: falseon checkout, addrepository_ownerand branch guards, and grantid-token: writeto the single job that publishes, never workflow-wide. - Shrink the OIDC token's window. Because it lives in runner memory during the run, allow no untrusted steps between mint and publish, purge Actions caches that cross the fork-to-base trust boundary, and alert on any publish outside your expected release windows.
-
On any CI compromise, rotate everything the runner could reach. npm tokens, GitHub tokens, SSH keys, and cloud credentials. The AsyncAPI payload harvested exactly those. Then add default-deny egress controls and watch for outbound IPFS, Nostr, or unexpected C2 from build and application processes, not just
postinstallhooks.
The one-line version for your next architecture review: OIDC trusted publishing moved the attacker's target off your npm token and onto your CI workflow. If you hardened the first and left the second full of pull_request_target, you did not close the gap. You relocated it.
Sources
- TanStack npm supply-chain compromise postmortem, TanStack Blog
- AsyncAPI supply chain compromise: npm packages backdoored via GitHub Actions "pwn request", Chainguard
- Unpacking the AsyncAPI npm supply chain compromise and import-time payload delivery, Microsoft Security Blog
- The npm Threat Landscape: Attack Surface and Mitigations, Unit 42, Palo Alto Networks
- Compromised next branch pushes malicious @asyncapi packages to npm, StepSecurity
Originally published at indragustiprasetya.com
Top comments (0)