The TanStack Heist, TeamPCP Files Episode 01
DW
DEFENSIVE.WORKS / COMICS
teampcp files · episode #001 · 2026.may
SCRIPT
by R.K.
≈ 8 panels · 60s read
SETUP / TRUSTED CACHE
01 / 08
// SCENE 01 / TRUSTED RELEASE WORKFLOW
THE CACHE HAS
NO OPINIONS.
NO OPINIONS.
release.yml
trusted workflow
→ saves →
actions/cache
shared cache
The trusted release workflow saves its build cache via
actions/cache. The cache has no opinions about who fills it.In plain terms: CI saves downloaded packages so the next build skips re-fetching. The cache does not authenticate writers; it reads back whatever the last writer left.
THREAT.001 / FORK PR
02 / 08
// SCENE 02 / PULL_REQUEST_TARGET
A SMALL CHANGE,
FROM A FORK.
FROM A FORK.
Fork PR
TeamPCP opens a pull request from a fork. The workflow trigger is
pull_request_target.In plain terms: A fork PR usually runs with the fork's permissions, which are nothing.
pull_request_target flips that. It runs with the repo owner's permissions instead.THREAT.002 / TRUSTED PERMISSIONS
03 / 08
// SCENE 03 / RUN-WITH-WRITE
ATTACKER CODE,
TRUSTED PERMISSIONS.
TRUSTED PERMISSIONS.
pull_request_target.run
$ permissions: write-all
$ ref: ${{ github.event.pull_request.head.sha }}
$ ./scripts/poison-cache.sh
poisoned bytes -> actions/cache
pull_request_target runs attacker code with the trusted job's permissions. The poisoned content lands in the shared cache.In plain terms: Attacker code now runs as if the maintainer wrote it. It is not sandboxed off; it shares the runner with the rest of the trusted job, and it writes to the same cache the next release will read.
THREAT.003 / TIME-SKIP
04 / 08
// SCENE 04 / LATER
A MAINTAINER
MERGES MAIN.
MERGES MAIN.
T+0
- - - - - later - - - - ->
T+N
✓
Merge legitimate PR
A maintainer later merges a legitimate PR to
main. Routine. The merge happens after the poisoned cache write.In plain terms: The maintainer never has to touch the attacker's PR. Any later merge to
main by anyone, on any unrelated change, is enough to trigger the trusted release run that picks up the poisoned cache.THREAT.004 / RESTORE-KEYS
05 / 08
// SCENE 05 / CACHE RESTORE ON MAIN
THE PR-POISONED
CACHE MATCHES.
CACHE MATCHES.
push main
→
restore-keys
pnpm-Linux-
→
match
PR cache
The release workflow runs on
main. The actions/cache restore step matches its restore-keys against existing entries. The poisoned cache from the PR matches first.In plain terms: Cache lookups are not exact-match.
restore-keys is a prefix-fallback list. The attacker's entry shares the prefix the release job falls back to, so the release loads the attacker's bytes instead of building fresh.THREAT.005 / NO ISOLATION
06 / 08
// SCENE 06 / ONE GHA JOB
THE TRUST BOUNDARY
NEVER EXISTED.
NEVER EXISTED.
pid 4242
poisoned step
×
no isolation
pid 4243
trusted publish
JWT
one GHA job
/proc/<pid>/mem ↑
There is no isolation inside one GHA job. The poisoned code reads the OIDC token out of runner memory via
/proc/<pid>/mem. The trust boundary never existed.In plain terms: Steps in one GHA job share the same UID on the same runner; they are not isolated processes. The poisoned step does not bypass a trust boundary, because no boundary exists between it and the trusted publish step it lives next to. So it reads memory and walks the OIDC token out.
THREAT.006 / TRUSTED PUBLISHING
07 / 08
// SCENE 07 / 170+ PACKAGES
THE TOKEN
WORKS.
WORKS.
170+packages
@tanstack/router
@tanstack/query
@mistralai/...
@uipath/...
@guardrails/...
+ 165 more
TeamPCP publishes using the stolen OIDC token. Trusted publishing accepts it. Issuer and subject look right. 84 versions across 42
@tanstack/* packages. 170+ packages in the broader wave.In plain terms: npm trusted publishing is not failing here, it is working exactly as designed. The token was minted by the real release workflow, signatures verify, provenance attestations chain back to a runner that GitHub really did spin up for this repo. None of that proves the publish is benign. It only proves the publish came from the runner, which is a much weaker claim than most teams assume when they enable provenance.
DEFENSE / FIX IT
08 / 08
// SCENE 08 / SHIP THE FIX
CACHE NAMESPACES.
ENVIRONMENT GATES.
ENVIRONMENT GATES.
supply-chain/cache-poisoning
HIGH
permissions/oidc-overscope
CRITICAL
pr-build.yml
key: pr-${{ github.event.pull_request.number }}-...
release.yml
environment: production
The defense is two parts: separate cache namespaces between PR and release workflows; gate OIDC-minting jobs with required-reviewer environments.
In plain terms: Scoped cache keys stop the PR cache from being restored by the release run. An
environment: with required reviewers stops the publish job from minting an OIDC token without a human in the loop. Neither fix is exotic. Most repos that took the May 11 hit had both controls missing.defensive.works · teampcp files · subscribe → newsletter
01 / 08
SETUP
FORK
TRAP
WAIT
RSTR
JOB1
PUB
FIX
← / → to navigate · thumbnails jump · script edition, art lands later
by R.K. · teampcp files
References
- Lab: github.com/raajheshkannaa/teampcp-goat
- Scanner: scan.defensive.works
- Source recon: Weekly Recon issue 5
- TanStack postmortem: tanstack.com/blog/npm-supply-chain-compromise-postmortem
- Series index: TeamPCP Files