Build Cache and Artifact Poisoning
- remote build cache writes from untrusted machines
- CI cache poisoning across workflows
- provenance for published artifacts
Scenario
Your company builds an internal Java library, com.example:auth-client, used by about forty services. Its CI pipeline builds with Gradle, uses a shared Develocity remote build cache, and publishes the JAR to Artifactory. The services resolve auth-client from Artifactory like any other dependency.
To speed up local builds, every developer's machine is allowed to write to the remote cache.
- An attacker compromises one developer's laptop with an infostealer.
- They modify the Gradle installation on that laptop so
compileJavaadds a small payload to one class. The task's inputs (sources, classpath, compiler version) are unchanged, so its cache key is unchanged. - The developer builds
auth-clientas usual. Gradle stores the poisoned class files in the remote cache under the legitimate key. - The next CI build of
auth-clientgets a cache hit, skips compilation and packages the poisoned classes. CI publishes the JAR to Artifactory. - Forty services pick up the new version on their next dependency update. Every build log looks normal.
The build cache never checks how an output was produced. A matching key is the only test.
Real-world precedent: On 11 May 2026, an attacker published 84 malicious versions of 42 @tanstack/* npm packages (TanStack postmortem). A fork pull request triggered a pull_request_target workflow that ran the fork's code. That code wrote a poisoned pnpm store into the Actions cache under the key the release workflow would compute from the lockfile. Hours later the release workflow restored it, and the payload read the OIDC token from the runner's memory and published straight to npm. The releases carried valid provenance, because they really did come from the release workflow. Earlier, in the SolarWinds attack, the SUNSPOT implant sat on the build server and swapped a source file during compilation, so signed Orion releases shipped the SUNBURST backdoor (discovered December 2020). In December 2024, the first malicious Ultralytics releases were very likely built from a GitHub Actions cache poisoned by an earlier injected workflow run.
Reconnaissance
Explanation
The attacker needs to know which machines can write to the caches your release builds trust. Build configuration usually tells them.
Insight
Caches are trusted implicitly. Anything that can write to a cache your release build reads is effectively part of your release pipeline.
Practical
- Find every cache your release builds read: Gradle remote build cache, Bazel remote cache, GitHub Actions
actions/cacheandsetup-*caches, Docker layer caches. - For each one, list who and what can write to it.
- Check
settings.gradle.ktsforisPush = truewithout a CI condition.
Tools/Techniques
grep -rn "isPush\|push = true" settings.gradle*to find unconditional cache pushes.- Develocity build cache node access control settings to list writers.
- zizmor to find GitHub Actions workflows that restore caches in release jobs.
Metrics/Signal
- Number of identities with write access to caches used by release builds.
- Release workflows that restore caches.
Evaluate
Explanation
Decide whether a poisoned cache entry could reach a published artifact without anyone noticing.
Insight
On GitHub Actions, caches written on the default branch are readable from other workflows in the repository. A low-privilege workflow that untrusted input can influence can therefore seed the cache for your release workflow. Cache keys are usually derived from the lockfile, which is public, so the attacker can compute the exact key in advance. That is how the TanStack release job restored an attacker's pnpm store. Setting permissions: contents: read does not help, because actions/cache saves entries with the runner's own token, not GITHUB_TOKEN.
Practical
- Check whether developer machines push to the remote Gradle cache. If they do, any compromised laptop can poison CI.
- Check whether your release job uses
actions/setup-python(orsetup-node,setup-java) withcache:enabled, oractions/cache. - Check whether any workflow triggered by
pull_request_target,issue_commentor similar runs on the default branch and writes caches. - Check whether you produce provenance for published artifacts, and whether consumers verify it.
Tools/Techniques
- Gradle build cache docs on push configuration.
- zizmor
cache-poisoningaudit. - SLSA build requirements: Build L3 requires that one build cannot inject entries into a cache used by another.
Metrics/Signal
- Developer identities with cache write access (target: zero).
- Published artifacts without provenance.
Fortify
Explanation
Only trusted CI should write caches that release builds read. Release builds should not trust caches at all.
Insight
Read-only caches for developers give them most of the speed benefit with none of the poisoning risk.
Practical
-
Make the remote Gradle cache writable by CI only:
// settings.gradle.kts val isCiServer = System.getenv().containsKey("CI") buildCache { remote<HttpBuildCache> { url = uri("https://cache.example.com/cache/") isPush = isCiServer credentials { username = System.getenv("CACHE_USER") password = System.getenv("CACHE_PASSWORD") } } }Enforce it on the server too: developer accounts get read-only access, and only CI credentials can write.
-
Build releases without the remote cache, or from a separate cache that only release builds write:
./gradlew clean publish --no-build-cache -
On GitHub Actions, disable caching in release workflows and keep publishing in its own workflow with minimal triggers.
-
Never check out or build fork code in a
pull_request_targetworkflow. Usepull_requestfor anything that runs PR code, so it gets a read-only token and an isolated cache scope. -
Generate provenance in the release job. On GitHub, use artifact attestations:
- uses: actions/attest-build-provenance@v4 with: subject-path: build/libs/auth-client-*.jar
Tools/Techniques
- Develocity build cache node access control.
- GitHub artifact attestations.
- SLSA build provenance.
Metrics/Signal
- Release builds run with caching disabled or isolated.
- Share of published artifacts with attestations.
Limit
Explanation
If a poisoned artifact is published, limit how far and fast it spreads.
Insight
Forty services picked up the backdoor because nothing checked where the JAR came from. Provenance verification at consumption time stops the spread at the first service. Be clear about what it proves, though. Provenance says which workflow built an artifact. It cannot say the workflow was clean: the poisoned TanStack releases passed provenance checks because the hijacked release workflow signed them.
Practical
-
Verify provenance before deploying or promoting an artifact:
gh attestation verify auth-client-2.4.1.jar --repo example/auth-client -
Promote artifacts through Artifactory repositories (staging, then release) only after verification succeeds.
-
Use a release-age delay for internal dependency updates in Renovate, so a bad version has time to be caught before it fans out.
-
Keep a way to purge the remote cache quickly. On Develocity, that is a single admin action. On GitHub, delete caches with
gh cache delete --all. TanStack purged the caches of every repository in its organisation, since any of them could have held a poisoned entry.
Tools/Techniques
gh attestation verifyfrom the GitHub CLI.- Renovate
minimumReleaseAge.
Metrics/Signal
- Services that verify provenance of internal libraries.
- Time to purge all caches and rebuild a clean release.
Expose
Explanation
Detect a release whose contents don't match its source.
Insight
A cache entry carries no proof of how it was made, so a cache miss or hit tells you nothing about tampering. Rebuilding from source without the cache and comparing outputs does.
Practical
-
Make archives reproducible so rebuilds can be compared byte for byte. Gradle 9 does this by default. On Gradle 8 and earlier, add:
tasks.withType<AbstractArchiveTask>().configureEach { isPreserveFileTimestamps = false isReproducibleFileOrder = true } -
Run a scheduled job that rebuilds each release with
--no-build-cacheon a clean runner and compares checksums with the published artifact. Any difference needs investigating. -
Alert on cache writes from identities other than CI, if your cache server logs them.
Tools/Techniques
- Reproducible Builds guidance.
- Gradle reproducible archives.
Metrics/Signal
- Releases whose clean rebuild matches the published checksum.
- Cache writes by non-CI identities.
Exercise
Explanation
Show that a poisoned cache entry cannot reach a release.
Insight
This one is easy to test safely in a sandbox, and the result is usually a surprise.
Practical
- Set up a throwaway project with a local build cache node (the Develocity build cache node runs as a container) and a local Artifactory or Nexus.
- From a "developer" account, push a cache entry with a harmless marker class added.
- Run the CI build. Did the push succeed? Did the release contain the marker?
- Run the reproducibility check and confirm it flags the difference.
- Time how long it takes to purge the cache and republish a clean artifact.
Tools/Techniques
- The Develocity build cache node container image in a sandbox.
- A local Nexus or Artifactory instance as the release repository.
Metrics/Signal
- Whether the marker reached the published artifact (target: no).
- Time from detection to clean republish.