Supply-chain CI gate
Most of the code that ships in a build is not yours. It comes from a registry, gets resolved into a lockfile, and is trusted by default. This runbook covers the gate we put in front of that trust: a blocking CI job that fails the build on known-vulnerable, wrongly-licensed, or wrongly-sourced dependencies, plus the handling that keeps a malicious artifact or cache from walking out of its directory.
The examples are from a Rust toolchain (cargo-audit, cargo-deny), but the
shape applies to any ecosystem with a lockfile and an advisory database.
Make it a blocking gate, in its own job
Run the supply-chain checks as a dedicated CI job that runs in parallel with your lint and test jobs and fails the build on any finding. Two reasons to isolate it:
- A supply-chain failure should be unmistakable, not buried in test output.
- A green build should mean the dependency graph was actually clean, not that nobody looked.
Pin the tool versions you install in CI. A scanner that silently updates is one more moving part in the thing whose job is to catch moving parts.
jobs:
supply-chain:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://rasterhub.com/rasterstate/setup-rust-action@v1
- run: cargo binstall -y --version 0.22.2 cargo-audit
- run: cargo binstall -y --version 0.19.9 cargo-deny
- run: cargo audit
- run: cargo deny --all-features checkWhat each check blocks on
cargo-auditscans the whole lockfile against the RustSec advisory database and fails on a known vulnerability or a yanked release.cargo-denywalks the resolved build graph and enforces policy across four axes: advisories, licenses, sources, and bans.
A representative policy:
[advisories]
db-urls = ["https://github.com/rustsec/advisory-db"]
yanked = "deny"
[licenses]
# Permissive licenses only; anything else is a deliberate review decision.
allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause", "BSD-3-Clause", "ISC", "Zlib", "0BSD", "BSL-1.0"]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn" # duplicate versions are noise, not a failure
wildcards = "deny" # pin your versions
[sources]
unknown-registry = "deny" # crates.io only
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]The distinction between deny and warn matters. License, source, and advisory
findings are hard failures, because each one is a decision a human should make.
Duplicate versions are a warn, because they are untidy rather than dangerous.
Allowlist with discipline, not convenience
Sometimes an advisory genuinely does not apply: the vulnerable code path is not in your compiled build, and no fixed release exists upstream. An allowlist entry is the right tool, but treat it as a small, documented exception:
[advisories]
# RUSTSEC-2023-0071: rsa timing side-channel. Reaches the lockfile only via an
# optional driver we do not compile. No fixed release upstream. Revisit when one
# ships, or when the transitive dep stops being pulled.
ignore = ["RUSTSEC-2023-0071"]Every entry should say why it is safe and what removes it. An allowlist of bare IDs with no rationale is how a real vulnerability hides in plain sight.
Generate an SBOM
Alongside the gate, emit a software bill of materials so you can answer “are we affected?” after the next disclosure, without re-deriving the dependency graph from memory. Produce it in a standard format and keep it as a build artifact:
- run: cargo binstall -y --version 0.5.7 cargo-cyclonedx
- run: cargo cyclonedx --format json --all-features
- uses: https://rasterhub.com/rasterstate/upload-artifact-action@v1
with:
name: sbom-cyclonedx
path: '*.cdx.json'Safe artifact and cache handling
The flip side of pulling things in is unpacking them safely. An artifact, a cache, or a downloaded toolchain is attacker-influenced input the moment it crosses a trust boundary. Two defences carry most of the weight.
Verify the digest before you trust the bytes
Anything you download and then execute or extract should be checked against a pinned SHA-256 before use. Pin the version, the URL, and the digest together, and fail closed on a mismatch:
curl -fsSL "$RUNNER_URL" -o /usr/local/bin/forgejo-runner
echo "$RUNNER_SHA256 /usr/local/bin/forgejo-runner" | sha256sum -c -
chmod +x /usr/local/bin/forgejo-runnersha256sum -c - exits non-zero on a mismatch, which aborts the step. The pin
comes from configuration the job cannot edit, so a poisoned mirror or a
man-in-the-middle is caught before the binary ever runs. The same pattern
applies to language toolchains: verify the published SHASUMS for Node, the .sha256 for Go, and so on, rather than trusting the download.
Reject path traversal on extraction
A tarball or artifact name is not a safe path. An entry like ../../etc/cron.d/x or an absolute /etc/... will, with a naive extractor, write outside the
directory you meant. Before writing any entry, resolve where it would land and refuse anything that escapes the destination:
- Reject names containing
.., a leading/, or a backslash. - Reject multi-segment names if you only expect a flat file.
- After joining to the destination, confirm the resolved parent is still the destination directory.
fn safe_dest(out: &Path, name: &str) -> Result<PathBuf> {
// Exactly one normal path component; no "..", no absolute, no separators.
let mut comps = Path::new(name).components();
let only = match (comps.next(), comps.next()) {
(Some(Component::Normal(c)), None) => c,
_ => bail!("refusing unsafe artifact name {name:?}"),
};
let dest = out.join(only);
if dest.parent() != Some(out) {
bail!("refusing to write outside {}: {name:?}", out.display());
}
Ok(dest)
}Do not forget symlinks. When you write scratch or output files to a shared temp
directory, create them with O_EXCL and a random name (the tempfile crate, or mktemp) rather than a predictable path. A predictable path is one a local
attacker can pre-create as a symlink, turning your write into a write somewhere
else.
Verify it
- The gate actually fails. Add a known-bad dependency in a throwaway branch
and confirm the
supply-chainjob goes red. A gate nobody has seen fail is a gate nobody can trust. - Digest mismatch aborts. Flip one character of a pinned hash in a test and confirm the step stops rather than continuing.
- Traversal is rejected. Unit-test the extraction guard against
.., absolute paths, and nested names.
Related
- Actions overview: the artifact and cache actions these guards live in.
- Runner autoscaling: the runner binary itself is pinned and checksum-verified at boot using this pattern.
- Stephen Way