From a2db0d4d1517642607a4d152fcfcc9b6f2f07e04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 12:44:13 +0000 Subject: [PATCH] Add syn-based type tracking and golden codebase integration tests Type tracking (type_tracker.rs): - Uses syn AST parsing to resolve variable types from function parameters, let bindings with annotations, constructor calls (Type::new()), struct literals, builder patterns, and closure parameters - Integrated into scanner to promote method-call matches from Medium to High confidence when the receiver's type is known Golden integration tests: - vulnerable_project: 4 vulnerable deps, 3 with reachable calls across multiple files (qualified calls, typed method calls, constructor-inferred types). Verifies regex dep is listed but Compiler::compile is NOT reachable. - safe_project: patched deps (hyper, smallvec) excluded, vulnerable deps (tokio, regex) listed but no symbols reachable. Reports POSSIBLY SAFE. - clean_project: no vulnerable deps at all. Reports CLEAN. 37 tests total (33 unit + 4 integration), all passing. https://claude.ai/code/session_011sdj18ZvQYbDVwEKqrWDj1 --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 1 + src/scanner.rs | 203 +++++++- src/type_tracker.rs | 471 ++++++++++++++++++ tests/fixtures/golden_vuln_db.json | 70 +++ tests/fixtures/safe_project/Cargo.lock | 27 + tests/fixtures/safe_project/src/handler.rs | 24 + tests/fixtures/safe_project/src/main.rs | 5 + tests/fixtures/vulnerable_project/Cargo.lock | 27 + tests/fixtures/vulnerable_project/src/main.rs | 6 + .../fixtures/vulnerable_project/src/server.rs | 15 + .../fixtures/vulnerable_project/src/utils.rs | 21 + tests/integration_test.rs | 224 ++++++--- 14 files changed, 1013 insertions(+), 83 deletions(-) create mode 100644 src/type_tracker.rs create mode 100644 tests/fixtures/golden_vuln_db.json create mode 100644 tests/fixtures/safe_project/Cargo.lock create mode 100644 tests/fixtures/safe_project/src/handler.rs create mode 100644 tests/fixtures/safe_project/src/main.rs create mode 100644 tests/fixtures/vulnerable_project/Cargo.lock create mode 100644 tests/fixtures/vulnerable_project/src/main.rs create mode 100644 tests/fixtures/vulnerable_project/src/server.rs create mode 100644 tests/fixtures/vulnerable_project/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 7177d70..73b0570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,7 @@ dependencies = [ "semver", "serde", "serde_json", + "syn", "tempfile", "toml", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index 9bf64a3..33de946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ git2 = "0.19" walkdir = "2" anyhow = "1" semver = "1" +syn = { version = "2", features = ["full", "visit"] } [dev-dependencies] tempfile = "3" diff --git a/src/main.rs b/src/main.rs index 1e3dcad..b95ebce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod diff_analyzer; mod github; mod lockfile; mod scanner; +mod type_tracker; use std::path::{Path, PathBuf}; use std::process; diff --git a/src/scanner.rs b/src/scanner.rs index 1e60dca..7678450 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -10,6 +10,8 @@ use std::path::{Path, PathBuf}; use regex::Regex; +use crate::type_tracker::{self, ImportMap, TypeBinding}; + /// Confidence that a call site actually invokes the vulnerable symbol. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Confidence { @@ -78,7 +80,16 @@ pub fn scan_for_symbols(project_root: &Path, symbols: &[&str]) -> Vec .to_path_buf(); let imports = extract_imports(&content); - let sites = find_call_sites(&rel_path, &content, &imports, symbols); + + // Build import map for type resolution and extract type bindings + let syn_import_map: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let type_bindings = type_tracker::extract_type_bindings(&content, &syn_import_map); + + let sites = find_call_sites(&rel_path, &content, &imports, &type_bindings, symbols); all_sites.extend(sites); } @@ -173,6 +184,7 @@ fn find_call_sites( file_path: &Path, source: &str, imports: &[Import], + type_bindings: &[TypeBinding], symbols: &[&str], ) -> Vec { let mut sites = Vec::new(); @@ -261,15 +273,38 @@ fn find_call_sites( continue; } - // Check for method-style calls (Medium confidence) - // Only flag if the type is imported in this file + // Check for method-style calls: `.foo(` + // Use type bindings to determine confidence level. if type_is_imported && line.contains(&method_pattern) { + // Try to extract the receiver variable name from `receiver.method(` + let confidence = if let Some(receiver_var) = + extract_method_receiver(trimmed, fn_name) + { + // Check if we know this variable's type via syn-based tracking + let receiver_matches = type_bindings.iter().any(|b| { + b.var_name == receiver_var && b.type_path == parent_path + }); + // Also check self.field patterns against struct field bindings + let field_matches = receiver_var.starts_with("self.") + && type_bindings.iter().any(|b| { + b.var_name.ends_with(&format!(".{}", &receiver_var[5..])) + && b.type_path == parent_path + }); + if receiver_matches || field_matches { + Confidence::High + } else { + Confidence::Medium + } + } else { + Confidence::Medium + }; + sites.push(CallSite { file: file_path.to_path_buf(), line: line_num + 1, snippet: trimmed.to_string(), symbol: symbol.to_string(), - confidence: Confidence::Medium, + confidence, }); } } @@ -287,6 +322,50 @@ fn find_call_sites( sites } +/// Extract the receiver variable name from a method call like `receiver.method(`. +/// +/// Handles common patterns: +/// - `foo.method(` → Some("foo") +/// - `self.field.method(` → Some("self.field") +/// - `foo.bar.method(` → Some("bar") (last segment before method) +/// - `foo.method().chain(` → None (chained calls) +/// - `(expr).method(` → None (complex expressions) +fn extract_method_receiver(line: &str, method_name: &str) -> Option { + let pattern = format!(".{}(", method_name); + let pos = line.find(&pattern)?; + let before = &line[..pos]; + + // Walk backwards to find the receiver token + let before = before.trim_end(); + if before.is_empty() { + return None; + } + + // Skip chained calls: if there's a `)` right before, it's a chain + if before.ends_with(')') || before.ends_with('}') || before.ends_with(']') { + return None; + } + + // Extract the last identifier chain (e.g., `self.field` or `variable`) + let receiver: String = before + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.') + .collect::() + .chars() + .rev() + .collect(); + + // Clean up: remove leading dots + let receiver = receiver.trim_start_matches('.').to_string(); + + if receiver.is_empty() || receiver.starts_with('.') { + return None; + } + + Some(receiver) +} + #[cfg(test)] mod tests { use super::*; @@ -336,10 +415,17 @@ fn handler() { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, &["hyper::http::Request::parse"], ); assert_eq!(sites.len(), 1); @@ -356,10 +442,17 @@ fn handler() { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, &["hyper::http::Request::parse"], ); assert_eq!(sites.len(), 1); @@ -367,7 +460,9 @@ fn handler() { } #[test] - fn test_find_method_call_medium_confidence() { + fn test_method_call_promoted_to_high_with_type_info() { + // With syn-based type tracking, fn parameter `req: Request` gives us + // enough info to promote the method call to High confidence. let source = r#" use hyper::http::Request; @@ -376,10 +471,46 @@ fn handler(req: Request) { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, + &["hyper::http::Request::parse"], + ); + assert_eq!(sites.len(), 1); + assert_eq!(sites[0].confidence, Confidence::High); + } + + #[test] + fn test_method_call_stays_medium_without_type_info() { + // If we can't determine the receiver's type, stays Medium. + let source = r#" +use hyper::http::Request; + +fn handler() { + let req = get_something(); + let result = req.parse(data); +} +"#; + let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); + let sites = find_call_sites( + Path::new("src/main.rs"), + source, + &imports, + &bindings, &["hyper::http::Request::parse"], ); assert_eq!(sites.len(), 1); @@ -395,10 +526,17 @@ fn handler(s: String) { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, &["hyper::http::Request::parse"], ); assert!(sites.is_empty()); @@ -414,10 +552,17 @@ fn handler() { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, &["hyper::http::Request::parse"], ); assert_eq!(sites.len(), 1); @@ -435,12 +580,60 @@ fn handler() { } "#; let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); let sites = find_call_sites( Path::new("src/main.rs"), source, &imports, + &bindings, &["hyper::http::Request::parse"], ); assert!(sites.is_empty()); } + + #[test] + fn test_extract_method_receiver() { + assert_eq!(extract_method_receiver("req.parse(data)", "parse"), Some("req".to_string())); + assert_eq!(extract_method_receiver("self.client.send(msg)", "send"), Some("self.client".to_string())); + assert_eq!(extract_method_receiver(" foo.bar.method(x)", "method"), Some("foo.bar".to_string())); + // Chained calls: can't determine receiver + assert_eq!(extract_method_receiver("get_req().parse(data)", "parse"), None); + // Complex expression + assert_eq!(extract_method_receiver("(a + b).parse(data)", "parse"), None); + } + + #[test] + fn test_constructor_promotes_to_high() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let req = Request::new(body); + req.parse(data); +} +"#; + let imports = extract_imports(source); + let imap: ImportMap = imports + .iter() + .filter(|i| i.local_name != "*") + .map(|i| (i.local_name.clone(), i.full_path.clone())) + .collect(); + let bindings = type_tracker::extract_type_bindings(source, &imap); + let sites = find_call_sites( + Path::new("src/main.rs"), + source, + &imports, + &bindings, + &["hyper::http::Request::parse"], + ); + // Should find 2 sites: the Request::new (high, qualified) and req.parse (high, type-tracked) + let method_sites: Vec<_> = sites.iter().filter(|s| s.snippet.contains(".parse")).collect(); + assert_eq!(method_sites.len(), 1); + assert_eq!(method_sites[0].confidence, Confidence::High); + } } diff --git a/src/type_tracker.rs b/src/type_tracker.rs new file mode 100644 index 0000000..7a900c1 --- /dev/null +++ b/src/type_tracker.rs @@ -0,0 +1,471 @@ +//! Track variable types through Rust source code using `syn` AST parsing. +//! +//! Builds a map of variable names to their resolved types by analyzing: +//! - Function/method parameters with type annotations +//! - Let bindings with explicit type annotations +//! - Let bindings with constructor calls (e.g., `let x = Foo::new()`) +//! - Struct/enum field definitions +//! - Closure parameters with type annotations +//! - For-loop bindings where the iterator type is known +//! +//! Combined with import resolution, this allows promoting method-call +//! matches from medium to high confidence. + +use std::collections::HashMap; + +use syn::visit::Visit; + +/// A resolved type binding: variable name → qualified type path. +/// +/// The qualified path uses the full crate path where possible +/// (e.g., `hyper::http::Request` rather than just `Request`). +#[derive(Debug, Clone)] +pub struct TypeBinding { + pub var_name: String, + pub type_path: String, +} + +/// Mapping from local type names to fully-qualified paths via imports. +/// e.g., `Request` → `hyper::http::Request` +pub type ImportMap = HashMap; + +/// Extract all variable → type bindings from a Rust source file. +/// +/// Uses `syn` to parse the AST and walks function bodies to find +/// type annotations and constructor patterns. Resolves short type +/// names to fully-qualified paths using the provided import map. +pub fn extract_type_bindings(source: &str, import_map: &ImportMap) -> Vec { + let file = match syn::parse_file(source) { + Ok(f) => f, + Err(_) => return Vec::new(), // Unparseable → no bindings + }; + + let mut visitor = TypeVisitor { + import_map, + bindings: Vec::new(), + }; + visitor.visit_file(&file); + visitor.bindings +} + +/// AST visitor that collects variable → type bindings. +struct TypeVisitor<'a> { + import_map: &'a ImportMap, + bindings: Vec, +} + +impl<'a> TypeVisitor<'a> { + /// Resolve a type path to its fully-qualified form using the import map. + /// + /// Given a syn `Path` like `Request` or `http::Request`, look up the + /// leading segment in the import map to expand it. + fn resolve_type_path(&self, path: &syn::Path) -> Option { + let segments: Vec = path + .segments + .iter() + .map(|seg| seg.ident.to_string()) + .collect(); + + if segments.is_empty() { + return None; + } + + // Try resolving the first segment via imports + let first = &segments[0]; + if let Some(full) = self.import_map.get(first.as_str()) { + if segments.len() == 1 { + return Some(full.clone()); + } + // Multi-segment: e.g., `http::Request` where `http` is imported + let rest = segments[1..].join("::"); + return Some(format!("{}::{}", full, rest)); + } + + // No import match — return the path as-is + Some(segments.join("::")) + } + + /// Extract the type name from a syn::Type, if it's a simple path type. + fn extract_type_name(&self, ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(type_path) => self.resolve_type_path(&type_path.path), + syn::Type::Reference(type_ref) => { + // &T or &mut T → resolve T + self.extract_type_name(&type_ref.elem) + } + _ => None, + } + } + + /// Try to infer the type from an expression (right-hand side of a let binding). + /// + /// Handles: + /// - `Type::new()`, `Type::default()`, `Type::from(...)` — constructor patterns + /// - `Type::builder().build()` — builder patterns (uses the initial type) + /// - `Type { field: value }` — struct literals + fn infer_type_from_expr(&self, expr: &syn::Expr) -> Option { + match expr { + // Type::method(...) — associated function call (constructor pattern) + syn::Expr::Call(call) => { + if let syn::Expr::Path(path_expr) = call.func.as_ref() { + let segments: Vec<&syn::PathSegment> = + path_expr.path.segments.iter().collect(); + // Need at least 2 segments: Type::new + if segments.len() >= 2 { + // The type is everything except the last segment (the method name) + let type_segments: Vec = segments[..segments.len() - 1] + .iter() + .map(|s| s.ident.to_string()) + .collect(); + let type_name = type_segments.join("::"); + + // Resolve through imports + if type_segments.len() == 1 { + if let Some(full) = self.import_map.get(&type_name) { + return Some(full.clone()); + } + } + return Some(type_name); + } + } + None + } + + // expr.method(...) — method chain; try to infer from the root + syn::Expr::MethodCall(method_call) => self.infer_type_from_expr(&method_call.receiver), + + // expr? — try operator; infer from inner + syn::Expr::Try(try_expr) => self.infer_type_from_expr(&try_expr.expr), + + // (expr) — parenthesized + syn::Expr::Paren(paren) => self.infer_type_from_expr(&paren.expr), + + // Type { field: value } — struct literal + syn::Expr::Struct(struct_expr) => self.resolve_type_path(&struct_expr.path), + + // await expressions: expr.await — infer from inner + syn::Expr::Await(await_expr) => self.infer_type_from_expr(&await_expr.base), + + _ => None, + } + } + + /// Record a binding from a pattern + type. + fn record_pat_type(&mut self, pat: &syn::Pat, type_path: &str) { + match pat { + syn::Pat::Ident(pat_ident) => { + self.bindings.push(TypeBinding { + var_name: pat_ident.ident.to_string(), + type_path: type_path.to_string(), + }); + } + // Destructuring: let (a, b): (TypeA, TypeB) — skip for now + // Ref patterns: let ref x: Type — extract x + syn::Pat::Reference(pat_ref) => { + self.record_pat_type(&pat_ref.pat, type_path); + } + _ => {} + } + } +} + +impl<'a, 'ast> Visit<'ast> for TypeVisitor<'a> { + /// Visit function/method signatures to extract parameter types. + fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { + for param in &node.sig.inputs { + if let syn::FnArg::Typed(pat_type) = param { + if let Some(type_name) = self.extract_type_name(&pat_type.ty) { + self.record_pat_type(&pat_type.pat, &type_name); + } + } + } + // Continue visiting the function body + syn::visit::visit_item_fn(self, node); + } + + /// Visit impl method signatures. + fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) { + for param in &node.sig.inputs { + if let syn::FnArg::Typed(pat_type) = param { + if let Some(type_name) = self.extract_type_name(&pat_type.ty) { + self.record_pat_type(&pat_type.pat, &type_name); + } + } + } + syn::visit::visit_impl_item_fn(self, node); + } + + /// Visit let bindings for explicit type annotations and constructor inference. + fn visit_local(&mut self, node: &'ast syn::Local) { + // Case 1: `let x: Type = ...` — explicit annotation + if let syn::Pat::Type(pat_type) = &node.pat { + if let Some(type_name) = self.extract_type_name(&pat_type.ty) { + self.record_pat_type(&pat_type.pat, &type_name); + } + } + + // Case 2: `let x = Type::new()` — infer from constructor + if let Some(init) = &node.init { + if let Some(type_name) = self.infer_type_from_expr(&init.expr) { + // Only record if we didn't already get a type from annotation + let var_name = extract_pat_name(&node.pat); + if let Some(name) = var_name { + if !self.bindings.iter().any(|b| b.var_name == name) { + self.bindings.push(TypeBinding { + var_name: name, + type_path: type_name, + }); + } + } + } + } + + syn::visit::visit_local(self, node); + } + + /// Visit closure parameters with type annotations. + fn visit_expr_closure(&mut self, node: &'ast syn::ExprClosure) { + for input in &node.inputs { + if let syn::Pat::Type(pat_type) = input { + if let Some(type_name) = self.extract_type_name(&pat_type.ty) { + self.record_pat_type(&pat_type.pat, &type_name); + } + } + } + syn::visit::visit_expr_closure(self, node); + } + + /// Visit struct definitions to track field types. + fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { + let struct_name = node.ident.to_string(); + for field in &node.fields { + if let Some(ident) = &field.ident { + if let Some(type_name) = self.extract_type_name(&field.ty) { + // Record as "StructName.field_name" for field access tracking + self.bindings.push(TypeBinding { + var_name: format!("{}.{}", struct_name, ident), + type_path: type_name, + }); + } + } + } + syn::visit::visit_item_struct(self, node); + } +} + +/// Extract a simple variable name from a pattern. +fn extract_pat_name(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(ident) => Some(ident.ident.to_string()), + syn::Pat::Type(pat_type) => extract_pat_name(&pat_type.pat), + syn::Pat::Reference(pat_ref) => extract_pat_name(&pat_ref.pat), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_import_map(pairs: &[(&str, &str)]) -> ImportMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn test_explicit_type_annotation() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let req: Request = get_request(); + req.parse(); +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected req → hyper::http::Request, got: {:?}", + bindings + ); + } + + #[test] + fn test_constructor_inference() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let req = Request::new(body); +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected req → hyper::http::Request from constructor, got: {:?}", + bindings + ); + } + + #[test] + fn test_function_parameter() { + let source = r#" +use hyper::http::Request; + +fn handle_request(req: Request, name: String) { + req.parse(); +} +"#; + let imports = make_import_map(&[ + ("Request", "hyper::http::Request"), + ("String", "String"), + ]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected req from fn param, got: {:?}", + bindings + ); + } + + #[test] + fn test_reference_type() { + let source = r#" +use hyper::http::Request; + +fn handle_request(req: &mut Request) { + req.parse(); +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Should see through &mut, got: {:?}", + bindings + ); + } + + #[test] + fn test_struct_literal() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let req = Request { method: "GET", path: "/" }; +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected struct literal inference, got: {:?}", + bindings + ); + } + + #[test] + fn test_builder_pattern() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let req = Request::builder().uri("/").body(()).unwrap(); +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + // Builder chains resolve to the root type + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected builder pattern inference, got: {:?}", + bindings + ); + } + + #[test] + fn test_no_type_info() { + let source = r#" +fn handler() { + let x = something(); + x.parse(); +} +"#; + let imports = make_import_map(&[]); + let bindings = extract_type_bindings(source, &imports); + + // No bindings for x since we can't determine the type + assert!( + !bindings.iter().any(|b| b.var_name == "x"), + "Should not infer type from unknown function call, got: {:?}", + bindings + ); + } + + #[test] + fn test_struct_field_types() { + let source = r#" +use hyper::Client; + +struct MyApp { + client: Client, + name: String, +} +"#; + let imports = make_import_map(&[("Client", "hyper::Client")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "MyApp.client" && b.type_path == "hyper::Client"), + "Expected struct field tracking, got: {:?}", + bindings + ); + } + + #[test] + fn test_closure_param() { + let source = r#" +use hyper::http::Request; + +fn handler() { + let f = |req: Request| { + req.parse(); + }; +} +"#; + let imports = make_import_map(&[("Request", "hyper::http::Request")]); + let bindings = extract_type_bindings(source, &imports); + + assert!( + bindings + .iter() + .any(|b| b.var_name == "req" && b.type_path == "hyper::http::Request"), + "Expected closure param type, got: {:?}", + bindings + ); + } +} diff --git a/tests/fixtures/golden_vuln_db.json b/tests/fixtures/golden_vuln_db.json new file mode 100644 index 0000000..151353a --- /dev/null +++ b/tests/fixtures/golden_vuln_db.json @@ -0,0 +1,70 @@ +{ + "generated_at": "unix:1700000000", + "entries": [ + { + "advisory_id": "RUSTSEC-2024-0001", + "package": "hyper", + "title": "Lenient HTTP header parsing allows request smuggling", + "date": "2024-01-15", + "patched_versions": [">= 1.0.0"], + "commit_sha": "abc123", + "vulnerable_symbols": [ + { + "file": "src/proto/h1/role.rs", + "function": "hyper::proto::h1::role::Client::encode", + "change_type": "Modified" + }, + { + "file": "src/proto/h1/decode.rs", + "function": "hyper::proto::h1::decode::Decoder::decode", + "change_type": "Modified" + } + ] + }, + { + "advisory_id": "RUSTSEC-2024-0010", + "package": "smallvec", + "title": "Buffer overflow in SmallVec::insert_many", + "date": "2024-02-20", + "patched_versions": [">= 1.11.0"], + "commit_sha": "def456", + "vulnerable_symbols": [ + { + "file": "src/lib.rs", + "function": "smallvec::SmallVec::insert_many", + "change_type": "Modified" + } + ] + }, + { + "advisory_id": "RUSTSEC-2024-0020", + "package": "tokio", + "title": "Race condition in task abort", + "date": "2024-03-01", + "patched_versions": [">= 1.38.0"], + "commit_sha": "ghi789", + "vulnerable_symbols": [ + { + "file": "src/runtime/task/mod.rs", + "function": "tokio::runtime::task::JoinHandle::abort", + "change_type": "Modified" + } + ] + }, + { + "advisory_id": "RUSTSEC-2024-0030", + "package": "regex", + "title": "ReDoS in regex compilation", + "date": "2024-04-01", + "patched_versions": [">= 1.10.0"], + "commit_sha": "jkl012", + "vulnerable_symbols": [ + { + "file": "src/compile.rs", + "function": "regex::compile::Compiler::compile", + "change_type": "Modified" + } + ] + } + ] +} diff --git a/tests/fixtures/safe_project/Cargo.lock b/tests/fixtures/safe_project/Cargo.lock new file mode 100644 index 0000000..5f29f75 --- /dev/null +++ b/tests/fixtures/safe_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 = "my-safe-app" +version = "0.1.0" + +[[package]] +name = "hyper" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "smallvec" +version = "1.11.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" + +[[package]] +name = "regex" +version = "1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tests/fixtures/safe_project/src/handler.rs b/tests/fixtures/safe_project/src/handler.rs new file mode 100644 index 0000000..e1381c1 --- /dev/null +++ b/tests/fixtures/safe_project/src/handler.rs @@ -0,0 +1,24 @@ +//! Handler module. +//! +//! Uses tokio and regex, but does NOT call the vulnerable symbols. +//! - tokio is at a vulnerable version but abort() is never called +//! - regex is at a vulnerable version but Compiler::compile is internal and not called +//! - hyper is at a PATCHED version (1.2.0 >= 1.0.0) +//! - smallvec is at a PATCHED version (1.11.0 >= 1.11.0) + +use tokio::runtime::task::JoinHandle; +use regex::Regex; + +pub fn run() { + // Uses regex but NOT the vulnerable Compiler::compile — just the public API + let re = Regex::new(r"\d+").unwrap(); + let matched = re.is_match("hello 123"); + println!("Matched: {}", matched); + + // References JoinHandle type but never calls .abort() + let handles: Vec = Vec::new(); + for handle in handles { + // Just awaits, doesn't abort + println!("waiting on handle"); + } +} diff --git a/tests/fixtures/safe_project/src/main.rs b/tests/fixtures/safe_project/src/main.rs new file mode 100644 index 0000000..661a235 --- /dev/null +++ b/tests/fixtures/safe_project/src/main.rs @@ -0,0 +1,5 @@ +mod handler; + +fn main() { + handler::run(); +} diff --git a/tests/fixtures/vulnerable_project/Cargo.lock b/tests/fixtures/vulnerable_project/Cargo.lock new file mode 100644 index 0000000..904b8f7 --- /dev/null +++ b/tests/fixtures/vulnerable_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 = "my-vulnerable-app" +version = "0.1.0" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "smallvec" +version = "1.10.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" + +[[package]] +name = "regex" +version = "1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/tests/fixtures/vulnerable_project/src/main.rs b/tests/fixtures/vulnerable_project/src/main.rs new file mode 100644 index 0000000..b40f764 --- /dev/null +++ b/tests/fixtures/vulnerable_project/src/main.rs @@ -0,0 +1,6 @@ +mod server; +mod utils; + +fn main() { + server::run_server(); +} diff --git a/tests/fixtures/vulnerable_project/src/server.rs b/tests/fixtures/vulnerable_project/src/server.rs new file mode 100644 index 0000000..f545e03 --- /dev/null +++ b/tests/fixtures/vulnerable_project/src/server.rs @@ -0,0 +1,15 @@ +//! Server module that uses hyper for HTTP handling. + +use hyper::proto::h1::role::Client; +use hyper::proto::h1::decode::Decoder; + +pub fn run_server() { + // Case 1: Qualified associated-function call (HIGH confidence expected) + let client = Client::encode(raw_request); + + // Case 2: Method call on typed variable (HIGH confidence with type tracking) + let decoder: Decoder = Decoder::new(); + let result = decoder.decode(buf); + + println!("Server running: {:?} {:?}", client, result); +} diff --git a/tests/fixtures/vulnerable_project/src/utils.rs b/tests/fixtures/vulnerable_project/src/utils.rs new file mode 100644 index 0000000..489e837 --- /dev/null +++ b/tests/fixtures/vulnerable_project/src/utils.rs @@ -0,0 +1,21 @@ +//! Utility module with additional vulnerable calls. + +use smallvec::SmallVec; +use tokio::runtime::task::JoinHandle; + +pub fn process_data(items: &[u8]) { + // Case 3: Method call on constructor-inferred type (HIGH with type tracking) + let mut vec = SmallVec::new(); + vec.insert_many(0, items.iter().copied()); + + // Case 4: Method call on typed parameter (HIGH with type tracking) + cancel_task(get_handle()); +} + +fn cancel_task(handle: JoinHandle) { + handle.abort(); +} + +fn get_handle() -> JoinHandle { + todo!() +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index bd2e274..5249707 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,15 +1,36 @@ 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") +} + +// ============================================================================= +// Phase 1: Enrichment pipeline tests +// ============================================================================= + #[test] fn test_parse_fixture_advisories() { - let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - + let f = fixtures(); let output = Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit")) .args([ "enrich", "--advisory-db", - fixtures.to_str().unwrap(), + f.to_str().unwrap(), "--limit", "10", "--include-all", @@ -21,92 +42,142 @@ fn test_parse_fixture_advisories() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - println!("STDOUT:\n{}", stdout); - println!("STDERR:\n{}", stderr); + println!("STDOUT:\n{stdout}"); + println!("STDERR:\n{stderr}"); - // Should find all 3 fixture advisories assert!( stdout.contains("Found 3 total advisories"), - "Expected 3 advisories, got: {}", - stdout + "Expected 3 advisories, got: {stdout}" ); - - // Should identify hyper and tokio as having GitHub refs assert!(stdout.contains("RUSTSEC-2024-0001")); assert!(stdout.contains("RUSTSEC-2024-0002")); assert!(stdout.contains("RUSTSEC-2024-0003")); - - // Should write output file assert!(stdout.contains("Wrote enriched database to /tmp/test_vuln_db.json")); - // Verify JSON output is valid 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().len() > 0); + assert!(!db["entries"].as_array().unwrap().is_empty()); } +// ============================================================================= +// Phase 2: Golden codebase integration tests +// ============================================================================= + +/// 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 test_analyze_detects_vulnerable_call() { - let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let test_project = fixtures.join("test_project"); - let vuln_db = fixtures.join("test_vuln_db.json"); - - let output = Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit")) - .args([ - "analyze", - "--project", - test_project.to_str().unwrap(), - "--db", - vuln_db.to_str().unwrap(), - ]) - .output() - .expect("failed to run binary"); - +fn golden_vulnerable_project() { + let output = run_analyze("vulnerable_project", "golden_vuln_db.json"); let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - println!("STDOUT:\n{}", stdout); - println!("STDERR:\n{}", stderr); + println!("STDOUT:\n{stdout}"); - // Should find hyper and tokio as vulnerable deps (both below patched versions) - assert!( - stdout.contains("RUSTSEC-2024-0001"), - "Should detect hyper advisory" - ); - assert!( - stdout.contains("RUSTSEC-2024-0002"), - "Should detect tokio advisory" - ); - - // serde 1.0.190 is above patched version >= 1.0.180, should NOT appear - assert!( - !stdout.contains("RUSTSEC-2024-0099"), - "serde should not be flagged as vulnerable (version is patched)" - ); - - // Should detect the Request::parse call in the test project - assert!( - stdout.contains("Request::parse"), - "Should find the vulnerable call site: {}", - stdout - ); - - // Should report as VULNERABLE since a reachable symbol was found - assert!( - stdout.contains("VULNERABLE"), - "Should report vulnerable status: {}", - stdout - ); - - // Exit code should be 1 (vulnerable) + // --- Should exit non-zero (vulnerable) --- assert!( !output.status.success(), "Should exit with non-zero when vulnerabilities found" ); + assert!( + stdout.contains("VULNERABLE"), + "Should report VULNERABLE status:\n{stdout}" + ); + + // --- 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 --- + 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}" + ); + + // --- 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}" + ); + + // --- Should scan multiple source files --- + assert!( + stdout.contains("3 source files"), + "Should scan all 3 .rs files:\n{stdout}" + ); } +/// 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 #[test] -fn test_analyze_clean_project() { - // Create a temporary project with no vulnerable calls +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}"); + + // --- Should exit zero (safe / not reachable) --- + assert!( + output.status.success(), + "Should exit successfully when no reachable vulns:\n{stdout}" + ); + + // --- Patched deps should NOT appear --- + 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}" + ); + + // --- Vulnerable but unreachable deps SHOULD appear in dep list --- + 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}" + ); + + // --- But no reachable findings --- + assert!( + stdout.contains("POSSIBLY SAFE"), + "Should report POSSIBLY SAFE (deps present but not reachable):\n{stdout}" + ); + assert!( + !stdout.contains("Reachable Vulnerable Symbols"), + "Should NOT have reachable symbols section:\n{stdout}" + ); +} + +/// Minimal test: project with zero vulnerable dependencies. +#[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(); @@ -116,7 +187,7 @@ fn test_analyze_clean_project() { r#"version = 3 [[package]] -name = "safe-app" +name = "clean-app" version = "0.1.0" [[package]] @@ -133,30 +204,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ) .unwrap(); - let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let vuln_db = fixtures.join("test_vuln_db.json"); - + let f = fixtures(); let output = Command::new(env!("CARGO_BIN_EXE_cargo-deep-audit")) .args([ "analyze", "--project", tmp.path().to_str().unwrap(), "--db", - vuln_db.to_str().unwrap(), + f.join("golden_vuln_db.json").to_str().unwrap(), ]) .output() .expect("failed to run binary"); let stdout = String::from_utf8_lossy(&output.stdout); - println!("STDOUT:\n{}", stdout); + println!("STDOUT:\n{stdout}"); - // Should report clean assert!( stdout.contains("CLEAN"), - "Should report clean status: {}", - stdout + "Should report CLEAN:\n{stdout}" + ); + assert!( + output.status.success(), + "Should exit successfully when clean" ); - - // Exit code should be 0 - assert!(output.status.success(), "Should exit successfully when clean"); }