A Rust service of any size depends on a few hundred crates written by strangers, and one of them will eventually have a published security advisory. Two tools turn that fact into a failing CI job instead of a surprise: cargo audit, which asks one question, and cargo deny, which asks four. This post runs both on a real project, reads the output, writes a deny.toml you can paste, and explains how to handle the day a check fails and nobody can fix it. The commands were run with cargo-audit 0.22 and cargo-deny 0.20 on Rust 1.99.
cargo audit is one question
The RustSec project keeps a git repository of advisories, one TOML file per issue, each naming the crate, the affected and patched version ranges, and sometimes a CVE. cargo audit fetches that database and checks every package in your Cargo.lock against it.
cargo install cargo-audit --locked
cargo audit Fetching advisory database from `https://github.com/RustSec/advisory-db.git`
Loaded 1446 security advisories (from /Users/me/.cargo/advisory-db)
Updating crates.io index
Scanning Cargo.lock for vulnerabilities (9 crate dependencies)A clean project prints that and exits zero. A project with a problem prints the advisory ID, the crate and version, the date, a title and a solution line such as "Upgrade to >=0.3.2", followed by a dependency tree showing who pulled it in. It exits non-zero for vulnerabilities, which is what you want from a CI step.
The tool reads the lockfile, so a library that does not commit Cargo.lock has nothing to audit locally, and the answer for downstream users depends on what they resolve. Commit the lockfile for applications, as discussed in whether to commit Cargo.lock, and audit that.
Warnings are a second class of result. RustSec also tracks crates that are unmaintained, unsound, or yanked. These print as warnings and do not fail the run by default. If your policy is "an unmaintained crate in the graph is a defect", turn them into errors:
cargo audit --deny warningscargo deny is four checks in one file
cargo deny overlaps with audit on advisories, then adds checks that a lockfile scanner cannot do. Generate a starting configuration and run everything:
cargo install cargo-deny --locked
cargo deny init
cargo deny checkThe four checks answer different questions:
| Check | Question | Typical failure |
|---|---|---|
advisories | Does anything in the graph have a RustSec advisory? | a vulnerable time or openssl version |
licenses | Is every license on our allow list? | a transitive crate under a license legal has not approved |
bans | Is a forbidden or duplicated crate present? | two versions of syn, or a crate you banned for policy |
sources | Does every crate come from an allowed registry or git host? | a git dependency someone added from a fork |
On a freshly created crate with one dependency, the default configuration fails the license check before anything else, because the default allow list is empty. This is the real output from a project depending on rand_core 0.6:
error[rejected]: failed to satisfy license requirements
┌─ rand_core-0.6.4/Cargo.toml:34:12
│
34 │ license = "MIT OR Apache-2.0"
│ ━━━────━━━━━━━━━━
│ │ │
│ │ rejected: license is not explicitly allowed
│ rejected: license is not explicitly allowedThat is the tool working. Deny is deny-by-default for licenses, so you list what you accept. The same run also reports that the root crate itself is unlicensed, which is the nudge to add a license field to your own manifest.
A deny.toml you can start from
[advisories]
version = 2
yanked = "deny"
ignore = []
[licenses]
version = 2
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn"
wildcards = "deny"
deny = [
{ crate = "openssl", reason = "we use rustls everywhere" },
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]Some choices here are opinions worth defending. yanked = "deny" fails the build when a locked version has been pulled from the registry, which usually means the author found a serious bug in it. wildcards = "deny" rejects a dependency written as "*", which accepts any version and makes the build irreproducible. multiple-versions = "warn" keeps duplicate crates visible without breaking builds, since duplicates are common and rarely a security issue; the license and ban configuration guide goes deeper on tuning it. Banning openssl with a reason is a cheap way to keep a decision from eroding: a new contributor who adds reqwest with default features gets a failing check that explains why instead of a silent C dependency.
Wire it into CI
name: deny
on:
push:
pull_request:
schedule:
- cron: "17 5 * * *"
jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2The schedule is the part people forget. Your code did not change overnight but the advisory database did. A job that runs only on pushes will stay green on a repository nobody touched for three weeks while a new vulnerability sits in the lockfile. A nightly run catches it and turns up in the same notification channel as any failed build. Consider splitting the nightly advisories check from the push-time license and ban checks, so a new advisory on a crate you did not touch does not block an unrelated pull request.
Handling a failure you cannot fix today
Sometimes the patched version does not exist, or upgrading breaks a transitive constraint. Both tools let you ignore a specific advisory, and the discipline is in how:
[advisories]
ignore = [
# RUSTSEC-2024-0001: only reachable through the CLI feature, which we do not build.
# Revisit when upstream releases a patched version, or by the first of next quarter.
"RUSTSEC-2024-0001",
]A comment that states why the code is not reachable and when you will look again turns an ignore into a decision. An uncommented ignore is a vulnerability with a note-to-self to forget it. Use the dependency risk guide to judge reachability before you write one: an advisory in a crate you only use under cfg(test) is a different risk from one in your request parser.
Reading one advisory end to end
An advisory is a small TOML file in the RustSec repository, and knowing its fields makes the output of both tools easier to judge. The header names the package and the dates, and the versions table is the part that decides whether you are affected:
[advisory]
id = "RUSTSEC-2024-0001"
package = "example-parser"
date = "2024-02-12"
categories = ["denial-of-service"]
keywords = ["panic", "stack overflow"]
[versions]
patched = [">= 0.3.2"]
unaffected = ["< 0.2.0"]
[affected]
functions = { "example_parser::parse_nested" = ["< 0.3.2"] }The patched range says which versions fix it, and unaffected lists older versions that never had the bug. The optional affected.functions table is the one to read when deciding whether to ignore: it names the function that is vulnerable, so a quick grep -r parse_nested src/ tells you whether your code can reach it. A crate that is in the lockfile only through a development dependency is another common reason an advisory is real but not a production risk, which cargo tree -e normal -i example-parser confirms by showing whether any non-dev path leads to it. Record that finding in the ignore comment so the next reader does not redo the investigation.
What neither tool can see
Both rely on someone having reported the problem. A crate that was compromised yesterday, or one that is malicious but unreported, passes cleanly. Audit and deny also say nothing about whether a dependency's code is any good; they match names and versions against lists. If your threat model includes a maintainer account takeover, add a review layer such as cargo vet, pin versions with a committed lockfile, and review changes in Cargo.lock diffs the way you review code. Think of audit and deny as the cheap floor, not the ceiling.



