diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 93667a8..3eaa3ba 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -109,6 +109,8 @@ jobs: run: cargo clippy -p compliance-mcp -- -D warnings - name: Clippy (werkbank-exec) run: cargo clippy -p werkbank-exec -- -D warnings + - name: Clippy (control-map) + run: cargo clippy -p control-map -- -D warnings # Security audit - name: Security Audit @@ -117,8 +119,8 @@ jobs: RUSTC_WRAPPER: "" # Tests (reuses compilation artifacts from clippy) - - name: Tests (core + agent + werkbank-exec) - run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec --lib + - name: Tests (core + agent + werkbank-exec + control-map) + run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec -p control-map --lib - name: Tests (dashboard server) run: cargo test -p compliance-dashboard --features server --no-default-features - name: Tests (dashboard web) diff --git a/Cargo.lock b/Cargo.lock index f8a6d02..92627ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "compliance-core", "compliance-dast", "compliance-graph", + "control-map", "dashmap", "dotenvy", "futures-core", @@ -968,6 +969,15 @@ dependencies = [ "charset", ] +[[package]] +name = "control-map" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "convert_case" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index e8cb61b..7e7ddfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "compliance-mcp", "compliance-smoke", "werkbank-exec", + "control-map", ] resolver = "2" @@ -17,6 +18,7 @@ expect_used = "deny" [workspace.dependencies] compliance-core = { path = "compliance-core", default-features = false } +control-map = { path = "control-map" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index 431c0c6..88298e6 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] } +control-map = { workspace = true } compliance-graph = { path = "../compliance-graph" } compliance-dast = { path = "../compliance-dast" } # Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also diff --git a/compliance-agent/src/controls/checker.rs b/compliance-agent/src/controls/checker.rs new file mode 100644 index 0000000..9684e65 --- /dev/null +++ b/compliance-agent/src/controls/checker.rs @@ -0,0 +1,114 @@ +//! The grounded control checker: judge each candidate region for a control, then +//! keep only the verdicts that survive the grounding gate. +//! +//! Generic over [`ControlJudge`] so tests drive it with a deterministic stub — +//! the whole recognize → ground path is then exercised without an LLM. With the +//! real judge, determinism comes from temperature 0 plus the gate. + +use compliance_core::control_check::{ground, CandidateRegion, ControlCheckSpec}; +use compliance_core::models::Finding; + +use super::judge::ControlJudge; + +/// Runs a [`ControlJudge`] over candidate regions and grounds the results. +pub struct GroundedControlChecker { + judge: J, +} + +impl GroundedControlChecker { + pub fn new(judge: J) -> Self { + Self { judge } + } + + /// Judge every candidate region for `spec` and return the grounded findings. + /// A verdict that doesn't quote real code in its region is dropped by + /// [`ground`], so nothing fabricated reaches the caller. + pub async fn check( + &self, + spec: &ControlCheckSpec, + regions: &[CandidateRegion], + repo_id: &str, + ) -> Vec { + let mut findings = Vec::new(); + for region in regions { + let verdict = self.judge.judge(spec, region).await; + if let Some(finding) = ground(spec, region, &verdict, repo_id) { + findings.push(finding); + } + } + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::control_check::LlmVerdict; + use compliance_core::models::finding::Severity; + + /// Deterministic stub: returns a fixed verdict for every region, so the + /// recognize → ground composition is tested without an LLM. + struct StubJudge { + verdict: LlmVerdict, + } + + impl ControlJudge for StubJudge { + async fn judge(&self, _spec: &ControlCheckSpec, _region: &CandidateRegion) -> LlmVerdict { + self.verdict.clone() + } + } + + fn spec() -> ControlCheckSpec { + ControlCheckSpec { + control_id: "cra-ai-8".into(), + title: "No default passwords".into(), + requirement: "No default credentials".into(), + default_cwe: Some("CWE-798".into()), + severity: Severity::High, + } + } + + fn region(content: &str) -> CandidateRegion { + CandidateRegion { + file: "src/auth.py".into(), + start_line: 1, + content: content.into(), + } + } + + #[tokio::test] + async fn keeps_grounded_and_drops_ungrounded() { + let checker = GroundedControlChecker::new(StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "PASSWORD = \"admin\"".into(), + cwe: None, + confidence: 0.9, + }, + }); + let regions = vec![ + region("x = 1\nPASSWORD = \"admin\"\n"), // quotes real code → grounded + region("totally unrelated code\n"), // snippet absent → dropped + ]; + let findings = checker.check(&spec(), ®ions, "repo").await; + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].control_refs, vec!["cra-ai-8".to_string()]); + assert_eq!(findings[0].line_number, Some(2)); + } + + #[tokio::test] + async fn non_violation_yields_nothing() { + let checker = GroundedControlChecker::new(StubJudge { + verdict: LlmVerdict { + violates: false, + snippet: String::new(), + cwe: None, + confidence: 0.0, + }, + }); + let findings = checker + .check(&spec(), &[region("PASSWORD = \"admin\"\n")], "repo") + .await; + assert!(findings.is_empty()); + } +} diff --git a/compliance-agent/src/controls/judge.rs b/compliance-agent/src/controls/judge.rs new file mode 100644 index 0000000..928ef41 --- /dev/null +++ b/compliance-agent/src/controls/judge.rs @@ -0,0 +1,167 @@ +//! The "recognize" stage: judge whether a code region violates a control. +//! +//! Behind the [`ControlJudge`] trait so the grounded checker can be driven by a +//! deterministic stub in tests. The real [`LlmControlJudge`] runs the model at +//! temperature 0 with a closed prompt — it must quote the offending code VERBATIM, +//! and everything it returns is then re-checked by the grounding gate +//! ([`compliance_core::control_check::ground`]). The judge is allowed to be +//! smart; it is never trusted. + +use std::sync::Arc; + +use serde::Deserialize; + +use compliance_core::control_check::{CandidateRegion, ControlCheckSpec, LlmVerdict}; + +use crate::llm::LlmClient; + +/// Prompt/logic version — part of the verdict cache key, bump on any change here. +pub const PROMPT_VERSION: &str = "control-judge-v1"; + +const SYSTEM_PROMPT: &str = "You are a precise security & compliance code auditor. \ +You are given ONE compliance control (a requirement) and ONE code region. Decide \ +ONLY whether the code region VIOLATES the control. Rules: (1) Judge only the code \ +shown — never assume code that is not present. (2) If and only if it violates, copy \ +the EXACT offending code VERBATIM into `snippet`, character-for-character from the \ +region — do not paraphrase, reformat, or reconstruct it. (3) If it does not clearly \ +violate, set violates=false and leave snippet empty. (4) Prefer false over guessing. \ +Respond with STRICT JSON only, no prose: \ +{\"violates\": bool, \"snippet\": \"\", \"cwe\": \"CWE-NNN or null\", \"confidence\": 0.0-1.0}"; + +/// Judges one (control, region). Async-in-trait so a stub can drive tests. +#[allow(async_fn_in_trait)] +pub trait ControlJudge: Send + Sync { + async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict; +} + +/// The real judge: the LLM at temperature 0 with the closed, verbatim-snippet prompt. +pub struct LlmControlJudge { + llm: Arc, +} + +impl LlmControlJudge { + pub fn new(llm: Arc) -> Self { + Self { llm } + } +} + +impl ControlJudge for LlmControlJudge { + async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict { + let user = build_user_prompt(spec, region); + match self.llm.chat(SYSTEM_PROMPT, &user, Some(0.0)).await { + Ok(response) => parse_verdict(&response), + Err(e) => { + // Fail closed: a transient model error yields no finding, never a + // fabricated one. + tracing::warn!(control = %spec.control_id, error = %e, "control judge call failed"); + no_violation() + } + } + } +} + +fn build_user_prompt(spec: &ControlCheckSpec, region: &CandidateRegion) -> String { + format!( + "CONTROL {id} — {title}\nRequirement: {req}\n\nCODE ({file}, first line = {line}):\n```\n{code}\n```\n\nReturn the JSON verdict.", + id = spec.control_id, + title = spec.title, + req = spec.requirement, + file = region.file, + line = region.start_line, + code = region.content, + ) +} + +#[derive(Debug, Default, Deserialize)] +struct RawVerdict { + #[serde(default)] + violates: bool, + #[serde(default)] + snippet: String, + #[serde(default)] + cwe: Option, + #[serde(default)] + confidence: f64, +} + +/// Parse the model's JSON verdict, tolerant of ```json fencing. Any parse failure +/// degrades to a non-violation (never a fabricated finding). +fn parse_verdict(response: &str) -> LlmVerdict { + let cleaned = response + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + match serde_json::from_str::(cleaned) { + Ok(raw) => LlmVerdict { + violates: raw.violates, + snippet: raw.snippet, + cwe: raw.cwe.filter(|c| !c.trim().is_empty()), + confidence: raw.confidence, + }, + Err(e) => { + tracing::debug!(error = %e, "failed to parse control verdict; treating as non-violation"); + no_violation() + } + } +} + +fn no_violation() -> LlmVerdict { + LlmVerdict { + violates: false, + snippet: String::new(), + cwe: None, + confidence: 0.0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::models::finding::Severity; + + fn spec() -> ControlCheckSpec { + ControlCheckSpec { + control_id: "cra-ai-8".into(), + title: "No default passwords".into(), + requirement: "Products must not ship default credentials".into(), + default_cwe: Some("CWE-798".into()), + severity: Severity::High, + } + } + + #[test] + fn parses_plain_and_fenced_json() { + let plain = r#"{"violates": true, "snippet": "PASSWORD = \"x\"", "cwe": "CWE-798", "confidence": 0.9}"#; + let v = parse_verdict(plain); + assert!(v.violates); + assert_eq!(v.snippet, "PASSWORD = \"x\""); + assert_eq!(v.cwe.as_deref(), Some("CWE-798")); + + let fenced = "```json\n{\"violates\": false, \"snippet\": \"\", \"cwe\": null, \"confidence\": 0.1}\n```"; + assert!(!parse_verdict(fenced).violates); + } + + #[test] + fn garbage_and_empty_cwe_are_safe() { + assert!(!parse_verdict("not json at all").violates); // fail closed + let no_cwe = + parse_verdict(r#"{"violates": true, "snippet": "x", "cwe": " ", "confidence": 0.5}"#); + assert!(no_cwe.cwe.is_none()); // blank CWE normalised away + } + + #[test] + fn user_prompt_carries_control_and_code() { + let region = CandidateRegion { + file: "src/auth.py".into(), + start_line: 10, + content: "PASSWORD = \"admin\"".into(), + }; + let p = build_user_prompt(&spec(), ®ion); + assert!(p.contains("cra-ai-8")); + assert!(p.contains("Products must not ship default credentials")); + assert!(p.contains("PASSWORD = \"admin\"")); + assert!(p.contains("src/auth.py")); + } +} diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index ce3d5da..f47894c 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -5,6 +5,14 @@ //! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog //! and snapshots it locally. +mod checker; +mod judge; mod oscal_provider; +mod scan_triage; +mod triage; +pub use checker::GroundedControlChecker; +pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use oscal_provider::OscalControlsProvider; +pub use scan_triage::triage_repo_findings; +pub use triage::{ControlTriage, TriageOutcome}; diff --git a/compliance-agent/src/controls/scan_triage.rs b/compliance-agent/src/controls/scan_triage.rs new file mode 100644 index 0000000..6c8b5d9 --- /dev/null +++ b/compliance-agent/src/controls/scan_triage.rs @@ -0,0 +1,139 @@ +//! Scan-pipeline integration for control triage. +//! +//! After the deterministic tools have produced findings, this stamps each finding +//! with the compliance control(s) it's evidence for and marks control-level false +//! positives — using the ingested OSCAL catalog for control text, the +//! `control-map` LUT for the finding→control link, and the grounded LLM judge to +//! confirm. Skipped entirely unless breakpilot is configured. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use compliance_core::control_check::{CandidateRegion, ControlCheckSpec}; +use compliance_core::models::finding::{Finding, FindingStatus, Severity}; +use compliance_core::models::onboarding::ComplianceFramework; +use compliance_core::AgentConfig; +use control_map::ControlMap; + +use super::{ControlTriage, LlmControlJudge, OscalControlsProvider, TriageOutcome}; +use crate::llm::LlmClient; + +/// Lines of context to read on each side of a finding's line. +const REGION_WINDOW: usize = 6; + +/// Triage every finding in `findings` against the CRA control map: stamp +/// `control_refs` on confirmed findings and flag control false positives. Returns +/// the number of findings tagged with at least one control. +pub async fn triage_repo_findings( + config: &AgentConfig, + llm: Arc, + repo_path: &Path, + findings: &mut [Finding], +) -> usize { + let Some(base_url) = config.breakpilot.base_url.clone() else { + return 0; // control triage is opt-in via BREAKPILOT_BASE_URL + }; + let provider = OscalControlsProvider::new( + reqwest::Client::new(), + base_url, + config.breakpilot.token.clone(), + &config.breakpilot.snapshot_dir, + ); + let specs = build_specs(&provider).await; + if specs.is_empty() { + return 0; + } + let map = match ControlMap::cra() { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, "control map failed to load; skipping control triage"); + return 0; + } + }; + let triage = ControlTriage::new(LlmControlJudge::new(llm), map, specs); + + let mut tagged = 0; + for finding in findings.iter_mut() { + let (Some(file), Some(line)) = (finding.file_path.clone(), finding.line_number) else { + continue; + }; + let Some(region) = fetch_region(repo_path, &file, line) else { + continue; + }; + match triage.triage(finding, ®ion).await { + TriageOutcome::Confirmed(controls) => { + finding.control_refs = controls; + tagged += 1; + } + TriageOutcome::FalsePositive => { + finding.status = FindingStatus::FalsePositive; + finding.triage_action = Some("control_false_positive".to_string()); + } + TriageOutcome::Unmapped => {} + } + } + tagged +} + +/// Build the control requirement specs (by id) from the ingested OSCAL catalog. +async fn build_specs(provider: &OscalControlsProvider) -> HashMap { + let mut specs = HashMap::new(); + match provider.load(ComplianceFramework::Cra).await { + Ok(doc) => { + for control in doc.to_controls() { + specs.insert( + control.id.clone(), + ControlCheckSpec { + control_id: control.id, + title: control.title, + requirement: control.text, + default_cwe: None, + severity: Severity::Medium, + }, + ); + } + } + Err(e) => tracing::warn!(error = %e, "could not load control catalog for triage"), + } + specs +} + +/// Read a window of lines around `line` (1-based) from `repo_path/file`. +fn fetch_region(repo_path: &Path, file: &str, line: u32) -> Option { + let content = std::fs::read_to_string(repo_path.join(file)).ok()?; + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return None; + } + let center = (line.saturating_sub(1) as usize).min(lines.len() - 1); + let start = center.saturating_sub(REGION_WINDOW); + let end = (center + REGION_WINDOW + 1).min(lines.len()); + Some(CandidateRegion { + file: file.to_string(), + start_line: (start as u32) + 1, + content: lines[start..end].join("\n"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_region_windows_around_the_line() { + let dir = std::env::temp_dir().join(format!("triage-region-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let file = "a.py"; + std::fs::write(dir.join(file), "l1\nl2\nl3\nSECRET=1\nl5\nl6\n").unwrap(); + let r = fetch_region(&dir, file, 4).unwrap(); + assert!(r.content.contains("SECRET=1")); + assert_eq!(r.start_line, 1); // window clamps to file start + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn fetch_region_missing_file_is_none() { + assert!(fetch_region(Path::new("/nonexistent"), "nope.py", 1).is_none()); + } +} diff --git a/compliance-agent/src/controls/triage.rs b/compliance-agent/src/controls/triage.rs new file mode 100644 index 0000000..c1b0998 --- /dev/null +++ b/compliance-agent/src/controls/triage.rs @@ -0,0 +1,184 @@ +//! Triage step: confirm/refute a deterministic tool finding against the controls +//! it maps to (via the `control-map` LUT), grounding the judgment. +//! +//! This is where the LLM finally enters — as a **false-positive filter over tool +//! output**, never as the detector (the ZeroFalse / IRIS pattern). A tool +//! (semgrep, gitleaks, syft/osv) detects deterministically; `controls_for(tool, +//! cwe)` attaches the finding to the control(s) it's evidence for; the grounded +//! judge then confirms or refutes each, and only judgments anchored to real code +//! survive. + +use std::collections::HashMap; + +use compliance_core::control_check::{ground, CandidateRegion, ControlCheckSpec}; +use compliance_core::models::Finding; +use control_map::ControlMap; + +use super::judge::ControlJudge; + +/// What triage decided for one tool finding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TriageOutcome { + /// The finding maps to no control in the LUT — keep it, untagged. + Unmapped, + /// Maps to controls and the grounded judge confirmed at least one — keep the + /// finding and tag it with these control ids. + Confirmed(Vec), + /// Maps to controls but the judge grounded none — treat as a false positive. + FalsePositive, +} + +/// Triages tool findings against the control map, confirming with a grounded judge. +pub struct ControlTriage { + judge: J, + map: ControlMap, + /// Control requirement specs (by control id), built from the ingested catalog. + specs: HashMap, +} + +impl ControlTriage { + pub fn new(judge: J, map: ControlMap, specs: HashMap) -> Self { + Self { judge, map, specs } + } + + /// Triage one tool finding. `region` is the code around the finding, used as + /// the grounding evidence for the judge. + pub async fn triage(&self, finding: &Finding, region: &CandidateRegion) -> TriageOutcome { + let Some(cwe) = finding.cwe.as_deref() else { + return TriageOutcome::Unmapped; + }; + let mapped = self.map.controls_for(&finding.scanner, cwe); + if mapped.is_empty() { + return TriageOutcome::Unmapped; + } + + let mut confirmed = Vec::new(); + for entry in mapped { + let Some(spec) = self.specs.get(&entry.control) else { + continue; + }; + let verdict = self.judge.judge(spec, region).await; + // The verdict only counts if it grounds to real code in the region. + if ground(spec, region, &verdict, &finding.repo_id).is_some() { + confirmed.push(entry.control.clone()); + } + } + + if confirmed.is_empty() { + TriageOutcome::FalsePositive + } else { + TriageOutcome::Confirmed(confirmed) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::control_check::LlmVerdict; + use compliance_core::models::finding::Severity; + use compliance_core::models::scan::ScanType; + + struct StubJudge { + verdict: LlmVerdict, + } + impl ControlJudge for StubJudge { + async fn judge(&self, _s: &ControlCheckSpec, _r: &CandidateRegion) -> LlmVerdict { + self.verdict.clone() + } + } + + fn specs() -> HashMap { + let mut m = HashMap::new(); + m.insert( + "cra-ai-8".to_string(), + ControlCheckSpec { + control_id: "cra-ai-8".into(), + title: "No default passwords".into(), + requirement: "No default credentials".into(), + default_cwe: Some("CWE-798".into()), + severity: Severity::High, + }, + ); + m + } + + fn semgrep_finding(cwe: &str) -> Finding { + let mut f = Finding::new( + "repo".into(), + "fp1".into(), + "semgrep".into(), + ScanType::Sast, + "hardcoded credential".into(), + "desc".into(), + Severity::High, + ); + f.cwe = Some(cwe.into()); + f + } + + fn region() -> CandidateRegion { + CandidateRegion { + file: "src/auth.py".into(), + start_line: 1, + content: "PASSWORD = \"admin123\"\n".into(), + } + } + + #[tokio::test] + async fn confirmed_finding_is_tagged_with_control() { + let triage = ControlTriage::new( + StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "PASSWORD = \"admin123\"".into(), + cwe: None, + confidence: 0.9, + }, + }, + ControlMap::cra().unwrap(), + specs(), + ); + let out = triage.triage(&semgrep_finding("CWE-798"), ®ion()).await; + assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-8".to_string()])); + } + + #[tokio::test] + async fn refuted_mapped_finding_is_false_positive() { + // Maps to cra-ai-8, but the judge doesn't confirm (no violation) → FP. + let triage = ControlTriage::new( + StubJudge { + verdict: LlmVerdict { + violates: false, + snippet: String::new(), + cwe: None, + confidence: 0.1, + }, + }, + ControlMap::cra().unwrap(), + specs(), + ); + let out = triage.triage(&semgrep_finding("CWE-798"), ®ion()).await; + assert_eq!(out, TriageOutcome::FalsePositive); + } + + #[tokio::test] + async fn unmapped_cwe_is_left_untagged() { + let triage = ControlTriage::new( + StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "PASSWORD = \"admin123\"".into(), + cwe: None, + confidence: 0.9, + }, + }, + ControlMap::cra().unwrap(), + specs(), + ); + let out = triage + .triage(&semgrep_finding("CWE-99999"), ®ion()) + .await; + assert_eq!(out, TriageOutcome::Unmapped); + } +} diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 093b041..1e7b265 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -215,6 +215,21 @@ impl PipelineOrchestrator { .await; tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold"); + // Stage 5b: control triage — stamp findings with the compliance control(s) + // they're evidence for and flag control false positives (grounded LLM over + // deterministic tool output). No-op unless breakpilot is configured. + self.update_phase(scan_run_id, "control_triage").await; + let tagged = crate::controls::triage_repo_findings( + &self.config, + self.llm.clone(), + &repo_path, + &mut all_findings, + ) + .await; + if tagged > 0 { + tracing::info!("[{repo_id}] Control triage tagged {tagged} findings with control refs"); + } + // Dedup against existing findings and insert new ones let mut new_count = 0u32; let mut new_findings: Vec = Vec::new(); diff --git a/compliance-agent/src/pipeline/semgrep.rs b/compliance-agent/src/pipeline/semgrep.rs index 7fc0747..6d28205 100644 --- a/compliance-agent/src/pipeline/semgrep.rs +++ b/compliance-agent/src/pipeline/semgrep.rs @@ -82,10 +82,7 @@ impl Scanner for SemgrepScanner { 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 - .and_then(|m| m.get("cwe").and_then(|v| v.as_str()).map(|s| s.to_string())); + finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe); finding }) .collect(); @@ -124,10 +121,34 @@ struct SemgrepExtra { 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#"{ diff --git a/compliance-core/src/control_check.rs b/compliance-core/src/control_check.rs new file mode 100644 index 0000000..af8ecb6 --- /dev/null +++ b/compliance-core/src/control_check.rs @@ -0,0 +1,204 @@ +//! Grounded control-driven checking. +//! +//! Turns a *text* control into findings via an LLM used as a **pattern-recognizer** +//! whose output is grounded to real code — so a hallucinated finding cannot +//! survive. Determinism is structural, not a prompt plea: +//! +//! 1. the LLM only ever judges *retrieved* regions — it can't invent findings in +//! code it never saw; +//! 2. a verdict becomes a finding only if its quoted snippet appears **verbatim** +//! in the region, and the line is recomputed from that match — the model's own +//! line number is never trusted ([`ground`]); +//! 3. verdicts are cached by content hash ([`cache_key`]) so re-scans reproduce. +//! +//! The LLM supplies cross-language / cross-stack pattern recognition; this module +//! supplies the determinism. + +use sha2::{Digest, Sha256}; + +use crate::models::finding::{Finding, Severity}; +use crate::models::scan::ScanType; + +/// A control rendered as a check the LLM judges code against. +#[derive(Debug, Clone)] +pub struct ControlCheckSpec { + /// Stable control id, e.g. `"cra-ai-8"`. + pub control_id: String, + /// Short control title (used in the finding title). + pub title: String, + /// The requirement text the LLM judges against (control objective/statement). + pub requirement: String, + /// CWE to fall back to when the model doesn't supply one. + pub default_cwe: Option, + /// Severity for findings raised from this control. + pub severity: Severity, +} + +/// A retrieved code region the LLM judges — never the whole repo. +#[derive(Debug, Clone)] +pub struct CandidateRegion { + /// Repo-relative path. + pub file: String, + /// 1-based line number of the region's first line in `file`. + pub start_line: u32, + /// The region's source text. + pub content: String, +} + +/// The LLM's structured verdict for one (control, region). `snippet` is the +/// verbatim code the model claims proves the violation — it is the anchor the +/// grounding gate checks. +#[derive(Debug, Clone)] +pub struct LlmVerdict { + pub violates: bool, + pub snippet: String, + pub cwe: Option, + pub confidence: f64, +} + +/// The grounding gate. A verdict becomes a [`Finding`] only if it claims a +/// violation AND its quoted `snippet` appears verbatim in `region.content`; the +/// finding's line is computed from the match, so a fabricated or mis-located +/// snippet is dropped. Pure — no LLM, no I/O. +pub fn ground( + spec: &ControlCheckSpec, + region: &CandidateRegion, + verdict: &LlmVerdict, + repo_id: &str, +) -> Option { + if !verdict.violates { + return None; + } + let snippet = verdict.snippet.trim(); + if snippet.is_empty() { + return None; + } + // Grounding: the quoted snippet must literally exist in the retrieved region. + let pos = region.content.find(snippet)?; + // Recompute the real line from the match — never trust the model's number. + let newlines_before = region.content[..pos].matches('\n').count(); + let line = region.start_line + newlines_before as u32; + + let mut finding = Finding::new( + repo_id.to_string(), + control_finding_fingerprint(&spec.control_id, ®ion.file, snippet), + "control-check".to_string(), + ScanType::CodeReview, + format!("{}: {}", spec.control_id, spec.title), + format!( + "Control {} appears violated ({}) at {}:{line}", + spec.control_id, spec.requirement, region.file + ), + spec.severity.clone(), + ); + finding.cwe = verdict.cwe.clone().or_else(|| spec.default_cwe.clone()); + finding.file_path = Some(region.file.clone()); + finding.line_number = Some(line); + finding.code_snippet = Some(snippet.to_string()); + finding.confidence = Some(verdict.confidence); + // Carry the control reference on the finding. + finding.control_refs = vec![spec.control_id.clone()]; + Some(finding) +} + +/// Deterministic cache key for a (control, region, model, prompt-version) verdict +/// so identical inputs reproduce the same verdict without another LLM call. +pub fn cache_key( + control_id: &str, + region_content: &str, + model: &str, + prompt_version: &str, +) -> String { + hash_parts(&[control_id, region_content, model, prompt_version]) +} + +fn control_finding_fingerprint(control_id: &str, file: &str, snippet: &str) -> String { + hash_parts(&[control_id, file, snippet]) +} + +fn hash_parts(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + hasher.update([0u8]); // domain separator between parts + } + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> ControlCheckSpec { + ControlCheckSpec { + control_id: "cra-ai-8".into(), + title: "No default passwords".into(), + requirement: "Products must not ship default credentials".into(), + default_cwe: Some("CWE-798".into()), + severity: Severity::High, + } + } + + fn region() -> CandidateRegion { + CandidateRegion { + file: "src/auth.py".into(), + start_line: 10, + content: "def login():\n PASSWORD = \"admin123\"\n return PASSWORD\n".into(), + } + } + + #[test] + fn grounds_real_snippet_with_recomputed_line() { + let v = LlmVerdict { + violates: true, + snippet: "PASSWORD = \"admin123\"".into(), + cwe: None, + confidence: 0.9, + }; + let f = ground(&spec(), ®ion(), &v, "repo").expect("should ground"); + assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10 + assert_eq!(f.cwe.as_deref(), Some("CWE-798")); // fell back to the spec default + assert_eq!(f.control_refs, vec!["cra-ai-8".to_string()]); // control ref carried + assert_eq!(f.file_path.as_deref(), Some("src/auth.py")); + assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\"")); + } + + #[test] + fn drops_fabricated_snippet_not_in_region() { + let v = LlmVerdict { + violates: true, + snippet: "SECRET = \"totally-made-up\"".into(), + cwe: None, + confidence: 0.99, + }; + assert!(ground(&spec(), ®ion(), &v, "repo").is_none()); + } + + #[test] + fn drops_non_violation_and_empty_snippet() { + let no = LlmVerdict { + violates: false, + snippet: "PASSWORD = \"admin123\"".into(), + cwe: None, + confidence: 0.9, + }; + assert!(ground(&spec(), ®ion(), &no, "repo").is_none()); + let empty = LlmVerdict { + violates: true, + snippet: " ".into(), + cwe: None, + confidence: 0.9, + }; + assert!(ground(&spec(), ®ion(), &empty, "repo").is_none()); + } + + #[test] + fn cache_key_and_fingerprint_are_deterministic() { + assert_eq!(cache_key("c", "x", "m", "v"), cache_key("c", "x", "m", "v")); + assert_ne!(cache_key("c", "x", "m", "v"), cache_key("c", "y", "m", "v")); + assert_eq!( + control_finding_fingerprint("c", "f", "s"), + control_finding_fingerprint("c", "f", "s") + ); + } +} diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index d43a542..260b045 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod control_check; pub mod db; pub mod error; pub mod models; diff --git a/compliance-core/src/models/finding.rs b/compliance-core/src/models/finding.rs index 745cc0e..5085b9c 100644 --- a/compliance-core/src/models/finding.rs +++ b/compliance-core/src/models/finding.rs @@ -76,6 +76,10 @@ pub struct Finding { pub triage_rationale: Option, /// Developer feedback on finding quality pub developer_feedback: Option, + /// Compliance control ids this finding is evidence for (stamped by control + /// triage against the `control-map` LUT). Empty when unmapped. + #[serde(default)] + pub control_refs: Vec, #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, #[serde(with = "super::serde_helpers::bson_datetime")] @@ -118,6 +122,7 @@ impl Finding { triage_action: None, triage_rationale: None, developer_feedback: None, + control_refs: Vec::new(), created_at: now, updated_at: now, } diff --git a/control-map/Cargo.toml b/control-map/Cargo.toml new file mode 100644 index 0000000..110438b --- /dev/null +++ b/control-map/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "control-map" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/control-map/data/cra_control_map.json b/control-map/data/cra_control_map.json new file mode 100644 index 0000000..3b238c5 --- /dev/null +++ b/control-map/data/cra_control_map.json @@ -0,0 +1,399 @@ +{ + "version": "1.0", + "framework": "cra", + "controls": [ + { + "control": "cra-ai-1", + "title": "Secure-by-Default-Konfiguration", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-2", + "title": "Minimale Angriffsflaeche", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-3", + "title": "Sichere Systemarchitektur", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-4", + "title": "Least-Privilege-Prinzip", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-5", + "title": "Manipulationsschutz", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-6", + "title": "Integritaetspruefung", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-7", + "title": "Starke Authentifizierung", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-8", + "title": "Keine Default-Passwoerter", + "scans": [ + { + "tool": "gitleaks", + "scan_type": "secret_detection", + "cwe": [], + "rules": [] + }, + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-798", + "CWE-259" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-9", + "title": "Sicheres Credential-Management", + "scans": [ + { + "tool": "gitleaks", + "scan_type": "secret_detection", + "cwe": [], + "rules": [] + }, + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-798", + "CWE-522" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-10", + "title": "Sitzungsmanagement", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-11", + "title": "Brute-Force-Schutz", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-12", + "title": "Rollenbasierte Autorisierung", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-13", + "title": "Verschluesselung sensibler Daten", + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-327", + "CWE-326" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-14", + "title": "Speicher-Schutz (Data at Rest)", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-15", + "title": "Transport-Schutz (Data in Transit)", + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-319", + "CWE-311" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-16", + "title": "Sicheres Schluesselmanagement", + "scans": [ + { + "tool": "gitleaks", + "scan_type": "secret_detection", + "cwe": [], + "rules": [] + }, + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-798", + "CWE-321" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-17", + "title": "Datenminimierung", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-18", + "title": "Strukturierter SSDLC", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-19", + "title": "Systematische Code Reviews", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-20", + "title": "Automatisierte Sicherheitstests", + "scans": [ + { + "tool": "semgrep", + "scan_type": "sast", + "cwe": [ + "CWE-89", + "CWE-78", + "CWE-79", + "CWE-22" + ], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-21", + "title": "Supply-Chain-Security", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-22", + "title": "Dependency-Monitoring", + "scans": [ + { + "tool": "osv", + "scan_type": "cve", + "cwe": [], + "rules": [] + }, + { + "tool": "syft", + "scan_type": "sbom", + "cwe": [], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-23", + "title": "Software Bill of Materials (SBOM)", + "scans": [ + { + "tool": "syft", + "scan_type": "sbom", + "cwe": [], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-24", + "title": "Security-Logging", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-25", + "title": "Ereignis-Monitoring", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-26", + "title": "Anomalie-Erkennung", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-27", + "title": "Log-Integritaet und -Aufbewahrung", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-28", + "title": "Sichere Update-Mechanismen", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-29", + "title": "Update-Authentizitaet", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-30", + "title": "Update-Integritaet", + "scans": [], + "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "status": "needs_tooling" + }, + { + "control": "cra-ai-31", + "title": "Lifecycle-Support", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-32", + "title": "Schwachstellen-Identifikation", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-33", + "title": "SBOM-Pflege und Analyse", + "scans": [ + { + "tool": "syft", + "scan_type": "sbom", + "cwe": [], + "rules": [] + }, + { + "tool": "osv", + "scan_type": "cve", + "cwe": [], + "rules": [] + } + ], + "note": null, + "status": "covered" + }, + { + "control": "cra-ai-34", + "title": "Risikobasierte Priorisierung", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-35", + "title": "Coordinated Vulnerability Disclosure", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-36", + "title": "Incident-Response-Prozess", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-37", + "title": "Fruehwarnung (24h)", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-38", + "title": "Detaillierter Vorfallsbericht (72h)", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-39", + "title": "Patch-Bereitstellung", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + }, + { + "control": "cra-ai-40", + "title": "Dokumentation und Nachbereitung", + "scans": [], + "note": "process / document control — outside static-scan scope", + "status": "not_code_checkable" + } + ] +} diff --git a/control-map/src/lib.rs b/control-map/src/lib.rs new file mode 100644 index 0000000..dfbb63a --- /dev/null +++ b/control-map/src/lib.rs @@ -0,0 +1,163 @@ +//! `control-map` — the deterministic control → scan lookup table (LUT). +//! +//! The "transcribing" layer: it maps each compliance control to the static-scan +//! step(s) that check it, or marks it as needing custom tooling, or as not +//! code-checkable at all. The map is **authored and human-reviewed** — no LLM +//! decides coverage. The LLM only enters later, downstream, to triage/ground the +//! *tool's* findings (that lives in the agent, not here). +//! +//! This crate is intentionally tiny and standalone: types + an embedded JSON LUT +//! + query helpers. + +use serde::{Deserialize, Serialize}; + +/// Coverage bucket for a control under static (SAST-family) scanning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Coverage { + /// An existing tool's scan surfaces findings for this control. + Covered, + /// Code-checkable, but no existing tool digs it out — we must write tooling. + NeedsTooling, + /// Process / document control — out of static-scan scope. + NotCodeCheckable, +} + +/// One tool binding: a scan step that (at least partially) checks a control. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanBinding { + /// Tool name, e.g. `"semgrep"`, `"gitleaks"`, `"syft"`, `"osv"`. + pub tool: String, + /// Scan family, e.g. `"sast"`, `"secret_detection"`, `"sbom"`, `"cve"`. + pub scan_type: String, + /// CWEs whose findings map to this control (used to attach findings back). + #[serde(default)] + pub cwe: Vec, + /// Optional specific rule ids this control keys on. + #[serde(default)] + pub rules: Vec, +} + +/// One control's entry in the LUT. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControlEntry { + /// Control id, e.g. `"cra-ai-8"`. + pub control: String, + /// Human-readable title (for the reviewable view). + #[serde(default)] + pub title: String, + /// Coverage bucket. + pub status: Coverage, + /// Tool bindings (empty unless `status == Covered`). + #[serde(default)] + pub scans: Vec, + /// Reviewer note — why it needs tooling / isn't code-checkable. + #[serde(default)] + pub note: Option, +} + +/// The control → scan lookup table for one framework. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ControlMap { + pub version: String, + pub framework: String, + pub controls: Vec, +} + +const CRA_MAP_JSON: &str = include_str!("../data/cra_control_map.json"); + +impl ControlMap { + /// Load the built-in CRA control map (the embedded, authored LUT). + pub fn cra() -> Result { + Ok(serde_json::from_str(CRA_MAP_JSON)?) + } + + /// The coverage entry for a control id, if present. + pub fn coverage(&self, control_id: &str) -> Option<&ControlEntry> { + self.controls.iter().find(|c| c.control == control_id) + } + + /// Controls whose bindings include the given `tool` + `cwe` — used to attach a + /// raw tool finding back to the control(s) it's evidence for. + pub fn controls_for(&self, tool: &str, cwe: &str) -> Vec<&ControlEntry> { + self.controls + .iter() + .filter(|c| { + c.scans + .iter() + .any(|s| s.tool == tool && s.cwe.iter().any(|w| w == cwe)) + }) + .collect() + } + + /// Count of controls in each coverage bucket. + pub fn summary(&self) -> CoverageSummary { + let mut s = CoverageSummary::default(); + for c in &self.controls { + match c.status { + Coverage::Covered => s.covered += 1, + Coverage::NeedsTooling => s.needs_tooling += 1, + Coverage::NotCodeCheckable => s.not_code_checkable += 1, + } + } + s + } +} + +/// Coverage bucket counts. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct CoverageSummary { + pub covered: usize, + pub needs_tooling: usize, + pub not_code_checkable: usize, +} + +impl CoverageSummary { + pub fn total(&self) -> usize { + self.covered + self.needs_tooling + self.not_code_checkable + } +} + +/// Errors loading a control map. +#[derive(Debug, thiserror::Error)] +pub enum MapError { + #[error("failed to parse control map: {0}")] + Parse(#[from] serde_json::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cra_map_loads_all_40_controls() { + let map = ControlMap::cra().expect("CRA map should parse"); + assert_eq!(map.framework, "cra"); + assert_eq!(map.controls.len(), 40); + assert_eq!(map.summary().total(), 40); + } + + #[test] + fn hardcoded_password_control_is_tool_covered() { + let map = ControlMap::cra().unwrap(); + let c = map.coverage("cra-ai-8").expect("cra-ai-8 present"); + assert_eq!(c.status, Coverage::Covered); + assert!(c.scans.iter().any(|s| s.tool == "semgrep")); + assert!(c.scans.iter().any(|s| s.tool == "gitleaks")); + } + + #[test] + fn finding_attaches_back_to_control_via_tool_and_cwe() { + let map = ControlMap::cra().unwrap(); + let hits = map.controls_for("semgrep", "CWE-798"); + assert!(hits.iter().any(|c| c.control == "cra-ai-8")); + } + + #[test] + fn every_bucket_is_represented() { + let s = ControlMap::cra().unwrap().summary(); + assert!(s.covered > 0); + assert!(s.needs_tooling > 0); + assert!(s.not_code_checkable > 0); + } +}