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