Guide contents

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.)

Terminal
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.

Terminal
cargo install cargo-audit --locked
cargo audit

For a project that still depends on time 0.1.45 (pulled in, say, by an old version of a date library):

Text
    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):

TOMLdeny.toml
[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 = []
Terminal
cargo install cargo-deny --locked
cargo deny check                   # all four
cargo deny check licenses          # just one

With 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):

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 ok

The 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.

Terminal
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 audit

It'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.

Terminal
cargo install cargo-auditable cargo-cyclonedx --locked
cargo auditable build --release
cargo audit bin target/release/my-service
cargo cyclonedx --format json

Part 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.

TOMLrust-toolchain.toml
[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.

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

Rust✗ does not compile · E0277
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());
}
Output
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:0

Use #[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.

Terminal
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 revision

For 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):

Text
    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 failed

Each 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:

TOMLCargo.toml
[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.
Terminal
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 configuration

14. 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)
TOML.cargo/config.toml
[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:

TOMLCargo.toml
[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"
TOMLcrates/billing/Cargo.toml
[lints]
workspace = true

Combined 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.

YAML.github/workflows/ci.yml
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.

Terminal
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 publishes

For 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.lock pins the exact build. Commit it, build with --locked, and update continuously.
  • cargo audit and cargo deny check advisories, licenses, duplicates, banned crates, and sources; cargo vet records human reviews; cargo-auditable and SBOM tools describe what you ship.
  • Pin the toolchain with rust-toolchain.toml; declare and test rust-version for 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] and cargo 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 for unsafe code, 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.