Skip to main content

keyhog_core/report/
junit.rs

1//! Structured JUnit XML findings reporter.
2
3use std::io::Write;
4
5use crate::VerifiedFinding;
6
7use super::{ReportError, Reporter, WriterBackedReporter};
8
9/// Structured JUnit XML findings reporter.
10pub struct JunitReporter<W: Write + Send> {
11    writer: W,
12    findings: Vec<VerifiedFinding>,
13}
14
15impl<W: Write + Send> JunitReporter<W> {
16    /// Create a new JUnit reporter.
17    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('&', "&amp;")
175        .replace('<', "&lt;")
176        .replace('>', "&gt;")
177        .replace('"', "&quot;")
178        .replace('\'', "&apos;")
179}
180
181/// Neutralize the CDATA terminator inside a value written into a `<![CDATA[…]]>`
182/// body. XML escaping does NOT apply inside CDATA, so an attacker-controlled
183/// field (git author/committer name, file path, or redacted credential bytes)
184/// containing the literal `]]>` would otherwise close the section early and
185/// inject arbitrary markup into the JUnit report a CI system ingests. The
186/// standard mitigation splits the `]]>` token across two CDATA sections
187/// (`]]]]><![CDATA[>`); a conformant XML parser rejoins the surrounding text, so
188/// the displayed value is unchanged. Borrows on the common no-terminator path.
189fn 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}