feat: control-driven SAST — LUT + grounded LLM triage over tool findings #213
Generated
+1
@@ -666,6 +666,7 @@ dependencies = [
|
|||||||
"compliance-core",
|
"compliance-core",
|
||||||
"compliance-dast",
|
"compliance-dast",
|
||||||
"compliance-graph",
|
"compliance-graph",
|
||||||
|
"control-map",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
|
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
|
||||||
|
control-map = { workspace = true }
|
||||||
compliance-graph = { path = "../compliance-graph" }
|
compliance-graph = { path = "../compliance-graph" }
|
||||||
compliance-dast = { path = "../compliance-dast" }
|
compliance-dast = { path = "../compliance-dast" }
|
||||||
# Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also
|
# Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
mod checker;
|
mod checker;
|
||||||
mod judge;
|
mod judge;
|
||||||
mod oscal_provider;
|
mod oscal_provider;
|
||||||
|
mod triage;
|
||||||
|
|
||||||
pub use checker::GroundedControlChecker;
|
pub use checker::GroundedControlChecker;
|
||||||
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
|
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
|
||||||
pub use oscal_provider::OscalControlsProvider;
|
pub use oscal_provider::OscalControlsProvider;
|
||||||
|
pub use triage::{ControlTriage, TriageOutcome};
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user