Back to Blog

TanStack's CI Published the Malware Itself. SLSA Said the Build Was Fine.

84 malicious @tanstack/* versions, 169+ packages including @mistralai/mistralai, all signed with valid SLSA provenance. The architectural failure that made it possible, and what to change in your CI/CD.

An ornate gilded fountain pours golden water into a row of identical sealed brass jugs while a tiny silhouetted figure on a high ledge secretly tips a vial of green liquid into the source, symbolizing a trusted CI/CD pipeline producing legitimately signed but poisoned packages

On May 11, 84 malicious versions of @tanstack/* packages landed on npm. The publish came through TanStack's own release pipeline, signed with a valid OIDC token, with valid SLSA Build Level 3 provenance attestations. No npm credential was stolen. Within hours the worm jumped to @mistralai/mistralai and over 160 other packages.

What Happened

Between 19:20 and 19:26 UTC on May 11, 2026, an attacker published 84 versions across 42 @tanstack/* packages, including @tanstack/react-router (more than 11 million weekly downloads per the npm registry). Within hours the same worm had infected the official @mistralai/mistralai SDK, both Mistral cloud SDKs (-azure and -gcp), @opensearch-project/opensearch, 65 @uipath/* packages, and dozens of others. Snyk catalogued 169 affected packages and 373 malicious versions by end of day.

Detection came from outside the project. StepSecurity researcher ashishkurmi posted the indicators to TanStack's repo at 19:46, less than 30 minutes after the first publish. TanStack engaged npm security, deprecated the affected versions, pulled the tarballs, and shipped a hardening PR.

The Attack Chain

The interesting part is how the publish happened. TanStack's release.yml uses GitHub Actions OIDC trusted publishing, the passwordless mechanism that went GA on npm in July 2025 and that the ecosystem has been recommending ever since. There was no long-lived npm token to steal.

So the attacker stole something else: the trust boundary between PR workflows and the main release workflow. The chain:

  1. pull_request_target "Pwn Request". A benchmark workflow checked out PR code from forks and ran it with elevated permissions. This is the same pattern that hit tj-actions/changed-files in March 2025. TanStack's postmortem credits that exact technique.

  2. GitHub Actions cache poisoning across the fork-to-base trust boundary. The malicious vite_setup.mjs wrote attacker-controlled binaries into the pnpm-store directory under the exact key the legit release workflow would later compute: Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}. When actions/cache@v5 ran its post-step on the benchmark-pr job, it saved the poisoned store to that key.

  3. OIDC token extraction from runner memory. On the next push to main, release.yml restored the poisoned cache. Attacker-controlled binaries were now on disk. They located the GitHub Actions Runner.Worker process via /proc/*/cmdline, read /proc/$pid/maps and /proc/$pid/mem, and extracted the OIDC token directly from the worker's memory.

  4. Direct publish to npm. With the live OIDC token, the malware POSTed straight to registry.npmjs.org, bypassing the workflow's Publish Packages step entirely. That step was never reached. Tests failed. The workflow ended with status: failure. npm received 84 valid, signed, provenance-attested publishes anyway.

SLSA Was Doing Its Job

This is the first documented case of a malicious npm package shipping with valid SLSA Build Level 3 provenance.

The Sigstore attestations on the compromised versions are real. They correctly attest that the packages were built and published by release.yml running on refs/heads/main in TanStack/router. That is, in fact, true.

What SLSA verifies: this build process produced this artifact. What SLSA does not verify: the code being built was clean. The poisoned pnpm-store entry got pulled into a legitimate workflow run on the legitimate main branch. Sigstore signed exactly what it was supposed to sign.

If your supply chain security depends on "the package came from a trusted source with valid provenance," you just learned what that promise covers and what it does not.

The Worm

The injected optionalDependencies entry points to a GitHub git dependency at an orphan commit on a fork of tanstack/router. npm fetches the commit, installs its declared dependencies (including bun), and runs the prepare script: bun run tanstack_runner.js && exit 1. The && exit 1 makes the install "fail" so npm silently discards it, masking the side effect.

That script executes a ~2.3 MB obfuscated router_init.js smuggled into the tarball root, not listed in the package's "files" array. The payload:

  • Harvests credentials from AWS IMDS and Secrets Manager, GCP metadata, Kubernetes service-account tokens, HashiCorp Vault, ~/.npmrc, GitHub tokens, and SSH keys.

  • Self-propagates: extracts other npm tarballs the victim can publish to, rewrites their package.json with the same optionalDependencies entry, repacks, and republishes under the victim's OIDC identity.

  • Forges SLSA-compatible Sigstore attestations using generateKeyPairSync, so the worm-propagated packages also carry valid-looking provenance.

  • Exfiltrates over the Session/Oxen messenger file-upload network (filev2.getsession.org), which is end-to-end encrypted with no attacker-controlled C2 to block.

That last detail matters operationally. There is no single domain to firewall.

What To Do If You Installed an Affected Version

Treat the install host as compromised. Then:

  1. Identify the affected versions. Confirmed-bad families include the entire @tanstack/router-*, @tanstack/react-router*, @tanstack/vue-router*, @tanstack/solid-router*, @tanstack/react-start*, and start framework families, plus the @mistralai/mistralai*, @opensearch-project/opensearch, @uipath/*, and @squawk/* packages listed in GHSA-g7cv-rxg3-hmpx. TanStack's postmortem confirms @tanstack/query*, @tanstack/table*, @tanstack/form*, @tanstack/virtual*, @tanstack/store, and the @tanstack/start meta-package are clean.

  2. Rotate every credential reachable from the install host. AWS, GCP, Kubernetes service accounts, Vault tokens, GitHub tokens, npm tokens, SSH keys, and anything else in ~/.npmrc or the runner environment.

  3. Pin to known-good versions. Two malicious versions per affected package were published roughly six minutes apart at 19:20 and 19:26 UTC on May 11. See CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx for the full table.

  4. Inspect tarballs without executing install scripts: npm pack @tanstack/<name>@<version> then tar -xzf *.tgz and look for router_init.js at the package root, or for an optionalDependencies pointing to a github: URL.

What To Change in Your Own Pipelines

The attack worked because two trust boundaries were treated as one.

The first: pull_request_target workflows run with the base repo's secrets and permissions, but they checked out untrusted code from a fork and executed it. Don't. If you need to run benchmarks or tests against PR code, run them in pull_request (which runs with the fork's permissions) or use a separate, isolated runner with no access to publish credentials.

The second: actions/cache stores entries indexed by content hash, which feels safe until you remember the cache is shared across jobs in the same repository. A poisoned cache written by a PR workflow is, by default, restorable by the release workflow. Pin cache keys per workflow, or use separate cache scopes for fork-triggered runs.

Two further hardenings TanStack's PR landed: pin third-party action references by commit SHA (the attacker abused the same tj-actions/changed-files technique that bit the ecosystem in March 2025), and add repository-owner guards to any workflow that runs fork code with elevated permissions.

The Larger Point

This incident did not break npm. It did not steal a credential. It did not exploit a CVE in any dependency. It exploited the design of TanStack's CI/CD pipeline, specifically the trust boundary between PR workflows and main-branch publishing.

That is the class of bug scanners cannot find. SAST sees source code. DAST sees the running app. SCA flags known-vulnerable dependency versions. None of them reason about whether two GitHub Actions workflows share a cache that crosses a trust boundary. That bug lives in the architecture, not the code.

The worm spread to Mistral, UiPath, OpenSearch, and dozens more because each of those projects has the same shape of pipeline: OIDC trusted publishing, shared caches, fork-triggered workflows with elevated permissions. The capability that made one publish convenient made all of them exploitable when one was compromised. If your CI/CD is set up the same way, you don't have a TanStack problem. You have a class problem.