From bfeb3f041a51b3ea69bf8a06f11d0d7d79ea5621 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:47:10 +0200 Subject: [PATCH] feat(core): grounded control-check backbone (ground gate + cache) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic spine of control-driven checking: turn a text control into a finding via an LLM used as a grounded pattern-recognizer. ground() admits a verdict only if its quoted snippet appears verbatim in the retrieved region and recomputes the finding line from that match (the model's line is discarded), so a fabricated snippet cannot survive. cache_key() makes verdicts reproducible. Pure — no LLM, no IO. 4 lib tests incl. fabricated-snippet-dropped. Co-Authored-By: Claude Opus 4.8 --- compliance-core/src/control_check.rs | 204 +++++++++++++++++++++++++++ compliance-core/src/lib.rs | 1 + 2 files changed, 205 insertions(+) create mode 100644 compliance-core/src/control_check.rs diff --git a/compliance-core/src/control_check.rs b/compliance-core/src/control_check.rs new file mode 100644 index 0000000..c62abd7 --- /dev/null +++ b/compliance-core/src/control_check.rs @@ -0,0 +1,204 @@ +//! 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 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)] +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 (a dedicated field lands with the dashboard slice). + finding.rule_id = Some(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.rule_id.as_deref(), Some("cra-ai-8")); // 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") + ); + } +} diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index d43a542..260b045 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod control_check; pub mod db; pub mod error; pub mod models;