From 4789eec76f9d97cd354eb42580f4a3c7a0716cae Mon Sep 17 00:00:00 2001 From: prasanth_j Date: Tue, 3 Mar 2026 16:45:27 +0530 Subject: [PATCH 1/4] feat: introduce scanr-engine crate with core engine abstractions --- Cargo.toml | 2 +- crates/scanr-engine/Cargo.toml | 8 ++++ crates/scanr-engine/src/lib.rs | 83 ++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 crates/scanr-engine/Cargo.toml create mode 100644 crates/scanr-engine/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 9bdcac0..7feeba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,6 @@ members = [ "crates/scanr-core", "crates/scanr-cli", + "crates/scanr-engine", ] resolver = "2" - diff --git a/crates/scanr-engine/Cargo.toml b/crates/scanr-engine/Cargo.toml new file mode 100644 index 0000000..c52ac22 --- /dev/null +++ b/crates/scanr-engine/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "scanr-engine" +version = "0.1.1" +edition = "2024" +description = "Scanr engine abstraction contracts" +license = "Apache-2.0" + +[dependencies] diff --git a/crates/scanr-engine/src/lib.rs b/crates/scanr-engine/src/lib.rs new file mode 100644 index 0000000..f3d9ed2 --- /dev/null +++ b/crates/scanr-engine/src/lib.rs @@ -0,0 +1,83 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EngineType { + SCA, + Container, + IaC, + SAST, + Secrets, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Severity { + Critical, + High, + Medium, + Low, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub id: String, + pub engine: EngineType, + pub severity: Severity, + pub title: String, + pub description: String, + pub location: Option, + pub remediation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScanInput { + Path(PathBuf), + Image(String), + Tar(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanMetadata { + pub engine: EngineType, + pub engine_name: String, + pub target: String, + pub total_dependencies: usize, + pub total_vulnerabilities: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScanResult { + pub findings: Vec, + pub metadata: ScanMetadata, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineError { + pub message: String, +} + +impl EngineError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for EngineError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl Error for EngineError {} + +pub type EngineResult = std::result::Result; + +pub trait ScanEngine { + fn name(&self) -> &'static str; + + fn scan(&self, input: ScanInput) -> EngineResult; +} From e41d2a5639960ce9ce2aea7551793bb67c5994da Mon Sep 17 00:00:00 2001 From: prasanth_j Date: Tue, 3 Mar 2026 17:08:39 +0530 Subject: [PATCH 2/4] refactor: move shared security models into scanr-engine --- Cargo.toml | 2 +- README.md | 6 +- crates/scanr-cli/Cargo.toml | 2 +- crates/scanr-cli/src/main.rs | 167 +++++++++--------- crates/scanr-cli/src/tui.rs | 55 +++--- crates/scanr-engine/Cargo.toml | 1 + crates/scanr-engine/src/lib.rs | 28 ++- crates/{scanr-core => scanr-sca}/Cargo.toml | 9 +- .../{scanr-core => scanr-sca}/src/baseline.rs | 0 .../src/cache/mod.rs | 0 .../src/cache/store.rs | 0 .../src/cache/ttl.rs | 0 crates/{scanr-core => scanr-sca}/src/lib.rs | 163 +++++++++++------ .../src/license/evaluator.rs | 0 .../src/license/extractor.rs | 0 .../src/license/mod.rs | 0 .../src/trace/mod.rs | 0 .../src/trace/node_graph.rs | 0 .../src/trace/tracer.rs | 0 docs/core.md | 6 +- docs/development.md | 3 +- docs/index.md | 8 +- docs/output-formats.md | 2 +- mkdocs.yml | 4 +- 24 files changed, 275 insertions(+), 181 deletions(-) rename crates/{scanr-core => scanr-sca}/Cargo.toml (75%) rename crates/{scanr-core => scanr-sca}/src/baseline.rs (100%) rename crates/{scanr-core => scanr-sca}/src/cache/mod.rs (100%) rename crates/{scanr-core => scanr-sca}/src/cache/store.rs (100%) rename crates/{scanr-core => scanr-sca}/src/cache/ttl.rs (100%) rename crates/{scanr-core => scanr-sca}/src/lib.rs (95%) rename crates/{scanr-core => scanr-sca}/src/license/evaluator.rs (100%) rename crates/{scanr-core => scanr-sca}/src/license/extractor.rs (100%) rename crates/{scanr-core => scanr-sca}/src/license/mod.rs (100%) rename crates/{scanr-core => scanr-sca}/src/trace/mod.rs (100%) rename crates/{scanr-core => scanr-sca}/src/trace/node_graph.rs (100%) rename crates/{scanr-core => scanr-sca}/src/trace/tracer.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 7feeba0..89762ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "crates/scanr-core", + "crates/scanr-sca", "crates/scanr-cli", "crates/scanr-engine", ] diff --git a/README.md b/README.md index 40f6ba4..c1ffd40 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Scanr is a Rust security scanner focused on dependency intelligence for engineer It is split into: - `scanr-cli`: end-user CLI and TUI (`scanr`) -- `scanr-core`: reusable scan engine and data models +- `scanr-engine`: shared engine contracts and unified finding model +- `scanr-sca`: SCA engine implementation and data models ## What Scanr Currently Does @@ -90,7 +91,8 @@ mkdocs serve ```text F:\Scanr ├── crates/ -│ ├── scanr-core/ +│ ├── scanr-engine/ +│ ├── scanr-sca/ │ └── scanr-cli/ ├── installers/ ├── docs/ diff --git a/crates/scanr-cli/Cargo.toml b/crates/scanr-cli/Cargo.toml index dc44b0b..275b829 100644 --- a/crates/scanr-cli/Cargo.toml +++ b/crates/scanr-cli/Cargo.toml @@ -14,7 +14,7 @@ categories = ["command-line-utilities", "development-tools"] clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" ratatui = "0.28" -scanr-core = { path = "../scanr-core" } +scanr-sca = { path = "../scanr-sca" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } diff --git a/crates/scanr-cli/src/main.rs b/crates/scanr-cli/src/main.rs index 9df5a6a..0c63de1 100644 --- a/crates/scanr-cli/src/main.rs +++ b/crates/scanr-cli/src/main.rs @@ -123,16 +123,16 @@ struct ScanRawOutput { offline_missing: usize, lookup_error: Option, cache_events: Vec, - risk_summary: scanr_core::RiskSummary, + risk_summary: scanr_sca::RiskSummary, policy_path: Option, - policy: Option, - policy_evaluation: Option, - license_policy: scanr_core::LicensePolicy, - license_evaluation: scanr_core::LicenseEvaluationResult, + policy: Option, + policy_evaluation: Option, + license_policy: scanr_sca::LicensePolicy, + license_evaluation: scanr_sca::LicenseEvaluationResult, baseline: Option, - dependencies: Vec, - vulnerabilities: Vec, - upgrade_recommendations: Vec, + dependencies: Vec, + vulnerabilities: Vec, + upgrade_recommendations: Vec, } #[derive(Debug, Serialize)] @@ -147,7 +147,7 @@ struct BaselineSummaryOutput { current_vulnerabilities: usize, new_vulnerabilities: usize, fixed_vulnerabilities: usize, - new_severity: scanr_core::SeverityCounts, + new_severity: scanr_sca::SeverityCounts, } #[tokio::main] @@ -174,7 +174,7 @@ async fn main() { refresh, recursive: _, }) => { - let loaded_policy = scanr_core::load_policy_for_target(&path); + let loaded_policy = scanr_sca::load_policy_for_target(&path); let cache_settings = match &loaded_policy { Ok((policy, _)) => (policy.cache_enabled, policy.cache_ttl_hours), Err(error) => { @@ -185,14 +185,18 @@ async fn main() { } }; - let scan_options = scanr_core::ScanOptions { + let scan_options = scanr_sca::ScanOptions { cache_enabled: cache_settings.0, cache_ttl_hours: cache_settings.1, offline, force_refresh: refresh, }; + let sca_engine = scanr_sca::ScaEngine::new(); - let scan_result = match scanr_core::scan_path_with_options(&path, &scan_options).await { + let scan_result = match sca_engine + .scan_detailed_with_options(&path, &scan_options) + .await + { Ok(scan_result) => scan_result, Err(error) => { eprintln!("Scan failed: {error}"); @@ -204,9 +208,9 @@ async fn main() { .as_ref() .map(|(policy, _)| policy.clone()) .unwrap_or_default(); - let license_info = scanr_core::extract_licenses(&path, &scan_result.dependencies); + let license_info = scanr_sca::extract_licenses(&path, &scan_result.dependencies); let license_evaluation = - scanr_core::evaluate_licenses(&license_info, &effective_policy.license); + scanr_sca::evaluate_licenses(&license_info, &effective_policy.license); let license_failed_in_ci = ci && effective_policy.license.enforce_in_ci && !license_evaluation.violations.is_empty(); @@ -223,7 +227,7 @@ async fn main() { } if sarif { - let sarif_report = scanr_core::scan_result_to_sarif(&scan_result); + let sarif_report = scanr_sca::scan_result_to_sarif(&scan_result); match serde_json::to_string_pretty(&sarif_report) { Ok(payload) => println!("{payload}"), Err(error) => { @@ -324,12 +328,12 @@ async fn main() { let mut baseline_delta = None; if baseline { - let baseline_path = scanr_core::baseline_path_for_target(&path); + let baseline_path = scanr_sca::baseline_path_for_target(&path); let baseline_path_display = normalize_windows_verbatim_path(baseline_path.display().to_string()); - match scanr_core::load_baseline_for_target(&path) { + match scanr_sca::load_baseline_for_target(&path) { Ok(Some((loaded_baseline, loaded_baseline_path))) => { - let delta = scanr_core::compare_scan_result_to_baseline( + let delta = scanr_sca::compare_scan_result_to_baseline( &scan_result, &loaded_baseline, ); @@ -337,7 +341,7 @@ async fn main() { loaded_baseline_path.display().to_string(), ); let version_mismatch = - loaded_baseline.version != scanr_core::current_scanr_version(); + loaded_baseline.version != scanr_sca::current_scanr_version(); println!(); println!("Baseline Comparison"); @@ -346,7 +350,7 @@ async fn main() { eprintln!( "Warning: baseline version '{}' differs from current Scanr '{}'.", loaded_baseline.version, - scanr_core::current_scanr_version() + scanr_sca::current_scanr_version() ); } println!("Baseline: {} vulnerabilities", delta.baseline_total); @@ -374,7 +378,7 @@ async fn main() { found: true, path: loaded_path_display, baseline_version: Some(loaded_baseline.version.clone()), - current_scanr_version: scanr_core::current_scanr_version().to_string(), + current_scanr_version: scanr_sca::current_scanr_version().to_string(), version_mismatch, baseline_vulnerabilities: delta.baseline_total, current_vulnerabilities: delta.current_total, @@ -396,13 +400,13 @@ async fn main() { found: false, path: baseline_path_display, baseline_version: None, - current_scanr_version: scanr_core::current_scanr_version().to_string(), + current_scanr_version: scanr_sca::current_scanr_version().to_string(), version_mismatch: false, baseline_vulnerabilities: 0, current_vulnerabilities: 0, new_vulnerabilities: 0, fixed_vulnerabilities: 0, - new_severity: scanr_core::SeverityCounts::default(), + new_severity: scanr_sca::SeverityCounts::default(), }); } Err(error) => { @@ -454,7 +458,7 @@ async fn main() { let risk_summary = risk_summary_from_scan_result(&scan_result); let evaluation = - scanr_core::evaluate_policy(&risk_summary, &loaded_policy); + scanr_sca::evaluate_policy(&risk_summary, &loaded_policy); if evaluation.passed { println!("Result: PASS"); } else { @@ -539,7 +543,7 @@ async fn main() { } Some(Commands::Sbom { command }) => match command { SbomCommands::Generate { path, output } => { - match scanr_core::generate_cyclonedx_sbom(&path) { + match scanr_sca::generate_cyclonedx_sbom(&path) { Ok(sbom) => { if let Some(parent) = output.parent() && !parent.as_os_str().is_empty() @@ -574,7 +578,7 @@ async fn main() { } } SbomCommands::Diff { old, new } => { - match scanr_core::diff_cyclonedx_sbom_files(&old, &new) { + match scanr_sca::diff_cyclonedx_sbom_files(&old, &new) { Ok(diff) => { println!("SBOM Diff"); println!("Old: {}", old.display()); @@ -598,14 +602,14 @@ async fn main() { if diff.introduced_dependencies.is_empty() { println!("New Vulnerabilities: 0"); } else { - match scanr_core::investigate_vulnerabilities( + match scanr_sca::investigate_vulnerabilities( &diff.introduced_dependencies, ) .await { Ok(report) => { let summary = - scanr_core::summarize_risk(&report.vulnerabilities); + scanr_sca::summarize_risk(&report.vulnerabilities); println!( "New Vulnerabilities: {} {}", summary.total, @@ -635,26 +639,29 @@ async fn main() { }, Some(Commands::Baseline { command }) => match command { BaselineCommands::Save { path } => { - let loaded_policy = scanr_core::load_policy_for_target(&path); + let loaded_policy = scanr_sca::load_policy_for_target(&path); let (cache_enabled, cache_ttl_hours) = match &loaded_policy { Ok((policy, _)) => (policy.cache_enabled, policy.cache_ttl_hours), Err(_) => (true, 24), }; - let scan_options = scanr_core::ScanOptions { + let scan_options = scanr_sca::ScanOptions { cache_enabled, cache_ttl_hours, offline: false, force_refresh: false, }; + let sca_engine = scanr_sca::ScaEngine::new(); - let scan_result = - match scanr_core::scan_path_with_options(&path, &scan_options).await { - Ok(scan_result) => scan_result, - Err(error) => { - eprintln!("Scan failed: {error}"); - process::exit(1); - } - }; + let scan_result = match sca_engine + .scan_detailed_with_options(&path, &scan_options) + .await + { + Ok(scan_result) => scan_result, + Err(error) => { + eprintln!("Scan failed: {error}"); + process::exit(1); + } + }; if scan_result.lookup_error.is_some() || scan_result.failed_queries > 0 { eprintln!( @@ -663,9 +670,8 @@ async fn main() { process::exit(1); } - let baseline = scanr_core::build_baseline_from_scan_result(&scan_result); - let baseline_path = match scanr_core::save_baseline_for_target(&path, &scan_result) - { + let baseline = scanr_sca::build_baseline_from_scan_result(&scan_result); + let baseline_path = match scanr_sca::save_baseline_for_target(&path, &scan_result) { Ok(path) => path, Err(error) => { eprintln!("Failed to save baseline: {error}"); @@ -683,10 +689,10 @@ async fn main() { println!("Scanr version: {}", baseline.version); } BaselineCommands::Status { path } => { - let (baseline, baseline_path) = match scanr_core::load_baseline_for_target(&path) { + let (baseline, baseline_path) = match scanr_sca::load_baseline_for_target(&path) { Ok(Some(payload)) => payload, Ok(None) => { - let expected = scanr_core::baseline_path_for_target(&path); + let expected = scanr_sca::baseline_path_for_target(&path); eprintln!( "Baseline not found at '{}'. Run `scanr baseline save` first.", normalize_windows_verbatim_path(expected.display().to_string()) @@ -699,27 +705,30 @@ async fn main() { } }; - let loaded_policy = scanr_core::load_policy_for_target(&path); + let loaded_policy = scanr_sca::load_policy_for_target(&path); let (cache_enabled, cache_ttl_hours) = match &loaded_policy { Ok((policy, _)) => (policy.cache_enabled, policy.cache_ttl_hours), Err(_) => (true, 24), }; - let scan_options = scanr_core::ScanOptions { + let scan_options = scanr_sca::ScanOptions { cache_enabled, cache_ttl_hours, offline: false, force_refresh: false, }; + let sca_engine = scanr_sca::ScaEngine::new(); - let scan_result = - match scanr_core::scan_path_with_options(&path, &scan_options).await { - Ok(scan_result) => scan_result, - Err(error) => { - eprintln!("Scan failed: {error}"); - process::exit(1); - } - }; - let delta = scanr_core::compare_scan_result_to_baseline(&scan_result, &baseline); + let scan_result = match sca_engine + .scan_detailed_with_options(&path, &scan_options) + .await + { + Ok(scan_result) => scan_result, + Err(error) => { + eprintln!("Scan failed: {error}"); + process::exit(1); + } + }; + let delta = scanr_sca::compare_scan_result_to_baseline(&scan_result, &baseline); println!("Baseline Status"); println!( @@ -729,13 +738,13 @@ async fn main() { println!("Baseline version: {}", baseline.version); println!( "Current Scanr version: {}", - scanr_core::current_scanr_version() + scanr_sca::current_scanr_version() ); - if baseline.version != scanr_core::current_scanr_version() { + if baseline.version != scanr_sca::current_scanr_version() { eprintln!( "Warning: baseline version '{}' differs from current Scanr '{}'.", baseline.version, - scanr_core::current_scanr_version() + scanr_sca::current_scanr_version() ); } println!(); @@ -767,7 +776,7 @@ async fn main() { } }, Some(Commands::Trace { package, path }) => { - let report = match scanr_core::trace_dependency_paths(&path, &package) { + let report = match scanr_sca::trace_dependency_paths(&path, &package) { Ok(report) => report, Err(error) => { eprintln!("Trace failed: {error}"); @@ -783,7 +792,7 @@ async fn main() { return; } - let loaded_policy = scanr_core::load_policy_for_target(&path); + let loaded_policy = scanr_sca::load_policy_for_target(&path); let (cache_enabled, cache_ttl_hours) = match &loaded_policy { Ok((policy, _)) => (policy.cache_enabled, policy.cache_ttl_hours), Err(_) => (true, 24), @@ -797,7 +806,7 @@ async fn main() { } else { std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()) }; - let vulnerability_options = scanr_core::VulnerabilityQueryOptions { + let vulnerability_options = scanr_sca::VulnerabilityQueryOptions { cache_base_path: Some(cache_base_path), cache_enabled, cache_ttl_hours, @@ -809,14 +818,14 @@ async fn main() { .iter() .map(|matched| matched.dependency.clone()) .collect::>(); - let vulnerability_report = match scanr_core::investigate_vulnerabilities_with_options( + let vulnerability_report = match scanr_sca::investigate_vulnerabilities_with_options( &trace_dependencies, &vulnerability_options, ) .await { Ok(report) => report, - Err(_) => scanr_core::VulnerabilityReport { + Err(_) => scanr_sca::VulnerabilityReport { vulnerabilities: Vec::new(), upgrade_recommendations: Vec::new(), queried_dependencies: 0, @@ -871,7 +880,7 @@ fn normalize_windows_verbatim_path(path: String) -> String { path } -fn print_vulnerability_table(vulnerabilities: &[scanr_core::Vulnerability]) { +fn print_vulnerability_table(vulnerabilities: &[scanr_sca::Vulnerability]) { let header = format!( "{:<4} {:<20} {:<8} {:<8} {:<14} {:<18} {}", "#", "CVE", "SEV", "SCORE", "AFFECTED", "PACKAGE", "FIX" @@ -897,7 +906,7 @@ fn print_vulnerability_table(vulnerabilities: &[scanr_core::Vulnerability]) { } } -fn print_risk_summary(scan_result: &scanr_core::ScanResult) { +fn print_risk_summary(scan_result: &scanr_sca::ScanResult) { println!(); println!("Risk Summary"); println!( @@ -912,7 +921,7 @@ fn print_risk_summary(scan_result: &scanr_core::ScanResult) { } fn print_license_compliance( - evaluation: &scanr_core::LicenseEvaluationResult, + evaluation: &scanr_sca::LicenseEvaluationResult, ci_mode: bool, enforce_in_ci: bool, final_ci_exit_code: i32, @@ -961,7 +970,7 @@ fn print_license_compliance( } } -fn print_upgrade_recommendations_table(recommendations: &[scanr_core::UpgradeRecommendation]) { +fn print_upgrade_recommendations_table(recommendations: &[scanr_sca::UpgradeRecommendation]) { let header = format!( "{:<4} {:<18} {:<8} {:<14} {:<14} {}", "#", "PACKAGE", "ECO", "CURRENT", "SUGGESTED", "STATUS" @@ -990,7 +999,7 @@ fn print_upgrade_recommendations_table(recommendations: &[scanr_core::UpgradeRec fn print_dependency_delta_section( label: &str, - dependencies: &[scanr_core::Dependency], + dependencies: &[scanr_sca::Dependency], max_rows: usize, ) { println!("{label}: {}", dependencies.len()); @@ -1005,7 +1014,7 @@ fn print_dependency_delta_section( } } -fn print_version_change_section(changes: &[scanr_core::SbomVersionChange], max_rows: usize) { +fn print_version_change_section(changes: &[scanr_sca::SbomVersionChange], max_rows: usize) { println!("Version changes: {}", changes.len()); for change in changes.iter().take(max_rows) { let old_versions = change.old_versions.join(", "); @@ -1020,7 +1029,7 @@ fn print_version_change_section(changes: &[scanr_core::SbomVersionChange], max_r } } -fn summarize_severity_for_delta(counts: &scanr_core::SeverityCounts) -> String { +fn summarize_severity_for_delta(counts: &scanr_sca::SeverityCounts) -> String { if counts.critical > 0 { return "CRITICAL".to_string(); } @@ -1106,7 +1115,7 @@ fn print_single_trace_path(path: &[String]) { fn print_trace_vulnerability_context( package: &str, version: &str, - vulnerability_report: &scanr_core::VulnerabilityReport, + vulnerability_report: &scanr_sca::VulnerabilityReport, ) { let vulnerability_matches = vulnerability_report .vulnerabilities @@ -1124,13 +1133,13 @@ fn print_trace_vulnerability_context( .iter() .map(|vulnerability| vulnerability.severity) .max_by_key(|severity| match severity { - scanr_core::Severity::Critical => 4, - scanr_core::Severity::High => 3, - scanr_core::Severity::Medium => 2, - scanr_core::Severity::Low => 1, - scanr_core::Severity::Unknown => 0, + scanr_sca::Severity::Critical => 4, + scanr_sca::Severity::High => 3, + scanr_sca::Severity::Medium => 2, + scanr_sca::Severity::Low => 1, + scanr_sca::Severity::Unknown => 0, }) - .unwrap_or(scanr_core::Severity::Unknown); + .unwrap_or(scanr_sca::Severity::Unknown); println!("Severity: {}", highest_severity.to_string().to_uppercase()); let mut cves = vulnerability_matches @@ -1208,10 +1217,10 @@ fn emit_raw_output( Ok(()) } -fn risk_summary_from_scan_result(scan_result: &scanr_core::ScanResult) -> scanr_core::RiskSummary { - scanr_core::RiskSummary { +fn risk_summary_from_scan_result(scan_result: &scanr_sca::ScanResult) -> scanr_sca::RiskSummary { + scanr_sca::RiskSummary { total: scan_result.vulnerabilities.len(), - counts: scanr_core::SeverityCounts { + counts: scanr_sca::SeverityCounts { critical: scan_result.severity_summary.critical as usize, high: scan_result.severity_summary.high as usize, medium: scan_result.severity_summary.medium as usize, diff --git a/crates/scanr-cli/src/tui.rs b/crates/scanr-cli/src/tui.rs index ee1e5f9..4bf22fa 100644 --- a/crates/scanr-cli/src/tui.rs +++ b/crates/scanr-cli/src/tui.rs @@ -41,7 +41,7 @@ enum ScanStatus { pub struct AppState { pub mode: AppMode, pub selected_index: usize, - pub scan_result: scanr_core::ScanResult, + pub scan_result: scanr_sca::ScanResult, has_scan_data: bool, selected_severity: usize, focus: Focus, @@ -50,7 +50,7 @@ pub struct AppState { project_path: PathBuf, scan_status: ScanStatus, spinner_index: usize, - scan_receiver: Option>>, + scan_receiver: Option>>, } impl AppState { @@ -64,7 +64,7 @@ impl AppState { .map(ToString::to_string) .unwrap_or_else(|| display_path.clone()); - let empty_result = scanr_core::ScanResult { + let empty_result = scanr_sca::ScanResult { target, path: display_path, total_dependencies: 0, @@ -72,8 +72,8 @@ impl AppState { vulnerabilities: Vec::new(), upgrade_recommendations: Vec::new(), risk_score: 0, - severity_summary: scanr_core::SeveritySummary::default(), - risk_level: scanr_core::RiskLevel::Low, + severity_summary: scanr_sca::SeveritySummary::default(), + risk_level: scanr_sca::RiskLevel::Low, queried_dependencies: 0, failed_queries: 0, offline_missing: 0, @@ -126,7 +126,7 @@ impl AppState { } let path = self.project_path.clone(); - let (tx, rx) = mpsc::channel::>(); + let (tx, rx) = mpsc::channel::>(); self.scan_receiver = Some(rx); self.scan_status = ScanStatus::Scanning; self.spinner_index = 0; @@ -137,9 +137,12 @@ impl AppState { .enable_all() .build(); let result = match runtime { - Ok(runtime) => runtime - .block_on(scanr_core::scan_path(&path)) - .map_err(|error| error.to_string()), + Ok(runtime) => { + let engine = scanr_sca::ScaEngine::new(); + runtime + .block_on(engine.scan_detailed(&path)) + .map_err(|error| error.to_string()) + } Err(error) => Err(format!("runtime initialization failed: {error}")), }; let _ = tx.send(result); @@ -399,9 +402,9 @@ fn render_severity_panel(frame: &mut Frame, app: &AppState, area: Rect) { } ), match app.scan_result.risk_level { - scanr_core::RiskLevel::High => Style::default().fg(Color::Red), - scanr_core::RiskLevel::Moderate => Style::default().fg(Color::Yellow), - scanr_core::RiskLevel::Low => Style::default().fg(Color::Green), + scanr_sca::RiskLevel::High => Style::default().fg(Color::Red), + scanr_sca::RiskLevel::Moderate => Style::default().fg(Color::Yellow), + scanr_sca::RiskLevel::Low => Style::default().fg(Color::Green), } .add_modifier(Modifier::BOLD), )), @@ -763,7 +766,7 @@ fn recommendation_details(app: &AppState) -> String { } fn dependency_cve_map( - vulnerabilities: &[scanr_core::Vulnerability], + vulnerabilities: &[scanr_sca::Vulnerability], ) -> HashMap> { let mut map = HashMap::>::new(); for vulnerability in vulnerabilities { @@ -782,7 +785,7 @@ fn dependency_cve_map( map } -fn vulnerable_dependency_names(vulnerabilities: &[scanr_core::Vulnerability]) -> HashSet { +fn vulnerable_dependency_names(vulnerabilities: &[scanr_sca::Vulnerability]) -> HashSet { vulnerabilities .iter() .map(|vulnerability| package_name_from_description(&vulnerability.description)) @@ -835,23 +838,23 @@ fn style_severity_line( Line::from(Span::styled(label, style)) } -fn severity_rank(severity: scanr_core::Severity) -> u8 { +fn severity_rank(severity: scanr_sca::Severity) -> u8 { match severity { - scanr_core::Severity::Critical => 0, - scanr_core::Severity::High => 1, - scanr_core::Severity::Medium => 2, - scanr_core::Severity::Low => 3, - scanr_core::Severity::Unknown => 4, + scanr_sca::Severity::Critical => 0, + scanr_sca::Severity::High => 1, + scanr_sca::Severity::Medium => 2, + scanr_sca::Severity::Low => 3, + scanr_sca::Severity::Unknown => 4, } } -fn severity_style(severity: scanr_core::Severity) -> Style { +fn severity_style(severity: scanr_sca::Severity) -> Style { match severity { - scanr_core::Severity::Critical => Style::default().fg(Color::Red), - scanr_core::Severity::High => Style::default().fg(Color::LightRed), - scanr_core::Severity::Medium => Style::default().fg(Color::Yellow), - scanr_core::Severity::Low => Style::default().fg(Color::Blue), - scanr_core::Severity::Unknown => Style::default().fg(Color::Gray), + scanr_sca::Severity::Critical => Style::default().fg(Color::Red), + scanr_sca::Severity::High => Style::default().fg(Color::LightRed), + scanr_sca::Severity::Medium => Style::default().fg(Color::Yellow), + scanr_sca::Severity::Low => Style::default().fg(Color::Blue), + scanr_sca::Severity::Unknown => Style::default().fg(Color::Gray), } } diff --git a/crates/scanr-engine/Cargo.toml b/crates/scanr-engine/Cargo.toml index c52ac22..04be766 100644 --- a/crates/scanr-engine/Cargo.toml +++ b/crates/scanr-engine/Cargo.toml @@ -6,3 +6,4 @@ description = "Scanr engine abstraction contracts" license = "Apache-2.0" [dependencies] +serde = { version = "1.0", features = ["derive"] } diff --git a/crates/scanr-engine/src/lib.rs b/crates/scanr-engine/src/lib.rs index f3d9ed2..f943282 100644 --- a/crates/scanr-engine/src/lib.rs +++ b/crates/scanr-engine/src/lib.rs @@ -2,7 +2,10 @@ use std::error::Error; use std::fmt::{Display, Formatter}; use std::path::PathBuf; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum EngineType { SCA, Container, @@ -11,7 +14,8 @@ pub enum EngineType { Secrets, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Severity { Critical, High, @@ -20,7 +24,19 @@ pub enum Severity { Unknown, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl Display for Severity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Critical => write!(f, "critical"), + Self::High => write!(f, "high"), + Self::Medium => write!(f, "medium"), + Self::Low => write!(f, "low"), + Self::Unknown => write!(f, "unknown"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Finding { pub id: String, pub engine: EngineType, @@ -31,14 +47,14 @@ pub struct Finding { pub remediation: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ScanInput { Path(PathBuf), Image(String), Tar(PathBuf), } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScanMetadata { pub engine: EngineType, pub engine_name: String, @@ -47,7 +63,7 @@ pub struct ScanMetadata { pub total_vulnerabilities: usize, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScanResult { pub findings: Vec, pub metadata: ScanMetadata, diff --git a/crates/scanr-core/Cargo.toml b/crates/scanr-sca/Cargo.toml similarity index 75% rename from crates/scanr-core/Cargo.toml rename to crates/scanr-sca/Cargo.toml index 5d8aaf9..3b52415 100644 --- a/crates/scanr-core/Cargo.toml +++ b/crates/scanr-sca/Cargo.toml @@ -1,22 +1,23 @@ [package] -name = "scanr-core" +name = "scanr-sca" version = "0.1.1" edition = "2024" -description = "Core scanning and reporting engine for Scanr" +description = "SCA engine implementation for Scanr" license = "Apache-2.0" repository = "https://github.com/scanr-dev/scanr" homepage = "https://scanr.dev" readme = "../../README.md" -keywords = ["security", "devsecops", "sbom", "vulnerability", "scanner"] +keywords = ["security", "devsecops", "sbom", "vulnerability", "sca"] categories = ["development-tools"] [dependencies] futures = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +scanr-engine = { path = "../scanr-engine" } semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tokio = { version = "1.43", features = ["time"] } +tokio = { version = "1.43", features = ["time", "rt-multi-thread"] } toml = "0.8" walkdir = "2.5" time = { version = "0.3", features = ["formatting", "parsing"] } diff --git a/crates/scanr-core/src/baseline.rs b/crates/scanr-sca/src/baseline.rs similarity index 100% rename from crates/scanr-core/src/baseline.rs rename to crates/scanr-sca/src/baseline.rs diff --git a/crates/scanr-core/src/cache/mod.rs b/crates/scanr-sca/src/cache/mod.rs similarity index 100% rename from crates/scanr-core/src/cache/mod.rs rename to crates/scanr-sca/src/cache/mod.rs diff --git a/crates/scanr-core/src/cache/store.rs b/crates/scanr-sca/src/cache/store.rs similarity index 100% rename from crates/scanr-core/src/cache/store.rs rename to crates/scanr-sca/src/cache/store.rs diff --git a/crates/scanr-core/src/cache/ttl.rs b/crates/scanr-sca/src/cache/ttl.rs similarity index 100% rename from crates/scanr-core/src/cache/ttl.rs rename to crates/scanr-sca/src/cache/ttl.rs diff --git a/crates/scanr-core/src/lib.rs b/crates/scanr-sca/src/lib.rs similarity index 95% rename from crates/scanr-core/src/lib.rs rename to crates/scanr-sca/src/lib.rs index a5787a1..4d86213 100644 --- a/crates/scanr-core/src/lib.rs +++ b/crates/scanr-sca/src/lib.rs @@ -9,6 +9,10 @@ use std::time::Duration; use futures::stream::{self, StreamExt}; use reqwest::StatusCode; +use scanr_engine::{ + EngineError, EngineResult, EngineType, Finding, ScanEngine, ScanInput, ScanMetadata, + ScanResult as EngineScanResult, +}; use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as JsonValue}; @@ -30,6 +34,7 @@ pub use license::{ LicenseEvaluationResult, LicenseInfo, LicensePolicy, LicenseViolation, evaluate_licenses, extract_licenses, }; +pub use scanr_engine::Severity; pub use trace::{TraceMatch, TraceReport, trace_dependency_paths}; const OSV_QUERY_URL: &str = "https://api.osv.dev/v1/query"; @@ -44,8 +49,59 @@ const SUPPORTED_MANIFESTS: [&str; 6] = [ "Cargo.lock", ]; +#[derive(Debug, Default, Clone, Copy)] +pub struct ScaEngine; + +impl ScaEngine { + pub fn new() -> Self { + Self + } + + pub async fn scan_detailed(&self, path: &Path) -> Result { + scan_path(path).await + } + + pub async fn scan_detailed_with_options( + &self, + path: &Path, + options: &ScanOptions, + ) -> Result { + scan_path_with_options(path, options).await + } +} + +impl ScanEngine for ScaEngine { + fn name(&self) -> &'static str { + "sca" + } + + fn scan(&self, input: ScanInput) -> EngineResult { + let path = match input { + ScanInput::Path(path) => path, + ScanInput::Image(_) => { + return Err(EngineError::new( + "unsupported scan input for sca engine: image", + )); + } + ScanInput::Tar(_) => { + return Err(EngineError::new( + "unsupported scan input for sca engine: tar", + )); + } + }; + + let runtime = tokio::runtime::Runtime::new().map_err(|error| { + EngineError::new(format!("failed to create tokio runtime: {error}")) + })?; + let detailed = runtime + .block_on(scan_path(&path)) + .map_err(|error| EngineError::new(error.to_string()))?; + Ok(convert_to_engine_scan_result(&detailed)) + } +} + pub fn placeholder_status() -> &'static str { - "scanr-core placeholder" + "scanr-sca placeholder" } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] @@ -74,28 +130,6 @@ pub struct Dependency { pub direct: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - Critical, - High, - Medium, - Low, - Unknown, -} - -impl Display for Severity { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::Critical => write!(f, "critical"), - Self::High => write!(f, "high"), - Self::Medium => write!(f, "medium"), - Self::Low => write!(f, "low"), - Self::Unknown => write!(f, "unknown"), - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Vulnerability { pub cve_id: String, @@ -727,6 +761,33 @@ pub async fn scan_path_with_options( }) } +fn convert_to_engine_scan_result(scan_result: &ScanResult) -> EngineScanResult { + let findings = scan_result + .vulnerabilities + .iter() + .map(|vulnerability| Finding { + id: vulnerability.cve_id.clone(), + engine: EngineType::SCA, + severity: vulnerability.severity, + title: short_message_from_description(&vulnerability.description), + description: vulnerability.description.clone(), + location: Some(scan_result.path.clone()), + remediation: vulnerability.remediation.clone(), + }) + .collect::>(); + + EngineScanResult { + findings, + metadata: ScanMetadata { + engine: EngineType::SCA, + engine_name: "sca".to_string(), + target: scan_result.target.clone(), + total_dependencies: scan_result.total_dependencies as usize, + total_vulnerabilities: scan_result.vulnerabilities.len(), + }, + } +} + pub fn scan_result_to_sarif(scan_result: &ScanResult) -> SarifReport { let mut rules_by_id = BTreeMap::::new(); for vulnerability in &scan_result.vulnerabilities { @@ -1709,20 +1770,20 @@ fn extract_severity(vulnerability: &OsvVulnerability) -> Severity { .as_ref() .and_then(|database_specific| database_specific.severity.as_deref()) { - let severity = Severity::from_label(label); + let severity = severity_from_label(label); if severity != Severity::Unknown { return severity; } } for entry in &vulnerability.severity { - let by_label = Severity::from_label(&entry.score); + let by_label = severity_from_label(&entry.score); if by_label != Severity::Unknown { return by_label; } if let Ok(score) = entry.score.parse::() { - return Severity::from_cvss_score(score); + return severity_from_cvss_score(score); } } @@ -1999,34 +2060,32 @@ fn deterministic_uuid(target: &str, path: &str, dependency_count: usize) -> Stri ) } -impl Severity { - fn from_label(label: &str) -> Severity { - let normalized = label.to_ascii_lowercase(); - if normalized.contains("critical") { - Severity::Critical - } else if normalized.contains("high") { - Severity::High - } else if normalized.contains("medium") || normalized.contains("moderate") { - Severity::Medium - } else if normalized.contains("low") { - Severity::Low - } else { - Severity::Unknown - } +fn severity_from_label(label: &str) -> Severity { + let normalized = label.to_ascii_lowercase(); + if normalized.contains("critical") { + Severity::Critical + } else if normalized.contains("high") { + Severity::High + } else if normalized.contains("medium") || normalized.contains("moderate") { + Severity::Medium + } else if normalized.contains("low") { + Severity::Low + } else { + Severity::Unknown } +} - fn from_cvss_score(score: f32) -> Severity { - if score >= 9.0 { - Severity::Critical - } else if score >= 7.0 { - Severity::High - } else if score >= 4.0 { - Severity::Medium - } else if score > 0.0 { - Severity::Low - } else { - Severity::Unknown - } +fn severity_from_cvss_score(score: f32) -> Severity { + if score >= 9.0 { + Severity::Critical + } else if score >= 7.0 { + Severity::High + } else if score >= 4.0 { + Severity::Medium + } else if score > 0.0 { + Severity::Low + } else { + Severity::Unknown } } diff --git a/crates/scanr-core/src/license/evaluator.rs b/crates/scanr-sca/src/license/evaluator.rs similarity index 100% rename from crates/scanr-core/src/license/evaluator.rs rename to crates/scanr-sca/src/license/evaluator.rs diff --git a/crates/scanr-core/src/license/extractor.rs b/crates/scanr-sca/src/license/extractor.rs similarity index 100% rename from crates/scanr-core/src/license/extractor.rs rename to crates/scanr-sca/src/license/extractor.rs diff --git a/crates/scanr-core/src/license/mod.rs b/crates/scanr-sca/src/license/mod.rs similarity index 100% rename from crates/scanr-core/src/license/mod.rs rename to crates/scanr-sca/src/license/mod.rs diff --git a/crates/scanr-core/src/trace/mod.rs b/crates/scanr-sca/src/trace/mod.rs similarity index 100% rename from crates/scanr-core/src/trace/mod.rs rename to crates/scanr-sca/src/trace/mod.rs diff --git a/crates/scanr-core/src/trace/node_graph.rs b/crates/scanr-sca/src/trace/node_graph.rs similarity index 100% rename from crates/scanr-core/src/trace/node_graph.rs rename to crates/scanr-sca/src/trace/node_graph.rs diff --git a/crates/scanr-core/src/trace/tracer.rs b/crates/scanr-sca/src/trace/tracer.rs similarity index 100% rename from crates/scanr-core/src/trace/tracer.rs rename to crates/scanr-sca/src/trace/tracer.rs diff --git a/docs/core.md b/docs/core.md index 94486f1..e941814 100644 --- a/docs/core.md +++ b/docs/core.md @@ -1,6 +1,6 @@ -# Scanr Core +# Scanr SCA -`scanr-core` is the shared security engine crate used by `scanr-cli`. +`scanr-sca` is the SCA engine implementation used by `scanr-cli`. ## Responsibilities @@ -106,4 +106,4 @@ pub struct ScanResult { ## Crate Location -- `crates/scanr-core` +- `crates/scanr-sca` diff --git a/docs/development.md b/docs/development.md index 23d97e7..8d01ed3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,7 +35,8 @@ cargo run -p scanr-cli -- sbom diff old.cdx.json new.cdx.json ## Workspace Layout ```text -crates/scanr-core reusable security engine and models +crates/scanr-engine shared engine contracts and finding schema +crates/scanr-sca SCA engine implementation and models crates/scanr-cli command and TUI frontend installers/ packaging assets (npm, bun, brew, aur, curl) docs/ mkdocs source pages diff --git a/docs/index.md b/docs/index.md index 5af3873..be73053 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,8 @@ Scanr is a Rust dependency security scanner with two first-class crates: - `scanr-cli`: command and terminal UI layer -- `scanr-core`: parsing, vulnerability, policy, SBOM, and output models +- `scanr-engine`: shared engine contracts and unified findings +- `scanr-sca`: SCA implementation (parsing, vulnerability, policy, SBOM, and outputs) ## Core Capabilities @@ -40,7 +41,8 @@ scanr sbom generate ```text F:\Scanr ├── crates/ -│ ├── scanr-core/ # reusable scan engine +│ ├── scanr-engine/ # shared engine contracts +│ ├── scanr-sca/ # SCA engine implementation │ └── scanr-cli/ # user-facing CLI and TUI ├── installers/ # npm, bun, brew, aur, curl assets ├── docs/ # mkdocs content @@ -57,5 +59,5 @@ F:\Scanr - **Output Formats**: human, JSON, SARIF, raw JSON - **CI Policy**: `scanr.toml` policy model and CI exit behavior - **SBOM**: CycloneDX generation and diff behavior -- **Scanr Core**: core models and API surface +- **Scanr SCA**: SCA models and API surface - **Development**: build, test, release, and contribution workflow diff --git a/docs/output-formats.md b/docs/output-formats.md index c127da7..90548f8 100644 --- a/docs/output-formats.md +++ b/docs/output-formats.md @@ -2,7 +2,7 @@ `scanr-cli` exposes one scan engine and multiple presentation layers. -All formats originate from the same core `ScanResult` model in `scanr-core`. +All formats originate from the same core `ScanResult` model in `scanr-sca`. ## Human-Readable Output (default) diff --git a/mkdocs.yml b/mkdocs.yml index 72b8395..b528ef8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Scanr -site_description: Open-source DevSecOps engine with a modular Rust CLI and core library +site_description: Open-source DevSecOps framework with modular Rust CLI and pluggable engines repo_url: https://github.com/Open-Lab-s/Scanr repo_name: Open-Lab-s/Scanr @@ -17,7 +17,7 @@ nav: - Baseline: baseline.md - Cache: cache.md - SBOM: sbom.md - - Scanr Core: core.md + - Scanr SCA: core.md - Development: development.md plugins: From 37abbc37224619c61df18d602fb8be2728d8b191 Mon Sep 17 00:00:00 2001 From: prasanth_j Date: Tue, 3 Mar 2026 17:16:50 +0530 Subject: [PATCH 3/4] refactor: decouple policy evaluation from SCA-specific logic --- crates/scanr-cli/Cargo.toml | 1 + crates/scanr-cli/src/main.rs | 21 ++++--- crates/scanr-cli/src/tui.rs | 4 +- crates/scanr-engine/src/lib.rs | 109 +++++++++++++++++++++++++++++++++ crates/scanr-sca/src/lib.rs | 30 +++++---- 5 files changed, 140 insertions(+), 25 deletions(-) diff --git a/crates/scanr-cli/Cargo.toml b/crates/scanr-cli/Cargo.toml index 275b829..f480dac 100644 --- a/crates/scanr-cli/Cargo.toml +++ b/crates/scanr-cli/Cargo.toml @@ -14,6 +14,7 @@ categories = ["command-line-utilities", "development-tools"] clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" ratatui = "0.28" +scanr-engine = { path = "../scanr-engine" } scanr-sca = { path = "../scanr-sca" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/scanr-cli/src/main.rs b/crates/scanr-cli/src/main.rs index 0c63de1..e3fb737 100644 --- a/crates/scanr-cli/src/main.rs +++ b/crates/scanr-cli/src/main.rs @@ -126,7 +126,7 @@ struct ScanRawOutput { risk_summary: scanr_sca::RiskSummary, policy_path: Option, policy: Option, - policy_evaluation: Option, + policy_evaluation: Option, license_policy: scanr_sca::LicensePolicy, license_evaluation: scanr_sca::LicenseEvaluationResult, baseline: Option, @@ -456,9 +456,15 @@ async fn main() { loaded_policy.max_critical, loaded_policy.max_high ); - let risk_summary = risk_summary_from_scan_result(&scan_result); - let evaluation = - scanr_sca::evaluate_policy(&risk_summary, &loaded_policy); + let findings = scanr_sca::findings_from_scan_result(&scan_result); + let policy_input = scanr_engine::VulnerabilityPolicy { + max_critical: loaded_policy.max_critical, + max_high: loaded_policy.max_high, + }; + let evaluation = scanr_engine::evaluate_vulnerability_policy( + &findings, + &policy_input, + ); if evaluation.passed { println!("Result: PASS"); } else { @@ -493,12 +499,7 @@ async fn main() { } let final_ci_exit_code = if ci { - match (vulnerability_failed_in_ci, license_failed_in_ci) { - (false, false) => 0, - (true, false) => 2, - (false, true) => 3, - (true, true) => 4, - } + scanr_engine::resolve_exit_code(vulnerability_failed_in_ci, license_failed_in_ci) } else { 0 }; diff --git a/crates/scanr-cli/src/tui.rs b/crates/scanr-cli/src/tui.rs index 4bf22fa..f9df7e0 100644 --- a/crates/scanr-cli/src/tui.rs +++ b/crates/scanr-cli/src/tui.rs @@ -298,7 +298,7 @@ fn handle_key(app: &mut AppState, key: KeyEvent) { } fn render(frame: &mut Frame, app: &AppState) { - let root = frame.size(); + let root = frame.area(); let vertical = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(4), Constraint::Min(0)]) @@ -668,7 +668,7 @@ fn render_recommendations(frame: &mut Frame, app: &AppState, area: Rect) { } fn render_popup(frame: &mut Frame, app: &AppState) { - let popup = centered_rect(80, 70, frame.size()); + let popup = centered_rect(80, 70, frame.area()); frame.render_widget(Clear, popup); let details = Paragraph::new(detail_text(app)) .block( diff --git a/crates/scanr-engine/src/lib.rs b/crates/scanr-engine/src/lib.rs index f943282..a6a26d9 100644 --- a/crates/scanr-engine/src/lib.rs +++ b/crates/scanr-engine/src/lib.rs @@ -69,6 +69,52 @@ pub struct ScanResult { pub metadata: ScanMetadata, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SeverityCounts { + pub critical: usize, + pub high: usize, + pub medium: usize, + pub low: usize, + pub unknown: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum RiskLevel { + Low, + Moderate, + High, +} + +impl Display for RiskLevel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Low => write!(f, "LOW"), + Self::Moderate => write!(f, "MODERATE"), + Self::High => write!(f, "HIGH"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FindingSummary { + pub total: usize, + pub counts: SeverityCounts, + pub risk_level: RiskLevel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct VulnerabilityPolicy { + pub max_critical: usize, + pub max_high: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyEvaluation { + pub passed: bool, + pub violations: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EngineError { pub message: String, @@ -97,3 +143,66 @@ pub trait ScanEngine { fn scan(&self, input: ScanInput) -> EngineResult; } + +pub fn summarize_findings(findings: &[Finding]) -> FindingSummary { + let mut counts = SeverityCounts::default(); + for finding in findings { + match finding.severity { + Severity::Critical => counts.critical += 1, + Severity::High => counts.high += 1, + Severity::Medium => counts.medium += 1, + Severity::Low => counts.low += 1, + Severity::Unknown => counts.unknown += 1, + } + } + + let risk_level = if counts.critical > 0 || counts.high > 0 { + RiskLevel::High + } else if counts.medium > 0 || counts.unknown > 0 { + RiskLevel::Moderate + } else { + RiskLevel::Low + }; + + FindingSummary { + total: findings.len(), + counts, + risk_level, + } +} + +pub fn evaluate_vulnerability_policy( + findings: &[Finding], + policy: &VulnerabilityPolicy, +) -> PolicyEvaluation { + let summary = summarize_findings(findings); + let mut violations = Vec::new(); + + if summary.counts.critical > policy.max_critical { + violations.push(format!( + "critical vulnerabilities {} exceed max_critical {}", + summary.counts.critical, policy.max_critical + )); + } + + if summary.counts.high > policy.max_high { + violations.push(format!( + "high vulnerabilities {} exceed max_high {}", + summary.counts.high, policy.max_high + )); + } + + PolicyEvaluation { + passed: violations.is_empty(), + violations, + } +} + +pub fn resolve_exit_code(vulnerability_failed: bool, license_failed: bool) -> i32 { + match (vulnerability_failed, license_failed) { + (false, false) => 0, + (true, false) => 2, + (false, true) => 3, + (true, true) => 4, + } +} diff --git a/crates/scanr-sca/src/lib.rs b/crates/scanr-sca/src/lib.rs index 4d86213..1a44ecd 100644 --- a/crates/scanr-sca/src/lib.rs +++ b/crates/scanr-sca/src/lib.rs @@ -762,19 +762,7 @@ pub async fn scan_path_with_options( } fn convert_to_engine_scan_result(scan_result: &ScanResult) -> EngineScanResult { - let findings = scan_result - .vulnerabilities - .iter() - .map(|vulnerability| Finding { - id: vulnerability.cve_id.clone(), - engine: EngineType::SCA, - severity: vulnerability.severity, - title: short_message_from_description(&vulnerability.description), - description: vulnerability.description.clone(), - location: Some(scan_result.path.clone()), - remediation: vulnerability.remediation.clone(), - }) - .collect::>(); + let findings = findings_from_scan_result(scan_result); EngineScanResult { findings, @@ -788,6 +776,22 @@ fn convert_to_engine_scan_result(scan_result: &ScanResult) -> EngineScanResult { } } +pub fn findings_from_scan_result(scan_result: &ScanResult) -> Vec { + scan_result + .vulnerabilities + .iter() + .map(|vulnerability| Finding { + id: vulnerability.cve_id.clone(), + engine: EngineType::SCA, + severity: vulnerability.severity, + title: short_message_from_description(&vulnerability.description), + description: vulnerability.description.clone(), + location: Some(scan_result.path.clone()), + remediation: vulnerability.remediation.clone(), + }) + .collect() +} + pub fn scan_result_to_sarif(scan_result: &ScanResult) -> SarifReport { let mut rules_by_id = BTreeMap::::new(); for vulnerability in &scan_result.vulnerabilities { From b1e11f4efa292038ca2bd80b558d36b07cccd2bf Mon Sep 17 00:00:00 2001 From: prasanth_j Date: Wed, 4 Mar 2026 08:53:03 +0530 Subject: [PATCH 4/4] refactor: optimized the Scanr engines --- README.md | 237 ++++++++++++++++++++++++++------- crates/scanr-cli/Cargo.toml | 6 +- crates/scanr-cli/src/main.rs | 18 ++- crates/scanr-engine/Cargo.toml | 5 + crates/scanr-sca/Cargo.toml | 4 +- crates/scanr-sca/src/lib.rs | 160 +++++++++++++++++----- docs/cli.md | 18 +++ docs/core.md | 33 +++++ docs/index.md | 190 +++++++++++++++++++------- docs/installation.md | 26 +++- 10 files changed, 556 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index c1ffd40..c3d73bb 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,211 @@ # Scanr -Scanr is a Rust security scanner focused on dependency intelligence for engineering teams. +> Open, privacy-first, self-hostable DevSecOps runtime. -It is split into: +[![Release](https://img.shields.io/github/v/release/Open-Lab-s/Scanr?label=release)](https://github.com/Open-Lab-s/Scanr/releases) +[![NPM](https://img.shields.io/npm/v/%40openlabs%2Fscanr_cli?label=npm)](https://www.npmjs.com/package/@openlabs/scanr_cli) +[![Bun](https://img.shields.io/badge/bun-supported-black)](https://www.npmjs.com/package/@openlabs/scanr_cli) +[![Homebrew](https://img.shields.io/badge/homebrew-Open--Lab--s%2Ftap%2Fscanr-FBB040)](https://github.com/Open-Lab-s/homebrew-tap) +[![Cargo](https://img.shields.io/crates/v/scanr-cli?label=cargo)](https://crates.io/crates/scanr-cli) +[![License](https://img.shields.io/github/license/Open-Lab-s/Scanr)](LICENSE) -- `scanr-cli`: end-user CLI and TUI (`scanr`) -- `scanr-engine`: shared engine contracts and unified finding model -- `scanr-sca`: SCA engine implementation and data models +## 🔭 Vision -## What Scanr Currently Does +Scanr is a multi-engine security framework built for teams that need deterministic security checks without SaaS lock-in. -- Parses dependencies from Node, Python, and Rust manifests -- Queries OSV for known vulnerabilities -- Produces remediation suggestions and upgrade recommendations -- Uses project-local OSV cache for fast and reproducible scans -- Supports offline scans from cache and explicit refresh mode -- Classifies risk (LOW / MODERATE / HIGH) with severity counters -- Enforces CI policy from `scanr.toml` -- Supports vulnerability baseline and security debt delta tracking -- Exports CycloneDX SBOM and computes SBOM diffs -- Traces dependency introduction paths (Node package-lock) -- Emits machine-readable JSON and SARIF -- Provides a full-screen interactive terminal UI +It is designed around: -## Install +- sovereignty +- offline capability +- transparent local execution +- engine-first extensibility +- deterministic CI enforcement + +## 🧱 Architecture + +```text +scanr-engine Unified engine contracts and finding model +scanr-sca Software composition analysis engine (production-ready) +scanr-cli CLI + TUI interface +scanr-container Container engine (planned) +scanr-iac IaC engine (planned) +scanr-sast SAST engine (planned) +scanr-secrets Secret scanning engine (planned) +scanr-server Self-hosted control plane (future) +scanr-dashboard Web UI (future) +``` + +## ✅ What Works Today (v0.1.1) + +- Node, Python, and Rust dependency parsing +- OSV vulnerability matching with CVE + severity data +- remediation suggestions and upgrade guidance +- baseline tracking (`.scanr/baseline.json`) +- project-local OSV cache (`.scanr/cache`) with offline/refresh modes +- policy enforcement in CI via `scanr.toml` +- deterministic exit codes (`0`, `1`, `2`, `3`, `4`) +- CycloneDX SBOM generation and SBOM diff +- SARIF + JSON + raw JSON structured outputs +- Node dependency path tracing (`scanr trace `) +- full-screen TUI with scan controls + +## 📦 Install Channels ```bash -# npm +# NPM npm install -g @openlabs/scanr_cli -# bun (uses npm package) +# BUN (uses npm package) bun install -g @openlabs/scanr_cli # Homebrew brew install Open-Lab-s/tap/scanr -# cargo (source install) -cargo install --path crates/scanr-cli +# Cargo (crates.io) +cargo install scanr-cli --locked -# curl installer +# Curl installer curl -fsSL https://scanr.dev/install.sh | bash ``` -## Quick Start +## 🧩 Which Rust Crate Should I Use? + +- `scanr-cli`: use this if you want the `scanr` command as an end user. +- `scanr-sca`: use this if you are building a Rust app and want to embed SCA scanning logic. +- `scanr-engine`: use this if you are building your own engine or shared policy/reporting on top of Scanr contracts. + +Published crates: + +- `https://crates.io/crates/scanr-cli` +- `https://crates.io/crates/scanr-sca` +- `https://crates.io/crates/scanr-engine` + +Library integration example: + +```toml +[dependencies] +scanr-sca = "0.1.1" +scanr-engine = "0.1.1" +``` + +```rust +use std::path::Path; +use scanr_sca::ScaEngine; + +#[tokio::main] +async fn main() -> Result<(), scanr_sca::ScanError> { + let engine = ScaEngine::new(); + let result = engine.scan_detailed(Path::new(".")).await?; + println!("dependencies: {}", result.total_dependencies); + Ok(()) +} +``` + +## 🛠️ Run From Source (Clone + Test Locally) ```bash +# 1) Clone +git clone https://github.com/Open-Lab-s/Scanr.git +cd Scanr + +# 2) Build release workspace +cargo build --workspace --release + +# 3) Run without installing (dev run) +cargo run --package scanr-cli --bin scanr -- scan . + +# 4) Install local CLI binary for testing (overwrites old local install) +cargo install --path crates/scanr-cli --force + +# 5) Verify installed CLI +scanr --version +scanr --help +``` + +Optional validation: + +```bash +cargo test --workspace +``` + +## ⚡ Quick Start + +```bash +# interactive UI +scanr + +# core scanning scanr scan . scanr scan . --ci scanr scan . --json scanr scan . --sarif + +# caching and baseline scanr scan . --offline scanr scan . --refresh -scanr scan . --baseline scanr baseline save scanr baseline status +scanr scan . --baseline --ci + +# investigation + sbom scanr trace minimatch scanr sbom generate scanr sbom diff old.cdx.json new.cdx.json ``` -Launch TUI: +## 🗺️ Release Timeline -```bash -scanr -``` +| Version | Theme | Highlights | +| --- | --- | --- | +| `v0.1.0` | Foundation | CLI skeleton, SCA scanning, OSV integration, recommendations, CI policy, SBOM, SARIF/JSON, TUI, distribution setup | +| `v0.1.1` | Enterprise hardening | Baseline/security debt tracking, OSV cache + offline mode, dependency tracing, license compliance, engine abstraction (`scanr-engine`) | -## Documentation - -- MkDocs source: [`docs/`](docs) -- Main pages: - - [`docs/index.md`](docs/index.md) - - [`docs/cli.md`](docs/cli.md) - - [`docs/core.md`](docs/core.md) - - [`docs/installation.md`](docs/installation.md) - - [`docs/output-formats.md`](docs/output-formats.md) - - [`docs/ci-policy.md`](docs/ci-policy.md) - - [`docs/baseline.md`](docs/baseline.md) - - [`docs/cache.md`](docs/cache.md) - - [`docs/sbom.md`](docs/sbom.md) - - [`docs/tui.md`](docs/tui.md) +## 📈 Product Timeline -Run docs locally: +| Phase | Version | Status | Outcome | +| --- | --- | --- | --- | +| Foundation | `v0.1.0` | Completed | Built Scanr CLI + SCA core, CI mode, SBOM, SARIF/JSON outputs, install channels | +| Hardening | `v0.1.1` | Completed | Added baseline, cache/offline, tracing, license enforcement, and engine abstraction | +| Multi-Engine Expansion | `v0.2.x` | Planned | Add container engine, then IaC/secrets/SAST engines on the same contract | +| Security OS Layer | `v1.x` | Planned | Self-hosted server, dashboard, org policy management, and governance workflows | -```bash -mkdocs serve -``` +## ✅ Phase Checklist (From Roadmap) + +- [x] Phase 1: Engine Stabilization - SCA engine complete (`scanr-sca`) +- [ ] Phase 1: Engine Stabilization - Container engine (`scanr-container`) +- [ ] Phase 1: Engine Stabilization - IaC engine (`scanr-iac`) +- [ ] Phase 1: Engine Stabilization - Secrets engine (`scanr-secrets`) +- [ ] Phase 1: Engine Stabilization - SAST engine (`scanr-sast`) +- [x] Phase 2: Local Security Suite - CLI + TUI foundation complete +- [ ] Phase 2: Local Security Suite - Multi-engine invocation UX +- [ ] Phase 3: Security OS - `scanr-server` (self-hosted control plane) +- [ ] Phase 3: Security OS - `scanr-dashboard` (web UI) +- [ ] Phase 3: Security OS - SCM/GitHub integration + org governance -## Workspace +## ✅ Feature Timeline (What Is Done) + +### `v0.1.0` delivered + +- CLI command system (`scan`, `sbom`, `trace` foundations) +- dependency parsing for Node/Python/Rust +- OSV vulnerability lookup with remediation hints +- risk summary and CI policy checks +- CycloneDX SBOM generation and SBOM diff +- JSON/SARIF/raw JSON outputs +- interactive TUI experience +- packaging for npm/bun/homebrew/cargo/curl + +### `v0.1.1` delivered + +- baseline save/status/compare workflow +- security debt delta behavior in CI with baseline mode +- project-local OSV cache with TTL +- offline mode and forced refresh mode +- Node dependency path tracing +- license policy enforcement with dedicated exit semantics +- refactor to `scanr-engine` + `scanr-sca` architecture + +## 🧠 Workspace ```text F:\Scanr @@ -99,3 +218,23 @@ F:\Scanr ├── Cargo.toml └── mkdocs.yml ``` + +## 📚 Docs + +- [Documentation index](docs/index.md) +- [Installation](docs/installation.md) +- [Scanr CLI](docs/cli.md) +- [Scanr SCA](docs/core.md) +- [Output formats](docs/output-formats.md) +- [CI policy](docs/ci-policy.md) +- [Baseline](docs/baseline.md) +- [Cache](docs/cache.md) +- [SBOM](docs/sbom.md) +- [TUI](docs/tui.md) +- [Changelog](docs/changelog.md) + +Run docs locally: + +```bash +mkdocs serve +``` diff --git a/crates/scanr-cli/Cargo.toml b/crates/scanr-cli/Cargo.toml index f480dac..aee25b1 100644 --- a/crates/scanr-cli/Cargo.toml +++ b/crates/scanr-cli/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.1" edition = "2024" description = "Scanr command-line interface" license = "Apache-2.0" -repository = "https://github.com/scanr-dev/scanr" +repository = "https://github.com/Open-Lab-s/Scanr" homepage = "https://scanr.dev" readme = "../../README.md" keywords = ["security", "devsecops", "sbom", "vulnerability", "cli"] @@ -14,8 +14,8 @@ categories = ["command-line-utilities", "development-tools"] clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" ratatui = "0.28" -scanr-engine = { path = "../scanr-engine" } -scanr-sca = { path = "../scanr-sca" } +scanr-engine = { version = "0.1.1", path = "../scanr-engine" } +scanr-sca = { version = "0.1.1", path = "../scanr-sca" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } diff --git a/crates/scanr-cli/src/main.rs b/crates/scanr-cli/src/main.rs index e3fb737..a99cb52 100644 --- a/crates/scanr-cli/src/main.rs +++ b/crates/scanr-cli/src/main.rs @@ -7,6 +7,8 @@ use serde::Serialize; mod tui; +const MAX_CACHE_EVENTS_DISPLAY: usize = 25; + #[derive(Debug, Parser)] #[command(name = "scanr", about = "Scanr CLI", version)] struct Cli { @@ -248,9 +250,23 @@ async fn main() { if !scan_result.cache_events.is_empty() { println!(); - for event in &scan_result.cache_events { + for event in scan_result + .cache_events + .iter() + .take(MAX_CACHE_EVENTS_DISPLAY) + { println!("{event}"); } + let remaining = scan_result + .cache_events + .len() + .saturating_sub(MAX_CACHE_EVENTS_DISPLAY); + if remaining > 0 { + println!( + "... and {} more cache events. Use --raw-json for full details.", + remaining + ); + } } if list_deps { diff --git a/crates/scanr-engine/Cargo.toml b/crates/scanr-engine/Cargo.toml index 04be766..db1d88c 100644 --- a/crates/scanr-engine/Cargo.toml +++ b/crates/scanr-engine/Cargo.toml @@ -4,6 +4,11 @@ version = "0.1.1" edition = "2024" description = "Scanr engine abstraction contracts" license = "Apache-2.0" +repository = "https://github.com/Open-Lab-s/Scanr" +homepage = "https://scanr.dev" +readme = "../../README.md" +keywords = ["security", "devsecops", "engine", "scanr"] +categories = ["development-tools"] [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/crates/scanr-sca/Cargo.toml b/crates/scanr-sca/Cargo.toml index 3b52415..e1e79a0 100644 --- a/crates/scanr-sca/Cargo.toml +++ b/crates/scanr-sca/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.1" edition = "2024" description = "SCA engine implementation for Scanr" license = "Apache-2.0" -repository = "https://github.com/scanr-dev/scanr" +repository = "https://github.com/Open-Lab-s/Scanr" homepage = "https://scanr.dev" readme = "../../README.md" keywords = ["security", "devsecops", "sbom", "vulnerability", "sca"] @@ -13,7 +13,7 @@ categories = ["development-tools"] [dependencies] futures = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -scanr-engine = { path = "../scanr-engine" } +scanr-engine = { version = "0.1.1", path = "../scanr-engine" } semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/scanr-sca/src/lib.rs b/crates/scanr-sca/src/lib.rs index 1a44ecd..873ec3f 100644 --- a/crates/scanr-sca/src/lib.rs +++ b/crates/scanr-sca/src/lib.rs @@ -5,6 +5,7 @@ use std::fs; use std::hash::{Hash, Hasher}; use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Duration; use futures::stream::{self, StreamExt}; @@ -16,6 +17,7 @@ use scanr_engine::{ use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as JsonValue}; +use tokio::sync::Mutex; use tokio::time::sleep; use toml::Value as TomlValue; @@ -38,7 +40,7 @@ pub use scanr_engine::Severity; pub use trace::{TraceMatch, TraceReport, trace_dependency_paths}; const OSV_QUERY_URL: &str = "https://api.osv.dev/v1/query"; -const OSV_CONCURRENCY_LIMIT: usize = 8; +const DEFAULT_OSV_CONCURRENCY_LIMIT: usize = 8; const OSV_MAX_RETRIES: usize = 4; const SUPPORTED_MANIFESTS: [&str; 6] = [ "package.json", @@ -1123,35 +1125,38 @@ pub async fn investigate_vulnerabilities_with_options( ) }); - let targets = prepare_vulnerability_targets(dependencies); - let queried_dependencies = targets.len(); + let batches = prepare_vulnerability_target_batches(dependencies); + let queried_dependencies = batches + .iter() + .map(|batch| batch.targets.len()) + .sum::(); let mut failed_queries = 0usize; let mut offline_missing = 0usize; - let mut vulnerabilities = Vec::new(); - let mut recommendations = Vec::new(); - let mut cache_events = Vec::new(); + let mut vulnerabilities = Vec::with_capacity(queried_dependencies.saturating_mul(2)); + let mut recommendations = Vec::with_capacity(queried_dependencies); + let mut cache_events = Vec::with_capacity(queried_dependencies); + let concurrency_limit = osv_concurrency_limit(); - let mut tasks = stream::iter(targets.into_iter().map(|target| { + let mut tasks = stream::iter(batches.into_iter().map(|batch| { let client = client.clone(); let cache_manager = cache_manager.clone(); - async move { fetch_vulnerabilities_for_dependency(&client, target, cache_manager).await } + async move { investigate_target_batch(&client, batch, cache_manager).await } })) - .buffer_unordered(OSV_CONCURRENCY_LIMIT); + .buffer_unordered(concurrency_limit); while let Some(result) = tasks.next().await { - match result { - Ok(package_result) => { - failed_queries += usize::from(package_result.failed_query); - offline_missing += usize::from(package_result.offline_missing); - if let Some(event) = package_result.cache_event { - cache_events.push(event); - } - vulnerabilities.extend(package_result.vulnerabilities); - if let Some(recommendation) = package_result.recommendation { - recommendations.push(recommendation); - } + let batch_outcome = result; + failed_queries += batch_outcome.failed_queries; + for package_result in batch_outcome.results { + failed_queries += usize::from(package_result.failed_query); + offline_missing += usize::from(package_result.offline_missing); + if let Some(event) = package_result.cache_event { + cache_events.push(event); + } + vulnerabilities.extend(package_result.vulnerabilities); + if let Some(recommendation) = package_result.recommendation { + recommendations.push(recommendation); } - Err(_) => failed_queries += 1, } } @@ -1277,6 +1282,11 @@ struct VulnerabilityTarget { version: Version, } +#[derive(Debug)] +struct VulnerabilityTargetBatch { + targets: Vec, +} + #[derive(Debug)] struct PackageInvestigationResult { vulnerabilities: Vec, @@ -1286,9 +1296,17 @@ struct PackageInvestigationResult { cache_event: Option, } -fn prepare_vulnerability_targets(dependencies: &[Dependency]) -> Vec { - let mut targets = Vec::new(); - let mut seen = BTreeSet::new(); +#[derive(Debug)] +struct BatchInvestigationOutcome { + results: Vec, + failed_queries: usize, +} + +fn prepare_vulnerability_target_batches( + dependencies: &[Dependency], +) -> Vec { + let mut batches = BTreeMap::<(Ecosystem, String), Vec>::new(); + let mut seen = HashSet::new(); for dependency in dependencies { let Some(version) = parse_semverish(&dependency.version) else { @@ -1301,20 +1319,36 @@ fn prepare_vulnerability_targets(dependencies: &[Dependency]) -> Vec usize { + std::env::var("SCANR_OSV_CONCURRENCY") + .ok() + .and_then(|raw| raw.parse::().ok()) + .map(|value| value.clamp(1, 64)) + .unwrap_or(DEFAULT_OSV_CONCURRENCY_LIMIT) } async fn fetch_vulnerabilities_for_dependency( client: &reqwest::Client, target: VulnerabilityTarget, cache_manager: Option, + shared_query_payload: Option>>>, ) -> Result { let offline_mode = cache_manager .as_ref() @@ -1329,8 +1363,18 @@ async fn fetch_vulnerabilities_for_dependency( let (payload_json, cache_event, offline_missing) = if let Some(cache_manager) = cache_manager { match cache_manager - .get_or_fetch(&target.dependency, || { - query_osv_json_with_retry(client, &request) + .get_or_fetch(&target.dependency, || async { + if let Some(shared_payload) = &shared_query_payload { + let mut guard = shared_payload.lock().await; + if let Some(payload) = guard.as_ref() { + return Ok(payload.clone()); + } + let payload = query_osv_json_with_retry(client, &request).await?; + *guard = Some(payload.clone()); + Ok(payload) + } else { + query_osv_json_with_retry(client, &request).await + } }) .await? { @@ -1362,11 +1406,22 @@ async fn fetch_vulnerabilities_for_dependency( } } } else { - ( - query_osv_json_with_retry(client, &request).await?, - None, - false, - ) + if let Some(shared_payload) = &shared_query_payload { + let mut guard = shared_payload.lock().await; + if let Some(payload) = guard.as_ref() { + (payload.clone(), None, false) + } else { + let payload = query_osv_json_with_retry(client, &request).await?; + *guard = Some(payload.clone()); + (payload, None, false) + } + } else { + ( + query_osv_json_with_retry(client, &request).await?, + None, + false, + ) + } }; let payload = serde_json::from_value::(payload_json).map_err(|source| { @@ -1430,6 +1485,43 @@ async fn fetch_vulnerabilities_for_dependency( }) } +async fn investigate_target_batch( + client: &reqwest::Client, + batch: VulnerabilityTargetBatch, + cache_manager: Option, +) -> BatchInvestigationOutcome { + let batch_size = batch.targets.len(); + if batch_size == 0 { + return BatchInvestigationOutcome { + results: Vec::new(), + failed_queries: 0, + }; + } + + let shared_query_payload = Arc::new(Mutex::new(None)); + let mut results = Vec::with_capacity(batch_size); + let mut failed_queries = 0usize; + + for target in batch.targets { + match fetch_vulnerabilities_for_dependency( + client, + target, + cache_manager.clone(), + Some(shared_query_payload.clone()), + ) + .await + { + Ok(result) => results.push(result), + Err(_) => failed_queries += 1, + } + } + + BatchInvestigationOutcome { + results, + failed_queries, + } +} + async fn query_osv_json_with_retry( client: &reqwest::Client, request: &OsvQueryRequest, diff --git a/docs/cli.md b/docs/cli.md index c7f78ce..79a9d07 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,6 +2,24 @@ `scanr-cli` is the executable crate that exposes the `scanr` command. +## Which Crate Should I Use? + +- `scanr-cli`: install and run `scanr` as an end user. +- `scanr-sca`: embed Scanr SCA scanning in your Rust app. +- `scanr-engine`: build custom engines on Scanr contracts (`ScanEngine`, `Finding`, `ScanResult`). + +Published crates: + +- `https://crates.io/crates/scanr-cli` +- `https://crates.io/crates/scanr-sca` +- `https://crates.io/crates/scanr-engine` + +CLI install: + +```bash +cargo install scanr-cli --locked +``` + ## Command Tree ```bash diff --git a/docs/core.md b/docs/core.md index e941814..77a1360 100644 --- a/docs/core.md +++ b/docs/core.md @@ -2,6 +2,39 @@ `scanr-sca` is the SCA engine implementation used by `scanr-cli`. +## Which Crate Should I Use? + +- Use `scanr-cli` if you want the ready-to-run `scanr` command. +- Use `scanr-sca` if you are embedding SCA logic in a Rust application. +- Use `scanr-engine` if you are implementing custom engines with shared contracts. + +Published crates: + +- `https://crates.io/crates/scanr-cli` +- `https://crates.io/crates/scanr-sca` +- `https://crates.io/crates/scanr-engine` + +Add `scanr-sca` for integration: + +```bash +cargo add scanr-sca +``` + +Minimal async usage: + +```rust +use std::path::Path; +use scanr_sca::ScaEngine; + +#[tokio::main] +async fn main() -> Result<(), scanr_sca::ScanError> { + let engine = ScaEngine::new(); + let result = engine.scan_detailed(Path::new(".")).await?; + println!("dependencies: {}", result.total_dependencies); + Ok(()) +} +``` + ## Responsibilities - Parse dependencies from supported manifest formats diff --git a/docs/index.md b/docs/index.md index be73053..2ce3db6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,63 +1,155 @@ # Scanr -Scanr is a Rust dependency security scanner with two first-class crates: - -- `scanr-cli`: command and terminal UI layer -- `scanr-engine`: shared engine contracts and unified findings -- `scanr-sca`: SCA implementation (parsing, vulnerability, policy, SBOM, and outputs) - -## Core Capabilities - -- Dependency parsing for: - - Node: `package.json`, `package-lock.json` - - Python: `requirements.txt`, `pyproject.toml` - - Rust: `Cargo.toml`, `Cargo.lock` -- Vulnerability investigation through OSV -- Project-local OSV caching with TTL -- Offline mode and forced refresh controls -- Severity and risk classification -- Upgrade recommendations (safe version targeting) -- Baseline save/compare/status for incremental adoption -- Node dependency path tracing (`scanr trace `) -- CI policy enforcement using `scanr.toml` +🔐 Privacy-first, self-hostable, open-source security platform. + +[![Release](https://img.shields.io/github/v/release/Open-Lab-s/Scanr?label=release)](https://github.com/Open-Lab-s/Scanr/releases) +[![NPM](https://img.shields.io/npm/v/%40openlabs%2Fscanr_cli?label=npm)](https://www.npmjs.com/package/@openlabs/scanr_cli) +[![Bun](https://img.shields.io/badge/bun-supported-black)](https://www.npmjs.com/package/@openlabs/scanr_cli) +[![Homebrew](https://img.shields.io/badge/homebrew-Open--Lab--s%2Ftap%2Fscanr-FBB040)](https://github.com/Open-Lab-s/homebrew-tap) +[![Cargo](https://img.shields.io/crates/v/scanr-cli?label=cargo)](https://crates.io/crates/scanr-cli) + +## 🚀 Platform Vision + +Scanr is evolving into an open Security OS for private infrastructure: + +- fully local and deterministic by default +- no mandatory SaaS dependency +- unified engine architecture +- CI-native policy enforcement +- composable, extensible security workflow + +## 🧱 Engine-First Architecture + +```text +scanr-engine Unified abstraction layer for findings and engine contracts +scanr-sca Software composition analysis engine (current production engine) +scanr-cli Local interface (CLI + TUI) +scanr-container Planned container engine +scanr-iac Planned IaC engine +scanr-sast Planned static analysis engine +scanr-secrets Planned secret scanning engine +``` + +## ✅ Current Feature Set + +- dependency scanning (Node, Python, Rust) +- OSV vulnerability matching with CVE/severity/remediation +- risk summary + CI policy enforcement +- baseline and security debt tracking +- project-local cache + offline mode +- license compliance enforcement - CycloneDX SBOM generation and SBOM diff -- Structured output modes: - - `--json` - - `--sarif` - - `--raw-json` / `--raw-json-out` -- Interactive TUI with overview, dependencies, and recommendations views +- JSON and SARIF outputs for automation +- dependency path tracing for Node lockfiles +- clean full-screen TUI flow + +## 📦 Installation Labels + +- `NPM`: `npm install -g @openlabs/scanr_cli` +- `BUN`: `bun install -g @openlabs/scanr_cli` +- `Homebrew`: `brew install Open-Lab-s/tap/scanr` +- `Cargo`: `cargo install scanr-cli --locked` +- `Curl`: `curl -fsSL https://scanr.dev/install.sh | bash` + +## 🧩 Which Rust Crate Should I Use? + +- `scanr-cli`: for CLI/TUI users who want the `scanr` binary. +- `scanr-sca`: for Rust integrators embedding dependency and vulnerability scanning. +- `scanr-engine`: for custom engine development with shared `Finding`/`ScanResult` contracts. -## Quick Start +Published crates: + +- `https://crates.io/crates/scanr-cli` +- `https://crates.io/crates/scanr-sca` +- `https://crates.io/crates/scanr-engine` + +## 🛠️ Clone and Run for Local Testing ```bash +git clone https://github.com/Open-Lab-s/Scanr.git +cd Scanr + +cargo build --workspace --release +cargo run --package scanr-cli --bin scanr -- scan . + +# install local binary for repeated manual testing +cargo install --path crates/scanr-cli --force + +scanr --version +``` + +## ⚡ Quick Start + +```bash +scanr scanr scan . scanr scan . --ci scanr scan . --json +scanr scan . --sarif +scanr baseline save +scanr trace minimatch scanr sbom generate ``` -## Architecture +## 🗺️ Release Timeline -```text -F:\Scanr -├── crates/ -│ ├── scanr-engine/ # shared engine contracts -│ ├── scanr-sca/ # SCA engine implementation -│ └── scanr-cli/ # user-facing CLI and TUI -├── installers/ # npm, bun, brew, aur, curl assets -├── docs/ # mkdocs content -├── Cargo.toml # workspace root -└── mkdocs.yml # docs site nav/config -``` +| Version | Scope | Key outcomes | +| --- | --- | --- | +| `v0.1.0` | Foundation | Core CLI, SCA, OSV, recommendations, CI mode, SBOM, SARIF/JSON, packaging channels | +| `v0.1.1` | Hardening + framework | Baseline, cache/offline, trace, license policy, engine abstraction and multi-engine-ready architecture | + +## 📈 Product Timeline + +| Phase | Version | Status | Outcome | +| --- | --- | --- | --- | +| Foundation | `v0.1.0` | Completed | Production-ready SCA CLI baseline with CI and reporting outputs | +| Hardening | `v0.1.1` | Completed | Baseline + cache/offline + trace + license + engine abstraction | +| Multi-Engine Expansion | `v0.2.x` | Planned | Container engine first, then IaC/secrets/SAST | +| Security OS | `v1.x` | Planned | Self-hosted server, dashboard, org governance and historical analytics | + +## ✅ Phase Checklist + +- [x] Phase 1: SCA engine stabilized and production-ready +- [ ] Phase 1: Container engine implementation +- [ ] Phase 1: IaC engine implementation +- [ ] Phase 1: Secrets engine implementation +- [ ] Phase 1: SAST engine implementation +- [x] Phase 2: Local suite foundation (CLI + TUI + CI outputs) +- [ ] Phase 2: Full multi-engine local orchestration +- [ ] Phase 3: Security OS (`scanr-server` + `scanr-dashboard`) +- [ ] Phase 3: SCM integrations and org-level governance + +## ✅ Feature Timeline + +### Delivered in `v0.1.0` + +- dependency parsing (Node/Python/Rust) +- OSV vulnerability investigation and remediation hints +- CI policy checks and risk classification +- CycloneDX SBOM generation + SBOM diff +- JSON and SARIF output modes +- interactive TUI +- install channels (npm, bun, brew, cargo, curl) + +### Delivered in `v0.1.1` + +- baseline tracking and baseline-aware CI behavior +- local OSV caching with TTL and refresh control +- offline scan mode +- dependency path tracing (Node lockfile) +- license compliance policy enforcement +- engine-layer refactor to `scanr-engine` + `scanr-sca` + +## 📚 Documentation Map -## Documentation Map - -- **Installation**: all supported install channels -- **Changelog**: release history by version -- **Scanr CLI**: commands, flags, and command output -- **TUI Mode**: interactive full-screen UI and key bindings -- **Output Formats**: human, JSON, SARIF, raw JSON -- **CI Policy**: `scanr.toml` policy model and CI exit behavior -- **SBOM**: CycloneDX generation and diff behavior -- **Scanr SCA**: SCA models and API surface -- **Development**: build, test, release, and contribution workflow +- [Installation](installation.md) +- [Changelog](changelog.md) +- [Scanr CLI](cli.md) +- [Scanr SCA](core.md) +- [Output Formats](output-formats.md) +- [CI Policy](ci-policy.md) +- [Baseline](baseline.md) +- [Cache](cache.md) +- [SBOM](sbom.md) +- [TUI](tui.md) +- [Development](development.md) diff --git a/docs/installation.md b/docs/installation.md index c77cc71..b23f525 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -30,16 +30,36 @@ Tap repository: - `https://github.com/Open-Lab-s/homebrew-tap` -## Cargo (source install) +## Cargo (crates.io) ```bash -cargo install --path crates/scanr-cli +cargo install scanr-cli --locked ``` -For local workspace development: +Crates page: + +- `https://crates.io/crates/scanr-cli` + +## Published Rust Crates + +| Crate | Purpose | Typical user | +| --- | --- | --- | +| `scanr-cli` | Installs the `scanr` command-line interface | CLI users, CI pipelines | +| `scanr-sca` | SCA engine library (dependency parsing + vuln resolution) | Rust app integrators | +| `scanr-engine` | Engine abstraction contracts (`ScanEngine`, `Finding`, `ScanResult`) | Engine/plugin developers | + +Add library crates to your Rust project: + +```bash +cargo add scanr-sca +cargo add scanr-engine +``` + +For local workspace development/testing: ```bash cargo build --workspace --release +cargo install --path crates/scanr-cli --force ``` ## curl Installer