diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index c3634e4..fcc73a3 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -17,6 +17,6 @@ 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::*;