1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct CsafDocument {
19 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
21 pub schema: Option<String>,
22
23 pub document: Document,
25
26 pub product_tree: ProductTree,
28
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub vulnerabilities: Vec<Vulnerability>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct Document {
41 pub category: String,
44
45 pub csaf_version: String,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub distribution: Option<Distribution>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub lang: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub notes: Vec<Note>,
59
60 pub publisher: Publisher,
62
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub references: Vec<Reference>,
66
67 pub title: String,
69
70 pub tracking: Tracking,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct Distribution {
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub tlp: Option<Tlp>,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub text: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct Tlp {
91 pub label: String,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub url: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct Note {
102 pub category: String,
105
106 pub text: String,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub title: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub audience: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct Publisher {
121 pub category: String,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub contact_details: Option<String>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub issuing_authority: Option<String>,
132
133 pub name: String,
135
136 pub namespace: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct Reference {
143 pub category: String,
145
146 pub summary: String,
148
149 pub url: String,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct Tracking {
160 pub current_release_date: String,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub generator: Option<Generator>,
166
167 pub id: String,
169
170 pub initial_release_date: String,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub revision_history: Vec<Revision>,
176
177 pub status: String,
179
180 pub version: String,
182
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub aliases: Vec<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct Generator {
191 pub engine: Engine,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub date: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct Engine {
202 pub name: String,
204
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub version: Option<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212pub struct Revision {
213 pub date: String,
215
216 pub number: String,
218
219 pub summary: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct ProductTree {
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub branches: Vec<Branch>,
233
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub full_product_names: Vec<FullProductName>,
237
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub product_groups: Vec<ProductGroup>,
241
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub relationships: Vec<Relationship>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249pub struct Branch {
250 pub category: String,
254
255 pub name: String,
257
258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub branches: Vec<Self>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub product: Option<FullProductName>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct FullProductName {
270 pub name: String,
272
273 pub product_id: String,
275
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub cpe: Option<String>,
279
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub purl: Option<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct ProductGroup {
288 pub group_id: String,
290
291 pub product_ids: Vec<String>,
293
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub summary: Option<String>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub struct Relationship {
302 pub category: String,
304
305 pub full_product_name: FullProductName,
307
308 pub product_reference: String,
310
311 pub relates_to_product_reference: String,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
321pub struct Vulnerability {
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub cve: Option<String>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub cwe: Option<Cwe>,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub discovery_date: Option<String>,
333
334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub ids: Vec<VulnerabilityId>,
337
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 pub notes: Vec<Note>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub product_status: Option<ProductStatus>,
345
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub remediations: Vec<Remediation>,
349
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub metrics: Vec<Metric>,
353
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub threats: Vec<Threat>,
357
358 #[serde(skip_serializing_if = "Option::is_none")]
360 pub title: Option<String>,
361
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub release_date: Option<String>,
365
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
368 pub references: Vec<Reference>,
369
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub involvements: Vec<Involvement>,
373
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
376 pub flags: Vec<Flag>,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
381pub struct Cwe {
382 pub id: String,
384
385 pub name: String,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
391pub struct VulnerabilityId {
392 pub system_name: String,
394
395 pub text: String,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
401pub struct ProductStatus {
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
404 pub known_affected: Vec<String>,
405
406 #[serde(default, skip_serializing_if = "Vec::is_empty")]
408 pub known_not_affected: Vec<String>,
409
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
412 pub fixed: Vec<String>,
413
414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub under_investigation: Vec<String>,
417
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
420 pub first_affected: Vec<String>,
421
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub first_fixed: Vec<String>,
425
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub last_affected: Vec<String>,
429
430 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub recommended: Vec<String>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
437pub struct Remediation {
438 pub category: String,
441
442 pub details: String,
444
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 pub product_ids: Vec<String>,
448
449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
451 pub group_ids: Vec<String>,
452
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub url: Option<String>,
456
457 #[serde(skip_serializing_if = "Option::is_none")]
459 pub date: Option<String>,
460
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub restart_required: Option<RestartRequired>,
464
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 pub entitlements: Vec<String>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
472pub struct RestartRequired {
473 pub category: String,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub details: Option<String>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
483pub struct Metric {
484 pub content: MetricContent,
486
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub products: Vec<String>,
490
491 #[serde(skip_serializing_if = "Option::is_none")]
493 pub source: Option<String>,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
498pub struct MetricContent {
499 #[serde(skip_serializing_if = "Option::is_none")]
501 pub cvss_v3: Option<CvssV3>,
502
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub cvss_v4: Option<CvssV4>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
510pub struct CvssV3 {
511 pub version: String,
513
514 #[serde(rename = "vectorString")]
516 pub vector_string: String,
517
518 #[serde(rename = "baseScore")]
520 pub base_score: f64,
521
522 #[serde(rename = "baseSeverity")]
524 pub base_severity: String,
525
526 #[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
528 pub attack_vector: Option<String>,
529
530 #[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
532 pub attack_complexity: Option<String>,
533
534 #[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
536 pub privileges_required: Option<String>,
537
538 #[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
540 pub user_interaction: Option<String>,
541
542 #[serde(skip_serializing_if = "Option::is_none")]
544 pub scope: Option<String>,
545
546 #[serde(
548 rename = "confidentialityImpact",
549 skip_serializing_if = "Option::is_none"
550 )]
551 pub confidentiality_impact: Option<String>,
552
553 #[serde(rename = "integrityImpact", skip_serializing_if = "Option::is_none")]
555 pub integrity_impact: Option<String>,
556
557 #[serde(rename = "availabilityImpact", skip_serializing_if = "Option::is_none")]
559 pub availability_impact: Option<String>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
564pub struct CvssV4 {
565 pub version: String,
567
568 #[serde(rename = "vectorString")]
570 pub vector_string: String,
571
572 #[serde(rename = "baseScore")]
574 pub base_score: f64,
575
576 #[serde(rename = "baseSeverity")]
578 pub base_severity: String,
579
580 #[serde(rename = "attackVector", skip_serializing_if = "Option::is_none")]
582 pub attack_vector: Option<String>,
583
584 #[serde(rename = "attackComplexity", skip_serializing_if = "Option::is_none")]
586 pub attack_complexity: Option<String>,
587
588 #[serde(rename = "attackRequirements", skip_serializing_if = "Option::is_none")]
590 pub attack_requirements: Option<String>,
591
592 #[serde(rename = "privilegesRequired", skip_serializing_if = "Option::is_none")]
594 pub privileges_required: Option<String>,
595
596 #[serde(rename = "userInteraction", skip_serializing_if = "Option::is_none")]
598 pub user_interaction: Option<String>,
599
600 #[serde(
602 rename = "confidentialityImpact",
603 skip_serializing_if = "Option::is_none"
604 )]
605 pub confidentiality_impact: Option<String>,
606
607 #[serde(rename = "integrityImpact", skip_serializing_if = "Option::is_none")]
609 pub integrity_impact: Option<String>,
610
611 #[serde(rename = "availabilityImpact", skip_serializing_if = "Option::is_none")]
613 pub availability_impact: Option<String>,
614
615 #[serde(
617 rename = "subConfidentialityImpact",
618 skip_serializing_if = "Option::is_none"
619 )]
620 pub sub_confidentiality_impact: Option<String>,
621
622 #[serde(rename = "subIntegrityImpact", skip_serializing_if = "Option::is_none")]
624 pub sub_integrity_impact: Option<String>,
625
626 #[serde(
628 rename = "subAvailabilityImpact",
629 skip_serializing_if = "Option::is_none"
630 )]
631 pub sub_availability_impact: Option<String>,
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636pub struct Threat {
637 pub category: String,
639
640 pub details: String,
642
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
645 pub product_ids: Vec<String>,
646
647 #[serde(default, skip_serializing_if = "Vec::is_empty")]
649 pub group_ids: Vec<String>,
650
651 #[serde(skip_serializing_if = "Option::is_none")]
653 pub date: Option<String>,
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
658pub struct Involvement {
659 pub party: String,
661
662 pub status: String,
664
665 #[serde(skip_serializing_if = "Option::is_none")]
667 pub summary: Option<String>,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct Flag {
673 pub label: String,
675
676 #[serde(skip_serializing_if = "Option::is_none")]
678 pub date: Option<String>,
679
680 #[serde(default, skip_serializing_if = "Vec::is_empty")]
682 pub product_ids: Vec<String>,
683
684 #[serde(default, skip_serializing_if = "Vec::is_empty")]
686 pub group_ids: Vec<String>,
687}
688
689impl CsafDocument {
694 #[must_use]
696 pub fn all_product_ids(&self) -> Vec<String> {
697 let mut ids = Vec::new();
698 collect_product_ids_from_branches(&self.product_tree.branches, &mut ids);
699 for fpn in &self.product_tree.full_product_names {
700 ids.push(fpn.product_id.clone());
701 }
702 ids
703 }
704
705 #[must_use]
707 pub fn tracking_id(&self) -> &str {
708 &self.document.tracking.id
709 }
710
711 #[must_use]
713 pub fn csaf_version(&self) -> &str {
714 &self.document.csaf_version
715 }
716
717 #[must_use]
719 pub fn category(&self) -> &str {
720 &self.document.category
721 }
722}
723
724fn collect_product_ids_from_branches(branches: &[Branch], ids: &mut Vec<String>) {
726 for branch in branches {
727 if let Some(product) = &branch.product {
728 ids.push(product.product_id.clone());
729 }
730 collect_product_ids_from_branches(&branch.branches, ids);
731 }
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct CsafMeta {
741 pub tracking_id: String,
743
744 pub title: String,
746
747 pub category: String,
749
750 pub csaf_version: String,
752
753 pub status: String,
755
756 pub current_release_date: String,
758
759 pub initial_release_date: String,
761
762 pub version: String,
764
765 pub publisher_name: String,
767
768 pub tlp_label: Option<String>,
770
771 pub vulnerability_count: usize,
773
774 pub max_cvss_v3_score: Option<f64>,
776
777 pub max_cvss_v4_score: Option<f64>,
779}
780
781impl CsafMeta {
782 #[must_use]
784 pub fn from_document(doc: &CsafDocument) -> Self {
785 let tlp_label = doc
786 .document
787 .distribution
788 .as_ref()
789 .and_then(|d| d.tlp.as_ref())
790 .map(|t| t.label.clone());
791
792 let mut max_v3: Option<f64> = None;
793 let mut max_v4: Option<f64> = None;
794
795 for vuln in &doc.vulnerabilities {
796 for metric in &vuln.metrics {
797 if let Some(v3) = &metric.content.cvss_v3 {
798 let current = max_v3.unwrap_or(0.0);
799 if v3.base_score > current {
800 max_v3 = Some(v3.base_score);
801 }
802 }
803 if let Some(v4) = &metric.content.cvss_v4 {
804 let current = max_v4.unwrap_or(0.0);
805 if v4.base_score > current {
806 max_v4 = Some(v4.base_score);
807 }
808 }
809 }
810 }
811
812 Self {
813 tracking_id: doc.document.tracking.id.clone(),
814 title: doc.document.title.clone(),
815 category: doc.document.category.clone(),
816 csaf_version: doc.document.csaf_version.clone(),
817 status: doc.document.tracking.status.clone(),
818 current_release_date: doc.document.tracking.current_release_date.clone(),
819 initial_release_date: doc.document.tracking.initial_release_date.clone(),
820 version: doc.document.tracking.version.clone(),
821 publisher_name: doc.document.publisher.name.clone(),
822 tlp_label,
823 vulnerability_count: doc.vulnerabilities.len(),
824 max_cvss_v3_score: max_v3,
825 max_cvss_v4_score: max_v4,
826 }
827 }
828}
829
830#[cfg(test)]
831#[allow(clippy::cognitive_complexity)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn test_deserialize_csaf_security_advisory() {
840 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
841 let doc: CsafDocument =
842 serde_json::from_str(json).expect("Failed to deserialize CSAF document");
843
844 assert_eq!(doc.document.category, "csaf_security_advisory");
845 assert_eq!(doc.document.csaf_version, "2.1");
846 assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-003");
847 assert_eq!(doc.document.tracking.status, "final");
848 assert_eq!(
849 doc.document.publisher.name,
850 "ndaal Gesellschaft f\u{fc}r Sicherheit in der Informationstechnik mbH & Co KG"
851 );
852 assert_eq!(doc.vulnerabilities.len(), 1);
853
854 let vuln = &doc.vulnerabilities[0];
855 assert_eq!(vuln.cve.as_deref(), Some("CVE-0000-0001"));
856 assert_eq!(vuln.metrics.len(), 1);
857
858 let metric = &vuln.metrics[0];
859 let v3 = metric.content.cvss_v3.as_ref().expect("CVSS v3 missing");
860 assert!((v3.base_score - 9.8).abs() < f64::EPSILON);
861 assert_eq!(v3.base_severity, "CRITICAL");
862
863 let v4 = metric.content.cvss_v4.as_ref().expect("CVSS v4 missing");
864 assert!((v4.base_score - 9.3).abs() < f64::EPSILON);
865 }
866
867 #[test]
868 fn test_deserialize_csaf_vex() {
869 let json = include_str!("../../../test/csaf/2026/015/ndaal-sa-2026-015.json");
870 let doc: CsafDocument =
871 serde_json::from_str(json).expect("Failed to deserialize VEX document");
872
873 assert_eq!(doc.document.category, "csaf_vex");
874 assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-015");
875 }
876
877 #[test]
878 fn test_deserialize_all_test_files() {
879 let test_dir =
880 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
881
882 for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
883 let entry = entry.expect("dir entry error");
884 if !entry.file_type().expect("file type error").is_dir() {
885 continue;
886 }
887 for file in std::fs::read_dir(entry.path()).expect("subdir read error") {
888 let file = file.expect("file entry error");
889 let path = file.path();
890 if path.extension().is_some_and(|e| e == "json") {
891 let content = std::fs::read_to_string(&path)
892 .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
893 let result: Result<CsafDocument, _> = serde_json::from_str(&content);
894 assert!(
895 result.is_ok(),
896 "Failed to parse {}: {:?}",
897 path.display(),
898 result.err()
899 );
900 }
901 }
902 }
903 }
904
905 #[test]
906 fn test_csaf_meta_extraction() {
907 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
908 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
909 let meta = CsafMeta::from_document(&doc);
910
911 assert_eq!(meta.tracking_id, "ndaal-sa-2026-003");
912 assert_eq!(meta.category, "csaf_security_advisory");
913 assert_eq!(meta.vulnerability_count, 1);
914 assert!(
915 meta.max_cvss_v3_score
916 .is_some_and(|s| (s - 9.8).abs() < f64::EPSILON)
917 );
918 assert!(
919 meta.max_cvss_v4_score
920 .is_some_and(|s| (s - 9.3).abs() < f64::EPSILON)
921 );
922 assert_eq!(meta.tlp_label.as_deref(), Some("CLEAR"));
923 }
924
925 #[test]
926 fn test_all_product_ids() {
927 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
928 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
929 let ids = doc.all_product_ids();
930
931 assert!(ids.contains(&"CSAFPID-001".to_owned()));
932 assert!(ids.contains(&"CSAFPID-002".to_owned()));
933 }
934
935 #[test]
936 fn test_roundtrip_serialization() {
937 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
938 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
939 let serialized = serde_json::to_string_pretty(&doc).expect("serialize error");
940 let doc2: CsafDocument = serde_json::from_str(&serialized).expect("re-parse error");
941 assert_eq!(doc, doc2);
942 }
943}