Fixing cache poisoning between branches Jump to heading
Build caches are usually reasoned about as a performance feature, which hides what they are: mutable storage, written by one pipeline run and read by another, frequently across a trust boundary. If a run triggered by an untrusted contribution can write an entry that a run on the default branch later restores, that contributor can place files into a trusted build. Nothing in the build output announces it. This recipe explains the shape of the problem, the scoping that prevents it, and how to clean up if it has already happened β part of CI caching and runner performance.
When to use this approach Jump to heading
- Your repository accepts pull requests from forks and uses a build cache.
- Cache entries are keyed or restored with broad prefixes.
- Any cached path contains executable content β binaries, scripts, compiled modules.
- A security review has asked how build inputs are trusted.
- If the repository is private with a small trusted team, the risk is lower, but the scoping below still prevents accidental cross-branch corruption.
Step 1 β Map who can write what Jump to heading
The question is not what the cache contains but which contexts can put things into it.
# Every cache entry, with the ref that created it
gh cache list --limit 100 --json key,ref,createdAt,sizeInBytes \
--jq '.[] | {key, ref, mb: (.sizeInBytes / 1048576 | floor)}' | head -20 # Entries written from anything other than the default branch
gh cache list --limit 100 --json key,ref \
--jq '.[] | select(.ref != "refs/heads/main") | {key, ref}' Step 2 β Scope writes to trusted refs only Jump to heading
The rule is simple: less trusted contexts may read the cache and must not write it.
# .github/workflows/ci.yml
- uses: actions/cache/restore@v4 # read on every run
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- uses: actions/cache/save@v4 # write only from the default branch
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} # Verification: no entry should carry a fork ref after this change
gh cache list --limit 50 --json key,ref --jq '.[] | select(.ref | test("pull/")) | .key' Splitting restore and save is the whole mechanism. The combined action does both, which is convenient and means every run that reads also writes.
Step 3 β Never cache executable output across the boundary Jump to heading
Caching a dependency download store is comparatively safe: entries are content-addressed and verified against the lockfile on install. Caching compiled output is not, because nothing re-verifies it.
# Audit what your cached paths actually contain
find ~/.npm -type f -perm -u+x | head # expect nothing executable
find ./build-cache -type f -perm -u+x | wc -l SAFETY WARNING β a cached toolchain, compiler output or node binary restored into a trusted build is executed without verification. Treat any such cache as a deployment artefact: write it only from the default branch, key it on the full set of inputs, and prefer rebuilding over restoring when the entryβs provenance is uncertain. The equivalent discipline for release artefacts is in verifying attestations before deploy.
Step 4 β Detect an entry that should not exist Jump to heading
# Entries whose key does not match the current lockfile hash
current="npm-Linux-$(sha256sum package-lock.json | cut -c1-64)"
gh cache list --limit 100 --json key --jq '.[].key' \
| grep '^npm-Linux-' | grep -v "$current" | head # Unexpected size is the clearest signal: a store should not double overnight
gh cache list --limit 100 --json key,sizeInBytes,createdAt \
--jq 'sort_by(-.sizeInBytes)[0:5] | .[] | {key, mb: (.sizeInBytes/1048576|floor), createdAt}' An entry substantially larger than its siblings, or one created from a ref that is not the default branch, is worth deleting on suspicion. Deleting a cache entry costs one cold build.
Step 5 β Clean up and re-key after an incident Jump to heading
If you believe an entry was poisoned, deleting it is not enough on its own: anything built from it may have been published.
# 1. Delete every entry in the affected family
gh cache delete --all 2>/dev/null || \
for k in $(gh cache list --limit 100 --json key --jq '.[].key'); do gh cache delete "$k"; done
# 2. Change the key prefix so nothing old can be restored by a stale workflow
# npm-v2-${{ runner.os }}-${{ hashFiles(...) }}
# 3. Rebuild and republish anything released from a build in the affected window
git log --since='2026-09-01' --until='2026-09-18' --oneline --first-parent main # Verification: the cache is empty and the new prefix is in use
gh cache list --limit 10 --json key --jq '.[].key' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is this a real attack or a theoretical one? Jump to heading
It has been demonstrated repeatedly against public repositories, and it is attractive precisely because the payload lands in a build rather than in a diff. Nobody reviews a cache entry, and the resulting artefact looks like every other one. The mitigations are cheap, which is the main argument for applying them regardless of your threat assessment.
Does restricting writes hurt the hit rate? Jump to heading
A little on the first run of a new lockfile, and almost not at all afterwards, because the default branch warms the cache for everyone. The entries that matter are the ones for the lockfile in the default branch, and those are exactly the ones still being written.
What about self-hosted runners with a shared local cache? Jump to heading
The same reasoning applies with more force, because a local directory has no scoping at all. Either run untrusted contributions on ephemeral runners with no access to the shared cache, or do not run them on self-hosted infrastructure β which is the usual recommendation.
Related Jump to heading
- CI Caching & Runner Performance β the parent topic and the caching levers.
- Caching Dependencies Keyed on the Lockfile β the key and restore-key rules in detail.
- Limiting Workflow Permissions Per Job β narrowing what a compromised run can reach.