keyhog_core/report/
csv.rs1use std::io::Write;
4
5use crate::VerifiedFinding;
6
7use super::{ReportError, Reporter, WriterBackedReporter};
8
9pub struct CsvReporter<W: Write + Send> {
11 writer: W,
12}
13
14impl<W: Write + Send> CsvReporter<W> {
15 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 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}