Skip to content

Back to the catalog

How a Misconfigured CI Workflow Became an npm Supply-Chain Compromise | Blog | Endor Labs
endorlabs.comwriteup

How a Misconfigured CI Workflow Became an npm Supply-Chain Compromise | Blog | Endor Labs

Endor Labs breaks down the May 11, 2026 compromise of 42 @tanstack/* npm packages (84 malicious versions) that carried valid SLSA provenance. The attacker chained a pull_request_target "Pwn Request", GitHub Actions cache poisoning across the fork/base boundary, and OIDC token extraction from Runner.Worker memory, then published directly to npm without stealing any credential. It explains why 2FA, trusted publishing, provenance and branch protection all failed, and lists mitigations: split pull_request_target workflows, treat the cache as untrusted, scope id-token: write to a publish job, pin actions to SHAs, require environment approval, use egress allowlists.

TL;DR TanStack npm compromise explained: pwn request + Actions cache poisoning + OIDC token theft from runner memory yielded validly-provenanced malicious packages.
Document

What it is

Endor Labs' write-up of the 11 May 2026 TanStack compromise (84 malicious versions across 42 @tanstack/* packages, published 19:20–26 UTC). Its value is the exact chain and the audit checklist. Nothing was stolen: the attacker ran code inside the legitimate release.yml run and published from there, so the packages carry valid provenance.

How it works

  1. Cache poisoning from a PR. The attacker forked the repo under a misleading name and opened a PR adding one file (packages/history/vite_setup.mjs). A pull_request_target workflow checked out the PR merge ref and ran the build. That fork-controlled code wrote attacker binaries into the pnpm-store directory. The post-job save of actions/cache@v5 then stored them under Linux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}, which the release workflow also requests. The key is computable because the lockfile is public.
  2. Cleanup. The PR was force-pushed back to main HEAD (a zero-file diff), closed, and its branch deleted. The poisoned cache entry stayed.
  3. Detonation. An unrelated maintainer's normal merge to main triggered release.yml, which restored the cache. The attacker binaries ran inside the build process.
  4. Token theft and publish. The binaries located Runner.Worker via /proc/*/cmdline, read /proc/<pid>/maps and /proc/<pid>/mem, and extracted the OIDC JWT. They exchanged it at registry.npmjs.org for a short-lived publish token and published every package in scope. The page says this is the same memory-dump technique used in the March 2025 tj-actions/changed-files compromise. The workflow's own publish step never ran, because tests failed.
  5. Payload delivery. Each package carried an optionalDependencies entry such as "@tanstack/setup": "github:tanstack/router#79ac49ee...". It points to an orphan commit in a fork, reachable through the parent's URL, and npm runs its prepare hook on install. The page treats this as delivery, not the initial compromise.

The key point is that permissions: contents: read does not stop the cache write, because the cache save uses a runner-internal token and not GITHUB_TOKEN.

flowchart LR
  A[Fork PR code] -->|pull_request_target: base trust| B[Poisoned cache]
  B -->|push to main restores it| C[Attacker code in release.yml]
  C -->|runner memory dump| D[OIDC token]
  D --> E[Direct npm publish]
  X[Fix: don't run fork code at base trust] -.cuts.-> A

Preconditions / Affected

  • A pull_request_target workflow that checks out the PR head or merge ref and executes it (install, build, test).
  • A release workflow on the same repo that restores a cache whose key derives from public files.
  • A release workflow with id-token: write available to the job that restores the cache.
  • Anyone who installed an affected @tanstack/* version is exposed to the install-time payload.

What to do

In the page's order of impact:

  1. Audit every pull_request_target workflow. If it runs PR code, convert it to pull_request, or split it. The build runs under pull_request, and a separate pull_request_target job only consumes its artifacts.
  2. Don't restore cache in publish-critical workflows. If you must, restore it only in a sandboxed job that produces verified artifacts, or key it on something not derivable from public files (for example a secret) or on a path fork PRs never touch.
  3. Put id-token: write on a single dedicated publish job only: fresh runner, no restored caches, no third-party actions.
  4. Pin actions to commit SHAs (actions/checkout@<sha>, not a tag like @v6.0.2). This does not stop this chain but closes the tj-actions-style risk.
  5. Add an environment with manual approval to the publish job. A stolen in-memory token cannot satisfy the human gate.
  6. Use a Harden-Runner-style egress allowlist. A workflow contacting registry.npmjs.org outside its publish step is a strong anomaly.
  7. Audit optionalDependencies and github: dependencies in lockfiles, and set ignore-scripts=true on CI runners that don't build native modules.
  8. If you installed an affected version, rotate everything reachable from that host: GitHub, npm, AWS/GCP, Kubernetes, Vault and SSH.

Caveats

  • Pinning actions and ignore-scripts do not break the cache-poisoning chain itself.
  • Provenance, trusted publishing, 2FA and branch protection all behaved correctly and could not have detected this. The page says provenance cannot record which step ran npm publish.
  • The page says the original detection came from an external researcher, not from the pipeline.
  • The page offers no fix for the platform gap: OIDC tokens are scoped to the whole run, not a step, and the cache has no isolation between jobs.

Organisations named here

Technologies named here

Techniques named here

  • pull_request_target 'Pwn Request'
  • Cache writes bypass the job's permissions
  • OIDC token extraction from Runner.Worker process memory
  • Evidence erasure
  • Misleading fork naming to evade fork-list searches
  • Pwn Request

Threat actors named here

  • Trivy compromise, via pull_request_target Pwn Request

Also named here

  • Adnan Khan