Skip to content
nulltapE-reader edition

← All e-reader articles

5 min read

Mandiant Details How Shared CI Caches Expose Release Builds

A pull request can plant dependencies in a shared build cache for a privileged release job. Separate cache writes by branch privilege and constrain publishing identities.

Read on the standard site

A build server with a central cache drawer divided into separate incoming and approved package compartments.

A pull request can write corrupted dependencies into a shared CI cache and let a later release build run them with greater authority. That is one of the cross-stage failures in Mandiant’s September 24 pipeline-hardening guidance. Platform teams running build jobs alongside contributions from forks or less-trusted branches should start with the path from cache write to release read, then look at the credentials available to each job.

Mandiant puts the consequence plainly: “a subsequent production build will retrieve this poisoned cache and run the malicious code in a trusted environment.” Its guidance also describes developer credential theft, extracted OIDC tokens and repointed GitHub Actions tags in recent campaigns. A package produced by a compromised pipeline can still carry legitimate cryptographic provenance when the authorized workflow itself builds and signs the altered output.

A cache crosses branch privileges

Caching accelerates builds by keeping downloaded dependencies or intermediate output for later jobs. The dangerous configuration lets an untrusted pull-request job write cache entries that a protected release job will read. The malicious content crosses from a lower-trust contribution into the job that can publish, even if nobody merges the pull request. Mandiant recommends separating cache access by branch privilege and rejecting cache writes from unauthenticated forks.

Two cache layouts: a fork job reaches the release publisher through a shared cache; a PR-only cache is separated from the publisher.

Figure details

The upper row, Shared cache, connects Fork job to Cache and then to Release with two arrows. The lower row, Separated cache, connects Fork job to PR-only; a vertical divider stands between PR-only and Release. The lower row has no arrow across that divider. The comparison shows the cache access change: keep a fork-written cache out of the release job.

The workflow trigger and checkout choice also affect exposure. GitHub’s Actions security guidance warns that privileged pull_request_target and workflow_run jobs processing an untrusted checkout can share the main branch cache and reach repository write permissions or secrets. Keep untrusted code out of those privileged jobs. Use separate, short-lived runners for release work; GitHub warns that a persistent self-hosted runner executing untrusted code can retain a foothold across jobs. Where a self-hosted runner is necessary, limit its outbound paths to approved repository APIs and registries, as Mandiant recommends.

Review the workflow graph for jobs that restore caches, upload artifacts or run dependencies before the release job. In job logs and workflow definitions, compare the cache key, branch or fork that populated an entry with the identity of the job that later restored it. An entry written by a less-trusted job and consumed by a publisher is the misconfiguration to remove. Trace the cache writer even on successful releases.

Signatures follow the build identity

Mandiant describes a second surprise: an attacker can subvert a mutable action tag or manipulate a pipeline while the publisher’s provenance remains valid. The signature identifies the workflow and artifact that produced the release, including a build that consumed malicious input. This is why the source recommends validating signatures at admission against the expected build identity and immutable artifact digest, while also pinning images by digest and third-party actions to full commit hashes.

GitHub says a full-length commit SHA is the only immutable reference for an action release, and advises verifying that the SHA belongs to the action’s original repository. A version tag can move. Record the reviewed hash in the workflow file and require full-SHA pins in repository or organization policy. Restrict the release job’s GITHUB_TOKEN permissions to those it needs; GitHub recommends read-only repository contents by default and job-scoped increases. Use OIDC exchanges for short-lived cloud credentials and scope publishing rights to the release job, as Mandiant advises. An attestation checked against the wrong workflow identity or a mutable tag leaves the release path exposed.

Hold new packages before builds

The dependency registry is another place to stop unreviewed input. Mandiant recommends specific dependency versions, verified lockfiles, an internal proxy that screens new packages, and a seven-day release-age hold on public versions. Builders should fetch from the controlled proxy; promotion to the trusted registry follows inspection. Keep private package namespaces separate from the public upstream to limit dependency confusion.

For npm CLI v11, the official configuration reference defines min-release-age in days. Setting min-release-age=7 in the project’s .npmrc keeps npm from installing a public version published within that window. The filter can also delay a security fix: npm documents a nonzero npm audit fix exit when the only available patched version is too new. Give urgent fixes a reviewed exception through min-release-age-exclude or a temporary policy change, and document that decision before the build. A cooldown buys time for review; a malicious package that survives the window still needs inspection.

Separate testing from release authority

For a platform team, the first change is to make fork and pull-request cache writes inaccessible to protected release jobs. Then move publishing to a fresh runner with scoped GITHUB_TOKEN permissions or short-lived federated credentials. Review action references for full commit SHAs, and require the registry to admit only the expected signer and artifact digest. Each change removes a specific route from untrusted code into a release.

Test the policy with a nonpublishing pull-request build: its cache entry should be absent from the protected release job’s restore candidates, and the pull-request job should have no publishing identity. In the controlled dependency build, a newly released public package should be held by the registry or min-release-age=7; an explicitly approved urgent patch should be traceable to its exception. Inspect runner lifecycle records to confirm the release job starts in a new environment. Together, these checks trace which job populated the cache before the publisher ran.

Primary sources