Skip to main content

sbom_tools/reports/
json.rs

1//! JSON report generator.
2
3use 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
10/// JSON report generator
11pub struct JsonReporter {
12    /// Whether to only include summary
13    summary_only: bool,
14    /// Pretty print output
15    pretty: bool,
16}
17
18impl JsonReporter {
19    /// Create a new JSON reporter
20    #[must_use]
21    pub const fn new() -> Self {
22        Self {
23            summary_only: false,
24            pretty: true,
25        }
26    }
27
28    /// Create a summary-only reporter
29    #[must_use]
30    pub const fn summary_only() -> Self {
31        Self {
32            summary_only: true,
33            pretty: true,
34        }
35    }
36
37    /// Set pretty printing
38    #[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// JSON report structures
265
266#[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    /// Summary of violations grouped by CRA article
288    article_summary: CraArticleSummary,
289}
290
291#[derive(Serialize)]
292struct CraArticleSummary {
293    /// Annex I Part II (1) - Machine-readable SBOM format
294    #[serde(rename = "machine_readable_format")]
295    machine_readable: usize,
296    /// Article 13(17) / Annex I Part II (6) / Annex II (2) - Single point of
297    /// contact for vulnerability reporting
298    #[serde(rename = "art_13_17_contact")]
299    art_13_17: usize,
300    /// Annex I Part II (5) - Coordinated vulnerability disclosure policy
301    #[serde(rename = "cvd_policy")]
302    cvd_policy: usize,
303    /// Article 13(8) / 13(19) - Support period
304    #[serde(rename = "art_13_8_support_period")]
305    art_13_8: usize,
306    /// Article 13(8) / Annex II (7) - Component lifecycle
307    #[serde(rename = "lifecycle")]
308    lifecycle: usize,
309    /// Article 13(15) / Annex II (3) - Product identification
310    #[serde(rename = "art_13_15_product_identification")]
311    art_13_15_product: usize,
312    /// Article 13(16) / Annex II (1) - Manufacturer identification
313    #[serde(rename = "art_13_16_manufacturer_identification")]
314    art_13_16_manufacturer: usize,
315    /// Annex I - Essential cybersecurity requirements (incl. Part II SBOM
316    /// elements and Part I (2)(f) integrity)
317    #[serde(rename = "annex_i_essential_requirements")]
318    annex_i: usize,
319    /// Annex V - EU Declaration of Conformity
320    #[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        // Count violations by article/annex reference. Predicates key on the
339        // requirement strings set at the compliance emit sites; more specific
340        // matches must precede the "annex v" / "annex i" prefix matches.
341        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                // Annex VIII (conformity assessment) and Annex IV (critical
359                // products / EUCC reference) have no bucket; both must
360                // precede the "annex v" / "annex i" prefix matches.
361            } 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    /// Count of document-level metadata changes (author/tool/timestamp/etc.).
403    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    /// Document-level metadata changes (omitted when none).
424    #[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/// Wrapper that adds computed SLA status to vulnerability JSON output.
464#[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// View report structures
499
500#[derive(Serialize)]
501struct JsonViewReport {
502    metadata: JsonViewMetadata,
503    summary: ViewSummary,
504    compliance: CraComplianceDetail,
505    components: Vec<ComponentView>,
506    /// Flattened list of every vulnerability across all components,
507    /// annotated with the package it affects and whether that package is
508    /// a direct or transitive dependency of the primary component.
509    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    /// "primary", "direct", or "transitive" relative to the SBOM's primary component.
535    dependency_kind: DependencyKind,
536    /// Number of vulnerabilities affecting this component.
537    vulnerability_count: usize,
538    /// Structured vulnerability details (empty when none).
539    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/// Per-component vulnerability detail (used both in `components[].vulnerabilities`
571/// and as the body of `vulnerabilities[]` at the top level of the view report).
572#[derive(Serialize, Clone)]
573struct VulnerabilityView {
574    /// Vulnerability identifier (CVE, GHSA, OSV, etc.).
575    id: String,
576    /// Source database (NVD, OSV, GHSA, ...).
577    source: String,
578    /// Severity label ("Critical", "High", ...) when known.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    severity: Option<String>,
581    /// Highest CVSS base score across attached CVSS records.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    cvss_score: Option<f32>,
584    /// CVSS vector string of the highest-scoring record, if any.
585    #[serde(skip_serializing_if = "Option::is_none")]
586    cvss_vector: Option<String>,
587    /// First non-empty fixed version reported across the vulnerability's
588    /// remediation records, when available.
589    #[serde(skip_serializing_if = "Option::is_none")]
590    fixed_version: Option<String>,
591    /// CWE identifiers.
592    #[serde(skip_serializing_if = "Vec::is_empty")]
593    cwes: Vec<String>,
594    /// `true` when listed in CISA's Known Exploited Vulnerabilities catalog.
595    kev: bool,
596    /// KEV catalog metadata (due date, ransomware flag, ...).
597    #[serde(skip_serializing_if = "Option::is_none")]
598    kev_info: Option<KevInfoView>,
599    /// VEX status when an applicable VEX statement is attached.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    vex_status: Option<String>,
602    /// Short description, when supplied by the source.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    description: Option<String>,
605    /// Publication date (RFC 3339), when supplied.
606    #[serde(skip_serializing_if = "Option::is_none")]
607    published: Option<String>,
608    /// Last-modified date (RFC 3339), when supplied.
609    #[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/// Top-level flattened vulnerability entry: a `VulnerabilityView` joined with
659/// the affected package, so consumers can iterate vulnerabilities without
660/// walking the components array.
661#[derive(Serialize)]
662struct FlatVulnerabilityView {
663    /// Vulnerability details.
664    #[serde(flatten)]
665    vuln: VulnerabilityView,
666    /// Affected package name.
667    package: String,
668    /// Affected package version, when known.
669    #[serde(skip_serializing_if = "Option::is_none")]
670    package_version: Option<String>,
671    /// Ecosystem of the affected package, when known.
672    #[serde(skip_serializing_if = "Option::is_none")]
673    ecosystem: Option<String>,
674    /// Direct/transitive classification of the affected package.
675    dependency_kind: DependencyKind,
676    /// Convenience boolean — `true` when `dependency_kind` is `direct` or `primary`.
677    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}