DEV Community

Cover image for TanStack shipped a postmortem for the 42-package npm compromise. Here is what every project should change this week.
GDS K S
GDS K S

Posted on

TanStack shipped a postmortem for the 42-package npm compromise. Here is what every project should change this week.

TanStack shipped a postmortem for the 42-package npm compromise. Here is what every project should change this week.

On May 11, 2026, between 19:20 and 19:26 UTC, an attacker published 84 malicious versions across 42 packages in the @tanstack scope. The attacker did not steal a maintainer's npm credentials. They hijacked the build pipeline itself, and the packages they shipped carried valid SLSA provenance attestations. That last part changes something important about how the ecosystem thinks about supply chain trust.

TanStack published a full postmortem. This piece walks through the attack chain, explains what made this incident novel, and gives you a concrete checklist for your own project.

TL;DR

What Detail
Date May 11, 2026, 19:20 to 19:26 UTC
Scope 42 @tanstack packages, 84 malicious versions
Worm reach 170+ packages total after self-propagation
Detection External researcher flagged it within 6 minutes
Full deprecation ~1 hour 43 minutes after first publish
Advisory GHSA-g7cv-rxg3-hmpx
Novel claim First documented malicious npm package carrying valid SLSA provenance

1. What happened and when

The attacker, operating under accounts zblgg and voicproducoes, targeted the TanStack Router/Start monorepo. The Query, Table, Form, Virtual, Store, and AI packages were not affected. Only the Router/Start monorepo contained the vulnerable workflow configuration.

At 19:20 UTC the first malicious versions landed. By 19:26 the full 84-version batch hit the registry. An external researcher named ashishkurmi from StepSecurity spotted the anomaly, an unusual optionalDependencies entry pointing to a GitHub fork, within minutes. No internal alerting triggered on TanStack's side.

TanStack deprecated the malicious versions 1 hour 43 minutes after the first publish. npm pulled the tarballs from 22:13 to 23:55 UTC, a 4.5-hour window after the initial compromise.

The payload was a 2.3 MB obfuscated file named router_init.js. It harvested credentials (GitHub tokens, AWS keys, Vault tokens, Kubernetes service accounts, SSH keys, GCP credentials), exfiltrated them over the Session/Oxen P2P messenger network, and then used any stolen publish-capable tokens to republish itself to every other package the victim could write to. It also installed persistence mechanisms in .claude/settings.json hooks, VS Code task injection, and a systemd monitoring service. If the stolen GitHub token was later revoked, the payload wiped the home directory.

Secondary victims included @mistralai/mistralai, 40-plus @uipath packages, and 19 packages in aviation-related namespaces. Wiz attributes the campaign, named "Mini Shai-Hulud" internally, to a threat group called TeamPCP, linked to prior SAP, Checkmarx, and Trivy compromises.

2. The three-primitive attack chain

Most supply chain coverage stops at "compromised package." The TanStack incident is worth studying in detail because the attacker chained three distinct primitives to get from zero access to a signed publish on a major open-source project.

Primitive 1: The Pwn Request

A "Pwn Request" is a specific GitHub Actions anti-pattern. When a workflow uses pull_request_target as its trigger, it runs in the context of the base repository rather than the fork. That means it has access to base repository secrets. The intent of pull_request_target is to let maintainers do things like post comments on pull requests from forks without exposing write tokens to fork code.

The problem: if the workflow also checks out the pull request's code and executes it, you get fork code running with base repository privileges. TanStack's bundle-size.yml workflow had this pattern.

The attacker opened a PR from a fork. The workflow executed the fork's code with base repo context.

Primitive 2: Cache poisoning across trust boundaries

The malicious fork code poisoned the pnpm package store cache. It wrote a 1.1 GB cache entry under the exact key that the legitimate release.yml workflow would later restore.

This is the trust-boundary crossing. The bundle-size workflow (lower trust, triggered by PRs) and the release workflow (higher trust, triggered by maintainer merges) shared a cache key namespace. The attacker wrote to cache from the low-trust context. The high-trust context read from it without re-validating.

The poisoned cache entry sat undetected for eight hours before the release workflow pulled it.

Primitive 3: OIDC token extraction from runner memory

Here is the part that bypasses npm credential protections entirely.

GitHub Actions supports OIDC-based publishing. Instead of storing a long-lived npm token in your repository secrets, your workflow requests a short-lived OIDC token from GitHub at publish time. npm's trusted publisher feature accepts this token. The design assumes that only the intended workflow step can request and use that token.

The attacker's payload included binaries that read /proc/<pid>/mem on the GitHub Actions runner. Processes in the runner environment, including the GitHub Actions agent, hold the OIDC token in memory while the job runs. The attacker extracted that token directly from memory and used it to authenticate npm publishes, bypassing the actual publish step in the release workflow.

This is why the packages carried valid SLSA provenance attestations. The attestation records that the package shipped from the expected repository and workflow. From Sigstore's perspective, that was true. The attacker did not forge the attestation. They hijacked the pipeline mid-run and minted legitimate credentials within it.

3. Why valid SLSA provenance on a malicious package matters

SLSA (Supply chain Levels for Software Artifacts) provenance is one of the main signals the npm ecosystem has been building toward for trusted package distribution. The idea: a package with SLSA provenance attestation proves it came from a specific source commit in a specific workflow. Consumers can verify this cryptographically.

The TanStack incident stands as the first documented case of a malicious npm package carrying SLSA provenance that the attacker did not forge. Sigstore verified the build correctly. The provenance was real. The code running through the pipeline was not safe.

SLSA provenance answers the question "did this package build how the maintainer intended?" It does not answer "did the build pipeline run clean before the build started?" Those are different questions, and the ecosystem has largely treated them as the same question.

This does not make SLSA provenance worthless. A package with no provenance is less trustworthy than one with provenance. But it does mean provenance is a necessary condition, not a complete one. The signal has a new attack surface.

What a cleaner version of SLSA provenance would need: a way to attest that the cache state restored before the build arrived clean, that no cross-context cache sharing occurred, and that OIDC token issuance covered only a specific workflow step rather than any code running in the job.

4. Lockdown checklist for your project this week

Run through this before your next release.

Audit your package-lock for affected versions

# Check for any @tanstack packages from May 11 UTC
npm audit
npx better-npm-audit audit

# List all @tanstack versions currently installed
npm ls --depth=0 | grep tanstack

# Verify against the advisory
# Affected: @tanstack/* versions published 2026-05-11 between 19:20-23:55 UTC
# Safe: any version before May 11 or after npm confirmed tarball removal
Enter fullscreen mode Exit fullscreen mode

If you pulled a new install or ran CI between May 11 19:20 UTC and May 11 23:55 UTC, treat your build environment as potentially compromised. Rotate any credentials that were present in that environment.

Harden your GitHub Actions workflows

The Pwn Request pattern is the root primitive. Audit every workflow file for pull_request_target triggers.

# DANGEROUS: pull_request_target that checks out and runs fork code
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  build:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # THIS IS THE PROBLEM
      - run: npm ci && npm run build  # fork code running with base repo context
Enter fullscreen mode Exit fullscreen mode
# SAFER: split into two workflows
# Workflow 1: runs on pull_request (fork context, no secrets)
on:
  pull_request:
jobs:
  build:
    steps:
      - uses: actions/checkout@v4  # checks out fork code, no secret access
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: pr-artifacts
          path: ./dist

# Workflow 2: runs on workflow_run (base context, has secrets, reads artifacts not code)
on:
  workflow_run:
    workflows: ["Build PR"]
    types: [completed]
jobs:
  comment:
    steps:
      - uses: actions/download-artifact@v4  # reads build output, not fork code
        with:
          name: pr-artifacts
Enter fullscreen mode Exit fullscreen mode

If you need pull_request_target for a legitimate reason (bot comments, label management), never check out PR code in that context. Keep it to read-only GitHub API calls.

Scope your OIDC token permissions

# Restrict permissions at the job level, not just the workflow level
jobs:
  publish:
    permissions:
      id-token: write    # only the publish job gets OIDC
      contents: read
    steps:
      - uses: actions/checkout@v4
      - run: npm publish --provenance
Enter fullscreen mode Exit fullscreen mode

Do not grant id-token: write at the workflow level if only one job needs it. The narrower the scope, the shorter the window an extracted token stays useful.

Isolate your cache keys by trust level

# Separate cache keys for PR workflows vs release workflows
- uses: actions/cache@v4
  with:
    path: ~/.pnpm-store
    key: release-pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
    # Never share this key with pull_request_target workflows
Enter fullscreen mode Exit fullscreen mode

Use different key prefixes for PR-triggered and release-triggered workflows. A compromised PR workflow cannot poison a release workflow's cache if the keys do not overlap. This is not a full defense (an attacker with arbitrary code execution can still do damage), but it eliminates the specific cache-poisoning vector used here.

Check for persistence artifacts if you ran a CI job during the window

# Check for the gh-token-monitor service (one of the payload's persistence mechanisms)
systemctl status gh-token-monitor 2>/dev/null
ls ~/.local/share/systemd/user/ | grep monitor

# Check VS Code tasks for injected entries
cat .vscode/tasks.json 2>/dev/null | grep -i monitor

# Check Claude settings for hook injection
cat ~/.claude/settings.json 2>/dev/null | grep -v '"permissions"'

# If you find any of these: stop, rotate credentials first, then remove
Enter fullscreen mode Exit fullscreen mode

The payload's wiper triggers when someone revokes a stolen token while the daemon runs. Confirm the daemon is not present before rotating credentials, or coordinate both actions at the same instant.

5. What changes downstream if provenance is not a clean signal

Practically, for most teams consuming public packages, the immediate answer is: not much changes in workflow, but the mental model needs updating.

Provenance attestation was the "this package came from a known clean pipeline" signal. That signal is now more accurately described as "this package came from the expected repository and workflow, assuming the pipeline itself was not injected into." For widely-used OSS packages where you have no visibility into the upstream CI environment, that assumption deserves scrutiny.

Three things worth watching in the next quarter:

First, whether npm or the SLSA spec adds guidance on cache attestation. The build pipeline audit trail currently does not record what cache state was restored before the build ran. Adding that would let downstream consumers see whether a restore happened and from what source.

Second, whether GitHub adds controls to block OIDC token issuance from jobs that restored cache from a lower-trust workflow. Right now the runner process holds the token regardless of how the cache arrived. A job-level flag to drop OIDC access after a cross-context cache restore would close this specific vector.

Third, whether teams start treating @ts-nocheck and skip audit patterns in CI the same way they treat the Pwn Request pattern: as defaults that need an explicit justification written next to them. The TanStack postmortem credits an external researcher with the detection. The internal system had no alert. That is the gap to close.

The bottom line

TanStack's maintainers handled this well. They published a detailed timeline, named the advisory, credited the researcher, and documented what their internal detection missed. That level of transparency under pressure is worth acknowledging.

The incident is notable for two reasons. One is scale: 12.7 million weekly downloads on @tanstack/react-router alone means a narrow six-minute window had real blast radius potential. The other is the SLSA provenance angle. The attacker did not break the signature. They got inside the signing process.

If your project uses GitHub Actions for publishing, run the workflow audit above before your next release. The Pwn Request pattern is common, the cache isolation gap is invisible until something like this happens, and the OIDC scoping is easy to miss in a busy workflow file. None of these fixes take more than an afternoon.

How does your team currently handle CI trust boundaries between PR workflows and release workflows? Drop your setup in the comments.


GDS K S ยท thegdsks.com ยท follow on X @thegdsks

Valid provenance on a malicious package is not a cryptography failure. Pipeline isolation failed.

Top comments (0)