diff --git a/compliance-agent/src/controls/index.rs b/compliance-agent/src/controls/index.rs new file mode 100644 index 0000000..494e918 --- /dev/null +++ b/compliance-agent/src/controls/index.rs @@ -0,0 +1,110 @@ +//! In-memory embedding index over the control corpus, for region → control +//! retrieval. +//! +//! At master-control scale (~13.6k) findings can't be mapped by CWE (the master +//! controls carry none), so we map by *similarity*: embed each control's +//! requirement text once, then for a code region pull the top-K nearest controls +//! to hand to the grounded judge. This is the retrieval half of the semantic path. + +use compliance_core::control_check::ControlCheckSpec; +use compliance_core::error::CoreError; + +use crate::llm::LlmClient; + +/// A control spec paired with its requirement-text embedding. +pub struct ControlIndex { + entries: Vec<(ControlCheckSpec, Vec)>, +} + +impl ControlIndex { + /// Build directly from precomputed embeddings (used by tests + callers that + /// already embedded the corpus). + pub fn from_embeddings(entries: Vec<(ControlCheckSpec, Vec)>) -> Self { + Self { entries } + } + + /// Build by embedding each control's requirement text. + pub async fn build(llm: &LlmClient, specs: Vec) -> Result { + if specs.is_empty() { + return Ok(Self { + entries: Vec::new(), + }); + } + let texts: Vec = specs.iter().map(|s| s.requirement.clone()).collect(); + let embeddings = llm + .embed(texts) + .await + .map_err(|e| CoreError::Llm(e.to_string()))?; + Ok(Self { + entries: specs.into_iter().zip(embeddings).collect(), + }) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The top-`k` control specs whose embedding is nearest (cosine) to `query`. + pub fn nearest(&self, query: &[f64], k: usize) -> Vec { + let mut scored: Vec<(f64, &ControlCheckSpec)> = self + .entries + .iter() + .map(|(spec, emb)| (cosine(query, emb), spec)) + .collect(); + scored.sort_by(|a, b| b.0.total_cmp(&a.0)); + scored.into_iter().take(k).map(|(_, s)| s.clone()).collect() + } +} + +/// Cosine similarity; 0.0 for length-mismatched, empty, or zero vectors. +fn cosine(a: &[f64], b: &[f64]) -> f64 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let na: f64 = a.iter().map(|x| x * x).sum(); + let nb: f64 = b.iter().map(|x| x * x).sum(); + if na == 0.0 || nb == 0.0 { + return 0.0; + } + dot / (na.sqrt() * nb.sqrt()) +} + +#[cfg(test)] +mod tests { + use super::*; + use compliance_core::models::finding::Severity; + + fn spec(id: &str) -> ControlCheckSpec { + ControlCheckSpec { + control_id: id.into(), + title: id.into(), + requirement: id.into(), + default_cwe: None, + severity: Severity::Medium, + } + } + + #[test] + fn nearest_ranks_by_cosine() { + let index = ControlIndex::from_embeddings(vec![ + (spec("a"), vec![1.0, 0.0]), + (spec("b"), vec![0.0, 1.0]), + (spec("c"), vec![0.7, 0.7]), + ]); + let hits = index.nearest(&[0.9, 0.1], 2); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].control_id, "a"); // closest to [0.9,0.1] + } + + #[test] + fn cosine_edges_are_zero() { + assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0); // length mismatch + assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), 0.0); // zero vector + assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9); // identical + } +} diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index f47894c..fcc73a3 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -6,13 +6,17 @@ //! and snapshots it locally. mod checker; +mod index; mod judge; mod oscal_provider; mod scan_triage; +mod semantic; mod triage; pub use checker::GroundedControlChecker; +pub use index::ControlIndex; pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use oscal_provider::OscalControlsProvider; -pub use scan_triage::triage_repo_findings; +pub use scan_triage::{semantic_stamp_findings, triage_repo_findings}; +pub use semantic::SemanticControlChecker; pub use triage::{ControlTriage, TriageOutcome}; diff --git a/compliance-agent/src/controls/oscal_provider.rs b/compliance-agent/src/controls/oscal_provider.rs index 0b484d3..84dfc8d 100644 --- a/compliance-agent/src/controls/oscal_provider.rs +++ b/compliance-agent/src/controls/oscal_provider.rs @@ -43,20 +43,20 @@ impl OscalControlsProvider { } } - fn catalog_url(&self, framework: ComplianceFramework) -> String { + fn catalog_url(&self, framework: &str) -> String { format!( "{}/api/compliance/v1/oscal/catalog?framework={framework}", self.base_url.trim_end_matches('/') ) } - fn snapshot_path(&self, framework: ComplianceFramework) -> PathBuf { + fn snapshot_path(&self, framework: &str) -> PathBuf { self.snapshot_dir .join(format!("oscal-catalog-{framework}.json")) } - /// Fetch the raw catalog bytes for a framework over HTTP. - async fn fetch_raw(&self, framework: ComplianceFramework) -> Result, CoreError> { + /// Fetch the raw catalog bytes for a framework token over HTTP. + async fn fetch_raw(&self, framework: &str) -> Result, CoreError> { let mut req = self.http.get(self.catalog_url(framework)); if let Some(token) = &self.token { req = req.bearer_auth(token.expose_secret()); @@ -78,11 +78,7 @@ impl OscalControlsProvider { } /// Write a catalog snapshot atomically (temp file + rename). - async fn write_snapshot( - &self, - framework: ComplianceFramework, - raw: &[u8], - ) -> Result<(), CoreError> { + async fn write_snapshot(&self, framework: &str, raw: &[u8]) -> Result<(), CoreError> { tokio::fs::create_dir_all(&self.snapshot_dir).await?; let path = self.snapshot_path(framework); let tmp = path.with_extension("json.tmp"); @@ -92,10 +88,7 @@ impl OscalControlsProvider { } /// Read a previously written snapshot, if one exists. - async fn read_snapshot( - &self, - framework: ComplianceFramework, - ) -> Result, CoreError> { + async fn read_snapshot(&self, framework: &str) -> Result, CoreError> { match tokio::fs::read(self.snapshot_path(framework)).await { Ok(raw) => Ok(Some(serde_json::from_slice(&raw)?)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), @@ -103,21 +96,21 @@ impl OscalControlsProvider { } } - /// Load the catalog for a framework: fetch fresh + snapshot the exact bytes; - /// on network failure, fall back to the last snapshot so scans still run. - pub async fn load(&self, framework: ComplianceFramework) -> Result { + /// Load the catalog for a framework token: fetch fresh + snapshot the exact + /// bytes; on network failure, fall back to the last snapshot so scans run. + async fn load_token(&self, framework: &str) -> Result { match self.fetch_raw(framework).await { Ok(raw) => { let doc: OscalDocument = serde_json::from_slice(&raw)?; if let Err(e) = self.write_snapshot(framework, &raw).await { - tracing::warn!(%framework, error = %e, "failed to write OSCAL snapshot"); + tracing::warn!(framework, error = %e, "failed to write OSCAL snapshot"); } Ok(doc) } Err(fetch_err) => match self.read_snapshot(framework).await? { Some(doc) => { tracing::warn!( - %framework, error = %fetch_err, + framework, error = %fetch_err, "OSCAL catalog fetch failed; falling back to snapshot" ); Ok(doc) @@ -126,6 +119,17 @@ impl OscalControlsProvider { }, } } + + /// Load the OSCAL catalog for a compliance framework. + pub async fn load(&self, framework: ComplianceFramework) -> Result { + self.load_token(&framework.to_string()).await + } + + /// Load the code-checkable master-controls catalog + /// (`?framework=master-controls`). + pub async fn load_master_controls(&self) -> Result { + self.load_token("master-controls").await + } } /// Order controls whose title/text mention the query context first (stable), then @@ -181,11 +185,11 @@ mod tests { fn builds_catalog_url_and_snapshot_path() { let p = provider(std::path::Path::new("/snap")); assert_eq!( - p.catalog_url(ComplianceFramework::Cra), + p.catalog_url("cra"), "http://unused/api/compliance/v1/oscal/catalog?framework=cra" ); assert_eq!( - p.snapshot_path(ComplianceFramework::Cra), + p.snapshot_path("cra"), std::path::Path::new("/snap/oscal-catalog-cra.json") ); } @@ -213,19 +217,11 @@ mod tests { async fn snapshot_round_trip_and_offline_fallback() { let dir = std::env::temp_dir().join(format!("oscal-test-{}", uuid::Uuid::new_v4())); let p = provider(&dir); - assert!(p - .read_snapshot(ComplianceFramework::Cra) - .await - .unwrap() - .is_none()); - p.write_snapshot(ComplianceFramework::Cra, MINI_CATALOG.as_bytes()) + assert!(p.read_snapshot("cra").await.unwrap().is_none()); + p.write_snapshot("cra", MINI_CATALOG.as_bytes()) .await .unwrap(); - let doc = p - .read_snapshot(ComplianceFramework::Cra) - .await - .unwrap() - .unwrap(); + let doc = p.read_snapshot("cra").await.unwrap().unwrap(); assert_eq!(doc.to_controls().len(), 1); assert_eq!(doc.framework(), Some(ComplianceFramework::Cra)); let _ = std::fs::remove_dir_all(&dir); diff --git a/compliance-agent/src/controls/scan_triage.rs b/compliance-agent/src/controls/scan_triage.rs index 6c8b5d9..3c7e62c 100644 --- a/compliance-agent/src/controls/scan_triage.rs +++ b/compliance-agent/src/controls/scan_triage.rs @@ -16,9 +16,15 @@ use compliance_core::models::onboarding::ComplianceFramework; use compliance_core::AgentConfig; use control_map::ControlMap; -use super::{ControlTriage, LlmControlJudge, OscalControlsProvider, TriageOutcome}; +use super::{ + ControlIndex, ControlTriage, LlmControlJudge, OscalControlsProvider, SemanticControlChecker, + TriageOutcome, +}; use crate::llm::LlmClient; +/// Nearest master controls judged per code region in the semantic pass. +const SEMANTIC_TOP_K: usize = 5; + /// Lines of context to read on each side of a finding's line. const REGION_WINDOW: usize = 6; @@ -116,6 +122,103 @@ fn fetch_region(repo_path: &Path, file: &str, line: u32) -> Option, + repo_path: &Path, + findings: &mut [Finding], +) -> usize { + let Some(base_url) = config.breakpilot.base_url.clone() else { + return 0; + }; + let provider = OscalControlsProvider::new( + reqwest::Client::new(), + base_url, + config.breakpilot.token.clone(), + &config.breakpilot.snapshot_dir, + ); + let doc = match provider.load_master_controls().await { + Ok(d) => d, + Err(e) => { + tracing::warn!(error = %e, "master-controls catalog unavailable; skipping semantic pass"); + return 0; + } + }; + let specs: Vec = doc + .to_controls() + .into_iter() + .map(|c| ControlCheckSpec { + control_id: c.id, + title: c.title, + requirement: c.text, + default_cwe: None, + severity: Severity::Medium, + }) + .collect(); + let index = match ControlIndex::build(&llm, specs).await { + Ok(i) if !i.is_empty() => i, + Ok(_) => return 0, + Err(e) => { + tracing::warn!(error = %e, "failed to embed master-controls corpus"); + return 0; + } + }; + let checker = SemanticControlChecker::new(LlmControlJudge::new(llm.clone())); + + let mut tagged = 0; + for finding in findings.iter_mut() { + if finding.status == FindingStatus::FalsePositive { + continue; + } + let (Some(file), Some(line)) = (finding.file_path.clone(), finding.line_number) else { + continue; + }; + let Some(region) = fetch_region(repo_path, &file, line) else { + continue; + }; + let region_emb = match llm.embed(vec![region.content.clone()]).await { + Ok(mut embs) => match embs.pop() { + Some(v) => v, + None => continue, + }, + Err(e) => { + tracing::warn!(error = %e, "region embed failed; skipping finding"); + continue; + } + }; + let confirmed = checker + .check( + &index, + ®ion, + ®ion_emb, + SEMANTIC_TOP_K, + &finding.repo_id, + ) + .await; + let before = finding.control_refs.len(); + for f in confirmed { + for cref in f.control_refs { + if !finding.control_refs.contains(&cref) { + finding.control_refs.push(cref); + } + } + } + if finding.control_refs.len() > before { + tagged += 1; + } + } + tagged +} + #[cfg(test)] mod tests { use super::*; diff --git a/compliance-agent/src/controls/semantic.rs b/compliance-agent/src/controls/semantic.rs new file mode 100644 index 0000000..1584de7 --- /dev/null +++ b/compliance-agent/src/controls/semantic.rs @@ -0,0 +1,119 @@ +//! Semantic control mapping: retrieve the top-K controls nearest a code region, +//! then confirm each with the grounded judge. +//! +//! The `region → controls` direction (vs. the CWE-LUT's `finding → control`) is +//! what scales to the full master-control corpus: the LLM only ever judges a +//! handful of retrieved candidates, and every surviving verdict is still anchored +//! to real code by the grounding gate. + +use compliance_core::control_check::{ground, CandidateRegion}; +use compliance_core::models::Finding; + +use super::index::ControlIndex; +use super::judge::ControlJudge; + +/// Retrieve → judge → ground, generic over the judge so tests use a stub. +pub struct SemanticControlChecker { + judge: J, +} + +impl SemanticControlChecker { + pub fn new(judge: J) -> Self { + Self { judge } + } + + /// Map a code region to the controls it violates. `region_embedding` is the + /// region's embedding (the caller computes it via the LLM); the top-`k` + /// nearest controls in `index` are judged and grounded. + pub async fn check( + &self, + index: &ControlIndex, + region: &CandidateRegion, + region_embedding: &[f64], + k: usize, + repo_id: &str, + ) -> Vec { + let candidates = index.nearest(region_embedding, k); + let mut findings = Vec::new(); + for spec in &candidates { + 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::{ControlCheckSpec, LlmVerdict}; + use compliance_core::models::finding::Severity; + + struct StubJudge { + verdict: LlmVerdict, + } + impl ControlJudge for StubJudge { + async fn judge(&self, _s: &ControlCheckSpec, _r: &CandidateRegion) -> LlmVerdict { + self.verdict.clone() + } + } + + fn spec(id: &str) -> ControlCheckSpec { + ControlCheckSpec { + control_id: id.into(), + title: id.into(), + requirement: id.into(), + default_cwe: None, + severity: Severity::Medium, + } + } + + #[tokio::test] + async fn retrieves_then_grounds_the_nearest_control() { + let index = ControlIndex::from_embeddings(vec![ + (spec("mc-near"), vec![1.0, 0.0]), + (spec("mc-far"), vec![0.0, 1.0]), + ]); + let checker = SemanticControlChecker::new(StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "PASSWORD = \"admin\"".into(), + cwe: None, + confidence: 0.9, + }, + }); + let region = CandidateRegion { + file: "src/auth.py".into(), + start_line: 1, + content: "PASSWORD = \"admin\"\n".into(), + }; + // Query embedding nearest to mc-near; k=1 → only mc-near is judged. + let findings = checker + .check(&index, ®ion, &[0.95, 0.05], 1, "repo") + .await; + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].control_refs, vec!["mc-near".to_string()]); + } + + #[tokio::test] + async fn ungrounded_verdict_is_dropped() { + let index = ControlIndex::from_embeddings(vec![(spec("mc-near"), vec![1.0, 0.0])]); + let checker = SemanticControlChecker::new(StubJudge { + verdict: LlmVerdict { + violates: true, + snippet: "not in the region".into(), + cwe: None, + confidence: 0.9, + }, + }); + let region = CandidateRegion { + file: "f".into(), + start_line: 1, + content: "real code\n".into(), + }; + let findings = checker.check(&index, ®ion, &[1.0, 0.0], 1, "repo").await; + assert!(findings.is_empty()); + } +}