use std::path::Path; use compliance_core::models::{SbomEntry, ScanType, VulnRef}; use compliance_core::traits::{ScanOutput, Scanner}; use compliance_core::CoreError; pub struct SbomScanner; impl Scanner for SbomScanner { fn name(&self) -> &str { "sbom" } fn scan_type(&self) -> ScanType { ScanType::Sbom } async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { let mut entries = Vec::new(); // Run syft for SBOM generation match run_syft(repo_path, repo_id).await { Ok(syft_entries) => entries.extend(syft_entries), Err(e) => tracing::warn!("syft failed: {e}"), } // Run cargo-audit for Rust-specific vulns match run_cargo_audit(repo_path, repo_id).await { Ok(vulns) => merge_audit_vulns(&mut entries, vulns), Err(e) => tracing::warn!("cargo-audit skipped: {e}"), } Ok(ScanOutput { findings: Vec::new(), sbom_entries: entries, }) } } async fn run_syft(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let output = tokio::process::Command::new("syft") .arg(repo_path) .args(["-o", "cyclonedx-json"]) .output() .await .map_err(|e| CoreError::Scanner { scanner: "syft".to_string(), source: Box::new(e), })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(CoreError::Scanner { scanner: "syft".to_string(), source: format!("syft exited with {}: {stderr}", output.status).into(), }); } let cdx: CycloneDxBom = serde_json::from_slice(&output.stdout)?; let entries = cdx .components .unwrap_or_default() .into_iter() .map(|c| { let mut entry = SbomEntry::new( repo_id.to_string(), c.name, c.version.unwrap_or_else(|| "unknown".to_string()), c.component_type.unwrap_or_else(|| "library".to_string()), ); entry.purl = c.purl; entry.license = c.licenses.and_then(|ls| { ls.first().and_then(|l| { l.license.as_ref().map(|lic| { lic.id .clone() .unwrap_or_else(|| lic.name.clone().unwrap_or_default()) }) }) }); entry }) .collect(); Ok(entries) } async fn run_cargo_audit(repo_path: &Path, _repo_id: &str) -> Result, CoreError> { let cargo_lock = repo_path.join("Cargo.lock"); if !cargo_lock.exists() { return Ok(Vec::new()); } let output = tokio::process::Command::new("cargo") .args(["audit", "--json"]) .current_dir(repo_path) .output() .await .map_err(|e| CoreError::Scanner { scanner: "cargo-audit".to_string(), source: Box::new(e), })?; let result: CargoAuditOutput = serde_json::from_slice(&output.stdout).unwrap_or_else(|_| CargoAuditOutput { vulnerabilities: CargoAuditVulns { list: Vec::new() }, }); let vulns = result .vulnerabilities .list .into_iter() .map(|v| AuditVuln { package: v.advisory.package, id: v.advisory.id, url: v.advisory.url, }) .collect(); Ok(vulns) } fn merge_audit_vulns(entries: &mut [SbomEntry], vulns: Vec) { for vuln in vulns { if let Some(entry) = entries.iter_mut().find(|e| e.name == vuln.package) { entry.known_vulnerabilities.push(VulnRef { id: vuln.id.clone(), source: "cargo-audit".to_string(), severity: None, url: Some(vuln.url), }); } } } // CycloneDX JSON types #[derive(serde::Deserialize)] struct CycloneDxBom { components: Option>, } #[derive(serde::Deserialize)] struct CdxComponent { name: String, version: Option, #[serde(rename = "type")] component_type: Option, purl: Option, licenses: Option>, } #[derive(serde::Deserialize)] struct CdxLicenseWrapper { license: Option, } #[derive(serde::Deserialize)] struct CdxLicense { id: Option, name: Option, } // Cargo audit types #[derive(serde::Deserialize)] struct CargoAuditOutput { vulnerabilities: CargoAuditVulns, } #[derive(serde::Deserialize)] struct CargoAuditVulns { list: Vec, } #[derive(serde::Deserialize)] struct CargoAuditEntry { advisory: CargoAuditAdvisory, } #[derive(serde::Deserialize)] struct CargoAuditAdvisory { id: String, package: String, url: String, } struct AuditVuln { package: String, id: String, url: String, }