Rust and Cargo Supply Chain Security: cargo-audit, cargo-deny, and Build Script Risks
Problem
Rust is widely adopted for its compile-time memory safety guarantees. That reputation creates a dangerous blind spot: teams assume “it’s Rust, so it’s safe” and apply less scrutiny to their dependency tree than they would for a Node or Python project. The supply chain attack surface in a Rust project is substantial.
- Build scripts (
build.rs) execute arbitrary code during everycargo build. A build script has full filesystem and network access with the permissions of the developer or CI runner running the build. - Procedural macros run inside the Rust compiler during compilation. A proc macro crate can exfiltrate secrets from environment variables, write files to the filesystem, or reach out to external hosts — all under the cover of “code generation.”
- crates.io has no mandatory code review. Anyone can publish any crate. There is no review process equivalent to npm’s audit system or Go’s module proxy. Typosquatting and name-squatting attacks are possible.
Cargo.lockis often absent or misunderstood. Many library authors follow advice to not commitCargo.lock, which means CI resolves dependency versions at build time and can silently pull in a newer, compromised crate version.- SemVer ranges are common in
Cargo.toml.tokio = "1"will resolve to any1.xrelease, including a future compromised1.99.0.
Rust’s memory safety guarantees apply to the code you write. They do not apply to the code that runs as part of your build process or that you pull in from crates.io.
Target systems: Rust projects using Cargo on any OS; CI pipelines on GitHub Actions, GitLab CI, Jenkins; teams building CLI tools, web services, embedded firmware, or WebAssembly modules with Rust.
Threat Model
- Adversary 1 — Compromised crate with malicious build script: A popular utility crate receives a malicious release. Its
build.rsreads environment variables (includingCARGO_ENCODED_RUSTFLAGS,HOME,AWS_SECRET_ACCESS_KEY) and exfiltrates them to an attacker-controlled host. Every developer who runscargo buildand every CI pipeline that builds the project executes this code. - Adversary 2 — Typosquat on crates.io: A developer mistypes
serde_jsonasserde-jsoninCargo.toml. The attacker-registered package with that name is downloaded, compiled, and linked into the binary. - Adversary 3 — Dep chain confusion via semver float: A project pins
reqwest = "0.11"but does not commitCargo.lock. CI resolves this to0.11.27one day and, after a compromised publish, to0.11.28the next. The binary changes without any source code change. - Adversary 4 — Proc macro data exfiltration: A proc macro crate dependency reads
std::env::vars()at macro expansion time and sends the contents to an external endpoint. Because this happens inside the compiler, standard runtime security controls (seccomp, network policies on containers) may not block it unless build environments are network-isolated. - Adversary 5 — Known CVE in transitive dependency: A transitive dependency has a published advisory in the RustSec database. No one on the team knows because there is no automated check. The vulnerability is exploited in production months after the advisory was published.
- Access level: Adversaries 1 and 4 require only that the target runs a build. Adversary 2 requires a typo in
Cargo.toml. Adversary 3 requires a compromised crates.io publish. Adversary 5 requires a known vulnerability and no advisory monitoring. - Objective: Credential theft during build, malicious code in the compiled binary, or exploitation of a known vulnerability in a running service.
- Blast radius: Every binary built from the affected project, every developer machine, every CI runner, and every production service running the output.
Configuration
Step 1: cargo-audit — Advisory Scanning
cargo-audit queries the RustSec Advisory Database against your Cargo.lock and reports known vulnerabilities, unmaintained crates, and security notices.
# Install cargo-audit.
cargo install cargo-audit --locked
# Run a basic audit against Cargo.lock.
cargo audit
# Fail the build if any advisory is found (use in CI).
cargo audit --deny warnings
# Audit and output JSON for downstream processing (SIEM, dashboards).
cargo audit --json | tee audit-report.json
# .github/workflows/security.yml
name: Security Audit
on:
push:
branches: [main]
pull_request:
schedule:
# Run daily — new advisories are published continuously.
- cron: "0 7 * * *"
jobs:
audit:
name: cargo-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Run cargo audit
# --deny warnings: treat unmaintained crates as failures too.
# --ignore RUSTSEC-0000-0000: add known false positives with justification.
run: cargo audit --deny warnings
- name: Upload audit report
if: failure()
uses: actions/upload-artifact@v4
with:
name: audit-report
path: audit-report.json
The --deny warnings flag is critical for CI enforcement. Without it, cargo audit reports findings but exits with code 0, so CI passes. Advisories are suppressed per-crate using --ignore RUSTSEC-XXXX-XXXX — document each suppression with a justification comment in a deny.toml (see Step 2) so the decision is visible to reviewers.
Step 2: cargo-deny — Comprehensive Policy Enforcement
cargo-audit checks known vulnerabilities. cargo-deny goes further: it enforces policies on licenses, permitted crate sources, banned crates, and duplicate dependencies — all in a single deny.toml configuration file.
# Install cargo-deny.
cargo install cargo-deny --locked
# Initialise a deny.toml with sensible defaults.
cargo deny init
# Run all checks.
cargo deny check
# Run only specific checks.
cargo deny check advisories
cargo deny check licenses
cargo deny check bans
cargo deny check sources
# deny.toml — checked into the repository root.
[advisories]
# Reject any crate with a published vulnerability or a security notice.
vulnerability = "deny"
unmaintained = "warn"
unsound = "deny"
yanked = "deny"
notice = "warn"
# Suppress specific advisories with documented justification.
# [advisories.ignore]
# id = "RUSTSEC-2020-0071" # time 0.1 — only used in test code, not in production path.
[licenses]
# Require explicitly listed licences. Deny anything else.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
# Deny copyleft licences in a commercial project.
deny = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "LGPL-2.0", "LGPL-3.0"]
# Treat unlicensed crates as a hard failure.
unlicensed = "deny"
# Require confidence above this threshold when cargo-deny detects a licence.
confidence-threshold = 0.8
[bans]
# Deny specific crates by name (known-bad or internally prohibited).
deny = [
# Example: ban an older, unsound version of a crate.
{ name = "openssl", wrappers = ["openssl-sys"] },
]
# Warn on multiple versions of the same crate in the dep tree.
# Duplicates increase binary size and may mean two crates with different
# vulnerability profiles are both present.
multiple-versions = "warn"
# Highlight crates with wildcard version requirements.
wildcards = "deny"
[sources]
# Only allow crates from crates.io and your internal registry.
# Deny git dependencies in production builds (unpinned, no audit trail).
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# Add your private registry:
# allow-registry = ["https://github.com/rust-lang/crates.io-index", "https://crates.internal.example.com/index"]
# If you must use git sources, allow only specific repositories.
# allow-git = ["https://github.com/your-org/internal-crate"]
# Add to security.yml workflow.
deny:
name: cargo-deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v1
with:
command: check
arguments: --all-features
Step 3: Cargo.lock — Commit Strategy
Cargo.lock records the exact resolved versions and checksums of every dependency in the tree. The Cargo documentation advises library authors not to commit Cargo.lock, but this advice is misapplied to binary crates and services.
Always commit Cargo.lock for:
- Binary crates (CLI tools, services, daemons)
- Applications deployed to production
- WebAssembly modules
- Anything where reproducible builds matter
The argument for not committing Cargo.lock in libraries is that library consumers should resolve the dep tree themselves, so they get compatible versions. This is correct for library authors publishing to crates.io. It does not apply to your service or application.
# Verify that Cargo.lock is committed and up to date in CI.
# --locked: fail if Cargo.lock needs to be updated (i.e., it is out of sync with Cargo.toml).
cargo build --locked
cargo test --locked
# This will fail if:
# - Cargo.lock is not committed.
# - Cargo.lock is out of date relative to Cargo.toml.
# - A dependency was added to Cargo.toml without regenerating Cargo.lock.
What Cargo.lock does not protect against:
Cargo.lock records exact versions and hashes for packages fetched from crates.io. It does not:
- Prevent a build script from those pinned crates from making network calls.
- Guarantee the crate source has not been silently replaced (though crates.io content-addresses tarballs; a SHA mismatch causes a build failure).
- Prevent a future
cargo updatefrom bringing in a new version if someone updates the lock file without review. - Protect against malicious code that was in the crate at the time it was published — if the crate was already compromised when you first pinned it, the lock file preserves the compromise.
Treat Cargo.lock updates as code changes. Review them in pull requests the same way you review source changes.
Step 4: cargo-vet — Third-Party Auditing
cargo-vet is Mozilla’s tool for requiring human-reviewed audits of third-party crates before they enter a build. It integrates with the supply chain Levels for Artifacts (SLSA) concept: each crate either has an audit entry in supply-chain/audits.toml or is covered by a trusted organisation’s published audit set.
# Install cargo-vet.
cargo install cargo-vet --locked
# Initialise cargo-vet in the project.
cargo vet init
# Check which crates still need audits.
cargo vet
# Add an audit after reviewing a crate manually.
cargo vet certify serde 1.0.197
# Import audits from a trusted organisation (e.g., Mozilla, Google).
cargo vet import mozilla https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml
# supply-chain/config.toml — generated by cargo vet init.
[imports.mozilla]
url = "https://raw.githubusercontent.com/mozilla/cargo-vet/main/supply-chain/audits.toml"
[imports.google]
url = "https://raw.githubusercontent.com/google/supply-chain/main/audits.toml"
# Require audits for all crates; not just new additions.
[policy]
audit-as-crates-io = true
cargo-vet is a high-friction control best suited to security-critical projects (cryptography libraries, firmware, financial systems). For most production services, cargo-deny with advisory enforcement and a reviewed Cargo.lock is the right baseline.
Step 5: Private Registry and Source Restriction
For organisations that need tighter control over which crates are used, .cargo/config.toml can redirect all crate resolution through a private registry and block direct crates.io access.
# .cargo/config.toml (project-level) or ~/.cargo/config.toml (user-level).
[source.crates-io]
# Replace crates.io with your private registry for all dependencies.
replace-with = "internal-registry"
[source.internal-registry]
registry = "https://crates.internal.example.com/index"
# Result: `cargo build` will only fetch from the internal registry.
# Crates not mirrored there will fail to resolve, preventing accidental
# use of unapproved upstream crates.
For organisations using Cloudsmith, Artifactory, or a self-hosted Kellnr registry, this pattern forces all resolution through the registry where crates can be scanned, approved, and mirrored.
To enforce this without relying on developer workstation configuration, set the registry source substitution in CI explicitly:
- name: Configure private registry
run: |
mkdir -p ~/.cargo
cat >> ~/.cargo/config.toml <<'EOF'
[source.crates-io]
replace-with = "internal-registry"
[source.internal-registry]
registry = "${{ secrets.CARGO_REGISTRY_URL }}"
token = "${{ secrets.CARGO_REGISTRY_TOKEN }}"
EOF
Step 6: Build Script Security
build.rs files are compiled and executed as native binaries during cargo build. This is necessary for FFI bindings, generated code, and platform-specific configuration — but it is a significant trust boundary.
# Cargo.toml — disable the build script if your crate does not need one.
# Do not include a build = "build.rs" line unless you need it.
# For dependencies you control, audit build.rs files explicitly.
# For third-party crates, check whether the build script:
# - Makes network calls (reqwest in build.rs is a red flag)
# - Reads environment variables beyond CARGO_* and OUT_DIR
# - Writes files outside OUT_DIR
# - Executes external binaries without a clear need
# Inspect build scripts before adding a dependency.
# After adding a crate to Cargo.toml, before committing:
# 1. Check if the crate has a build.rs.
find ~/.cargo/registry/src/*/serde-1.0.197/ -name "build.rs"
# 2. Read the build script and verify it only reads CARGO_* env vars
# and writes to OUT_DIR.
# 3. For CI runners: use network egress restrictions.
# A build.rs that makes outbound HTTP calls is anomalous.
# Use iptables rules, security groups, or a network policy (in k8s)
# to drop outbound connections from CI runners during the build phase.
In Kubernetes-based CI (Tekton, Argo Workflows), apply a NetworkPolicy that blocks egress from the build pod except to the package registry:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ci-build-egress
namespace: ci-builds
spec:
podSelector:
matchLabels:
role: cargo-build
policyTypes:
- Egress
egress:
# Allow only the internal crate registry and DNS.
- to:
- ipBlock:
cidr: 10.0.0.5/32 # Internal registry IP.
ports:
- port: 443
- to:
- namespaceSelector:
matchLabels:
name: kube-system
ports:
- port: 53
protocol: UDP
Step 7: Proc Macro Security
Procedural macros are compiled as dynamic libraries and loaded into the Rust compiler during the compilation phase. They receive the token stream of the code being compiled and produce new token stream output. The security concern is that they run before the final binary is linked — so network egress restrictions that apply to the running binary do not apply during the compilation phase on a typical developer workstation.
Treat proc macro crates with the same scrutiny as build scripts:
# Identify proc macro crates in your dependency tree.
cargo metadata --format-version 1 | \
python3 -c "
import json, sys
meta = json.load(sys.stdin)
for pkg in meta['packages']:
for target in pkg.get('targets', []):
if 'proc-macro' in target.get('kind', []):
print(pkg['name'], pkg['version'], pkg['source'])
"
# Review proc macro crates before accepting them as dependencies.
# Key questions:
# - Is the crate widely used and maintained by a reputable author?
# - Does the crate's functionality justify a proc macro (vs. a regular macro)?
# - Have any security researchers reviewed it?
# - Does cargo-audit report any advisories for it?
Minimise proc macro exposure: prefer derive macros from established crates (serde, thiserror, tokio::main) over novel proc macro crates from unknown authors.
Step 8: Reproducible Builds
A reproducible build produces a bit-for-bit identical binary given the same source, toolchain, and inputs. Reproducibility lets you verify that the binary you ship matches the source you reviewed.
# Pin the Rust toolchain version exactly via rust-toolchain.toml.
# Without this, `rustup` uses whatever is current, and builds vary over time.
cat rust-toolchain.toml
# rust-toolchain.toml — commit this to the repository.
[toolchain]
channel = "1.78.0" # Exact version; not "stable" or "1.78".
components = ["rustfmt", "clippy"]
targets = ["x86_64-unknown-linux-musl"] # Pin the target too.
# Build with locked dependencies and the pinned toolchain.
cargo build --release --locked --target x86_64-unknown-linux-musl
# Enable source-based code coverage flags for reproducibility checks.
# RUSTFLAGS affects the binary; pin it in CI.
export RUSTFLAGS="-C target-feature=+crt-static"
export CARGO_BUILD_TARGET=x86_64-unknown-linux-musl
# Verify reproducibility by building twice and comparing.
cargo build --release --locked
cp target/release/myapp /tmp/myapp-build1
cargo build --release --locked
diff /tmp/myapp-build1 target/release/myapp
# Should produce no output if the build is reproducible.
Common sources of non-reproducibility in Rust builds:
- Timestamps embedded by
std::time::SystemTime::now()at build time — avoid in build scripts. - Non-deterministic hash maps in code generation — use
BTreeMapin proc macros and build scripts. file!()andenv!()macros embed absolute paths — useCARGO_MANIFEST_DIRcarefully.- Differing
CARGO_PKG_VERSION_*env vars if they are not set consistently.
The Reproducible Builds project maintains a Rust-specific guide at reproducible-builds.org.
Expected Behaviour
| Signal | Without Controls | With Controls |
|---|---|---|
| Known CVE in transitive dep | Silently present; discovered in pentest | cargo audit --deny warnings fails CI at PR time |
| Unlicensed or GPL crate added | Merged without review | cargo deny check licenses fails PR |
| Build script makes outbound HTTP | Executes silently; credentials exfiltrated | Network policy blocks egress; CI fails |
Cargo.lock out of date |
CI resolves new version silently | cargo build --locked fails; engineer must update and review lockfile diff |
| Typosquat crate added | Compiled and linked into binary | cargo deny check sources blocks unknown registries; cargo-vet requires audit before build |
| New proc macro dep added | Compiled into build; no review | Team audit process flags it; cargo deny policy enforced |
Trade-offs
| Control | Benefit | Cost | Mitigation |
|---|---|---|---|
cargo audit --deny warnings |
Blocks builds with known CVEs | May block builds on unmaintained-but-safe crates | Use --ignore with documented justification in deny.toml |
cargo deny license enforcement |
Prevents GPL crate integration | Requires licence review when adding deps | Use cargo deny check licenses locally as a pre-commit check |
Commit Cargo.lock for binaries |
Reproducible, auditable builds | Lockfile update PRs require explicit review | Treat lockfile diffs as meaningful security-relevant changes |
| Network egress restriction in CI | Prevents build-time exfiltration | Blocks legitimate network access in some build scripts (e.g., downloading system headers for FFI) | Mirror required build assets into the internal registry or CI cache |
cargo-vet |
Strongest supply chain guarantee | High operational overhead; requires manual crate reviews | Suitable for security-critical projects; too heavy for general applications |
rust-toolchain.toml pin |
Reproducible, auditable toolchain | Requires periodic toolchain update PRs | Automate via Dependabot or a weekly workflow that proposes the toolchain update |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| New advisory published for pinned dep | cargo audit fails in daily scheduled CI run |
Scheduled audit workflow produces failure notification | Assess the advisory; update the dep or add a justified --ignore entry |
| Lockfile update pulls in compromised version | Binary behaviour changes without source changes | Review lockfile diff in PR; compare binary hashes | Revert Cargo.lock to last known good; investigate the crate version |
| Build script fails with network restriction | CI build fails at compile step with network error | CI log shows connection refused in build.rs | Determine if the network call is legitimate; if so, mirror the resource into CI |
deny.toml too strict for a new dep |
Dependency blocked by licence or source policy | cargo deny fails with policy violation |
Review the policy entry; update deny.toml with justification if the dep is acceptable |
| Proc macro crate compromised | Malicious code compiled into binary | No automatic detection without cargo-vet; audit diff |
Rotate all credentials that were in env vars during the affected build; rebuild from known-good lockfile |
rust-toolchain.toml pin falls behind security-relevant compiler fix |
Builds use a compiler with a known miscompilation or unsoundness | Track Rust security advisories; subscribe to the Rust security mailing list | Update channel in rust-toolchain.toml; rebuild and re-test |
Related Articles
- Dependency Pinning and Lockfile Integrity: Preventing Supply Chain Attacks in CI
- Artifact Integrity: SLSA Provenance and Sigstore for Build Outputs
- Container Build Hardening: Rootless BuildKit and Minimal Base Images
- GitHub Actions Security: Permissions, Pinning, and Workflow Injection
- WebAssembly Linear Memory Safety