Skip to main content

sbom_tools/reports/
csv.rs

1//! CSV report generator.
2//!
3//! Generates comma-separated reports for diff and view modes,
4//! suitable for spreadsheet import and data analysis pipelines.
5
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use std::fmt::Write;
10
11/// CSV report generator.
12pub struct CsvReporter;
13
14impl CsvReporter {
15    #[must_use]
16    pub const fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for CsvReporter {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl ReportGenerator for CsvReporter {
28    fn generate_diff_report(
29        &self,
30        result: &DiffResult,
31        _old_sbom: &NormalizedSbom,
32        _new_sbom: &NormalizedSbom,
33        _config: &ReportConfig,
34    ) -> Result<String, ReportError> {
35        // Pre-allocate based on estimated output size
36        let estimated_lines = result.components.total()
37            + result.vulnerabilities.introduced.len()
38            + result.vulnerabilities.resolved.len()
39            + result.vulnerabilities.persistent.len()
40            + 10; // headers
41        let mut content = String::with_capacity(estimated_lines * 100);
42
43        // Components CSV
44        content.push_str("# Components\n");
45        content.push_str("Change,Name,Old Version,New Version,Ecosystem\n");
46
47        for comp in &result.components.added {
48            write_component_line(&mut content, "Added", comp);
49        }
50
51        for comp in &result.components.removed {
52            write_component_line(&mut content, "Removed", comp);
53        }
54
55        for comp in &result.components.modified {
56            write_component_line(&mut content, "Modified", comp);
57        }
58
59        // Vulnerabilities CSV
60        content.push_str("\n# Vulnerabilities\n");
61        content.push_str("Status,ID,Severity,Type,SLA,Component,Description,VEX\n");
62
63        for vuln in &result.vulnerabilities.introduced {
64            write_vuln_line(&mut content, "Introduced", vuln);
65        }
66
67        for vuln in &result.vulnerabilities.resolved {
68            write_vuln_line(&mut content, "Resolved", vuln);
69        }
70
71        for vuln in &result.vulnerabilities.persistent {
72            write_vuln_line(&mut content, "Persistent", vuln);
73        }
74
75        Ok(content)
76    }
77
78    fn generate_view_report(
79        &self,
80        sbom: &NormalizedSbom,
81        _config: &ReportConfig,
82    ) -> Result<String, ReportError> {
83        // Pre-allocate based on component count
84        let mut content = String::with_capacity(sbom.components.len() * 150 + 100);
85
86        content.push_str(
87            "Name,Version,Ecosystem,Type,PURL,Licenses,Vulnerabilities,EOL Status,EOL Date,Crypto Asset Type,Algorithm Family,Quantum Level\n",
88        );
89
90        for (_, comp) in &sbom.components {
91            let licenses = comp
92                .licenses
93                .declared
94                .iter()
95                .map(|l| l.expression.as_str())
96                .collect::<Vec<_>>()
97                .join("; ");
98            let vuln_count = comp.vulnerabilities.len();
99            let ecosystem = comp.ecosystem.as_ref().map(|e| format!("{e:?}"));
100            let ecosystem = escape_csv_opt(ecosystem.as_deref());
101
102            let eol_status = comp.eol.as_ref().map_or("-", |e| e.status.label());
103            let eol_date = comp
104                .eol
105                .as_ref()
106                .and_then(|e| e.eol_date.map(|d| d.to_string()));
107            let eol_date = eol_date.as_deref().unwrap_or("-");
108
109            // Crypto fields
110            let crypto_type = comp
111                .crypto_properties
112                .as_ref()
113                .map(|cp| cp.asset_type.to_string())
114                .unwrap_or_default();
115            let algo_family = comp
116                .crypto_properties
117                .as_ref()
118                .and_then(|cp| {
119                    cp.algorithm_properties
120                        .as_ref()
121                        .and_then(|a| a.algorithm_family.as_deref().map(escape_csv))
122                })
123                .unwrap_or_default();
124            let quantum_level = comp
125                .crypto_properties
126                .as_ref()
127                .and_then(|cp| {
128                    cp.algorithm_properties
129                        .as_ref()
130                        .and_then(|a| a.nist_quantum_security_level.map(|l| l.to_string()))
131                })
132                .unwrap_or_default();
133
134            let _ = writeln!(
135                content,
136                "\"{}\",\"{}\",\"{}\",\"{:?}\",\"{}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
137                escape_csv(&comp.name),
138                escape_csv_opt(comp.version.as_deref()),
139                ecosystem,
140                comp.component_type,
141                escape_csv_opt(comp.identifiers.purl.as_deref()),
142                escape_csv(&licenses),
143                vuln_count,
144                eol_status,
145                eol_date,
146                crypto_type,
147                algo_family,
148                quantum_level,
149            );
150        }
151
152        Ok(content)
153    }
154
155    fn format(&self) -> ReportFormat {
156        ReportFormat::Csv
157    }
158}
159
160/// Write a component line using write! macro to avoid format! allocation.
161fn write_component_line(
162    content: &mut String,
163    change_type: &str,
164    comp: &crate::diff::ComponentChange,
165) {
166    let _ = writeln!(
167        content,
168        "{},\"{}\",\"{}\",\"{}\",\"{}\"",
169        change_type,
170        escape_csv(&comp.name),
171        escape_csv_opt(comp.old_version.as_deref()),
172        escape_csv_opt(comp.new_version.as_deref()),
173        escape_csv_opt(comp.ecosystem.as_deref())
174    );
175}
176
177fn write_vuln_line(content: &mut String, status: &str, vuln: &VulnerabilityDetail) {
178    let depth_label = match vuln.component_depth {
179        Some(1) => "Direct",
180        Some(_) => "Transitive",
181        None => "-",
182    };
183    let sla_display = format_sla_csv(vuln);
184    let desc = vuln
185        .description
186        .as_deref()
187        .map(escape_csv)
188        .unwrap_or_default();
189    let vex_display = match vuln.vex_state.as_ref() {
190        Some(crate::model::VexState::NotAffected) => "Not Affected",
191        Some(crate::model::VexState::Fixed) => "Fixed",
192        Some(crate::model::VexState::Affected) => "Affected",
193        Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
194        None => "",
195    };
196
197    let _ = writeln!(
198        content,
199        "{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
200        status,
201        escape_csv(&vuln.id),
202        escape_csv(&vuln.severity),
203        depth_label,
204        sla_display,
205        escape_csv(&vuln.component_name),
206        desc,
207        vex_display,
208    );
209}
210
211/// Escape a string for CSV embedding: double-quote escaping per RFC 4180,
212/// plus newline flattening since fields are already wrapped in double quotes.
213///
214/// Values starting with a formula trigger (`=`, `+`, `-`, `@`, tab, CR) are
215/// prefixed with a single quote so spreadsheet applications treat them as
216/// text rather than executable formulas (OWASP CSV injection guidance).
217fn escape_csv(s: &str) -> String {
218    let escaped = s.replace('"', "\"\"").replace('\n', " ");
219    if s.starts_with(['=', '+', '-', '@', '\t', '\r']) {
220        format!("'{escaped}")
221    } else {
222        escaped
223    }
224}
225
226/// Escape an optional string for CSV embedding, returning "-" for `None`.
227fn escape_csv_opt(s: Option<&str>) -> String {
228    s.map_or_else(|| "-".to_string(), escape_csv)
229}
230
231fn format_sla_csv(vuln: &VulnerabilityDetail) -> String {
232    match vuln.sla_status() {
233        SlaStatus::Overdue(days) => format!("{days}d late"),
234        SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
235        SlaStatus::NoDueDate => vuln
236            .days_since_published
237            .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn escape_csv_guards_formula_triggers() {
247        assert_eq!(escape_csv("=1+2"), "'=1+2");
248        assert_eq!(escape_csv("+SUM(A1:A2)"), "'+SUM(A1:A2)");
249        assert_eq!(escape_csv("-2+3"), "'-2+3");
250        assert_eq!(escape_csv("@cmd"), "'@cmd");
251        assert_eq!(escape_csv("\tpayload"), "'\tpayload");
252        assert_eq!(escape_csv("\rpayload"), "'\rpayload");
253        // Scoped npm names share the '@' trigger; the quote prefix keeps them
254        // readable while staying inert in spreadsheets
255        assert_eq!(escape_csv("@types/node"), "'@types/node");
256    }
257
258    #[test]
259    fn escape_csv_guards_formula_after_quote_doubling() {
260        assert_eq!(escape_csv("=cmd|'/c calc'!A0"), "'=cmd|'/c calc'!A0");
261        assert_eq!(escape_csv("=\"evil\""), "'=\"\"evil\"\"");
262    }
263
264    #[test]
265    fn escape_csv_leaves_benign_values_alone() {
266        assert_eq!(escape_csv("lodash"), "lodash");
267        assert_eq!(escape_csv("1.2.3"), "1.2.3");
268        assert_eq!(
269            escape_csv("pkg:npm/lodash@4.17.21"),
270            "pkg:npm/lodash@4.17.21"
271        );
272        assert_eq!(escape_csv("MIT OR Apache-2.0"), "MIT OR Apache-2.0");
273        assert_eq!(escape_csv("name \"quoted\""), "name \"\"quoted\"\"");
274        assert_eq!(escape_csv("line1\nline2"), "line1 line2");
275        assert_eq!(escape_csv(""), "");
276    }
277
278    #[test]
279    fn escape_csv_opt_uses_placeholder_for_none() {
280        assert_eq!(escape_csv_opt(None), "-");
281        assert_eq!(escape_csv_opt(Some("=evil")), "'=evil");
282        assert_eq!(escape_csv_opt(Some("1.0.0")), "1.0.0");
283    }
284}