diff --git a/src/scanner.rs b/src/scanner.rs index 7678450..6b2d98a 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -235,7 +235,15 @@ fn find_call_sites( } } - // 4. After `use hyper::http::Request;` and calling as method: foo.parse(...) + // 4. Free function import: `use cookie::parse::parse_cookie;` → bare `parse_cookie(...)` + // When the full symbol is imported directly, the bare name is a valid caller. + for (local, full) in &import_map { + if *full == symbol { + local_callers.push(local.to_string()); + } + } + + // 5. After `use hyper::http::Request;` and calling as method: foo.parse(...) // This is lower confidence since we can't verify the receiver type. let method_pattern = format!(".{}(", fn_name); @@ -248,12 +256,32 @@ fn find_call_sites( .unwrap_or(false) }); + let mut in_block_comment = false; + for (line_num, line) in source.lines().enumerate() { let trimmed = line.trim(); - // Skip comments - if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') - { + // Track block comments: /* ... */ + if in_block_comment { + if trimmed.contains("*/") { + in_block_comment = false; + } + continue; + } + if trimmed.starts_with("/*") { + if !trimmed.contains("*/") { + in_block_comment = true; + } + continue; + } + + // Skip line comments + if trimmed.starts_with("//") || trimmed.starts_with('*') { + continue; + } + + // Skip `use` statements — they are imports, not calls + if trimmed.starts_with("use ") { continue; } @@ -290,9 +318,18 @@ fn find_call_sites( b.var_name.ends_with(&format!(".{}", &receiver_var[5..])) && b.type_path == parent_path }); + if receiver_matches || field_matches { Confidence::High } else { + // If we positively know the receiver's type and it + // doesn't match, suppress the finding entirely. + let type_is_known = type_bindings + .iter() + .any(|b| b.var_name == receiver_var); + if type_is_known { + continue; // Known wrong type — not a match + } Confidence::Medium } } else { diff --git a/tests/fixtures/edge_cases_project/Cargo.lock b/tests/fixtures/edge_cases_project/Cargo.lock new file mode 100644 index 0000000..e2899f3 --- /dev/null +++ b/tests/fixtures/edge_cases_project/Cargo.lock @@ -0,0 +1,27 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "edge-cases-app" +version = "0.1.0" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "serde_json" +version = "1.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tests/fixtures/edge_cases_project/src/aliased.rs b/tests/fixtures/edge_cases_project/src/aliased.rs new file mode 100644 index 0000000..93c38ac --- /dev/null +++ b/tests/fixtures/edge_cases_project/src/aliased.rs @@ -0,0 +1,12 @@ +//! Edge case: aliased import. +//! +//! `use hyper::proto::h1::decode::Decoder as HyperDecoder;` +//! Then `HyperDecoder::decode(...)` should still be detected. + +use hyper::proto::h1::decode::Decoder as HyperDecoder; + +pub fn run() { + let dec = HyperDecoder::new(); + let result = dec.decode(data); + println!("{:?}", result); +} diff --git a/tests/fixtures/edge_cases_project/src/commented.rs b/tests/fixtures/edge_cases_project/src/commented.rs new file mode 100644 index 0000000..0e85543 --- /dev/null +++ b/tests/fixtures/edge_cases_project/src/commented.rs @@ -0,0 +1,19 @@ +//! Edge case: vulnerable calls that are commented out. +//! +//! These should NOT be detected as call sites. + +use hyper::proto::h1::role::Client; +use serde_json::de::Deserializer; + +pub fn run() { + // Client::encode(raw_request); + // let d = Deserializer::parse(input); + + /* Also in block comments: + Client::encode(raw_request); + Deserializer::parse(input); + */ + + // Only safe code actually runs here + println!("nothing vulnerable here"); +} diff --git a/tests/fixtures/edge_cases_project/src/false_positive.rs b/tests/fixtures/edge_cases_project/src/false_positive.rs new file mode 100644 index 0000000..106b372 --- /dev/null +++ b/tests/fixtures/edge_cases_project/src/false_positive.rs @@ -0,0 +1,39 @@ +//! Edge case: same method name on a different type. +//! +//! `tokio::runtime::task::JoinHandle::abort` is vulnerable, but calling +//! `.abort()` on an unrelated type should NOT be flagged. +//! +//! Also: `serde_json::de::Deserializer::parse` is vulnerable, but calling +//! `.parse()` on a String (a very common Rust pattern) should NOT be flagged. + +use tokio::runtime::task::JoinHandle; + +struct MyHandle { + id: u32, +} + +impl MyHandle { + fn abort(&self) { + println!("aborting {}", self.id); + } +} + +pub fn run() { + // .abort() on our own MyHandle — should NOT match JoinHandle::abort + let h = MyHandle { id: 42 }; + h.abort(); + + // .parse() is very common on strings — should NOT match serde_json Deserializer::parse + // because serde_json::de::Deserializer is not imported + let n: u32 = "42".parse().unwrap(); + let port: u16 = "8080".parse().unwrap(); + + // JoinHandle is imported but we never call .abort() on one + let handles: Vec = Vec::new(); + for handle in handles { + // We only print, never abort + println!("handle exists"); + } + + println!("n={}, port={}", n, port); +} diff --git a/tests/fixtures/edge_cases_project/src/grouped.rs b/tests/fixtures/edge_cases_project/src/grouped.rs new file mode 100644 index 0000000..420238b --- /dev/null +++ b/tests/fixtures/edge_cases_project/src/grouped.rs @@ -0,0 +1,16 @@ +//! Edge case: grouped import. +//! +//! `use cookie::{parse::parse_cookie, jar::CookieJar};` +//! Both symbols from the cookie advisory should be detected. + +use cookie::parse::parse_cookie; +use cookie::jar::CookieJar; + +pub fn run() { + // Qualified call to a free function + let c = parse_cookie("session=abc123"); + + // Method call on a typed local + let jar: CookieJar = CookieJar::new(); + jar.add(c); +} diff --git a/tests/fixtures/edge_cases_project/src/main.rs b/tests/fixtures/edge_cases_project/src/main.rs new file mode 100644 index 0000000..240244a --- /dev/null +++ b/tests/fixtures/edge_cases_project/src/main.rs @@ -0,0 +1,11 @@ +mod aliased; +mod commented; +mod false_positive; +mod grouped; + +fn main() { + aliased::run(); + grouped::run(); + commented::run(); + false_positive::run(); +} diff --git a/tests/fixtures/golden_vuln_db.json b/tests/fixtures/golden_vuln_db.json index 151353a..efab17c 100644 --- a/tests/fixtures/golden_vuln_db.json +++ b/tests/fixtures/golden_vuln_db.json @@ -65,6 +65,41 @@ "change_type": "Modified" } ] + }, + { + "advisory_id": "RUSTSEC-2024-0040", + "package": "serde_json", + "title": "Stack overflow on deeply nested input", + "date": "2024-05-01", + "patched_versions": [">= 1.0.100"], + "commit_sha": "mno345", + "vulnerable_symbols": [ + { + "file": "src/de.rs", + "function": "serde_json::de::Deserializer::parse", + "change_type": "Modified" + } + ] + }, + { + "advisory_id": "RUSTSEC-2024-0050", + "package": "cookie", + "title": "Cookie parsing allows header injection", + "date": "2024-06-01", + "patched_versions": [">= 0.18.0", ">= 0.17.1, < 0.18.0"], + "commit_sha": "pqr678", + "vulnerable_symbols": [ + { + "file": "src/parse.rs", + "function": "cookie::parse::parse_cookie", + "change_type": "Modified" + }, + { + "file": "src/jar.rs", + "function": "cookie::jar::CookieJar::add", + "change_type": "Modified" + } + ] } ] } diff --git a/tests/fixtures/vulnerable_project/Cargo.lock b/tests/fixtures/vulnerable_project/Cargo.lock index 904b8f7..d4df2cc 100644 --- a/tests/fixtures/vulnerable_project/Cargo.lock +++ b/tests/fixtures/vulnerable_project/Cargo.lock @@ -25,3 +25,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" name = "regex" version = "1.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "serde_json" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 5249707..11984bd 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -19,8 +19,121 @@ fn run_analyze(project: &str, db: &str) -> std::process::Output { .expect("failed to run binary") } +/// Parse structured fields from the report output. +struct Report { + stdout: String, +} + +impl Report { + fn new(output: &std::process::Output) -> Self { + Self { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + } + } + + /// Extract the integer after a label like "Found 4 vulnerable dependencies". + fn extract_count(&self, label: &str) -> usize { + for line in self.stdout.lines() { + if line.contains(label) { + for word in line.split_whitespace() { + if let Ok(n) = word.parse::() { + return n; + } + } + } + } + panic!("Could not find '{}' in output:\n{}", label, self.stdout); + } + + fn vulnerable_dep_count(&self) -> usize { + self.extract_count("vulnerable dependencies") + } + + fn reachable_symbol_count(&self) -> usize { + self.extract_count("reachable vulnerable symbols") + } + + fn files_scanned(&self) -> usize { + self.extract_count("Scanned") + } + + /// Return all `[HIGH] file:line → snippet` entries from the reachable section. + fn high_findings(&self) -> Vec { + self.findings_with_tag("[HIGH]") + } + + /// Return all `[MEDIUM] file:line → snippet` entries from the reachable section. + fn medium_findings(&self) -> Vec { + self.findings_with_tag("[MEDIUM]") + } + + fn findings_with_tag(&self, tag: &str) -> Vec { + self.stdout + .lines() + .filter(|l| l.contains(tag)) + .map(|l| l.trim().to_string()) + .collect() + } + + /// Return advisory IDs listed in the "Vulnerable Dependencies" section. + fn listed_advisories(&self) -> Vec { + let dep_section = self + .stdout + .split("--- Vulnerable Dependencies ---") + .nth(1) + .unwrap_or("") + .split("---") + .next() + .unwrap_or(""); + dep_section + .lines() + .filter_map(|l| { + let trimmed = l.trim(); + if trimmed.starts_with("RUSTSEC-") { + Some(trimmed.split_whitespace().next().unwrap().to_string()) + } else { + None + } + }) + .collect() + } + + /// Return advisory IDs that appear in the "Reachable Vulnerable Symbols" section. + fn reachable_advisories(&self) -> Vec { + let reachable = self + .stdout + .split("Reachable Vulnerable Symbols") + .nth(1) + .unwrap_or(""); + reachable + .lines() + .filter_map(|l| { + let trimmed = l.trim(); + if trimmed.starts_with("RUSTSEC-") { + Some( + trimmed + .split_whitespace() + .next() + .unwrap() + .to_string(), + ) + } else { + None + } + }) + .collect() + } + + fn result_line(&self) -> &str { + self.stdout + .lines() + .find(|l| l.starts_with("RESULT:")) + .unwrap_or("RESULT: not found") + } +} + // ============================================================================= -// Phase 1: Enrichment pipeline tests +// Phase 1: Enrichment pipeline // ============================================================================= #[test] @@ -41,13 +154,10 @@ fn test_parse_fixture_advisories() { .expect("failed to run binary"); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - println!("STDOUT:\n{stdout}"); - println!("STDERR:\n{stderr}"); assert!( stdout.contains("Found 3 total advisories"), - "Expected 3 advisories, got: {stdout}" + "Expected 3 advisories:\n{stdout}" ); assert!(stdout.contains("RUSTSEC-2024-0001")); assert!(stdout.contains("RUSTSEC-2024-0002")); @@ -60,122 +170,245 @@ fn test_parse_fixture_advisories() { } // ============================================================================= -// Phase 2: Golden codebase integration tests +// Golden: vulnerable_project +// +// Fixture layout: +// Cargo.lock: hyper 0.14.27, smallvec 1.10.0, tokio 1.37.0, regex 1.9.6, +// serde_json 1.0.120 (patched) +// src/main.rs: module declarations only +// src/server.rs: Client::encode (qualified), decoder.decode (typed method) +// src/utils.rs: vec.insert_many (constructor-inferred), handle.abort (typed param) +// +// Expected vulnerable deps: 4 (hyper, smallvec, tokio, regex) +// Expected reachable: 4 (Client::encode, Decoder::decode, SmallVec::insert_many, +// JoinHandle::abort) +// Expected NOT reachable: regex::compile::Compiler::compile (internal, never called) +// Expected NOT listed: serde_json (version 1.0.120 >= 1.0.100 → patched) // ============================================================================= -/// Golden test: vulnerable project with multiple call patterns. -/// -/// The vulnerable_project fixture has: -/// - hyper 0.14.27 (vulnerable, patched >= 1.0.0) with calls to Client::encode and Decoder::decode -/// - smallvec 1.10.0 (vulnerable, patched >= 1.11.0) with calls to SmallVec::insert_many -/// - tokio 1.37.0 (vulnerable, patched >= 1.38.0) with calls to JoinHandle::abort -/// - regex 1.9.6 (vulnerable, patched >= 1.10.0) — present but Compiler::compile is NOT called #[test] fn golden_vulnerable_project() { let output = run_analyze("vulnerable_project", "golden_vuln_db.json"); - let stdout = String::from_utf8_lossy(&output.stdout); - println!("STDOUT:\n{stdout}"); + let r = Report::new(&output); + println!("{}", r.stdout); - // --- Should exit non-zero (vulnerable) --- + // Exit non-zero + assert!(!output.status.success()); + assert!(r.result_line().contains("VULNERABLE")); + + // Counts + assert_eq!(r.files_scanned(), 3, "3 .rs files in src/"); + assert_eq!(r.vulnerable_dep_count(), 4, "hyper + smallvec + tokio + regex"); + assert_eq!(r.reachable_symbol_count(), 4, "4 symbols reachable"); + + // Vulnerable deps listed (order may vary) + let listed = r.listed_advisories(); + assert!(listed.contains(&"RUSTSEC-2024-0001".to_string()), "hyper"); + assert!(listed.contains(&"RUSTSEC-2024-0010".to_string()), "smallvec"); + assert!(listed.contains(&"RUSTSEC-2024-0020".to_string()), "tokio"); + assert!(listed.contains(&"RUSTSEC-2024-0030".to_string()), "regex"); + assert_eq!(listed.len(), 4); + + // serde_json 1.0.120 is patched — should NOT be listed assert!( - !output.status.success(), - "Should exit with non-zero when vulnerabilities found" - ); - assert!( - stdout.contains("VULNERABLE"), - "Should report VULNERABLE status:\n{stdout}" + !r.stdout.contains("RUSTSEC-2024-0040"), + "serde_json is patched, should not appear" ); - // --- Should detect all four vulnerable dependencies --- - assert!(stdout.contains("RUSTSEC-2024-0001"), "hyper advisory missing:\n{stdout}"); - assert!(stdout.contains("RUSTSEC-2024-0010"), "smallvec advisory missing:\n{stdout}"); - assert!(stdout.contains("RUSTSEC-2024-0020"), "tokio advisory missing:\n{stdout}"); - assert!(stdout.contains("RUSTSEC-2024-0030"), "regex advisory missing:\n{stdout}"); - - // --- Should find reachable calls for hyper, smallvec, and tokio --- + // Reachable findings: exact set + let reachable = r.reachable_advisories(); + assert!(reachable.contains(&"RUSTSEC-2024-0001".to_string()), "hyper reachable"); + assert!(reachable.contains(&"RUSTSEC-2024-0010".to_string()), "smallvec reachable"); + assert!(reachable.contains(&"RUSTSEC-2024-0020".to_string()), "tokio reachable"); + // regex Compiler::compile is NOT reachable (internal symbol, user only calls Regex::new) assert!( - stdout.contains("Client::encode"), - "Should find Client::encode call:\n{stdout}" - ); - assert!( - stdout.contains("insert_many"), - "Should find SmallVec::insert_many call:\n{stdout}" - ); - assert!( - stdout.contains("abort"), - "Should find JoinHandle::abort call:\n{stdout}" + !reachable.contains(&"RUSTSEC-2024-0030".to_string()), + "regex Compiler::compile should NOT be reachable" ); - // --- regex Compiler::compile is NOT called in user code --- - // It's an internal symbol; user code only calls Regex::new. - // It should appear in "Vulnerable Dependencies" but NOT in "Reachable" findings. - let reachable_section = stdout - .split("Reachable Vulnerable Symbols") - .nth(1) - .unwrap_or(""); - assert!( - !reachable_section.contains("Compiler::compile"), - "Compiler::compile should NOT appear as reachable:\n{stdout}" - ); + // All findings are HIGH confidence + let highs = r.high_findings(); + assert_eq!(highs.len(), 4, "all 4 findings should be HIGH"); + assert_eq!(r.medium_findings().len(), 0, "no MEDIUM findings"); - // --- Should scan multiple source files --- + // Verify specific call sites assert!( - stdout.contains("3 source files"), - "Should scan all 3 .rs files:\n{stdout}" + highs.iter().any(|h| h.contains("src/server.rs:8") && h.contains("Client::encode")), + "Client::encode at server.rs:8:\n{:?}", + highs + ); + assert!( + highs.iter().any(|h| h.contains("src/server.rs:12") && h.contains("decoder.decode")), + "Decoder::decode at server.rs:12:\n{:?}", + highs + ); + assert!( + highs.iter().any(|h| h.contains("src/utils.rs:9") && h.contains("insert_many")), + "SmallVec::insert_many at utils.rs:9:\n{:?}", + highs + ); + assert!( + highs.iter().any(|h| h.contains("src/utils.rs:16") && h.contains("handle.abort")), + "JoinHandle::abort at utils.rs:16:\n{:?}", + highs ); } -/// Golden test: safe project where vulnerabilities exist in deps but aren't reachable. -/// -/// The safe_project fixture has: -/// - hyper 1.2.0 (PATCHED — version >= 1.0.0) → should not appear at all -/// - smallvec 1.11.0 (PATCHED — version >= 1.11.0) → should not appear at all -/// - tokio 1.37.0 (vulnerable) but abort() is NEVER called → dep listed, no findings -/// - regex 1.9.6 (vulnerable) but Compiler::compile is NEVER called → dep listed, no findings +// ============================================================================= +// Golden: safe_project +// +// Fixture layout: +// Cargo.lock: hyper 1.2.0 (patched), smallvec 1.11.0 (patched), +// tokio 1.37.0 (vulnerable), regex 1.9.6 (vulnerable) +// src/main.rs: module declaration +// src/handler.rs: uses Regex::new (not Compiler::compile), +// references JoinHandle but never calls .abort() +// +// Expected vulnerable deps: 2 (tokio, regex) +// Expected reachable: 0 (neither abort() nor Compiler::compile is called) +// Expected NOT listed: hyper (patched), smallvec (patched) +// ============================================================================= + #[test] fn golden_safe_project() { let output = run_analyze("safe_project", "golden_vuln_db.json"); - let stdout = String::from_utf8_lossy(&output.stdout); - println!("STDOUT:\n{stdout}"); + let r = Report::new(&output); + println!("{}", r.stdout); - // --- Should exit zero (safe / not reachable) --- + // Exit zero + assert!(output.status.success()); + assert!(r.result_line().contains("POSSIBLY SAFE")); + + // Counts + assert_eq!(r.files_scanned(), 2, "2 .rs files in src/"); + assert_eq!(r.vulnerable_dep_count(), 2, "tokio + regex"); + assert_eq!(r.reachable_symbol_count(), 0, "nothing reachable"); + + // Only tokio and regex listed (both vulnerable versions) + let listed = r.listed_advisories(); + assert!(listed.contains(&"RUSTSEC-2024-0020".to_string()), "tokio listed"); + assert!(listed.contains(&"RUSTSEC-2024-0030".to_string()), "regex listed"); + assert_eq!(listed.len(), 2); + + // Patched deps NOT listed + assert!(!r.stdout.contains("RUSTSEC-2024-0001"), "hyper is patched"); + assert!(!r.stdout.contains("RUSTSEC-2024-0010"), "smallvec is patched"); + + // No findings at all + assert!(r.high_findings().is_empty(), "no HIGH findings"); + assert!(r.medium_findings().is_empty(), "no MEDIUM findings"); + assert!(r.reachable_advisories().is_empty(), "no reachable advisories"); +} + +// ============================================================================= +// Golden: edge_cases_project +// +// Fixture layout: +// Cargo.lock: hyper 0.14.27, serde_json 1.0.90, cookie 0.17.0, tokio 1.37.0 +// src/main.rs: module declarations only +// src/aliased.rs: `use Decoder as HyperDecoder` → dec.decode(data) +// src/grouped.rs: `use cookie::parse::parse_cookie` (free fn), jar.add(c) (typed method) +// src/commented.rs: Client::encode & Deserializer::parse inside comments +// src/false_positive.rs: .abort() on MyHandle (wrong type), .parse() on String +// +// Expected vulnerable deps: 4 (hyper, tokio, serde_json, cookie) +// Expected reachable: 3 findings from 2 advisories +// - hyper::Decoder::decode via aliased import (HIGH) +// - cookie::parse_cookie free function call (HIGH) +// - cookie::CookieJar::add typed method call (HIGH) +// Expected NOT reachable: +// - hyper::Client::encode (commented out) +// - serde_json::Deserializer::parse (commented out) +// - tokio::JoinHandle::abort (only called on MyHandle, wrong type → suppressed) +// ============================================================================= + +#[test] +fn golden_edge_cases() { + let output = run_analyze("edge_cases_project", "golden_vuln_db.json"); + let r = Report::new(&output); + println!("{}", r.stdout); + + // Exit non-zero (some reachable findings exist) + assert!(!output.status.success()); + assert!(r.result_line().contains("VULNERABLE")); + + // Counts + assert_eq!(r.files_scanned(), 5, "5 .rs files in src/"); + assert_eq!(r.vulnerable_dep_count(), 4, "hyper + tokio + serde_json + cookie"); + assert_eq!(r.reachable_symbol_count(), 3, "3 symbols reachable"); + + // All 4 vulnerable deps listed + let listed = r.listed_advisories(); + assert!(listed.contains(&"RUSTSEC-2024-0001".to_string()), "hyper listed"); + assert!(listed.contains(&"RUSTSEC-2024-0020".to_string()), "tokio listed"); + assert!(listed.contains(&"RUSTSEC-2024-0040".to_string()), "serde_json listed"); + assert!(listed.contains(&"RUSTSEC-2024-0050".to_string()), "cookie listed"); + assert_eq!(listed.len(), 4); + + // Reachable: hyper (Decoder::decode only) and cookie (both symbols) + let reachable = r.reachable_advisories(); + assert!(reachable.contains(&"RUSTSEC-2024-0001".to_string()), "hyper Decoder::decode reachable"); + assert!(reachable.contains(&"RUSTSEC-2024-0050".to_string()), "cookie symbols reachable"); + + // NOT reachable: tokio and serde_json assert!( - output.status.success(), - "Should exit successfully when no reachable vulns:\n{stdout}" + !reachable.contains(&"RUSTSEC-2024-0020".to_string()), + "tokio abort() should be suppressed (called on wrong type MyHandle)" + ); + assert!( + !reachable.contains(&"RUSTSEC-2024-0040".to_string()), + "serde_json Deserializer::parse should be suppressed (inside comments)" ); - // --- Patched deps should NOT appear --- + // All findings are HIGH (type tracker resolved all receivers) + let highs = r.high_findings(); + assert_eq!(highs.len(), 3, "exactly 3 HIGH findings"); + assert_eq!(r.medium_findings().len(), 0, "no MEDIUM findings"); + + // Verify: aliased import → Decoder::decode detected assert!( - !stdout.contains("RUSTSEC-2024-0001"), - "hyper is patched (1.2.0), should not appear:\n{stdout}" - ); - assert!( - !stdout.contains("RUSTSEC-2024-0010"), - "smallvec is patched (1.11.0), should not appear:\n{stdout}" + highs.iter().any(|h| h.contains("src/aliased.rs:10") && h.contains("dec.decode")), + "Aliased import Decoder::decode at aliased.rs:10:\n{:?}", + highs ); - // --- Vulnerable but unreachable deps SHOULD appear in dep list --- + // Verify: free function parse_cookie detected assert!( - stdout.contains("RUSTSEC-2024-0020"), - "tokio advisory should be listed (vulnerable version):\n{stdout}" - ); - assert!( - stdout.contains("RUSTSEC-2024-0030"), - "regex advisory should be listed (vulnerable version):\n{stdout}" + highs.iter().any(|h| h.contains("src/grouped.rs:11") && h.contains("parse_cookie")), + "Free function parse_cookie at grouped.rs:11:\n{:?}", + highs ); - // --- But no reachable findings --- + // Verify: typed method CookieJar::add detected assert!( - stdout.contains("POSSIBLY SAFE"), - "Should report POSSIBLY SAFE (deps present but not reachable):\n{stdout}" + highs.iter().any(|h| h.contains("src/grouped.rs:15") && h.contains("jar.add")), + "CookieJar::add at grouped.rs:15:\n{:?}", + highs ); + + // Verify: Client::encode NOT found (commented out in commented.rs) assert!( - !stdout.contains("Reachable Vulnerable Symbols"), - "Should NOT have reachable symbols section:\n{stdout}" + !highs.iter().any(|h| h.contains("Client::encode")), + "Client::encode should not appear (it's in a comment)" + ); + + // Verify: Deserializer::parse NOT found (commented out) + assert!( + !highs.iter().any(|h| h.contains("Deserializer::parse")), + "Deserializer::parse should not appear (it's in a comment)" + ); + + // Verify: JoinHandle::abort NOT found (called on wrong type) + assert!( + !highs.iter().any(|h| h.contains("abort")), + "abort() should not appear (called on MyHandle, not JoinHandle)" ); } -/// Minimal test: project with zero vulnerable dependencies. +// ============================================================================= +// Golden: clean project (no vulnerable deps at all) +// ============================================================================= + #[test] fn golden_clean_project() { let tmp = tempfile::tempdir().unwrap(); @@ -216,15 +449,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" .output() .expect("failed to run binary"); - let stdout = String::from_utf8_lossy(&output.stdout); - println!("STDOUT:\n{stdout}"); + let r = Report::new(&output); + println!("{}", r.stdout); - assert!( - stdout.contains("CLEAN"), - "Should report CLEAN:\n{stdout}" - ); - assert!( - output.status.success(), - "Should exit successfully when clean" - ); + assert!(output.status.success()); + assert!(r.result_line().contains("CLEAN")); + assert_eq!(r.vulnerable_dep_count(), 0); + assert!(r.high_findings().is_empty()); + assert!(r.medium_findings().is_empty()); }