Skip to main content

sbom_tools/reports/
csaf.rs

1//! CSAF v2.0 (ISO/IEC 20153:2025) advisory emitter.
2//!
3//! Produces a valid CSAF VEX document from an SBOM whose components carry
4//! `VexStatus` values (typically applied by `VexEnricher`). Closes the
5//! round-trip with [`crate::enrichment::vex::csaf`]: ingest a CSAF
6//! advisory → enrich SBOM → emit CSAF that yields the same VEX states
7//! when re-ingested.
8//!
9//! Mapping internal model → CSAF v2.0:
10//!
11//! - One product entry per SBOM component that carries a PURL (resolved
12//!   to `product_tree.full_product_names[].product_identification_helper.purl`).
13//! - One vulnerability entry per CVE/identifier; `product_status` lists
14//!   reflect the corresponding `VexState`:
15//!     - `Affected` → `known_affected`
16//!     - `NotAffected` → `known_not_affected`
17//!     - `Fixed` → `fixed`
18//!     - `UnderInvestigation` → `under_investigation`
19//!
20//! See <https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html>.
21
22use crate::model::{NormalizedSbom, VexState};
23use serde::Serialize;
24use std::collections::BTreeMap;
25
26use super::ReportError;
27
28/// Configuration for the CSAF emitter. Operators that don't supply a
29/// publisher / title fall back to sbom-tools defaults so the output is
30/// still a valid CSAF document.
31#[derive(Debug, Clone, Default)]
32pub struct CsafEmitOptions {
33    pub document_id: Option<String>,
34    pub publisher_name: Option<String>,
35    pub publisher_namespace: Option<String>,
36    pub publisher_category: Option<String>,
37    pub title: Option<String>,
38    pub category: Option<String>,
39}
40
41/// Emit a CSAF v2.0 VEX document from `sbom` as pretty-printed JSON.
42pub fn emit_csaf(sbom: &NormalizedSbom, options: &CsafEmitOptions) -> Result<String, ReportError> {
43    let doc = build_csaf_document(sbom, options);
44    serde_json::to_string_pretty(&doc).map_err(|e| ReportError::SerializationError(e.to_string()))
45}
46
47fn build_csaf_document(sbom: &NormalizedSbom, opt: &CsafEmitOptions) -> CsafDocOut {
48    let now_rfc3339 = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
49
50    // Build product_tree and a (component_id) → product_id index.
51    let mut product_index: BTreeMap<String, String> = BTreeMap::new();
52    let mut full_product_names: Vec<CsafProductOut> = Vec::new();
53
54    let mut counter: usize = 0;
55    for (id, comp) in &sbom.components {
56        let Some(purl) = comp.identifiers.purl.as_ref() else {
57            continue;
58        };
59        counter += 1;
60        let pid = format!("CSAFPID-{counter:04}");
61        let display = match comp.version.as_deref() {
62            Some(v) => format!("{} {}", comp.name, v),
63            None => comp.name.clone(),
64        };
65        full_product_names.push(CsafProductOut {
66            product_id: pid.clone(),
67            name: display,
68            product_identification_helper: Some(CsafProductHelperOut {
69                purl: Some(purl.clone()),
70            }),
71        });
72        product_index.insert(id.value().to_string(), pid);
73    }
74
75    // Group vulnerability statuses by (vuln_id) → buckets.
76    let mut buckets: BTreeMap<String, ProductStatusBuckets> = BTreeMap::new();
77    for (id, comp) in &sbom.components {
78        let Some(pid) = product_index.get(id.value()) else {
79            continue;
80        };
81        for vuln in &comp.vulnerabilities {
82            let Some(vex) = vuln.vex_status.as_ref() else {
83                continue;
84            };
85            let entry = buckets.entry(vuln.id.clone()).or_default();
86            match vex.status {
87                VexState::Affected => entry.known_affected.push(pid.clone()),
88                VexState::NotAffected => entry.known_not_affected.push(pid.clone()),
89                VexState::Fixed => entry.fixed.push(pid.clone()),
90                VexState::UnderInvestigation => entry.under_investigation.push(pid.clone()),
91            }
92        }
93    }
94
95    let vulnerabilities: Vec<CsafVulnOut> = buckets
96        .into_iter()
97        .map(|(vuln_id, status)| {
98            let (cve, ids) = if vuln_id.starts_with("CVE-") {
99                (Some(vuln_id), Vec::new())
100            } else {
101                (
102                    None,
103                    vec![CsafVulnIdOut {
104                        system_name: identifier_system(&vuln_id).to_string(),
105                        text: vuln_id,
106                    }],
107                )
108            };
109            CsafVulnOut {
110                cve,
111                ids,
112                product_status: status.into_serializable(),
113            }
114        })
115        .collect();
116
117    let publisher_name = opt
118        .publisher_name
119        .clone()
120        .or_else(|| {
121            sbom.document
122                .creators
123                .iter()
124                .find(|c| matches!(c.creator_type, crate::model::CreatorType::Organization))
125                .map(|c| c.name.clone())
126        })
127        .unwrap_or_else(|| "sbom-tools".to_string());
128
129    let title = opt.title.clone().unwrap_or_else(|| {
130        let primary = sbom
131            .document
132            .name
133            .clone()
134            .or_else(|| {
135                sbom.primary_component_id
136                    .as_ref()
137                    .map(|c| c.value().to_string())
138            })
139            .unwrap_or_else(|| "SBOM".to_string());
140        format!("VEX advisory for {primary}")
141    });
142
143    let document_id = opt.document_id.clone().unwrap_or_else(|| {
144        format!(
145            "sbom-tools-vex-{}",
146            chrono::Utc::now().format("%Y%m%d%H%M%S")
147        )
148    });
149
150    CsafDocOut {
151        document: CsafHeaderOut {
152            csaf_version: "2.0".to_string(),
153            category: opt
154                .category
155                .clone()
156                .unwrap_or_else(|| "csaf_vex".to_string()),
157            publisher: CsafPublisherOut {
158                category: opt
159                    .publisher_category
160                    .clone()
161                    .unwrap_or_else(|| "vendor".to_string()),
162                name: publisher_name,
163                namespace: opt
164                    .publisher_namespace
165                    .clone()
166                    .unwrap_or_else(|| "https://example.invalid".to_string()),
167            },
168            title,
169            tracking: CsafTrackingOut {
170                id: document_id,
171                version: "1".to_string(),
172                status: "final".to_string(),
173                initial_release_date: now_rfc3339.clone(),
174                current_release_date: now_rfc3339.clone(),
175                revision_history: vec![CsafRevisionOut {
176                    number: "1".to_string(),
177                    date: now_rfc3339.clone(),
178                    summary: "Initial publication".to_string(),
179                }],
180                generator: CsafGeneratorOut {
181                    engine: CsafEngineOut {
182                        name: "sbom-tools".to_string(),
183                        version: env!("CARGO_PKG_VERSION").to_string(),
184                    },
185                },
186            },
187        },
188        product_tree: CsafProductTreeOut { full_product_names },
189        vulnerabilities,
190    }
191}
192
193fn identifier_system(id: &str) -> &'static str {
194    if id.starts_with("GHSA-") {
195        "GHSA"
196    } else if id.starts_with("RUSTSEC-") {
197        "RUSTSEC"
198    } else if id.starts_with("OSV-") {
199        "OSV"
200    } else if id.starts_with("CVE-") {
201        "CVE"
202    } else {
203        "vendor"
204    }
205}
206
207#[derive(Default)]
208struct ProductStatusBuckets {
209    known_affected: Vec<String>,
210    known_not_affected: Vec<String>,
211    fixed: Vec<String>,
212    under_investigation: Vec<String>,
213}
214
215impl ProductStatusBuckets {
216    fn into_serializable(self) -> CsafProductStatusOut {
217        CsafProductStatusOut {
218            known_affected: self.known_affected,
219            known_not_affected: self.known_not_affected,
220            fixed: self.fixed,
221            under_investigation: self.under_investigation,
222        }
223    }
224}
225
226// ============================================================================
227// CSAF v2.0 serde structs (output side)
228// ============================================================================
229
230#[derive(Serialize)]
231struct CsafDocOut {
232    document: CsafHeaderOut,
233    product_tree: CsafProductTreeOut,
234    #[serde(skip_serializing_if = "Vec::is_empty")]
235    vulnerabilities: Vec<CsafVulnOut>,
236}
237
238#[derive(Serialize)]
239struct CsafHeaderOut {
240    category: String,
241    csaf_version: String,
242    publisher: CsafPublisherOut,
243    title: String,
244    tracking: CsafTrackingOut,
245}
246
247#[derive(Serialize)]
248struct CsafPublisherOut {
249    category: String,
250    name: String,
251    namespace: String,
252}
253
254#[derive(Serialize)]
255struct CsafTrackingOut {
256    id: String,
257    initial_release_date: String,
258    current_release_date: String,
259    version: String,
260    status: String,
261    revision_history: Vec<CsafRevisionOut>,
262    generator: CsafGeneratorOut,
263}
264
265#[derive(Serialize)]
266struct CsafRevisionOut {
267    number: String,
268    date: String,
269    summary: String,
270}
271
272#[derive(Serialize)]
273struct CsafGeneratorOut {
274    engine: CsafEngineOut,
275}
276
277#[derive(Serialize)]
278struct CsafEngineOut {
279    name: String,
280    version: String,
281}
282
283#[derive(Serialize)]
284struct CsafProductTreeOut {
285    #[serde(skip_serializing_if = "Vec::is_empty")]
286    full_product_names: Vec<CsafProductOut>,
287}
288
289#[derive(Serialize)]
290struct CsafProductOut {
291    product_id: String,
292    name: String,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    product_identification_helper: Option<CsafProductHelperOut>,
295}
296
297#[derive(Serialize)]
298struct CsafProductHelperOut {
299    #[serde(skip_serializing_if = "Option::is_none")]
300    purl: Option<String>,
301}
302
303#[derive(Serialize)]
304struct CsafVulnOut {
305    #[serde(skip_serializing_if = "Option::is_none")]
306    cve: Option<String>,
307    #[serde(skip_serializing_if = "Vec::is_empty")]
308    ids: Vec<CsafVulnIdOut>,
309    product_status: CsafProductStatusOut,
310}
311
312#[derive(Serialize)]
313struct CsafVulnIdOut {
314    system_name: String,
315    text: String,
316}
317
318#[derive(Serialize)]
319struct CsafProductStatusOut {
320    #[serde(skip_serializing_if = "Vec::is_empty")]
321    known_affected: Vec<String>,
322    #[serde(skip_serializing_if = "Vec::is_empty")]
323    known_not_affected: Vec<String>,
324    #[serde(skip_serializing_if = "Vec::is_empty")]
325    fixed: Vec<String>,
326    #[serde(skip_serializing_if = "Vec::is_empty")]
327    under_investigation: Vec<String>,
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::model::{
334        Component, NormalizedSbom, VexStatus, VulnerabilityRef, VulnerabilitySource,
335    };
336
337    fn sbom_with(purl: &str, name: &str, vuln: &str, state: VexState) -> NormalizedSbom {
338        let mut sbom = NormalizedSbom::default();
339        let mut c = Component::new(name.to_string(), name.to_string());
340        c.identifiers.purl = Some(purl.to_string());
341        let mut v = VulnerabilityRef::new(vuln.to_string(), VulnerabilitySource::Cve);
342        v.vex_status = Some(VexStatus::new(state));
343        c.vulnerabilities.push(v);
344        sbom.add_component(c);
345        sbom
346    }
347
348    #[test]
349    fn emit_minimal_csaf_document() {
350        let sbom = sbom_with(
351            "pkg:cargo/example@1.0.0",
352            "example",
353            "CVE-2024-12345",
354            VexState::Affected,
355        );
356        let csaf = emit_csaf(&sbom, &CsafEmitOptions::default()).expect("emit");
357        let json: serde_json::Value = serde_json::from_str(&csaf).expect("valid JSON");
358        assert_eq!(json["document"]["csaf_version"], "2.0");
359        assert_eq!(json["document"]["category"], "csaf_vex");
360        let products = json["product_tree"]["full_product_names"]
361            .as_array()
362            .unwrap();
363        assert_eq!(products.len(), 1);
364        assert_eq!(
365            products[0]["product_identification_helper"]["purl"],
366            "pkg:cargo/example@1.0.0"
367        );
368        let vulns = json["vulnerabilities"].as_array().unwrap();
369        assert_eq!(vulns.len(), 1);
370        assert_eq!(vulns[0]["cve"], "CVE-2024-12345");
371        let affected = vulns[0]["product_status"]["known_affected"]
372            .as_array()
373            .unwrap();
374        assert_eq!(affected.len(), 1);
375    }
376
377    #[test]
378    fn emit_groups_states_by_product_status() {
379        let mut sbom = NormalizedSbom::default();
380        for (i, state) in [
381            VexState::Affected,
382            VexState::NotAffected,
383            VexState::Fixed,
384            VexState::UnderInvestigation,
385        ]
386        .iter()
387        .enumerate()
388        {
389            let mut c = Component::new(format!("c{i}"), format!("c{i}@1.0"));
390            c.identifiers.purl = Some(format!("pkg:cargo/c{i}@1.0"));
391            let mut v =
392                VulnerabilityRef::new("CVE-2024-99999".to_string(), VulnerabilitySource::Cve);
393            v.vex_status = Some(VexStatus::new(state.clone()));
394            c.vulnerabilities.push(v);
395            sbom.add_component(c);
396        }
397        let csaf = emit_csaf(&sbom, &CsafEmitOptions::default()).expect("emit");
398        let json: serde_json::Value = serde_json::from_str(&csaf).unwrap();
399        let status = &json["vulnerabilities"][0]["product_status"];
400        assert_eq!(status["known_affected"].as_array().unwrap().len(), 1);
401        assert_eq!(status["known_not_affected"].as_array().unwrap().len(), 1);
402        assert_eq!(status["fixed"].as_array().unwrap().len(), 1);
403        assert_eq!(status["under_investigation"].as_array().unwrap().len(), 1);
404    }
405
406    #[test]
407    fn emit_uses_ids_for_non_cve_identifiers() {
408        let sbom = sbom_with(
409            "pkg:cargo/example@1.0.0",
410            "example",
411            "GHSA-aaaa-bbbb-cccc",
412            VexState::Affected,
413        );
414        // The internal model defaults VulnerabilityRef::new to CVE source,
415        // but emit logic keys off the `vuln_id` string itself (CVE prefix).
416        let csaf = emit_csaf(&sbom, &CsafEmitOptions::default()).expect("emit");
417        let json: serde_json::Value = serde_json::from_str(&csaf).unwrap();
418        let vuln = &json["vulnerabilities"][0];
419        assert!(vuln["cve"].is_null(), "non-CVE id must not surface as cve");
420        let ids = vuln["ids"].as_array().unwrap();
421        assert_eq!(ids[0]["system_name"], "GHSA");
422        assert_eq!(ids[0]["text"], "GHSA-aaaa-bbbb-cccc");
423    }
424
425    #[test]
426    fn emit_skips_components_without_purl() {
427        let mut sbom = NormalizedSbom::default();
428        let mut c = Component::new("noident".to_string(), "noident".to_string());
429        let mut v = VulnerabilityRef::new("CVE-2024-12345".to_string(), VulnerabilitySource::Cve);
430        v.vex_status = Some(VexStatus::new(VexState::Affected));
431        c.vulnerabilities.push(v);
432        sbom.add_component(c);
433
434        let csaf = emit_csaf(&sbom, &CsafEmitOptions::default()).expect("emit");
435        let json: serde_json::Value = serde_json::from_str(&csaf).unwrap();
436        assert!(
437            json["product_tree"]["full_product_names"]
438                .as_array()
439                .map_or(true, |a| a.is_empty()),
440            "components without PURL must not appear in product_tree"
441        );
442        // No products → no product_status entries → no vulnerabilities surfaced
443        assert!(
444            json["vulnerabilities"]
445                .as_array()
446                .map_or(true, |a| a.is_empty())
447        );
448    }
449}