feat(agent): wire control triage into the scan pipeline (end-to-end SAST)
CI / Check (push) Skipped
CI / Check (pull_request) Successful in 5m59s
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
CI / Check (push) Skipped
CI / Check (pull_request) Successful in 5m59s
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
After the deterministic tools run, the orchestrator's new control_triage stage stamps each finding with the compliance control(s) it is evidence for and flags control false positives. triage_repo_findings builds control specs from the ingested OSCAL catalog, reads a code window per finding, and runs ControlTriage (control-map LUT -> grounded judge). Adds Finding.control_refs (serde default); the ground gate stamps it. Opt-in via BREAKPILOT_BASE_URL. 2 region tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f712ba1e60
commit
075a4cb81b
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<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"),
|
||||
})
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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<Finding> = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user