From 18a23403a1f6fe7932c4828a1361d78f5241f294 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 21 Jul 2026 09:48:37 +0000 Subject: [PATCH] feat(oscal): emit unmapped findings as-is + MCP oscal_assessment tool (#214) --- compliance-agent/src/api/handlers/oscal.rs | 55 +---- .../src/models/oscal_assessment.rs | 196 ++++++++++-------- compliance-mcp/src/server.rs | 13 +- compliance-mcp/src/tools/mod.rs | 1 + compliance-mcp/src/tools/oscal.rs | 51 +++++ 5 files changed, 180 insertions(+), 136 deletions(-) create mode 100644 compliance-mcp/src/tools/oscal.rs diff --git a/compliance-agent/src/api/handlers/oscal.rs b/compliance-agent/src/api/handlers/oscal.rs index 4030431..ac2f348 100644 --- a/compliance-agent/src/api/handlers/oscal.rs +++ b/compliance-agent/src/api/handlers/oscal.rs @@ -1,9 +1,9 @@ //! OSCAL assessment endpoint. //! -//! Assesses a target's findings against the breakpilot-compliance control -//! catalog and returns a standard OSCAL assessment-results document. Ties -//! together the ingest provider ([`OscalControlsProvider`]) and the assessment -//! emitter (`compliance_core::models::oscal_assessment`). +//! Returns a standard OSCAL assessment-results document for a target's findings, +//! driven by each finding's stamped `control_refs` (from the scan's control-triage +//! stage): mapped findings target their controls, unmapped findings are reported +//! as-is. See `compliance_core::models::oscal_assessment`. use axum::extract::Extension; use axum::http::StatusCode; @@ -12,45 +12,24 @@ use axum::Json; use mongodb::bson::doc; use serde::Deserialize; -use compliance_core::models::onboarding::ComplianceFramework; -use compliance_core::models::oscal_assessment::{assess, ControlLinker}; +use compliance_core::models::oscal_assessment::assess; use compliance_core::models::Finding; use compliance_core::tenant_ctx::TenantCtx; use super::dto::{collect_cursor_async, tenant_db, AgentExt}; -use crate::controls::OscalControlsProvider; #[derive(Debug, Deserialize)] pub struct AssessRequest { /// The target / repo id whose findings are assessed. pub target_id: String, - /// Frameworks to assess against; defaults to `[Cra]` when empty. - #[serde(default)] - pub frameworks: Vec, } -/// `POST /api/v1/oscal/assess` — pull the catalog(s), load the target's findings, -/// and emit an OSCAL assessment-results document linking findings to controls. +/// `POST /api/v1/oscal/assess` — OSCAL assessment-results for a target's findings. pub async fn assess_target( Extension(agent): AgentExt, tenant: TenantCtx, Json(req): Json, ) -> Response { - let cfg = &agent.config.breakpilot; - let Some(base_url) = cfg.base_url.clone() else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - "breakpilot base URL not configured (set BREAKPILOT_BASE_URL)", - ) - .into_response(); - }; - - let frameworks = if req.frameworks.is_empty() { - vec![ComplianceFramework::Cra] - } else { - req.frameworks.clone() - }; - let db = match tenant_db(&agent, &tenant).await { Ok(db) => db, Err(code) => return code.into_response(), @@ -65,25 +44,5 @@ pub async fn assess_target( } }; - let provider = OscalControlsProvider::new( - agent.http.clone(), - base_url, - cfg.token.clone(), - &cfg.snapshot_dir, - ); - let mut controls = Vec::new(); - for framework in &frameworks { - match provider.load(*framework).await { - Ok(document) => controls.extend(document.to_controls()), - Err(e) => tracing::warn!(?framework, error = %e, "OSCAL catalog load failed"), - } - } - - let assessment = assess( - &controls, - &findings, - &ControlLinker::cra_seed(), - chrono::Utc::now(), - ); - Json(assessment).into_response() + Json(assess(&findings, chrono::Utc::now())).into_response() } diff --git a/compliance-core/src/models/oscal_assessment.rs b/compliance-core/src/models/oscal_assessment.rs index f78ea6d..404b559 100644 --- a/compliance-core/src/models/oscal_assessment.rs +++ b/compliance-core/src/models/oscal_assessment.rs @@ -15,8 +15,7 @@ use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; -use crate::models::finding::Finding; -use crate::traits::Control; +use crate::models::finding::{Finding, FindingStatus}; const OSCAL_VERSION: &str = "1.1.2"; /// Same namespace as the catalog exporter, so ids are stable and correlatable. @@ -83,22 +82,23 @@ impl ControlLinker { } } -/// Assess `findings` against `controls`: link findings to control-ids and build a -/// standard OSCAL assessment-results document. `at` is the assessment timestamp. -pub fn assess( - controls: &[Control], - findings: &[Finding], - linker: &ControlLinker, - at: DateTime, -) -> AssessmentResultsDoc { +/// Build a standard OSCAL assessment-results document from `findings`, using each +/// finding's stamped `control_refs` for control linkage. EVERY non-false-positive +/// finding is emitted as an observation — mapped findings additionally produce a +/// per-control `not-satisfied` finding; **unmapped findings are reported as-is** +/// (an observation carrying their CWE/tool/severity, with no control target) so +/// nothing is lost. `at` is the assessment timestamp. +pub fn assess(findings: &[Finding], at: DateTime) -> AssessmentResultsDoc { let ts = at.to_rfc3339(); let mut observations = Vec::new(); let mut obs_by_control: HashMap> = HashMap::new(); + let mut mapped = 0usize; + let mut unmapped = 0usize; + for finding in findings { - let targets = linker.controls_for(finding); - if targets.is_empty() { - continue; + if finding.status == FindingStatus::FalsePositive { + continue; // flagged tool false positive — excluded from the report } let obs_uuid = det_uuid(&format!("obs:{}", finding.fingerprint)); let location = match (&finding.file_path, finding.line_number) { @@ -106,58 +106,67 @@ pub fn assess( (Some(f), None) => Some(f.clone()), _ => None, }; + let is_mapped = !finding.control_refs.is_empty(); + if is_mapped { + mapped += 1; + } else { + unmapped += 1; + } + let mut props = vec![ + ObsProp::new("tool", &finding.scanner), + ObsProp::new("severity", &finding.severity.to_string()), + ObsProp::new("mapping", if is_mapped { "mapped" } else { "unmapped" }), + ]; + if let Some(cwe) = &finding.cwe { + props.push(ObsProp::new("cwe", cwe)); + } observations.push(Observation { uuid: obs_uuid.clone(), - description: finding.title.clone(), + title: finding.title.clone(), + description: finding.description.clone(), methods: vec!["TEST".to_string()], collected: ts.clone(), + props, relevant_evidence: vec![RelevantEvidence { href: location.map(|l| format!("file://{l}")), description: format!("[{}] {}", finding.scanner, finding.title), }], }); - for control_id in targets { + for control_id in &finding.control_refs { obs_by_control - .entry(control_id) + .entry(control_id.clone()) .or_default() .push(obs_uuid.clone()); } } - let titles: HashMap<&str, &str> = controls - .iter() - .map(|c| (c.id.as_str(), c.title.as_str())) - .collect(); let mut hit_controls: Vec<&String> = obs_by_control.keys().collect(); hit_controls.sort(); let ar_findings: Vec = hit_controls - .into_iter() - .map(|control_id| { - let title = titles.get(control_id.as_str()).copied().unwrap_or(""); - ArFinding { - uuid: det_uuid(&format!("finding:{control_id}")), - title: format!("Findings affect {control_id}: {title}"), - target: FindingTarget { - target_type: "statement-id".to_string(), - target_id: format!("{control_id}_smt"), - status: TargetStatus { - state: "not-satisfied".to_string(), - }, + .iter() + .map(|control_id| ArFinding { + uuid: det_uuid(&format!("finding:{control_id}")), + title: format!("Findings affect {control_id}"), + target: FindingTarget { + target_type: "statement-id".to_string(), + target_id: format!("{control_id}_smt"), + status: TargetStatus { + state: "not-satisfied".to_string(), }, - related_observations: obs_by_control[control_id] - .iter() - .map(|u| RelatedObservation { - observation_uuid: u.clone(), - }) - .collect(), - } + }, + related_observations: obs_by_control[*control_id] + .iter() + .map(|u| RelatedObservation { + observation_uuid: u.clone(), + }) + .collect(), }) .collect(); - let include_controls = controls + let include_controls: Vec = hit_controls .iter() .map(|c| SelectControlById { - control_id: c.id.clone(), + control_id: (*c).clone(), }) .collect(); @@ -165,9 +174,9 @@ pub fn assess( uuid: det_uuid("result:cra"), title: "Automated code-compliance assessment".to_string(), description: format!( - "{} finding-linked observation(s) across {} reviewed control(s)", + "{} observation(s): {mapped} control-linked, {unmapped} unmapped (as-is); {} control(s) affected", observations.len(), - controls.len() + include_controls.len() ), start: ts.clone(), reviewed_controls: ReviewedControls { @@ -262,13 +271,33 @@ pub struct SelectControlById { #[derive(Debug, Clone, Serialize)] pub struct Observation { pub uuid: String, + pub title: String, pub description: String, pub methods: Vec, pub collected: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub props: Vec, #[serde(rename = "relevant-evidence", skip_serializing_if = "Vec::is_empty")] pub relevant_evidence: Vec, } +/// A name/value observation property (cwe, tool, severity, mapping status). Lets an +/// unmapped finding be reported fully as-is. +#[derive(Debug, Clone, Serialize)] +pub struct ObsProp { + pub name: String, + pub value: String, +} + +impl ObsProp { + fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct RelevantEvidence { #[serde(skip_serializing_if = "Option::is_none")] @@ -310,20 +339,9 @@ pub struct RelatedObservation { mod tests { use super::*; use crate::models::finding::Severity; - use crate::models::onboarding::ComplianceFramework; use crate::models::scan::ScanType; - fn control(id: &str, title: &str) -> Control { - Control { - id: id.into(), - framework: ComplianceFramework::Cra, - title: title.into(), - text: String::new(), - source: None, - } - } - - fn finding(fp: &str, cwe: Option<&str>) -> Finding { + fn finding(fp: &str, cwe: Option<&str>, refs: &[&str]) -> Finding { let mut f = Finding::new( "repo".into(), fp.into(), @@ -336,6 +354,7 @@ mod tests { f.cwe = cwe.map(Into::into); f.file_path = Some("src/auth.rs".into()); f.line_number = Some(42); + f.control_refs = refs.iter().map(|s| s.to_string()).collect(); f } @@ -346,57 +365,60 @@ mod tests { } #[test] - fn links_cwe_finding_to_control_not_satisfied() { - let controls = vec![ - control("cra-ai-8", "No default passwords"), - control("cra-ai-13", "Crypto"), - ]; - let findings = vec![finding("f1", Some("CWE-798"))]; - let doc = assess(&controls, &findings, &ControlLinker::cra_seed(), at()); + fn mapped_finding_becomes_control_finding() { + let doc = assess(&[finding("f1", Some("CWE-798"), &["cra-ai-8"])], at()); let r = &doc.assessment_results.results[0]; assert_eq!(r.observations.len(), 1); assert_eq!(r.findings.len(), 1); assert_eq!(r.findings[0].target.target_id, "cra-ai-8_smt"); assert_eq!(r.findings[0].target.status.state, "not-satisfied"); - assert_eq!(r.findings[0].related_observations.len(), 1); assert_eq!( r.reviewed_controls.control_selections[0] .include_controls .len(), - 2 + 1 ); } #[test] - fn unlinked_finding_yields_no_control_finding() { - let controls = vec![control("cra-ai-8", "x")]; - let findings = vec![finding("f1", Some("CWE-99999"))]; - let doc = assess(&controls, &findings, &ControlLinker::cra_seed(), at()); + fn unmapped_finding_is_reported_as_is() { + let doc = assess(&[finding("f1", Some("CWE-319"), &[])], at()); let r = &doc.assessment_results.results[0]; - assert!(r.observations.is_empty()); - assert!(r.findings.is_empty()); + assert_eq!(r.observations.len(), 1); // still emitted... + assert!(r.findings.is_empty()); // ...but no control finding + assert!(r.reviewed_controls.control_selections[0] + .include_controls + .is_empty()); + let props: Vec<(&str, &str)> = r.observations[0] + .props + .iter() + .map(|p| (p.name.as_str(), p.value.as_str())) + .collect(); + assert!(props.contains(&("mapping", "unmapped"))); + assert!(props.contains(&("cwe", "CWE-319"))); } #[test] - fn output_is_deterministic_and_valid_oscal() { - let controls = vec![control("cra-ai-8", "x")]; - let findings = vec![finding("f1", Some("798"))]; - let a = serde_json::to_string(&assess( - &controls, - &findings, - &ControlLinker::cra_seed(), - at(), - )) - .unwrap(); - let b = serde_json::to_string(&assess( - &controls, - &findings, - &ControlLinker::cra_seed(), - at(), - )) - .unwrap(); + fn false_positive_is_excluded() { + let mut f = finding("f1", Some("CWE-798"), &["cra-ai-8"]); + f.status = FindingStatus::FalsePositive; + let doc = assess(&[f], at()); + assert!(doc.assessment_results.results[0].observations.is_empty()); + } + + #[test] + fn deterministic_and_valid_oscal() { + let mk = || { + vec![ + finding("f1", Some("CWE-798"), &["cra-ai-8"]), + finding("f2", Some("CWE-319"), &[]), + ] + }; + let a = serde_json::to_string(&assess(&mk(), at())).unwrap(); + let b = serde_json::to_string(&assess(&mk(), at())).unwrap(); assert_eq!(a, b); assert!(a.contains("\"oscal-version\":\"1.1.2\"")); assert!(a.contains("\"not-satisfied\"")); + assert!(a.contains("\"mapping\"")); } } diff --git a/compliance-mcp/src/server.rs b/compliance-mcp/src/server.rs index bba442e..6e29505 100644 --- a/compliance-mcp/src/server.rs +++ b/compliance-mcp/src/server.rs @@ -4,7 +4,7 @@ use rmcp::{ use crate::auth::current_tenant_id; use crate::database::{Database, DatabasePool}; -use crate::tools::{dast, findings, pentest, sbom}; +use crate::tools::{dast, findings, oscal, pentest, sbom}; pub struct ComplianceMcpServer { pool: DatabasePool, @@ -68,6 +68,17 @@ impl ComplianceMcpServer { findings::findings_summary(&db, params).await } + #[tool( + description = "Emit an OSCAL 1.1 assessment-results document for a repo's findings (mapped findings target their compliance controls; unmapped findings are reported as-is)" + )] + async fn oscal_assessment( + &self, + Parameters(params): Parameters, + ) -> Result { + let db = self.tenant_db()?; + oscal::oscal_assessment(&db, params).await + } + // ── SBOM ────────────────────────────────────────────── #[tool( diff --git a/compliance-mcp/src/tools/mod.rs b/compliance-mcp/src/tools/mod.rs index 373d1a3..2a3751c 100644 --- a/compliance-mcp/src/tools/mod.rs +++ b/compliance-mcp/src/tools/mod.rs @@ -1,4 +1,5 @@ pub mod dast; pub mod findings; +pub mod oscal; pub mod pentest; pub mod sbom; diff --git a/compliance-mcp/src/tools/oscal.rs b/compliance-mcp/src/tools/oscal.rs new file mode 100644 index 0000000..9b68af2 --- /dev/null +++ b/compliance-mcp/src/tools/oscal.rs @@ -0,0 +1,51 @@ +//! OSCAL assessment MCP tool. +//! +//! Emits a standard OSCAL assessment-results document for a repo's findings — +//! what breakpilot's scanner MCP client pulls. Mapped findings target their +//! compliance controls (via the stamped `control_refs`); unmapped findings are +//! reported as-is, so nothing is lost. + +use mongodb::bson::doc; +use rmcp::{model::*, ErrorData as McpError}; +use schemars::JsonSchema; +use serde::Deserialize; + +use compliance_core::models::oscal_assessment::assess; +use compliance_core::models::Finding; + +use crate::database::Database; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OscalAssessmentParams { + /// Repository / target id to assess. + pub repo_id: String, +} + +pub async fn oscal_assessment( + db: &Database, + params: OscalAssessmentParams, +) -> Result { + let mut cursor = db + .findings() + .find(doc! { "repo_id": ¶ms.repo_id }) + .await + .map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?; + + let mut findings: Vec = Vec::new(); + while cursor + .advance() + .await + .map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))? + { + findings.push( + cursor + .deserialize_current() + .map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?, + ); + } + + let document = assess(&findings, chrono::Utc::now()); + let json = serde_json::to_string_pretty(&document) + .map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?; + Ok(CallToolResult::success(vec![Content::text(json)])) +}