# TanStack npm Supply Chain Compromise — Postmortem

On 2026-05-11 between 19:20 and 19:26 UTC, an attacker published 84 malicious versions across 42 `@tanstack/*` npm packages. No npm tokens were stolen, and the formal `Publish Packages` step in the release workflow never ran. Tanner Linsley's postmortem walks the chain and is uncommonly explicit about what each defender could have done to break it.

The point of this page is not the timeline. The point is the defense surface — because every link in the chain is a known pattern with a known mitigation.

## The chain in one paragraph

A fork PR triggered `pull_request_target` on a benchmark workflow. That workflow checked out the fork's PR-merge ref and ran a build, so the fork-controlled code executed in the base repo's permission context. The malicious code wrote a poisoned `pnpm-store` into the GitHub Actions cache, under the exact key the `release.yml` workflow would later compute. The PR was then force-pushed back to a no-op and closed — the visible diff is clean. Hours later a normal merge to `main` triggers `release.yml`, which restores the poisoned cache, executes the attacker's binaries during a test step, dumps the GitHub Actions Runner.Worker process memory, extracts the OIDC token minted for npm Trusted Publishing, and posts directly to `registry.npmjs.org` — bypassing the workflow's actual publish step entirely.

Three vulnerabilities, none new, none alone enough:

1. The [[pwn-request-pattern]] in a `pull_request_target` workflow that checked out and built fork code
2. [[github-actions-cache-poisoning]] across the fork→base→production trust boundary
3. [[ci-runner-token-extraction]] of the OIDC token from the runner process's memory

The attacker did not invent novel tradecraft. The cache-poisoning vector was documented by Adnan Khan in May 2024. The memory-extraction script was lifted verbatim — with attribution comment intact — from the tj-actions/changed-files compromise of March 2025.

## How to avoid this attack

The maintainers' own "what could have been better" list, plus what the chain itself shows about which defenses actually break it. Ordered roughly by leverage per hour of work.

### 1. Never run fork-controlled code under `pull_request_target`

`pull_request_target` runs against the *base* repo with base-repo secrets and permissions, but it is triggered by a fork PR. The whole point is that you can do trusted operations (label, comment, gate) on untrusted PRs. The moment a `pull_request_target` workflow does `actions/checkout@v… with: ref: refs/pull/.../merge` and then runs a build, you have given a stranger code execution in your base-repo permission context.

Concrete rule: if you want to run benchmarks or builds on fork PRs, use the **two-workflow pattern**.

- A `pull_request_target`-triggered workflow does *nothing* that involves running fork code. It labels, comments, and dispatches.
- A separate `workflow_run`- or manual-`workflow_dispatch`-triggered workflow runs the build, with the explicit understanding that it carries privileges. Approval is required before it runs against new contributors.

GitHub's own security-lab guide has covered this since 2021. The TanStack workflow tried a trust split inside one file (separating `benchmark-pr` from `comment-pr`) which is the right instinct, but the split is too fine — once `actions/checkout` of the fork's merge ref has run, you have already lost.

### 2. Treat the Actions cache as a write-amplified attack surface

`actions/cache@v…`'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 and shared across `pull_request_target` runs and pushes to `main`.

What this means in practice: any workflow that ran `actions/cache`'s post-step on attacker-controlled code can have poisoned the cache for production workflows. Mitigations, in order of preference:

- **Don't share caches across trust boundaries.** Production / release workflows should pull dependencies fresh, or restore from a cache that is *only* written by `push` to protected branches, never by anything triggered by a PR. This is the highest-leverage fix and it costs you a few minutes of CI time per release.
- **Pin cache keys to commit-scoped inputs.** If a release-workflow cache key includes only `hashFiles('**/pnpm-lock.yaml')` then any prior run that wrote to that key wins. Add a scope component (`${{ github.ref }}` for trusted branches, or a key prefix that PR runs structurally cannot match) so the attacker can't predict and target the production key.
- **Audit what poisons what.** The Adnan Khan post and tools like `zizmor` flag the dangerous patterns. Run the audit once; the result is a small list.

### 3. Provenance-source-verification on npm publishes

Trusted Publishing (OIDC) removes long-lived tokens — which is good, and what [[long-lived-keys]] argues for in general. But OIDC, *as currently implemented*, has no per-publish review. Once your repo is bound, **any code path in the workflow that has `id-token: write`** can mint a publish-capable token. The npm registry has no way to tell whether the publish came from your `Publish Packages` step or from `npm install` running in a test step.

Two mitigations the TanStack team explicitly names:

- **Move publishes to a workflow with `id-token: write` in *one* job and no third-party deps in that job.** Pull pre-built tarballs from a previous job's artifact, verify their hashes against a manifest signed elsewhere, and publish. The job that has the token executes no contributor code.
- **Add provenance metadata that records the workflow file path and step ID**, so the registry can reject publishes from unexpected steps. This requires registry-side cooperation. npm has the building blocks via `--provenance`; it's the verification side that's still under-built.

The general principle: minimum-privilege per *step*, not per *workflow*.

### 4. Pin third-party actions to commit SHAs, not tags

`uses: actions/checkout@v6.0.2` looks pinned — it isn't. Tags can be force-pushed. `uses: actions/checkout@8d1ad...` (40-char SHA) actually pins the code. Use Dependabot or Renovate to bump pinned SHAs; both understand the pattern.

Floating refs (`@v6`, `@main`) were the entire mechanism behind the tj-actions/changed-files compromise — that attack didn't need a `pull_request_target`; it just needed to push a new tag.

Tools that automate this: `pinact` (one-shot), `zizmor` (lints), Dependabot's `action-update` strategy with `pinned`.

### 5. Monitor your own publishes

The TanStack team learned about the compromise from a third-party researcher 20 minutes after the publish. That's actually fast — and it's still 20 minutes during which malicious tarballs were installable by every CI job worldwide.

What internal monitoring looks like:

- A webhook or scheduled job that polls `registry.npmjs.org/-/v1/search?text=maintainer:<your-scope>` and alerts on new versions that weren't produced by the expected workflow run ID.
- A `npm pack`-and-diff check that fires on every publish: any file not declared in `"files"` is a red flag (the attacker's `router_init.js` was outside the `files` allowlist but still in the tarball because npm pack ignores the allowlist for some metadata-driven paths — verify what your tooling actually ships).
- Subscribe to Socket.dev / StepSecurity / Phylum alerts on your own packages. They scan the registry continuously and the cost of being on their radar is zero.

### 6. Reduce the maintainer-token attack surface

The TanStack scope had seven maintainers. Each is a separate credential-theft target with the same blast radius. The Trusted Publishing migration mostly solves this for *publishing*, but maintainer accounts still have unpublish/deprecate/transfer powers.

- Use the smallest maintainer list that meets your bus-factor target.
- Require 2FA with a hardware key (not SMS, not TOTP-app where the device also has the npm CLI).
- Audit `npm token list` and `gh auth status` quarterly. Long-lived tokens accumulate.

### 7. Make unpublish realistic for malicious versions

npm's policy is that you cannot unpublish a version if other packages depend on it. For a malicious version, that policy inverts the incentive — every minute the malicious tarball stays available, more CI jobs install it. You have to wait for npm Security to pull tarballs server-side, which adds hours.

There is no clean fix for this as a publisher. The user-side mitigation is **install pinning + dependency cooldowns**: don't auto-update within the first 48 hours of a release. See [[supply-chain-security]] for cooldown tooling (Dependabot's `cooldown`, Renovate's `minimumReleaseAge`).

## What broke in the attacker's favor (and the defender's)

The postmortem is explicit about luck. The attacker chose a payload that broke tests, which made the legitimate publish step skip. That is *what made the attack loud enough to detect within 20 minutes*. A more careful attacker who didn't break tests could have published silently for hours. Two implications:

- The detection window in the real worst case is hours, not minutes. Cooldown periods of 48 hours catch this. Cooldowns of "auto-merge on green" do not.
- "Tests passed" is not evidence of clean code in CI. The publish ran *outside* the publish step entirely.

The second piece of luck: the attacker reused public tradecraft instead of writing novel code. The IOC signatures matched within hours because the memory-dump script and the optionalDependencies fingerprint were already in security tools' databases. A bespoke version of this attack would have taken much longer to recognize.

## What this incident does not change

This is a CI compromise, not an npm-registry compromise, not a TanStack-source compromise, not a Trusted Publishing compromise. Specifically:

- OIDC / Trusted Publishing is still better than long-lived tokens. The attacker didn't compromise the OIDC binding — they ran inside a workflow that legitimately had the token. [[long-lived-keys]] is still right.
- `pull_request_target` is still useful for the operations it was designed for (label, comment, deploy-preview-comment). It's the *combination* with fork-code execution that broke.
- npm-the-registry behaved correctly given the inputs it received. The signed publish came from a workflow with a valid OIDC binding. The fix is not on the registry side; it's the workflow-internal provenance gap.

## Cross-references

- [[supply-chain-security]] — the concept this page extends
- [[pwn-request-pattern]] — link #1 in the chain
- [[github-actions-cache-poisoning]] — link #2
- [[ci-runner-token-extraction]] — link #3
- [[open-source-security-astral]] — Astral's defenses against most of these patterns, in production
- [[long-lived-keys]] — Trusted Publishing is the right direction; this incident is about the gaps that remain
- [[marius-bitwarden-not-recommended]] — the prior major npm-CI incident (Bitwarden CLI / Shai-Hulud)
- [[tanstack]] — the affected project
