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::{ComplianceChecker, ComplianceLevel, 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
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// JSON report structures
269
270#[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    /// Summary of violations grouped by CRA article
290    article_summary: CraArticleSummary,
291}
292
293#[derive(Serialize)]
294struct CraArticleSummary {
295    /// Article 13(4) - Machine-readable format
296    #[serde(rename = "art_13_4_machine_readable_format")]
297    art_13_4: usize,
298    /// Article 13(6) - Vulnerability disclosure
299    #[serde(rename = "art_13_6_vulnerability_disclosure")]
300    art_13_6: usize,
301    /// Article 13(7) - Coordinated vulnerability disclosure policy
302    #[serde(rename = "art_13_7_coordinated_disclosure")]
303    art_13_7: usize,
304    /// Article 13(8) - Support period
305    #[serde(rename = "art_13_8_support_period")]
306    art_13_8: usize,
307    /// Article 13(11) - Component lifecycle
308    #[serde(rename = "art_13_11_component_lifecycle")]
309    art_13_11: usize,
310    /// Article 13(12) - Product identification
311    #[serde(rename = "art_13_12_product_identification")]
312    art_13_12: usize,
313    /// Article 13(15) - Manufacturer identification
314    #[serde(rename = "art_13_15_manufacturer_identification")]
315    art_13_15: usize,
316    /// Annex I - Technical documentation
317    #[serde(rename = "annex_i_technical_documentation")]
318    annex_i: usize,
319    /// Annex VII - EU Declaration of Conformity
320    #[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        // Count violations by article reference
339        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    /// Count of document-level metadata changes (author/tool/timestamp/etc.).
397    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    /// Document-level metadata changes (omitted when none).
418    #[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/// Wrapper that adds computed SLA status to vulnerability JSON output.
458#[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// View report structures
493
494#[derive(Serialize)]
495struct JsonViewReport {
496    metadata: JsonViewMetadata,
497    summary: ViewSummary,
498    compliance: CraComplianceDetail,
499    components: Vec<ComponentView>,
500    /// Flattened list of every vulnerability across all components,
501    /// annotated with the package it affects and whether that package is
502    /// a direct or transitive dependency of the primary component.
503    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    /// "primary", "direct", or "transitive" relative to the SBOM's primary component.
529    dependency_kind: DependencyKind,
530    /// Number of vulnerabilities affecting this component.
531    vulnerability_count: usize,
532    /// Structured vulnerability details (empty when none).
533    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/// Per-component vulnerability detail (used both in `components[].vulnerabilities`
565/// and as the body of `vulnerabilities[]` at the top level of the view report).
566#[derive(Serialize, Clone)]
567struct VulnerabilityView {
568    /// Vulnerability identifier (CVE, GHSA, OSV, etc.).
569    id: String,
570    /// Source database (NVD, OSV, GHSA, ...).
571    source: String,
572    /// Severity label ("Critical", "High", ...) when known.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    severity: Option<String>,
575    /// Highest CVSS base score across attached CVSS records.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    cvss_score: Option<f32>,
578    /// CVSS vector string of the highest-scoring record, if any.
579    #[serde(skip_serializing_if = "Option::is_none")]
580    cvss_vector: Option<String>,
581    /// First non-empty fixed version reported across the vulnerability's
582    /// remediation records, when available.
583    #[serde(skip_serializing_if = "Option::is_none")]
584    fixed_version: Option<String>,
585    /// CWE identifiers.
586    #[serde(skip_serializing_if = "Vec::is_empty")]
587    cwes: Vec<String>,
588    /// `true` when listed in CISA's Known Exploited Vulnerabilities catalog.
589    kev: bool,
590    /// KEV catalog metadata (due date, ransomware flag, ...).
591    #[serde(skip_serializing_if = "Option::is_none")]
592    kev_info: Option<KevInfoView>,
593    /// VEX status when an applicable VEX statement is attached.
594    #[serde(skip_serializing_if = "Option::is_none")]
595    vex_status: Option<String>,
596    /// Short description, when supplied by the source.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    description: Option<String>,
599    /// Publication date (RFC 3339), when supplied.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    published: Option<String>,
602    /// Last-modified date (RFC 3339), when supplied.
603    #[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/// Top-level flattened vulnerability entry: a `VulnerabilityView` joined with
653/// the affected package, so consumers can iterate vulnerabilities without
654/// walking the components array.
655#[derive(Serialize)]
656struct FlatVulnerabilityView {
657    /// Vulnerability details.
658    #[serde(flatten)]
659    vuln: VulnerabilityView,
660    /// Affected package name.
661    package: String,
662    /// Affected package version, when known.
663    #[serde(skip_serializing_if = "Option::is_none")]
664    package_version: Option<String>,
665    /// Ecosystem of the affected package, when known.
666    #[serde(skip_serializing_if = "Option::is_none")]
667    ecosystem: Option<String>,
668    /// Direct/transitive classification of the affected package.
669    dependency_kind: DependencyKind,
670    /// Convenience boolean — `true` when `dependency_kind` is `direct` or `primary`.
671    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}