A bad tarball has three tells. An infected machine has a few more.
In a package tarball
- An
optionalDependenciesentry named@tanstack/setup, which is not a real npm package, pointing at a git URL. - A
router_init.jsof about 2.3 MB at the tarball root, left out of the manifest's"files"list. - A tarball far larger than its predecessor. StepSecurity's detector flagged a 3.7× size jump. vendor
On a machine that installed one
- Outbound traffic to
filev2.getsession.orgorseed1–seed3.getsession.org, the Session messenger network the payload used to send stolen data out. - New hooks in
.claude/settings.json(SessionStart) or.vscode/tasks.json(folderOpen), new LaunchAgents or systemd services. vendor - An npm token you did not create whose description warns against revoking it. vendor
- Releases of your own packages that you did not publish: the worm stage.
Four trust boundaries, crossed one at a time.
This isn't a bug in TanStack's code that you patch by upgrading. The packages themselves were malware, so the question is what ran on your machines.
Every step used a feature as designed. The weakness was transitive trust: each link assumed the one before it was trusted. Fork code reached the base repository's cache; the cache reached the release job; the release job reached npm. Dashed boxes are untrusted.
- Actions cacheFiles, such as a package-manager store, that GitHub Actions keeps between runs so installs are faster.
- Cache keyThe name a cache entry is stored and looked up under, here derived from the lockfile's hash.
10 May · 17:16–23:29 UTC
The attacker prepares a fork.
An account named
zblggforks TanStack/router and renames the forkzblgg/configuration, so it doesn't show up when someone searches the fork list.A commit on the fork, under the forged identity
claude <claude@users.noreply.github.com>(not Anthropic), adds a roughly 30,000-line bundled payload. Its message starts with[skip ci].1- repo
- zblgg/configuration
- commit
- 65bf499d…
- adds
- packages/history/vite_setup.mjs
- size
- ~30,000 lines
11 May · 10:49–11:11 UTC
A pull request runs with the base repository's privileges.
PR #7378, “WIP: simplify history build”, triggers
bundle-size.yml. That workflow usespull_request_target, a GitHub Actions trigger that runs in the context of the base repository and skips the approval normally required for first-time contributors.The job checked out the PR's code and ran
pnpm installand a build, which runs the contributor's code. This pattern is called a Pwn Request. The job was meant to be read-only, butpermissions:limits only theGITHUB_TOKEN.9- trigger
- pull_request_target
- context
- base repo, TanStack/router
- approval gate
- skipped
- runs
- the PR's install + build
- permissions:
- limits GITHUB_TOKEN only
11 May · 11:29–11:31 UTC
The build poisons the cache the release job will use.
GitHub Actions saves caches in a step after the job ends, using a runner-internal token that
permissions:doesn't restrict. Apull_request_targetrun shares the base repository's cache scope with pushes tomain.13The payload wrote attacker files into the pnpm store and saved it under the exact key the release workflow computes. Two minutes later the PR was reset to an empty diff and closed. The cache stayed.
- scope
- refs/heads/main
- key
- Linux-pnpm-store-<lockfile hash>
- size
- 1.1 GB
- written by
- post-job save, runner token
- after PR closed
- still there
11 May · 19:15–19:16 UTC
Almost eight hours later, real releases restore it.
A release run from a PR merged two days earlier is re-run, and a maintainer merges another PR, starting a second release run. Both restore the poisoned pnpm store “entirely as designed”.
That puts attacker-controlled binaries on disk inside TanStack's trusted release job, and they run during the build and test phase.1
- runs
- 25613093674 · 25691781302
- cache hit
- the poisoned key
- branch
- main
- attacker code
- runs during build/test
The planted code reads the publish token out of memory.
TanStack used npm trusted publishing: instead of a stored npm token, the release job asks GitHub for a short-lived OIDC identity token (that is what
id-token: writeallows), and npm accepts it as proof the publish comes from TanStack's workflow.The planted code found the runner's
Runner.Workerprocess, read its memory through/procand pulled the token out. TanStack says the script was the same one used in the March 2025tj-actions/changed-filescompromise.- permission
- id-token: write
- token
- OIDC, short-lived
- read from
- Runner.Worker memory
- long-lived secret
- none needed
11 May · 19:20:39 and 19:26:14 UTC
It publishes straight to npm, as TanStack.
The malware exchanged the token with npm and uploaded packages directly. The workflow's own Publish step never ran: the payload had broken the tests. To npm, the uploads came from
release.yml@refs/heads/main, so they carried valid provenance, the signed record of which workflow built a package.Two release runs restored the same cache, which is why each package got exactly two bad versions about six minutes apart.
- publisher
- TanStack/router release.yml
- ref
- refs/heads/main
- provenance
- valid
- versions
- 84 across 42 packages
- Publish step
- skipped (tests failed)
Installing a bad version runs the payload, then hides it.
Each malicious manifest adds an
optionalDependenciesentry, a dependency that may fail to install without failing the install, named@tanstack/setup. It points at a git URL for an orphan commit in the TanStack/router fork network; GitHub serves those through the parent repository's URL, so no write access was needed.The package manager fetches it and runs its
preparelifecycle script, a command a package asks to run during install. That startsrouter_init.js, a ~2.3 MB obfuscated payload, then exits with an error so the optional dependency is silently dropped.4- optional dep
- @tanstack/setup → git URL
- source
- orphan commit, fork network
- script
- prepare → router_init.js
- then
- exits with error, dropped
It steals credentials and tries to spread.
TanStack and the GHSA list what it takes: AWS and GCP metadata and secrets, Kubernetes and Vault tokens,
~/.npmrc, GitHub tokens and SSH private keys. It sends them over the Session messenger's file network, end-to-end encrypted, with no attacker-run server to block.Then it lists the victim's own npm packages and republishes them with the same injection. That makes it a worm: each infected maintainer becomes the next publisher. StepSecurity calls it “the first documented npm worm that produces validly-attested malicious packages”.11
- steal
- cloud, k8s, Vault, npm, GitHub, SSH
- send
- Session network (getsession.org)
- spread
- republish victim's packages
- persist
- .claude/, .vscode/ (vendor reports)
- revoke token
- reported wiper trigger
The numbered markers on the diagram match the steps.
Each is necessary for the attack; none alone is sufficient.
Eight quiet hours, then a fast response.
- 7h 44min
the poisoned cache sat unused before a release restored it.
- 26min
from the first malicious publish to the public report.
- 1h 43min
until all 84 versions were deprecated.
- 4h 35min
the longest any bad version stayed on npm.
Why it was noticed quickly
The payload broke the test suite, so the release runs failed loudly. A quieter attacker “could have published silently for hours longer”, the postmortem says.
We learned about the compromise from a third party.
Provenance proved where the packages came from. Not what ran inside.
- Provenance provesWhich workflow, branch and run published it
- It can't proveWhat code ran inside that workflow
Provenance answered its question truthfully. The packages really were built and published from TanStack/router's release.yml on main. What it cannot say is what code ran inside that job. StepSecurity called this “the first documented npm worm that produces validly-attested malicious packages”.11
TanStack's own summary: “npm provenance, SLSA, OIDC, and 2FA all worked as advertised and still didn't stop this attack.” And: “Provenance shouldn't be confused with innocence.”
It still paid off afterwards. Provenance pinned every bad version to an exact run, branch and time, which made scoping the incident fast.
[Trusted publishing] has no per-publish review. Once configured, any code path in the workflow can mint a publish-capable token.
Three publisher controls would each have broken the chain.
What would have broken the chain, mapped to the steps above. TanStack has since applied most of the publisher-side controls.
| Control | Effect here | Why |
|---|---|---|
| No pull_request_target that builds fork code | Breaks step 2 | Build untrusted code in pull_request; do privileged follow-ups in a separate workflow_run that uses only artifacts. TanStack removed pull_request_target entirely. |
| Cache isolation | Breaks steps 3, 4 | Don't cache in release workflows at all (Adnan Khan, 2024); elsewhere restore without saving (actions/cache/restore). TanStack disabled the pnpm cache in its release pipeline. |
| id-token: write only in a minimal publish job | Breaks step 5 | A poisoned build step could not mint the token if the job that runs dependency code never has the permission. inference from the chain TanStack proposes checks that flag publishes from unexpected workflow steps. |
| Install cooldown | Protects step 7 | The longest any version was live was 4 h 35 min; pnpm 11 defaults to one day (Fig. 2). Verify your package manager actually enforces the setting. |
| Install scripts off by default | Protects step 7 | The payload ran from a prepare script. ignore-scripts, or an allow-list of packages permitted to run scripts, removes that path. npm v12 now blocks dependency lifecycle scripts unless allowed. |
| SHA-pinned actions, workflow linting, CODEOWNERS on .github | Hardening | Part of TanStack's follow-up (zizmor as a planned required check). They raise the bar for workflow changes rather than break one step of this chain.2 |
| Lockfile | Finds exposure | The GHSA's first step is to search lockfiles, caches and CI logs for the listed versions. |
| Trusted publishing, provenance, 2FA | Didn't stop it | All worked as advertised. The attacker never needed a maintainer's credentials. |
Sources
Primary sources are the maintainer, advisory databases, the registry and government. Secondary sources are the discoverer's and researchers' own analyses.
Primary · maintainer
- 1TanStack (Tanner Linsley)npm supply chain compromise postmortem
Timeline, the attack chain, payload, response timing, lessons, the all-clear.
- 2TanStackHardening TanStack After the npm Compromise
Follow-up controls, reflections on provenance and OIDC, the dead-man's-switch warning.
- 3GitHub issueTanStack/router#7383, "Several npm latest releases were compromised"
The public report.
Primary · advisories, registry and government
- 4GitHub Advisory DBGHSA-g7cv-rxg3-hmpx (also the repository advisory)
CVE, CVSS, CWE, versions, the install mechanism, detection steps, workarounds.
- 5CISAKnown Exploited Vulnerabilities catalog (feed)
Listed as "TanStack Unspecified Vulnerability", due 2026-06-10, ransomware flag set (the feed gives no reason).
- 6npm registryregistry.npmjs.org/@tanstack/history and sibling packages
Publish timestamps; the absence of the GHSA's "patched" versions.
- 7NISTNVD CVE-2026-45321
Linked from KEV. Its fields could not be retrieved for verification.
- 8CISAWidespread supply chain compromise impacting npm
The original Shai-Hulud worm.
- 9GitHub Security LabPreventing pwn requests
The pull_request_target danger and the workflow_run split.
- 10pnpmDependency resolution settings
minimumReleaseAge, 1440 minutes by default since v11.
Secondary · discoverer, researchers, press
- 11StepSecurityMini Shai-Hulud is back
Detection, the size anomaly, valid attestations, persistence, the dead-man's switch.
- 12SocketTanStack npm packages compromised
Six-minute detection, campaign attribution and spread to other projects (vendor claims).
- 13Adnan KhanThe monsters in your build cache
How Actions cache poisoning works.
- 14The RegisterCache poisoning caper turns TanStack npm packages toxic
No public statement from GitHub or npm.
Written from sources checked on 2026-09-30. Attribution of the wave to a named group, and its spread to other projects, are vendor claims not confirmed by TanStack or a government source, so they are described here only as such. Pull quotes are verbatim from the TanStack postmortem; the bracketed words are ours.