Happyberg Labs

TanStack and the day provenance attestation stopped being a defense

On May 11, 2026, malicious npm artifacts were signed by TanStack's legitimate OIDC pipeline. Sigstore verified them. Every signature check we built for this case returned green. Here is what happened, what failed, and the one defense that still works.

TL;DR

On May 11, 2026, attackers published 84 malicious npm artifacts across 42 @tanstack packages. Within 48 hours, the same campaign reached 172 packages across npm and PyPI. The malicious versions were signed by TanStack’s legitimate OIDC publishing pipeline. Sigstore verified them. Provenance attestation showed them as authentic. They were indistinguishable from legitimate.

A 4-day release-age cooldown would have blocked every fresh install of those versions on a developer machine. It is the only defense in the standard supply-chain hardening kit that the attackers did not subvert.

This post is the first in a series where I walk through each major supply-chain incident from the last eight months: what was supposed to stop it, why those defenses failed, and what pkg-quarantine does about it. If you maintain a CI pipeline, or you use AI coding agents that install packages, the rest of this matters to you.

What happened

On Monday May 11, 2026, between 19:20 and 19:26 UTC, 84 malicious npm package artifacts were published across 42 packages in the @tanstack namespace (Aikido, Snyk, Socket).

TanStack is one of the larger framework ecosystems in JavaScript. @tanstack/react-router alone has around 12 million weekly downloads. @tanstack/react-query, @tanstack/react-table, and the rest of the family sit in the dependency tree of an enormous number of React applications. A successful publish in that namespace has the kind of blast radius that gets called by name.

The malicious versions executed at install time, harvesting credentials from ~/.npmrc, ~/.aws/credentials, ~/.ssh/, GitHub Actions environment variables, HashiCorp Vault tokens, and any cloud credentials they could find in the runner’s process tree. The harvested credentials were then used to attempt onward compromise of additional packages, which is why the campaign reached 172 packages across npm and PyPI within 48 hours rather than staying contained to TanStack (Wiz, Endor Labs).

Socket’s automated scanner flagged the artifacts within 6 minutes of publication. The TanStack team and npm pulled the versions later that evening. By then the campaign was already moving.

This is wave 4 of the Shai-Hulud campaign, the self-replicating worm that first appeared on npm in September 2025. The cadence so far:

  • September 15, 2025: Shai-Hulud (original), hundreds of packages.
  • November 2025: Shai-Hulud 2.0, 25,000+ malicious GitHub repos, Zapier / PostHog / Postman among the named hits.
  • March 2026: Trivy npm packages, attributed to TeamPCP.
  • April 22, 2026: @bitwarden/cli, malicious for 93 minutes, also TeamPCP.
  • May 11–13, 2026: TanStack / Mini Shai-Hulud wave 4, 172 packages.

The attack chain

What makes this one different is not the payload. It is how the malicious versions were published.

TanStack does not let humans push directly to npm. Releases happen through a trusted-publishing pipeline: a GitHub Actions workflow with an OIDC identity that npm accepts as the legitimate publisher. The workflow signs each artifact with Sigstore and publishes the provenance attestation to npm. This is the recommended modern hardening posture. It is what every supply-chain hardening guide for the last two years has told maintainers to set up.

The attackers did not bypass it. They became it.

The chain, in order:

  1. pull_request_target Pwn Request. TanStack had a workflow that ran on pull_request_target and checked out the PR’s head ref before running. pull_request_target runs with the privileges of the base repository, not the fork. Combined with checking out untrusted code from a fork, this lets a malicious PR execute arbitrary code with the workflow’s secrets and tokens. This is a known anti-pattern, well documented by GitHub themselves, but it is also a pattern that is easy to introduce by accident in any workflow that wants to interact with PRs from forks.

  2. GitHub Actions cache poisoning. Once the malicious PR’s code was running in the privileged context, the attackers used it to poison the Actions cache for the repository. Cached artifacts persist across workflow runs and are not scoped to PRs. Future runs of the release workflow would pull the poisoned cache.

  3. OIDC token extraction from runner process memory. When the release workflow ran next, it requested an OIDC token from GitHub’s identity provider to authenticate as the trusted TanStack publisher to npm. The poisoned cache included tooling that read this token out of runner process memory at the exact moment it existed. The token has a short lifetime, but it does not need to last long. It needs to last one npm publish call.

  4. Publish through the legitimate pipeline. With the stolen OIDC token, the attackers called npm’s publish API as TanStack’s trusted publisher identity. npm accepted the request because the OIDC token was valid and freshly issued by GitHub. Sigstore signed the artifact because that is what the trusted-publishing flow does. The provenance attestation was attached because that is also what the flow does. The malicious version landed on npm bearing every signature and proof a defender could ask for.

In one sentence: the attackers did not forge TanStack’s identity. They borrowed it for as long as the OIDC token was alive, and used it through the actual TanStack publish path. Every check downstream of “is this a real TanStack release” returned yes, because it was a real TanStack release.

What was supposed to stop this

If you have been reading supply-chain hardening guides for the last two years, the list of defenses recommended to maintainers is roughly:

  1. 2FA on maintainer accounts, to stop account takeover by stolen passwords.
  2. Trusted publishing with OIDC, to remove long-lived npm tokens from CI. Tokens are issued per-run by GitHub, scoped to a specific workflow, short-lived.
  3. Sigstore provenance attestation: cryptographic proof that an artifact was built and signed by a specific workflow on a specific repository at a specific commit.
  4. npm provenance verification on install: consumers can require packages to have provenance, or use tools that reject packages without it.
  5. npm audit on the consumer side, which pulls known vulnerability data from the npm advisory database.
  6. GitHub Actions hardening: pinning third-party actions by commit SHA, restricting GITHUB_TOKEN permissions, treating pull_request_target carefully.

All of these are good controls. Most of them did exactly what they were supposed to do. None of them stopped this attack on a consumer.

Why those defenses failed

DefenseWhat it doesWhat happened on May 11
2FA on maintainer accountsStops account takeoverDid not apply. No human credentials were stolen.
Trusted publishing with OIDCReplaces long-lived tokens with short-lived onesThe short-lived token was stolen while it was alive. Its short lifetime did not help.
Sigstore provenance attestationProves an artifact was built by a specific workflowThe artifact was built by that specific workflow. Provenance was accurate.
npm provenance verification on installRequires packages to have provenanceThe malicious package had provenance.
npm auditSurfaces known vulnerabilitiesAt install time, the package was not yet in the advisory database.
GitHub Actions hardeningReduces supply-chain risk in CIThe Pwn Request + cache poisoning chain bypassed most of the standard hardening.

The single defense that did work, and worked exactly as designed, was detection time. Socket flagged the artifacts within 6 minutes. The TanStack team and npm pulled the versions later that day. “The package is gone after a few hours” only protects you if you were not in those few hours. If your CI ran a fresh install between 19:20 UTC on May 11 and the moment npm pulled the package, the runner pulled the malicious version. Everything downstream of that runner was compromised.

What a release-age cooldown does

A release-age cooldown is a setting that tells your package manager to refuse versions newer than N days. With min-release-age=4 in .npmrc, npm will not install @tanstack/react-router@<malicious-version> until that version has been live for at least four days. By then, every detection tool in the ecosystem has had time to flag it, the maintainer has had time to pull it, and the package is no longer on npm.

It is the supply-chain equivalent of waiting for the dust to settle. It does not stop attacks. It stops you from being the one who runs the install in the first five minutes.

This works because supply-chain attacks have a built-in time decay. Malicious versions are detected and pulled within hours to days. The exploit window is short. A 4-day cooldown sits comfortably outside that window for the bulk of attacks, including TanStack.

It does not require trusting any party. It does not assume anything about the publisher’s identity, the artifact’s signatures, or the dependency tree’s provenance. It just delays installs of fresh versions. That is enough.

The downside is real. Security patches are also fresh versions. If a CVE drops at 14:00 UTC and a fixed version ships at 14:15, a 4-day cooldown delays that fix by 4 days for everyone enforcing it. The mitigation is the same as the trade-off for any rate limiter: have an override (quarantine update --force in pkg-quarantine), document when to use it, and trust the maintainers of critical software to communicate when an override is warranted.

What pkg-quarantine does

pkg-quarantine is a small CLI that writes a release-age cooldown into every package manager on your machine in one command.

The native settings exist:

ManagerSettingWhere
npm 11.10+min-release-age~/.npmrc
pnpmminimum-release-age~/Library/Preferences/pnpm/rc (macOS) or ~/.config/pnpm/rc
buninstall.minimumReleaseAge~/.bunfig.toml
uvexclude-newer~/.config/uv/uv.toml
yarn (per project)npmMinimalAgeGate.yarnrc.yml
deno (per project)minimumDependencyAgedeno.json

They live in six different config files with six different keys and six different units. quarantine init writes the right setting in the right place for every detected manager, without clobbering auth tokens or other settings.

quarantine audit then verifies they are actually in effect. This is not the same as “the file is on disk.” npm versions before 11.10.0 silently accept min-release-age while ignoring it. The setting is there, the audit passes from npm’s perspective, the package manager still installs fresh versions. quarantine audit catches this and flags it loudly. In CI, quarantine audit --exit-code fails the build.

For ecosystems without a native install-time gate (pip, gem, composer, cargo, hex), quarantine update checks the registry API before any global upgrade and refuses fresh versions. This is weaker than a native gate, because bare pip install foo still bypasses it. But it gates the common upgrade path.

Concretely, on a machine with quarantine init run and the default 4-day window, an attempt to install @tanstack/react-router at the time of the May 11 publish would fail. npm refuses the install with a message naming min-release-age as the reason. The malicious version never lands in node_modules. By the time the cooldown lifts, the version is no longer on npm.

What pkg-quarantine does not catch yet

Honest list:

  1. Already-installed malicious versions. If your node_modules/ had pulled a malicious @tanstack/react-router between 19:20 UTC and the moment npm pulled it, the damage is done. pkg-quarantine is a prevention tool, not a remediation tool.
  2. Bare pip install foo (and friends) on a machine without a native gate. quarantine update enforces the policy. pip install directly does not. A pip plugin to close this gap is on the roadmap.
  3. Per-project configurations. Yarn and deno only have per-project release-age settings. quarantine init for those prints the snippet to add. We do not currently scan the filesystem to find every project and inject the setting.
  4. Audit of node_modules/ after the fact. A quarantine doctor command that scans installed packages against known incident IoC lists and warns when a known-bad version is present would close a real gap. This is the highest-priority item on the post-launch roadmap and the gap-closing feature that this article will be matched with.
  5. Cross-registry IoC awareness. The pgserve worm hit npm AND PyPI from the same campaign. A cross-registry advisory feed integrated into quarantine doctor is on the roadmap.

The point of this engineering blog is to walk through each major incident in the cadence and ship the gap-closing feature alongside the article. The May 11 wave shipped us quarantine doctor as the next thing. That work is in progress.

Try it

npm install -g @happyberg/pkg-quarantine
quarantine init
quarantine audit

If you are running CI:

- name: Verify quarantine policy
  run: |
    npm install -g @happyberg/pkg-quarantine
    quarantine audit --exit-code

pkg-quarantine is MIT-licensed, has 154 tests, two runtime dependencies (commander + @iarna/toml), and zero transitive runtime deps. The code lives at github.com/happyberg/pkg-quarantine. Issues and feedback welcome.

The next post in this series covers the Bitwarden CLI incident from April 22, where the malicious version was live for 93 minutes. That one closes a different gap.


If you want to be notified when each incident post lands, the RSS feed for this engineering blog is the lowest-friction path. Otherwise the project follow-on on GitHub works too.

References