Skip to main content

keyhog_core/report/
html.rs

1//! Dynamic themed HTML findings reporter.
2
3use std::io::Write;
4
5use crate::VerifiedFinding;
6
7use super::{ReportError, Reporter, WriterBackedReporter};
8
9/// Make a serialized JSON string safe to inline inside an HTML `<script>`
10/// element's raw-text content.
11///
12/// `serde_json` escapes JSON string syntax but leaves `<`, `>`, and `/`
13/// untouched, so an attacker-controlled field containing the byte sequence
14/// `</script>` (file path, git author, redacted credential preview, metadata
15/// value, ...) would terminate the script element in the browser's HTML parser
16/// and execute injected markup (stored XSS). Escaping `<`, `>`, and `/` to
17/// `\uXXXX` JSON escapes makes it impossible for `</script` (or any tag close)
18/// to appear in the raw text while still producing a value that `JSON.parse`
19/// and a JS object literal decode to exactly the original string.
20fn escape_for_script(serialized: &str) -> String {
21    let mut out = String::with_capacity(serialized.len());
22    for ch in serialized.chars() {
23        match ch {
24            '<' => out.push_str("\\u003c"),
25            '>' => out.push_str("\\u003e"),
26            '/' => out.push_str("\\u002f"),
27            // U+2028 / U+2029 are valid in JSON but terminate JS statements.
28            '\u{2028}' => out.push_str("\\u2028"),
29            '\u{2029}' => out.push_str("\\u2029"),
30            other => out.push(other),
31        }
32    }
33    out
34}
35
36/// Dynamic themed HTML findings reporter.
37pub struct HtmlReporter<W: Write + Send> {
38    writer: W,
39    findings: Vec<VerifiedFinding>,
40}
41
42impl<W: Write + Send> HtmlReporter<W> {
43    /// Create a new HTML reporter.
44    pub fn new(writer: W) -> Self {
45        Self {
46            writer,
47            findings: Vec::new(),
48        }
49    }
50}
51
52impl<W: Write + Send> Reporter for HtmlReporter<W> {
53    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
54        self.findings.push(finding.clone());
55        Ok(())
56    }
57
58    fn finish(&mut self) -> Result<(), ReportError> {
59        // VerifiedFinding::verification serializes its unit variants as plain
60        // strings ("dead"/"live"/…) but the Error(String) variant as an object
61        // ({"error":"…"}). The report JS treats `verification` as a string
62        // everywhere (f.verification.toLowerCase()), so an Error finding crashed
63        // the page (blank render). Flatten the object form to the bare "error"
64        // discriminant — uniform with the other variants — before inlining, so
65        // every finding renders. (Full error text is still in json/csv/sarif.)
66        let mut findings_value = serde_json::to_value(&self.findings)?;
67        if let Some(arr) = findings_value.as_array_mut() {
68            for finding in arr {
69                if let Some(v) = finding.get_mut("verification") {
70                    if v.as_object().is_some_and(|o| o.contains_key("error")) {
71                        *v = serde_json::Value::String("error".to_string());
72                    }
73                }
74            }
75        }
76        let serialized_findings = escape_for_script(&serde_json::to_string(&findings_value)?);
77
78        writeln!(self.writer, "<!DOCTYPE html>")?;
79        writeln!(self.writer, "<html lang=\"en\" data-theme=\"obsidian\">")?;
80        writeln!(self.writer, "<head>")?;
81        writeln!(self.writer, "  <meta charset=\"UTF-8\">")?;
82        writeln!(
83            self.writer,
84            "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
85        )?;
86        writeln!(self.writer, "  <title>KeyHog Secret Scan Report</title>")?;
87        writeln!(self.writer, "  <style>")?;
88        writeln!(self.writer, "{}", include_str!("html_styles.css"))?;
89        writeln!(self.writer, "  </style>")?;
90        writeln!(self.writer, "</head>")?;
91        writeln!(self.writer, "<body>")?;
92
93        writeln!(self.writer, "{}", include_str!("html_body.html"))?;
94
95        writeln!(self.writer, "  <script>")?;
96        writeln!(
97            self.writer,
98            "    const rawFindings = {};",
99            serialized_findings
100        )?;
101        writeln!(self.writer, "{}", include_str!("html_script.js"))?;
102        writeln!(self.writer, "  </script>")?;
103        writeln!(self.writer, "</body>")?;
104        writeln!(self.writer, "</html>")?;
105
106        self.flush_writer()
107    }
108}
109
110impl<W: Write + Send> WriterBackedReporter for HtmlReporter<W> {
111    type Writer = W;
112
113    fn writer_mut(&mut self) -> &mut Self::Writer {
114        &mut self.writer
115    }
116}