A Fork PR Poisoned the pnpm Cache Your Release Workflow Trusted, and GitHub’s Audit Log Never Logged the Write
An untrusted fork opens a pull request, a pull_request_target workflow runs its code in your default-branch context, and that code writes a pnpm-store cache entry keyed exactly the way your release workflow will later look for it. Hours pass. The release run restores the poisoned store, the malicious postinstall reads the runner’s OIDC token out of process memory, and 84 package versions ship to npm under valid provenance. That is the TanStack compromise of May 11, 2026, reconstructed step by step — 84 malicious versions across 42 @tanstack/* packages — and the part that should keep you up is this: the cache write that made the whole thing possible never appears in your GitHub audit log. GitHub audits a cache deletion through the REST API (actions_cache.delete), but there is no documented event for cache creation or save. So the single most important action in the kill chain is, by default, invisible in the place you’d look first. One correction up front, because it changes how you read the rest of this: GitHub closed the direct low-trust write path six weeks after the incident (see the platform-update box below). The reconstruction is history; the visibility gap it exposed is not.
TL;DR
- A fork PR’s
pull_request_targetjob wrote a poisoned pnpm-store cache into the default-branch scope (refs/heads/main), keyed exactly to what TanStack’s release workflow later restored, letting a malicious postinstall lift the runner’s OIDC token from process memory and publish 84 npm versions across 42 packages under valid provenance.- The trust boundary for Actions caches is the branch, not privilege: at the time,
pull_request_targetran fork code in default-branch scope with write access to it. GitHub made that scope read-only for low-trust triggers on June 26, 2026, so the direct write primitive is now historical on GitHub.com — but a trusted cache-writing workflow that attacker-influenced input can reach still bridges the boundary, as does any GitHub Enterprise Server deployment where the same read-only restriction isn’t present.- GitHub audits a REST cache deletion (
actions_cache.delete) but documents no cache-create or save event — so the single most important step in the kill chain leaves no record in the place teams check first. That’s a narrower, truer claim than “the cache is an uninstrumented black box.”- Post-hoc detection lives on the runner (CI/CD EDR for egress, file integrity, and
/proc/<pid>/memreads) and in cache-inventory differencing — the cache-list API shows a newrefs/heads/mainentry but not who or what created it (no run, actor, event, or commit field), so attribution needs time-correlation against workflow runs and runner telemetry, not a cache field. A poisonedmaincache is indistinguishable in the listing from a legitimate one.- SLSA attestation faithfully attested a poisoned pipeline because the compromise was inside the build, so the real fixes are upstream (don’t run untrusted code in default-branch context; restrict cache production to trusted triggers; use restore-only in low-trust jobs) and prevention (CodeQL’s cache-poisoning query) beats post-hoc cache attribution. Present-day exposure concentrates where attacker-influenced input can reach a trusted cache-writing workflow, on GitHub Enterprise Server without the same read-only restriction, or where publish jobs restore mutable dependency caches.
Platform update — June 26, 2026. GitHub has since closed the direct write path this attack used. Low-trust triggers — including
pull_request_target,issue_comment, and fork-PRworkflow_runcascades — now get read-only access to the default-branch cache scope; a save attempt fails with a warning while the job keeps running. Only GitHub’s permitted default-branch cache-writing events —push,schedule,workflow_dispatch,repository_dispatch,delete,registry_package,page_build— retain write access there, and those still need hardening: an allowed event doesn’t make every input its workflow consumes trustworthy. The change shipped to GitHub.com and GitHub with Data Residency, so the exact TanStack write primitive is mitigated there. Two things keep the class alive: an attacker who can steer a separately trusted cache-writing workflow can still bridge the boundary, and the announcement names no GitHub Enterprise Server release — GHES operators should verify cache-token behavior against their deployed version and release notes rather than assuming parity with GitHub.com. The broader lesson doesn’t move: caches are unsigned, unverified inputs that can carry executable dependencies or build outputs, and GitHub still gives defenders no cache-creation audit trail.
If you ran a public repo with fork PRs and a release pipeline that cached its dependency store, this was your problem — and the primitive has a history: it burned Angular’s dev infra, MDN, and Hyperledger through 2024 bug bounties, and an automated scanner spent late February 2026 sweeping public repos for exactly this workflow shape. That exposure is much narrower now (see the box above), but not gone. The technique worked because every piece of it was GitHub Actions behaving as designed — and the parts that remain by design (unsigned caches consumed as trusted input, no cache-creation audit trail, and any trusted workflow that can be nudged into a cache write) are still there.
The trust boundary is the branch, not the privilege
Here’s the mechanism, and it’s worth getting exactly right because the mitigations that don’t work fail on a misunderstanding of it.
GitHub Actions caches are scoped by branch. A cache created on a branch is readable by that branch and its child branches, and caches created on the default branch are readable by every branch. A normal pull_request run from a fork is sandboxed: its cache writes land in the merge-ref scope and, per GitHub’s own scoping rules, “cannot be restored by the base branch or other pull requests targeting that base branch.” That isolation is the thing keeping fork PRs from poisoning your main-branch builds. It holds.
pull_request_target broke it — and this is the exact piece GitHub changed after the incident, so read it in the past tense for current GitHub.com. That trigger runs in the base repository’s context, with the base repository’s GITHUB_TOKEN and whatever secrets and permissions the workflow makes available to it — and, until June 2026, a cache token that could write the default-branch scope. Adnan Khan’s original 2024 research on the technique put it plainly: branches are the security boundary for caching, and privilege is not part of that boundary. A cache written from a pull_request_target job carried default-branch scope, and a release workflow running on main would happily restore it. That low-trust write is refused now — but the mechanism is exactly why the historical attack worked, and why any trusted trigger that can be nudged into a cache write is still a live concern.
The second half is restore-key matching. Caches don’t require exact key hits: when there’s no exact match for the computed key, GitHub falls back to the restore-keys prefixes, and any entry with a matching prefix satisfies the lookup (most-recent-created winning ties, version and compression compatibility permitting). Khan’s line — “all cache keys are restore keys” — is the uncomfortable summary. So an attacker doesn’t necessarily need to predict your lockfile hash: a prefix match is enough, and when your pnpm-lock.yaml changes the exact-key hash rolls anyway, walking a workflow with no exact hit straight onto the poisoned prefix.
In the TanStack case the attacker didn’t even need the prefix trick. watchTowr-style reconstruction by land.tech shows the bundle-size.yml workflow checked out the PR merge ref and ran build commands under pull_request_target, so the fork’s postinstall script executed with the runtime token in the environment. The payload wrote to Linux-pnpm-store-6f9233a5... — the exact key the release workflow restores — saved into the shared refs/heads/main cache scope. That scope detail matters later: the postmortem confirms the PR workflow and pushes to main used the same cache scope, so the poisoned entry carried refs/heads/main, not a PR ref. Then the attacker force-pushed the PR branch back to a no-op so the diff looked clean while the poisoned cache sat there. The ACTIONS_RUNTIME_TOKEN is only usable while the job runs, so the poisoning happens in-build — but the cache entry it leaves behind persists in scope for days, long after the token is dead.
One detail makes eviction attacks more practical than they used to be. A repository’s Actions cache has a configured storage limit — 10 GB by default, though enterprise, org, or repo admins can raise it — and GitHub evicts in least-recently-accessed order once the limit is reached. An attacker who floods the repo with junk caches drives aggregate usage toward that limit and forces older entries — including the legitimate store — out, improving the odds of landing a replacement under the key a later job restores. It’s a nudge in the attacker’s favor, not a guaranteed deterministic overwrite, and it only helps while a cache-writing path is actually available to them. Handy, if you’re the attacker.
Your audit log is the wrong place to look
This is where most teams get it backwards on the first pass. They wire GitHub Enterprise audit logs into Splunk — sourcetype="github:enterprise:audit" or the org audit-log streaming to S3 — and expect to see the cache write. It isn’t there. The audit log records workflows.created_workflow_run, git.push, repo events, org membership, publish events — and it does audit a cache deletion through the REST API (actions_cache.delete, carrying the cache id, key, and scope). What it has no documented event for is cache creation or save. So you can index every audit event GitHub emits and still be blind to the poisoning write itself. Be precise about why, though: it isn’t that the cache is an uninstrumented black box — GitHub keeps cache inventory, tracks last-access times, warns in the run log on a rejected save, and audits deletions. It’s specifically that cache-upload attribution — who created a given entry — is absent from the customer-visible audit stream.
So the detection has to move to two places the audit log won’t cover for you: the runner, and the cache inventory.
On the runner, you need a CI/CD EDR — runtime telemetry on the runner itself. StepSecurity’s Harden-Runner is the common choice; it monitors network egress, file integrity, and process activity on the runner. The signals that matter for this attack are concrete, but verify the sensor you deploy actually captures each one rather than assuming it does across every edition, runner type, and kernel. A build step that overwrites a checkout hook or an actions/checkout script mid-job is the kind of file-integrity event to alert on (a generic tamper example, not a confirmed TanStack artifact). A build process reading /proc/<pid>/mem of another process — how the TanStack payload lifted the OIDC token — is a high-value anomaly outside explicitly approved debugging, profiling, crash-dump, or security-tooling activity; but network and file telemetry alone won’t see it, so confirm your runtime sensor records or blocks cross-process memory reads before you lean on that signal. And the exfil in this case went to Session messenger endpoints (filev2.getsession.org and seed nodes), a dead-obvious egress hit under egress-policy: audit and a hard block under egress-policy: block with an allowlist. Start in audit. Block mode on a monorepo with a hundred contributors will page you at 0300 the first time someone adds a legitimate new registry mirror, and you’ll turn it off in frustration if you haven’t built the allowlist first.
In the cache inventory, the honest picture is narrower than a smoking gun, and it’s worth being exact because a detection built on the wrong model won’t fire. The documented cache-list response — via gh cache list or the REST API — contains each entry’s id, ref, key, version, created_at, last_accessed_at, and size_in_bytes. Note what it does not contain: the workflow run, the triggering event, the actor, or the source commit that created the entry. So the tempting detection — “a cache whose creating ref is refs/pull/7378/merge that your default branch can nonetheless restore” — describes a state that can’t exist. A PR-scoped cache carries refs/pull/<n>/merge and, per GitHub’s own scoping rules, can only be restored by re-runs of that PR. A cache that poisons the default-branch scope carries refs/heads/main — and in the listing it is indistinguishable from a legitimate main cache. TanStack’s postmortem is explicit: the poisoned store was saved to scope refs/heads/main, the same scope pushes to main and the release workflow shared. It looked like a normal main-scoped cache because that is exactly what it was.
So the cache API cannot prove that a main-scoped cache was created by a fork PR — it does not record the uploader at all. What it can support is inventory differencing and time correlation. Snapshot the repository’s cache inventory on a schedule. Flag a newly created entry under a sensitive default-branch key or prefix (Linux-pnpm-store- and friends) by its created_at, especially when it lands right after an expected entry was evicted or when its creation is off-cadence — caches are immutable once written, so an attacker doesn’t overwrite one in place; they evict the legitimate entry and create a fresh one under the useful key. Correlate that timestamp against the workflow runs active in the same window, and investigate any contemporaneous low-trust or otherwise anomalous run. Size and churn stay secondary signals — a burst of newly created caches driving aggregate repository usage toward its configured limit, with older entries evicted right after, is the cache-blasting tell. Don’t anchor on a single enormous object: the attacker can spread the pressure across many keys, and the repo limit may be set above the 10 GB default. Preserve the cache id/key/version/size and timestamps alongside the workflow logs and runner telemetry, because attribution to a specific run stays inferential unless the runner or workflow telemetry actually captured the upload. That’s the ceiling the platform sets, not a gap in your tuning.
There’s a cheaper static control that catches the setup before runtime. GitHub’s CodeQL cache-poisoning query (actions/cache-poisoning/direct-cache, security severity 7.5) is designed to flag workflows that check out an untrusted ref and then save to cache in a default-branch context — the bundle-size.yml-shaped pattern — and the broader Actions query suite adds related checks for untrusted-code execution and code injection, so run the suite, not just the one query. It’s close to free preventive coverage. I’d stop short of saying it would have caught TanStack, though: proving that means running the actual vulnerable revision through the current CodeQL Actions pack, and TanStack’s own postmortem only claims there’s a chance a workflow linter would have caught the shape. Treat it as high-value prevention to test against the historical workflow, not a guarantee that a particular query fires.
What cache-inventory monitoring can — and can’t — tell you
The inventory-differencing detection is noisy before it’s useful, and the noise is instructive.
Legitimate restore-keys prefix matches are everywhere on a busy monorepo — that’s the whole point of the feature — and legitimate main-scoped caches are minted constantly by trusted push and schedule runs. You cannot discriminate a poisoned main cache from a good one by its ref; both read refs/heads/main. The discriminator you actually have is timing and provenance you reconstruct: which prefixes are normally written, by which workflows, on what cadence, and whether a newly created entry under a sensitive prefix lines up with a trusted producer run or with something low-trust firing off-schedule. Build that baseline first, or the differencing alert fires on every normal cache refresh and you drown.
Bot actors — Dependabot and friends — are the next wave of false positives once you’re correlating to workflow runs. They open real PRs and drive real cache activity, dozens a day on a repo with automated dependency updates. Here’s the catch the cache API forces on you: the cache object has no actor field, so you can’t exclude a bot at the cache-list layer at all. Any actor filtering happens only after you’ve joined the cache’s created_at to candidate workflow runs and pulled the actor from the run — a separate, probabilistic join, and concurrent runs in the same window can leave it genuinely ambiguous. When the correlated run metadata exposes an immutable numeric actor ID, key exclusions to that ID rather than a display name or commit-author string — those are trivially forged, and the TanStack attacker’s forged commit identity was literally claude <claude@users.noreply.github.com>. Mind the plumbing, too: depending on your ingestion the SIEM may retain only the actor login, not the numeric ID, so confirm the field is actually there before you build a rule on it. And bot identity alone doesn’t settle trust — a legitimate bot-triggered run can still consume attacker-controlled dependency metadata or artifacts, so keep the event type, repository permission level, workflow identity, and source ref in the decision.
Expect the first tuning pass to also carve out your own build-agent forks and any internal automation that legitimately produces caches under sensitive prefixes. How much volume you’ll face isn’t a number I can hand you: it depends on matrix size, workflow frequency, cache-key design, and whether caches are cut per branch, architecture, runtime, or package group. Establish a baseline before you set any threshold — there’s no defensible cross-repo alert rate to borrow. The goal is the same either way: get the unexplained, off-cadence, low-trust-adjacent cache write rare enough that when it surfaces, you actually look.
Environment changes the math more than any single control. A private repo with external forks disabled has little exposure to the exact fork-PR path TanStack suffered — no untrusted fork means no untrusted pull_request_target execution. It isn’t automatically immune to the broader class, though: internal forks, issue-driven workflows (issue_comment), workflow_run artifact handoffs, or any attacker-influenced input can still reach privileged execution if the workflow design allows it. Past that path, the population still worth worrying about is GitHub Enterprise Server without the read-only restriction, and any repo where attacker-influenced input can steer a trusted cache-writing workflow. Self-hosted runners add a separate persistence surface, and it pays not to conflate the pieces: Actions cache poisoning, workspace contamination, and host-level persistence are different problems. A reused self-hosted runner can retain attacker files or processes outside the Actions cache entirely — a compromised host that survives the job is a worse and independent problem from a poisoned cache entry, not the same one worn larger. Ephemeral, single-job self-hosted runners cut that down materially, provided the underlying host and any attached storage are reset too — not just the workspace.
Where it maps, and what to actually change
The fixes are upstream of all the detection, and they start at the trigger: don’t run untrusted code in the default-branch context. If bundle-size.yml had used pull_request instead of pull_request_target, the fork’s cache write lands in merge-ref scope and never reaches the release path. Most pull_request_target usage is there so a fork PR can post a bundle-size comment, which needs the token but not fork code execution — split the privileged comment step from the untrusted build step and the trigger stops being a liability.
On the caching side, the lowest-complexity posture is not to restore mutable dependency-store caches in a job that publishes — that’s what TanStack did as its immediate response, disabling the release cache outright. But that’s a risk decision, not the only technically valid fix. A publishing workflow can cache safely when it restricts cache production to trusted triggers (which GitHub now enforces for the default-branch scope), uses restore-only behavior in low-trust jobs, caches immutable download material rather than executable package-manager state, verifies cached content cryptographically, or regenerates dependencies from authenticated registries and lockfiles before it ships. Where the performance case for caching is real, harden those paths rather than treating “never cache in a publishing job” as the only option.
Against 800-53, the control story is a supply-chain and integrity story before it’s an access-control one. SR-3 and SR-4 cover the provenance and integrity of the delivered components, and the sharp lesson here is that SLSA attestation is necessary and not sufficient: the TanStack packages shipped valid provenance because the compromise was inside the build, so the attestation faithfully identified the trusted build workflow that produced them — it just couldn’t establish that every input and step inside that workflow was benign. SI-7 is the direct hit — the cache is unverified content consumed as trusted input. Verifying cache values before restore is an SI-7 implementation, not “the” SI-7 answer; the control doesn’t dictate a mechanism. Signed cache manifests, content hashes tied to trusted workflow outputs, allowlisted cache producers, rebuilding dependencies, immutable artifact repositories, and reproducibility checks are all valid ways to satisfy it. There’s no native GitHub Actions primitive for the signing route specifically, so that one means a custom signing-and-verification shim wrapped around the restore step. CM-3 and CM-5 land on workflow files as configuration items: a CODEOWNERS rule requiring review on any .github/workflows/ change, plus pinning actions to full commit SHAs instead of floating @v6 or @main refs, is baseline change control the postmortem explicitly flagged as missing. AC-4 is the weaker mapping — it’s defensible, but this is primarily an integrity and trust-domain problem, and AC-4 only really fits if your authorization boundary explicitly defines the low-to-high workflow data flow and treats the cache as one of the controlled transfer mechanisms. And AU-2/AU-12 are where you file the gap itself — the cache control plane produces no cache-creation audit record, so your audit strategy has to compensate with runner-level and API-level telemetry rather than assuming the platform log is complete.
Assume your platform audit log has holes shaped exactly like the actions that matter. Cache creation is one such hole — GitHub closed the direct low-trust write in June 2026, but it still emits no cache-create audit event, so the “who uploaded this” question has no answer in the log. The TanStack window was six minutes of publishing and roughly twenty minutes to external detection. The signal that could have caught it earlier lived in runner behavior — the cross-process memory read and the unusual egress; cache-inventory differencing could only have contributed supporting timeline evidence, because the poisoned entry looked like a legitimate refs/heads/main cache. Neither surfaced as a cache-create event in the log everyone checks first.
Sources
- Postmortem: TanStack npm supply-chain compromise (TanStack Blog)
- TanStack Compromised: Inside the GitHub Actions Cache Poisoning attack (land.tech)
- The Monsters in Your Build Cache — GitHub Actions Cache Poisoning (Adnan Khan)
- Turning Almost Nothing into a Supply Chain Compromise of Angular with GitHub Actions Cache Poisoning (Adnan Khan)
- Cache Poisoning via caching of untrusted files — CodeQL query help (GitHub)
- Dependency caching reference (GitHub Docs)
- Read-only Actions cache for untrusted triggers (GitHub Changelog, 2026-06-26)
- List GitHub Actions caches for a repository — REST API reference (GitHub Docs)
- Audit log events for your organization (GitHub Docs)
- Harden-Runner (StepSecurity / GitHub)
- 2026’s CI/CD Pipeline Attacks: Cache Poisoning and Advanced Exploitation (Sesame Disk)
- GitHub Actions Supply Chain Attack: Coinbase to tj-actions/changed-files (Unit 42, Palo Alto Networks)
This post was engineered and validated through a multi-agent AI workflow — drafted, adversarially reviewed by several independent models, checked against primary sources, and given a human review before publishing. See an inaccuracy, or found this useful? Leave a comment below — corrections and feedback are read and shape what comes next.