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

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:
Sharang Parnerkar
2026-07-21 10:28:01 +02:00
co-authored by Claude Fable 5
parent f712ba1e60
commit 075a4cb81b
6 changed files with 165 additions and 4 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ mod tests {
]; ];
let findings = checker.check(&spec(), &regions, "repo").await; let findings = checker.check(&spec(), &regions, "repo").await;
assert_eq!(findings.len(), 1); 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)); assert_eq!(findings[0].line_number, Some(2));
} }
+2
View File
@@ -8,9 +8,11 @@
mod checker; mod checker;
mod judge; mod judge;
mod oscal_provider; mod oscal_provider;
mod scan_triage;
mod triage; mod triage;
pub use checker::GroundedControlChecker; pub use checker::GroundedControlChecker;
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION}; pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
pub use oscal_provider::OscalControlsProvider; pub use oscal_provider::OscalControlsProvider;
pub use scan_triage::triage_repo_findings;
pub use triage::{ControlTriage, TriageOutcome}; 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, &region).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; .await;
tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold"); 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 // Dedup against existing findings and insert new ones
let mut new_count = 0u32; let mut new_count = 0u32;
let mut new_findings: Vec<Finding> = Vec::new(); let mut new_findings: Vec<Finding> = Vec::new();
+3 -3
View File
@@ -96,8 +96,8 @@ pub fn ground(
finding.line_number = Some(line); finding.line_number = Some(line);
finding.code_snippet = Some(snippet.to_string()); finding.code_snippet = Some(snippet.to_string());
finding.confidence = Some(verdict.confidence); finding.confidence = Some(verdict.confidence);
// Carry the control reference (a dedicated field lands with the dashboard slice). // Carry the control reference on the finding.
finding.rule_id = Some(spec.control_id.clone()); finding.control_refs = vec![spec.control_id.clone()];
Some(finding) Some(finding)
} }
@@ -158,7 +158,7 @@ mod tests {
let f = ground(&spec(), &region(), &v, "repo").expect("should ground"); let f = ground(&spec(), &region(), &v, "repo").expect("should ground");
assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10 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.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.file_path.as_deref(), Some("src/auth.py"));
assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\"")); assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\""));
} }
+5
View File
@@ -76,6 +76,10 @@ pub struct Finding {
pub triage_rationale: Option<String>, pub triage_rationale: Option<String>,
/// Developer feedback on finding quality /// Developer feedback on finding quality
pub developer_feedback: Option<String>, pub developer_feedback: Option<String>,
/// 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<String>,
#[serde(with = "super::serde_helpers::bson_datetime")] #[serde(with = "super::serde_helpers::bson_datetime")]
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
#[serde(with = "super::serde_helpers::bson_datetime")] #[serde(with = "super::serde_helpers::bson_datetime")]
@@ -118,6 +122,7 @@ impl Finding {
triage_action: None, triage_action: None,
triage_rationale: None, triage_rationale: None,
developer_feedback: None, developer_feedback: None,
control_refs: Vec::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }