
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.
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
- 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). Apull_request_targetworkflow 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 ofactions/cache@v5then stored them underLinux-pnpm-store-${hashFiles('**/pnpm-lock.yaml')}, which the release workflow also requests. The key is computable because the lockfile is public. - Cleanup. The PR was force-pushed back to main HEAD (a zero-file diff), closed, and its branch deleted. The poisoned cache entry stayed.
- Detonation. An unrelated maintainer's normal merge to main triggered
release.yml, which restored the cache. The attacker binaries ran inside the build process. - Token theft and publish. The binaries located
Runner.Workervia/proc/*/cmdline, read/proc/<pid>/mapsand/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. - Payload delivery. Each package carried an
optionalDependenciesentry 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 itspreparehook 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_targetworkflow 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: writeavailable 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:
- Audit every
pull_request_targetworkflow. If it runs PR code, convert it topull_request, or split it. The build runs underpull_request, and a separatepull_request_targetjob only consumes its artifacts. - 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.
- Put
id-token: writeon a single dedicated publish job only: fresh runner, no restored caches, no third-party actions. - 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. - Add an environment with manual approval to the publish job. A stolen in-memory token cannot satisfy the human gate.
- Use a Harden-Runner-style egress allowlist. A workflow contacting registry.npmjs.org outside its publish step is a strong anomaly.
- Audit
optionalDependenciesandgithub:dependencies in lockfiles, and setignore-scripts=trueon CI runners that don't build native modules. - 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-scriptsdo 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
- GitHub Actions
- TanStack npm packages
- actions/cache@v5
- npm OIDC trusted publishing
- SLSA provenance attestations
- npm registry OIDC trusted publishing
- Harden-Runner-style egress allowlist
- pnpm-store cache
- GitHub Security Lab Pwn Request write-up
- Developer machines that installed affected packages
- Adnan Khan's 2024 cache poisoning paper
- actions/checkout
- pnpm / npm
- npm
- OpenID Connect
- pnpm
- GitHub
- Harden-Runner
- tanstack
- tj-actions/changed-files
- SLSA
- Endor Labs
- Trivy
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
Related in Supply Chain


