//! 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); } }