1use crate::model::{NormalizedSbom, VexState};
23use serde::Serialize;
24use std::collections::BTreeMap;
25
26use super::ReportError;
27
28#[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
41pub 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 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 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#[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 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 assert!(
444 json["vulnerabilities"]
445 .as_array()
446 .map_or(true, |a| a.is_empty())
447 );
448 }
449}