From 29e164d540cfd65dbe9d1988bff5f332dcf7b168 Mon Sep 17 00:00:00 2001 From: bordumb Date: Fri, 20 Mar 2026 11:04:31 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20ecosystem=20integration=20=E2=80=94=20S?= =?UTF-8?q?ARIF=20fixes,=20check-deny,=20badge,=20audit=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SARIF reporter: add partialFingerprints, security-severity, and repo-relative artifactLocation.uri for GitHub Code Scanning - New DiscoveryResult struct returns cargo metadata workspace_root - New `cargo capsec check-deny` subcommand to verify #[capsec::deny] annotations are respected (filters to deny violations only) - New `cargo capsec badge` subcommand to generate shields.io badges (markdown or --json endpoint format) - Ecosystem audit blog post for 47 popular crates (docs/blog/) - Fix audit_wild.py: version detection and temp path stripping --- crates/cargo-capsec/bench/audit_wild.py | 29 ++- crates/cargo-capsec/src/cli.rs | 43 ++++- crates/cargo-capsec/src/discovery.rs | 20 +- crates/cargo-capsec/src/main.rs | 231 +++++++++++++++++++++-- crates/cargo-capsec/src/reporter.rs | 207 ++++++++++++++++++-- crates/cargo-capsec/tests/integration.rs | 2 +- docs/blog/ecosystem-audit-report.md | 211 +++++++++++++++++++++ 7 files changed, 707 insertions(+), 36 deletions(-) create mode 100644 docs/blog/ecosystem-audit-report.md diff --git a/crates/cargo-capsec/bench/audit_wild.py b/crates/cargo-capsec/bench/audit_wild.py index 2332fb4..af0916a 100644 --- a/crates/cargo-capsec/bench/audit_wild.py +++ b/crates/cargo-capsec/bench/audit_wild.py @@ -61,14 +61,22 @@ def parse_crates_toml(path: Path) -> list[dict]: def get_capsec_version() -> str: """Get the installed cargo-capsec version.""" + # Try --version first (most reliable) + result = subprocess.run( + ["cargo", "capsec", "--version"], + capture_output=True, + text=True, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + # Fall back to parsing help output result = subprocess.run( ["cargo", "capsec", "audit", "--help"], capture_output=True, text=True, ) - # Try to extract from help output or just return unknown for line in result.stdout.splitlines(): - if "version" in line.lower(): + if "version" in line.lower() or "capsec" in line.lower(): return line.strip() return "unknown" @@ -97,6 +105,20 @@ def clone_repo(repo_url: str, dest: Path) -> bool: return True +def strip_temp_paths(audit_data: dict | None, crate_dir: Path) -> dict | None: + """Strip absolute temp directory prefix from file paths in audit output.""" + if not audit_data: + return audit_data + prefix = str(crate_dir) + if not prefix.endswith("/"): + prefix += "/" + for crate_entry in audit_data.get("crates", []): + for finding in crate_entry.get("findings", []): + if "file" in finding and finding["file"].startswith(prefix): + finding["file"] = finding["file"][len(prefix):] + return audit_data + + def run_audit(crate_dir: Path) -> dict: """Run cargo capsec audit on a directory. Returns parsed result.""" start = time.monotonic() @@ -117,7 +139,8 @@ def run_audit(crate_dir: Path) -> dict: if result.stdout.strip(): try: - output["audit"] = json.loads(result.stdout) + audit = json.loads(result.stdout) + output["audit"] = strip_temp_paths(audit, crate_dir) except json.JSONDecodeError: output["audit"] = None output["parse_error"] = "Failed to parse JSON output" diff --git a/crates/cargo-capsec/src/cli.rs b/crates/cargo-capsec/src/cli.rs index e8f7ebd..1de1d34 100644 --- a/crates/cargo-capsec/src/cli.rs +++ b/crates/cargo-capsec/src/cli.rs @@ -13,7 +13,10 @@ use std::path::PathBuf; cargo capsec audit --format sarif SARIF for GitHub Code Scanning\n \ cargo capsec audit --baseline Save results as baseline\n \ cargo capsec audit --diff Show changes since last baseline\n \ - cargo capsec audit --min-risk high Only show high and critical findings" + cargo capsec audit --min-risk high Only show high and critical findings\n \ + cargo capsec check-deny Verify #[capsec::deny] annotations\n \ + cargo capsec badge Generate shields.io badge\n \ + cargo capsec badge --json Output shields.io endpoint JSON" )] pub struct Cli { /// When invoked as `cargo capsec`, cargo passes "capsec" as the first arg. @@ -36,6 +39,10 @@ pub enum CargoSubcommand { pub enum Commands { /// Scan for ambient authority usage Audit(AuditArgs), + /// Verify #[capsec::deny] annotations are respected + CheckDeny(CheckDenyArgs), + /// Generate a shields.io badge from audit results + Badge(BadgeArgs), } #[derive(clap::Args)] @@ -80,3 +87,37 @@ pub struct AuditArgs { #[arg(short, long)] pub quiet: bool, } + +#[derive(clap::Args)] +pub struct CheckDenyArgs { + /// Path to workspace root + #[arg(short, long, default_value = ".")] + pub path: PathBuf, + + /// Output format + #[arg(short, long, default_value = "text", value_parser = ["text", "json", "sarif"])] + pub format: String, + + /// Only scan these crates (comma-separated) + #[arg(long)] + pub only: Option, + + /// Skip these crates (comma-separated) + #[arg(long)] + pub skip: Option, +} + +#[derive(clap::Args)] +pub struct BadgeArgs { + /// Path to workspace root + #[arg(short, long, default_value = ".")] + pub path: PathBuf, + + /// Output shields.io endpoint JSON instead of markdown + #[arg(long)] + pub json: bool, + + /// Risk threshold for badge color (default: high) + #[arg(long, default_value = "high", value_parser = ["low", "medium", "high", "critical"])] + pub fail_on: String, +} diff --git a/crates/cargo-capsec/src/discovery.rs b/crates/cargo-capsec/src/discovery.rs index 84f64aa..256077d 100644 --- a/crates/cargo-capsec/src/discovery.rs +++ b/crates/cargo-capsec/src/discovery.rs @@ -28,6 +28,7 @@ pub struct CrateInfo { #[derive(Deserialize)] struct CargoMetadata { packages: Vec, + workspace_root: String, } #[derive(Deserialize)] @@ -38,17 +39,27 @@ struct Package { source: Option, } +/// Result of workspace discovery: crates and the resolved workspace root. +pub struct DiscoveryResult { + /// All discovered crates. + pub crates: Vec, + /// The Cargo workspace root (from `cargo metadata`). + pub workspace_root: PathBuf, +} + /// Discovers all crates in a Cargo workspace by running `cargo metadata`. /// /// When `include_deps` is `false` (default), passes `--no-deps` for speed — only /// workspace members and path dependencies appear. When `true`, all transitive /// dependencies with cached source are included. +/// +/// Returns both the discovered crates and the resolved workspace root path. pub fn discover_crates( workspace_root: &Path, include_deps: bool, spawn_cap: &impl capsec_core::has::Has, _fs_cap: &impl capsec_core::has::Has, -) -> Result, String> { +) -> Result { // Use --no-deps by default for speed (avoids resolving 300+ transitive deps). // Drop it when --include-deps is set so path dependencies and registry crates appear. let mut args = vec!["metadata", "--format-version=1"]; @@ -70,6 +81,8 @@ pub fn discover_crates( let metadata: CargoMetadata = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse cargo metadata: {e}"))?; + let resolved_root = PathBuf::from(&metadata.workspace_root); + let mut crates = Vec::new(); for package in &metadata.packages { @@ -90,7 +103,10 @@ pub fn discover_crates( } } - Ok(crates) + Ok(DiscoveryResult { + crates, + workspace_root: resolved_root, + }) } /// Recursively discovers all `.rs` source files in a directory. diff --git a/crates/cargo-capsec/src/main.rs b/crates/cargo-capsec/src/main.rs index 8a4e4b0..6c81cec 100644 --- a/crates/cargo-capsec/src/main.rs +++ b/crates/cargo-capsec/src/main.rs @@ -9,7 +9,7 @@ mod reporter; use authorities::Risk; use clap::Parser; -use cli::{AuditArgs, CargoSubcommand, Cli, Commands}; +use cli::{AuditArgs, BadgeArgs, CargoSubcommand, CheckDenyArgs, Cli, Commands}; fn main() { let cli = Cli::parse(); @@ -17,6 +17,8 @@ fn main() { match command { Commands::Audit(args) => run_audit(args), + Commands::CheckDeny(args) => run_check_deny(args), + Commands::Badge(args) => run_badge(args), } } @@ -28,10 +30,10 @@ fn run_audit(args: AuditArgs) { let fs_write = cap_root.grant::(); let spawn_cap = cap_root.grant::(); - let workspace_root = args.path.canonicalize().unwrap_or(args.path.clone()); + let path_arg = args.path.canonicalize().unwrap_or(args.path.clone()); // Load config - let cfg = match config::load_config(&workspace_root, &fs_read) { + let cfg = match config::load_config(&path_arg, &fs_read) { Ok(c) => c, Err(e) => { eprintln!("Warning: {e}"); @@ -40,19 +42,17 @@ fn run_audit(args: AuditArgs) { }; // Discover crates - let crates = match discovery::discover_crates( - &workspace_root, - args.include_deps, - &spawn_cap, - &fs_read, - ) { - Ok(c) => c, - Err(e) => { - eprintln!("Error: {e}"); - eprintln!("Hint: Run from a directory containing Cargo.toml, or use --path"); - std::process::exit(2); - } - }; + let discovery = + match discovery::discover_crates(&path_arg, args.include_deps, &spawn_cap, &fs_read) { + Ok(d) => d, + Err(e) => { + eprintln!("Error: {e}"); + eprintln!("Hint: Run from a directory containing Cargo.toml, or use --path"); + std::process::exit(2); + } + }; + let workspace_root = discovery.workspace_root; + let crates = discovery.crates; // Filter crates let crates: Vec<_> = crates @@ -129,7 +129,7 @@ fn run_audit(args: AuditArgs) { if !args.quiet { match args.format.as_str() { "json" => println!("{}", reporter::report_json(&all_findings)), - "sarif" => println!("{}", reporter::report_sarif(&all_findings)), + "sarif" => println!("{}", reporter::report_sarif(&all_findings, &workspace_root)), _ => reporter::report_text(&all_findings), } } @@ -166,3 +166,200 @@ fn run_audit(args: AuditArgs) { } } } + +fn run_check_deny(args: CheckDenyArgs) { + let cap_root = capsec_core::root::root(); + let fs_read = cap_root.grant::(); + let spawn_cap = cap_root.grant::(); + + let path_arg = args.path.canonicalize().unwrap_or(args.path.clone()); + + // Discover crates + let discovery = match discovery::discover_crates(&path_arg, false, &spawn_cap, &fs_read) { + Ok(d) => d, + Err(e) => { + eprintln!("Error: {e}"); + eprintln!("Hint: Run from a directory containing Cargo.toml, or use --path"); + std::process::exit(2); + } + }; + let workspace_root = discovery.workspace_root; + let crates = discovery.crates; + + // Filter crates + let crates: Vec<_> = crates + .into_iter() + .filter(|c| { + if c.is_dependency { + return false; + } + if let Some(ref only) = args.only { + let allowed: Vec<&str> = only.split(',').collect(); + return allowed.contains(&c.name.as_str()); + } + if let Some(ref skip) = args.skip { + let skipped: Vec<&str> = skip.split(',').collect(); + return !skipped.contains(&c.name.as_str()); + } + true + }) + .collect(); + + // Set up detector + let det = detector::Detector::new(); + + // Parse, detect, and filter to deny violations only + let mut violations = Vec::new(); + + for krate in &crates { + let source_files = discovery::discover_source_files(&krate.source_dir, &fs_read); + + for file_path in source_files { + match parser::parse_file(&file_path, &fs_read) { + Ok(parsed) => { + let findings = det.analyse(&parsed, &krate.name, &krate.version); + violations.extend(findings.into_iter().filter(|f| f.is_deny_violation)); + } + Err(e) => { + eprintln!(" Warning: {e}"); + } + } + } + } + + if violations.is_empty() { + println!("OK All #[capsec::deny] annotations are respected."); + return; + } + + // Report violations + match args.format.as_str() { + "json" => println!("{}", reporter::report_json(&violations)), + "sarif" => println!("{}", reporter::report_sarif(&violations, &workspace_root)), + _ => { + // Text output grouped by function + use std::collections::BTreeMap; + let mut by_func: BTreeMap> = BTreeMap::new(); + for v in &violations { + let key = format!("{}:{} fn {}", v.file, v.function_line, v.function); + by_func.entry(key).or_default().push(v); + } + + for (func_key, funcs) in &by_func { + println!("\nDENY VIOLATION in {func_key}"); + for v in funcs { + println!( + " - {} (line {}) [{}]", + v.call_text, + v.call_line, + v.category.label().to_lowercase() + ); + } + } + + let func_count = by_func.len(); + println!( + "\n{func_count} {} with violations, {} total violations", + if func_count == 1 { + "function" + } else { + "functions" + }, + violations.len() + ); + } + } + + std::process::exit(1); +} + +fn run_badge(args: BadgeArgs) { + let cap_root = capsec_core::root::root(); + let fs_read = cap_root.grant::(); + let spawn_cap = cap_root.grant::(); + + let path_arg = args.path.canonicalize().unwrap_or(args.path.clone()); + + // Load config + let cfg = match config::load_config(&path_arg, &fs_read) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: {e}"); + config::Config::default() + } + }; + + // Discover and audit + let discovery = match discovery::discover_crates(&path_arg, false, &spawn_cap, &fs_read) { + Ok(d) => d, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(2); + } + }; + let crates = discovery.crates; + + let mut det = detector::Detector::new(); + let customs = config::custom_authorities(&cfg); + det.add_custom_authorities(&customs); + + let mut all_findings = Vec::new(); + for krate in &crates { + if krate.is_dependency { + continue; + } + let source_files = discovery::discover_source_files(&krate.source_dir, &fs_read); + for file_path in source_files { + if config::should_exclude(&file_path, &cfg.analysis.exclude) { + continue; + } + if let Ok(parsed) = parser::parse_file(&file_path, &fs_read) { + let findings = det.analyse(&parsed, &krate.name, &krate.version); + all_findings.extend(findings); + } + } + } + + // Apply allow rules + all_findings.retain(|f| !config::should_allow(f, &cfg)); + + // Determine badge color based on highest risk level + let threshold = Risk::parse(&args.fail_on); + let has_critical = all_findings.iter().any(|f| f.risk >= Risk::Critical); + let has_high = all_findings.iter().any(|f| f.risk >= Risk::High); + let has_medium = all_findings.iter().any(|f| f.risk >= Risk::Medium); + let exceeds_threshold = all_findings.iter().any(|f| f.risk >= threshold); + + let (color, message) = if all_findings.is_empty() { + ("brightgreen", "0 findings".to_string()) + } else if has_critical { + ("red", format!("{} findings", all_findings.len())) + } else if has_high { + ("orange", format!("{} findings", all_findings.len())) + } else if has_medium { + ("yellowgreen", format!("{} findings", all_findings.len())) + } else { + ("green", format!("{} findings", all_findings.len())) + }; + + if args.json { + // shields.io endpoint JSON format + let badge = serde_json::json!({ + "schemaVersion": 1, + "label": "capsec", + "message": message, + "color": color, + "isError": exceeds_threshold + }); + println!( + "{}", + serde_json::to_string_pretty(&badge).unwrap_or_default() + ); + } else { + // Markdown badge + let encoded_message = message.replace(' ', "%20"); + println!( + "[![capsec](https://img.shields.io/badge/capsec-{encoded_message}-{color})](https://github.com/bordumb/capsec)" + ); + } +} diff --git a/crates/cargo-capsec/src/reporter.rs b/crates/cargo-capsec/src/reporter.rs index 94b3907..50f2607 100644 --- a/crates/cargo-capsec/src/reporter.rs +++ b/crates/cargo-capsec/src/reporter.rs @@ -12,6 +12,7 @@ use crate::detector::Finding; use colored::Colorize; use serde::Serialize; use std::collections::BTreeMap; +use std::path::Path; /// Prints findings to stdout as color-coded text grouped by crate. /// @@ -278,7 +279,14 @@ fn finding_to_json(f: &Finding) -> JsonFinding { /// The output includes a `rules` array in `tool.driver` with descriptions and default /// severity for each rule, and each result carries a `ruleIndex` pointing into that array. /// This is required for GitHub Code Scanning to display rule metadata in the Security tab. -pub fn report_sarif(findings: &[Finding]) -> String { +/// +/// `workspace_root` is used to make `artifactLocation.uri` repo-relative. Paths that +/// start with the workspace root have that prefix stripped; other paths are left as-is. +/// +/// Each rule includes `properties.security-severity` (numeric, 0.0–10.0) for GitHub +/// severity sorting, and each result includes `partialFingerprints.primaryLocationLineHash` +/// for cross-run deduplication. +pub fn report_sarif(findings: &[Finding], workspace_root: &Path) -> String { // Build deduplicated rules array. Each unique ruleId gets one entry. // Use BTreeMap for deterministic ordering. let mut rule_index_map: BTreeMap = BTreeMap::new(); @@ -301,6 +309,9 @@ pub fn report_sarif(findings: &[Finding]) -> String { "defaultConfiguration": { "level": risk_to_sarif_level(f.risk) }, + "properties": { + "security-severity": risk_to_security_severity(f.risk) + }, "helpUri": "https://github.com/bordumb/capsec" })); } @@ -315,6 +326,8 @@ pub fn report_sarif(findings: &[Finding]) -> String { f.subcategory ); let rule_index = rule_index_map[&rule_id]; + let relative_path = make_relative(&f.file, workspace_root); + let fingerprint = compute_fingerprint(f); serde_json::json!({ "ruleId": rule_id, "ruleIndex": rule_index, @@ -324,13 +337,16 @@ pub fn report_sarif(findings: &[Finding]) -> String { }, "locations": [{ "physicalLocation": { - "artifactLocation": { "uri": f.file }, + "artifactLocation": { "uri": relative_path }, "region": { "startLine": f.call_line, "startColumn": f.call_col } } - }] + }], + "partialFingerprints": { + "primaryLocationLineHash": fingerprint + } }) }) .collect(); @@ -354,6 +370,45 @@ pub fn report_sarif(findings: &[Finding]) -> String { serde_json::to_string_pretty(&sarif).unwrap_or_default() } +/// Maps risk level to numeric security-severity (0.0–10.0) for GitHub Code Scanning. +fn risk_to_security_severity(risk: Risk) -> f64 { + match risk { + Risk::Critical => 9.5, + Risk::High => 7.5, + Risk::Medium => 5.0, + Risk::Low => 2.0, + } +} + +/// Strips `workspace_root` prefix from a file path to produce a repo-relative URI. +fn make_relative(file_path: &str, workspace_root: &Path) -> String { + let root_str = workspace_root.to_string_lossy(); + let root_prefix = if root_str.ends_with('/') { + root_str.to_string() + } else { + format!("{root_str}/") + }; + if file_path.starts_with(&root_prefix) { + file_path[root_prefix.len()..].to_string() + } else { + file_path.to_string() + } +} + +/// Computes a stable fingerprint for a finding, used by GitHub to deduplicate +/// alerts across runs. Uses the same identity tuple as baseline diffing. +fn compute_fingerprint(f: &Finding) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + f.file.hash(&mut hasher); + f.function.hash(&mut hasher); + f.call_text.hash(&mut hasher); + f.category.label().hash(&mut hasher); + f.subcategory.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + fn risk_to_sarif_level(risk: Risk) -> &'static str { match risk { Risk::Critical => "error", @@ -365,11 +420,16 @@ fn risk_to_sarif_level(risk: Risk) -> &'static str { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; + + fn test_root() -> PathBuf { + PathBuf::from("/workspace/project") + } fn sample_findings() -> Vec { vec![ Finding { - file: "src/main.rs".to_string(), + file: "/workspace/project/src/main.rs".to_string(), function: "main".to_string(), function_line: 5, call_line: 8, @@ -385,7 +445,7 @@ mod tests { is_deny_violation: false, }, Finding { - file: "src/net.rs".to_string(), + file: "/workspace/project/src/net.rs".to_string(), function: "connect".to_string(), function_line: 10, call_line: 12, @@ -415,7 +475,7 @@ mod tests { #[test] fn sarif_report_is_valid() { let findings = sample_findings(); - let sarif_str = report_sarif(&findings); + let sarif_str = report_sarif(&findings, &test_root()); let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); assert_eq!(parsed["version"], "2.1.0"); assert_eq!(parsed["runs"][0]["results"].as_array().unwrap().len(), 2); @@ -424,7 +484,7 @@ mod tests { #[test] fn sarif_has_rules_array() { let findings = sample_findings(); - let sarif_str = report_sarif(&findings); + let sarif_str = report_sarif(&findings, &test_root()); let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); let driver = &parsed["runs"][0]["tool"]["driver"]; @@ -456,7 +516,7 @@ mod tests { #[test] fn sarif_results_have_valid_rule_index() { let findings = sample_findings(); - let sarif_str = report_sarif(&findings); + let sarif_str = report_sarif(&findings, &test_root()); let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); let rules = parsed["runs"][0]["tool"]["driver"]["rules"] @@ -486,7 +546,7 @@ mod tests { // Two findings with the same category+subcategory should produce one rule let findings = vec![ Finding { - file: "src/a.rs".to_string(), + file: "/workspace/project/src/a.rs".to_string(), function: "a".to_string(), function_line: 1, call_line: 2, @@ -502,7 +562,7 @@ mod tests { is_deny_violation: false, }, Finding { - file: "src/b.rs".to_string(), + file: "/workspace/project/src/b.rs".to_string(), function: "b".to_string(), function_line: 10, call_line: 12, @@ -519,7 +579,7 @@ mod tests { }, ]; - let sarif_str = report_sarif(&findings); + let sarif_str = report_sarif(&findings, &test_root()); let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); let rules = parsed["runs"][0]["tool"]["driver"]["rules"] .as_array() @@ -541,7 +601,7 @@ mod tests { #[test] fn sarif_empty_findings_has_empty_rules() { - let sarif_str = report_sarif(&[]); + let sarif_str = report_sarif(&[], &test_root()); let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); let rules = parsed["runs"][0]["tool"]["driver"]["rules"] .as_array() @@ -555,4 +615,127 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(parsed["summary"]["total_findings"], 0); } + + #[test] + fn sarif_results_have_partial_fingerprints() { + let findings = sample_findings(); + let sarif_str = report_sarif(&findings, &test_root()); + let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); + let results = parsed["runs"][0]["results"].as_array().unwrap(); + + for result in results { + let fingerprint = result["partialFingerprints"]["primaryLocationLineHash"] + .as_str() + .expect("each result must have partialFingerprints.primaryLocationLineHash"); + assert_eq!(fingerprint.len(), 16, "fingerprint should be 16 hex chars"); + assert!( + fingerprint.chars().all(|c| c.is_ascii_hexdigit()), + "fingerprint should be hex" + ); + } + + // Two different findings should have different fingerprints + let fp0 = results[0]["partialFingerprints"]["primaryLocationLineHash"] + .as_str() + .unwrap(); + let fp1 = results[1]["partialFingerprints"]["primaryLocationLineHash"] + .as_str() + .unwrap(); + assert_ne!( + fp0, fp1, + "different findings should have different fingerprints" + ); + } + + #[test] + fn sarif_rules_have_security_severity() { + let findings = sample_findings(); + let sarif_str = report_sarif(&findings, &test_root()); + let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); + let rules = parsed["runs"][0]["tool"]["driver"]["rules"] + .as_array() + .unwrap(); + + for rule in rules { + let severity = rule["properties"]["security-severity"] + .as_f64() + .expect("each rule must have properties.security-severity as number"); + assert!( + (0.0..=10.0).contains(&severity), + "security-severity must be 0.0-10.0, got {severity}" + ); + } + + // fs/read is Medium → 5.0, net/connect is High → 7.5 + assert_eq!(rules[0]["properties"]["security-severity"], 5.0); + assert_eq!(rules[1]["properties"]["security-severity"], 7.5); + } + + #[test] + fn sarif_artifact_uris_are_relative() { + let findings = sample_findings(); + let sarif_str = report_sarif(&findings, &test_root()); + let parsed: serde_json::Value = serde_json::from_str(&sarif_str).unwrap(); + let results = parsed["runs"][0]["results"].as_array().unwrap(); + + for result in results { + let uri = result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] + .as_str() + .unwrap(); + assert!( + !uri.starts_with('/'), + "artifactLocation.uri must be relative, got: {uri}" + ); + } + + assert_eq!( + results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + "src/main.rs" + ); + assert_eq!( + results[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + "src/net.rs" + ); + } + + #[test] + fn make_relative_strips_prefix() { + let root = PathBuf::from("/home/runner/work/repo"); + assert_eq!( + make_relative("/home/runner/work/repo/src/main.rs", &root), + "src/main.rs" + ); + } + + #[test] + fn make_relative_preserves_non_matching_path() { + let root = PathBuf::from("/home/runner/work/repo"); + assert_eq!( + make_relative("/other/path/src/main.rs", &root), + "/other/path/src/main.rs" + ); + } + + #[test] + fn fingerprint_is_stable() { + let f = Finding { + file: "src/main.rs".to_string(), + function: "main".to_string(), + function_line: 5, + call_line: 8, + call_col: 5, + call_text: "std::fs::read".to_string(), + category: Category::Fs, + subcategory: "read".to_string(), + risk: Risk::Medium, + description: "Read arbitrary file".to_string(), + is_build_script: false, + crate_name: "my-app".to_string(), + crate_version: "0.1.0".to_string(), + is_deny_violation: false, + }; + let fp1 = compute_fingerprint(&f); + let fp2 = compute_fingerprint(&f); + assert_eq!(fp1, fp2, "fingerprint must be stable across calls"); + } } diff --git a/crates/cargo-capsec/tests/integration.rs b/crates/cargo-capsec/tests/integration.rs index 4b56036..f7c851d 100644 --- a/crates/cargo-capsec/tests/integration.rs +++ b/crates/cargo-capsec/tests/integration.rs @@ -170,7 +170,7 @@ fn sarif_output_is_valid() { let detector = Detector::new(); let findings = detector.analyse(&parsed, "fs_crate", "0.1.0"); - let sarif = cargo_capsec::reporter::report_sarif(&findings); + let sarif = cargo_capsec::reporter::report_sarif(&findings, std::path::Path::new(".")); let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap(); assert_eq!(parsed["version"], "2.1.0"); assert!(!parsed["runs"][0]["results"].as_array().unwrap().is_empty()); diff --git a/docs/blog/ecosystem-audit-report.md b/docs/blog/ecosystem-audit-report.md new file mode 100644 index 0000000..536a97c --- /dev/null +++ b/docs/blog/ecosystem-audit-report.md @@ -0,0 +1,211 @@ +# What Can Your Dependencies Do? An Ambient Authority Audit of the Rust Ecosystem + +**Date:** 2026-03-19 +**Tool:** [capsec](https://github.com/bordumb/capsec) — static capability audit for Rust + +## Introduction + +Every Rust crate you depend on has implicit access to your filesystem, network, environment variables, and process spawning. There is no permission system — any function in any dependency can `std::fs::remove_dir_all("/")` or `TcpStream::connect("evil.com")` without your code ever granting that capability. + +**capsec** is a static analysis tool that makes this ambient authority visible. It scans Rust source code and reports every call site that exercises a system capability: reading files, opening sockets, accessing environment variables, spawning processes, or calling into foreign code. + +We ran `cargo capsec audit` against 47 popular crates to answer a simple question: **what does the Rust ecosystem actually do with ambient authority?** + +## Methodology + +- **Tool:** cargo-capsec (from source, workspace build) +- **Rust toolchain:** 1.94.0 +- **Date:** 2026-03-19 +- **Scope:** First-party source code only (no transitive dependencies) +- **Method:** Each crate was shallow-cloned from its GitHub repository and audited with `cargo capsec audit --format json` + +### What capsec detects + +capsec identifies syntactic patterns matching known ambient authority calls across five categories: + +| Category | Examples | +|----------|---------| +| **FS** | `std::fs::read`, `File::open`, `remove_dir_all` | +| **NET** | `TcpStream::connect`, `UdpSocket::bind`, `TcpListener::bind` | +| **ENV** | `std::env::var`, `env::set_var`, `env::current_dir` | +| **PROC** | `Command::new`, `process::exit`, `process::abort` | +| **FFI** | `extern` blocks, raw FFI calls | + +### Limitations + +- **Proc-macro-generated code is invisible.** Calls emitted by derive macros or `#[tokio::main]` are not seen. +- **No data flow analysis.** Dead code paths are flagged. A function that constructs a `Command` but never calls `.output()` is still counted. +- **No type resolution.** A local function named `read` that shadows `std::fs::read` could be a false positive. +- **build.rs is included.** Build scripts legitimately use FS/ENV/PROC — these are expected findings, not bugs. + +## Results Summary + +**47 crates audited** across 8 categories: + +| Metric | Value | +|--------|-------| +| Total crates | 47 | +| Crates with zero findings | 13 (28%) | +| Total findings | 1,448 | +| Median findings per crate | 8 | +| Highest single crate | starship (470) | + +### Findings by category + +| Category | Total | % of all findings | +|----------|-------|-------------------| +| **FS** | 957 | 66% | +| **ENV** | 206 | 14% | +| **PROC** | 169 | 12% | +| **NET** | 65 | 4% | +| **FFI** | 9 | <1% | + +Filesystem access dominates — unsurprising for a systems language ecosystem where I/O is the norm, not the exception. + +## Results by Category + +### Pure Libraries (17 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| serde | 2 | 0 | 3 | 4 | 0 | 9 | +| syn | 1 | 0 | 1 | 2 | 0 | 4 | +| clap | 3 | 0 | 13 | 28 | 0 | 44 | +| thiserror | 2 | 0 | 2 | 4 | 0 | 8 | +| anyhow | 1 | 0 | 1 | 4 | 0 | 6 | +| pest | 3 | 0 | 3 | 0 | 0 | 6 | +| crossbeam | 0 | 0 | 4 | 0 | 0 | 4 | +| rayon | 1 | 0 | 2 | 0 | 0 | 3 | +| regex | 0 | 0 | 0 | 0 | 0 | **0** | +| rand | 0 | 0 | 0 | 0 | 0 | **0** | +| itertools | 0 | 0 | 0 | 0 | 0 | **0** | +| bytes | 0 | 0 | 0 | 0 | 0 | **0** | +| nom | 0 | 0 | 0 | 0 | 0 | **0** | +| dashmap | 0 | 0 | 0 | 0 | 0 | **0** | +| smallvec | 0 | 0 | 0 | 0 | 0 | **0** | +| bitflags | 0 | 0 | 0 | 0 | 0 | **0** | +| unicode-segmentation | 0 | 0 | 0 | 0 | 0 | **0** | + +**Key insight:** 9 of 17 pure libraries have zero ambient authority. The remaining 8 have findings primarily in `build.rs` and test infrastructure, not in library code proper. **clap** is the outlier with 44 findings — it legitimately reads environment variables (for `env!` defaults) and calls `process::exit` on parse errors. + +**The build.rs question:** serde, syn, thiserror, and anyhow all have findings. These come from their build scripts, which use FS (reading/writing generated code), ENV (checking compiler flags), and PROC (running `rustc` for version detection). This is expected and correct behavior — build scripts _need_ ambient authority. A future capsec version could separate build-script findings from library findings. + +### Crypto (3 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| ring | 0 | 0 | 0 | 0 | 1 | 1 | +| rustls | 20 | 19 | 1 | 20 | 0 | 60 | +| sha2 | 0 | 0 | 0 | 0 | 1 | 1 | + +**ring** and **sha2** are nearly clean — their single FFI finding is the `extern` block for the C/assembly crypto primitives. This is exactly what you'd expect. + +**rustls** is more surprising at 60 findings — but these come from its examples and test infrastructure, not the library itself. The NET findings are TLS connection examples, the FS findings are certificate loading, and the PROC findings are process management in integration tests. + +### CLI Applications (14 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| starship | 452 | 1 | 16 | 1 | 0 | 470 | +| nushell | 82 | 7 | 48 | 20 | 0 | 157 | +| bat | 24 | 0 | 21 | 6 | 0 | 51 | +| delta | 27 | 0 | 19 | 10 | 0 | 56 | +| tokei | 34 | 0 | 2 | 1 | 0 | 37 | +| bottom | 22 | 0 | 3 | 8 | 2 | 35 | +| zoxide | 3 | 0 | 4 | 20 | 0 | 27 | +| just | 8 | 0 | 2 | 12 | 0 | 22 | +| ripgrep | 14 | 0 | 2 | 1 | 0 | 17 | +| exa | 3 | 0 | 7 | 2 | 2 | 14 | +| procs | 3 | 3 | 3 | 4 | 0 | 13 | +| hyperfine | 5 | 0 | 0 | 7 | 0 | 12 | +| fd | 1 | 0 | 4 | 6 | 0 | 11 | +| dust | 6 | 0 | 0 | 0 | 0 | 6 | + +CLI applications are the heaviest users of ambient authority, as expected. They exist to interact with the system. + +**starship** (470 findings) stands out because it's a shell prompt customizer that reads dozens of config files, checks git status, reads environment variables, and inspects system state. Every one of those 452 FS findings is a _feature_ — the whole point of starship is to read your system and display it. + +**nushell** (157) is a shell — it needs all the capabilities. **bat** (51) is a file viewer — it needs FS access. + +The interesting signal is _what a CLI tool doesn't use_. `dust` (disk usage) has only FS findings — no network, no env, no process spawning. `fd` (file finder) is similarly constrained. This is the kind of assurance capsec can provide: "this tool only touches the filesystem." + +### Web Frameworks (5 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| axum | 4 | 24 | 1 | 0 | 0 | 29 | +| actix-web | 6 | 6 | 1 | 0 | 0 | 13 | +| reqwest | 3 | 0 | 2 | 0 | 3 | 8 | +| warp | 2 | 3 | 0 | 0 | 0 | 5 | +| hyper | 0 | 0 | 0 | 0 | 0 | **0** | + +**hyper** at zero findings is notable — it's a pure HTTP protocol implementation that delegates all I/O to the caller via the `tower::Service` trait. This is capability-based design in practice, even without a formal capability system. + +**axum** has 24 NET findings because it wraps hyper's listener and exposes `TcpListener::bind` and connection handling. **reqwest** has 3 FFI findings from its native-tls integration. + +### Async Runtimes (2 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| tokio | 10 | 2 | 1 | 9 | 0 | 22 | +| async-std | 18 | 0 | 0 | 0 | 0 | 18 | + +Runtimes need ambient authority to provide async I/O. tokio's 22 findings cover its FS, NET, and process modules — each is a direct wrapper around `std` with async semantics. async-std's 18 FS findings are its file I/O wrappers. + +### I/O Libraries (5 crates) + +| Crate | FS | NET | ENV | PROC | FFI | Total | +|-------|----|-----|-----|------|-----|-------| +| notify | 162 | 0 | 7 | 0 | 0 | 169 | +| rusqlite | 3 | 0 | 21 | 0 | 0 | 24 | +| tracing | 10 | 0 | 10 | 0 | 0 | 20 | +| tempfile | 2 | 0 | 4 | 0 | 0 | 6 | +| walkdir | 1 | 0 | 0 | 0 | 0 | 1 | + +**notify** (162 FS) is a filesystem event watcher — it has more FS calls than any other library because it wraps platform-specific file watching APIs (inotify, kqueue, FSEvents). Every one of those calls is essential. + +**rusqlite** (21 ENV) reads environment variables for path resolution and database configuration. **tracing** (10 ENV, 10 FS) reads environment variables for filter directives and writes to log files. + +## Performance + +Most crates audit in under 1 second. Three outliers: + +| Crate | Time | Likely cause | +|-------|------|-------------| +| dashmap | 63.75s | `cargo metadata` compilation overhead | +| exa | 88.49s | `cargo metadata` compilation overhead | +| nushell | 49.85s | Large codebase (300+ source files) | + +The slow runs are dominated by `cargo metadata` resolving the dependency tree, not by capsec's analysis. The actual source scanning is typically <0.5s even for large crates. + +## What We Learned + +### 1. Pure computation crates are genuinely clean + +regex, rand, itertools, bytes, nom, smallvec, bitflags, and unicode-segmentation have zero ambient authority. If your library does pure computation, capsec confirms it. + +### 2. Build scripts are the main source of "surprising" findings + +serde, syn, anyhow, and thiserror show up with findings — but all from `build.rs`, not library code. This is a common pattern: the library is pure, but the build script needs FS/ENV/PROC to generate code or detect compiler features. + +### 3. Capability-based design exists in the wild + +hyper's zero-finding result demonstrates that you can build a major networking library without any ambient authority in the library itself. It pushes all I/O decisions to the caller. This is exactly the pattern capsec encourages. + +### 4. CLI tools need everything — and that's fine + +The value of auditing CLI tools isn't finding them guilty — it's understanding their surface area. Knowing that `dust` only uses FS while `nushell` uses everything helps you assess supply chain risk. + +### 5. Finding counts don't equal risk + +starship (470) is not 470x riskier than walkdir (1). Finding counts measure _surface area_, not _severity_. A tool that reads 452 files is doing its job. The interesting signal is when a library that shouldn't need network access has NET findings. + +## Try It Yourself + +```bash +cargo install cargo-capsec +cargo capsec audit +cargo capsec audit --format sarif # GitHub Code Scanning integration +``` + +Raw data for all 47 crates is available in [bench/results/](../../crates/cargo-capsec/bench/results/2026-03-19/).