use std::path::{Path, PathBuf}; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::traits::{ScanOutput, Scanner}; use compliance_core::CoreError; use crate::pipeline::dedup; /// Custom CRA-control detectors bundled into the binary and staged to a temp file /// at scan time so semgrep can `--config` them alongside the auto ruleset. These /// cover controls no off-the-shelf rule digs out (secure defaults, weak password /// hashing, insecure session cookies, weak data-at-rest ciphers); each rule id is /// keyed back to its control by the `control-map` LUT. const CRA_RULES: &str = include_str!("../../rules/cra_semgrep.yaml"); /// Write the bundled CRA rules to a stable temp path (atomic: unique tmp + /// rename). Returns `None` on failure — the scan then runs with auto rules only. async fn stage_cra_rules() -> Option { let dir = std::env::temp_dir(); let path = dir.join("compliance-cra-semgrep.yaml"); let tmp = dir.join(format!("compliance-cra-semgrep.{}.tmp", std::process::id())); if let Err(e) = tokio::fs::write(&tmp, CRA_RULES).await { tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only"); return None; } if let Err(e) = tokio::fs::rename(&tmp, &path).await { tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only"); return None; } Some(path) } pub struct SemgrepScanner; impl Scanner for SemgrepScanner { fn name(&self) -> &str { "semgrep" } fn scan_type(&self) -> ScanType { ScanType::Sast } #[tracing::instrument(skip_all)] async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result { let cra_rules = stage_cra_rules().await; let mut command = tokio::process::Command::new("semgrep"); command.arg("--config=auto"); if let Some(path) = &cra_rules { command.arg(format!("--config={}", path.display())); } command .args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"]) .arg(repo_path); let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output()) .await .map_err(|_| CoreError::Scanner { scanner: "semgrep".to_string(), source: "timed out after 10 minutes".into(), })? .map_err(|e| CoreError::Scanner { scanner: "semgrep".to_string(), source: Box::new(e), })?; if !output.status.success() && output.stdout.is_empty() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!("Semgrep exited with {}: {stderr}", output.status); return Ok(ScanOutput::default()); } let result: SemgrepOutput = serde_json::from_slice(&output.stdout)?; let findings = result .results .into_iter() .map(|r| { let severity = match r.extra.severity.as_str() { "ERROR" => Severity::High, "WARNING" => Severity::Medium, "INFO" => Severity::Low, _ => Severity::Info, }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, &r.check_id, &r.path, &r.start.line.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "semgrep".to_string(), ScanType::Sast, r.extra.message.clone(), r.extra.message, severity, ); finding.rule_id = Some(r.check_id); finding.file_path = Some(r.path); finding.line_number = Some(r.start.line); finding.code_snippet = Some(r.extra.lines); finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe); finding }) .collect(); Ok(ScanOutput { findings, sbom_entries: Vec::new(), }) } } #[derive(serde::Deserialize)] struct SemgrepOutput { results: Vec, } #[derive(serde::Deserialize)] struct SemgrepResult { check_id: String, path: String, start: SemgrepPosition, extra: SemgrepExtra, } #[derive(serde::Deserialize)] struct SemgrepPosition { line: u32, } #[derive(serde::Deserialize)] struct SemgrepExtra { message: String, severity: String, lines: String, #[serde(default)] metadata: Option, } /// semgrep emits `metadata.cwe` as a list of strings like /// `"CWE-798: Use of Hard-coded Credentials"` (occasionally a bare string). Take /// the first entry and normalise it to just the `CWE-NNN` id. fn extract_cwe(metadata: &serde_json::Value) -> Option { let raw = metadata.get("cwe")?; let text = match raw { serde_json::Value::Array(items) => items.first()?.as_str()?, serde_json::Value::String(s) => s.as_str(), _ => return None, }; let id = text.split(':').next().unwrap_or(text).trim(); (!id.is_empty()).then(|| id.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_cwe_handles_list_and_normalises() { let md = serde_json::json!({"cwe": ["CWE-798: Use of Hard-coded Credentials"]}); assert_eq!(extract_cwe(&md).as_deref(), Some("CWE-798")); let bare = serde_json::json!({"cwe": "CWE-89"}); assert_eq!(extract_cwe(&bare).as_deref(), Some("CWE-89")); let none = serde_json::json!({"severity": "ERROR"}); assert_eq!(extract_cwe(&none), None); } #[test] fn deserialize_semgrep_output() { let json = r#"{ "results": [ { "check_id": "python.lang.security.audit.exec-detected", "path": "src/main.py", "start": {"line": 15}, "extra": { "message": "Detected use of exec()", "severity": "ERROR", "lines": "exec(user_input)", "metadata": {"cwe": "CWE-78"} } } ] }"#; let output: SemgrepOutput = serde_json::from_str(json).unwrap(); assert_eq!(output.results.len(), 1); let r = &output.results[0]; assert_eq!(r.check_id, "python.lang.security.audit.exec-detected"); assert_eq!(r.path, "src/main.py"); assert_eq!(r.start.line, 15); assert_eq!(r.extra.message, "Detected use of exec()"); assert_eq!(r.extra.severity, "ERROR"); assert_eq!(r.extra.lines, "exec(user_input)"); assert_eq!( r.extra .metadata .as_ref() .unwrap() .get("cwe") .unwrap() .as_str(), Some("CWE-78") ); } #[test] fn deserialize_semgrep_empty_results() { let json = r#"{"results": []}"#; let output: SemgrepOutput = serde_json::from_str(json).unwrap(); assert!(output.results.is_empty()); } #[test] fn deserialize_semgrep_no_metadata() { let json = r#"{ "results": [ { "check_id": "rule-1", "path": "app.py", "start": {"line": 1}, "extra": { "message": "found something", "severity": "WARNING", "lines": "import os" } } ] }"#; let output: SemgrepOutput = serde_json::from_str(json).unwrap(); assert!(output.results[0].extra.metadata.is_none()); } #[test] fn semgrep_severity_mapping() { let cases = vec![ ("ERROR", "High"), ("WARNING", "Medium"), ("INFO", "Low"), ("UNKNOWN", "Info"), ]; for (input, expected) in cases { let result = match input { "ERROR" => "High", "WARNING" => "Medium", "INFO" => "Low", _ => "Info", }; assert_eq!(result, expected, "Severity for '{input}'"); } } #[test] fn deserialize_semgrep_multiple_results() { let json = r#"{ "results": [ { "check_id": "rule-a", "path": "a.py", "start": {"line": 1}, "extra": { "message": "msg a", "severity": "ERROR", "lines": "line a" } }, { "check_id": "rule-b", "path": "b.py", "start": {"line": 99}, "extra": { "message": "msg b", "severity": "INFO", "lines": "line b" } } ] }"#; let output: SemgrepOutput = serde_json::from_str(json).unwrap(); assert_eq!(output.results.len(), 2); assert_eq!(output.results[1].start.line, 99); } }