185 lines
6.0 KiB
Rust
185 lines
6.0 KiB
Rust
//! 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<String>),
|
|
/// 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<J> {
|
|
judge: J,
|
|
map: ControlMap,
|
|
/// Control requirement specs (by control id), built from the ingested catalog.
|
|
specs: HashMap<String, ControlCheckSpec>,
|
|
}
|
|
|
|
impl<J: ControlJudge> ControlTriage<J> {
|
|
pub fn new(judge: J, map: ControlMap, specs: HashMap<String, ControlCheckSpec>) -> 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<String, ControlCheckSpec> {
|
|
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);
|
|
}
|
|
}
|