Skip to content
5 min read

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.

.forgejo/workflows/ci.yml
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 check

What each check blocks on

  • cargo-audit scans the whole lockfile against the RustSec advisory database and fails on a known vulnerability or a yanked release.
  • cargo-deny walks the resolved build graph and enforces policy across four axes: advisories, licenses, sources, and bans.

A representative policy:

deny.toml
[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:

.cargo/audit.toml
[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-runner

sha256sum -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)
}
Note

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-chain job 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.
  • 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.
Contributors
  • Stephen Way