diff --git a/compliance-agent/src/controls/checker.rs b/compliance-agent/src/controls/checker.rs index cd2c9ef..9684e65 100644 --- a/compliance-agent/src/controls/checker.rs +++ b/compliance-agent/src/controls/checker.rs @@ -92,7 +92,7 @@ mod tests { ]; let findings = checker.check(&spec(), ®ions, "repo").await; assert_eq!(findings.len(), 1); - assert_eq!(findings[0].rule_id.as_deref(), Some("cra-ai-8")); + assert_eq!(findings[0].control_refs, vec!["cra-ai-8".to_string()]); assert_eq!(findings[0].line_number, Some(2)); } diff --git a/compliance-agent/src/controls/mod.rs b/compliance-agent/src/controls/mod.rs index 2a8721e..f47894c 100644 --- a/compliance-agent/src/controls/mod.rs +++ b/compliance-agent/src/controls/mod.rs @@ -8,9 +8,11 @@ mod checker; mod judge; mod oscal_provider; +mod scan_triage; mod triage; pub use checker::GroundedControlChecker; pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use oscal_provider::OscalControlsProvider; +pub use scan_triage::triage_repo_findings; pub use triage::{ControlTriage, TriageOutcome}; diff --git a/compliance-agent/src/controls/scan_triage.rs b/compliance-agent/src/controls/scan_triage.rs new file mode 100644 index 0000000..6c8b5d9 --- /dev/null +++ b/compliance-agent/src/controls/scan_triage.rs @@ -0,0 +1,139 @@ +//! 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::{ControlTriage, LlmControlJudge, OscalControlsProvider, TriageOutcome}; +use crate::llm::LlmClient; + +/// 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 +} + +/// 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"), + }) +} + +#[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()); + } +} diff --git a/compliance-agent/src/pipeline/orchestrator.rs b/compliance-agent/src/pipeline/orchestrator.rs index 093b041..1e7b265 100644 --- a/compliance-agent/src/pipeline/orchestrator.rs +++ b/compliance-agent/src/pipeline/orchestrator.rs @@ -215,6 +215,21 @@ impl PipelineOrchestrator { .await; tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold"); + // Stage 5b: control triage — stamp findings with the compliance control(s) + // they're evidence for and flag control false positives (grounded LLM over + // deterministic tool output). No-op unless breakpilot is configured. + self.update_phase(scan_run_id, "control_triage").await; + let tagged = crate::controls::triage_repo_findings( + &self.config, + self.llm.clone(), + &repo_path, + &mut all_findings, + ) + .await; + if tagged > 0 { + tracing::info!("[{repo_id}] Control triage tagged {tagged} findings with control refs"); + } + // 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/control_check.rs b/compliance-core/src/control_check.rs index c62abd7..af8ecb6 100644 --- a/compliance-core/src/control_check.rs +++ b/compliance-core/src/control_check.rs @@ -96,8 +96,8 @@ pub fn ground( 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()); + // Carry the control reference on the finding. + finding.control_refs = vec![spec.control_id.clone()]; Some(finding) } @@ -158,7 +158,7 @@ mod tests { 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.control_refs, vec!["cra-ai-8".to_string()]); // control ref carried assert_eq!(f.file_path.as_deref(), Some("src/auth.py")); assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\"")); } diff --git a/compliance-core/src/models/finding.rs b/compliance-core/src/models/finding.rs index 745cc0e..5085b9c 100644 --- a/compliance-core/src/models/finding.rs +++ b/compliance-core/src/models/finding.rs @@ -76,6 +76,10 @@ pub struct Finding { pub triage_rationale: Option, /// Developer feedback on finding quality pub developer_feedback: Option, + /// Compliance control ids this finding is evidence for (stamped by control + /// triage against the `control-map` LUT). Empty when unmapped. + #[serde(default)] + pub control_refs: Vec, #[serde(with = "super::serde_helpers::bson_datetime")] pub created_at: DateTime, #[serde(with = "super::serde_helpers::bson_datetime")] @@ -118,6 +122,7 @@ impl Finding { triage_action: None, triage_rationale: None, developer_feedback: None, + control_refs: Vec::new(), created_at: now, updated_at: now, }