From 939951a8c409290873f7bed08335d62a1786b476 Mon Sep 17 00:00:00 2001 From: telcharr Date: Mon, 15 Jun 2026 19:09:34 -0400 Subject: [PATCH 1/7] add snapshot subcommand --- crates/capdiff-core/src/snapshot.rs | 54 +++++++++++++++++++++++++++ crates/capdiff-core/tests/diff_e2e.rs | 27 ++++++++++++++ crates/cargo-capdiff/src/main.rs | 30 ++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/capdiff-core/src/snapshot.rs b/crates/capdiff-core/src/snapshot.rs index 1303f2f..414ecba 100644 --- a/crates/capdiff-core/src/snapshot.rs +++ b/crates/capdiff-core/src/snapshot.rs @@ -74,6 +74,23 @@ pub fn save(snapshot: &Snapshot, path: &Path) -> Result<(), SnapshotError> { Ok(()) } +pub fn from_audit(report: &crate::audit::AuditReport) -> Snapshot { + let mut crates: Vec = report + .findings + .iter() + .map(|f| SnapshotCrate { + name: f.krate.clone(), + version: f.version.clone(), + capabilities: f.capabilities.clone(), + }) + .collect(); + crates.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); + Snapshot { + version: CURRENT_VERSION, + crates, + } +} + #[cfg(test)] mod tests { use super::*; @@ -142,4 +159,41 @@ mod tests { let err = load(&path).unwrap_err(); assert!(matches!(err, SnapshotError::Parse(_))); } + + #[test] + fn from_audit_maps_findings_sorted() { + use crate::audit::{AuditFinding, AuditReport}; + let report = AuditReport { + version: 4, + crate_count: 2, + skipped_count: 0, + findings: vec![ + AuditFinding { + krate: "zlib".into(), + version: Version::new(1, 0, 0), + capabilities: BTreeSet::from([Capability::Ffi]), + severity: crate::severity::Severity::Notable, + evidence: vec![], + skipped_files: vec![], + }, + AuditFinding { + krate: "ahash".into(), + version: Version::new(0, 8, 11), + capabilities: BTreeSet::from([Capability::Env]), + severity: crate::severity::Severity::Notable, + evidence: vec![], + skipped_files: vec![], + }, + ], + }; + let snap = from_audit(&report); + assert_eq!(snap.version, CURRENT_VERSION); + assert_eq!(snap.crates.len(), 2); + assert_eq!(snap.crates[0].name, "ahash"); + assert_eq!(snap.crates[1].name, "zlib"); + assert_eq!( + snap.crates[0].capabilities, + BTreeSet::from([Capability::Env]) + ); + } } diff --git a/crates/capdiff-core/tests/diff_e2e.rs b/crates/capdiff-core/tests/diff_e2e.rs index 6a5460e..4b92f3a 100644 --- a/crates/capdiff-core/tests/diff_e2e.rs +++ b/crates/capdiff-core/tests/diff_e2e.rs @@ -188,3 +188,30 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" "working tree matches HEAD, expected no findings; got: {report:#?}" ); } + +#[test] +fn snapshot_round_trip_yields_no_findings() { + let project: PathBuf = ["tests", "fixtures", "audit-suspicious"].iter().collect(); + let cache = Cache::new(cache_root()); + + let report = capdiff_core::audit::audit_project(&project, &cache).unwrap(); + let snapshot = capdiff_core::snapshot::from_audit(&report); + + let dir = tempfile::tempdir().unwrap(); + let snapshot_path = dir.path().join("capdiff.lock"); + capdiff_core::snapshot::save(&snapshot, &snapshot_path).unwrap(); + + let diff = diff_project( + &project, + BaselineSource::Snapshot { + path: &snapshot_path, + }, + &cache, + ) + .unwrap(); + + assert!( + diff.findings.is_empty(), + "snapshot taken from the same tree should produce no gained capabilities; got: {diff:#?}" + ); +} diff --git a/crates/cargo-capdiff/src/main.rs b/crates/cargo-capdiff/src/main.rs index 362fb32..5d8ff73 100644 --- a/crates/cargo-capdiff/src/main.rs +++ b/crates/cargo-capdiff/src/main.rs @@ -24,6 +24,19 @@ struct Cli { enum Command { Diff(DiffArgs), Audit(AuditArgs), + Snapshot(SnapshotArgs), +} + +#[derive(clap::Args)] +struct SnapshotArgs { + #[arg(default_value = ".")] + path: PathBuf, + + #[arg(long, short = 'o', default_value = "capdiff.lock")] + output: PathBuf, + + #[arg(long)] + no_cache: bool, } #[derive(clap::Args)] @@ -110,7 +123,7 @@ fn main() -> ExitCode { } fn inject_default_subcommand(raw: &mut Vec) { - let subcommands = ["audit", "diff", "help"]; + let subcommands = ["audit", "diff", "snapshot", "help"]; let next = raw.get(1).map(String::as_str); let should_inject = match next { None => true, @@ -127,9 +140,24 @@ fn run(cli: Cli) -> Result> { match cli.command { Command::Audit(args) => run_audit(args), Command::Diff(args) => run_diff(args), + Command::Snapshot(args) => run_snapshot(args), } } +fn run_snapshot(args: SnapshotArgs) -> Result> { + let cache = build_cache(args.no_cache)?; + let report = audit_project(&args.path, &cache)?; + let snapshot = capdiff_core::snapshot::from_audit(&report); + capdiff_core::snapshot::save(&snapshot, &args.output)?; + println!( + "wrote {} crate{} to {}", + snapshot.crates.len(), + if snapshot.crates.len() == 1 { "" } else { "s" }, + args.output.display() + ); + Ok(ExitCode::SUCCESS) +} + fn run_audit(args: AuditArgs) -> Result> { let cache = build_cache(args.no_cache)?; let report = audit_project(&args.path, &cache)?; From 31b81506d2f6f83bda5c2c854f0dcd0676a821db Mon Sep 17 00:00:00 2001 From: telcharr Date: Mon, 15 Jun 2026 23:29:47 -0400 Subject: [PATCH 2/7] trace dependency paths for findings --- crates/capdiff-core/src/audit.rs | 12 +++++- crates/capdiff-core/src/diff.rs | 5 +++ crates/capdiff-core/src/resolve.rs | 56 +++++++++++++++++++++++++- crates/capdiff-core/src/snapshot.rs | 2 + crates/capdiff-core/tests/audit_e2e.rs | 5 +++ crates/cargo-capdiff/src/main.rs | 14 ++++++- 6 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/capdiff-core/src/audit.rs b/crates/capdiff-core/src/audit.rs index fd06fd4..00752bb 100644 --- a/crates/capdiff-core/src/audit.rs +++ b/crates/capdiff-core/src/audit.rs @@ -28,6 +28,8 @@ pub struct AuditFinding { pub evidence: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub skipped_files: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dep_path: Vec, } #[derive(Debug)] @@ -76,7 +78,7 @@ impl From for AuditError { } pub fn audit_project(project_root: &Path, cache: &Cache) -> Result { - let resolved = crate::resolve::resolve_lockfile(project_root)?; + let (resolved, dep_paths) = crate::resolve::resolve_and_paths(project_root)?; let crate_count = resolved.len(); let findings: Result, AuditError> = resolved @@ -85,6 +87,12 @@ pub fn audit_project(project_root: &Path, cache: &Cache) -> Result, pub severity: Severity, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dep_path: Vec, } #[derive(Debug, Clone, Serialize)] @@ -180,6 +182,7 @@ pub fn diff_project( new, gained, severity, + dep_path: f.dep_path, }); } @@ -346,6 +349,7 @@ checksum = "abc" }, gained: BTreeSet::from([Capability::Net]), severity: Severity::Notable, + dep_path: vec![], }; let v = serde_json::to_value(&f).unwrap(); assert_eq!(v["crate"], "ahash"); @@ -395,6 +399,7 @@ checksum = "abc" }, gained: BTreeSet::new(), severity: Severity::Info, + dep_path: vec![], }; let v = serde_json::to_value(&f).unwrap(); assert_eq!(v["old"], serde_json::Value::Null); diff --git a/crates/capdiff-core/src/resolve.rs b/crates/capdiff-core/src/resolve.rs index 229eb39..b7fe0fb 100644 --- a/crates/capdiff-core/src/resolve.rs +++ b/crates/capdiff-core/src/resolve.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::path::{Path, PathBuf}; use semver::Version; @@ -44,6 +44,12 @@ impl std::error::Error for ResolveError { } pub fn resolve_lockfile(project_root: &Path) -> Result, ResolveError> { + Ok(resolve_and_paths(project_root)?.0) +} + +pub fn resolve_and_paths( + project_root: &Path, +) -> Result<(Vec, BTreeMap<(String, Version), Vec>), ResolveError> { let mut cmd = guppy::MetadataCommand::new(); cmd.manifest_path(project_root.join("Cargo.toml")); let graph = cmd @@ -53,6 +59,8 @@ pub fn resolve_lockfile(project_root: &Path) -> Result, Resolve let checksums = parse_lockfile_checksums(project_root).map_err(ResolveError::Metadata)?; let workspace_root = graph.workspace().root().as_std_path().to_path_buf(); + let paths = dependency_paths(&graph); + let mut out = Vec::new(); for pkg in graph.packages() { if pkg.in_workspace() { @@ -70,7 +78,51 @@ pub fn resolve_lockfile(project_root: &Path) -> Result, Resolve } out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version))); - Ok(out) + Ok((out, paths)) +} + +pub(crate) fn dependency_paths( + graph: &guppy::graph::PackageGraph, +) -> BTreeMap<(String, Version), Vec> { + let mut map: BTreeMap<(String, Version), Vec> = BTreeMap::new(); + let mut visited: std::collections::HashSet<(String, Version)> = + std::collections::HashSet::new(); + let mut queue: VecDeque<(guppy::graph::PackageMetadata<'_>, Vec)> = VecDeque::new(); + + for member in graph.workspace().iter() { + for link in member.direct_links() { + let dep = link.to(); + if dep.in_workspace() { + continue; + } + let path = vec![dep.name().to_string()]; + queue.push_back((dep, path)); + } + } + + while let Some((pkg, path)) = queue.pop_front() { + let key = (pkg.name().to_string(), pkg.version().clone()); + if visited.contains(&key) { + continue; + } + visited.insert(key.clone()); + map.insert(key, path.clone()); + + for link in pkg.direct_links() { + let dep = link.to(); + if dep.in_workspace() { + continue; + } + let dep_key = (dep.name().to_string(), dep.version().clone()); + if !visited.contains(&dep_key) { + let mut child_path = path.clone(); + child_path.push(dep.name().to_string()); + queue.push_back((dep, child_path)); + } + } + } + + map } fn classify_source( diff --git a/crates/capdiff-core/src/snapshot.rs b/crates/capdiff-core/src/snapshot.rs index 414ecba..9e54080 100644 --- a/crates/capdiff-core/src/snapshot.rs +++ b/crates/capdiff-core/src/snapshot.rs @@ -175,6 +175,7 @@ mod tests { severity: crate::severity::Severity::Notable, evidence: vec![], skipped_files: vec![], + dep_path: vec![], }, AuditFinding { krate: "ahash".into(), @@ -183,6 +184,7 @@ mod tests { severity: crate::severity::Severity::Notable, evidence: vec![], skipped_files: vec![], + dep_path: vec![], }, ], }; diff --git a/crates/capdiff-core/tests/audit_e2e.rs b/crates/capdiff-core/tests/audit_e2e.rs index 685a113..80ebbe1 100644 --- a/crates/capdiff-core/tests/audit_e2e.rs +++ b/crates/capdiff-core/tests/audit_e2e.rs @@ -50,6 +50,11 @@ fn audit_suspicious_fixture_flags_high() { ); assert!(badnet.capabilities.contains(&Capability::Net)); assert!(badnet.capabilities.contains(&Capability::BuildScript)); + assert_eq!( + badnet.dep_path, + vec!["badnet".to_string()], + "badnet is a direct path dep of the fixture workspace" + ); } #[test] diff --git a/crates/cargo-capdiff/src/main.rs b/crates/cargo-capdiff/src/main.rs index 5d8ff73..d4a054b 100644 --- a/crates/cargo-capdiff/src/main.rs +++ b/crates/cargo-capdiff/src/main.rs @@ -262,8 +262,13 @@ fn print_human_audit(report: &AuditReport) { .filter(|c| !(**c == Capability::BuildScript && any_buildscript_evidence)) .map(|c| c.as_str()) .collect(); + let via = if finding.dep_path.len() > 1 { + format!(" (via {})", finding.dep_path.join(" -> ")) + } else { + String::new() + }; println!( - "[{sev}] {name} {ver} ({caps})", + "[{sev}] {name} {ver} ({caps}){via}", sev = finding.severity.as_str(), name = finding.krate, ver = finding.version, @@ -316,8 +321,13 @@ fn print_human_diff(report: &DiffReport) { .filter(|c| !(**c == Capability::BuildScript && any_buildscript_evidence)) .map(|c| c.as_str()) .collect(); + let via = if finding.dep_path.len() > 1 { + format!(" (via {})", finding.dep_path.join(" -> ")) + } else { + String::new() + }; println!( - "[{sev}] {name} {from} -> {to} gained: {gained}", + "[{sev}] {name} {from} -> {to} gained: {gained}{via}", sev = finding.severity.as_str(), name = finding.krate, from = from, From a2352287c50553da6aa05f0234ce39060d4a8eff Mon Sep 17 00:00:00 2001 From: telcharr Date: Wed, 17 Jun 2026 19:49:46 -0400 Subject: [PATCH 3/7] add capdiff.toml for suppressions and severity overrides --- crates/capdiff-core/src/audit.rs | 26 ++- crates/capdiff-core/src/config.rs | 260 +++++++++++++++++++++++++ crates/capdiff-core/src/diff.rs | 14 +- crates/capdiff-core/src/lib.rs | 1 + crates/capdiff-core/src/resolve.rs | 4 +- crates/capdiff-core/src/snapshot.rs | 1 + crates/capdiff-core/tests/audit_e2e.rs | 47 +++++ crates/cargo-capdiff/src/main.rs | 36 +++- 8 files changed, 384 insertions(+), 5 deletions(-) create mode 100644 crates/capdiff-core/src/config.rs diff --git a/crates/capdiff-core/src/audit.rs b/crates/capdiff-core/src/audit.rs index 00752bb..323bf33 100644 --- a/crates/capdiff-core/src/audit.rs +++ b/crates/capdiff-core/src/audit.rs @@ -7,6 +7,7 @@ use serde::Serialize; use crate::cache::Cache; use crate::capability::Capability; +use crate::config::Config; use crate::fingerprint::Evidence; use crate::severity::Severity; @@ -15,6 +16,7 @@ pub struct AuditReport { pub version: u32, pub crate_count: usize, pub skipped_count: usize, + pub suppressed_count: usize, pub findings: Vec, } @@ -78,6 +80,14 @@ impl From for AuditError { } pub fn audit_project(project_root: &Path, cache: &Cache) -> Result { + audit_project_with_config(project_root, cache, &Config::default()) +} + +pub fn audit_project_with_config( + project_root: &Path, + cache: &Cache, + config: &Config, +) -> Result { let (resolved, dep_paths) = crate::resolve::resolve_and_paths(project_root)?; let crate_count = resolved.len(); @@ -93,6 +103,12 @@ pub fn audit_project(project_root: &Path, cache: &Cache) -> Result Result, + #[serde(default, rename = "override")] + pub overrides: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Suppression { + #[serde(rename = "crate")] + pub krate: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SeverityOverride { + #[serde(rename = "crate")] + pub krate: String, + #[serde(default)] + pub version: Option, + pub severity: Severity, +} + +#[derive(Debug)] +pub enum ConfigError { + Io(std::io::Error), + Parse(toml::de::Error), + BadVersionReq { + value: String, + source: semver::Error, + }, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "io error: {e}"), + Self::Parse(e) => write!(f, "parse error: {e}"), + Self::BadVersionReq { value, .. } => { + write!(f, "bad version requirement: {value}") + } + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Parse(e) => Some(e), + Self::BadVersionReq { source, .. } => Some(source), + } + } +} + +impl From for ConfigError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +pub fn load(project_root: &Path) -> Result { + let path = project_root.join("capdiff.toml"); + if !path.exists() { + return Ok(Config::default()); + } + let text = std::fs::read_to_string(&path)?; + let config: Config = toml::from_str(&text).map_err(ConfigError::Parse)?; + + for s in &config.suppress { + if let Some(v) = &s.version { + VersionReq::parse(v).map_err(|source| ConfigError::BadVersionReq { + value: v.clone(), + source, + })?; + } + } + for o in &config.overrides { + if let Some(v) = &o.version { + VersionReq::parse(v).map_err(|source| ConfigError::BadVersionReq { + value: v.clone(), + source, + })?; + } + } + + Ok(config) +} + +fn version_matches(req: &Option, version: &Version) -> bool { + match req { + None => true, + Some(s) => VersionReq::parse(s) + .map(|r| r.matches(version)) + .unwrap_or(false), + } +} + +impl Config { + pub fn is_suppressed(&self, krate: &str, version: &Version) -> bool { + self.suppress + .iter() + .any(|s| s.krate == krate && version_matches(&s.version, version)) + } + + pub fn severity_override(&self, krate: &str, version: &Version) -> Option { + self.overrides + .iter() + .rfind(|o| o.krate == krate && version_matches(&o.version, version)) + .map(|o| o.severity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use semver::Version; + + const FULL_CONFIG: &str = r#" +[[suppress]] +crate = "ring" +version = ">=0.17, <0.18" +reason = "vendored asm, reviewed" + +[[suppress]] +crate = "libc" + +[[override]] +crate = "some-crate" +severity = "Notable" +version = "=1.2.3" + +[[override]] +crate = "other-crate" +severity = "High" +"#; + + #[test] + fn parse_full_config() { + let config: Config = toml::from_str(FULL_CONFIG).unwrap(); + assert_eq!(config.suppress.len(), 2); + assert_eq!(config.suppress[0].krate, "ring"); + assert_eq!(config.suppress[0].version.as_deref(), Some(">=0.17, <0.18")); + assert_eq!( + config.suppress[0].reason.as_deref(), + Some("vendored asm, reviewed") + ); + assert_eq!(config.suppress[1].krate, "libc"); + assert!(config.suppress[1].version.is_none()); + assert_eq!(config.overrides.len(), 2); + assert_eq!(config.overrides[0].krate, "some-crate"); + assert_eq!(config.overrides[0].severity, Severity::Notable); + assert_eq!(config.overrides[0].version.as_deref(), Some("=1.2.3")); + assert_eq!(config.overrides[1].krate, "other-crate"); + assert_eq!(config.overrides[1].severity, Severity::High); + assert!(config.overrides[1].version.is_none()); + } + + #[test] + fn load_missing_file_returns_default() { + let dir = tempfile::tempdir().unwrap(); + let config = load(dir.path()).unwrap(); + assert!(config.suppress.is_empty()); + assert!(config.overrides.is_empty()); + } + + #[test] + fn load_malformed_toml_returns_parse_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("capdiff.toml"), "[[not valid toml{{").unwrap(); + assert!(matches!(load(dir.path()), Err(ConfigError::Parse(_)))); + } + + #[test] + fn load_bad_version_req_returns_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("capdiff.toml"), + "[[suppress]]\ncrate = \"foo\"\nversion = \"not-a-req\"\n", + ) + .unwrap(); + assert!(matches!( + load(dir.path()), + Err(ConfigError::BadVersionReq { .. }) + )); + } + + #[test] + fn is_suppressed_all_versions() { + let config: Config = toml::from_str("[[suppress]]\ncrate = \"libc\"\n").unwrap(); + assert!(config.is_suppressed("libc", &Version::new(0, 2, 150))); + assert!(config.is_suppressed("libc", &Version::new(1, 0, 0))); + assert!(!config.is_suppressed("ring", &Version::new(0, 17, 0))); + } + + #[test] + fn is_suppressed_version_req_match_and_no_match() { + let config: Config = + toml::from_str("[[suppress]]\ncrate = \"ring\"\nversion = \">=0.17, <0.18\"\n") + .unwrap(); + assert!(config.is_suppressed("ring", &Version::new(0, 17, 5))); + assert!(!config.is_suppressed("ring", &Version::new(0, 18, 0))); + assert!(!config.is_suppressed("ring", &Version::new(0, 16, 0))); + } + + #[test] + fn is_suppressed_name_mismatch() { + let config: Config = toml::from_str("[[suppress]]\ncrate = \"libc\"\n").unwrap(); + assert!(!config.is_suppressed("ring", &Version::new(0, 17, 0))); + } + + #[test] + fn severity_override_match() { + let config: Config = + toml::from_str("[[override]]\ncrate = \"foo\"\nseverity = \"Notable\"\n").unwrap(); + assert_eq!( + config.severity_override("foo", &Version::new(1, 0, 0)), + Some(Severity::Notable) + ); + } + + #[test] + fn severity_override_last_wins() { + let toml = r#" +[[override]] +crate = "foo" +severity = "Info" + +[[override]] +crate = "foo" +severity = "High" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!( + config.severity_override("foo", &Version::new(1, 0, 0)), + Some(Severity::High) + ); + } + + #[test] + fn severity_override_no_match() { + let config: Config = + toml::from_str("[[override]]\ncrate = \"foo\"\nseverity = \"High\"\n").unwrap(); + assert_eq!( + config.severity_override("bar", &Version::new(1, 0, 0)), + None + ); + } +} diff --git a/crates/capdiff-core/src/diff.rs b/crates/capdiff-core/src/diff.rs index 04dd5f3..8415aa3 100644 --- a/crates/capdiff-core/src/diff.rs +++ b/crates/capdiff-core/src/diff.rs @@ -7,6 +7,7 @@ use serde::Serialize; use crate::cache::Cache; use crate::capability::Capability; +use crate::config::Config; use crate::fingerprint::{CrateFingerprint, Evidence}; use crate::severity::{Severity, classify}; @@ -14,6 +15,7 @@ use crate::severity::{Severity, classify}; pub struct DiffReport { pub version: u32, pub skipped_count: usize, + pub suppressed_count: usize, pub findings: Vec, } @@ -109,7 +111,16 @@ pub fn diff_project( baseline: BaselineSource, cache: &Cache, ) -> Result { - let current = crate::audit::audit_project(project_root, cache)?; + diff_project_with_config(project_root, baseline, cache, &Config::default()) +} + +pub fn diff_project_with_config( + project_root: &Path, + baseline: BaselineSource, + cache: &Cache, + config: &Config, +) -> Result { + let current = crate::audit::audit_project_with_config(project_root, cache, config)?; let baseline_map: BaselineMap = match baseline { BaselineSource::GitHead { project_root: root } => from_git_head(root, cache)?, @@ -196,6 +207,7 @@ pub fn diff_project( Ok(DiffReport { version: 3, skipped_count: current.skipped_count, + suppressed_count: current.suppressed_count, findings, }) } diff --git a/crates/capdiff-core/src/lib.rs b/crates/capdiff-core/src/lib.rs index 615d250..479fb5b 100644 --- a/crates/capdiff-core/src/lib.rs +++ b/crates/capdiff-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod audit; pub mod cache; pub mod capability; +pub mod config; pub(crate) mod dataflow; pub mod diff; pub mod extract; diff --git a/crates/capdiff-core/src/resolve.rs b/crates/capdiff-core/src/resolve.rs index b7fe0fb..713ffe9 100644 --- a/crates/capdiff-core/src/resolve.rs +++ b/crates/capdiff-core/src/resolve.rs @@ -43,13 +43,15 @@ impl std::error::Error for ResolveError { } } +pub type DependencyPaths = BTreeMap<(String, Version), Vec>; + pub fn resolve_lockfile(project_root: &Path) -> Result, ResolveError> { Ok(resolve_and_paths(project_root)?.0) } pub fn resolve_and_paths( project_root: &Path, -) -> Result<(Vec, BTreeMap<(String, Version), Vec>), ResolveError> { +) -> Result<(Vec, DependencyPaths), ResolveError> { let mut cmd = guppy::MetadataCommand::new(); cmd.manifest_path(project_root.join("Cargo.toml")); let graph = cmd diff --git a/crates/capdiff-core/src/snapshot.rs b/crates/capdiff-core/src/snapshot.rs index 9e54080..fc87e14 100644 --- a/crates/capdiff-core/src/snapshot.rs +++ b/crates/capdiff-core/src/snapshot.rs @@ -167,6 +167,7 @@ mod tests { version: 4, crate_count: 2, skipped_count: 0, + suppressed_count: 0, findings: vec![ AuditFinding { krate: "zlib".into(), diff --git a/crates/capdiff-core/tests/audit_e2e.rs b/crates/capdiff-core/tests/audit_e2e.rs index 80ebbe1..0bd1cbb 100644 --- a/crates/capdiff-core/tests/audit_e2e.rs +++ b/crates/capdiff-core/tests/audit_e2e.rs @@ -102,3 +102,50 @@ fn audit_json_output_has_expected_shape() { } } } + +#[test] +fn config_suppresses_finding_and_counts_it() { + use capdiff_core::audit::audit_project_with_config; + use capdiff_core::config::{Config, Suppression}; + + let project: PathBuf = ["tests", "fixtures", "audit-suspicious"].iter().collect(); + let cache = Cache::new(cache_root()); + let config = Config { + suppress: vec![Suppression { + krate: "badnet".into(), + version: None, + reason: Some("test".into()), + }], + overrides: vec![], + }; + let report = audit_project_with_config(&project, &cache, &config).unwrap(); + assert!( + report.findings.iter().all(|f| f.krate != "badnet"), + "badnet should be suppressed; report: {report:#?}" + ); + assert_eq!(report.suppressed_count, 1); +} + +#[test] +fn config_overrides_severity() { + use capdiff_core::audit::audit_project_with_config; + use capdiff_core::config::{Config, SeverityOverride}; + + let project: PathBuf = ["tests", "fixtures", "audit-suspicious"].iter().collect(); + let cache = Cache::new(cache_root()); + let config = Config { + suppress: vec![], + overrides: vec![SeverityOverride { + krate: "badnet".into(), + version: None, + severity: Severity::Notable, + }], + }; + let report = audit_project_with_config(&project, &cache, &config).unwrap(); + let badnet = report + .findings + .iter() + .find(|f| f.krate == "badnet") + .expect("badnet present"); + assert_eq!(badnet.severity, Severity::Notable); +} diff --git a/crates/cargo-capdiff/src/main.rs b/crates/cargo-capdiff/src/main.rs index d4a054b..b7548ab 100644 --- a/crates/cargo-capdiff/src/main.rs +++ b/crates/cargo-capdiff/src/main.rs @@ -58,6 +58,9 @@ struct DiffArgs { #[arg(long)] strict: bool, + + #[arg(long)] + no_config: bool, } #[derive(clap::Args)] @@ -76,6 +79,9 @@ struct AuditArgs { #[arg(long)] strict: bool, + + #[arg(long)] + no_config: bool, } #[derive(Clone, Copy, ValueEnum)] @@ -158,9 +164,21 @@ fn run_snapshot(args: SnapshotArgs) -> Result Result> { + if no_config { + Ok(capdiff_core::config::Config::default()) + } else { + Ok(capdiff_core::config::load(project_root)?) + } +} + fn run_audit(args: AuditArgs) -> Result> { let cache = build_cache(args.no_cache)?; - let report = audit_project(&args.path, &cache)?; + let config = load_config(&args.path, args.no_config)?; + let report = capdiff_core::audit::audit_project_with_config(&args.path, &cache, &config)?; match args.format { Format::Human => print_human_audit(&report), Format::Json => println!("{}", serde_json::to_string_pretty(&report)?), @@ -186,13 +204,15 @@ fn run_audit(args: AuditArgs) -> Result> { fn run_diff(args: DiffArgs) -> Result> { let cache = build_cache(args.no_cache)?; + let config = load_config(&args.path, args.no_config)?; let baseline = match args.baseline.as_deref() { Some(p) => capdiff_core::diff::BaselineSource::Snapshot { path: p }, None => capdiff_core::diff::BaselineSource::GitHead { project_root: &args.path, }, }; - let report = capdiff_core::diff::diff_project(&args.path, baseline, &cache)?; + let report = + capdiff_core::diff::diff_project_with_config(&args.path, baseline, &cache, &config)?; match args.format { Format::Human => print_human_diff(&report), Format::Json => println!("{}", serde_json::to_string_pretty(&report)?), @@ -300,6 +320,12 @@ fn print_human_audit(report: &AuditReport) { report.skipped_count ); } + if report.suppressed_count > 0 { + println!( + "{} finding(s) suppressed by capdiff.toml", + report.suppressed_count + ); + } } fn print_human_diff(report: &DiffReport) { @@ -358,6 +384,12 @@ fn print_human_diff(report: &DiffReport) { report.skipped_count ); } + if report.suppressed_count > 0 { + println!( + "{} finding(s) suppressed by capdiff.toml", + report.suppressed_count + ); + } } fn print_sarif_audit(report: &AuditReport) -> Result<(), Box> { From 70851075dd2662b79cfc67dfe40d4a2856c40977 Mon Sep 17 00:00:00 2001 From: telcharr Date: Wed, 17 Jun 2026 21:41:23 -0400 Subject: [PATCH 4/7] add explain subcommand and severity colors --- Cargo.lock | 30 +++++- crates/capdiff-core/src/severity.rs | 98 ++++++++++++++---- crates/cargo-capdiff/Cargo.toml | 2 + crates/cargo-capdiff/src/main.rs | 148 ++++++++++++++++++++++++---- 4 files changed, 239 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb5140b..eddbd7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstream" version = "1.0.0" @@ -28,7 +43,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -42,6 +57,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-parse" version = "1.0.0" @@ -170,6 +194,8 @@ dependencies = [ name = "cargo-capdiff" version = "0.1.0" dependencies = [ + "anstream 0.6.21", + "anstyle", "capdiff-core", "clap", "serde_json", @@ -257,7 +283,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim", diff --git a/crates/capdiff-core/src/severity.rs b/crates/capdiff-core/src/severity.rs index 05335e4..af3c493 100644 --- a/crates/capdiff-core/src/severity.rs +++ b/crates/capdiff-core/src/severity.rs @@ -38,34 +38,50 @@ impl std::str::FromStr for Severity { } } -pub fn classify(fingerprint: &CrateFingerprint) -> Severity { - let caps = &fingerprint.capabilities; - let evidence = &fingerprint.evidence; +use std::collections::BTreeSet; - // Net | Process | Env | FsSensitive evidence inside a build script. - let buildscript_high_kinds = [ +use crate::fingerprint::Evidence; + +// Net | Process | Env | FsSensitive evidence inside a build script. +fn buildscript_high(evidence: &[Evidence]) -> bool { + let kinds = [ Capability::Net, Capability::Process, Capability::Env, Capability::FsSensitive, ]; - let buildscript_high = evidence + evidence .iter() - .any(|e| e.in_build_script && buildscript_high_kinds.contains(&e.capability)); + .any(|e| e.in_build_script && kinds.contains(&e.capability)) +} - // Net + credential access: exfiltration shape. - let exfil_shape = caps.contains(&Capability::Net) - && (caps.contains(&Capability::Env) || caps.contains(&Capability::FsSensitive)); +// Net + credential access: exfiltration shape. +fn exfil_shape(caps: &BTreeSet) -> bool { + caps.contains(&Capability::Net) + && (caps.contains(&Capability::Env) || caps.contains(&Capability::FsSensitive)) +} + +// Obfuscated payload paired with execution primitive: smuggled-loader shape. +fn obfuscated_action(caps: &BTreeSet) -> bool { + caps.contains(&Capability::ObfuscatedBlob) + && (caps.contains(&Capability::Process) || caps.contains(&Capability::FnPtrTransmute)) +} - // Obfuscated payload paired with execution primitive: smuggled-loader shape. - let obfuscated_action = caps.contains(&Capability::ObfuscatedBlob) - && (caps.contains(&Capability::Process) || caps.contains(&Capability::FnPtrTransmute)); +// Proc-macro doing Net or Process at compile time. +fn proc_macro_active(caps: &BTreeSet) -> bool { + caps.contains(&Capability::ProcMacro) + && (caps.contains(&Capability::Net) || caps.contains(&Capability::Process)) +} - // Proc-macro doing Net or Process at compile time. - let proc_macro_active = caps.contains(&Capability::ProcMacro) - && (caps.contains(&Capability::Net) || caps.contains(&Capability::Process)); +pub fn classify(fingerprint: &CrateFingerprint) -> Severity { + let caps = &fingerprint.capabilities; + let evidence = &fingerprint.evidence; - if buildscript_high || exfil_shape || obfuscated_action || proc_macro_active { + if buildscript_high(evidence) + || exfil_shape(caps) + || obfuscated_action(caps) + || proc_macro_active(caps) + { return Severity::High; } @@ -86,6 +102,27 @@ pub fn classify(fingerprint: &CrateFingerprint) -> Severity { Severity::Info } +pub fn reasons_for(caps: &BTreeSet, evidence: &[Evidence]) -> Vec<&'static str> { + let mut out = Vec::new(); + if buildscript_high(evidence) { + out.push("network, process, env, or credential access inside a build script"); + } + if exfil_shape(caps) { + out.push("network access plus environment or credential access (exfiltration shape)"); + } + if obfuscated_action(caps) { + out.push("an obfuscated blob alongside a process spawn or function-pointer transmute"); + } + if proc_macro_active(caps) { + out.push("a proc-macro performing network or process work at compile time"); + } + out +} + +pub fn reasons(fingerprint: &CrateFingerprint) -> Vec<&'static str> { + reasons_for(&fingerprint.capabilities, &fingerprint.evidence) +} + #[cfg(test)] mod tests { use super::*; @@ -308,4 +345,31 @@ mod tests { assert!(Severity::Info < Severity::Notable); assert!(Severity::Notable < Severity::High); } + + #[test] + fn reasons_net_in_buildscript() { + let f = fp( + &[Capability::Net, Capability::BuildScript], + vec![ev(Capability::Net, true), ev(Capability::BuildScript, true)], + ); + let r = reasons(&f); + assert_eq!(r.len(), 1); + assert!(r[0].contains("build script")); + } + + #[test] + fn reasons_exfil_shape() { + let f = fp( + &[Capability::Net, Capability::Env], + vec![ev(Capability::Net, false), ev(Capability::Env, false)], + ); + let r = reasons(&f); + assert!(r.iter().any(|s| s.contains("exfiltration"))); + } + + #[test] + fn reasons_empty_for_lone_fs_read() { + let f = fp(&[Capability::FsRead], vec![ev(Capability::FsRead, false)]); + assert!(reasons(&f).is_empty()); + } } diff --git a/crates/cargo-capdiff/Cargo.toml b/crates/cargo-capdiff/Cargo.toml index e2f03c1..8ff5e61 100644 --- a/crates/cargo-capdiff/Cargo.toml +++ b/crates/cargo-capdiff/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" capdiff-core = { path = "../capdiff-core", version = "0.1.0" } clap = { version = "4", features = ["derive"] } serde_json = "1" +anstyle = "1" +anstream = "0.6" [lints] workspace = true diff --git a/crates/cargo-capdiff/src/main.rs b/crates/cargo-capdiff/src/main.rs index b7548ab..88b8acc 100644 --- a/crates/cargo-capdiff/src/main.rs +++ b/crates/cargo-capdiff/src/main.rs @@ -1,3 +1,4 @@ +use std::io::Write as _; use std::path::PathBuf; use std::process::ExitCode; @@ -9,6 +10,27 @@ use capdiff_core::capability::Capability; use capdiff_core::diff::DiffReport; use capdiff_core::severity::Severity; +fn severity_style(sev: Severity) -> anstyle::Style { + use anstyle::AnsiColor; + match sev { + Severity::High => anstyle::Style::new() + .fg_color(Some(AnsiColor::Red.into())) + .bold(), + Severity::Notable => anstyle::Style::new().fg_color(Some(AnsiColor::Yellow.into())), + Severity::Info => anstyle::Style::new().dimmed(), + } +} + +fn sev_tag(sev: Severity) -> String { + let style = severity_style(sev); + format!( + "{}[{}]{}", + style.render(), + sev.as_str(), + style.render_reset() + ) +} + #[derive(Parser)] #[command( name = "cargo-capdiff", @@ -25,6 +47,21 @@ enum Command { Diff(DiffArgs), Audit(AuditArgs), Snapshot(SnapshotArgs), + Explain(ExplainArgs), +} + +#[derive(clap::Args)] +struct ExplainArgs { + crate_name: String, + + #[arg(default_value = ".")] + path: PathBuf, + + #[arg(long)] + no_cache: bool, + + #[arg(long)] + no_config: bool, } #[derive(clap::Args)] @@ -129,7 +166,7 @@ fn main() -> ExitCode { } fn inject_default_subcommand(raw: &mut Vec) { - let subcommands = ["audit", "diff", "snapshot", "help"]; + let subcommands = ["audit", "diff", "snapshot", "explain", "help"]; let next = raw.get(1).map(String::as_str); let should_inject = match next { None => true, @@ -147,9 +184,70 @@ fn run(cli: Cli) -> Result> { Command::Audit(args) => run_audit(args), Command::Diff(args) => run_diff(args), Command::Snapshot(args) => run_snapshot(args), + Command::Explain(args) => run_explain(args), } } +fn run_explain(args: ExplainArgs) -> Result> { + let cache = build_cache(args.no_cache)?; + let config = load_config(&args.path, args.no_config)?; + let report = capdiff_core::audit::audit_project_with_config(&args.path, &cache, &config)?; + + let matches: Vec<_> = report + .findings + .iter() + .filter(|f| f.krate == args.crate_name) + .collect(); + if matches.is_empty() { + eprintln!("no finding for crate '{}'", args.crate_name); + return Ok(ExitCode::from(1)); + } + + let mut out = anstream::stdout(); + for finding in matches { + let caps: Vec<&str> = finding.capabilities.iter().map(|c| c.as_str()).collect(); + let _ = writeln!( + out, + "{tag} {name} {ver}", + tag = sev_tag(finding.severity), + name = finding.krate, + ver = finding.version, + ); + if finding.dep_path.len() > 1 { + let _ = writeln!(out, " reached via: {}", finding.dep_path.join(" -> ")); + } + let _ = writeln!(out, " capabilities: {}", caps.join(", ")); + + let reasons = capdiff_core::severity::reasons_for(&finding.capabilities, &finding.evidence); + if reasons.is_empty() { + let _ = writeln!(out, " no high-severity rule fired"); + } else { + let _ = writeln!(out, " flagged because:"); + for r in reasons { + let _ = writeln!(out, " - {r}"); + } + } + + for ev in &finding.evidence { + let bs = if ev.in_build_script { + " [buildscript]" + } else { + "" + }; + let _ = writeln!( + out, + " {cap} at {file}:{line}{bs} // {snippet}", + cap = ev.capability.as_str(), + file = ev.file.display(), + line = ev.line, + snippet = ev.snippet, + ); + } + let _ = writeln!(out); + } + Ok(ExitCode::SUCCESS) +} + fn run_snapshot(args: SnapshotArgs) -> Result> { let cache = build_cache(args.no_cache)?; let report = audit_project(&args.path, &cache)?; @@ -265,15 +363,16 @@ fn default_cache_dir() -> PathBuf { } fn print_human_audit(report: &AuditReport) { + let mut out = anstream::stdout(); let n = report.crate_count; - println!("audited {n} crate{}", if n == 1 { "" } else { "s" }); + let _ = writeln!(out, "audited {n} crate{}", if n == 1 { "" } else { "s" }); if report.findings.is_empty() { - println!("no findings"); + let _ = writeln!(out, "no findings"); return; } - println!(); + let _ = writeln!(out); for finding in &report.findings { let any_buildscript_evidence = finding.evidence.iter().any(|e| e.in_build_script); let caps: Vec<&str> = finding @@ -287,9 +386,10 @@ fn print_human_audit(report: &AuditReport) { } else { String::new() }; - println!( - "[{sev}] {name} {ver} ({caps}){via}", - sev = finding.severity.as_str(), + let _ = writeln!( + out, + "{tag} {name} {ver} ({caps}){via}", + tag = sev_tag(finding.severity), name = finding.krate, ver = finding.version, caps = caps.join(", "), @@ -300,7 +400,8 @@ fn print_human_audit(report: &AuditReport) { } else { "" }; - println!( + let _ = writeln!( + out, " {cap} at {file}:{line}{bs} // {snippet}", cap = ev.capability.as_str(), file = ev.file.display(), @@ -309,19 +410,21 @@ fn print_human_audit(report: &AuditReport) { ); } for f in &finding.skipped_files { - println!(" skipped: {}", f.display()); + let _ = writeln!(out, " skipped: {}", f.display()); } - println!(); + let _ = writeln!(out); } if report.skipped_count > 0 { - println!( + let _ = writeln!( + out, "{} source file(s) skipped as unparseable", report.skipped_count ); } if report.suppressed_count > 0 { - println!( + let _ = writeln!( + out, "{} finding(s) suppressed by capdiff.toml", report.suppressed_count ); @@ -329,8 +432,9 @@ fn print_human_audit(report: &AuditReport) { } fn print_human_diff(report: &DiffReport) { + let mut out = anstream::stdout(); if report.findings.is_empty() && report.skipped_count == 0 { - println!("no findings"); + let _ = writeln!(out, "no findings"); return; } for finding in &report.findings { @@ -352,9 +456,10 @@ fn print_human_diff(report: &DiffReport) { } else { String::new() }; - println!( - "[{sev}] {name} {from} -> {to} gained: {gained}{via}", - sev = finding.severity.as_str(), + let _ = writeln!( + out, + "{tag} {name} {from} -> {to} gained: {gained}{via}", + tag = sev_tag(finding.severity), name = finding.krate, from = from, to = to, @@ -366,7 +471,8 @@ fn print_human_diff(report: &DiffReport) { } else { "" }; - println!( + let _ = writeln!( + out, " {cap} at {file}:{line}{bs} // {snippet}", cap = ev.capability.as_str(), file = ev.file.display(), @@ -375,17 +481,19 @@ fn print_human_diff(report: &DiffReport) { snippet = ev.snippet, ); } - println!(); + let _ = writeln!(out); } if report.skipped_count > 0 { - println!( + let _ = writeln!( + out, "{} source file(s) skipped as unparseable", report.skipped_count ); } if report.suppressed_count > 0 { - println!( + let _ = writeln!( + out, "{} finding(s) suppressed by capdiff.toml", report.suppressed_count ); From 7d7afe269e99d06d165c169b8263ef39bdf9208a Mon Sep 17 00:00:00 2001 From: telcharr Date: Thu, 18 Jun 2026 00:33:39 -0400 Subject: [PATCH 5/7] bump schemas and version for 0.2.0 --- CHANGELOG.md | 15 ++++++++++ Cargo.lock | 4 +-- Cargo.toml | 2 +- README.md | 40 ++++++++++++++++++++++---- crates/capdiff-core/src/audit.rs | 6 ++-- crates/capdiff-core/src/diff.rs | 2 +- crates/capdiff-core/tests/audit_e2e.rs | 4 +-- crates/capdiff-core/tests/diff_e2e.rs | 6 ++-- crates/cargo-capdiff/Cargo.toml | 2 +- schema/audit.json | 9 ++++-- schema/diff.json | 11 +++++-- 11 files changed, 77 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2daf34..843cf89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.2.0 + +- `cargo capdiff snapshot` writes a `capdiff.lock` snapshot of the current + capability sets, for use as a reviewed diff baseline. +- `cargo capdiff explain ` deep-dives on one crate with severity rationale, + dependency path, and full evidence. +- Findings now carry the dependency path that pulled the crate in, shown as + `(via a -> b -> crate)` in human output and `dep_path` in JSON. +- `capdiff.toml` in the project root suppresses known-acceptable findings and + overrides severity per crate. `--no-config` ignores it. +- Severity tags are colorized in human output (red High, yellow Notable, dim + Info), auto-disabled on non-TTY and when `NO_COLOR` is set. +- JSON schemas bumped: audit version 5, diff version 4. Both add + `suppressed_count` (top-level) and optional `dep_path` (per finding). + ## 0.1.0 First release. diff --git a/Cargo.lock b/Cargo.lock index eddbd7e..3c53aa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,7 +169,7 @@ dependencies = [ [[package]] name = "capdiff-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "flate2", "guppy", @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "cargo-capdiff" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anstream 0.6.21", "anstyle", diff --git a/Cargo.toml b/Cargo.toml index 43f8f4e..c0e663b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/capdiff-core", "crates/cargo-capdiff"] [workspace.package] edition = "2024" -version = "0.1.0" +version = "0.2.0" rust-version = "1.85" license = "MIT OR Apache-2.0" repository = "https://github.com/telcharr/capdiff" diff --git a/README.md b/README.md index 2115767..4e5a76a 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,19 @@ Audit the whole tree with no baseline: cargo capdiff audit ``` -Diff against a reviewed snapshot instead of git HEAD: +Write a reviewed snapshot of the current capabilities, then diff against it later: ``` +cargo capdiff snapshot # writes capdiff.lock cargo capdiff --baseline capdiff.lock ``` +Dig into one crate to see why it was flagged, what it does, and how it entered your tree: + +``` +cargo capdiff explain ring +``` + ### Output formats `--format human` (default), `--format json`, or `--format sarif`. JSON conforms to @@ -57,9 +64,10 @@ code-scanning. JSON audit output: ```json { - "version": 4, + "version": 5, "crate_count": 1, "skipped_count": 0, + "suppressed_count": 0, "findings": [ { "crate": "badnet", @@ -83,14 +91,34 @@ code-scanning. JSON audit output: ### Flags - `--format {human|json|sarif}` -- `--fail-on {none|notable|high}` -- exit `1` when a finding reaches the threshold +- `--fail-on {none|notable|high}`: exit `1` when a finding reaches the threshold (default `high` for diff, `none` for audit) -- `--baseline ` -- diff against a committed `capdiff.lock` snapshot -- `--strict` -- exit `2` if any source file could not be parsed -- `--no-cache` -- do not persist the fetch/extract cache +- `--baseline `: diff against a committed `capdiff.lock` snapshot +- `--strict`: exit `2` if any source file could not be parsed +- `--no-cache`: do not persist the fetch/extract cache +- `--no-config`: ignore `capdiff.toml` Exit codes: `0` clean, `1` findings at or above `--fail-on`, `2` usage, IO, or strict failure. +### Suppressing known findings + +Drop a `capdiff.toml` next to your `Cargo.toml` to silence findings you have +reviewed and accepted, or to pin a crate's severity: + +```toml +[[suppress]] +crate = "ring" +version = ">=0.17, <0.18" # optional; omit to match all versions +reason = "vendored asm, reviewed" + +[[override]] +crate = "some-build-tool" +severity = "Notable" +``` + +Suppressed findings leave the report but the count is still shown, so nothing +disappears silently. + ## What it looks for Each dependency version fingerprints to a small capability set: diff --git a/crates/capdiff-core/src/audit.rs b/crates/capdiff-core/src/audit.rs index 323bf33..eba1b10 100644 --- a/crates/capdiff-core/src/audit.rs +++ b/crates/capdiff-core/src/audit.rs @@ -125,7 +125,7 @@ pub fn audit_project_with_config( findings.retain(|f| !config.is_suppressed(&f.krate, &f.version)); Ok(AuditReport { - version: 4, + version: 5, crate_count, skipped_count, suppressed_count, @@ -172,7 +172,7 @@ mod tests { #[test] fn report_is_serializable() { let report = AuditReport { - version: 4, + version: 5, crate_count: 0, skipped_count: 0, suppressed_count: 0, @@ -181,7 +181,7 @@ mod tests { let v = serde_json::to_value(&report).unwrap(); assert_eq!( v, - serde_json::json!({"version": 4, "crate_count": 0, "skipped_count": 0, "suppressed_count": 0, "findings": []}) + serde_json::json!({"version": 5, "crate_count": 0, "skipped_count": 0, "suppressed_count": 0, "findings": []}) ); } diff --git a/crates/capdiff-core/src/diff.rs b/crates/capdiff-core/src/diff.rs index 8415aa3..8a384eb 100644 --- a/crates/capdiff-core/src/diff.rs +++ b/crates/capdiff-core/src/diff.rs @@ -205,7 +205,7 @@ pub fn diff_project_with_config( }); Ok(DiffReport { - version: 3, + version: 4, skipped_count: current.skipped_count, suppressed_count: current.suppressed_count, findings, diff --git a/crates/capdiff-core/tests/audit_e2e.rs b/crates/capdiff-core/tests/audit_e2e.rs index 0bd1cbb..1f92c1b 100644 --- a/crates/capdiff-core/tests/audit_e2e.rs +++ b/crates/capdiff-core/tests/audit_e2e.rs @@ -23,7 +23,7 @@ fn audit_sample_project_succeeds() { let project = repo_root().join("examples/sample-project"); let cache = Cache::new(cache_root()); let report = audit_project(&project, &cache).unwrap(); - assert_eq!(report.version, 4); + assert_eq!(report.version, 5); assert!(report.crate_count >= 1, "expected at least one dep"); assert!( report.crate_count == report.findings.len(), @@ -64,7 +64,7 @@ fn audit_json_output_has_expected_shape() { let report = audit_project(&project, &cache).unwrap(); let json = serde_json::to_value(&report).unwrap(); - assert_eq!(json["version"], 4); + assert_eq!(json["version"], 5); assert!(json["crate_count"].is_u64()); assert!(json["skipped_count"].is_u64()); assert!(json["findings"].is_array()); diff --git a/crates/capdiff-core/tests/diff_e2e.rs b/crates/capdiff-core/tests/diff_e2e.rs index 4b92f3a..bf814e7 100644 --- a/crates/capdiff-core/tests/diff_e2e.rs +++ b/crates/capdiff-core/tests/diff_e2e.rs @@ -25,7 +25,7 @@ fn diff_against_snapshot_flags_high_for_gained_net_in_buildscript() { ) .unwrap(); - assert_eq!(report.version, 3); + assert_eq!(report.version, 4); let badnet = report .findings .iter() @@ -59,7 +59,7 @@ fn diff_json_output_has_expected_shape() { .unwrap(); let v = serde_json::to_value(&report).unwrap(); - assert_eq!(v["version"], 3); + assert_eq!(v["version"], 4); assert!(v["findings"].is_array()); let valid_caps = [ @@ -182,7 +182,7 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" let cache = Cache::new(cache_root()); let report = diff_project(root, BaselineSource::GitHead { project_root: root }, &cache) .expect("git-head diff on a workspace should not error"); - assert_eq!(report.version, 3); + assert_eq!(report.version, 4); assert!( report.findings.is_empty(), "working tree matches HEAD, expected no findings; got: {report:#?}" diff --git a/crates/cargo-capdiff/Cargo.toml b/crates/cargo-capdiff/Cargo.toml index 8ff5e61..dde4213 100644 --- a/crates/cargo-capdiff/Cargo.toml +++ b/crates/cargo-capdiff/Cargo.toml @@ -15,7 +15,7 @@ name = "cargo-capdiff" path = "src/main.rs" [dependencies] -capdiff-core = { path = "../capdiff-core", version = "0.1.0" } +capdiff-core = { path = "../capdiff-core", version = "0.2.0" } clap = { version = "4", features = ["derive"] } serde_json = "1" anstyle = "1" diff --git a/schema/audit.json b/schema/audit.json index 4326e30..487b1bf 100644 --- a/schema/audit.json +++ b/schema/audit.json @@ -3,12 +3,13 @@ "$id": "https://github.com/telcharr/capdiff/schema/audit.json", "title": "capdiff audit report", "type": "object", - "required": ["version", "crate_count", "skipped_count", "findings"], + "required": ["version", "crate_count", "skipped_count", "suppressed_count", "findings"], "additionalProperties": false, "properties": { - "version": { "const": 4 }, + "version": { "const": 5 }, "crate_count": { "type": "integer", "minimum": 0 }, "skipped_count": { "type": "integer", "minimum": 0 }, + "suppressed_count": { "type": "integer", "minimum": 0 }, "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } @@ -35,6 +36,10 @@ "skipped_files": { "type": "array", "items": { "type": "string" } + }, + "dep_path": { + "type": "array", + "items": { "type": "string" } } } }, diff --git a/schema/diff.json b/schema/diff.json index fa7e9cc..82145cd 100644 --- a/schema/diff.json +++ b/schema/diff.json @@ -3,11 +3,12 @@ "$id": "https://github.com/telcharr/capdiff/schema/diff.json", "title": "capdiff diff report", "type": "object", - "required": ["version", "skipped_count", "findings"], + "required": ["version", "skipped_count", "suppressed_count", "findings"], "additionalProperties": false, "properties": { - "version": { "const": 3 }, + "version": { "const": 4 }, "skipped_count": { "type": "integer", "minimum": 0 }, + "suppressed_count": { "type": "integer", "minimum": 0 }, "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } @@ -27,7 +28,11 @@ "items": { "$ref": "#/$defs/capability" }, "uniqueItems": true }, - "severity": { "$ref": "#/$defs/severity" } + "severity": { "$ref": "#/$defs/severity" }, + "dep_path": { + "type": "array", + "items": { "type": "string" } + } } }, "summary": { From dc12257037a495e47a2bfa46a1355defa0e46afc Mon Sep 17 00:00:00 2001 From: telcharr Date: Thu, 18 Jun 2026 23:16:39 -0400 Subject: [PATCH 6/7] fix path traversal check for windows-style absolute paths --- crates/capdiff-core/src/extract.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/capdiff-core/src/extract.rs b/crates/capdiff-core/src/extract.rs index 9e214ee..1741608 100644 --- a/crates/capdiff-core/src/extract.rs +++ b/crates/capdiff-core/src/extract.rs @@ -36,11 +36,18 @@ impl From for ExtractError { } fn has_path_traversal(p: &Path) -> bool { - if p.is_absolute() { + // untrusted tar paths can be absolute under rules other than the host's, so + // is_absolute() isn't enough — e.g. windows treats /tmp/x as relative. check + // raw separators, a windows drive prefix, and `..` segments host-independently. + let s = p.to_string_lossy(); + let bytes = s.as_bytes(); + if bytes.first().is_some_and(|&b| b == b'/' || b == b'\\') { return true; } - p.components() - .any(|c| matches!(c, std::path::Component::ParentDir)) + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return true; + } + s.split(['/', '\\']).any(|seg| seg == "..") } pub fn extract_crate(tarball: &[u8], dest: &Path) -> Result<(), ExtractError> { @@ -214,4 +221,18 @@ mod tests { "expected PathTraversal for absolute path, got {result:?}" ); } + + #[test] + fn extract_rejects_windows_absolute_path() { + for path in [&b"C:\\evil.txt"[..], &b"\\evil.txt"[..]] { + let gz_out = build_raw_tarball_with_path(path, b""); + let dir = tempfile::tempdir().unwrap(); + let result = extract_crate(&gz_out, dir.path()); + assert!( + matches!(result, Err(ExtractError::PathTraversal(_))), + "expected PathTraversal for {:?}, got {result:?}", + String::from_utf8_lossy(path) + ); + } + } } From e141213e9180a51b4ed7457e0cf0b3f6ed0af7bc Mon Sep 17 00:00:00 2001 From: telcharr Date: Thu, 18 Jun 2026 23:25:00 -0400 Subject: [PATCH 7/7] grow stack around full syn tree lifetime to survive deep drop --- crates/capdiff-core/src/fingerprint.rs | 49 ++++++++++++++------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/crates/capdiff-core/src/fingerprint.rs b/crates/capdiff-core/src/fingerprint.rs index 08d0fec..a1929b9 100644 --- a/crates/capdiff-core/src/fingerprint.rs +++ b/crates/capdiff-core/src/fingerprint.rs @@ -304,29 +304,32 @@ fn process_file_lossy( state: &mut State, ) -> Result<(), FingerprintError> { let src = std::fs::read_to_string(file)?; - // syn's parser recurses during parsing; grow before it starts. - let parse_result = stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, || syn::parse_file(&src)); - let parsed = match parse_result { - Ok(p) => p, - Err(_) => { - state.skipped_files.push(file.to_path_buf()); - return Ok(()); - } - }; - let call_site_values = if in_build_script { - crate::dataflow::analyze_file(&parsed) - } else { - crate::dataflow::CallSiteMap::new() - }; - let mut visitor = Visitor { - file: file.to_path_buf(), - in_build_script, - state, - aliases: AliasMap::default(), - call_site_values, - }; - visitor.visit_file(&parsed); - Ok(()) + // syn parses, visits, AND drops the tree recursively. drop runs unguarded on + // the caller's stack, which overflows on deep input (esp. windows' 1MB main + // thread), so keep the whole tree lifetime inside one grown segment. + stacker::maybe_grow(512 * 1024, 8 * 1024 * 1024, || { + let parsed = match syn::parse_file(&src) { + Ok(p) => p, + Err(_) => { + state.skipped_files.push(file.to_path_buf()); + return Ok(()); + } + }; + let call_site_values = if in_build_script { + crate::dataflow::analyze_file(&parsed) + } else { + crate::dataflow::CallSiteMap::new() + }; + let mut visitor = Visitor { + file: file.to_path_buf(), + in_build_script, + state, + aliases: AliasMap::default(), + call_site_values, + }; + visitor.visit_file(&parsed); + Ok(()) + }) } fn finalize(state: &mut State) {