//! Grounded control-driven checking. //! //! Turns a *text* control into findings via an LLM used as a **pattern-recognizer** //! whose output is grounded to real code — so a hallucinated finding cannot //! survive. Determinism is structural, not a prompt plea: //! //! 1. the LLM only ever judges *retrieved* regions — it can't invent findings in //! code it never saw; //! 2. a verdict becomes a finding only if its quoted snippet appears **verbatim** //! in the region, and the line is recomputed from that match — the model's own //! line number is never trusted ([`ground`]); //! 3. verdicts are cached by content hash ([`cache_key`]) so re-scans reproduce. //! //! The LLM supplies cross-language / cross-stack pattern recognition; this module //! supplies the determinism. use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::models::finding::{Finding, Severity}; use crate::models::scan::ScanType; /// A control rendered as a check the LLM judges code against. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ControlCheckSpec { /// Stable control id, e.g. `"cra-ai-8"`. pub control_id: String, /// Short control title (used in the finding title). pub title: String, /// The requirement text the LLM judges against (control objective/statement). pub requirement: String, /// CWE to fall back to when the model doesn't supply one. pub default_cwe: Option, /// Severity for findings raised from this control. pub severity: Severity, } /// A retrieved code region the LLM judges — never the whole repo. #[derive(Debug, Clone)] pub struct CandidateRegion { /// Repo-relative path. pub file: String, /// 1-based line number of the region's first line in `file`. pub start_line: u32, /// The region's source text. pub content: String, } /// The LLM's structured verdict for one (control, region). `snippet` is the /// verbatim code the model claims proves the violation — it is the anchor the /// grounding gate checks. #[derive(Debug, Clone)] pub struct LlmVerdict { pub violates: bool, pub snippet: String, pub cwe: Option, pub confidence: f64, } /// The grounding gate. A verdict becomes a [`Finding`] only if it claims a /// violation AND its quoted `snippet` appears verbatim in `region.content`; the /// finding's line is computed from the match, so a fabricated or mis-located /// snippet is dropped. Pure — no LLM, no I/O. pub fn ground( spec: &ControlCheckSpec, region: &CandidateRegion, verdict: &LlmVerdict, repo_id: &str, ) -> Option { if !verdict.violates { return None; } let snippet = verdict.snippet.trim(); if snippet.is_empty() { return None; } // Grounding: the quoted snippet must literally exist in the retrieved region. let pos = region.content.find(snippet)?; // Recompute the real line from the match — never trust the model's number. let newlines_before = region.content[..pos].matches('\n').count(); let line = region.start_line + newlines_before as u32; let mut finding = Finding::new( repo_id.to_string(), control_finding_fingerprint(&spec.control_id, ®ion.file, snippet), "control-check".to_string(), ScanType::CodeReview, format!("{}: {}", spec.control_id, spec.title), format!( "Control {} appears violated ({}) at {}:{line}", spec.control_id, spec.requirement, region.file ), spec.severity.clone(), ); finding.cwe = verdict.cwe.clone().or_else(|| spec.default_cwe.clone()); finding.file_path = Some(region.file.clone()); finding.line_number = Some(line); finding.code_snippet = Some(snippet.to_string()); finding.confidence = Some(verdict.confidence); // Carry the control reference on the finding. finding.control_refs = vec![spec.control_id.clone()]; Some(finding) } /// Deterministic cache key for a (control, region, model, prompt-version) verdict /// so identical inputs reproduce the same verdict without another LLM call. pub fn cache_key( control_id: &str, region_content: &str, model: &str, prompt_version: &str, ) -> String { hash_parts(&[control_id, region_content, model, prompt_version]) } fn control_finding_fingerprint(control_id: &str, file: &str, snippet: &str) -> String { hash_parts(&[control_id, file, snippet]) } fn hash_parts(parts: &[&str]) -> String { let mut hasher = Sha256::new(); for part in parts { hasher.update(part.as_bytes()); hasher.update([0u8]); // domain separator between parts } hex::encode(hasher.finalize()) } #[cfg(test)] mod tests { use super::*; 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, } } fn region() -> CandidateRegion { CandidateRegion { file: "src/auth.py".into(), start_line: 10, content: "def login():\n PASSWORD = \"admin123\"\n return PASSWORD\n".into(), } } #[test] fn grounds_real_snippet_with_recomputed_line() { let v = LlmVerdict { violates: true, snippet: "PASSWORD = \"admin123\"".into(), cwe: None, confidence: 0.9, }; let f = ground(&spec(), ®ion(), &v, "repo").expect("should ground"); assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10 assert_eq!(f.cwe.as_deref(), Some("CWE-798")); // fell back to the spec default assert_eq!(f.control_refs, vec!["cra-ai-8".to_string()]); // control ref carried assert_eq!(f.file_path.as_deref(), Some("src/auth.py")); assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\"")); } #[test] fn drops_fabricated_snippet_not_in_region() { let v = LlmVerdict { violates: true, snippet: "SECRET = \"totally-made-up\"".into(), cwe: None, confidence: 0.99, }; assert!(ground(&spec(), ®ion(), &v, "repo").is_none()); } #[test] fn drops_non_violation_and_empty_snippet() { let no = LlmVerdict { violates: false, snippet: "PASSWORD = \"admin123\"".into(), cwe: None, confidence: 0.9, }; assert!(ground(&spec(), ®ion(), &no, "repo").is_none()); let empty = LlmVerdict { violates: true, snippet: " ".into(), cwe: None, confidence: 0.9, }; assert!(ground(&spec(), ®ion(), &empty, "repo").is_none()); } #[test] fn cache_key_and_fingerprint_are_deterministic() { assert_eq!(cache_key("c", "x", "m", "v"), cache_key("c", "x", "m", "v")); assert_ne!(cache_key("c", "x", "m", "v"), cache_key("c", "y", "m", "v")); assert_eq!( control_finding_fingerprint("c", "f", "s"), control_finding_fingerprint("c", "f", "s") ); } }