Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/cargo-capsec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,8 @@ pub struct AuditArgs {
/// Skip these crates (comma-separated)
#[arg(long)]
pub skip: Option<String>,

/// Suppress output (exit code only, for CI)
#[arg(short, long)]
pub quiet: bool,
}
24 changes: 22 additions & 2 deletions crates/cargo-capsec/src/detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ impl Detector {
.map(|call| expand_call(&call.segments, &import_map))
.collect();

// Pass 1: collect path-based findings and build a set of matched paths
// (needed for MethodWithContext co-occurrence checks in pass 2)
// Pass 1: collect path-based findings and build a set of matched patterns.
// We store the *pattern* (e.g. ["Command", "new"]), not the expanded call path.
// This is correct: if someone writes `use std::process::Command; Command::new("sh")`,
// import expansion produces `std::process::Command::new`, which suffix-matches
// the pattern ["Command", "new"]. Pass 2 then checks for pattern co-occurrence,
// so `.output()` fires only when the Command::new *pattern* was matched in pass 1.
let mut matched_paths: HashSet<Vec<String>> = HashSet::new();

for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
Expand Down Expand Up @@ -356,6 +360,22 @@ mod tests {
}
}

#[test]
fn detect_aliased_import() {
let source = r#"
use std::fs::read as load;
fn fetch() {
let _ = load("data.bin");
}
"#;
let parsed = parse_source(source, "test.rs").unwrap();
let detector = Detector::new();
let findings = detector.analyse(&parsed, "test-crate", "0.1.0");
assert!(!findings.is_empty(), "Should detect aliased import: use std::fs::read as load");
assert_eq!(findings[0].category, Category::Fs);
assert!(findings[0].call_text.contains("std::fs::read"));
}

#[test]
fn detect_impl_block_method() {
let source = r#"
Expand Down
13 changes: 9 additions & 4 deletions crates/cargo-capsec/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ struct Package {
source: Option<String>,
}

pub fn discover_crates(workspace_root: &Path) -> Result<Vec<CrateInfo>, String> {
// Don't use --no-deps: we need path dependencies to appear.
// We distinguish local vs registry crates by source == None (local) vs Some (registry).
pub fn discover_crates(workspace_root: &Path, include_deps: bool) -> Result<Vec<CrateInfo>, String> {
// 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"];
if !include_deps {
args.push("--no-deps");
}

let output = Command::new("cargo")
.args(["metadata", "--format-version=1"])
.args(&args)
.current_dir(workspace_root)
.output()
.map_err(|e| format!("Failed to run cargo metadata: {e}"))?;
Expand Down
30 changes: 19 additions & 11 deletions crates/cargo-capsec/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fn run_audit(args: AuditArgs) {
};

// Discover crates
let crates = match discovery::discover_crates(&workspace_root) {
let crates = match discovery::discover_crates(&workspace_root, args.include_deps) {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {e}");
Expand Down Expand Up @@ -96,21 +96,30 @@ fn run_audit(args: AuditArgs) {
// Apply allow rules
all_findings.retain(|f| !config::should_allow(f, &cfg));

// Load baseline once if needed for diff or fail-on
let baseline_data = if args.diff || args.fail_on.is_some() {
baseline::load_baseline(&workspace_root)
} else {
None
};

// Diff against baseline
if args.diff {
if let Some(bl) = baseline::load_baseline(&workspace_root) {
let diff_result = baseline::diff(&all_findings, &bl);
if let Some(ref bl) = baseline_data {
let diff_result = baseline::diff(&all_findings, bl);
baseline::print_diff(&diff_result);
} else {
eprintln!("No baseline found. Run with --baseline first.");
}
}

// Report
match args.format.as_str() {
"json" => println!("{}", reporter::report_json(&all_findings)),
"sarif" => println!("{}", reporter::report_sarif(&all_findings)),
_ => reporter::report_text(&all_findings),
// Report (suppress with --quiet)
if !args.quiet {
match args.format.as_str() {
"json" => println!("{}", reporter::report_json(&all_findings)),
"sarif" => println!("{}", reporter::report_sarif(&all_findings)),
_ => reporter::report_text(&all_findings),
}
}

// Save baseline
Expand All @@ -126,9 +135,8 @@ fn run_audit(args: AuditArgs) {
let threshold = Risk::from_str(fail_level);

if args.diff {
// When diffing, only fail on NEW findings above threshold
if let Some(bl) = baseline::load_baseline(&workspace_root) {
let diff_result = baseline::diff(&all_findings, &bl);
if let Some(ref bl) = baseline_data {
let diff_result = baseline::diff(&all_findings, bl);
let new_set: std::collections::HashSet<_> =
diff_result.new_findings.into_iter().collect();
let has_new_high = all_findings.iter().any(|f| {
Expand Down