feat(core): OSCAL 1.1 catalog types + mapping to controls corpus
Deserialise the OSCAL catalog served by breakpilot-compliance and flatten it into the framework-agnostic traits::Control the mapping engine consumes. Pure serde (no reqwest); exposes content-hash for snapshot/drift on the consumer side. Fixture-tested against the real 40-control CRA catalog (3 lib tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
94c0d51a11
commit
0e5a2d7e43
@@ -10,6 +10,7 @@ pub mod mcp;
|
|||||||
pub mod mcp_token;
|
pub mod mcp_token;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
|
pub mod oscal;
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
@@ -39,6 +40,7 @@ pub use onboarding::{
|
|||||||
GitArtifactConfig, IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat,
|
GitArtifactConfig, IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat,
|
||||||
TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig,
|
TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig,
|
||||||
};
|
};
|
||||||
|
pub use oscal::OscalDocument;
|
||||||
pub use pentest::{
|
pub use pentest::{
|
||||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
||||||
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! OSCAL 1.1 catalog types + mapping into the controls corpus.
|
||||||
|
//!
|
||||||
|
//! Deserialises the OSCAL catalog served by breakpilot-compliance
|
||||||
|
//! (`GET /api/compliance/v1/oscal/catalog`) and maps its controls into the
|
||||||
|
//! framework-agnostic [`crate::traits::Control`] that the mapping engine consumes.
|
||||||
|
//! Only the fields we use are modelled; unknown OSCAL fields are ignored so the
|
||||||
|
//! producer can add detail without breaking us.
|
||||||
|
//!
|
||||||
|
//! Scope boundary: this is the *catalog* (domain content). Assessment objectives
|
||||||
|
//! and scanner routing live in our assessment layer, not here — see
|
||||||
|
//! [`crate::traits::ControlsProvider`].
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::models::onboarding::ComplianceFramework;
|
||||||
|
use crate::traits::Control as CorpusControl;
|
||||||
|
|
||||||
|
/// A parsed OSCAL catalog document (`{"catalog": {...}}`).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct OscalDocument {
|
||||||
|
pub catalog: Catalog,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OSCAL catalog: metadata + a tree of control groups.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Catalog {
|
||||||
|
pub uuid: String,
|
||||||
|
pub metadata: Metadata,
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups: Vec<Group>,
|
||||||
|
#[serde(rename = "back-matter", default)]
|
||||||
|
pub back_matter: Option<BackMatter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog metadata (title/version + provenance props).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Metadata {
|
||||||
|
pub title: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(rename = "oscal-version")]
|
||||||
|
pub oscal_version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub props: Vec<Prop>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name/value property, optionally namespaced.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Prop {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ns: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control group (may nest sub-groups and controls).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Group {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub controls: Vec<Control>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups: Vec<Group>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OSCAL control (may nest enhancement controls).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Control {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub props: Vec<Prop>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parts: Vec<Part>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub links: Vec<Link>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub controls: Vec<Control>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control part (e.g. the `statement`), may nest sub-parts.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Part {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prose: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parts: Vec<Part>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link, e.g. a `reference` to a back-matter resource.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Link {
|
||||||
|
pub href: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-matter holding referenced resources (e.g. the CRA measures).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct BackMatter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub resources: Vec<Resource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A back-matter resource referenced by control links.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Resource {
|
||||||
|
pub uuid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata {
|
||||||
|
/// First prop value with the given name.
|
||||||
|
pub fn prop(&self, name: &str) -> Option<&str> {
|
||||||
|
self.props
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == name)
|
||||||
|
.map(|p| p.value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Control {
|
||||||
|
/// First prop value with the given name.
|
||||||
|
pub fn prop(&self, name: &str) -> Option<&str> {
|
||||||
|
self.props
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == name)
|
||||||
|
.map(|p| p.value.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control's `statement` prose, if present.
|
||||||
|
pub fn statement(&self) -> Option<&str> {
|
||||||
|
self.parts
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name == "statement")
|
||||||
|
.and_then(|p| p.prose.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OscalDocument {
|
||||||
|
/// The framework this catalog declares (`metadata.props[name="framework"]`).
|
||||||
|
pub fn framework(&self) -> Option<ComplianceFramework> {
|
||||||
|
framework_from_str(self.catalog.metadata.prop("framework")?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalog `content-hash` prop — consumers pin this to snapshot/detect drift.
|
||||||
|
pub fn content_hash(&self) -> Option<&str> {
|
||||||
|
self.catalog.metadata.prop("content-hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten the catalog into the corpus controls the mapping engine consumes.
|
||||||
|
pub fn to_controls(&self) -> Vec<CorpusControl> {
|
||||||
|
let framework = self.framework().unwrap_or(ComplianceFramework::Cra);
|
||||||
|
let source_label = self.catalog.metadata.title.as_str();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for group in &self.catalog.groups {
|
||||||
|
collect_group(group, framework, source_label, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an OSCAL framework token (e.g. `"cra"`) to [`ComplianceFramework`] via its
|
||||||
|
/// serde snake_case representation.
|
||||||
|
fn framework_from_str(raw: &str) -> Option<ComplianceFramework> {
|
||||||
|
serde_json::from_value(serde_json::Value::String(raw.to_string())).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_group(
|
||||||
|
group: &Group,
|
||||||
|
framework: ComplianceFramework,
|
||||||
|
source_label: &str,
|
||||||
|
out: &mut Vec<CorpusControl>,
|
||||||
|
) {
|
||||||
|
for control in &group.controls {
|
||||||
|
collect_control(control, framework, source_label, out);
|
||||||
|
}
|
||||||
|
for sub in &group.groups {
|
||||||
|
collect_group(sub, framework, source_label, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_control(
|
||||||
|
control: &Control,
|
||||||
|
framework: ComplianceFramework,
|
||||||
|
source_label: &str,
|
||||||
|
out: &mut Vec<CorpusControl>,
|
||||||
|
) {
|
||||||
|
let source = match control.prop("annex-anchor") {
|
||||||
|
Some(anchor) => Some(format!("{source_label} · {anchor}")),
|
||||||
|
None => Some(source_label.to_string()),
|
||||||
|
};
|
||||||
|
out.push(CorpusControl {
|
||||||
|
id: control.id.clone(),
|
||||||
|
framework,
|
||||||
|
title: control.title.clone(),
|
||||||
|
text: control.statement().unwrap_or_default().to_string(),
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
for enhancement in &control.controls {
|
||||||
|
collect_control(enhancement, framework, source_label, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const CATALOG: &str = include_str!("../../tests/data/cra_catalog.json");
|
||||||
|
|
||||||
|
fn parse() -> OscalDocument {
|
||||||
|
serde_json::from_str(CATALOG).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_full_catalog() {
|
||||||
|
let doc = parse();
|
||||||
|
assert_eq!(doc.catalog.metadata.oscal_version, "1.1.2");
|
||||||
|
assert!(!doc.catalog.groups.is_empty());
|
||||||
|
assert!(doc.catalog.back_matter.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_all_controls_to_corpus() {
|
||||||
|
let doc = parse();
|
||||||
|
let controls = doc.to_controls();
|
||||||
|
assert_eq!(controls.len(), 40);
|
||||||
|
assert_eq!(doc.framework(), Some(ComplianceFramework::Cra));
|
||||||
|
|
||||||
|
let c8 = controls.iter().find(|c| c.id == "cra-ai-8").unwrap();
|
||||||
|
assert_eq!(c8.framework, ComplianceFramework::Cra);
|
||||||
|
assert!(!c8.title.is_empty());
|
||||||
|
assert!(!c8.text.is_empty(), "statement prose should map into text");
|
||||||
|
assert!(c8.source.as_deref().unwrap_or_default().contains("Annex I"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exposes_content_hash_for_snapshotting() {
|
||||||
|
assert_eq!(parse().content_hash().map(str::len), Some(64));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user