feat: control-driven SAST — LUT + grounded LLM triage over tool findings #213
@@ -0,0 +1,167 @@
|
||||
//! The "recognize" stage: judge whether a code region violates a control.
|
||||
//!
|
||||
//! Behind the [`ControlJudge`] trait so the grounded checker can be driven by a
|
||||
//! deterministic stub in tests. The real [`LlmControlJudge`] runs the model at
|
||||
//! temperature 0 with a closed prompt — it must quote the offending code VERBATIM,
|
||||
//! and everything it returns is then re-checked by the grounding gate
|
||||
//! ([`compliance_core::control_check::ground`]). The judge is allowed to be
|
||||
//! smart; it is never trusted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::control_check::{CandidateRegion, ControlCheckSpec, LlmVerdict};
|
||||
|
||||
use crate::llm::LlmClient;
|
||||
|
||||
/// Prompt/logic version — part of the verdict cache key, bump on any change here.
|
||||
pub const PROMPT_VERSION: &str = "control-judge-v1";
|
||||
|
||||
const SYSTEM_PROMPT: &str = "You are a precise security & compliance code auditor. \
|
||||
You are given ONE compliance control (a requirement) and ONE code region. Decide \
|
||||
ONLY whether the code region VIOLATES the control. Rules: (1) Judge only the code \
|
||||
shown — never assume code that is not present. (2) If and only if it violates, copy \
|
||||
the EXACT offending code VERBATIM into `snippet`, character-for-character from the \
|
||||
region — do not paraphrase, reformat, or reconstruct it. (3) If it does not clearly \
|
||||
violate, set violates=false and leave snippet empty. (4) Prefer false over guessing. \
|
||||
Respond with STRICT JSON only, no prose: \
|
||||
{\"violates\": bool, \"snippet\": \"<verbatim code or empty>\", \"cwe\": \"CWE-NNN or null\", \"confidence\": 0.0-1.0}";
|
||||
|
||||
/// Judges one (control, region). Async-in-trait so a stub can drive tests.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait ControlJudge: Send + Sync {
|
||||
async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict;
|
||||
}
|
||||
|
||||
/// The real judge: the LLM at temperature 0 with the closed, verbatim-snippet prompt.
|
||||
pub struct LlmControlJudge {
|
||||
llm: Arc<LlmClient>,
|
||||
}
|
||||
|
||||
impl LlmControlJudge {
|
||||
pub fn new(llm: Arc<LlmClient>) -> Self {
|
||||
Self { llm }
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlJudge for LlmControlJudge {
|
||||
async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict {
|
||||
let user = build_user_prompt(spec, region);
|
||||
match self.llm.chat(SYSTEM_PROMPT, &user, Some(0.0)).await {
|
||||
Ok(response) => parse_verdict(&response),
|
||||
Err(e) => {
|
||||
// Fail closed: a transient model error yields no finding, never a
|
||||
// fabricated one.
|
||||
tracing::warn!(control = %spec.control_id, error = %e, "control judge call failed");
|
||||
no_violation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_user_prompt(spec: &ControlCheckSpec, region: &CandidateRegion) -> String {
|
||||
format!(
|
||||
"CONTROL {id} — {title}\nRequirement: {req}\n\nCODE ({file}, first line = {line}):\n```\n{code}\n```\n\nReturn the JSON verdict.",
|
||||
id = spec.control_id,
|
||||
title = spec.title,
|
||||
req = spec.requirement,
|
||||
file = region.file,
|
||||
line = region.start_line,
|
||||
code = region.content,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawVerdict {
|
||||
#[serde(default)]
|
||||
violates: bool,
|
||||
#[serde(default)]
|
||||
snippet: String,
|
||||
#[serde(default)]
|
||||
cwe: Option<String>,
|
||||
#[serde(default)]
|
||||
confidence: f64,
|
||||
}
|
||||
|
||||
/// Parse the model's JSON verdict, tolerant of ```json fencing. Any parse failure
|
||||
/// degrades to a non-violation (never a fabricated finding).
|
||||
fn parse_verdict(response: &str) -> LlmVerdict {
|
||||
let cleaned = response
|
||||
.trim()
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim();
|
||||
match serde_json::from_str::<RawVerdict>(cleaned) {
|
||||
Ok(raw) => LlmVerdict {
|
||||
violates: raw.violates,
|
||||
snippet: raw.snippet,
|
||||
cwe: raw.cwe.filter(|c| !c.trim().is_empty()),
|
||||
confidence: raw.confidence,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse control verdict; treating as non-violation");
|
||||
no_violation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn no_violation() -> LlmVerdict {
|
||||
LlmVerdict {
|
||||
violates: false,
|
||||
snippet: String::new(),
|
||||
cwe: None,
|
||||
confidence: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::finding::Severity;
|
||||
|
||||
fn spec() -> ControlCheckSpec {
|
||||
ControlCheckSpec {
|
||||
control_id: "cra-ai-8".into(),
|
||||
title: "No default passwords".into(),
|
||||
requirement: "Products must not ship default credentials".into(),
|
||||
default_cwe: Some("CWE-798".into()),
|
||||
severity: Severity::High,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_and_fenced_json() {
|
||||
let plain = r#"{"violates": true, "snippet": "PASSWORD = \"x\"", "cwe": "CWE-798", "confidence": 0.9}"#;
|
||||
let v = parse_verdict(plain);
|
||||
assert!(v.violates);
|
||||
assert_eq!(v.snippet, "PASSWORD = \"x\"");
|
||||
assert_eq!(v.cwe.as_deref(), Some("CWE-798"));
|
||||
|
||||
let fenced = "```json\n{\"violates\": false, \"snippet\": \"\", \"cwe\": null, \"confidence\": 0.1}\n```";
|
||||
assert!(!parse_verdict(fenced).violates);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_and_empty_cwe_are_safe() {
|
||||
assert!(!parse_verdict("not json at all").violates); // fail closed
|
||||
let no_cwe =
|
||||
parse_verdict(r#"{"violates": true, "snippet": "x", "cwe": " ", "confidence": 0.5}"#);
|
||||
assert!(no_cwe.cwe.is_none()); // blank CWE normalised away
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_carries_control_and_code() {
|
||||
let region = CandidateRegion {
|
||||
file: "src/auth.py".into(),
|
||||
start_line: 10,
|
||||
content: "PASSWORD = \"admin\"".into(),
|
||||
};
|
||||
let p = build_user_prompt(&spec(), ®ion);
|
||||
assert!(p.contains("cra-ai-8"));
|
||||
assert!(p.contains("Products must not ship default credentials"));
|
||||
assert!(p.contains("PASSWORD = \"admin\""));
|
||||
assert!(p.contains("src/auth.py"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
||||
//! and snapshots it locally.
|
||||
|
||||
mod judge;
|
||||
mod oscal_provider;
|
||||
|
||||
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
|
||||
pub use oscal_provider::OscalControlsProvider;
|
||||
|
||||
Reference in New Issue
Block a user