Release workflow restores a cache with an unscoped key
Control: Release workflows must not restore an untrusted cache· Config key: releaseWorkflowsMustNotRestoreUntrustedCache
📋 What is this?
A release or publish job restores a build cache whose key is not scoped to the release ref. GitHub Actions caches are shared across branches with a permissive prefix fallback, so a run on any feature branch — including an attacker's PR — can populate the same key (or a restore-keys prefix) that the release job later restores.
⚠️ Impact
Whoever can open a PR can prime the cache the trusted release build reuses, injecting compromised artefacts into the published package while nothing changes in the release commit. This is the May 2026 TanStack vector. Pinning the cache action by SHA does not help — the poisoned bytes live in the cache, not the action.
🔧 How to fix
Weave github.ref_name / github.sha into the cache key AND every restore-keys fallback, or disable caching on publish paths (e.g. cache: false on a setup-* action). The action/script inventory and a per-job allowlist are configurable in .plumber.yaml.
# .github/workflows/release.yml — ❌ Unscoped key on a release jobon: [release]jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/cache@v4 with: path: ~/.npm key: deps-${{ hashFiles('**/package-lock.json') }} # shared with every branch - uses: JS-DevTools/npm-publish@v3# .github/workflows/release.yml — ✅ Key woven with the release refon: [release]jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/cache@v4 with: path: ~/.npm key: release-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }} - uses: JS-DevTools/npm-publish@v3
# .plumber.yaml — the inventories and the whitelist are configurablegithub: controls: releaseWorkflowsMustNotRestoreUntrustedCache: enabled: true publishActions: # actions that mark a job as "release" - JS-DevTools/npm-publish - pypa/gh-action-pypi-publish publishScriptPatterns: # publish commands in run: scripts - '(?i)cargo\s+publish' cacheActions: # which actions restore a cache, and how - {action: actions/cache, mode: always} - {action: actions/setup-go, mode: default, disableInput: cache, disableValue: false} - {action: actions/setup-node, mode: opt-in, enableInput: cache} allowedJobs: # jobs you have reviewed and accept (glob) - 'docs/*'💡 Tips
- A
restore-keysprefix fallback must be release-scoped too — a scopedkeywith an unscopedrestore-keysis still poisonable. - Release context = a
releasetrigger, a publish action (publishActions), or a publish command in a script (publishScriptPatterns). cacheActionscarries per-action semantics:always,default(off viadisableInput/disableValue), oropt-in(on viaenableInput). Add your org's cache actions there — nothing is hardcoded.allowedJobsis a glob over the<workflow-file>/<job-id>name — the escape hatch for release jobs you have reviewed and accept.setup-*actions only cache when theircache:input is set;actions/cachealways restores, so a boguscache: falseon it does not exempt it.
⚙️ Configuration
This control is configured in .plumber.yaml under the github section:
github:
controls:
releaseWorkflowsMustNotRestoreUntrustedCache:
enabled: trueSee the CLI documentation for the full configuration reference.