use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CsafDocument {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema: Option<String>,
pub document: Document,
pub product_tree: ProductTree,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vulnerabilities: Vec<Vulnerability>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Document {
pub category: String,
pub csaf_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub distribution: Option<Distribution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
pub publisher: Publisher,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<Reference>,
pub title: String,
pub tracking: Tracking,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Distribution {
#[serde(skip_serializing_if = "Option::is_none")]
pub tlp: Option<Tlp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tlp {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Note {
pub category: String,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Publisher {
pub category: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issuing_authority: Option<String>,
pub name: String,
pub namespace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Reference {
pub category: String,
pub summary: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tracking {
pub current_release_date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub generator: Option<Generator>,
pub id: String,
pub initial_release_date: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub revision_history: Vec<Revision>,
pub status: String,
pub version: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Generator {
pub engine: Engine,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Engine {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Revision {
pub date: String,
pub number: String,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProductTree {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branches: Vec<Branch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub full_product_names: Vec<FullProductName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub product_groups: Vec<ProductGroup>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Branch {
pub category: String,
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branches: Vec<Self>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product: Option<FullProductName>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FullProductName {
pub name: String,
pub product_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cpe: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purl: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProductGroup {
pub group_id: String,
pub product_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Relationship {
pub category: String,
pub full_product_name: FullProductName,
pub product_reference: String,
pub relates_to_product_reference: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Vulnerability {
#[serde(skip_serializing_if = "Option::is_none")]
pub cve: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwe: Option<Cwe>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovery_date: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ids: Vec<VulnerabilityId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
#[serde(skip_serializing_if = "Option::is_none")]
pub product_status: Option<ProductStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remediations: Vec<Remediation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metrics: Vec<Metric>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub threats: Vec<Threat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_date: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<Reference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub involvements: Vec<Involvement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<Flag>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cwe {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VulnerabilityId {
pub system_name: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProductStatus {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub known_affected: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub known_not_affected: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fixed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub under_investigation: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub first_affected: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub first_fixed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub last_affected: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recommended: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Remediation {
pub category: String,
pub details: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub product_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub group_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub restart_required: Option<RestartRequired>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entitlements: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RestartRequired {
pub category: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Metric {
pub content: MetricContent,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub products: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MetricContent {
#[serde(skip_serializing_if = "Option::is_none")]
pub cvss_v3: Option<CvssV3>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cvss_v4: Option<CvssV4>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CvssV3 {
pub version: String,
#[serde(rename = "vectorString")]
pub vector_string: String,
#[serde(rename = "baseScore")]
pub base_score: f64,
#[serde(rename = "baseSeverity")]
pub base_severity: String,
#[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
pub attack_vector: Option<String>,
#[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
pub attack_complexity: Option<String>,
#[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
pub privileges_required: Option<String>,
#[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
pub user_interaction: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(
rename = "confidentialityImpact",
skip_serializing_if = "Option::is_none"
)]
pub confidentiality_impact: Option<String>,
#[serde(rename = "integrityImpact", skip_serializing_if = "Option::is_none")]
pub integrity_impact: Option<String>,
#[serde(rename = "availabilityImpact", skip_serializing_if = "Option::is_none")]
pub availability_impact: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CvssV4 {
pub version: String,
#[serde(rename = "vectorString")]
pub vector_string: String,
#[serde(rename = "baseScore")]
pub base_score: f64,
#[serde(rename = "baseSeverity")]
pub base_severity: String,
#[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
pub attack_vector: Option<String>,
#[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
pub attack_complexity: Option<String>,
#[serde(rename = "attackRequirements", skip_serializing_if = "Option::is_none")]
pub attack_requirements: Option<String>,
#[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
pub privileges_required: Option<String>,
#[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
pub user_interaction: Option<String>,
#[serde(
rename = "vulnConfidentialityImpact",
skip_serializing_if = "Option::is_none"
)]
pub confidentiality_impact: Option<String>,
#[serde(
rename = "vulnIntegrityImpact",
skip_serializing_if = "Option::is_none"
)]
pub integrity_impact: Option<String>,
#[serde(
rename = "vulnAvailabilityImpact",
skip_serializing_if = "Option::is_none"
)]
pub availability_impact: Option<String>,
#[serde(
rename = "subConfidentialityImpact",
skip_serializing_if = "Option::is_none"
)]
pub sub_confidentiality_impact: Option<String>,
#[serde(rename = "subIntegrityImpact", skip_serializing_if = "Option::is_none")]
pub sub_integrity_impact: Option<String>,
#[serde(
rename = "subAvailabilityImpact",
skip_serializing_if = "Option::is_none"
)]
pub sub_availability_impact: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Threat {
pub category: String,
pub details: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub product_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub group_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Involvement {
pub party: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Flag {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub product_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub group_ids: Vec<String>,
}
impl CsafDocument {
#[must_use]
pub fn all_product_ids(&self) -> Vec<String> {
let mut ids = Vec::new();
collect_product_ids_from_branches(&self.product_tree.branches, &mut ids);
for fpn in &self.product_tree.full_product_names {
ids.push(fpn.product_id.clone());
}
ids
}
#[must_use]
pub fn tracking_id(&self) -> &str {
&self.document.tracking.id
}
#[must_use]
pub fn csaf_version(&self) -> &str {
&self.document.csaf_version
}
#[must_use]
pub fn category(&self) -> &str {
&self.document.category
}
}
fn collect_product_ids_from_branches(branches: &[Branch], ids: &mut Vec<String>) {
for branch in branches {
if let Some(product) = &branch.product {
ids.push(product.product_id.clone());
}
collect_product_ids_from_branches(&branch.branches, ids);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CsafMeta {
pub tracking_id: String,
pub title: String,
pub category: String,
pub csaf_version: String,
pub status: String,
pub current_release_date: String,
pub initial_release_date: String,
pub version: String,
pub publisher_name: String,
pub tlp_label: Option<String>,
pub vulnerability_count: usize,
pub max_cvss_v3_score: Option<f64>,
pub max_cvss_v4_score: Option<f64>,
}
impl CsafMeta {
#[must_use]
pub fn from_document(doc: &CsafDocument) -> Self {
let tlp_label = doc
.document
.distribution
.as_ref()
.and_then(|d| d.tlp.as_ref())
.map(|t| t.label.clone());
let mut max_v3: Option<f64> = None;
let mut max_v4: Option<f64> = None;
for vuln in &doc.vulnerabilities {
for metric in &vuln.metrics {
if let Some(v3) = &metric.content.cvss_v3 {
let current = max_v3.unwrap_or(0.0);
if v3.base_score > current {
max_v3 = Some(v3.base_score);
}
}
if let Some(v4) = &metric.content.cvss_v4 {
let current = max_v4.unwrap_or(0.0);
if v4.base_score > current {
max_v4 = Some(v4.base_score);
}
}
}
}
Self {
tracking_id: doc.document.tracking.id.clone(),
title: doc.document.title.clone(),
category: doc.document.category.clone(),
csaf_version: doc.document.csaf_version.clone(),
status: doc.document.tracking.status.clone(),
current_release_date: doc.document.tracking.current_release_date.clone(),
initial_release_date: doc.document.tracking.initial_release_date.clone(),
version: doc.document.tracking.version.clone(),
publisher_name: doc.document.publisher.name.clone(),
tlp_label,
vulnerability_count: doc.vulnerabilities.len(),
max_cvss_v3_score: max_v3,
max_cvss_v4_score: max_v4,
}
}
}
#[cfg(test)]
#[allow(clippy::cognitive_complexity)]
mod tests {
use super::*;
#[test]
fn test_deserialize_csaf_security_advisory() {
let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
let doc: CsafDocument =
serde_json::from_str(json).expect("Failed to deserialize CSAF document");
assert_eq!(doc.document.category, "csaf_security_advisory");
assert_eq!(doc.document.csaf_version, "2.1");
assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-003");
assert_eq!(doc.document.tracking.status, "final");
assert_eq!(
doc.document.publisher.name,
"ndaal Gesellschaft f\u{fc}r Sicherheit in der Informationstechnik mbH & Co KG"
);
assert_eq!(doc.vulnerabilities.len(), 1);
let vuln = &doc.vulnerabilities[0];
assert_eq!(vuln.cve.as_deref(), Some("CVE-0000-0001"));
assert_eq!(vuln.metrics.len(), 1);
let metric = &vuln.metrics[0];
let v3 = metric.content.cvss_v3.as_ref().expect("CVSS v3 missing");
assert!((v3.base_score - 9.8).abs() < f64::EPSILON);
assert_eq!(v3.base_severity, "CRITICAL");
let v4 = metric.content.cvss_v4.as_ref().expect("CVSS v4 missing");
assert!((v4.base_score - 9.3).abs() < f64::EPSILON);
}
#[test]
fn test_deserialize_csaf_vex() {
let json = include_str!("../../../test/csaf/2026/015/ndaal-sa-2026-015.json");
let doc: CsafDocument =
serde_json::from_str(json).expect("Failed to deserialize VEX document");
assert_eq!(doc.document.category, "csaf_vex");
assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-015");
}
#[test]
fn test_deserialize_all_test_files() {
let test_dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
let entry = entry.expect("dir entry error");
if !entry.file_type().expect("file type error").is_dir() {
continue;
}
for file in std::fs::read_dir(entry.path()).expect("subdir read error") {
let file = file.expect("file entry error");
let path = file.path();
if path.extension().is_some_and(|e| e == "json") {
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
let result: Result<CsafDocument, _> = serde_json::from_str(&content);
assert!(
result.is_ok(),
"Failed to parse {}: {:?}",
path.display(),
result.err()
);
}
}
}
}
#[test]
fn test_csaf_meta_extraction() {
let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
let meta = CsafMeta::from_document(&doc);
assert_eq!(meta.tracking_id, "ndaal-sa-2026-003");
assert_eq!(meta.category, "csaf_security_advisory");
assert_eq!(meta.vulnerability_count, 1);
assert!(
meta.max_cvss_v3_score
.is_some_and(|s| (s - 9.8).abs() < f64::EPSILON)
);
assert!(
meta.max_cvss_v4_score
.is_some_and(|s| (s - 9.3).abs() < f64::EPSILON)
);
assert_eq!(meta.tlp_label.as_deref(), Some("CLEAR"));
}
#[test]
fn test_all_product_ids() {
let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
let ids = doc.all_product_ids();
assert!(ids.contains(&"CSAFPID-001".to_owned()));
assert!(ids.contains(&"CSAFPID-002".to_owned()));
}
#[test]
fn test_roundtrip_serialization() {
let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
let serialized = serde_json::to_string_pretty(&doc).expect("serialize error");
let doc2: CsafDocument = serde_json::from_str(&serialized).expect("re-parse error");
assert_eq!(doc, doc2);
}
}