use std::path::Path; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::traits::{ScanOutput, Scanner}; use compliance_core::CoreError; use crate::pipeline::dedup; pub struct GitleaksScanner; impl Scanner for GitleaksScanner { fn name(&self) -> &str { "gitleaks" } fn scan_type(&self) -> ScanType { ScanType::SecretDetection } async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { let output = tokio::process::Command::new("gitleaks") .args([ "detect", "--source", ".", "--report-format", "json", "--report-path", "/dev/stdout", "--no-banner", "--exit-code", "0", ]) .current_dir(repo_path) .output() .await .map_err(|e| CoreError::Scanner { scanner: "gitleaks".to_string(), source: Box::new(e), })?; if output.stdout.is_empty() { return Ok(ScanOutput::default()); } let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let findings = results .into_iter() .filter(|r| !is_allowlisted(&r.file)) .map(|r| { let severity = match r.rule_id.as_str() { s if s.contains("private-key") => Severity::Critical, s if s.contains("token") || s.contains("password") || s.contains("secret") => { Severity::High } s if s.contains("api-key") => Severity::High, _ => Severity::Medium, }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, &r.rule_id, &r.file, &r.start_line.to_string(), ]); let title = format!("Secret detected: {}", r.description); let description = format!( "Potential secret ({}) found in {}:{}. Match: {}", r.rule_id, r.file, r.start_line, r.r#match.chars().take(80).collect::(), ); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "gitleaks".to_string(), ScanType::SecretDetection, title, description, severity, ); finding.rule_id = Some(r.rule_id); finding.file_path = Some(r.file); finding.line_number = Some(r.start_line); finding.code_snippet = Some(r.r#match); finding }) .collect(); Ok(ScanOutput { findings, sbom_entries: Vec::new(), }) } } /// Skip files that commonly contain example/placeholder secrets fn is_allowlisted(file_path: &str) -> bool { let lower = file_path.to_lowercase(); lower.ends_with(".env.example") || lower.ends_with(".env.sample") || lower.ends_with(".env.template") || lower.contains("/test/") || lower.contains("/tests/") || lower.contains("/fixtures/") || lower.contains("/testdata/") || lower.contains("mock") || lower.ends_with("_test.go") || lower.ends_with(".test.ts") || lower.ends_with(".test.js") || lower.ends_with(".spec.ts") || lower.ends_with(".spec.js") } #[derive(serde::Deserialize)] #[serde(rename_all = "PascalCase")] struct GitleaksResult { description: String, #[serde(rename = "RuleID")] rule_id: String, file: String, start_line: u32, #[serde(rename = "Match")] r#match: String, }