feat: control-driven SAST — LUT + grounded LLM triage over tool findings #213
@@ -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<J> {
|
||||||
|
judge: J,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<J: ControlJudge> GroundedControlChecker<J> {
|
||||||
|
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<Finding> {
|
||||||
|
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].rule_id.as_deref(), Some("cra-ai-8"));
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
||||||
//! and snapshots it locally.
|
//! and snapshots it locally.
|
||||||
|
|
||||||
|
mod checker;
|
||||||
mod judge;
|
mod judge;
|
||||||
mod oscal_provider;
|
mod oscal_provider;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user