CI / Check (push) Skipped
CI / Check (pull_request) Successful in 6m3s
CI / Detect Changes (pull_request) Skipped
CI / Deploy Agent (pull_request) Skipped
CI / Deploy Dashboard (pull_request) Skipped
CI / Deploy Docs (pull_request) Skipped
CI / Deploy MCP (pull_request) Skipped
Wires the semantic mapping into a callable scan pass: - OscalControlsProvider: token-based fetch refactor + load_master_controls() (pulls ?framework=master-controls) - semantic_stamp_findings(): build the ControlIndex from the master-controls catalog, then per finding embed its code region, retrieve the top-K nearest master controls, grounded-judge confirm, and stamp the confirmed ids onto control_refs (alongside the CRA CWE-LUT refs). Opt-in — the orchestrator doesn't auto-run it, and it rebuilds the corpus index per call (production should cache it). Live-verifiable once breakpilot #129 deploys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
8.4 KiB
Rust
243 lines
8.4 KiB
Rust
//! 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::{
|
|
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;
|
|
|
|
/// 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<LlmClient>,
|
|
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<String, ControlCheckSpec> {
|
|
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<CandidateRegion> {
|
|
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.
|
|
///
|
|
/// Opt-in: the orchestrator does not run this yet. It builds the control embedding
|
|
/// index per call (embeds the whole corpus) — production should cache/persist that
|
|
/// index rather than rebuild it each scan.
|
|
pub async fn semantic_stamp_findings(
|
|
config: &AgentConfig,
|
|
llm: Arc<LlmClient>,
|
|
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<ControlCheckSpec> = 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::*;
|
|
|
|
#[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());
|
|
}
|
|
}
|