Skip to main content
Back to Blog
automationsupply-chain-attackslsa-provenancenpm-securitygithub-actions-securityoidc-token-hijackingtanstackdevsecops

The TanStack Compromise: When Valid SLSA Provenance Signs Malware

How attackers forged valid SLSA Build Level 3 provenance in the TanStack npm supply chain attack, signing 84 malicious packages via OIDC token hijacking.

Zyfolks Team ·

The worst supply chain attack of 2026 isn’t bad because of its scale — it’s bad because every signature checks out. On May 11, between 19:20 and 19:26 UTC, attackers published 84 malicious package versions across 42 packages in the @tanstack namespace, and every single one carried valid SLSA Build Level 3 provenance attestations. Sigstore correctly verified them. npm’s trusted-publisher OIDC flow accepted them. The packages were signed by TanStack’s actual release pipeline, on the actual main branch, in the actual repository. The cryptography didn’t fail. The trust model did.

How a Worm Forged Valid SLSA Build Level 3 Attestations

Per StepSecurity’s attribution and the TanStack postmortem: a threat group called TeamPCP hijacked TanStack’s GitHub Actions release runner mid-workflow and used the legitimate OIDC token to publish 84 tampered tarballs. The same group is tied to the March 2026 Trivy compromise and the April 2026 Bitwarden CLI npm package incident. The TanStack attack is the fourth wave of the Shai-Hulud worm family — earlier waves hit 500+ packages in September 2025 and 492 packages with 132M monthly downloads in November 2025, per StepSecurity’s tracking. @tanstack/react-router alone pulls more than 12.7 million weekly downloads, according to the npm API.

Why it matters: SLSA provenance was meant to be the answer to typosquatting and credential theft. It cryptographically proves which repository and which workflow built a package. The TanStack attack proves that proof isn’t enough. The attestation accurately certified that release.yml ran on refs/heads/main in TanStack/router — but said nothing about whether the workflow was authorized to run, executed from a protected branch, or used unpoisoned inputs.

If you’re publishing an npm package with OIDC trusted publishing and you haven’t pinned the trusted publisher to a specific branch and workflow file, your provenance is forgeable in exactly the same way. The configuration repository: org/repo with no branch or workflow constraint is now a known-exploitable pattern. The same logic that makes audit trails useful for verifying what was recorded rather than whether it should have been applies here — cryptographic integrity isn’t authorization.

Prediction: within six months, npm will deprecate unpinned trusted-publisher configurations, and SLSA v1.1 will introduce branch-pinning requirements for Build Level 3.

The Three-Vulnerability Chain That Owned a Legitimate Publisher

The mechanics, drawn from the TanStack postmortem: three chained exploits, each individually known, combined into a working attack chain. Step one was a “Pwn Request” — the attacker opened PR #7378 against TanStack/router from a deliberately misnamed fork (zblgg/configuration), exploiting the bundle-size.yml workflow’s pull_request_target trigger. That trigger runs in the base repo’s security context but checked out fork code. Step two was cache poisoning: the malicious fork code computed the exact pnpm cache key release.yml would later look up, then seeded a 1.1 GB poisoned cache entry that sat undetected for nearly eight hours. Step three was OIDC token extraction — when release.yml ran, the poisoned binaries read /proc/<pid>/mem on the Runner.Worker process and lifted the OIDC token before the publish step was even reached.

Why it matters: the author of bundle-size.yml had tried to isolate untrusted code by splitting the benchmark job out with restricted permissions. They got bitten by an obscure GitHub Actions behavior — actions/cache@v5’s post-job save uses a runner-internal token, not the workflow GITHUB_TOKEN. Cache scope is shared across pull_request_target runs and base-branch pushes. The trust split looked correct in the YAML and failed in practice.

The verbatim Python memory-extraction script first surfaced in the tj-actions/changed-files compromise of March 2025 (CVE-2025-30066), which CISA documented at the time. Attackers reuse what works. If your team runs any pull_request_target workflow that also writes to actions/cache, that cache is a cross-boundary attack surface — purge it, and either move the workflow to pull_request (read-only fork context) or fully separate fork code execution from base-repo cache writes.

Defense-in-depth at the YAML layer is brittle. One misunderstood runner internal collapsed it. Behavioral analysis at install time caught what the attestation couldn’t — Socket.dev flagged all 84 affected artifacts within six minutes of publication by detecting anomalies in router_init.js, according to its report.

Why the Payload’s Dead-Man’s Switch Changes Incident Response

The payload itself, dropped as a 2.3 MB router_init.js file, isn’t just a credential stealer. Three layers of obfuscation deep — webcrack produced 221,771 lines of readable JavaScript from the 11.7 MB obfuscated blob, per Upwind Security’s analysis — sits a system-level monitor service. On Linux it lives at ~/.local/bin/gh-token-monitor.sh registered as a user systemd unit; on macOS at ~/Library/LaunchAgents/com.user.gh-token-monitor.plist. It polls API.GitHub.com/user every 60 seconds with the stolen GitHub token. If the token returns HTTP 40x — that is, if you revoke it — the script runs rm -rf ~/.

Why it matters: every incident response playbook starts with “rotate credentials immediately.” That instinct, applied to this worm, destroys the developer’s home directory. Researcher carlini identified the trap independently in a TanStack issue comment within hours of the attack. The campaign even embedded the string IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner in its source — a taunt baked into the artifact.

Practical scenario: if you ran npm install on an affected @tanstack/* version on May 11 and you’re a developer with a personal GitHub PAT lying around, your first move is NOT to revoke that PAT. Disable the gh-token-monitor systemd unit or LaunchAgent first. Then remove the .claude/settings.JSON hooks and .vscode/setup.mjs persistence. Then rotate. Order matters more than speed.

Prediction: dead-man’s switches keyed to credential validity will become standard in supply chain malware within twelve months. Every IR runbook needs an explicit “scan for persistence services before rotating” step.

The AI Coding Agent Persistence Problem

One subtle escalation: the worm writes itself into .claude/settings.JSON, abusing Claude Code’s hooks configuration to re-execute on every tool event. It also drops a .vscode/setup.mjs for workspace task auto-run. A developer who removes the malicious package via npm uninstall but reopens their editor in the affected workspace is re-compromising the machine on every session. The Mini Shai-Hulud wave in April 2026 was the first to use AI coding agent hooks as a persistence vector, per Wiz’s reporting on the SAP/Intercom incident — TanStack is the second.

Why it matters: AI coding agents now run with production-grade trust. They read your environment variables, run shell commands, and edit files across your entire workspace. The hooks mechanism that makes them useful also makes them an ideal persistence host. The worm also harvests ~/.claude/projects/*.jsonl — Claude Code session history files, which routinely contain credentials that appeared in prior sessions.

If your team uses AI coding tools in regulated domains like healthcare and clinical software, the agent’s hook configuration is now a privileged surface that needs the same review discipline as a CI workflow file. Treat .claude/settings.JSON and equivalents as code — review every diff, pin them in CODEOWNERS, and audit them in your secret-scanning pipeline.

Prediction: Anthropic and the Claude Code ecosystem will ship a hooks-pinning or hooks-signing feature within nine months, in direct response to this attack class.

FAQ

Q: Was my project affected if I used Bun instead of npm? A: Partially mitigated, not safe. Bun does not execute lifecycle scripts by default, so installing an affected @tanstack/* version via Bun would not have triggered the prepare hook in the malicious optionalDependencies entry. The router_init.js file is still present in the tarball, though, so if any other tool in your pipeline executes it, you’re exposed. Treat Bun as a reduced attack surface, not a complete control.

Q: Which TanStack packages were not compromised? A: Per TanStack’s postmortem and Snyk’s Security Database, the confirmed-clean families are @tanstack/query*, @tanstack/table*, @tanstack/form*, @tanstack/virtual*, and @tanstack/store. The compromised set is the router family — react-router, vue-router, solid-router, router-core, react-start, router-plugin, and 36 others listed in GHSA-g7cv-rxg3-hmpx. The worm self-propagated to 170+ unrelated packages including @mistralai/mistralai and 40+ packages in the @uipath namespace.

Q: Why didn’t SLSA provenance stop this attack? A: SLSA provenance proves a package was built by a specific repository’s GitHub Actions run. It does not prove the workflow was authorized to run, that it ran from a protected branch, or that its inputs were clean. The TanStack attestations correctly certify that release.yml ran on refs/heads/main in TanStack/router — that is factually true. The attacker hijacked the legitimate runner mid-workflow via cache poisoning, so the provenance signed real-but-malicious tarballs. Provenance is necessary, not sufficient.

Key Takeaways

  • Pin every npm OIDC trusted-publisher configuration to a specific branch and workflow file; unpinned repository: org/repo configs are now a known-exploitable pattern.
  • Add a release-age cooldown (seven days is the practical floor) to your package manager config — the TanStack malicious versions were live for roughly three hours, and a cooldown would have fully prevented exposure.
  • Update your incident response runbook to scan for persistence services (gh-token-monitor, .claude/settings.JSON hooks, .vscode/setup.mjs) before revoking any credentials; rotating first can trigger rm -rf ~/ on affected hosts.
  • Audit every pull_request_target workflow that also writes to actions/cache — that cross-boundary cache write is the entry vector for this entire attack class.
  • Treat SLSA provenance as one input to install-time behavioral analysis, not a substitute for it; expect provenance v1.1 and npm’s trusted-publisher config to add branch-pinning requirements.

Have a project in mind?

Tell us what you're building — we reply within 24 hours.