Skip to content

Repository files navigation

capscan

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

Install

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

Usage

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.

What it detects

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.

What gets scanned

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.

Use as a GitHub Action

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 = latest

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

PR-comment mode

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 = latest

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

Limitations

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::new is 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.

Tests

cargo test              # pure in-memory fixtures, no network
cargo test -- --ignored # also exercises the real crates.io fetch path

About

Diff the capability surface (unsafe, FFI, process/network/fs access, build scripts) of a crate between two versions, before you cargo update.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages