Lesson 91 of 94
Dependencies, Security & CI
A Rust service typically pulls in a hundred or more crates, most of them transitive. A lead engineer is responsible for what those crates are, whether they have known vulnerabilities, whether their licenses are acceptable, how upgrades happen, and whether the CI pipeline catches problems before they merge. One risk deserves particular attention: Rust dependencies can run code on your build machine.
| Concern | Rust / Cargo |
|---|---|
| Package registry | crates.io, private registries (Artifactory, Cloudsmith, kellnr) |
| Version requirements | semver requirements by default (serde = "1" means >=1.0.0, <2.0.0) |
| Lockfile | Cargo.lock, always generated |
| Vulnerability scanning | cargo audit, cargo deny check advisories |
| License checks | cargo deny check licenses |
| Banned dependencies | cargo deny check bans |
| Automated upgrades | Renovate, Dependabot (both support Cargo) |
| Minimum toolchain version | rust-version (MSRV) and rust-toolchain.toml |
| API compatibility | cargo semver-checks |
| Linting and formatting | rustfmt, Clippy |
| SBOM generation | cargo-cyclonedx, cargo-auditable |
Part 1: Choosing and pinning dependencies
1. Build scripts and proc macros run code at build time
A Rust dependency can execute arbitrary code during cargo build:
- a build script (
build.rs) runs before the crate compiles, with the permissions of the user running the build; - a procedural macro runs inside the compiler whenever code that uses it is compiled, including in your editor through rust-analyzer.
That makes developer laptops and CI runners part of the attack surface. Practical consequences: review new dependencies that have a build.rs or are proc macros, don't give CI jobs that build pull requests from forks access to secrets, and prefer crates with few, well-known dependencies.
2. Evaluating a crate before adding it
| Question | Where to look |
|---|---|
| Is it widely used and maintained? | crates.io downloads and reverse dependencies, date of the last release, open issues |
| Who publishes it? | crates.io owners, the repository's organization (rust-lang, tokio-rs, a known company) |
| How many dependencies does it bring? | cargo tree -p the-crate after adding it, or the "Dependencies" tab on crates.io |
Does it use unsafe, and is it tested for it? |
the source; whether its CI runs Miri or fuzzing |
| Does it run code at build time? | a build.rs in the source, proc-macro = true in its Cargo.toml |
| Is the license acceptable? | license field in Cargo.toml; cargo deny check licenses |
| Are there known advisories? | the RustSec advisory database (rustsec.org), cargo audit |
| Is it the community's default choice? | blessed.rs, "awesome Rust" lists, what major projects use |
Adding a dependency is cheap; removing one after a hundred files use it isn't. For small needs (a function or two), writing the code yourself is often the better trade in Rust too.
3. Version requirements
Cargo interprets a plain version as a caret requirement: any version compatible under semver. For crates below 1.0, the first non-zero component is treated as the major version.
| Requirement | Allows | Note |
|---|---|---|
serde = "1.0.200" |
>=1.0.200, <2.0.0 |
the usual form; the minimum matters |
rand = "0.9" |
>=0.9.0, <0.10.0 |
for 0.x, the minor version is the breaking one |
ring = "0.17.8" |
>=0.17.8, <0.18.0 |
|
foo = "~1.2.3" |
>=1.2.3, <1.3.0 |
patch updates only |
foo = "=1.2.3" |
exactly 1.2.3 |
avoid in libraries: it blocks everyone's upgrades |
foo = ">=1.2, <1.5" |
a range | rare |
Libraries should state the lowest version they actually need and let applications resolve to the newest compatible one; the lockfile makes the build reproducible.
4. Cargo.lock: commit it
Cargo.lock records the exact version and checksum of every crate in the build. Commit it for applications and, per Cargo's current guidance, for libraries too: it makes CI reproducible and makes "it built yesterday" debuggable. (Consumers of a library ignore its lockfile; their own lockfile decides.)
cargo build --locked # fail if Cargo.lock would need to change (use in CI)
cargo update # move everything to the newest compatible versions
cargo update -p serde # only one crate
cargo update -p serde --precise 1.0.200 # an exact version
cargo tree -i openssl-sys # why is this crate in my build?Upgrading across major versions (0.9 to 0.10, 1 to 2) requires editing Cargo.toml; cargo upgrade from cargo-edit or Renovate/Dependabot automate that. A weekly automated cargo update pull request keeps the lockfile fresh, so security fixes in transitive dependencies arrive without a scramble.
Part 2: Auditing
5. cargo audit: known vulnerabilities
cargo audit checks Cargo.lock against the RustSec advisory database: vulnerabilities, crates marked unmaintained, and yanked releases.
cargo install cargo-audit --locked
cargo auditFor a project that still depends on time 0.1.45 (pulled in, say, by an old version of a date library):
Fetching advisory database from `https://github.com/RustSec/advisory-db.git`
Loaded 1271 security advisories (from ~/.cargo/advisory-db)
Updating crates.io index
Scanning Cargo.lock for vulnerabilities (7 crate dependencies)
Crate: time
Version: 0.1.45
Title: Potential segfault in the time crate
Date: 2020-11-18
ID: RUSTSEC-2020-0071
URL: https://rustsec.org/advisories/RUSTSEC-2020-0071
Severity: 6.2 (medium)
Solution: Upgrade to >=0.2.23
error: 1 vulnerability found!The command exits with a non-zero status, so it fails a CI job. The fix is usually cargo update -p the-crate (for a compatible fix) or upgrading the dependency that pulls it in (cargo tree -i time@0.1.45 shows which). When an advisory doesn't apply to how you use the crate, record that decision explicitly (next example) rather than ignoring the failure.
6. cargo deny: one policy for advisories, licenses, bans, and sources
cargo deny enforces a written policy in CI. It checks four things, configured in deny.toml (cargo deny init creates a commented template):
[advisories]
# Fail on vulnerabilities; unmaintained crates are reported for workspace dependencies.
unmaintained = "workspace"
ignore = [
# { id = "RUSTSEC-2024-0000", reason = "not reachable: we never call the affected API" },
]
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib"]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn" # duplicate crates (see Compile Times & Build Engineering)
wildcards = "deny" # no `foo = "*"` requirements
deny = [
{ crate = "openssl", reason = "we standardize on rustls" },
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-git = []cargo install cargo-deny --locked
cargo deny check # all four
cargo deny check licenses # just oneWith this deny.toml, a project depending on openssl and on the vulnerable time 0.1.45 from the previous example fails two of the four checks (output shortened; the advisory section also prints the full advisory text):
warning[license-not-encountered]: license was not encountered
┌─ ./deny.toml:9:32
│
9 │ allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib"]
│ ━━━━━━━━━━━━ unmatched license allowance
error[banned]: crate 'openssl = 0.10.81' is explicitly banned
┌─ ./deny.toml:16:16
│
16 │ { crate = "openssl", reason = "we standardize on rustls" },
│ ━━━━━━━ ──────────────────────── reason
│ │
│ banned here
│
├ openssl v0.10.81
└── au v0.1.0
error[vulnerability]: Potential segfault in the time crate
┌─ ./Cargo.lock:17:1
│
17 │ time 0.1.45 registry+https://github.com/rust-lang/crates.io-index
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ security vulnerability detected
│
├ ID: RUSTSEC-2020-0071
├ Advisory: https://rustsec.org/advisories/RUSTSEC-2020-0071
...
├ Solution: Upgrade to >=0.2.23 (try `cargo update -p time`)
├ time v0.1.45
└── au v0.1.0
advisories FAILED, bans FAILED, licenses ok, sources okThe warnings point at allowed licenses that no dependency uses, which keeps the allow list from silently growing.
Each exception belongs in deny.toml with a reason, so the policy and its exceptions are reviewed like code.
7. cargo vet: recording who reviewed what
Advisories catch known problems. cargo vet addresses the other question: has anyone we trust actually looked at this code? It records audits ("I reviewed foo 1.2.3 and it's safe to deploy") in the repository, can import audits published by other organizations (Mozilla, Google, the Bytecode Alliance, and others publish theirs), and fails CI when a new or upgraded crate has no audit.
cargo install cargo-vet --locked
cargo vet init # start with everything currently in use as exempted
cargo vet # check: fails for unaudited new versions
cargo vet diff foo 1.2.3 1.2.4 # review only what changed between versions
cargo vet certify foo 1.2.4 # record your auditIt's most valuable for teams with strict supply-chain requirements, where upgrading a dependency should require a human to look at the diff.
8. Know what you ship: cargo-auditable and SBOMs
A compiled binary doesn't list the crate versions inside it. cargo auditable build embeds the dependency list into the binary (a few kilobytes), so a binary found on a server years later can be scanned for vulnerabilities with cargo audit bin, Trivy, Grype, or Syft. For compliance, cargo cyclonedx generates a CycloneDX software bill of materials (SBOM) from the workspace.
cargo install cargo-auditable cargo-cyclonedx --locked
cargo auditable build --release
cargo audit bin target/release/my-service
cargo cyclonedx --format jsonPart 3: Toolchains and compatibility
9. Pinning the toolchain with rust-toolchain.toml
A new stable Rust is released every six weeks. Pin the version your project builds with, so every developer and CI job uses the same compiler, Clippy, and rustfmt. rustup reads this file and installs the toolchain automatically.
[toolchain]
channel = "1.94"
components = ["clippy", "rustfmt"]
targets = ["x86_64-unknown-linux-musl"]Upgrade deliberately: bump the file in a pull request, fix new Clippy warnings, and merge. Rust's stability guarantee means code that compiles keeps compiling, but new lints and deprecation warnings appear with each release.
10. MSRV: the minimum supported Rust version
Libraries declare the oldest compiler they support with rust-version. Cargo refuses to build the crate with an older compiler, with a clear message, instead of failing with confusing syntax errors. Since Rust 1.84, the dependency resolver (resolver = "3", the default in edition 2024) also prefers dependency versions compatible with your rust-version.
[package]
name = "billing-client"
version = "2.3.0"
edition = "2024"
rust-version = "1.85"Test it in CI with a job that installs exactly that toolchain and runs cargo check. cargo msrv find (from cargo-msrv) determines the real minimum by bisecting compiler versions. For applications, the MSRV is simply the pinned toolchain.
11. Semver for libraries: what counts as breaking
If you publish a library (to crates.io or an internal registry), its version number is a promise. Rust has many ways to break users, because inference, traits, and auto traits make more of an API observable. The Cargo book's SemVer reference lists them all; these are the common ones:
| Change | Breaking? | Why |
|---|---|---|
| Remove or rename a public item | yes | callers stop compiling |
| Add a public field to a struct with all-public fields | yes | struct literals and exhaustive patterns in callers break |
| Add a variant to a public enum | yes | callers' exhaustive matches break |
| Add a method without a default to a public trait | yes | callers' implementations break |
Make a type lose Send or Sync (for example by adding an Rc field) |
yes | callers that send it to threads break |
| Add a new public function, type, or module | no (minor) | |
Add a variant to a #[non_exhaustive] enum |
no (minor) | callers must already have a wildcard arm |
| Add a trait method with a default | usually minor | can cause ambiguity if callers use another trait with the same method name |
| Raise the MSRV | debated; many crates treat it as minor | document your policy |
The auto-trait case is the one that surprises people, because nothing in the signature changes. A library's type gets an internal cache, and a caller's code stops compiling:
mod library {
use std::rc::Rc;
pub struct Client {
base_url: String,
cache: Rc<Vec<String>>, // new in "2.3.1": an internal detail... that removed Send
}
impl Client {
pub fn new(base_url: &str) -> Self {
Client { base_url: base_url.to_string(), cache: Rc::new(Vec::new()) }
}
pub fn get(&self, path: &str) -> String {
format!("GET {}{path} ({} cached)", self.base_url, self.cache.len())
}
}
}
fn main() {
// Caller code that worked with 2.3.0:
let client = library::Client::new("https://api.example.com");
let handle = std::thread::spawn(move || client.get("/users"));
println!("{}", handle.join().unwrap());
}error[E0277]: `Rc<Vec<String>>` cannot be sent between threads safely
--> src/main.rs:22:37
|
22 | let handle = std::thread::spawn(move || client.get("/users"));
| ------------------ -------^^^^^^^^^^^^^^^^^^^^^
| | |
| | `Rc<Vec<String>>` cannot be sent between threads safely
| | within this `{closure@src/main.rs:22:37: 22:44}`
| required by a bound introduced by this call
|
= help: within `{closure@src/main.rs:22:37: 22:44}`, the trait `Send` is not implemented for `Rc<Vec<String>>`
note: required because it appears within the type `Client`
--> src/main.rs:4:16
|
4 | pub struct Client {
| ^^^^^^
note: required because it's used within this closure
--> src/main.rs:22:37
|
22 | let handle = std::thread::spawn(move || client.get("/users"));
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/<version>/library/std/src/thread/functions.rs:125:0Use #[non_exhaustive] on public enums and structs that may grow, keep fields private behind constructors, and let a tool check the rest.
12. cargo semver-checks: catching breaking changes before release
cargo semver-checks compares your crate's public API with the last published version (or a git revision) and reports changes that require a major version bump. It's the Rust counterpart of japicmp or revapi.
cargo install cargo-semver-checks --locked
cargo semver-checks # against the version on crates.io
cargo semver-checks --baseline-rev origin/main # against a git revisionFor example, take a small billing-client library whose next commit adds a Currency::Inr variant, adds a public currency field to Invoice, and renames format_amount to format_cents, all without changing the version number. Checking against the previous commit (paths shortened):
Checking billing-client v0.1.0 -> v0.1.0 (no change; assume minor)
Checked [ 0.012s] 196 checks: 193 pass, 3 fail, 0 warn, 58 skip
--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---
Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
Failed in:
field Invoice.currency in src/lib.rs:10
--- failure enum_variant_added: enum variant added on exhaustive enum ---
Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
Failed in:
variant Currency:Inr in src/lib.rs:4
--- failure function_missing: pub fn removed or renamed ---
Description:
A publicly-visible function cannot be imported by its prior path. A `pub use` may have been removed, or the function itself may have been renamed or removed entirely.
Failed in:
function billing_client::format_amount, previously in file src/lib.rs:11
Summary semver requires new major version: 3 major and 0 minor checks failedEach failure links to the rule in the Cargo SemVer reference. The fixes are the ones from the previous example: mark the enum and struct #[non_exhaustive] (in a major release, since adding that attribute is itself breaking), and keep the old function as a #[deprecated] wrapper around the new name.
Run it in CI for every library pull request. It turns "we accidentally shipped a breaking change in a patch release" into a failed check.
13. Features must be additive
Features are unified across the whole build: if two crates depend on yours with different features, both get the union. So a feature may only add functionality, never remove or change it. cargo hack checks that every combination compiles:
[features]
default = ["json"]
json = ["dep:serde_json"]
metrics = ["dep:prometheus"]
# Wrong: a feature that disables something, or two features that can't be enabled together.cargo install cargo-hack --locked
cargo hack check --each-feature --no-dev-deps # every feature on its own
cargo hack check --feature-powerset --depth 2 # combinations of up to two features
cargo check --no-default-features # the minimal configuration14. Private registries, vendoring, and offline builds
Companies that can't depend on crates.io at build time have several options:
| Need | Cargo mechanism |
|---|---|
| Internal crates | a private registry ([registries] in .cargo/config.toml), published with cargo publish --registry internal |
| A proxy or mirror of crates.io | source replacement ([source.crates-io] replace-with = "company-mirror") |
| Builds with no network at all | cargo vendor copies every dependency's source into vendor/; then cargo build --offline |
| Reproducible CI | --locked (lockfile must not change) or --frozen (also no network) |
[registries]
internal = { index = "sparse+https://cargo.example.com/api/v1/crates/" }
[source.crates-io]
replace-with = "company-mirror"
[source.company-mirror]
registry = "sparse+https://artifactory.example.com/api/cargo/crates-io/index/"Part 4: The CI pipeline
15. Lint configuration in Cargo.toml
Lints can be configured once for a whole workspace in Cargo.toml (Rust 1.74+), instead of #![deny(...)] attributes scattered over files:
[workspace.lints.rust]
unsafe_code = "deny" # allow per module where justified
missing_debug_implementations = "warn"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
unwrap_used = "warn"
await_holding_lock = "deny"
large_futures = "warn"
undocumented_unsafe_blocks = "deny"[lints]
workspace = trueCombined with cargo clippy -- -D warnings in CI, every warning fails the build, so warnings never pile up. Formatting is enforced the same way with cargo fmt --check (and a rustfmt.toml if you need to change defaults).
16. A complete CI pipeline
A pipeline for a Rust service or library, as GitHub Actions. The same stages map to GitLab CI or Jenkins.
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1" # weekly: catch new advisories and toolchain changes
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -D warnings
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --all-features --locked
- run: cargo doc --workspace --no-deps --locked
env:
RUSTDOCFLAGS: -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- run: cargo nextest run --workspace --all-features --locked
- run: cargo test --workspace --doc --locked
msrv:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.85 # keep in sync with rust-version
- run: cargo check --workspace --locked
supply-chain:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2 # advisories, licenses, bans, sources
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- run: cargo miri test -p my-unsafe-crate
semver:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: obi1kenobi/cargo-semver-checks-action@v2 # libraries only| Stage | Blocks merging? | Why |
|---|---|---|
| fmt, clippy, docs | yes | cheap, catches most review nits automatically |
| tests (nextest + doc tests) | yes | correctness |
| MSRV check | yes, for libraries | keeps the rust-version promise honest |
| cargo deny | yes | vulnerabilities, licenses, banned crates, unknown sources |
| Miri | yes, for crates with unsafe |
undefined behavior |
| semver-checks | yes, for published libraries | accidental breaking changes |
| fuzzing, benchmarks, coverage, mutants | no; scheduled or on demand | slow, or need human judgment |
The scheduled weekly run matters: a new advisory against a dependency you haven't touched in months should fail a build and notify someone, not wait for the next unrelated pull request.
17. Releasing
For libraries and versioned services, release-plz and cargo-release automate the release chores: bump versions (following semver-checks), update changelogs from commit messages, tag, and publish.
cargo install release-plz --locked
release-plz release-pr # opens a PR with version bumps and changelog entries
release-plz release # after merge: tags and publishesFor binaries, cargo-dist builds release artifacts for several platforms, with installers and checksums, from a single configuration. For containers, see the cargo-chef Dockerfile in Compile Times & Build Engineering.
18. A supply-chain checklist
| Check | Tool or practice |
|---|---|
| New dependencies are reviewed before merging | a PR review rule for changes to Cargo.toml; extra scrutiny for build.rs and proc macros |
| No known vulnerabilities | cargo deny check advisories in CI, plus a scheduled run |
| Licenses are acceptable | cargo deny check licenses with an allow list |
| No duplicate or banned crates | cargo deny check bans |
| Dependencies only from approved sources | cargo deny check sources; a registry mirror |
| Builds are reproducible | committed Cargo.lock, --locked in CI, pinned rust-toolchain.toml |
| Upgrades happen continuously | Renovate or Dependabot, weekly cargo update PRs |
| Libraries don't break users | rust-version, #[non_exhaustive], cargo semver-checks, additive features tested with cargo hack |
| Shipped binaries can be scanned later | cargo auditable build, SBOM with cargo cyclonedx |
| CI can't leak secrets to untrusted code | no secrets in jobs that build fork PRs |
Summary
- Rust dependencies can run code at build time through build scripts and proc macros; treat adding a crate as a security decision.
- Version requirements are semver ranges by default;
Cargo.lockpins the exact build. Commit it, build with--locked, and update continuously. cargo auditandcargo denycheck advisories, licenses, duplicates, banned crates, and sources;cargo vetrecords human reviews;cargo-auditableand SBOM tools describe what you ship.- Pin the toolchain with
rust-toolchain.toml; declare and testrust-versionfor libraries. - Semver in Rust covers more than signatures: new enum variants, new public fields, and lost auto traits (
Send,Sync) are breaking. Use#[non_exhaustive]andcargo semver-checks. - Features must be additive; check combinations with
cargo hack. - A CI pipeline runs fmt, Clippy with
-D warnings, tests, MSRV,cargo deny, Miri forunsafecode, and semver checks for libraries, plus a scheduled run for new advisories.
Next: Debugging & Profiling in Production: debuggers, core dumps, profilers, and task dumps for a misbehaving service.
