keyhog_core/report/
junit.rs1use std::io::Write;
4
5use crate::VerifiedFinding;
6
7use super::{ReportError, Reporter, WriterBackedReporter};
8
9pub struct JunitReporter<W: Write + Send> {
11 writer: W,
12 findings: Vec<VerifiedFinding>,
13}
14
15impl<W: Write + Send> JunitReporter<W> {
16 pub fn new(writer: W) -> Self {
18 Self {
19 writer,
20 findings: Vec::new(),
21 }
22 }
23}
24
25impl<W: Write + Send> Reporter for JunitReporter<W> {
26 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
27 self.findings.push(finding.clone());
28 Ok(())
29 }
30
31 fn finish(&mut self) -> Result<(), ReportError> {
32 writeln!(self.writer, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
33 writeln!(self.writer, "<testsuites>")?;
34
35 let tests_count = self.findings.len();
36 writeln!(
37 self.writer,
38 " <testsuite name=\"keyhog\" tests=\"{}\" failures=\"{}\" errors=\"0\" time=\"0.0\">",
39 tests_count, tests_count
40 )?;
41
42 for finding in &self.findings {
43 let line_str = finding
44 .location
45 .line
46 .map(|l| l.to_string())
47 .unwrap_or_default();
48 let file_path_str = finding
49 .location
50 .file_path
51 .as_ref()
52 .map(|f| f.as_ref())
53 .unwrap_or_default();
54 let case_name = if file_path_str.is_empty() {
55 format!("{}:{}", finding.detector_id, line_str)
56 } else if line_str.is_empty() {
57 format!("{}:{}", file_path_str, finding.detector_id)
58 } else {
59 format!("{}:{}:{}", file_path_str, line_str, finding.detector_id)
60 };
61
62 let confidence_str = finding
63 .confidence
64 .map(|c| c.to_string())
65 .unwrap_or_default();
66 let verification_str = match &finding.verification {
67 crate::VerificationResult::Live => "live".to_string(),
68 crate::VerificationResult::Revoked => "revoked".to_string(),
69 crate::VerificationResult::Dead => "dead".to_string(),
70 crate::VerificationResult::RateLimited => "rate_limited".to_string(),
71 crate::VerificationResult::Error(err) => format!("error: {err}"),
72 crate::VerificationResult::Unverifiable => "unverifiable".to_string(),
73 crate::VerificationResult::Skipped => "skipped".to_string(),
74 };
75
76 writeln!(
77 self.writer,
78 " <testcase name=\"{}\" classname=\"keyhog.findings\" time=\"0.0\">",
79 escape_xml_attr(&case_name)
80 )?;
81
82 let failure_msg = format!(
83 "Secret detected: {} (id: {})",
84 finding.detector_name, finding.detector_id
85 );
86 writeln!(
87 self.writer,
88 " <failure message=\"{}\" type=\"{}\">",
89 escape_xml_attr(&failure_msg),
90 escape_xml_attr(&finding.severity.to_string())
91 )?;
92
93 writeln!(self.writer, " <![CDATA[")?;
94 writeln!(
95 self.writer,
96 "Detector Name: {}",
97 escape_cdata(&finding.detector_name)
98 )?;
99 writeln!(
100 self.writer,
101 "Detector ID: {}",
102 escape_cdata(&finding.detector_id)
103 )?;
104 writeln!(
105 self.writer,
106 "Service: {}",
107 escape_cdata(&finding.service)
108 )?;
109 writeln!(self.writer, "Severity: {}", finding.severity)?;
110 writeln!(
111 self.writer,
112 "Source: {}",
113 escape_cdata(&finding.location.source)
114 )?;
115 if !file_path_str.is_empty() {
116 writeln!(
117 self.writer,
118 "File Path: {}",
119 escape_cdata(file_path_str)
120 )?;
121 }
122 if !line_str.is_empty() {
123 writeln!(self.writer, "Line: {}", line_str)?;
124 }
125 writeln!(self.writer, "Offset: {}", finding.location.offset)?;
126 if let Some(c) = &finding.location.commit {
127 writeln!(self.writer, "Commit: {}", escape_cdata(c))?;
128 }
129 if let Some(a) = &finding.location.author {
130 writeln!(self.writer, "Author: {}", escape_cdata(a))?;
131 }
132 if let Some(d) = &finding.location.date {
133 writeln!(self.writer, "Date: {}", escape_cdata(d))?;
134 }
135 writeln!(
136 self.writer,
137 "Redacted: {}",
138 escape_cdata(&finding.credential_redacted)
139 )?;
140 writeln!(
141 self.writer,
142 "Hash: {}",
143 crate::hex_encode(&finding.credential_hash)
144 )?;
145 writeln!(
146 self.writer,
147 "Verification: {}",
148 escape_cdata(&verification_str)
149 )?;
150 if !confidence_str.is_empty() {
151 writeln!(self.writer, "Confidence: {}", confidence_str)?;
152 }
153 writeln!(self.writer, " ]]>")?;
154 writeln!(self.writer, " </failure>")?;
155 writeln!(self.writer, " </testcase>")?;
156 }
157
158 writeln!(self.writer, " </testsuite>")?;
159 writeln!(self.writer, "</testsuites>")?;
160
161 self.flush_writer()
162 }
163}
164
165impl<W: Write + Send> WriterBackedReporter for JunitReporter<W> {
166 type Writer = W;
167
168 fn writer_mut(&mut self) -> &mut Self::Writer {
169 &mut self.writer
170 }
171}
172
173fn escape_xml_attr(val: &str) -> String {
174 val.replace('&', "&")
175 .replace('<', "<")
176 .replace('>', ">")
177 .replace('"', """)
178 .replace('\'', "'")
179}
180
181fn escape_cdata(val: &str) -> std::borrow::Cow<'_, str> {
190 if val.contains("]]>") {
191 std::borrow::Cow::Owned(val.replace("]]>", "]]]]><![CDATA[>"))
192 } else {
193 std::borrow::Cow::Borrowed(val)
194 }
195}