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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
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, Eq)]
381pub struct Cwe {
382 pub id: String,
384
385 pub name: String,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391pub struct VulnerabilityId {
392 pub system_name: String,
394
395 pub text: String,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
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, Eq)]
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, Eq)]
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 = "vulnConfidentialityImpact",
603 skip_serializing_if = "Option::is_none"
604 )]
605 pub confidentiality_impact: Option<String>,
606
607 #[serde(
609 rename = "vulnIntegrityImpact",
610 skip_serializing_if = "Option::is_none"
611 )]
612 pub integrity_impact: Option<String>,
613
614 #[serde(
616 rename = "vulnAvailabilityImpact",
617 skip_serializing_if = "Option::is_none"
618 )]
619 pub availability_impact: Option<String>,
620
621 #[serde(
623 rename = "subConfidentialityImpact",
624 skip_serializing_if = "Option::is_none"
625 )]
626 pub sub_confidentiality_impact: Option<String>,
627
628 #[serde(rename = "subIntegrityImpact", skip_serializing_if = "Option::is_none")]
630 pub sub_integrity_impact: Option<String>,
631
632 #[serde(
634 rename = "subAvailabilityImpact",
635 skip_serializing_if = "Option::is_none"
636 )]
637 pub sub_availability_impact: Option<String>,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
642pub struct Threat {
643 pub category: String,
645
646 pub details: String,
648
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
651 pub product_ids: Vec<String>,
652
653 #[serde(default, skip_serializing_if = "Vec::is_empty")]
655 pub group_ids: Vec<String>,
656
657 #[serde(skip_serializing_if = "Option::is_none")]
659 pub date: Option<String>,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
664pub struct Involvement {
665 pub party: String,
667
668 pub status: String,
670
671 #[serde(skip_serializing_if = "Option::is_none")]
673 pub summary: Option<String>,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
678pub struct Flag {
679 pub label: String,
681
682 #[serde(skip_serializing_if = "Option::is_none")]
684 pub date: Option<String>,
685
686 #[serde(default, skip_serializing_if = "Vec::is_empty")]
688 pub product_ids: Vec<String>,
689
690 #[serde(default, skip_serializing_if = "Vec::is_empty")]
692 pub group_ids: Vec<String>,
693}
694
695impl CsafDocument {
700 #[must_use]
702 pub fn all_product_ids(&self) -> Vec<String> {
703 let mut ids = Vec::new();
704 collect_product_ids_from_branches(&self.product_tree.branches, &mut ids);
705 for fpn in &self.product_tree.full_product_names {
706 ids.push(fpn.product_id.clone());
707 }
708 ids
709 }
710
711 #[must_use]
713 pub fn tracking_id(&self) -> &str {
714 &self.document.tracking.id
715 }
716
717 #[must_use]
719 pub fn csaf_version(&self) -> &str {
720 &self.document.csaf_version
721 }
722
723 #[must_use]
725 pub fn category(&self) -> &str {
726 &self.document.category
727 }
728}
729
730fn collect_product_ids_from_branches(branches: &[Branch], ids: &mut Vec<String>) {
732 for branch in branches {
733 if let Some(product) = &branch.product {
734 ids.push(product.product_id.clone());
735 }
736 collect_product_ids_from_branches(&branch.branches, ids);
737 }
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
746pub struct CsafMeta {
747 pub tracking_id: String,
749
750 pub title: String,
752
753 pub category: String,
755
756 pub csaf_version: String,
758
759 pub status: String,
761
762 pub current_release_date: String,
764
765 pub initial_release_date: String,
767
768 pub version: String,
770
771 pub publisher_name: String,
773
774 pub tlp_label: Option<String>,
776
777 pub vulnerability_count: usize,
779
780 pub max_cvss_v3_score: Option<f64>,
782
783 pub max_cvss_v4_score: Option<f64>,
785}
786
787impl CsafMeta {
788 #[must_use]
790 pub fn from_document(doc: &CsafDocument) -> Self {
791 let tlp_label = doc
792 .document
793 .distribution
794 .as_ref()
795 .and_then(|d| d.tlp.as_ref())
796 .map(|t| t.label.clone());
797
798 let mut max_v3: Option<f64> = None;
799 let mut max_v4: Option<f64> = None;
800
801 for vuln in &doc.vulnerabilities {
802 for metric in &vuln.metrics {
803 if let Some(v3) = &metric.content.cvss_v3 {
804 let current = max_v3.unwrap_or(0.0);
805 if v3.base_score > current {
806 max_v3 = Some(v3.base_score);
807 }
808 }
809 if let Some(v4) = &metric.content.cvss_v4 {
810 let current = max_v4.unwrap_or(0.0);
811 if v4.base_score > current {
812 max_v4 = Some(v4.base_score);
813 }
814 }
815 }
816 }
817
818 Self {
819 tracking_id: doc.document.tracking.id.clone(),
820 title: doc.document.title.clone(),
821 category: doc.document.category.clone(),
822 csaf_version: doc.document.csaf_version.clone(),
823 status: doc.document.tracking.status.clone(),
824 current_release_date: doc.document.tracking.current_release_date.clone(),
825 initial_release_date: doc.document.tracking.initial_release_date.clone(),
826 version: doc.document.tracking.version.clone(),
827 publisher_name: doc.document.publisher.name.clone(),
828 tlp_label,
829 vulnerability_count: doc.vulnerabilities.len(),
830 max_cvss_v3_score: max_v3,
831 max_cvss_v4_score: max_v4,
832 }
833 }
834}
835
836#[cfg(test)]
837#[allow(clippy::cognitive_complexity)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn test_deserialize_csaf_security_advisory() {
846 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
847 let doc: CsafDocument =
848 serde_json::from_str(json).expect("Failed to deserialize CSAF document");
849
850 assert_eq!(doc.document.category, "csaf_security_advisory");
851 assert_eq!(doc.document.csaf_version, "2.1");
852 assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-003");
853 assert_eq!(doc.document.tracking.status, "final");
854 assert_eq!(
855 doc.document.publisher.name,
856 "ndaal Gesellschaft f\u{fc}r Sicherheit in der Informationstechnik mbH & Co KG"
857 );
858 assert_eq!(doc.vulnerabilities.len(), 1);
859
860 let vuln = &doc.vulnerabilities[0];
861 assert_eq!(vuln.cve.as_deref(), Some("CVE-0000-0001"));
862 assert_eq!(vuln.metrics.len(), 1);
863
864 let metric = &vuln.metrics[0];
865 let v3 = metric.content.cvss_v3.as_ref().expect("CVSS v3 missing");
866 assert!((v3.base_score - 9.8).abs() < f64::EPSILON);
867 assert_eq!(v3.base_severity, "CRITICAL");
868
869 let v4 = metric.content.cvss_v4.as_ref().expect("CVSS v4 missing");
870 assert!((v4.base_score - 9.3).abs() < f64::EPSILON);
871 }
872
873 #[test]
874 fn test_deserialize_csaf_vex() {
875 let json = include_str!("../../../test/csaf/2026/015/ndaal-sa-2026-015.json");
876 let doc: CsafDocument =
877 serde_json::from_str(json).expect("Failed to deserialize VEX document");
878
879 assert_eq!(doc.document.category, "csaf_vex");
880 assert_eq!(doc.document.tracking.id, "ndaal-sa-2026-015");
881 }
882
883 #[test]
884 fn test_deserialize_all_test_files() {
885 let test_dir =
886 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
887
888 for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
889 let entry = entry.expect("dir entry error");
890 if !entry.file_type().expect("file type error").is_dir() {
891 continue;
892 }
893 for file in std::fs::read_dir(entry.path()).expect("subdir read error") {
894 let file = file.expect("file entry error");
895 let path = file.path();
896 if path.extension().is_some_and(|e| e == "json") {
897 let content = std::fs::read_to_string(&path)
898 .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display()));
899 let result: Result<CsafDocument, _> = serde_json::from_str(&content);
900 assert!(
901 result.is_ok(),
902 "Failed to parse {}: {:?}",
903 path.display(),
904 result.err()
905 );
906 }
907 }
908 }
909 }
910
911 #[test]
912 fn test_csaf_meta_extraction() {
913 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
914 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
915 let meta = CsafMeta::from_document(&doc);
916
917 assert_eq!(meta.tracking_id, "ndaal-sa-2026-003");
918 assert_eq!(meta.category, "csaf_security_advisory");
919 assert_eq!(meta.vulnerability_count, 1);
920 assert!(
921 meta.max_cvss_v3_score
922 .is_some_and(|s| (s - 9.8).abs() < f64::EPSILON)
923 );
924 assert!(
925 meta.max_cvss_v4_score
926 .is_some_and(|s| (s - 9.3).abs() < f64::EPSILON)
927 );
928 assert_eq!(meta.tlp_label.as_deref(), Some("CLEAR"));
929 }
930
931 #[test]
932 fn test_all_product_ids() {
933 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
934 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
935 let ids = doc.all_product_ids();
936
937 assert!(ids.contains(&"CSAFPID-001".to_owned()));
938 assert!(ids.contains(&"CSAFPID-002".to_owned()));
939 }
940
941 #[test]
942 fn test_roundtrip_serialization() {
943 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
944 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
945 let serialized = serde_json::to_string_pretty(&doc).expect("serialize error");
946 let doc2: CsafDocument = serde_json::from_str(&serialized).expect("re-parse error");
947 assert_eq!(doc, doc2);
948 }
949}