Map
↑Supply Chain Security

TanStack npm Supply Chain Compromise β€” Postmortem

Wiki summarysecuritysupply-chaingithub-actionsnpmci-cdincident ↳ show in map Markdown
title
TanStack npm Supply Chain Compromise β€” Postmortem
type
summary
summary
How three known vulnerabilities chained into 84 malicious @tanstack/* npm publishes, and what stops the same chain
tags
security, supply-chain, github-actions, npm, ci-cd, incident
created
2026-05-12
updated
2026-05-12

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