Skip to main content

Six Minutes, 84 Malicious Versions: What the TanStack npm Compromise Teaches Every Build Pipeline

12 min readDora NodaDora Noda
Share
On this page

On May 11, 2026, between 19:20 and 19:26 UTC — six minutes — an attacker published 84 malicious versions across 42 @tanstack/* npm packages. No maintainer was phished. No password leaked. No token was stolen from anyone's laptop. The project's own CI pipeline stole its own publish credential and handed it over, at the exact moment it was minted, through a cache everyone in the chain implicitly trusted.

If you run a build pipeline that installs npm packages, this is your incident too. If you operate a platform whose tenants do, it is twice yours. TanStack Router and Start sit underneath thousands of React apps, including plenty that get deployed by a git-push PaaS. Here is the whole attack and the whole lesson up front; the rest of this post is the evidence.

Time (UTC, May 11, 2026)What happened
19:20–19:26The burst: 84 malicious versions published across 42 @tanstack/* packages, two per package, roughly six minutes apart
19:46Detection — by a stranger: external researcher ashishkurmi (StepSecurity) opens issue #7383 with full IOCs, ~26 minutes after the first publish
20:19 / 20:41 / 21:03Deprecation in three phases: first 2 versions, then the initial 28-version scope, then the full 84-version scope (~1h43m after publish)
22:13–23:55Registry removal: npm pulls the tarballs server-side, last one ~4.5 hours after publish

The chain that made it possible has three links, and each one crossed a trust boundary the next link assumed was solid:

  1. A fork's pull request ran inside the base repo's trust context (pull_request_target checking out fork code) and poisoned the shared CI cache.
  2. A legitimate release workflow on main restored that poisoned cache, putting attacker-controlled binaries on the release runner.
  3. Those binaries dumped the runner's memory, extracted the short-lived OIDC publish token, and published directly to the registry — bypassing the workflow's own publish step entirely.

Each link below maps to one row of the build-pipeline checklist at the end — what to verify in tenant builds on the left, what to verify in your own CI on the right. And the sentence to carry through all of it: at no point did the attacker touch a maintainer's credentials. The pipeline betrayed itself.

The entry point was bundle-size.yml, a CI workflow that ran on pull_request_target for fork PRs touching packages/** or benchmarks/**. Inside that trigger context it checked out the fork's merge ref and built it:

yaml
on:
  pull_request_target:
    paths: ['packages/**', 'benchmarks/**']
 
jobs:
  benchmark-pr:
    steps:
      - uses: actions/checkout@v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge # fork's merged code
      - uses: TanStack/config/.github/setup@main # transitively calls actions/cache@v5
      - run: pnpm nx run @benchmarks/bundle-size:build # executes fork-controlled code

pull_request_target is the well-known "Pwn Request" pattern: the workflow runs in the base repository's context — with base-repo secrets, base-repo cache scope, and base-repo permissions — but checks out and executes fork code. GitHub's own security lab has documented it as a known-bad pattern for over three years. The workflow's author even attempted a trust split (a separate comment-pr job, a comment noting benchmark-pr should stay "untrusted with read-only permissions"). The intent was right; the mental model was wrong in exactly the way that mattered.

The attacker's PR came from a throwaway fork (zblgg/configuration, a renamed fork of TanStack/router chosen to evade fork searches). It was opened and immediately closed — never reviewed, never merged. It didn't need to be. The trigger fired on open, the fork's code ran with the base repo's cache scope, and the damage was done before any human looked at anything.

The deliverable for your pipelines: pull_request_target combined with checking out PR-head code is not "CI that handles forks" — it is remote code execution with your credentials. The GitHub-recommended shape is a sandboxed pull_request job that builds untrusted code with no secrets, plus a separate workflow_run job against its artifacts for anything privileged.

The fork's payload — a file called vite_setup.mjs — was purpose-built to write poisoned data into the pnpm store directory under the exact cache key the legitimate release.yml workflow would later compute: Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}. When the benchmark-pr job finished, actions/cache@v5's post-job step saved the poisoned store to that key. When release.yml next ran on a push to main, its setup step restored the poisoned entry. Entirely as designed.

Here is the single most counter-intuitive fact in the whole incident, straight from the postmortem:

  • actions/cache@v5's post-job save is not gated by permissions:. Cache writes use a runner-internal token, not the workflow GITHUB_TOKEN. Setting permissions: contents: read does not block cache mutation.
  • Cache scope is per-repo, shared across pull_request_target runs (which use the base repo's cache scope) and pushes to main. A PR running in the base repo's cache scope can poison entries that production release workflows later restore.

This is the attack class Adnan Khan documented in 2024 ("The Monsters in Your Build Cache"). It is not a TanStack-specific bug; it is a GitHub Actions design property that requires conscious mitigation. TanStack's response tells you how seriously to take it: they disabled the pnpm cache in the release pipeline, removed caches from the affected workflows, and are evaluating replacing actions/cache with actions/cache/restore, whose explicit-restore, no-implicit-write defaults close the write path by design instead of by accident.

The deliverable for your pipelines: treat shared caches as a trust boundary, not an optimization. Release jobs should restore no cache they didn't write, or no cache at all; PR jobs should never write to keys that release jobs read. Isolate cache scopes across the fork-to-base boundary the same way you isolate secrets.

TanStack's release setup was, by the standards of 2025, exemplary. Releases were cut by CI, not from laptops. Authentication to npm used OIDC trusted publishing — no long-lived npm token existed anywhere to steal, which closes off the entire infostealer-to-maintainer-laptop attack class. The credential was minted at release time, scoped to the workflow, and expired almost immediately.

The attacker never needed the token's home. They waited at the place it was born.

With the poisoned pnpm store restored onto the release runner, attacker-controlled binaries were on disk and got invoked during the build step. Those binaries located the Runner.Worker process via /proc/*/cmdline, dumped its memory through /proc/<pid>/maps and /proc/<pid>/mem, extracted the OIDC token the runner had minted in memory (present because release.yml legitimately declares id-token: write), and used it to POST directly to registry.npmjs.org — bypassing the workflow's Publish Packages step entirely. The memory-extraction script was verbatim published tradecraft, attribution comment included: the same technique used in the tj-actions/changed-files compromise of March 2025. Nothing here was novel; it was recombined research, which is exactly why "nobody would bother chaining that" is not a defense.

The deliverable for your pipelines: short-lived, OIDC-minted credentials narrow the theft window to the job's runtime — but the job's runtime is precisely where poisoned build inputs execute. Token hygiene must be paired with input hygiene: least-privilege audience and expiry on the token plus a release job whose filesystem you can vouch for. Either one alone leaves the chain intact.

Why the green checkmarks didn't save them

This is the uncomfortable section, because every defense below worked as advertised — and the malicious packages shipped anyway:

  • OIDC trusted publishing minted the token correctly, scoped to the right workflow, expiring on schedule. The attacker just used it faster than its expiry.
  • SLSA provenance and Sigstore attestations were valid on the malicious artifacts. Publication happened through the genuine release pipeline, so the poisoned packages carried legitimate provenance binding them to the real repo, the real workflow, the real run. Provenance proves the path, not the innocence — as TanStack's followup puts it, "provenance shouldn't be confused with innocence."
  • 2FA was required on maintainer accounts and irrelevant throughout, because no maintainer account was ever involved.

TanStack's own summary is admirably blunt: the workflow shape itself was the hole, and the parts of the security posture the team actively thought about — OIDC, lockfiles, 2FA, signed commits — were the parts already invested in. CI was the part nobody had audited, and that is the part the attacker walked through. Their hardening list since reads like a template: caches removed from release paths, every third-party action pinned to a commit SHA, pull_request_target eliminated from CI, non-SMS 2FA enforced, pnpm 11's install-cooldown inherited — with a zizmor static-analysis gate on workflows and CODEOWNERS over .github/ queued next.

One more honest limit worth naming: npm's "no unpublish if dependents exist" policy meant the malicious versions could only be deprecated by the maintainers; actual tarball removal required npm security acting server-side, which stretched the exposure window to roughly four and a half hours. Your incident runbook should assume the same: deprecation is fast, disappearance is someone else's queue.

This was one wave in a bigger tide

The TanStack incident — dubbed "Mini Shai-Hulud," attributed to the TeamPCP group, tracked as CVE-2026-45321 with a CVSS score of 9.6 — was not isolated. It sits in a campaign that has been escalating for a year: the original Shai-Hulud self-propagating npm worm in September 2025, the far larger "Sha1-Hulud" second wave in November 2025 (hundreds of packages, tens of thousands of hijacked repositories), an April 2026 wave through SAP packages, and the May 2026 burst that swept TanStack together with Mistral AI, UiPath, and 160-plus more packages across npm and PyPI. On May 27, 2026, CISA added CVE-2026-45321 to its Known Exploited Vulnerabilities catalog with a federal remediation deadline of June 10.

The worm's self-propagation logic is the detail that should worry platform operators most. The TanStack payload didn't just steal credentials — AWS keys, GCP metadata, Kubernetes service-account tokens, Vault tokens, npm and GitHub credentials, SSH keys, exfiltrated over an end-to-end-encrypted messenger file-upload network with no fixed C2 to block — it enumerated every other package the victim maintained and republished those too. One compromised install inside one tenant build can become many compromised packages downstream. The blast radius of "just run npm install" is the maintainer graph of everyone on that machine.

The build-pipeline checklist: tenant builds and your own CI

Here is the artifact the whole post owes you. Two columns, because the incident proved you need both: the platform's build step that installs tenant dependencies, and the platform's own CI that ships the platform.

Attack linkVerify in tenant buildsVerify in your own CI
Pwn Request (untrusted code, trusted context)Build untrusted code with no secrets and no write-capable tokens, always — tenant repos are fork-equivalents by definitionNo pull_request_target that checks out fork code; privileged reactions go through workflow_run on sandboxed artifacts; zizmor as a required check; CODEOWNERS over .github/
Cache poisoning (shared mutable state)No cache shared across tenants or across trust levels; release/production builds restore nothing a PR-equivalent wrote (or nothing at all)Release jobs use explicit restore-only caching or none; audit every actions/cache write scope; purge caches as an incident-response step, as TanStack did
OIDC/token theft (credential where code runs)Builds never mint publish-capable OIDC tokens; where a token must exist, audience-restricted, minutes-lived, least-privilegeid-token: write only on the release workflow, never adjacent to restored caches or untrusted build inputs; pin all third-party actions to SHAs
Malicious versions (bad bits on the registry)Pinned lockfiles installed frozen (--frozen-lockfile) as a detection tripwire — a lockfile diff on a fresh resolve is an alarm, not an inconvenience; ignore-scripts / explicit build allowlists; signature and provenance verification (npm audit signatures) plus behavioral scanning for new install scripts or new maintainers; install cooldowns (pnpm 11 ships a 1-day minimumReleaseAge default) so a six-minute burst never lands in your builds the same dayMonitor your own publishes — TanStack learned about its compromise from a third party ~26 minutes in; alert on any publish your pipeline didn't schedule, and rehearse the deprecate-first, registry-removal-second runbook knowing unpublish won't save you

Two footnotes on that table. First, lockfiles deserve their promotion: most teams treat them as reproducibility files, but a frozen install that suddenly wants to resolve differently is one of the cheapest intrusion signals a build step can emit — the registry moved underneath you, and you get to ask why before executing anything. Second, cooldowns buy what TanStack didn't have: time. A one-day default refusal to install freshly-published versions turns a six-minute burst with a four-hour exposure window into an event you read about instead of an event you ingest.

"We don't publish to npm" is not a boundary

The TODO item that assigned this post named the exact complacency to kill: assuming the attack class is irrelevant because you don't publish packages. TanStack's attacker didn't care who publishes. They cared who installs — and every build step that runs npm install on framework code your tenants chose is inside the blast radius, whether the pipeline belongs to a Fortune 500 or a five-box self-hosted fleet. The trust boundary that failed was never "maintainer versus attacker." It was "the CI system's left hand versus its right" — the cache the untrusted job could write and the release job implicitly trusted.

Audit the shape of your workflows, not just the strength of your credentials. The credentials worked. The shape betrayed them.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. If your platform builds other people's code, its build pipeline is your most privileged code: star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex