From 7037eed46e030ef4e75e8eb4a3ccbf0835f240de Mon Sep 17 00:00:00 2001 From: prasanth_j Date: Wed, 4 Mar 2026 21:30:19 +0530 Subject: [PATCH] feat: implement unified dependency collection for container engine --- crates/scanr-cli/src/main.rs | 2 +- crates/scanr-container/src/lib.rs | 115 ++++++++++------- crates/scanr-sca/src/lib.rs | 143 ++++++++++++++++++---- crates/scanr-sca/src/license/extractor.rs | 7 +- 4 files changed, 201 insertions(+), 66 deletions(-) diff --git a/crates/scanr-cli/src/main.rs b/crates/scanr-cli/src/main.rs index 7a1e8cf..5173f88 100644 --- a/crates/scanr-cli/src/main.rs +++ b/crates/scanr-cli/src/main.rs @@ -584,7 +584,7 @@ async fn main() { println!("Scanr Container Scan"); println!("Engine: {}", result.metadata.engine_name); println!("Target: {}", result.metadata.target); - println!("Status: placeholder implementation (C1 skeleton)"); + println!("Status: dependency composition scan"); println!("Dependencies discovered: {}", result.metadata.total_dependencies); println!("Findings: {}", result.findings.len()); } diff --git a/crates/scanr-container/src/lib.rs b/crates/scanr-container/src/lib.rs index 547ade0..d7d8503 100644 --- a/crates/scanr-container/src/lib.rs +++ b/crates/scanr-container/src/lib.rs @@ -6,7 +6,7 @@ use std::process::Command; use std::time::{Duration, Instant}; use scanr_engine::{EngineError, EngineType, ScanEngine, ScanInput, ScanMetadata, ScanResult}; -use scanr_sca::{Dependency as ScaDependency, ScaEngine}; +use scanr_sca::{Dependency as ScaDependency, Ecosystem as ScaEcosystem, ScaEngine}; use serde::Deserialize; use tempfile::TempDir; use walkdir::WalkDir; @@ -56,13 +56,6 @@ pub struct OsDependency { pub version: String, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ContainerDependency { - ecosystem: String, - name: String, - version: String, -} - #[derive(Debug)] struct AcquiredImage { source_mode: ImageSourceMode, @@ -138,22 +131,27 @@ impl ScanEngine for ContainerEngine { let acquired = self.acquire_image(input)?; let rootfs = self.build_rootfs(&acquired.image_extract_path)?; let distro = self.detect_distro(&rootfs.path); - let os_dependencies = self.extract_os_dependencies(&rootfs.path, distro)?; - let app_dependencies = self.discover_application_dependencies(&rootfs.path)?; - let merged_dependencies = self.merge_dependencies(&os_dependencies, &app_dependencies); - - let _ = &self.sca_engine; - let _ = rootfs.path.as_path(); + let dependencies = self.collect_all_dependencies(&rootfs.path, distro)?; + let sca_result = self.resolve_with_sca_dependencies( + dependencies, + &acquired.target_display, + &rootfs.path, + )?; + let mut findings = scanr_sca::findings_from_scan_result(&sca_result); + for finding in &mut findings { + finding.engine = EngineType::Container; + finding.location = Some(acquired.target_display.clone()); + } let _ = acquired.source_mode; Ok(ScanResult { - findings: Vec::new(), + findings, metadata: ScanMetadata { engine: EngineType::Container, engine_name: self.name().to_string(), - target: acquired.target_display, - total_dependencies: merged_dependencies.len(), - total_vulnerabilities: 0, + target: acquired.target_display.clone(), + total_dependencies: sca_result.total_dependencies as usize, + total_vulnerabilities: sca_result.vulnerabilities.len(), }, }) } @@ -399,6 +397,33 @@ impl ContainerEngine { } } + fn collect_all_dependencies( + &self, + rootfs_path: &Path, + distro: Distro, + ) -> Result, EngineError> { + let os_dependencies = self.extract_os_dependencies(rootfs_path, distro)?; + let app_dependencies = self.discover_application_dependencies(rootfs_path)?; + + let mut merged = app_dependencies; + for dependency in os_dependencies { + let ecosystem = ecosystem_from_os_label(&dependency.ecosystem).ok_or_else(|| { + EngineError::new(format!( + "unsupported OS ecosystem mapping '{}'", + dependency.ecosystem + )) + })?; + merged.push(ScaDependency { + ecosystem, + name: dependency.name, + version: dependency.version, + direct: false, + }); + } + + Ok(dedupe_sca_dependencies(merged)) + } + fn discover_application_dependencies( &self, rootfs_path: &Path, @@ -440,30 +465,28 @@ impl ContainerEngine { manifests } - fn merge_dependencies( + fn resolve_with_sca_dependencies( &self, - os_dependencies: &[OsDependency], - app_dependencies: &[ScaDependency], - ) -> Vec { - let mut set = BTreeSet::new(); - - for dependency in os_dependencies { - set.insert(ContainerDependency { - ecosystem: dependency.ecosystem.clone(), - name: dependency.name.clone(), - version: dependency.version.clone(), - }); - } - - for dependency in app_dependencies { - set.insert(ContainerDependency { - ecosystem: dependency.ecosystem.to_string(), - name: dependency.name.clone(), - version: dependency.version.clone(), - }); - } - - set.into_iter().collect() + dependencies: Vec, + target: &str, + rootfs_path: &Path, + ) -> Result { + let query_options = scanr_sca::VulnerabilityQueryOptions { + cache_base_path: Some(rootfs_path.to_path_buf()), + cache_enabled: false, + cache_ttl_hours: 24, + offline: false, + force_refresh: false, + }; + + self.sca_engine + .resolve_dependencies_with_query_options( + dependencies, + query_options, + target.to_string(), + rootfs_path.display().to_string(), + ) + .map_err(|error| EngineError::new(format!("sca dependency resolution failed: {error}"))) } fn extract_alpine_packages(&self, rootfs_path: &Path) -> Result, EngineError> { @@ -909,6 +932,16 @@ fn looks_distroless(rootfs_path: &Path) -> bool { !has_common_package_managers && !has_shell } +fn ecosystem_from_os_label(label: &str) -> Option { + match label.to_ascii_lowercase().as_str() { + "alpine" => Some(ScaEcosystem::Alpine), + "debian" => Some(ScaEcosystem::Debian), + "ubuntu" => Some(ScaEcosystem::Ubuntu), + "rhel" => Some(ScaEcosystem::Rhel), + _ => None, + } +} + fn dedupe_sca_dependencies(dependencies: Vec) -> Vec { let mut seen = BTreeSet::new(); let mut output = Vec::new(); diff --git a/crates/scanr-sca/src/lib.rs b/crates/scanr-sca/src/lib.rs index 873ec3f..475b79c 100644 --- a/crates/scanr-sca/src/lib.rs +++ b/crates/scanr-sca/src/lib.rs @@ -70,6 +70,42 @@ impl ScaEngine { ) -> Result { scan_path_with_options(path, options).await } + + pub fn resolve_dependencies(&self, dependencies: Vec) -> Result { + self.resolve_dependencies_with_query_options( + dependencies, + VulnerabilityQueryOptions::default(), + "dependency-set".to_string(), + "container://dependency-set".to_string(), + ) + } + + pub fn resolve_dependencies_with_query_options( + &self, + dependencies: Vec, + options: VulnerabilityQueryOptions, + target: String, + path: String, + ) -> Result { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + return tokio::task::block_in_place(|| { + handle.block_on(resolve_dependencies_with_options( + dependencies, &options, target, path, + )) + }); + } + + let runtime = tokio::runtime::Runtime::new().map_err(|source| ScanError::Io { + path: PathBuf::from("tokio-runtime"), + source: std::io::Error::other(format!("failed to create tokio runtime: {source}")), + })?; + runtime.block_on(resolve_dependencies_with_options( + dependencies, + &options, + target, + path, + )) + } } impl ScanEngine for ScaEngine { @@ -112,6 +148,10 @@ pub enum Ecosystem { Node, Python, Rust, + Alpine, + Debian, + Ubuntu, + Rhel, } impl Display for Ecosystem { @@ -120,6 +160,10 @@ impl Display for Ecosystem { Self::Node => write!(f, "node"), Self::Python => write!(f, "python"), Self::Rust => write!(f, "rust"), + Self::Alpine => write!(f, "alpine"), + Self::Debian => write!(f, "debian"), + Self::Ubuntu => write!(f, "ubuntu"), + Self::Rhel => write!(f, "rhel"), } } } @@ -719,9 +763,23 @@ pub async fn scan_path_with_options( force_refresh: options.force_refresh, }; + resolve_dependencies_with_options( + dependencies, + &vulnerability_options, + target, + display_path, + ) + .await +} + +pub async fn resolve_dependencies_with_options( + dependencies: Vec, + options: &VulnerabilityQueryOptions, + target: String, + path: String, +) -> Result { let (vulnerability_report, lookup_error) = - match investigate_vulnerabilities_with_options(&dependencies, &vulnerability_options).await - { + match investigate_vulnerabilities_with_options(&dependencies, options).await { Ok(report) => (report, None), Err(error) => ( VulnerabilityReport { @@ -735,6 +793,23 @@ pub async fn scan_path_with_options( Some(error.to_string()), ), }; + + Ok(build_scan_result_from_dependencies( + target, + path, + dependencies, + vulnerability_report, + lookup_error, + )) +} + +fn build_scan_result_from_dependencies( + target: String, + path: String, + dependencies: Vec, + vulnerability_report: VulnerabilityReport, + lookup_error: Option, +) -> ScanResult { let risk_summary = summarize_risk(&vulnerability_report.vulnerabilities); let severity_summary = SeveritySummary { critical: to_u32(risk_summary.counts.critical), @@ -745,9 +820,9 @@ pub async fn scan_path_with_options( }; let risk_score = calculate_risk_score(&severity_summary); - Ok(ScanResult { + ScanResult { target, - path: display_path, + path, total_dependencies: to_u32(dependencies.len()), dependencies, vulnerabilities: vulnerability_report.vulnerabilities, @@ -760,7 +835,7 @@ pub async fn scan_path_with_options( offline_missing: to_u32(vulnerability_report.offline_missing), lookup_error, cache_events: vulnerability_report.cache_events, - }) + } } fn convert_to_engine_scan_result(scan_result: &ScanResult) -> EngineScanResult { @@ -1279,7 +1354,7 @@ fn resolve_scan_root(target_path: &Path) -> PathBuf { #[derive(Debug, Clone)] struct VulnerabilityTarget { dependency: Dependency, - version: Version, + parsed_version: Option, } #[derive(Debug)] @@ -1309,10 +1384,6 @@ fn prepare_vulnerability_target_batches( let mut seen = HashSet::new(); for dependency in dependencies { - let Some(version) = parse_semverish(&dependency.version) else { - continue; - }; - let key = ( dependency.ecosystem, dependency.name.clone(), @@ -1321,7 +1392,7 @@ fn prepare_vulnerability_target_batches( if seen.insert(key) { let target = VulnerabilityTarget { dependency: dependency.clone(), - version, + parsed_version: parse_semverish(&dependency.version), }; batches .entry((target.dependency.ecosystem, target.dependency.name.clone())) @@ -1433,7 +1504,11 @@ async fn fetch_vulnerabilities_for_dependency( let mut vulnerabilities = Vec::new(); for vuln in &payload.vulns { - if !vulnerability_applies_to_dependency(&vuln, &target.dependency, &target.version) { + if !vulnerability_applies_to_dependency( + &vuln, + &target.dependency, + target.parsed_version.as_ref(), + ) { continue; } @@ -1570,6 +1645,10 @@ async fn recommend_safe_upgrade( return Ok(None); } + let Some(target_version) = target.parsed_version.as_ref() else { + return Ok(None); + }; + let registry_versions = if allow_registry_fetch { fetch_registry_versions(client, target.dependency.ecosystem, &target.dependency.name) .await? @@ -1584,11 +1663,11 @@ async fn recommend_safe_upgrade( candidates.sort_by(|(_, left), (_, right)| left.cmp(right)); let suggested = candidates.into_iter().find(|(_raw, parsed)| { - parsed >= &target.version + parsed >= target_version && !version_is_vulnerable_for_dependency( package_vulns, &target.dependency, - parsed, + Some(parsed), &target.dependency.version, ) }); @@ -1614,7 +1693,7 @@ async fn recommend_safe_upgrade( ecosystem: target.dependency.ecosystem, current_version: target.dependency.version.clone(), suggested_version: suggested_raw, - major_bump: suggested_parsed.major > target.version.major, + major_bump: suggested_parsed.major > target_version.major, })) } @@ -1645,7 +1724,11 @@ async fn fetch_registry_versions( .unwrap_or_default(); Ok(versions) } - Ecosystem::Rust => Ok(Vec::new()), + Ecosystem::Rust + | Ecosystem::Alpine + | Ecosystem::Debian + | Ecosystem::Ubuntu + | Ecosystem::Rhel => Ok(Vec::new()), } } @@ -1711,7 +1794,7 @@ fn is_retryable_error(error: &reqwest::Error) -> bool { fn vulnerability_applies_to_dependency( vulnerability: &OsvVulnerability, dependency: &Dependency, - dependency_version: &Version, + dependency_version: Option<&Version>, ) -> bool { for affected in &vulnerability.affected { if let Some(package) = &affected.package { @@ -1738,7 +1821,7 @@ fn vulnerability_applies_to_dependency( fn version_is_vulnerable_for_dependency( vulnerabilities: &[OsvVulnerability], dependency: &Dependency, - candidate_version: &Version, + candidate_version: Option<&Version>, candidate_raw: &str, ) -> bool { vulnerabilities.iter().any(|vulnerability| { @@ -1763,15 +1846,17 @@ fn version_is_vulnerable_for_dependency( fn affected_versions_match( affected: &OsvAffected, - dependency_version: &Version, + dependency_version: Option<&Version>, raw_dependency_version: &str, ) -> bool { for explicit in &affected.versions { if explicit == raw_dependency_version { return true; } - if let Some(parsed) = parse_semverish(explicit) { - if &parsed == dependency_version { + if let (Some(parsed_dependency_version), Some(parsed)) = + (dependency_version, parse_semverish(explicit)) + { + if &parsed == parsed_dependency_version { return true; } } @@ -1779,8 +1864,9 @@ fn affected_versions_match( for range in &affected.ranges { let kind = range.kind.to_ascii_uppercase(); - if (kind == "SEMVER" || kind == "ECOSYSTEM") - && version_matches_range_events(dependency_version, &range.events) + if let Some(parsed_dependency_version) = dependency_version + && (kind == "SEMVER" || kind == "ECOSYSTEM") + && version_matches_range_events(parsed_dependency_version, &range.events) { return true; } @@ -1979,6 +2065,10 @@ fn package_url(dependency: &Dependency) -> String { Ecosystem::Node => "npm", Ecosystem::Python => "pypi", Ecosystem::Rust => "cargo", + Ecosystem::Alpine => "apk", + Ecosystem::Debian => "deb", + Ecosystem::Ubuntu => "deb", + Ecosystem::Rhel => "rpm", }; let name = encode_purl_name(&dependency.name); let version = encode_purl_segment(&dependency.version); @@ -2000,6 +2090,9 @@ fn parse_purl_dependency(purl: &str) -> Option<(Ecosystem, String, Option Ecosystem::Node, "pypi" => Ecosystem::Python, "cargo" | "crates.io" => Ecosystem::Rust, + "apk" => Ecosystem::Alpine, + "deb" => Ecosystem::Debian, + "rpm" => Ecosystem::Rhel, _ => return None, }; @@ -2190,6 +2283,10 @@ fn osv_ecosystem(ecosystem: Ecosystem) -> &'static str { Ecosystem::Node => "npm", Ecosystem::Python => "PyPI", Ecosystem::Rust => "crates.io", + Ecosystem::Alpine => "Alpine", + Ecosystem::Debian => "Debian", + Ecosystem::Ubuntu => "Ubuntu", + Ecosystem::Rhel => "Red Hat", } } diff --git a/crates/scanr-sca/src/license/extractor.rs b/crates/scanr-sca/src/license/extractor.rs index 0313aa7..94d9e24 100644 --- a/crates/scanr-sca/src/license/extractor.rs +++ b/crates/scanr-sca/src/license/extractor.rs @@ -36,7 +36,12 @@ pub fn extract_licenses_for_dependencies( resolved }) .unwrap_or_else(|| "UNKNOWN".to_string()), - Ecosystem::Python | Ecosystem::Rust => "UNKNOWN".to_string(), + Ecosystem::Python + | Ecosystem::Rust + | Ecosystem::Alpine + | Ecosystem::Debian + | Ecosystem::Ubuntu + | Ecosystem::Rhel => "UNKNOWN".to_string(), }; LicenseInfo {