diff --git a/Cargo.lock b/Cargo.lock index 280e7f4..f8a6d02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5089,6 +5089,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -6472,6 +6478,7 @@ dependencies = [ "getrandom 0.4.1", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 7ea8120..e8cb61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "co thiserror = "2" sha2 = "0.10" hex = "0.4" -uuid = { version = "1", features = ["v4", "serde"] } +uuid = { version = "1", features = ["v4", "v5", "serde"] } secrecy = { version = "0.10", features = ["serde"] } regex = "1" zip = { version = "2", features = ["aes-crypto", "deflate"] } diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index 926dd78..b850955 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -11,6 +11,7 @@ pub mod mcp_token; pub mod notification; pub mod onboarding; pub mod oscal; +pub mod oscal_assessment; pub mod pentest; pub mod repository; pub mod sbom; @@ -41,6 +42,7 @@ pub use onboarding::{ TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig, }; pub use oscal::OscalDocument; +pub use oscal_assessment::{assess, AssessmentResultsDoc, ControlLinker}; pub use pentest::{ AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider, PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats, diff --git a/compliance-core/src/models/oscal_assessment.rs b/compliance-core/src/models/oscal_assessment.rs new file mode 100644 index 0000000..f78ea6d --- /dev/null +++ b/compliance-core/src/models/oscal_assessment.rs @@ -0,0 +1,402 @@ +//! OSCAL 1.1 assessment-results — assess our findings against catalog controls. +//! +//! The catalog (domain content) comes from the producer; the **assessment** is +//! ours. This links compliance [`Finding`]s to catalog control-ids and emits a +//! standard OSCAL assessment-results document: an observation per linked finding, +//! and a per-control finding with a `not-satisfied` status. `reviewed-controls` +//! records the full catalog set we considered. +//! +//! Deterministic: stable `uuid5` ids; the caller supplies the assessment +//! timestamp. Pure — no DB, no network. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +use crate::models::finding::Finding; +use crate::traits::Control; + +const OSCAL_VERSION: &str = "1.1.2"; +/// Same namespace as the catalog exporter, so ids are stable and correlatable. +const NAMESPACE: Uuid = Uuid::from_bytes([ + 0x6f, 0x1e, 0x7c, 0x2a, 0x3b, 0x4d, 0x5e, 0x6f, 0x8a, 0x9b, 0x0c, 0x1d, 0x2e, 0x3f, 0x4a, 0x5b, +]); + +fn det_uuid(name: &str) -> String { + Uuid::new_v5(&NAMESPACE, name.as_bytes()).to_string() +} + +/// Links findings to the catalog control-ids they provide evidence for. +pub struct ControlLinker { + cwe_to_controls: HashMap>, +} + +impl ControlLinker { + /// Build a linker from an explicit CWE → control-id map. + pub fn new(cwe_to_controls: HashMap>) -> Self { + Self { cwe_to_controls } + } + + /// Seed of CWE → CRA Annex I control mappings (mirrors breakpilot's + /// `_CWE_TO_REQ`; extend as scanner coverage grows). + pub fn cra_seed() -> Self { + let pairs: &[(u32, &str)] = &[ + (798, "cra-ai-8"), + (259, "cra-ai-8"), + (1392, "cra-ai-8"), + (327, "cra-ai-13"), + (326, "cra-ai-13"), + (319, "cra-ai-15"), + (311, "cra-ai-15"), + (89, "cra-ai-20"), + (79, "cra-ai-20"), + (78, "cra-ai-20"), + (22, "cra-ai-20"), + ]; + let mut map: HashMap> = HashMap::new(); + for (cwe, id) in pairs { + map.entry(*cwe).or_default().push((*id).to_string()); + } + Self::new(map) + } + + /// Parse a CWE token such as `"CWE-798"` or `"798"` into its number. + fn parse_cwe(raw: &str) -> Option { + raw.trim_start_matches(|c: char| !c.is_ascii_digit()) + .split(|c: char| !c.is_ascii_digit()) + .next() + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse().ok()) + } + + /// The control-ids a finding provides evidence for (via its CWE). + pub fn controls_for(&self, finding: &Finding) -> Vec { + finding + .cwe + .as_deref() + .and_then(Self::parse_cwe) + .and_then(|cwe| self.cwe_to_controls.get(&cwe)) + .cloned() + .unwrap_or_default() + } +} + +/// 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 { + let ts = at.to_rfc3339(); + + let mut observations = Vec::new(); + let mut obs_by_control: HashMap> = HashMap::new(); + for finding in findings { + let targets = linker.controls_for(finding); + if targets.is_empty() { + continue; + } + let obs_uuid = det_uuid(&format!("obs:{}", finding.fingerprint)); + let location = match (&finding.file_path, finding.line_number) { + (Some(f), Some(l)) => Some(format!("{f}:{l}")), + (Some(f), None) => Some(f.clone()), + _ => None, + }; + observations.push(Observation { + uuid: obs_uuid.clone(), + description: finding.title.clone(), + methods: vec!["TEST".to_string()], + collected: ts.clone(), + relevant_evidence: vec![RelevantEvidence { + href: location.map(|l| format!("file://{l}")), + description: format!("[{}] {}", finding.scanner, finding.title), + }], + }); + for control_id in targets { + obs_by_control + .entry(control_id) + .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(), + }, + }, + related_observations: obs_by_control[control_id] + .iter() + .map(|u| RelatedObservation { + observation_uuid: u.clone(), + }) + .collect(), + } + }) + .collect(); + + let include_controls = controls + .iter() + .map(|c| SelectControlById { + control_id: c.id.clone(), + }) + .collect(); + + let result = ArResult { + uuid: det_uuid("result:cra"), + title: "Automated code-compliance assessment".to_string(), + description: format!( + "{} finding-linked observation(s) across {} reviewed control(s)", + observations.len(), + controls.len() + ), + start: ts.clone(), + reviewed_controls: ReviewedControls { + control_selections: vec![ControlSelection { include_controls }], + }, + observations, + findings: ar_findings, + }; + + AssessmentResultsDoc { + assessment_results: AssessmentResults { + uuid: det_uuid("assessment-results:cra"), + metadata: ArMetadata { + title: "Compliance scanner — OSCAL assessment results".to_string(), + last_modified: ts, + version: "1.0.0".to_string(), + oscal_version: OSCAL_VERSION.to_string(), + }, + import_ap: ImportAp { + href: "#cra-annex-i".to_string(), + }, + results: vec![result], + }, + } +} + +// ── OSCAL assessment-results document (serialise) ──────────────────────────── + +/// The root OSCAL assessment-results document. +#[derive(Debug, Clone, Serialize)] +pub struct AssessmentResultsDoc { + #[serde(rename = "assessment-results")] + pub assessment_results: AssessmentResults, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AssessmentResults { + pub uuid: String, + pub metadata: ArMetadata, + #[serde(rename = "import-ap")] + pub import_ap: ImportAp, + pub results: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArMetadata { + pub title: String, + #[serde(rename = "last-modified")] + pub last_modified: String, + pub version: String, + #[serde(rename = "oscal-version")] + pub oscal_version: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ImportAp { + pub href: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArResult { + pub uuid: String, + pub title: String, + pub description: String, + pub start: String, + #[serde(rename = "reviewed-controls")] + pub reviewed_controls: ReviewedControls, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub observations: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReviewedControls { + #[serde(rename = "control-selections")] + pub control_selections: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ControlSelection { + #[serde(rename = "include-controls", skip_serializing_if = "Vec::is_empty")] + pub include_controls: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SelectControlById { + #[serde(rename = "control-id")] + pub control_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Observation { + pub uuid: String, + pub description: String, + pub methods: Vec, + pub collected: String, + #[serde(rename = "relevant-evidence", skip_serializing_if = "Vec::is_empty")] + pub relevant_evidence: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RelevantEvidence { + #[serde(skip_serializing_if = "Option::is_none")] + pub href: Option, + pub description: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArFinding { + pub uuid: String, + pub title: String, + pub target: FindingTarget, + #[serde(rename = "related-observations", skip_serializing_if = "Vec::is_empty")] + pub related_observations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindingTarget { + #[serde(rename = "type")] + pub target_type: String, + #[serde(rename = "target-id")] + pub target_id: String, + pub status: TargetStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TargetStatus { + pub state: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RelatedObservation { + #[serde(rename = "observation-uuid")] + pub observation_uuid: String, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +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 { + let mut f = Finding::new( + "repo".into(), + fp.into(), + "semgrep".into(), + ScanType::Sast, + "hardcoded credential".into(), + "desc".into(), + Severity::High, + ); + f.cwe = cwe.map(Into::into); + f.file_path = Some("src/auth.rs".into()); + f.line_number = Some(42); + f + } + + fn at() -> DateTime { + DateTime::parse_from_rfc3339("2026-07-20T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + #[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()); + 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 + ); + } + + #[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()); + let r = &doc.assessment_results.results[0]; + assert!(r.observations.is_empty()); + assert!(r.findings.is_empty()); + } + + #[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(); + assert_eq!(a, b); + assert!(a.contains("\"oscal-version\":\"1.1.2\"")); + assert!(a.contains("\"not-satisfied\"")); + } +}