From caba45de9098071baa99aa5a662cec128c04a553 Mon Sep 17 00:00:00 2001 From: KayProject Date: Tue, 30 Jun 2026 10:29:57 +0100 Subject: [PATCH] feat(security): offline line-level static analysis in the contract scanner The built-in audit only ran file-level checklist heuristics and delegated real vulnerability detection to Slither/Mythril. When those tools are not installed the scanner silently missed line-level issues. This wires the existing SecurityPatternLibrary into the built-in analysis so the scanner detects reentrancy, unchecked arithmetic, unsafe unwraps, missing authorization and hardcoded addresses on its own, each reported with a file:line location, severity and remediation. Adds an --offline flag to run the scan with built-in analysis only and skip external tools. Closes #362 --- src/commands/security.rs | 7 ++- src/utils/security/audit.rs | 95 ++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/commands/security.rs b/src/commands/security.rs index 369b1fbd..218f8bb3 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -49,6 +49,9 @@ pub struct AuditArgs { /// Run Mythril if installed #[arg(long, default_value = "true")] pub mythril: bool, + /// Scan with built-in static analysis only; skip all external tools + #[arg(long)] + pub offline: bool, /// Output format: text or json #[arg(long, default_value = "text")] pub format: String, @@ -383,8 +386,8 @@ fn handle_audit(args: AuditArgs) -> Result<()> { p::kv("Contract", &args.path.display().to_string()); let cfg = AuditConfig { - run_slither: args.slither, - run_mythril: args.mythril, + run_slither: args.slither && !args.offline, + run_mythril: args.mythril && !args.offline, }; let result = run_audit(&args.path, &cfg)?; diff --git a/src/utils/security/audit.rs b/src/utils/security/audit.rs index 83c0dcd0..9b43a822 100644 --- a/src/utils/security/audit.rs +++ b/src/utils/security/audit.rs @@ -248,9 +248,10 @@ fn parse_mythril_output(json_str: &str) -> Result> { } fn run_builtin_analysis(path: &Path) -> Result> { - let result = super::checklist::run_checklist(path)?; let mut findings = Vec::new(); + // File-level checklist heuristics. + let result = super::checklist::run_checklist(path)?; for item in result.items { if !item.passed { findings.push(VulnerabilityFinding { @@ -264,9 +265,52 @@ fn run_builtin_analysis(path: &Path) -> Result> { }); } } + + // Line-level static analysis against the built-in pattern library. This is + // what gives the offline scanner real coverage of reentrancy, unchecked + // arithmetic, unsafe unwraps and missing authorization without needing + // Slither or Mythril. + findings.extend(run_pattern_analysis(path)?); + + Ok(findings) +} + +fn run_pattern_analysis(path: &Path) -> Result> { + use super::hardening::{apply_hardening, HardeningOptions}; + + let hardening = apply_hardening( + path, + &HardeningOptions { + apply_fixes: false, + dry_run: true, + pattern_ids: None, + }, + )?; + + let file = hardening.file.clone(); + let findings = hardening + .findings + .into_iter() + .map(|f| VulnerabilityFinding { + id: format!("SF-PATTERN-{}", f.pattern_id.to_uppercase()), + title: f.pattern_name, + severity: f.severity, + description: f.message, + location: Some(format!("{}:{}", file, f.line)), + remediation: pattern_remediation(&f.pattern_id), + tool: "starforge-builtin".to_string(), + }) + .collect(); + Ok(findings) } +fn pattern_remediation(pattern_id: &str) -> String { + super::patterns::SecurityPatternLibrary::by_id(pattern_id) + .and_then(|p| p.fix.map(|fix| fix.description)) + .unwrap_or_else(|| format!("Review and remediate the '{}' pattern.", pattern_id)) +} + fn builtin_remediation(id: &str) -> String { match id { "auth_check" => { @@ -421,4 +465,53 @@ mod tests { assert_eq!(s.low, 1); assert_eq!(s.critical + s.medium + s.info, 0); } + + fn write_temp_contract(src: &str) -> std::path::PathBuf { + use std::io::Write; + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("sf_audit_test_{}_{}.rs", std::process::id(), unique)); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(src.as_bytes()).unwrap(); + path + } + + #[test] + fn builtin_analysis_detects_line_patterns_without_external_tools() { + let src = "pub fn withdraw(amount: i128) {\n\ + \x20 let total = balance + amount;\n\ + \x20 client.invoke_contract(&addr); storage.set(&key, &total);\n\ + \x20 let v = data.unwrap();\n\ + }\n"; + let path = write_temp_contract(src); + let findings = run_builtin_analysis(&path).unwrap(); + std::fs::remove_file(&path).ok(); + + assert!( + findings.iter().any(|f| f.id == "SF-PATTERN-REENTRANCY-RISK"), + "expected reentrancy to be detected offline, got: {:?}", + findings.iter().map(|f| f.id.clone()).collect::>() + ); + + // Every line-level pattern finding must carry a line-qualified location, + // a non-empty remediation, and be attributed to the built-in scanner. + for f in findings.iter().filter(|f| f.id.starts_with("SF-PATTERN-")) { + assert!( + f.location.as_deref().unwrap_or_default().contains(':'), + "missing line in location for {}", + f.id + ); + assert!(!f.remediation.is_empty(), "missing remediation for {}", f.id); + assert_eq!(f.tool, "starforge-builtin"); + } + } + + #[test] + fn pattern_remediation_falls_back_for_unknown_pattern() { + let remediation = pattern_remediation("does-not-exist"); + assert!(remediation.contains("does-not-exist")); + } }