1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::DiffResult;
5use crate::model::{Component, NormalizedSbom, VulnerabilityRef};
6use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
7use chrono::{DateTime, Utc};
8use serde::Serialize;
9
10pub struct JsonReporter {
12 summary_only: bool,
14 pretty: bool,
16}
17
18impl JsonReporter {
19 #[must_use]
21 pub const fn new() -> Self {
22 Self {
23 summary_only: false,
24 pretty: true,
25 }
26 }
27
28 #[must_use]
30 pub const fn summary_only() -> Self {
31 Self {
32 summary_only: true,
33 pretty: true,
34 }
35 }
36
37 #[must_use]
39 pub const fn pretty(mut self, pretty: bool) -> Self {
40 self.pretty = pretty;
41 self
42 }
43}
44
45impl Default for JsonReporter {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl ReportGenerator for JsonReporter {
52 fn generate_diff_report(
53 &self,
54 result: &DiffResult,
55 old_sbom: &NormalizedSbom,
56 new_sbom: &NormalizedSbom,
57 config: &ReportConfig,
58 ) -> Result<String, ReportError> {
59 let old_cra = config
60 .old_cra_compliance
61 .clone()
62 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom));
63 let new_cra = config
64 .new_cra_compliance
65 .clone()
66 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom));
67 let cra_compliance = CraCompliance {
68 old: CraComplianceDetail::from_result(old_cra),
69 new: CraComplianceDetail::from_result(new_cra),
70 };
71
72 let report = JsonDiffReport {
73 metadata: JsonReportMetadata {
74 tool: ToolInfo {
75 name: "sbom-tools".to_string(),
76 version: env!("CARGO_PKG_VERSION").to_string(),
77 },
78 generated_at: Utc::now().to_rfc3339(),
79 old_sbom: SbomInfo {
80 format: old_sbom.document.format.to_string(),
81 file_path: config.metadata.old_sbom_path.clone(),
82 component_count: old_sbom.component_count(),
83 },
84 new_sbom: SbomInfo {
85 format: new_sbom.document.format.to_string(),
86 file_path: config.metadata.new_sbom_path.clone(),
87 component_count: new_sbom.component_count(),
88 },
89 },
90 summary: JsonSummary {
91 total_changes: result.summary.total_changes,
92 components: ComponentSummary {
93 added: result.summary.components_added,
94 removed: result.summary.components_removed,
95 modified: result.summary.components_modified,
96 },
97 vulnerabilities: VulnerabilitySummary {
98 introduced: result.summary.vulnerabilities_introduced,
99 resolved: result.summary.vulnerabilities_resolved,
100 persistent: result.summary.vulnerabilities_persistent,
101 },
102 metadata_changes: result.summary.metadata_changes_count,
103 semantic_score: result.semantic_score,
104 },
105 cra_compliance,
106 reports: if self.summary_only {
107 None
108 } else {
109 Some(JsonReports {
110 metadata_changes: if result.metadata_changes.is_empty() {
111 None
112 } else {
113 Some(&result.metadata_changes)
114 },
115 components: if config.includes(ReportType::Components) {
116 Some(ComponentsReport {
117 added: &result.components.added,
118 removed: &result.components.removed,
119 modified: &result.components.modified,
120 })
121 } else {
122 None
123 },
124 dependencies: if config.includes(ReportType::Dependencies) {
125 Some(DependenciesReport {
126 added: &result.dependencies.added,
127 removed: &result.dependencies.removed,
128 })
129 } else {
130 None
131 },
132 licenses: if config.includes(ReportType::Licenses) {
133 Some(LicensesReport {
134 new_licenses: &result.licenses.new_licenses,
135 removed_licenses: &result.licenses.removed_licenses,
136 conflicts: &result.licenses.conflicts,
137 })
138 } else {
139 None
140 },
141 vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
142 Some(VulnerabilitiesReport {
143 introduced: VulnerabilityWithSla::from_slice(
144 &result.vulnerabilities.introduced,
145 ),
146 resolved: VulnerabilityWithSla::from_slice(
147 &result.vulnerabilities.resolved,
148 ),
149 persistent: VulnerabilityWithSla::from_slice(
150 &result.vulnerabilities.persistent,
151 ),
152 })
153 } else {
154 None
155 },
156 })
157 },
158 };
159
160 let json = if self.pretty {
161 serde_json::to_string_pretty(&report)
162 } else {
163 serde_json::to_string(&report)
164 }
165 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
166
167 Ok(json)
168 }
169
170 fn generate_view_report(
171 &self,
172 sbom: &NormalizedSbom,
173 config: &ReportConfig,
174 ) -> Result<String, ReportError> {
175 let cra_result = config
176 .view_cra_compliance
177 .clone()
178 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
179 let compliance = CraComplianceDetail::from_result(cra_result);
180
181 let direct_ids = sbom.direct_dependency_ids();
182 let primary_id = sbom.primary_component_id.as_ref();
183
184 let components: Vec<ComponentView> = sbom
185 .components
186 .values()
187 .map(|c| {
188 let kind = classify_dependency(&c.canonical_id, primary_id, &direct_ids);
189 ComponentView {
190 name: c.name.clone(),
191 version: c.version.clone(),
192 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
193 licenses: c
194 .licenses
195 .declared
196 .iter()
197 .map(|l| l.expression.clone())
198 .collect(),
199 supplier: c.supplier.as_ref().map(|s| s.name.clone()),
200 dependency_kind: kind,
201 vulnerability_count: c.vulnerabilities.len(),
202 vulnerabilities: c
203 .vulnerabilities
204 .iter()
205 .map(VulnerabilityView::from)
206 .collect(),
207 eol_status: c.eol.as_ref().map(|e| e.status.label().to_string()),
208 eol_date: c
209 .eol
210 .as_ref()
211 .and_then(|e| e.eol_date.map(|d| d.to_string())),
212 eol_product: c.eol.as_ref().map(|e| e.product.clone()),
213 }
214 })
215 .collect();
216
217 let mut vulnerabilities: Vec<FlatVulnerabilityView> = Vec::new();
218 for comp in sbom.components.values() {
219 let kind = classify_dependency(&comp.canonical_id, primary_id, &direct_ids);
220 for v in &comp.vulnerabilities {
221 vulnerabilities.push(FlatVulnerabilityView::from_pair(comp, v, kind));
222 }
223 }
224
225 let report = JsonViewReport {
226 metadata: JsonViewMetadata {
227 tool: ToolInfo {
228 name: "sbom-tools".to_string(),
229 version: env!("CARGO_PKG_VERSION").to_string(),
230 },
231 generated_at: Utc::now().to_rfc3339(),
232 sbom: SbomInfo {
233 format: sbom.document.format.to_string(),
234 file_path: config.metadata.old_sbom_path.clone(),
235 component_count: sbom.component_count(),
236 },
237 },
238 summary: ViewSummary {
239 total_components: sbom.component_count(),
240 total_dependencies: sbom.edges.len(),
241 ecosystems: sbom
242 .ecosystems()
243 .iter()
244 .map(std::string::ToString::to_string)
245 .collect(),
246 vulnerability_counts: sbom.vulnerability_counts(),
247 },
248 compliance,
249 components,
250 vulnerabilities,
251 };
252
253 let json = if self.pretty {
254 serde_json::to_string_pretty(&report)
255 } else {
256 serde_json::to_string(&report)
257 }
258 .map_err(|e| ReportError::SerializationError(e.to_string()))?;
259
260 Ok(json)
261 }
262
263 fn format(&self) -> ReportFormat {
264 ReportFormat::Json
265 }
266}
267
268#[derive(Serialize)]
271struct JsonDiffReport<'a> {
272 metadata: JsonReportMetadata,
273 summary: JsonSummary,
274 cra_compliance: CraCompliance,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 reports: Option<JsonReports<'a>>,
277}
278
279#[derive(Serialize)]
280struct CraCompliance {
281 old: CraComplianceDetail,
282 new: CraComplianceDetail,
283}
284
285#[derive(Serialize)]
286struct CraComplianceDetail {
287 #[serde(flatten)]
288 result: ComplianceResult,
289 article_summary: CraArticleSummary,
291}
292
293#[derive(Serialize)]
294struct CraArticleSummary {
295 #[serde(rename = "art_13_4_machine_readable_format")]
297 art_13_4: usize,
298 #[serde(rename = "art_13_6_vulnerability_disclosure")]
300 art_13_6: usize,
301 #[serde(rename = "art_13_7_coordinated_disclosure")]
303 art_13_7: usize,
304 #[serde(rename = "art_13_8_support_period")]
306 art_13_8: usize,
307 #[serde(rename = "art_13_11_component_lifecycle")]
309 art_13_11: usize,
310 #[serde(rename = "art_13_12_product_identification")]
312 art_13_12: usize,
313 #[serde(rename = "art_13_15_manufacturer_identification")]
315 art_13_15: usize,
316 #[serde(rename = "annex_i_technical_documentation")]
318 annex_i: usize,
319 #[serde(rename = "annex_vii_declaration_of_conformity")]
321 annex_vii: usize,
322}
323
324impl CraComplianceDetail {
325 fn from_result(result: ComplianceResult) -> Self {
326 let mut summary = CraArticleSummary {
327 art_13_4: 0,
328 art_13_6: 0,
329 art_13_7: 0,
330 art_13_8: 0,
331 art_13_11: 0,
332 art_13_12: 0,
333 art_13_15: 0,
334 annex_i: 0,
335 annex_vii: 0,
336 };
337
338 for violation in &result.violations {
340 let req = violation.requirement.to_lowercase();
341 if req.contains("art. 13(4)") || req.contains("art.13(4)") {
342 summary.art_13_4 += 1;
343 } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
344 summary.art_13_6 += 1;
345 } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
346 summary.art_13_7 += 1;
347 } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
348 summary.art_13_8 += 1;
349 } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
350 summary.art_13_11 += 1;
351 } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
352 summary.art_13_12 += 1;
353 } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
354 summary.art_13_15 += 1;
355 } else if req.contains("annex vii") {
356 summary.annex_vii += 1;
357 } else if req.contains("annex i") || req.contains("annex_i") {
358 summary.annex_i += 1;
359 }
360 }
361
362 Self {
363 result,
364 article_summary: summary,
365 }
366 }
367}
368
369#[derive(Serialize)]
370struct JsonReportMetadata {
371 tool: ToolInfo,
372 generated_at: String,
373 old_sbom: SbomInfo,
374 new_sbom: SbomInfo,
375}
376
377#[derive(Serialize)]
378struct ToolInfo {
379 name: String,
380 version: String,
381}
382
383#[derive(Serialize)]
384struct SbomInfo {
385 format: String,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 file_path: Option<String>,
388 component_count: usize,
389}
390
391#[derive(Serialize)]
392struct JsonSummary {
393 total_changes: usize,
394 components: ComponentSummary,
395 vulnerabilities: VulnerabilitySummary,
396 metadata_changes: usize,
398 semantic_score: f64,
399}
400
401#[derive(Serialize)]
402struct ComponentSummary {
403 added: usize,
404 removed: usize,
405 modified: usize,
406}
407
408#[derive(Serialize)]
409struct VulnerabilitySummary {
410 introduced: usize,
411 resolved: usize,
412 persistent: usize,
413}
414
415#[derive(Serialize)]
416struct JsonReports<'a> {
417 #[serde(skip_serializing_if = "Option::is_none")]
419 metadata_changes: Option<&'a [crate::diff::MetadataChange]>,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 components: Option<ComponentsReport<'a>>,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 dependencies: Option<DependenciesReport<'a>>,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 licenses: Option<LicensesReport<'a>>,
426 #[serde(skip_serializing_if = "Option::is_none")]
427 vulnerabilities: Option<VulnerabilitiesReport>,
428}
429
430#[derive(Serialize)]
431struct ComponentsReport<'a> {
432 added: &'a [crate::diff::ComponentChange],
433 removed: &'a [crate::diff::ComponentChange],
434 modified: &'a [crate::diff::ComponentChange],
435}
436
437#[derive(Serialize)]
438struct DependenciesReport<'a> {
439 added: &'a [crate::diff::DependencyChange],
440 removed: &'a [crate::diff::DependencyChange],
441}
442
443#[derive(Serialize)]
444struct LicensesReport<'a> {
445 new_licenses: &'a [crate::diff::LicenseChange],
446 removed_licenses: &'a [crate::diff::LicenseChange],
447 conflicts: &'a [crate::diff::LicenseConflict],
448}
449
450#[derive(Serialize)]
451struct VulnerabilitiesReport {
452 introduced: Vec<VulnerabilityWithSla>,
453 resolved: Vec<VulnerabilityWithSla>,
454 persistent: Vec<VulnerabilityWithSla>,
455}
456
457#[derive(Serialize)]
459struct VulnerabilityWithSla {
460 #[serde(flatten)]
461 detail: crate::diff::VulnerabilityDetail,
462 sla_status: String,
463 sla_category: String,
464}
465
466impl VulnerabilityWithSla {
467 fn from_detail(v: &crate::diff::VulnerabilityDetail) -> Self {
468 let sla = v.sla_status();
469 let (status_text, category) = match &sla {
470 crate::diff::SlaStatus::Overdue(days) => (format!("{days}d overdue"), "overdue"),
471 crate::diff::SlaStatus::DueSoon(days) => (format!("{days}d remaining"), "due_soon"),
472 crate::diff::SlaStatus::OnTrack(days) => (format!("{days}d remaining"), "on_track"),
473 crate::diff::SlaStatus::NoDueDate => {
474 let text = v
475 .days_since_published
476 .map_or_else(|| "unknown".to_string(), |d| format!("{d}d old"));
477 (text, "no_due_date")
478 }
479 };
480 Self {
481 detail: v.clone(),
482 sla_status: status_text,
483 sla_category: category.to_string(),
484 }
485 }
486
487 fn from_slice(vulns: &[crate::diff::VulnerabilityDetail]) -> Vec<Self> {
488 vulns.iter().map(Self::from_detail).collect()
489 }
490}
491
492#[derive(Serialize)]
495struct JsonViewReport {
496 metadata: JsonViewMetadata,
497 summary: ViewSummary,
498 compliance: CraComplianceDetail,
499 components: Vec<ComponentView>,
500 vulnerabilities: Vec<FlatVulnerabilityView>,
504}
505
506#[derive(Serialize)]
507struct JsonViewMetadata {
508 tool: ToolInfo,
509 generated_at: String,
510 sbom: SbomInfo,
511}
512
513#[derive(Serialize)]
514struct ViewSummary {
515 total_components: usize,
516 total_dependencies: usize,
517 ecosystems: Vec<String>,
518 vulnerability_counts: crate::model::VulnerabilityCounts,
519}
520
521#[derive(Serialize)]
522struct ComponentView {
523 name: String,
524 version: Option<String>,
525 ecosystem: Option<String>,
526 licenses: Vec<String>,
527 supplier: Option<String>,
528 dependency_kind: DependencyKind,
530 vulnerability_count: usize,
532 vulnerabilities: Vec<VulnerabilityView>,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 eol_status: Option<String>,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 eol_date: Option<String>,
538 #[serde(skip_serializing_if = "Option::is_none")]
539 eol_product: Option<String>,
540}
541
542#[derive(Serialize, Clone, Copy)]
543#[serde(rename_all = "snake_case")]
544enum DependencyKind {
545 Primary,
546 Direct,
547 Transitive,
548}
549
550fn classify_dependency(
551 id: &crate::model::CanonicalId,
552 primary: Option<&crate::model::CanonicalId>,
553 direct: &std::collections::HashSet<crate::model::CanonicalId>,
554) -> DependencyKind {
555 if primary == Some(id) {
556 DependencyKind::Primary
557 } else if direct.contains(id) {
558 DependencyKind::Direct
559 } else {
560 DependencyKind::Transitive
561 }
562}
563
564#[derive(Serialize, Clone)]
567struct VulnerabilityView {
568 id: String,
570 source: String,
572 #[serde(skip_serializing_if = "Option::is_none")]
574 severity: Option<String>,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 cvss_score: Option<f32>,
578 #[serde(skip_serializing_if = "Option::is_none")]
580 cvss_vector: Option<String>,
581 #[serde(skip_serializing_if = "Option::is_none")]
584 fixed_version: Option<String>,
585 #[serde(skip_serializing_if = "Vec::is_empty")]
587 cwes: Vec<String>,
588 kev: bool,
590 #[serde(skip_serializing_if = "Option::is_none")]
592 kev_info: Option<KevInfoView>,
593 #[serde(skip_serializing_if = "Option::is_none")]
595 vex_status: Option<String>,
596 #[serde(skip_serializing_if = "Option::is_none")]
598 description: Option<String>,
599 #[serde(skip_serializing_if = "Option::is_none")]
601 published: Option<String>,
602 #[serde(skip_serializing_if = "Option::is_none")]
604 modified: Option<String>,
605}
606
607#[derive(Serialize, Clone)]
608struct KevInfoView {
609 date_added: String,
610 due_date: String,
611 known_ransomware_use: bool,
612}
613
614impl From<&VulnerabilityRef> for VulnerabilityView {
615 fn from(v: &VulnerabilityRef) -> Self {
616 let (cvss_score, cvss_vector) = v
617 .cvss
618 .iter()
619 .max_by(|a, b| {
620 a.base_score
621 .partial_cmp(&b.base_score)
622 .unwrap_or(std::cmp::Ordering::Equal)
623 })
624 .map_or((None, None), |c| (Some(c.base_score), c.vector.clone()));
625
626 Self {
627 id: v.id.clone(),
628 source: v.source.to_string(),
629 severity: v.severity.as_ref().map(ToString::to_string),
630 cvss_score,
631 cvss_vector,
632 fixed_version: v.remediation.as_ref().and_then(|r| r.fixed_version.clone()),
633 cwes: v.cwes.clone(),
634 kev: v.is_kev,
635 kev_info: v.kev_info.as_ref().map(|k| KevInfoView {
636 date_added: rfc3339(k.date_added),
637 due_date: rfc3339(k.due_date),
638 known_ransomware_use: k.known_ransomware_use,
639 }),
640 vex_status: v.vex_status.as_ref().map(|s| format!("{s:?}")),
641 description: v.description.clone(),
642 published: v.published.map(rfc3339),
643 modified: v.modified.map(rfc3339),
644 }
645 }
646}
647
648fn rfc3339(dt: DateTime<Utc>) -> String {
649 dt.to_rfc3339()
650}
651
652#[derive(Serialize)]
656struct FlatVulnerabilityView {
657 #[serde(flatten)]
659 vuln: VulnerabilityView,
660 package: String,
662 #[serde(skip_serializing_if = "Option::is_none")]
664 package_version: Option<String>,
665 #[serde(skip_serializing_if = "Option::is_none")]
667 ecosystem: Option<String>,
668 dependency_kind: DependencyKind,
670 is_direct: bool,
672}
673
674impl FlatVulnerabilityView {
675 fn from_pair(comp: &Component, v: &VulnerabilityRef, kind: DependencyKind) -> Self {
676 let is_direct = matches!(kind, DependencyKind::Direct | DependencyKind::Primary);
677 Self {
678 vuln: VulnerabilityView::from(v),
679 package: comp.name.clone(),
680 package_version: comp.version.clone(),
681 ecosystem: comp.ecosystem.as_ref().map(ToString::to_string),
682 dependency_kind: kind,
683 is_direct,
684 }
685 }
686}