diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index a18044a..5022b2b 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -103,5 +103,8 @@ fn load_breakpilot_config() -> BreakpilotConfig { semantic_mapping: env_var_opt("BREAKPILOT_SEMANTIC_MAPPING") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(d.semantic_mapping), + grounded_control_checks: env_var_opt("BREAKPILOT_GROUNDED_CHECKS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(d.grounded_control_checks), } } diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index fcc73a3..675e785 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -11,12 +11,13 @@ mod judge; mod oscal_provider; mod scan_triage; mod semantic; +mod surface; 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::{semantic_stamp_findings, triage_repo_findings}; +pub use scan_triage::{grounded_surface_findings, semantic_stamp_findings, triage_repo_findings}; pub use semantic::SemanticControlChecker; pub use triage::{ControlTriage, TriageOutcome}; diff --git a/compliance-agent/src/controls/scan_triage.rs b/compliance-agent/src/controls/scan_triage.rs index 83c1b82..edfbbf9 100644 --- a/compliance-agent/src/controls/scan_triage.rs +++ b/compliance-agent/src/controls/scan_triage.rs @@ -16,9 +16,10 @@ use compliance_core::models::onboarding::ComplianceFramework; use compliance_core::AgentConfig; use control_map::ControlMap; +use super::surface; use super::{ - ControlIndex, ControlTriage, LlmControlJudge, OscalControlsProvider, SemanticControlChecker, - TriageOutcome, + ControlIndex, ControlTriage, GroundedControlChecker, LlmControlJudge, OscalControlsProvider, + SemanticControlChecker, TriageOutcome, }; use crate::llm::LlmClient; @@ -105,6 +106,50 @@ async fn build_specs(provider: &OscalControlsProvider) -> HashMap, + repo_path: &Path, + repo_id: &str, +) -> Vec { + let Some(base_url) = config.breakpilot.base_url.clone() else { + return Vec::new(); + }; + let provider = OscalControlsProvider::new( + reqwest::Client::new(), + base_url, + config.breakpilot.token.clone(), + &config.breakpilot.snapshot_dir, + ); + let specs = build_specs(&provider).await; + if specs.is_empty() { + return Vec::new(); + } + let checker = GroundedControlChecker::new(LlmControlJudge::new(llm)); + + let mut out = Vec::new(); + for surf in surface::SURFACES { + let Some(spec) = specs.get(surf.control_id) else { + continue; // catalog doesn't carry this control + }; + let regions = surface::retrieve(repo_path, surf.terms); + if regions.is_empty() { + continue; + } + out.extend(checker.check(spec, ®ions, repo_id).await); + } + out +} + /// Read a window of lines around `line` (1-based) from `repo_path/file`. fn fetch_region(repo_path: &Path, file: &str, line: u32) -> Option { let content = std::fs::read_to_string(repo_path.join(file)).ok()?; diff --git a/compliance-agent/src/controls/surface.rs b/compliance-agent/src/controls/surface.rs new file mode 100644 index 0000000..ade5bc1 --- /dev/null +++ b/compliance-agent/src/controls/surface.rs @@ -0,0 +1,246 @@ +//! Surface retrieval for absence-based controls. +//! +//! Some CRA controls are violated by an *absence* — no rate limiting on login, no +//! security logging, no signature check on an update — so there's no offending +//! pattern for semgrep to match. Instead we deterministically locate the code +//! *surface* the control governs (a login route, a logging setup, update/download +//! code) by identifier/route terms, then hand each surface region to the grounded +//! judge, which decides whether the control is satisfied there. The resulting +//! finding grounds to the surface snippet, so nothing fabricated survives. +//! +//! Retrieval is intentionally cheap and bounded: keyword match + a fixed window, +//! capped per control to keep the downstream LLM cost predictable. + +use std::path::Path; + +use compliance_core::control_check::CandidateRegion; + +/// An absence-based control and the case-insensitive terms that mark the code +/// surface it governs. +pub struct Surface { + pub control_id: &'static str, + pub terms: &'static [&'static str], +} + +/// The absence-based CRA controls we retrieve surfaces for — the grounded half of +/// the hybrid coverage (the pattern-expressible half is custom semgrep rules). +pub const SURFACES: &[Surface] = &[ + Surface { + control_id: "cra-ai-6", // Integritaetspruefung + terms: &[ + "checksum", + "sha256", + "signature", + "hmac", + "integrity", + "verify", + ], + }, + Surface { + control_id: "cra-ai-11", // Brute-Force-Schutz + terms: &[ + "login", + "signin", + "authenticate", + "/auth", + "password", + "ratelimit", + ], + }, + Surface { + control_id: "cra-ai-24", // Security-Logging + terms: &["login", "authorize", "permission", "role", "admin", "audit"], + }, + Surface { + control_id: "cra-ai-27", // Log-Integritaet und -Aufbewahrung + terms: &["logging", "logger", "getlogger", "audit_log"], + }, + Surface { + control_id: "cra-ai-28", // Sichere Update-Mechanismen + terms: &["update", "upgrade", "download", "firmware"], + }, + Surface { + control_id: "cra-ai-29", // Update-Authentizitaet + terms: &["update", "signature", "verify", "pubkey", "certificate"], + }, + Surface { + control_id: "cra-ai-30", // Update-Integritaet + terms: &["update", "checksum", "digest", "integrity", "verify"], + }, +]; + +/// Source file extensions worth reading (skip binaries/assets/lockfiles). +const CODE_EXTS: &[&str] = &[ + "py", "js", "ts", "tsx", "jsx", "go", "java", "rb", "php", "rs", "cs", "kt", +]; +/// Directories never worth walking. +const SKIP_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + "vendor", + ".venv", + "__pycache__", + "dist", + "build", +]; +/// Lines of context on each side of a hit. +const WINDOW: usize = 6; +/// Cap on regions per control, to bound downstream LLM calls. +const MAX_REGIONS_PER_CONTROL: usize = 8; +/// Skip files larger than this (generated/minified). +const MAX_FILE_BYTES: u64 = 512 * 1024; + +/// Deterministically retrieve up to [`MAX_REGIONS_PER_CONTROL`] code regions in +/// `repo_path` whose lines mention any of `terms`. Hits close together within a +/// file are merged into one region; results are capped to bound LLM cost. +pub fn retrieve(repo_path: &Path, terms: &[&str]) -> Vec { + let lowered: Vec = terms.iter().map(|t| t.to_lowercase()).collect(); + let mut regions = Vec::new(); + for entry in walk(repo_path) { + if regions.len() >= MAX_REGIONS_PER_CONTROL { + break; + } + let path = entry.path(); + if !has_code_ext(path) { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES { + continue; + } + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + let rel = path + .strip_prefix(repo_path) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + let lines: Vec<&str> = content.lines().collect(); + let hits: Vec = lines + .iter() + .enumerate() + .filter(|(_, line)| { + let ll = line.to_lowercase(); + lowered.iter().any(|t| ll.contains(t.as_str())) + }) + .map(|(i, _)| i) + .collect(); + for center in merge_centers(&hits) { + if regions.len() >= MAX_REGIONS_PER_CONTROL { + break; + } + let start = center.saturating_sub(WINDOW); + let end = (center + WINDOW + 1).min(lines.len()); + regions.push(CandidateRegion { + file: rel.clone(), + start_line: (start as u32) + 1, + content: lines[start..end].join("\n"), + }); + } + } + regions +} + +/// Collapse ascending hit indices that fall within one window into a single +/// representative center, so overlapping regions aren't judged repeatedly. +fn merge_centers(hits: &[usize]) -> Vec { + let mut out: Vec = Vec::new(); + for &h in hits { + match out.last() { + Some(&last) if h.saturating_sub(last) <= WINDOW => {} + _ => out.push(h), + } + } + out +} + +fn has_code_ext(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| CODE_EXTS.contains(&e)) +} + +fn walk(root: &Path) -> Vec { + walkdir::WalkDir::new(root) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + !SKIP_DIRS.contains(&name.as_ref()) + }) + .filter_map(|e| e.ok()) + .collect() +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn write(dir: &Path, rel: &str, body: &str) { + let p = dir.join(rel); + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(p, body).unwrap(); + } + + fn terms_for(control_id: &str) -> &'static [&'static str] { + SURFACES + .iter() + .find(|s| s.control_id == control_id) + .unwrap() + .terms + } + + #[test] + fn surfaces_cover_the_absence_based_controls() { + assert_eq!(SURFACES.len(), 7); + for id in [ + "cra-ai-6", + "cra-ai-11", + "cra-ai-24", + "cra-ai-27", + "cra-ai-28", + "cra-ai-29", + "cra-ai-30", + ] { + assert!(SURFACES.iter().any(|s| s.control_id == id), "{id} missing"); + } + } + + #[test] + fn retrieves_matching_region_with_context() { + let dir = std::env::temp_dir().join(format!("surface-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + write( + &dir, + "app/auth.py", + "import x\n\n\n\n\n\n\ndef login(u, p):\n return check(u, p)\n", + ); + let regions = retrieve(&dir, terms_for("cra-ai-11")); + assert_eq!(regions.len(), 1); + assert!(regions[0].content.contains("def login")); + assert_eq!(regions[0].file, "app/auth.py"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn skips_non_code_and_vendored() { + let dir = std::env::temp_dir().join(format!("surface-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + write(&dir, "README.md", "login and password and audit\n"); // not code ext + write(&dir, "node_modules/pkg/index.js", "function login() {}\n"); // vendored + assert!(retrieve(&dir, terms_for("cra-ai-11")).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn merges_adjacent_hits_into_one_region() { + // Two hits one line apart collapse to a single center/region. + assert_eq!(merge_centers(&[10, 11, 30]), vec![10, 30]); + assert_eq!(merge_centers(&[]), Vec::::new()); + assert_eq!(merge_centers(&[5]), vec![5]); + } +} diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 3614b37..1bff9af 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -252,6 +252,31 @@ impl PipelineOrchestrator { } } + // Stage 5d: grounded surface checks — the absence-based controls (no + // rate limiting, no security logging, no update-signature check) have no + // syntactic pattern to match, so we retrieve the code surface each governs + // and let the grounded judge decide whether it holds, producing net-new + // findings already tagged + grounded. Gated (default off): absence + // detection is the least deterministic path, kept off until tuned live. + if self.config.breakpilot.grounded_control_checks { + self.update_phase(scan_run_id, "grounded_control_checks") + .await; + let grounded = crate::controls::grounded_surface_findings( + &self.config, + self.llm.clone(), + &repo_path, + &repo_id, + ) + .await; + if !grounded.is_empty() { + tracing::info!( + "[{repo_id}] Grounded surface checks raised {} control findings", + grounded.len() + ); + all_findings.extend(grounded); + } + } + // Dedup against existing findings and insert new ones let mut new_count = 0u32; let mut new_findings: Vec = Vec::new(); diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index 222f89e..75505be 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -80,6 +80,11 @@ pub struct BreakpilotConfig { /// scale path and stays gated until verified live against a deployed /// master-controls catalog. pub semantic_mapping: bool, + /// Enable the **grounded surface** pass for absence-based controls (retrieve + /// the code surface a control governs, judge whether it holds). Off by + /// default: absence detection is the least deterministic path and stays gated + /// until tuned against live scans. + pub grounded_control_checks: bool, } impl Default for BreakpilotConfig { @@ -89,6 +94,7 @@ impl Default for BreakpilotConfig { token: None, snapshot_dir: "/data/compliance-scanner/oscal".to_string(), semantic_mapping: false, + grounded_control_checks: false, } } } diff --git a/control-map/data/cra_control_map.json b/control-map/data/cra_control_map.json index b03961c..9705e63 100644 --- a/control-map/data/cra_control_map.json +++ b/control-map/data/cra_control_map.json @@ -53,7 +53,7 @@ "control": "cra-ai-6", "title": "Integritaetspruefung", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { @@ -139,7 +139,7 @@ "control": "cra-ai-11", "title": "Brute-Force-Schutz", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { @@ -308,7 +308,7 @@ "control": "cra-ai-24", "title": "Security-Logging", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { @@ -329,28 +329,28 @@ "control": "cra-ai-27", "title": "Log-Integritaet und -Aufbewahrung", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { "control": "cra-ai-28", "title": "Sichere Update-Mechanismen", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { "control": "cra-ai-29", "title": "Update-Authentizitaet", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, { "control": "cra-ai-30", "title": "Update-Integritaet", "scans": [], - "note": "code-checkable but no off-the-shelf tool digs it out — author a detector (custom semgrep rule / check)", + "note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning", "status": "needs_tooling" }, {