keyhog-core 0.5.40

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
//! Dynamic themed HTML findings reporter.

use std::io::Write;

use crate::VerifiedFinding;

use super::{ReportError, Reporter, WriterBackedReporter};

/// Make a serialized JSON string safe to inline inside an HTML `<script>`
/// element's raw-text content.
///
/// `serde_json` escapes JSON string syntax but leaves `<`, `>`, and `/`
/// untouched, so an attacker-controlled field containing the byte sequence
/// `</script>` (file path, git author, redacted credential preview, metadata
/// value, ...) would terminate the script element in the browser's HTML parser
/// and execute injected markup (stored XSS). Escaping `<`, `>`, and `/` to
/// `\uXXXX` JSON escapes makes it impossible for `</script` (or any tag close)
/// to appear in the raw text while still producing a value that `JSON.parse`
/// and a JS object literal decode to exactly the original string.
fn escape_for_script(serialized: &str) -> String {
    let mut out = String::with_capacity(serialized.len());
    for ch in serialized.chars() {
        match ch {
            '<' => out.push_str("\\u003c"),
            '>' => out.push_str("\\u003e"),
            '/' => out.push_str("\\u002f"),
            // U+2028 / U+2029 are valid in JSON but terminate JS statements.
            '\u{2028}' => out.push_str("\\u2028"),
            '\u{2029}' => out.push_str("\\u2029"),
            other => out.push(other),
        }
    }
    out
}

/// Dynamic themed HTML findings reporter.
pub struct HtmlReporter<W: Write + Send> {
    writer: W,
    findings: Vec<VerifiedFinding>,
}

impl<W: Write + Send> HtmlReporter<W> {
    /// Create a new HTML reporter.
    pub fn new(writer: W) -> Self {
        Self {
            writer,
            findings: Vec::new(),
        }
    }
}

impl<W: Write + Send> Reporter for HtmlReporter<W> {
    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
        self.findings.push(finding.clone());
        Ok(())
    }

    fn finish(&mut self) -> Result<(), ReportError> {
        // VerifiedFinding::verification serializes its unit variants as plain
        // strings ("dead"/"live"/…) but the Error(String) variant as an object
        // ({"error":"…"}). The report JS treats `verification` as a string
        // everywhere (f.verification.toLowerCase()), so an Error finding crashed
        // the page (blank render). Flatten the object form to the bare "error"
        // discriminant — uniform with the other variants — before inlining, so
        // every finding renders. (Full error text is still in json/csv/sarif.)
        let mut findings_value = serde_json::to_value(&self.findings)?;
        if let Some(arr) = findings_value.as_array_mut() {
            for finding in arr {
                if let Some(v) = finding.get_mut("verification") {
                    if v.as_object().is_some_and(|o| o.contains_key("error")) {
                        *v = serde_json::Value::String("error".to_string());
                    }
                }
            }
        }
        let serialized_findings = escape_for_script(&serde_json::to_string(&findings_value)?);

        writeln!(self.writer, "<!DOCTYPE html>")?;
        writeln!(self.writer, "<html lang=\"en\" data-theme=\"obsidian\">")?;
        writeln!(self.writer, "<head>")?;
        writeln!(self.writer, "  <meta charset=\"UTF-8\">")?;
        writeln!(
            self.writer,
            "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
        )?;
        writeln!(self.writer, "  <title>KeyHog Secret Scan Report</title>")?;
        writeln!(self.writer, "  <style>")?;
        writeln!(self.writer, "{}", include_str!("html_styles.css"))?;
        writeln!(self.writer, "  </style>")?;
        writeln!(self.writer, "</head>")?;
        writeln!(self.writer, "<body>")?;

        writeln!(self.writer, "{}", include_str!("html_body.html"))?;

        writeln!(self.writer, "  <script>")?;
        writeln!(
            self.writer,
            "    const rawFindings = {};",
            serialized_findings
        )?;
        writeln!(self.writer, "{}", include_str!("html_script.js"))?;
        writeln!(self.writer, "  </script>")?;
        writeln!(self.writer, "</body>")?;
        writeln!(self.writer, "</html>")?;

        self.flush_writer()
    }
}

impl<W: Write + Send> WriterBackedReporter for HtmlReporter<W> {
    type Writer = W;

    fn writer_mut(&mut self) -> &mut Self::Writer {
        &mut self.writer
    }
}