Protect.Computer
ARTICLE

When Valid Attestations Aren't Enough: TanStack, OpenAI's Laptops, and the Case for Structural Pre-Flight Checks

· 12 min read · Malicious byte Data hijack

TL;DR

On 11 May 2026, an attacker known to StepSecurity as TeamPCP chained three GitHub Actions misconfigurations to publish 84 malicious versions across 42 @tanstack/* npm packages in a six-minute window. The packages carried cryptographically valid SLSA Build Level 3 provenance attestations — the same gold-standard supply-chain artifact the industry has been pushing for since Sigstore became generally available. Two OpenAI laptops without the updated security baseline pulled the malicious dependency tree on a routine npm install. Credentials were exfiltrated; some internal repositories were accessed.

This piece dissects the technical chain, explains why provenance failed as a positive safety signal, and walks through what changes when the host’s package manager is replaced with one that combines pre-flight structural inspection with mandatory sandboxing. The example tool is snapem — a Node.js wrapper for macOS that the author maintains; the analysis would apply to any tool combining the same two ideas.

The attack chain in detail

The vulnerability that mattered wasn’t in any @tanstack package — it was in TanStack’s CI configuration on GitHub.

Stage 1 — Pwn Request

A workflow trigger of pull_request_target runs with the base repository’s GITHUB_TOKEN and secret scope, even when the PR comes from a fork. TanStack/router had a workflow that triggered on pull_request_target and used the PR’s head ref. The attacker — a throwaway account named zblgg (GitHub ID 127806521), forking under zblgg/configuration to evade fork-list searches — opened a PR with malicious workflow code that the base repo trusted.

Stage 2 — Cache poisoning across the trust boundary

GitHub Actions’ actions/cache shares a cache namespace between a fork’s PR runs and the base repo’s main runs, keyed on hash of the lockfile + manifest. The Pwn Request workflow wrote a poisoned pnpm store entry under the key:

Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11

The next legitimate maintainer merge to main invoked the real release.yml, which restored the cache and silently pulled the attacker’s pnpm-store contents into the trusted runner.

Stage 3 — OIDC token extraction from runner memory

The poisoned cache contained a small binary that, when executed during the install step, scraped ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL directly from the Actions runner’s process memory. The scraper deliberately ignored any token whose name contained github_token — a hint at operational tradecraft, since GitHub’s own secret scanning would flag exfiltrated GITHUB_TOKEN strings.

With the OIDC token, the attacker minted Fulcio short-lived signing certificates that named the legitimate workflow as the OIDC subject:

oidc:db7d6f54-05d5-412b-8a10-e7a8398b303e
subject: https://github.com/TanStack/router/.github/workflows/release.yml@refs/heads/main

Stage 4 — Publish 84 versions with valid Sigstore attestations

The malicious release script ran npm publish --provenance from the trusted runner. The resulting Sigstore bundles for each compromised tarball verified successfully:

  • DSSE envelope signature: valid
  • Fulcio cert chain: rooted at Sigstore’s public good instance
  • Rekor inclusion proof: present and verifiable
  • Predicate type: https://slsa.dev/provenance/v1
  • Subject PURL: matches the published pkg:npm/@tanstack/<name>@<ver> exactly
  • Builder identity: https://github.com/actions/runner/github-hosted
  • External parameters: repository=https://github.com/TanStack/router, ref=refs/heads/main, path=.github/workflows/release.yml

Every attestation field told the truth. The release pipeline was the legitimate one. It was just running with poisoned inputs the predicate couldn’t see.

Stage 5 — Payload

Each malicious version’s package.json carried this entry:

"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

@tanstack/setup does not exist as an npm package. The github: shorthand makes npm clone that exact commit from tanstack/router (the legitimate repo, at a commit the attacker had pushed to the orphan branch via the same compromised pipeline), then run the package’s prepare lifecycle hook automatically — git URL installs run prepare unconditionally.

The orphan commit contained tanstack_runner.js, which spawned bun to execute the obfuscated router_init.js (~2.3 MB) shipped at the root of each malicious tarball. Critically, router_init.js was not declared in the tarball’s package.json files field, which read ["dist", "src"]. The file was present in the bytes but absent from the manifest’s promise about what those bytes should contain.

Tarball metrics from StepSecurity’s post-mortem:

PropertyClean (1.169.9)Compromised (1.169.5/1.169.8)
Size~190 KB~900 KB
File count101124
Root-level .js file outside filesnonerouter_init.js

The payload itself swept every credential plane that matters in a 2026 developer environment: ~/.ssh, ~/.aws, ~/.config/gcloud, ~/.kube/config, ~/.vault-token, ~/.npmrc, GitHub CLI tokens, and the cloud metadata services at 169.254.169.254 and metadata.google.internal. Exfiltration ran over the Session/Oxen messenger network (filev2.getsession.org, seed{1,2,3}.getsession.org), with second-stage payloads pulled from litter.catbox.moe.

The worm completes its loop by enumerating npm packages owned by the victim’s stolen tokens and republishing them with the same injection — and forges its own Sigstore-style attestations using generateKeyPairSync so the downstream republishes also look attested. They aren’t signed by Fulcio, but a registry consumer that only does shape-checks against the bundle’s JSON will not notice.

This was tracked as CVE-2026-45321 and GHSA-g7cv-rxg3-hmpx. @tanstack/react-router alone pulls 12.7 million weekly downloads. OpenAI’s published statement attributes the two-laptop breach to development machines that had not received an updated endpoint baseline; no production systems or customer data were affected.

Why provenance didn’t help

This is the load-bearing point. Industry guidance for the past three years has converged on “demand provenance” as the answer to npm supply-chain attacks. The TanStack incident is the first documented case of validly-attested malicious npm packages.

SLSA Build Level 3 verifies process integrity. It answers: “Did this artifact come from a hermetic, non-tampered build of the declared source at the declared ref by the declared builder?” In TanStack’s case, the answer was yes. Every step in the predicate happened exactly as described. The release.yml workflow ran on refs/heads/main of TanStack/router, on a GitHub-hosted runner, with the configured Node toolchain.

What SLSA does not verify is source integrity — whether the inputs to that build were the inputs the maintainer thought they were. A cache restored from a poisoned shared namespace is not a piece of source code the predicate has any opinion about. The build pipeline produced the artifact correctly. The pipeline was just operating on attacker-supplied bytes.

Consumer-side verifiers like npm audit signatures and Sigstore policy controllers will give a clean bill of health to these tarballs. There is no public-key signal a downstream installer can check today that distinguishes the malicious @tanstack/react-router@1.169.5 from its clean predecessor @tanstack/react-router@1.169.4. Both bundles verify.

Behavioral detection’s speed and its limit

Socket.dev’s automated behavioral analysis flagged all 84 artifacts within six minutes of publication. That’s an extraordinary number — install-time behavior analysis genuinely caught this attack faster than any other public signal — but six minutes is also a window during which @tanstack/react-router was downloaded at roughly 140,000 requests per hour in steady state. The first installers in that window had no public signal to act on.

The OSV/GHSA entry didn’t appear until hours later. Maintainer-marked deprecation followed after that. Scorecard scores are computed over weeks and were unaffected. Provenance, as established, said “looks great.”

If the only defenses available are signature checks and behavioral cloud lookups, the floor of the zero-day window is whatever the fastest analytics provider can do — and the user pays a cache-miss in dependency-chain reach the entire time.

What structural pre-flight looks like

The two TanStack tells that existed before any analytics provider knew the attack was happening were both structural:

  1. A published package.json declaring optionalDependencies pointed at a github: URL.
  2. A 2.3 MB JavaScript file at the tarball root that the same package.json’s files field excluded.

Both are mechanical predicates over the package’s own self-description. Neither requires a database of known-bad versions, a maintained allowlist, or a phone-home to a vendor’s classifier. Both can be evaluated against the npm registry’s per-version document and the tarball stream alone.

snapem ships these as two of seven pre-flight scanners that fire on every install, run, scan, or exec invocation:

  • gitdep: parses dependencies, optionalDependencies, and peerDependencies from each pinned package’s registry document. Flags git+ssh://, git+https://, git://, github:, gitlab:, bitbucket:, gist:, bare owner/repo shortcut, raw HTTP(S) tarball URLs, and file: paths. The signal is independent of the dependency’s content: any of those specifiers routes the installer somewhere the rest of the scanner stack — OSV, Socket, Scorecard, provenance — cannot reason about, and prepare runs unconditionally on a git URL install. Severity is high; the per-package allowlist is the escape hatch for the legitimate monorepo-internal-fork case.
  • tarball: streams each version’s dist.tarball and walks the entries. For every path inside package/, the scanner checks whether the path is one of npm’s always-included files (package.json, README*, LICENSE*/LICENCE*, CHANGELOG*, HISTORY*, NOTICE*), or matches one of the declared entry points (main, module, bin, types, typings), or is covered by the files whitelist treated as a directory prefix or simple glob. Files matching none of those — files the package’s own manifest did not admit to — are reported. The check is opt-out when files uses ** recursion or !-negations (these are gitignore-style precedence rules filepath.Match cannot faithfully evaluate; false positives would teach users to ignore the scanner).

Both checks complete inside a typical 30-second timeout for projects with hundreds of transitive dependencies. The tarball scanner is the more expensive of the two — one HTTP GET per (name, version) per cache miss — but bandwidth is bounded by a 25 MiB per-tarball ceiling that covers the 99th percentile of real-world npm tarballs.

Against the @tanstack tarballs, both would have fired pre-disclosure. gitdep finds optionalDependencies.@tanstack/setup = "github:tanstack/router#79ac49ee...". tarball finds router_init.js (and a few orphan files) at the package root with files: ["dist","src"] declared. No threat intel involved.

The container backstop

Pre-flight detection is a probabilistic layer. The deterministic layer is sandboxing.

On macOS Apple Silicon, container (Apple’s native containerization framework, since 0.9.0 stable in early 2026) is the host-side primitive. snapem invokes every install, run, and exec through it. The shape of that boundary against the documented Mini Shai-Hulud payload:

Payload actionOutcome inside the container
Read ~/.ssh/id_*, known_hostsPath not mounted. ENOENT.
Read ~/.aws/credentials, ~/.aws/configNot mounted.
Read ~/.config/gcloud/application_default_credentials.jsonNot mounted.
Read ~/.kube/configNot mounted.
Read ~/.npmrc (host)Host npmrc not exposed; project-scoped .npmrc is via the mounted project directory. No publish-scoped tokens leak unless the user has explicitly opted into MountNpmrc.
GET 169.254.169.254/latest/meta-data/... (AWS IMDS)No route to the link-local address from the named container network. With --network none, no DNS resolution at all (verified EAI_AGAIN).
Exfil to filev2.getsession.orgBlocked under --network none; visible/loggable on the default named network.
npm publish of the victim’s own packages (worm propagation)No publish token present; no credentialed registry path; no DNS under --network none.
Open macOS KeychainKeychain APIs unreachable — no IPC bridge from container to host securityd.
Write to ~/.zshrc, install launchd persistenceHost home not mounted.

The two OpenAI machines were owned because npm install ran on the host. The same install through a containerized package manager would have boxed the prepare script’s environment to the project directory, the container’s own ephemeral filesystem, and (depending on policy) either a named network with no route to host services or no network at all. Credentials would have stayed on the host. The worm would have had no token to propagate with.

This is not snapem-specific. Any package manager that installs through a hardened sandbox achieves the same result. The point is that the sandbox is unconditional — it does not depend on a scanner getting a correct verdict in a six-minute window.

Layered outcome for the OpenAI scenario

Walking the timeline of an OpenAI developer running npm install against a poisoned lockfile on 11 May 2026 at 19:24 UTC, four minutes after the first malicious version hit npm:

  1. Socket.dev: indeterminate verdict (behavioral analysis hadn’t completed yet). Result: scanner returns no finding.
  2. OSV/GHSA: no entry. No finding.
  3. OSSF Scorecard: TanStack’s repo metrics unchanged. No finding.
  4. npm provenance: valid SLSA L3 attestation, subject PURL matches, builder identity is the real TanStack release workflow. No finding. The scanner explicitly returns “healthy provenance” — and that result is no longer treated as a positive safety signal in snapem’s documentation, exactly because of this attack class.
  5. deps.dev metadata: package not yet deprecated. No finding.
  6. gitdep: optionalDependencies.@tanstack/setup = "github:tanstack/router#...". High-severity finding.
  7. tarball: router_init.js present at tarball root; files: ["dist","src"] does not cover it. Medium-severity finding.

Default policy under snapem treats high-severity quality findings as advisory rather than blocking — the install would not have been auto-rejected on the gitdep signal alone. But the developer would have seen, on the terminal, before npm install ever spawned a prepare script, two structural findings naming the precise IOCs of the attack, both citing fields inside the package’s own declared manifest.

If they had hit “continue anyway,” the container backstop would still have prevented credential exfil. The breach would have been a “we saw something weird and the sandbox absorbed it” footnote, not “we replaced our code-signing certificates.”

Honest limitations

  • A registry compromise that serves a fake files field for the malicious version defeats the tarball scanner. The signal lives in the manifest the registry is serving; if the registry is the adversary, no manifest-derived check applies.
  • gitdep does not know about npm aliasing (npm:foo@^1) versus a real foo package, only about the specifier shape. A determined attacker could plant a worm payload behind a registry alias and bypass gitdep entirely. tarball still applies.
  • A 100% sandboxed install does not protect the project directory. The malware can still read and modify your source code on $PWD. That is the irreducible cost of letting npm write node_modules.
  • Provenance still earns its place as a check against the naïve token-theft attack class — an attacker who steals an npm publish token without the means to run the legitimate workflow will skip --provenance, and the scanner will flag the omission. The Mini Shai-Hulud class is the case where provenance has nothing useful to say.
  • This is all macOS-Apple-Silicon-specific in the snapem implementation. The architectural argument — pre-flight structural inspection plus sandboxing — generalizes; the tooling does not.

Lessons

Three takeaways for security teams running Node.js code in 2026:

  1. Stop treating valid SLSA attestations as a safety signal. Treat them as an answer to one specific question (is this naïve token theft?) and nothing more. Update internal threat models accordingly.
  2. Structural pre-flight checks are nearly free and catch what attestations cannot. A package’s own package.json carries enough self-description to detect the two most common npm install-time-execution patterns (out-of-registry dependency specifiers, undeclared root files) without any external threat feed.
  3. Treat the developer laptop as the highest-value target inside the kill chain. A prepare hook running on a host with ~/.ssh, ~/.aws, and a logged-in gh token is a full compromise. Sandboxing the install step is the deterministic backstop the rest of the stack — scanners, attestations, behavioral analytics — is probabilistic about.

Mini Shai-Hulud will not be the last incident in this class. The pipeline that produced it is the pipeline most reputable npm publishers use. The next incident will also ship with valid attestations. The question is whether the consumer side has anything to say before an analytics provider notices.

Try snapem

If you ship Node.js code from a macOS Apple Silicon laptop and you want the two defenses described above — pre-flight structural inspection of every install, plus mandatory sandboxing of the install itself — snapem is open source and ready to drop in alongside (or in front of) npm, pnpm, yarn, and bun.

Repository: github.com/Positronico/snapem

Issues, PRs, and counter-examples (especially: “here’s a real-world legitimate package the structural scanners flag”) are all welcome.


The author maintains snapem, an open-source npm/bun/pnpm/yarn wrapper for macOS Apple Silicon. The structural scanners described here landed in the current release of the tool.

Related reading