cargo update can silently hand a dependency new abilities: a build.rs that
didn't exist before, a new unsafe fn, a Command::new call, a socket. None
of that shows up in a normal diff review because nobody reviews vendored
dependency source on every update. capscan does a structural pass over a
crate's source with syn and tells you what changed,
capability-wise, between two versions.
It is not a replacement for cargo-audit
(known-vulnerability scanning) or cackle
(configured, enforced capability policy for CI). It's the zero-config check
you run before an update: no policy file, no crate list to maintain — just
"what capabilities did this gain."
Prebuilt binary, no Rust toolchain needed -- macOS, Linux, and Windows, from the latest release:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/poglesbyg/capscan/releases/latest/download/capscan-installer.sh | sh
(PowerShell on Windows: see the install command on the release page.)
Or build from source:
cargo install capscan
or, from a checkout of this repo:
cargo install --path .
Either way this installs cargo-capscan so cargo capscan ... works as a
normal cargo subcommand. It also runs fine unbuilt-installed, straight from
the repo:
cargo run --release --bin cargo-capscan -- scan anyhow 1.0.104
Scan a single version:
$ cargo capscan scan anyhow 1.0.104
anyhow 1.0.104 (13 files scanned, 4113 lines)
dependencies:
[low] build.rs:24 env read -- env::var_os
[high] build.rs:117 process spawn -- Command::new
[medium] build.rs:156 filesystem write -- fs::remove_dir_all
[medium] src/error.rs:163 unsafe block -- unsafe { .. }
[high] src/error.rs:736 unsafe fn -- object_drop
...
Diff two versions — the workflow this is built for:
$ cargo capscan diff anyhow 1.0.70 1.0.104
anyhow 1.0.70 -> anyhow 1.0.104
+ 3 new signal(s):
[high] src/error.rs:777 unsafe fn -- object_reallocate_boxed
[medium] build.rs:156 filesystem write -- fs::remove_dir_all
[low] src/nightly.rs:39 build-time macro -- option_env
- 6 signal(s) no longer present:
[medium] build.rs:85 filesystem write -- fs::write
[high] src/backtrace.rs:332 unsafe impl -- LazilyResolvedCapture
...
- removed dependencies: backtrace
worst new severity: high
$ echo $?
2
Exit code is 2 if the update adds a high severity signal, 1 for
medium, 0 otherwise — wire it into CI right before you'd otherwise run
cargo update && cargo build:
cargo capscan diff "$CRATE" "$OLD_VERSION" "$NEW_VERSION" || fail_the_build
Audit an entire project at once — reads Cargo.lock, checks every crates.io
dependency against its latest published version, and diffs the ones that are
behind:
$ cargo capscan audit
audited 54 registry dependencies (47 already at latest)
7 have updates available:
[medium] serde_spanned 0.6.9 -> 1.1.1 (+0 signal(s), -0 signal(s), +1 new dep(s))
[medium] toml 0.8.23 -> 1.1.3+spec-1.1.0 (+0 signal(s), -0 signal(s), +7 new dep(s))
[medium] toml_edit 0.22.27 -> 0.25.13+spec-1.1.0 (+0 signal(s), -2 signal(s), +5 new dep(s))
[none ] syn 2.0.119 -> 3.0.2 (+0 signal(s), -0 signal(s))
...
run `cargo capscan diff <name> <old> <new>` for details on any of the above.
(That's real output from running capscan on its own Cargo.lock — the
toml 0.8 → 1.1 line is genuine: that major bump quietly pulls in 7 new
transitive dependencies.) Point it at another lockfile with --lockfile path/to/Cargo.lock. Exit code is the worst severity found across every
dependency, same scale as diff -- computed from every dependency
regardless of --min-severity below, so filtering what's displayed never
silently changes what a CI gate would catch.
Add --min-severity low/medium/high to only show dependencies whose
worst new capability is at least that severity, skipping the ones already
at latest (or below the threshold) instead of scrolling past them:
$ cargo capscan audit --min-severity medium
audited 54 registry dependencies (47 already at latest)
4 have updates available:
[medium] serde_spanned 0.6.9 -> 1.1.1 (+0 signal(s), -0 signal(s), +1 new dep(s))
[medium] toml 0.8.23 -> 1.1.3+spec-1.1.0 (+0 signal(s), -0 signal(s), +7 new dep(s))
...
The header always reflects the true total either way; only the listed
entries (and, with --json, the returned array) are filtered.
Add --json to any subcommand for machine-readable output.
If the requested version isn't already in your local cargo registry cache,
capscan fetches it: it spins up a scratch project, runs
cargo add name@=version && cargo fetch in it, and reads the result out of
~/.cargo/registry/src/. No custom download/untar code — it reuses cargo's
own already-trusted path to the registry.
audit answers "what's out of date vs. crates.io in general." A different,
narrower question is "what did this specific PR change" — given two
lockfiles (e.g. the base branch's and the PR branch's), which crates were
added, removed, or bumped, and what did that do to their capability surface:
$ cargo capscan diff-lockfiles base.lock head.lock --markdown
Same underlying scan/diff machinery as audit and diff, but scoped to
just the crates that actually changed between the two given lockfiles
(rather than every dependency vs. its latest release), and each crate is
scanned independently — one crate's transient network failure surfaces as
an inline error for that crate, not a failed command. Supports --json,
--markdown (a ready-to-post GitHub comment body — see PR-comment mode
below), and --min-severity; exit code is the worst severity across every
changed crate, same scale as audit/diff.
| Signal | Severity | Notes |
|---|---|---|
unsafe fn / unsafe impl |
high | |
FFI (extern "C" { .. }) |
high | |
exported symbol (#[no_mangle] / #[export_name]) |
high | pins a symbol name so it's callable from outside the crate |
mem::transmute / transmute_copy |
high | reinterprets bytes across types; a common UB source even among "safe" unsafe usage |
process spawn (Command::new, incl. tokio::process::Command) |
high | |
build.rs present |
high | runs arbitrary code with full FS/network access on every build |
proc-macro crate (lib.proc-macro = true) |
high | runs arbitrary code at compile time |
native linkage (package.links) |
high | |
unsafe { .. } block |
medium | |
network access (TcpStream/TcpListener/UdpSocket/UnixStream, or a reqwest::/hyper::/ureq:: call) |
medium | |
filesystem write (fs::write, remove_dir_all, ...) |
medium | |
env::set_var / env::remove_var |
medium | |
env::var / env::var_os |
low | read-only |
env! / include! / include_str! / include_bytes! |
low | build-time macros |
New dependencies pulled in by the update are also reported and count as
medium severity toward the diff's worst-severity exit code.
Diffing keys signals on (kind, scope, detail), not file/line — a function
moving 50 lines down the file isn't a "new" signal, so updates with heavy
internal refactors don't drown you in noise.
tests/, benches/, and examples/ are skipped by default. None of it
is compiled when you depend on a crate, so a socket opened in a test says
nothing about the risk of taking an update. Pass --include-dev to scan it
anyway; signals are then tagged dev rather than shipped.
This matters more than it sounds. Across the 100 most-downloaded crates on
crates.io, counting test code inflates NetworkAccess from 92 signals to
227, and takes the number of crates that appear to touch the network from 3
to 7 — the extra four being serde_json, tempfile, syn, and h2, none
of which open a socket in anything you actually link.
build.rs is always scanned and always counts as shipped: it runs on your
machine at compile time, which makes it the most sensitive code in a
dependency, not the least. src/bin/ counts too — it ships to anyone who
cargo installs the crate.
action.yml at the root of this repo wraps cargo capscan audit as a
composite action, so any repo can gate CI on it without installing anything
by hand:
name: Dependency capability audit
on:
pull_request:
paths: ['**/Cargo.lock']
schedule:
- cron: '0 6 * * 1' # catch updates published on their own, not just yours
jobs:
capscan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: poglesbyg/capscan@v0.3.0
with:
fail-on: medium # 'high' | 'medium' | 'none' (report only)
# lockfile: path/to/Cargo.lock
# version: pin a specific capscan release; empty = latestThe action installs capscan via cargo install, runs cargo capscan audit,
and fails the job if the worst severity found is at or above fail-on
(default medium) — same severity scale as everywhere else in this README.
It exposes the raw exit code as the audit-exit-code output if you want
custom logic instead. This repo dogfoods it in its own
.github/workflows/ci.yml via uses: ./.
audit above answers "does this fail CI." pr-comment/action.yml answers a
narrower, more readable question: "what did this PR's dependency bumps
actually change" -- posted straight to the PR as a comment, no separate
webhook server or bot account needed, just the built-in GITHUB_TOKEN:
name: capscan PR comment
on:
pull_request:
paths: ['**/Cargo.lock']
permissions:
pull-requests: write
jobs:
capscan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: poglesbyg/capscan/pr-comment@v0.3.0
with:
min-severity: medium # optional: only show crates at/above this severity
# lockfile: path/to/Cargo.lock
# version: pin a specific capscan release; empty = latestIt diffs the PR's head Cargo.lock against the base branch's version at
github.event.pull_request.base.sha -- fetched directly via git fetch --depth=1 origin <base_sha> plus git show, so it works with the default
shallow actions/checkout@v4 checkout; no fetch-depth: 0 needed. Each run
edits the bot's own last comment (gh pr comment --edit-last --create-if-none) instead of piling up a new one per push. Uses
diff-lockfiles under the hood (see above), so the same per-crate resilience
applies: one crate's transient scan failure shows up as an inline error in
that crate's row, not a failed job.
Path classification is AST matching plus file-local use resolution, not
real name resolution — that would require compiling the crate. Call paths are
resolved through the imports of the file they appear in, so
use std::process::Command as Proc; Proc::new(..) is correctly read as a
process spawn, and use clap::Command; Command::new(..) is correctly read as
not one. Beyond that file's imports, though:
- A glob import (
use std::process::*) binds names capscan can't enumerate, so calls through it fall back to the unresolved default. - An unresolvable bare
Command::newis still reported as a process spawn. Under-reporting a capability is worse than a false positive, so ambiguity resolves toward flagging. - A re-export chain that the calling file never imports directly won't resolve.
- Anything generated by a proc-macro before your crate expands it is invisible — capscan reads the macro's own source, not what it expands to at your call sites.
Treat this as a fast heuristic triage step, not a proof of safety. It tells you where to look, not that everything else is fine.
cargo test # pure in-memory fixtures, no network
cargo test -- --ignored # also exercises the real crates.io fetch path