Skip to main content

keyhog_core/report/
csv.rs

1//! Tabular CSV findings reporter.
2
3use std::io::Write;
4
5use crate::VerifiedFinding;
6
7use super::{ReportError, Reporter, WriterBackedReporter};
8
9/// Tabular CSV output.
10pub struct CsvReporter<W: Write + Send> {
11    writer: W,
12}
13
14impl<W: Write + Send> CsvReporter<W> {
15    /// Create a new CSV reporter and write headers.
16    pub fn new(mut writer: W) -> Result<Self, ReportError> {
17        writeln!(
18            writer,
19            "detector_id,detector_name,service,severity,credential_redacted,credential_hash,source,file_path,line,offset,commit,author,date,verification,confidence"
20        )?;
21        Ok(Self { writer })
22    }
23}
24
25impl<W: Write + Send> Reporter for CsvReporter<W> {
26    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
27        let line_str = finding
28            .location
29            .line
30            .map(|l| l.to_string())
31            .unwrap_or_default();
32        let commit_str = finding
33            .location
34            .commit
35            .as_ref()
36            .map(|c| c.as_ref())
37            .unwrap_or_default();
38        let author_str = finding
39            .location
40            .author
41            .as_ref()
42            .map(|a| a.as_ref())
43            .unwrap_or_default();
44        let date_str = finding
45            .location
46            .date
47            .as_ref()
48            .map(|d| d.as_ref())
49            .unwrap_or_default();
50        let file_path_str = finding
51            .location
52            .file_path
53            .as_ref()
54            .map(|f| f.as_ref())
55            .unwrap_or_default();
56        let confidence_str = finding
57            .confidence
58            .map(|c| c.to_string())
59            .unwrap_or_default();
60
61        let verification_str = match &finding.verification {
62            crate::VerificationResult::Live => "live".to_string(),
63            crate::VerificationResult::Revoked => "revoked".to_string(),
64            crate::VerificationResult::Dead => "dead".to_string(),
65            crate::VerificationResult::RateLimited => "rate_limited".to_string(),
66            crate::VerificationResult::Error(err) => format!("error: {err}"),
67            crate::VerificationResult::Unverifiable => "unverifiable".to_string(),
68            crate::VerificationResult::Skipped => "skipped".to_string(),
69        };
70
71        writeln!(
72            self.writer,
73            "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
74            escape_csv(&finding.detector_id),
75            escape_csv(&finding.detector_name),
76            escape_csv(&finding.service),
77            escape_csv(&finding.severity.to_string()),
78            escape_csv(&finding.credential_redacted),
79            escape_csv(&crate::hex_encode(&finding.credential_hash)),
80            escape_csv(&finding.location.source),
81            escape_csv(file_path_str),
82            escape_csv(&line_str),
83            escape_csv(&finding.location.offset.to_string()),
84            escape_csv(commit_str),
85            escape_csv(author_str),
86            escape_csv(date_str),
87            escape_csv(&verification_str),
88            escape_csv(&confidence_str)
89        )?;
90        Ok(())
91    }
92
93    fn finish(&mut self) -> Result<(), ReportError> {
94        self.flush_writer()
95    }
96}
97
98impl<W: Write + Send> WriterBackedReporter for CsvReporter<W> {
99    type Writer = W;
100
101    fn writer_mut(&mut self) -> &mut Self::Writer {
102        &mut self.writer
103    }
104}
105
106fn escape_csv(val: &str) -> String {
107    // Neutralize spreadsheet formula injection (OWASP CSV-injection guidance):
108    // a cell whose first character is `=`, `+`, `-`, `@`, or a leading tab/CR is
109    // evaluated as a formula by Excel/LibreOffice/Sheets after CSV unquoting.
110    // Prefix such attacker-controlled values with a single quote so the cell is
111    // rendered as literal text, then apply the normal RFC-4180 quoting below.
112    let neutralized = match val.as_bytes().first() {
113        Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
114            let mut guarded = String::with_capacity(val.len() + 1);
115            guarded.push('\'');
116            guarded.push_str(val);
117            guarded
118        }
119        _ => val.to_string(),
120    };
121
122    if neutralized.contains(',')
123        || neutralized.contains('"')
124        || neutralized.contains('\n')
125        || neutralized.contains('\r')
126    {
127        let escaped = neutralized.replace('"', "\"\"");
128        format!("\"{}\"", escaped)
129    } else {
130        neutralized
131    }
132}