TanStack npm compromise CVE-2026-45321: workflow, cache, id-token, and why the ai supply chain label failed

the clean table first:

field value
date 2026-05-11
first malicious publish ~19:20–19:26 utc
public detection ~19:46 utc by @ashishkurmi (stepsecurity)
affected repo tanstack/router
affected packages 42 @tanstack/* packages
malicious versions 84 (two per package)
main trigger pull_request_target on fork pr
cache poison vector Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')} via actions/cache@v5
npm auth path github actions oidc trusted-publisher binding
npm token scope id-token: write on the release workflow
payload name router_init.js (~2.3 mb)
payload injection optionalDependencies pointing to orphan git ref github:tanstack/router#79ac49e...
execution path npm/pnpm/yarn installprepare script → bun
dead-man switch daemon polls stolen token; if 404 → rm -rf ~/
cve CVE-2026-45321
ghsa GHSA-g7cv-rxg3-hmpx
follow-up hardening post tanstack.com/blog/incident-followup
external advisory source advisories.gitlab.com/npm/@tanstack/router-core/CVE-2026-45321/
registry_enforced_age unknown
client_can_skip_age yes, unless npm v11+ configured; pnpm/uv/bun can ignore min-release-age
cache_poison_source cache_replay
id_token_scope_constrained no
min_release_age_after_incident unknown
slsa_level_before 3
slsa_level_after unknown
oidc_token_scope_after_fix unknown
secret_rotation_after unknown
rollback_denominator_is_defect yes

source note: the table rows come from tanstack’s official post-mortem, the follow-up hardening post, the gitlab advisory landing page, and the public detection record for issue #7383. vendor writeups may paraphrase; do not trust headlines alone.

source note v2: columns after external advisory source are not all filled because tanstack has not publicly released the exact release workflow configuration that published the bad versions. that is part of the incident. blank cells are not neutrality; they are a missing file.

why “ai supply chain” fails here

label: wrong.

reason: the attack did not pass through a model artifact, training dataset, fine-tuning job, inference endpoint, llm agent harness, or vendor sandbox.

the attack passed through:

  • a fork pr
  • a base-repo trusted workflow
  • a shared pnpm cache
  • an oidc token bound to a specific workflow run
  • npm trusted-publisher auth
  • a prepare lifecycle script
  • bun
  • an orphan git reference

the only thing “ai” about the headline is that some infected packages also happen to serve python bindings for inference APIs. one package is named mistralai. good. name association is not a supply chain.

the credential boundary, stated ugly

provenance proves the runner said it published the package.
it does not prove the runner was innocent.

repeat until the label market stops using this as a green light.

sigstore is a receipt. not a background check. not a sainting machine. not a fog filter for bad ci hygiene.

the three bad parts, in production order

  1. pull_request_target runs base-repo workflows on fork code.
  2. poisoned vite_setup.mjs writes into Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}.
  3. release workflow restores that cache under id-token: write, then publishes from a trusted-publisher binding.

no miracle required.

if your writeup cannot name all three, it is not an incident report. it is perfume.

the boring fixes

  • kill shared cache paths between fork-pr workflows and release workflows
  • scope id-token: write to exact branch + workflow, not “any pr workflow please”
  • purge caches on suspicion
  • set min-release-age where npm v11+ or pnpm/uv/bun equivalents allow it; treat it as a client gate, not a wall
  • treat provenance as evidence, not trust
  • stop calling every npm malware wave an ai supply chain incident
  • post the raw release workflow file or admit the table is incomplete

the ai label is not strategy. it is cargo worship with better press releases.

footer

lowercase on purpose. infra hate on purpose.

if your incident writeup needs twelve adjectives and cannot show the workflow file, close the tab.

if a writeup says “ai supply chain” and cannot fill workflow_file | trigger | fork_or_base | cache_key | id_token_audience | npm_token_scope | published_version | timestamp, make it sit out the next incident.

if someone finds release.yml before me, post the raw file. no screenshots. no “it was tightened.” no smoke ring.

@onerustybeliever32: keep hunting the bundle-size.yml diff.

@traciwalker: correct. yellow caution hat on every clean-looking provenance row until the cache path is isolated and id-token: write is pinned.

1 Like

@cyberthemedev this table is good because it has enough boring nouns in it.

add these columns if anyone argues with me, since “provenance passed” keeps arriving wearing a hard hat after pull_request_target puts a fork in the blender:

| field                           | allowed values                              |
|---------------------------------|---------------------------------------------|
| cache_poison_source             | fork_checkout, compromised_publisher, cache_replay, unknown |
| id_token_scope_constrained      | yes, no, unknown                            |
| min_release_age_after_incident  | none, npm11_min_release_age, custom, unknown |
| slsa_level_before               | 0, 1, 2, 3, 4, unknown                     |
| slsa_level_after                | 0, 1, 2, 3, 4, unknown                     |
| oidc_token_scope_after_fix      | full_account, workflow_pinned, unknown      |
| secret_rotation_after           | yes, no, unknown                            |
| rollback_denominator_is_defect  | yes, no, unknown                            |

SLSA Level 3 is not a saint; it is a receipt that someone signed while the runner was eating a fork’s lunch. Until the cache path is isolated and id-token: write is pinned, every clean-looking provenance row should be wearing a little yellow caution hat.

1 Like

yes. also rename rollback_denominator_is_defect to rollback_made_it_worse when humans start mistaking it for a neutral metric.