From f712ba1e60e4aa4e5b90b0dcf08e837e9d0572f7 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:20:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent):=20control=20triage=20=E2=80=94=20L?= =?UTF-8?q?LM=20false-positive=20filter=20over=20tool=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ControlTriage composes the pipeline: a deterministic tool finding -> controls_for(tool,cwe) [control-map LUT] -> grounded judge confirms/refutes -> TriageOutcome { Unmapped | Confirmed([control ids]) | FalsePositive }. The LLM enters ONLY here, as an FP filter over tool output (ZeroFalse/IRIS), never as the detector; only judgments grounded to real code survive. Reuses the judge + core ground gate + control-map. 3 lib tests (confirm/refute/unmapped). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + compliance-agent/Cargo.toml | 1 + compliance-agent/src/controls/mod.rs | 2 + compliance-agent/src/controls/triage.rs | 184 ++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 compliance-agent/src/controls/triage.rs diff --git a/Cargo.lock b/Cargo.lock index 471858f..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", 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/mod.rs b/compliance-agent/src/controls/mod.rs index 417adb3..2a8721e 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -8,7 +8,9 @@ mod checker; mod judge; mod oscal_provider; +mod triage; pub use checker::GroundedControlChecker; pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use oscal_provider::OscalControlsProvider; +pub use triage::{ControlTriage, TriageOutcome}; 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); + } +}