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    pub fn new() -> Self {
21        Self {
22            summary_only: false,
23            pretty: true,
24        }
25    }
26
27    /// Create a summary-only reporter
28    pub fn summary_only() -> Self {
29        Self {
30            summary_only: true,
31            pretty: true,
32        }
33    }
34
35    /// Set pretty printing
36    pub fn pretty(mut self, pretty: bool) -> Self {
37        self.pretty = pretty;
38        self
39    }
40}
41
42impl Default for JsonReporter {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl ReportGenerator for JsonReporter {
49    fn generate_diff_report(
50        &self,
51        result: &DiffResult,
52        old_sbom: &NormalizedSbom,
53        new_sbom: &NormalizedSbom,
54        config: &ReportConfig,
55    ) -> Result<String, ReportError> {
56        let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
57            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
58        });
59        let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
60            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
61        });
62        let cra_compliance = CraCompliance {
63            old: CraComplianceDetail::from_result(old_cra),
64            new: CraComplianceDetail::from_result(new_cra),
65        };
66
67        let report = JsonDiffReport {
68            metadata: JsonReportMetadata {
69                tool: ToolInfo {
70                    name: "sbom-tools".to_string(),
71                    version: env!("CARGO_PKG_VERSION").to_string(),
72                },
73                generated_at: Utc::now().to_rfc3339(),
74                old_sbom: SbomInfo {
75                    format: old_sbom.document.format.to_string(),
76                    file_path: config.metadata.old_sbom_path.clone(),
77                    component_count: old_sbom.component_count(),
78                },
79                new_sbom: SbomInfo {
80                    format: new_sbom.document.format.to_string(),
81                    file_path: config.metadata.new_sbom_path.clone(),
82                    component_count: new_sbom.component_count(),
83                },
84            },
85            summary: JsonSummary {
86                total_changes: result.summary.total_changes,
87                components: ComponentSummary {
88                    added: result.summary.components_added,
89                    removed: result.summary.components_removed,
90                    modified: result.summary.components_modified,
91                },
92                vulnerabilities: VulnerabilitySummary {
93                    introduced: result.summary.vulnerabilities_introduced,
94                    resolved: result.summary.vulnerabilities_resolved,
95                    persistent: result.summary.vulnerabilities_persistent,
96                },
97                semantic_score: result.semantic_score,
98            },
99            cra_compliance,
100            reports: if self.summary_only {
101                None
102            } else {
103                Some(JsonReports {
104                    components: if config.includes(ReportType::Components) {
105                        Some(ComponentsReport {
106                            added: &result.components.added,
107                            removed: &result.components.removed,
108                            modified: &result.components.modified,
109                        })
110                    } else {
111                        None
112                    },
113                    dependencies: if config.includes(ReportType::Dependencies) {
114                        Some(DependenciesReport {
115                            added: &result.dependencies.added,
116                            removed: &result.dependencies.removed,
117                        })
118                    } else {
119                        None
120                    },
121                    licenses: if config.includes(ReportType::Licenses) {
122                        Some(LicensesReport {
123                            new_licenses: &result.licenses.new_licenses,
124                            removed_licenses: &result.licenses.removed_licenses,
125                            conflicts: &result.licenses.conflicts,
126                        })
127                    } else {
128                        None
129                    },
130                    vulnerabilities: if config.includes(ReportType::Vulnerabilities) {
131                        Some(VulnerabilitiesReport {
132                            introduced: &result.vulnerabilities.introduced,
133                            resolved: &result.vulnerabilities.resolved,
134                            persistent: &result.vulnerabilities.persistent,
135                        })
136                    } else {
137                        None
138                    },
139                })
140            },
141        };
142
143        let json = if self.pretty {
144            serde_json::to_string_pretty(&report)
145        } else {
146            serde_json::to_string(&report)
147        }
148        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
149
150        Ok(json)
151    }
152
153    fn generate_view_report(
154        &self,
155        sbom: &NormalizedSbom,
156        config: &ReportConfig,
157    ) -> Result<String, ReportError> {
158        let cra_result = config.view_cra_compliance.clone().unwrap_or_else(|| {
159            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom)
160        });
161        let compliance = CraComplianceDetail::from_result(cra_result);
162
163        let report = JsonViewReport {
164            metadata: JsonViewMetadata {
165                tool: ToolInfo {
166                    name: "sbom-tools".to_string(),
167                    version: env!("CARGO_PKG_VERSION").to_string(),
168                },
169                generated_at: Utc::now().to_rfc3339(),
170                sbom: SbomInfo {
171                    format: sbom.document.format.to_string(),
172                    file_path: config.metadata.old_sbom_path.clone(),
173                    component_count: sbom.component_count(),
174                },
175            },
176            summary: ViewSummary {
177                total_components: sbom.component_count(),
178                total_dependencies: sbom.edges.len(),
179                ecosystems: sbom.ecosystems().iter().map(|e| e.to_string()).collect(),
180                vulnerability_counts: sbom.vulnerability_counts(),
181            },
182            compliance,
183            components: sbom
184                .components
185                .values()
186                .map(|c| ComponentView {
187                    name: c.name.clone(),
188                    version: c.version.clone(),
189                    ecosystem: c.ecosystem.as_ref().map(|e| e.to_string()),
190                    licenses: c
191                        .licenses
192                        .declared
193                        .iter()
194                        .map(|l| l.expression.clone())
195                        .collect(),
196                    supplier: c.supplier.as_ref().map(|s| s.name.clone()),
197                    vulnerabilities: c.vulnerabilities.len(),
198                })
199                .collect(),
200        };
201
202        let json = if self.pretty {
203            serde_json::to_string_pretty(&report)
204        } else {
205            serde_json::to_string(&report)
206        }
207        .map_err(|e| ReportError::SerializationError(e.to_string()))?;
208
209        Ok(json)
210    }
211
212    fn format(&self) -> ReportFormat {
213        ReportFormat::Json
214    }
215}
216
217// JSON report structures
218
219#[derive(Serialize)]
220struct JsonDiffReport<'a> {
221    metadata: JsonReportMetadata,
222    summary: JsonSummary,
223    cra_compliance: CraCompliance,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    reports: Option<JsonReports<'a>>,
226}
227
228#[derive(Serialize)]
229struct CraCompliance {
230    old: CraComplianceDetail,
231    new: CraComplianceDetail,
232}
233
234#[derive(Serialize)]
235struct CraComplianceDetail {
236    #[serde(flatten)]
237    result: ComplianceResult,
238    /// Summary of violations grouped by CRA article
239    article_summary: CraArticleSummary,
240}
241
242#[derive(Serialize)]
243struct CraArticleSummary {
244    /// Article 13(4) - Machine-readable format
245    #[serde(rename = "art_13_4_machine_readable_format")]
246    art_13_4: usize,
247    /// Article 13(6) - Vulnerability disclosure
248    #[serde(rename = "art_13_6_vulnerability_disclosure")]
249    art_13_6: usize,
250    /// Article 13(7) - Coordinated vulnerability disclosure policy
251    #[serde(rename = "art_13_7_coordinated_disclosure")]
252    art_13_7: usize,
253    /// Article 13(8) - Support period
254    #[serde(rename = "art_13_8_support_period")]
255    art_13_8: usize,
256    /// Article 13(11) - Component lifecycle
257    #[serde(rename = "art_13_11_component_lifecycle")]
258    art_13_11: usize,
259    /// Article 13(12) - Product identification
260    #[serde(rename = "art_13_12_product_identification")]
261    art_13_12: usize,
262    /// Article 13(15) - Manufacturer identification
263    #[serde(rename = "art_13_15_manufacturer_identification")]
264    art_13_15: usize,
265    /// Annex I - Technical documentation
266    #[serde(rename = "annex_i_technical_documentation")]
267    annex_i: usize,
268    /// Annex VII - EU Declaration of Conformity
269    #[serde(rename = "annex_vii_declaration_of_conformity")]
270    annex_vii: usize,
271}
272
273impl CraComplianceDetail {
274    fn from_result(result: ComplianceResult) -> Self {
275        let mut summary = CraArticleSummary {
276            art_13_4: 0,
277            art_13_6: 0,
278            art_13_7: 0,
279            art_13_8: 0,
280            art_13_11: 0,
281            art_13_12: 0,
282            art_13_15: 0,
283            annex_i: 0,
284            annex_vii: 0,
285        };
286
287        // Count violations by article reference
288        for violation in &result.violations {
289            let req = violation.requirement.to_lowercase();
290            if req.contains("art. 13(4)") || req.contains("art.13(4)") {
291                summary.art_13_4 += 1;
292            } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
293                summary.art_13_6 += 1;
294            } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
295                summary.art_13_7 += 1;
296            } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
297                summary.art_13_8 += 1;
298            } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
299                summary.art_13_11 += 1;
300            } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
301                summary.art_13_12 += 1;
302            } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
303                summary.art_13_15 += 1;
304            } else if req.contains("annex vii") {
305                summary.annex_vii += 1;
306            } else if req.contains("annex i") || req.contains("annex_i") {
307                summary.annex_i += 1;
308            }
309        }
310
311        Self {
312            result,
313            article_summary: summary,
314        }
315    }
316}
317
318#[derive(Serialize)]
319struct JsonReportMetadata {
320    tool: ToolInfo,
321    generated_at: String,
322    old_sbom: SbomInfo,
323    new_sbom: SbomInfo,
324}
325
326#[derive(Serialize)]
327struct ToolInfo {
328    name: String,
329    version: String,
330}
331
332#[derive(Serialize)]
333struct SbomInfo {
334    format: String,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    file_path: Option<String>,
337    component_count: usize,
338}
339
340#[derive(Serialize)]
341struct JsonSummary {
342    total_changes: usize,
343    components: ComponentSummary,
344    vulnerabilities: VulnerabilitySummary,
345    semantic_score: f64,
346}
347
348#[derive(Serialize)]
349struct ComponentSummary {
350    added: usize,
351    removed: usize,
352    modified: usize,
353}
354
355#[derive(Serialize)]
356struct VulnerabilitySummary {
357    introduced: usize,
358    resolved: usize,
359    persistent: usize,
360}
361
362#[derive(Serialize)]
363struct JsonReports<'a> {
364    #[serde(skip_serializing_if = "Option::is_none")]
365    components: Option<ComponentsReport<'a>>,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    dependencies: Option<DependenciesReport<'a>>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    licenses: Option<LicensesReport<'a>>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    vulnerabilities: Option<VulnerabilitiesReport<'a>>,
372}
373
374#[derive(Serialize)]
375struct ComponentsReport<'a> {
376    added: &'a [crate::diff::ComponentChange],
377    removed: &'a [crate::diff::ComponentChange],
378    modified: &'a [crate::diff::ComponentChange],
379}
380
381#[derive(Serialize)]
382struct DependenciesReport<'a> {
383    added: &'a [crate::diff::DependencyChange],
384    removed: &'a [crate::diff::DependencyChange],
385}
386
387#[derive(Serialize)]
388struct LicensesReport<'a> {
389    new_licenses: &'a [crate::diff::LicenseChange],
390    removed_licenses: &'a [crate::diff::LicenseChange],
391    conflicts: &'a [crate::diff::LicenseConflict],
392}
393
394#[derive(Serialize)]
395struct VulnerabilitiesReport<'a> {
396    introduced: &'a [crate::diff::VulnerabilityDetail],
397    resolved: &'a [crate::diff::VulnerabilityDetail],
398    persistent: &'a [crate::diff::VulnerabilityDetail],
399}
400
401// View report structures
402
403#[derive(Serialize)]
404struct JsonViewReport {
405    metadata: JsonViewMetadata,
406    summary: ViewSummary,
407    compliance: CraComplianceDetail,
408    components: Vec<ComponentView>,
409}
410
411#[derive(Serialize)]
412struct JsonViewMetadata {
413    tool: ToolInfo,
414    generated_at: String,
415    sbom: SbomInfo,
416}
417
418#[derive(Serialize)]
419struct ViewSummary {
420    total_components: usize,
421    total_dependencies: usize,
422    ecosystems: Vec<String>,
423    vulnerability_counts: crate::model::VulnerabilityCounts,
424}
425
426#[derive(Serialize)]
427struct ComponentView {
428    name: String,
429    version: Option<String>,
430    ecosystem: Option<String>,
431    licenses: Vec<String>,
432    supplier: Option<String>,
433    vulnerabilities: usize,
434}