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;
|
let findings = checker.check(&spec(), ®ions, "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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ®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;
|
.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();
|
||||||
|
|||||||
@@ -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(), ®ion(), &v, "repo").expect("should ground");
|
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.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\""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user