1
0
Fork 0
mirror of https://github.com/imjasonh/rustvulncheck synced 2026-07-08 12:15:03 +00:00
rustvulncheck/tests/integration_test.rs
Claude 76921cfde0
Precise golden integration tests and scanner fixes for edge cases
Scanner fixes:
- Block comment tracking: skip lines inside /* ... */ blocks
- Skip `use` statements from being matched as call sites
- Free function call detection: `parse_cookie(...)` after direct import
- Type-aware suppression: when the receiver's type is positively known
  and doesn't match the vulnerable type, suppress the finding entirely
  (e.g., .abort() on MyHandle vs JoinHandle)

Golden test improvements:
- All assertions are now specific: exact counts (deps, reachable symbols,
  files scanned), exact advisory IDs listed vs reachable, exact call site
  locations (file:line), exact confidence levels (HIGH vs MEDIUM)
- Each test documents its fixture layout and expected positive/negative
  findings in a header comment

New edge_cases_project fixture covering:
- Aliased imports (Decoder as HyperDecoder → dec.decode detected)
- Free function calls (parse_cookie after direct import)
- Commented-out code (block & line comments correctly skipped)
- False positive suppression (abort() on MyHandle, parse() on String)
- Typed method calls (jar.add(c) with explicit CookieJar type)

38 tests total (33 unit + 5 integration), all passing.

https://claude.ai/code/session_011sdj18ZvQYbDVwEKqrWDj1
2026-03-25 13:01:33 +00:00

460 lines
16 KiB
Rust

use std::path::PathBuf;
use std::process::Command;
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn run_analyze(project: &str, db: &str) -> std::process::Output {
let f = fixtures();
Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit"))
.args([
"analyze",
"--project",
f.join(project).to_str().unwrap(),
"--db",
f.join(db).to_str().unwrap(),
])
.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::<usize>() {
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<String> {
self.findings_with_tag("[HIGH]")
}
/// Return all `[MEDIUM] file:line → snippet` entries from the reachable section.
fn medium_findings(&self) -> Vec<String> {
self.findings_with_tag("[MEDIUM]")
}
fn findings_with_tag(&self, tag: &str) -> Vec<String> {
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<String> {
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<String> {
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
// =============================================================================
#[test]
fn test_parse_fixture_advisories() {
let f = fixtures();
let output = Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit"))
.args([
"enrich",
"--advisory-db",
f.to_str().unwrap(),
"--limit",
"10",
"--include-all",
"--output",
"/tmp/test_vuln_db.json",
])
.output()
.expect("failed to run binary");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Found 3 total advisories"),
"Expected 3 advisories:\n{stdout}"
);
assert!(stdout.contains("RUSTSEC-2024-0001"));
assert!(stdout.contains("RUSTSEC-2024-0002"));
assert!(stdout.contains("RUSTSEC-2024-0003"));
assert!(stdout.contains("Wrote enriched database to /tmp/test_vuln_db.json"));
let json_content = std::fs::read_to_string("/tmp/test_vuln_db.json").unwrap();
let db: serde_json::Value = serde_json::from_str(&json_content).unwrap();
assert!(!db["entries"].as_array().unwrap().is_empty());
}
// =============================================================================
// 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)
// =============================================================================
#[test]
fn golden_vulnerable_project() {
let output = run_analyze("vulnerable_project", "golden_vuln_db.json");
let r = Report::new(&output);
println!("{}", r.stdout);
// 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!(
!r.stdout.contains("RUSTSEC-2024-0040"),
"serde_json is patched, should not appear"
);
// 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!(
!reachable.contains(&"RUSTSEC-2024-0030".to_string()),
"regex Compiler::compile should NOT be reachable"
);
// 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");
// Verify specific call sites
assert!(
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: 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 r = Report::new(&output);
println!("{}", r.stdout);
// 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!(
!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)"
);
// 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!(
highs.iter().any(|h| h.contains("src/aliased.rs:10") && h.contains("dec.decode")),
"Aliased import Decoder::decode at aliased.rs:10:\n{:?}",
highs
);
// Verify: free function parse_cookie detected
assert!(
highs.iter().any(|h| h.contains("src/grouped.rs:11") && h.contains("parse_cookie")),
"Free function parse_cookie at grouped.rs:11:\n{:?}",
highs
);
// Verify: typed method CookieJar::add detected
assert!(
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!(
!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)"
);
}
// =============================================================================
// Golden: clean project (no vulnerable deps at all)
// =============================================================================
#[test]
fn golden_clean_project() {
let tmp = tempfile::tempdir().unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(
tmp.path().join("Cargo.lock"),
r#"version = 3
[[package]]
name = "clean-app"
version = "0.1.0"
[[package]]
name = "serde"
version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#,
)
.unwrap();
std::fs::write(
src_dir.join("main.rs"),
"fn main() { println!(\"Hello\"); }\n",
)
.unwrap();
let f = fixtures();
let output = Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit"))
.args([
"analyze",
"--project",
tmp.path().to_str().unwrap(),
"--db",
f.join("golden_vuln_db.json").to_str().unwrap(),
])
.output()
.expect("failed to run binary");
let r = Report::new(&output);
println!("{}", r.stdout);
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());
}