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