//! Scan-pipeline integration for control triage. //! //! After the deterministic tools have produced findings, this stamps each finding //! with the compliance control(s) it's evidence for and marks control-level false //! positives — using the ingested OSCAL catalog for control text, the //! `control-map` LUT for the finding→control link, and the grounded LLM judge to //! confirm. Skipped entirely unless breakpilot is configured. use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use compliance_core::control_check::{CandidateRegion, ControlCheckSpec}; use compliance_core::models::finding::{Finding, FindingStatus, Severity}; use compliance_core::models::onboarding::ComplianceFramework; use compliance_core::AgentConfig; use control_map::ControlMap; use super::surface; use super::{ ControlIndex, ControlTriage, GroundedControlChecker, 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; /// Triage every finding in `findings` against the CRA control map: stamp /// `control_refs` on confirmed findings and flag control false positives. Returns /// the number of findings tagged with at least one control. pub async fn triage_repo_findings( config: &AgentConfig, llm: Arc, repo_path: &Path, findings: &mut [Finding], ) -> usize { let Some(base_url) = config.breakpilot.base_url.clone() else { return 0; // control triage is opt-in via BREAKPILOT_BASE_URL }; 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 0; } let map = match ControlMap::cra() { Ok(m) => m, Err(e) => { tracing::warn!(error = %e, "control map failed to load; skipping control triage"); return 0; } }; let triage = ControlTriage::new(LlmControlJudge::new(llm), map, specs); let mut tagged = 0; for finding in findings.iter_mut() { 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; }; match triage.triage(finding, ®ion).await { TriageOutcome::Confirmed(controls) => { finding.control_refs = controls; tagged += 1; } TriageOutcome::FalsePositive => { finding.status = FindingStatus::FalsePositive; finding.triage_action = Some("control_false_positive".to_string()); } TriageOutcome::Unmapped => {} } } tagged } /// Build the control requirement specs (by id) from the ingested OSCAL catalog. async fn build_specs(provider: &OscalControlsProvider) -> HashMap { let mut specs = HashMap::new(); match provider.load(ComplianceFramework::Cra).await { Ok(doc) => { for control in doc.to_controls() { specs.insert( control.id.clone(), ControlCheckSpec { control_id: control.id, title: control.title, requirement: control.text, default_cwe: None, severity: Severity::Medium, }, ); } } Err(e) => tracing::warn!(error = %e, "could not load control catalog for triage"), } specs } /// Absence-based control pass (the grounded half of the hybrid coverage): for each /// control with a [`surface`] definition, deterministically retrieve the code /// surfaces it governs (login routes, logging setup, update/download code) and have /// the grounded judge decide whether the control holds there. Returns net-new /// findings, each already tagged with its control and grounded to a real snippet. /// /// The orchestrator runs this when `breakpilot.grounded_control_checks` is set /// (on by default). Validated live; it covers the 8 absence-based CRA controls /// (the judge decides presence/absence, grounded to a real snippet). pub async fn grounded_surface_findings( config: &AgentConfig, llm: Arc, 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()?; let lines: Vec<&str> = content.lines().collect(); if lines.is_empty() { return None; } let center = (line.saturating_sub(1) as usize).min(lines.len() - 1); let start = center.saturating_sub(REGION_WINDOW); let end = (center + REGION_WINDOW + 1).min(lines.len()); Some(CandidateRegion { file: file.to_string(), start_line: (start as u32) + 1, content: lines[start..end].join("\n"), }) } /// Master-controls **semantic** pass: for each finding's code region, retrieve the /// top-K nearest master controls by embedding, have the grounded judge confirm, /// and stamp the confirmed control ids onto the finding — the scale path for the /// ~13.6k master-control corpus (which has no CWE to LUT on). Returns the number /// of findings that gained a master-control ref. /// /// The orchestrator runs this when `breakpilot.semantic_mapping` is set (on by /// default). The control embedding index is built once and cached to /// `snapshot_dir` keyed by corpus hash ([`ControlIndex::load_or_build`]), so only /// the first scan after a catalog change pays the embedding cost. pub async fn semantic_stamp_findings( config: &AgentConfig, llm: Arc, 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 cache_path = Path::new(&config.breakpilot.snapshot_dir).join("control-index-master-controls.json"); let index = match ControlIndex::load_or_build(&llm, specs, &cache_path).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; }; // Retrieve on the finding's intent + the code, not the region alone: two // findings in one file share overlapping windows and otherwise embed alike, // collapsing onto the same controls. The finding's title/description carry // the discriminating signal (e.g. "brute-force protection" vs "weak hash"). // The raw `region` still goes to the judge for snippet grounding. let query = format!( "{}\n{}\n\n{}", finding.title, finding.description, region.content ); let query_emb = match llm.embed(vec![query]).await { Ok(mut embs) => match embs.pop() { Some(v) => v, None => continue, }, Err(e) => { tracing::warn!(error = %e, "query embed failed; skipping finding"); continue; } }; let confirmed = checker .check( &index, ®ion, &query_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::*; #[test] fn fetch_region_windows_around_the_line() { let dir = std::env::temp_dir().join(format!("triage-region-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let file = "a.py"; std::fs::write(dir.join(file), "l1\nl2\nl3\nSECRET=1\nl5\nl6\n").unwrap(); let r = fetch_region(&dir, file, 4).unwrap(); assert!(r.content.contains("SECRET=1")); assert_eq!(r.start_line, 1); // window clamps to file start let _ = std::fs::remove_dir_all(&dir); } #[test] fn fetch_region_missing_file_is_none() { assert!(fetch_region(Path::new("/nonexistent"), "nope.py", 1).is_none()); } }