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::NormalizedSbom;
6use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
7use chrono::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                semantic_score: result.semantic_score,
103            },
104            cra_compliance,
105            reports: if self.summary_only {
106                None
107            } else {
108                Some(JsonReports {
109                    components: if config.includes(ReportType::Components) {
110                        Some(ComponentsReport {
111                            added: &result.components.added,
112                            removed: &result.components.removed,
113                            modified: &result.components.modified,
114                        })
115                    } else {
116                        None
117                    },
118                    dependencies: if config.includes(ReportType::Dependencies) {
119                        Some(DependenciesReport {
120                            added: &result.dependencies.added,
121                            removed: &result.dependencies.removed,
122                        })
123                    } else {
124                        None
125                    },
126                    licenses: if config.includes(ReportType::Licenses) {
127                        Some(LicensesReport {
128                            new_licenses: &result.licenses.new_licenses,
129                            removed_licenses: &result.licenses.removed_licenses,
130                            conflicts: &result.licenses.conflicts,
131                        })
132                    } else {
133                        None
134                    },
135                    vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
136                        Some(VulnerabilitiesReport {
137                            introduced: VulnerabilityWithSla::from_slice(
138                                &result.vulnerabilities.introduced,
139                            ),
140                            resolved: VulnerabilityWithSla::from_slice(
141                                &result.vulnerabilities.resolved,
142                            ),
143                            persistent: VulnerabilityWithSla::from_slice(
144                                &result.vulnerabilities.persistent,
145                            ),
146                        })
147                    } else {
148                        None
149                    },
150                })
151            },
152        };
153
154        let json = if self.pretty {
155            serde_json::to_string_pretty(&report)
156        } else {
157            serde_json::to_string(&report)
158        }
159        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
160
161        Ok(json)
162    }
163
164    fn generate_view_report(
165        &self,
166        sbom: &NormalizedSbom,
167        config: &ReportConfig,
168    ) -> Result<String, ReportError> {
169        let cra_result = config
170            .view_cra_compliance
171            .clone()
172            .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
173        let compliance = CraComplianceDetail::from_result(cra_result);
174
175        let report = JsonViewReport {
176            metadata: JsonViewMetadata {
177                tool: ToolInfo {
178                    name: "sbom-tools".to_string(),
179                    version: env!("CARGO_PKG_VERSION").to_string(),
180                },
181                generated_at: Utc::now().to_rfc3339(),
182                sbom: SbomInfo {
183                    format: sbom.document.format.to_string(),
184                    file_path: config.metadata.old_sbom_path.clone(),
185                    component_count: sbom.component_count(),
186                },
187            },
188            summary: ViewSummary {
189                total_components: sbom.component_count(),
190                total_dependencies: sbom.edges.len(),
191                ecosystems: sbom
192                    .ecosystems()
193                    .iter()
194                    .map(std::string::ToString::to_string)
195                    .collect(),
196                vulnerability_counts: sbom.vulnerability_counts(),
197            },
198            compliance,
199            components: sbom
200                .components
201                .values()
202                .map(|c| ComponentView {
203                    name: c.name.clone(),
204                    version: c.version.clone(),
205                    ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
206                    licenses: c
207                        .licenses
208                        .declared
209                        .iter()
210                        .map(|l| l.expression.clone())
211                        .collect(),
212                    supplier: c.supplier.as_ref().map(|s| s.name.clone()),
213                    vulnerabilities: c.vulnerabilities.len(),
214                    eol_status: c.eol.as_ref().map(|e| e.status.label().to_string()),
215                    eol_date: c
216                        .eol
217                        .as_ref()
218                        .and_then(|e| e.eol_date.map(|d| d.to_string())),
219                    eol_product: c.eol.as_ref().map(|e| e.product.clone()),
220                })
221                .collect(),
222        };
223
224        let json = if self.pretty {
225            serde_json::to_string_pretty(&report)
226        } else {
227            serde_json::to_string(&report)
228        }
229        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
230
231        Ok(json)
232    }
233
234    fn format(&self) -> ReportFormat {
235        ReportFormat::Json
236    }
237}
238
239// JSON report structures
240
241#[derive(Serialize)]
242struct JsonDiffReport<'a> {
243    metadata: JsonReportMetadata,
244    summary: JsonSummary,
245    cra_compliance: CraCompliance,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    reports: Option<JsonReports<'a>>,
248}
249
250#[derive(Serialize)]
251struct CraCompliance {
252    old: CraComplianceDetail,
253    new: CraComplianceDetail,
254}
255
256#[derive(Serialize)]
257struct CraComplianceDetail {
258    #[serde(flatten)]
259    result: ComplianceResult,
260    /// Summary of violations grouped by CRA article
261    article_summary: CraArticleSummary,
262}
263
264#[derive(Serialize)]
265struct CraArticleSummary {
266    /// Article 13(4) - Machine-readable format
267    #[serde(rename = "art_13_4_machine_readable_format")]
268    art_13_4: usize,
269    /// Article 13(6) - Vulnerability disclosure
270    #[serde(rename = "art_13_6_vulnerability_disclosure")]
271    art_13_6: usize,
272    /// Article 13(7) - Coordinated vulnerability disclosure policy
273    #[serde(rename = "art_13_7_coordinated_disclosure")]
274    art_13_7: usize,
275    /// Article 13(8) - Support period
276    #[serde(rename = "art_13_8_support_period")]
277    art_13_8: usize,
278    /// Article 13(11) - Component lifecycle
279    #[serde(rename = "art_13_11_component_lifecycle")]
280    art_13_11: usize,
281    /// Article 13(12) - Product identification
282    #[serde(rename = "art_13_12_product_identification")]
283    art_13_12: usize,
284    /// Article 13(15) - Manufacturer identification
285    #[serde(rename = "art_13_15_manufacturer_identification")]
286    art_13_15: usize,
287    /// Annex I - Technical documentation
288    #[serde(rename = "annex_i_technical_documentation")]
289    annex_i: usize,
290    /// Annex VII - EU Declaration of Conformity
291    #[serde(rename = "annex_vii_declaration_of_conformity")]
292    annex_vii: usize,
293}
294
295impl CraComplianceDetail {
296    fn from_result(result: ComplianceResult) -> Self {
297        let mut summary = CraArticleSummary {
298            art_13_4: 0,
299            art_13_6: 0,
300            art_13_7: 0,
301            art_13_8: 0,
302            art_13_11: 0,
303            art_13_12: 0,
304            art_13_15: 0,
305            annex_i: 0,
306            annex_vii: 0,
307        };
308
309        // Count violations by article reference
310        for violation in &result.violations {
311            let req = violation.requirement.to_lowercase();
312            if req.contains("art. 13(4)") || req.contains("art.13(4)") {
313                summary.art_13_4 += 1;
314            } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
315                summary.art_13_6 += 1;
316            } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
317                summary.art_13_7 += 1;
318            } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
319                summary.art_13_8 += 1;
320            } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
321                summary.art_13_11 += 1;
322            } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
323                summary.art_13_12 += 1;
324            } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
325                summary.art_13_15 += 1;
326            } else if req.contains("annex vii") {
327                summary.annex_vii += 1;
328            } else if req.contains("annex i") || req.contains("annex_i") {
329                summary.annex_i += 1;
330            }
331        }
332
333        Self {
334            result,
335            article_summary: summary,
336        }
337    }
338}
339
340#[derive(Serialize)]
341struct JsonReportMetadata {
342    tool: ToolInfo,
343    generated_at: String,
344    old_sbom: SbomInfo,
345    new_sbom: SbomInfo,
346}
347
348#[derive(Serialize)]
349struct ToolInfo {
350    name: String,
351    version: String,
352}
353
354#[derive(Serialize)]
355struct SbomInfo {
356    format: String,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    file_path: Option<String>,
359    component_count: usize,
360}
361
362#[derive(Serialize)]
363struct JsonSummary {
364    total_changes: usize,
365    components: ComponentSummary,
366    vulnerabilities: VulnerabilitySummary,
367    semantic_score: f64,
368}
369
370#[derive(Serialize)]
371struct ComponentSummary {
372    added: usize,
373    removed: usize,
374    modified: usize,
375}
376
377#[derive(Serialize)]
378struct VulnerabilitySummary {
379    introduced: usize,
380    resolved: usize,
381    persistent: usize,
382}
383
384#[derive(Serialize)]
385struct JsonReports<'a> {
386    #[serde(skip_serializing_if = "Option::is_none")]
387    components: Option<ComponentsReport<'a>>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    dependencies: Option<DependenciesReport<'a>>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    licenses: Option<LicensesReport<'a>>,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    vulnerabilities: Option<VulnerabilitiesReport>,
394}
395
396#[derive(Serialize)]
397struct ComponentsReport<'a> {
398    added: &'a [crate::diff::ComponentChange],
399    removed: &'a [crate::diff::ComponentChange],
400    modified: &'a [crate::diff::ComponentChange],
401}
402
403#[derive(Serialize)]
404struct DependenciesReport<'a> {
405    added: &'a [crate::diff::DependencyChange],
406    removed: &'a [crate::diff::DependencyChange],
407}
408
409#[derive(Serialize)]
410struct LicensesReport<'a> {
411    new_licenses: &'a [crate::diff::LicenseChange],
412    removed_licenses: &'a [crate::diff::LicenseChange],
413    conflicts: &'a [crate::diff::LicenseConflict],
414}
415
416#[derive(Serialize)]
417struct VulnerabilitiesReport {
418    introduced: Vec<VulnerabilityWithSla>,
419    resolved: Vec<VulnerabilityWithSla>,
420    persistent: Vec<VulnerabilityWithSla>,
421}
422
423/// Wrapper that adds computed SLA status to vulnerability JSON output.
424#[derive(Serialize)]
425struct VulnerabilityWithSla {
426    #[serde(flatten)]
427    detail: crate::diff::VulnerabilityDetail,
428    sla_status: String,
429    sla_category: String,
430}
431
432impl VulnerabilityWithSla {
433    fn from_detail(v: &crate::diff::VulnerabilityDetail) -> Self {
434        let sla = v.sla_status();
435        let (status_text, category) = match &sla {
436            crate::diff::SlaStatus::Overdue(days) => (format!("{days}d overdue"), "overdue"),
437            crate::diff::SlaStatus::DueSoon(days) => (format!("{days}d remaining"), "due_soon"),
438            crate::diff::SlaStatus::OnTrack(days) => (format!("{days}d remaining"), "on_track"),
439            crate::diff::SlaStatus::NoDueDate => {
440                let text = v
441                    .days_since_published
442                    .map_or_else(|| "unknown".to_string(), |d| format!("{d}d old"));
443                (text, "no_due_date")
444            }
445        };
446        Self {
447            detail: v.clone(),
448            sla_status: status_text,
449            sla_category: category.to_string(),
450        }
451    }
452
453    fn from_slice(vulns: &[crate::diff::VulnerabilityDetail]) -> Vec<Self> {
454        vulns.iter().map(Self::from_detail).collect()
455    }
456}
457
458// View report structures
459
460#[derive(Serialize)]
461struct JsonViewReport {
462    metadata: JsonViewMetadata,
463    summary: ViewSummary,
464    compliance: CraComplianceDetail,
465    components: Vec<ComponentView>,
466}
467
468#[derive(Serialize)]
469struct JsonViewMetadata {
470    tool: ToolInfo,
471    generated_at: String,
472    sbom: SbomInfo,
473}
474
475#[derive(Serialize)]
476struct ViewSummary {
477    total_components: usize,
478    total_dependencies: usize,
479    ecosystems: Vec<String>,
480    vulnerability_counts: crate::model::VulnerabilityCounts,
481}
482
483#[derive(Serialize)]
484struct ComponentView {
485    name: String,
486    version: Option<String>,
487    ecosystem: Option<String>,
488    licenses: Vec<String>,
489    supplier: Option<String>,
490    vulnerabilities: usize,
491    #[serde(skip_serializing_if = "Option::is_none")]
492    eol_status: Option<String>,
493    #[serde(skip_serializing_if = "Option::is_none")]
494    eol_date: Option<String>,
495    #[serde(skip_serializing_if = "Option::is_none")]
496    eol_product: Option<String>,
497}