Skip to main content

keyhog_core/report/
text.rs

1//! Human-readable terminal reporter with severity coloring and rich finding details.
2
3use std::io::IsTerminal;
4use std::io::Write;
5
6use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
7
8use super::{ReportError, Reporter, WriterBackedReporter};
9
10/// Human-readable text output with gradient banner and styled findings.
11///
12/// # Examples
13///
14/// ```rust
15/// use keyhog_core::TextReporter;
16///
17/// let reporter = TextReporter::with_color(Vec::new(), false);
18/// let _ = reporter;
19/// ```
20pub struct TextReporter<W: Write + Send> {
21    writer: W,
22    count: usize,
23    color: bool,
24    live_count: usize,
25    dead_count: usize,
26    /// Number of credentials matched and then suppressed as known
27    /// examples/test/placeholder values. Surfaced in the empty-findings
28    /// summary so "0 secrets" doesn't get conflated with "0 matches at
29    /// all". Set by the caller before `finish()`; default 0 keeps the
30    /// original behavior for callers that don't track it.
31    example_suppressions: usize,
32    /// True when the caller is running with --dogfood (or
33    /// KEYHOG_DOGFOOD=1). The empty-findings line drops the
34    /// "Pass --dogfood to see them" hint in that case, since the user
35    /// has clearly already done so. Set by the caller before
36    /// `finish()`; default false matches the historical behavior.
37    dogfood_active: bool,
38}
39
40impl<W: Write + Send> TextReporter<W> {
41    /// Create a text reporter with ANSI colors enabled when stdout is a TTY.
42    ///
43    /// # Examples
44    ///
45    /// ```rust
46    /// use keyhog_core::TextReporter;
47    ///
48    /// let reporter = TextReporter::new(Vec::new());
49    /// let _ = reporter;
50    /// ```
51    pub fn new(writer: W) -> Self {
52        Self::with_color(writer, std::io::stdout().is_terminal())
53    }
54
55    /// Create a text reporter with explicit ANSI color control.
56    ///
57    /// # Examples
58    ///
59    /// ```rust
60    /// use keyhog_core::TextReporter;
61    ///
62    /// let reporter = TextReporter::with_color(Vec::new(), false);
63    /// let _ = reporter;
64    /// ```
65    pub fn with_color(writer: W, color: bool) -> Self {
66        Self {
67            writer,
68            count: 0,
69            color,
70            live_count: 0,
71            dead_count: 0,
72            example_suppressions: 0,
73            dogfood_active: false,
74        }
75    }
76
77    /// Tell the reporter how many credentials were matched and silently
78    /// suppressed as known example/test/placeholder values. The reporter
79    /// uses this only to phrase the empty-findings summary honestly
80    /// (e.g. demo-secret.env's `AKIAIOSFODNN7EXAMPLE` shouldn't render
81    /// as "Your code is clean"). Idempotent; later calls replace.
82    pub fn set_example_suppressions(&mut self, n: usize) {
83        self.example_suppressions = n;
84    }
85
86    /// Tell the reporter that the caller is already running with
87    /// `--dogfood` (or `KEYHOG_DOGFOOD=1`). Suppresses the
88    /// "Pass --dogfood to see them" hint in the empty-findings line,
89    /// since the user has clearly already passed it. Idempotent.
90    pub fn set_dogfood_active(&mut self, active: bool) {
91        self.dogfood_active = active;
92    }
93
94    fn print_header(&mut self) -> Result<(), ReportError> {
95        Ok(())
96    }
97}
98
99impl<W: Write + Send> Reporter for TextReporter<W> {
100    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError> {
101        if self.count == 0 {
102            self.print_header()?;
103        }
104        self.count += 1;
105
106        // Track verification stats
107        match &finding.verification {
108            VerificationResult::Live => self.live_count += 1,
109            VerificationResult::Dead => self.dead_count += 1,
110            _ => {}
111        }
112
113        let severity_str = format_severity(finding.severity, self.color);
114        let verified = format_verification(&finding.verification, self.color);
115        let location = format_location(&finding.location);
116        let confidence_value = finding.confidence.unwrap_or(0.0);
117        const BAR_WIDTH: usize = 6;
118        let filled = (confidence_value * BAR_WIDTH as f64) as usize;
119        let bar = format!(
120            "{}{}",
121            "■".repeat(filled.min(BAR_WIDTH)),
122            "□".repeat(BAR_WIDTH.saturating_sub(filled.min(BAR_WIDTH)))
123        );
124        let confidence_tone = if confidence_value >= 0.8 {
125            "31"
126        } else if confidence_value >= 0.5 {
127            "33"
128        } else {
129            "90"
130        };
131        let confidence = format!(
132            "{} {}",
133            colorize(&bar, confidence_tone, self.color),
134            colorize(
135                &format!("{:>3}%", (confidence_value * 100.0) as u32),
136                "90",
137                self.color,
138            )
139        );
140
141        // Severity color for the box border
142        let border_ansi = match finding.severity {
143            Severity::Critical => "1;31",
144            Severity::High => "31",
145            Severity::Medium => "33",
146            Severity::Low => "36",
147            Severity::ClientSafe => "2;36",
148            Severity::Info => "90",
149        };
150
151        // Top border with severity and detector name
152        writeln!(
153            self.writer,
154            "  {} {} {}",
155            colorize("┌", border_ansi, self.color),
156            severity_str,
157            colorize(
158                &format!("─── {}", finding.detector_name),
159                border_ansi,
160                self.color,
161            ),
162        )?;
163
164        // Secret
165        writeln!(
166            self.writer,
167            "  {} {} {}",
168            colorize("│", border_ansi, self.color),
169            dim("Secret:    ", self.color),
170            highlight(&finding.credential_redacted, self.color),
171        )?;
172
173        // Location
174        writeln!(
175            self.writer,
176            "  {} {} {}",
177            colorize("│", border_ansi, self.color),
178            dim("Location:  ", self.color),
179            location,
180        )?;
181
182        // Confidence + verification
183        let verify_suffix = if verified.is_empty() {
184            String::new()
185        } else {
186            format!("  ({})", verified)
187        };
188        writeln!(
189            self.writer,
190            "  {} {} {}{}",
191            colorize("│", border_ansi, self.color),
192            dim("Confidence:", self.color),
193            confidence,
194            verify_suffix,
195        )?;
196
197        // Commit info
198        if let Some(commit) = &finding.location.commit {
199            writeln!(
200                self.writer,
201                "  {} {} {}",
202                colorize("│", border_ansi, self.color),
203                dim("Commit:    ", self.color),
204                commit,
205            )?;
206        }
207
208        if let Some(author) = &finding.location.author {
209            writeln!(
210                self.writer,
211                "  {} {} {}",
212                colorize("│", border_ansi, self.color),
213                dim("Author:    ", self.color),
214                author,
215            )?;
216        }
217
218        if let Some(date) = &finding.location.date {
219            writeln!(
220                self.writer,
221                "  {} {} {}",
222                colorize("│", border_ansi, self.color),
223                dim("Date:      ", self.color),
224                date,
225            )?;
226        }
227
228        // Extra metadata
229        for (key, value) in &finding.metadata {
230            writeln!(
231                self.writer,
232                "  {} {} {}",
233                colorize("│", border_ansi, self.color),
234                dim(&format!("{:<11}", format!("{}:", key)), self.color),
235                value,
236            )?;
237        }
238
239        if !finding.additional_locations.is_empty() {
240            writeln!(
241                self.writer,
242                "  {} {} (+{} more locations)",
243                colorize("│", border_ansi, self.color),
244                dim("Extra:     ", self.color),
245                finding.additional_locations.len(),
246            )?;
247        }
248
249        // Remediation
250        let remediation = match finding.severity {
251            Severity::Critical | Severity::High => "Revoke immediately and rotate.",
252            Severity::Medium => "Review usage and rotate if active.",
253            Severity::ClientSafe => {
254                "Public by design (client bundle key); verify scope restrictions."
255            }
256            _ => "Remove from codebase.",
257        };
258        writeln!(
259            self.writer,
260            "  {} {} {}",
261            colorize("│", border_ansi, self.color),
262            dim("Action:    ", self.color),
263            colorize(remediation, "3;32", self.color),
264        )?;
265
266        // Bottom border
267        writeln!(
268            self.writer,
269            "  {}\n",
270            colorize(
271                "└─────────────────────────────────────────────",
272                border_ansi,
273                self.color,
274            ),
275        )?;
276
277        Ok(())
278    }
279
280    fn finish(&mut self) -> Result<(), ReportError> {
281        if self.count == 0 {
282            self.print_header()?;
283            if self.example_suppressions > 0 {
284                let plural = if self.example_suppressions == 1 {
285                    ""
286                } else {
287                    "s"
288                };
289                let msg = if self.dogfood_active {
290                    format!(
291                        "No real secrets, but {} example/test key{} suppressed (see --dogfood output above for the full list).",
292                        self.example_suppressions, plural
293                    )
294                } else {
295                    format!(
296                        "No real secrets, but {} example/test key{} suppressed. Pass --dogfood to see them.",
297                        self.example_suppressions, plural
298                    )
299                };
300                writeln!(self.writer, "  {}\n", colorize(&msg, "1;33", self.color))?;
301            } else {
302                writeln!(
303                    self.writer,
304                    "  {}\n",
305                    colorize("No secrets found. Your code is clean.", "1;32", self.color),
306                )?;
307            }
308        } else {
309            let summary_border = colorize(
310                "━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
311                "90",
312                self.color,
313            );
314            writeln!(self.writer, "  {}", summary_border)?;
315
316            let plural = if self.count == 1 { "" } else { "s" };
317
318            let mut parts = vec![highlight(
319                &format!("{} secret{plural} found", self.count),
320                self.color,
321            )];
322            if self.live_count > 0 {
323                parts.push(colorize(
324                    &format!("{} live", self.live_count),
325                    "1;31",
326                    self.color,
327                ));
328            }
329            if self.dead_count > 0 {
330                parts.push(colorize(
331                    &format!("{} dead", self.dead_count),
332                    "32",
333                    self.color,
334                ));
335            }
336            let unverified = self.count - self.live_count - self.dead_count;
337            if unverified > 0 {
338                parts.push(colorize(
339                    &format!("{unverified} unverified"),
340                    "33",
341                    self.color,
342                ));
343            }
344
345            writeln!(self.writer, "  {}", parts.join(" · "))?;
346
347            // Next steps
348            writeln!(self.writer)?;
349            writeln!(
350                self.writer,
351                "  {} Revoke active secrets in the provider's dashboard.",
352                colorize("1.", "1;31", self.color),
353            )?;
354            writeln!(
355                self.writer,
356                "  {} Remove credentials from codebase and git history.",
357                colorize("2.", "1;33", self.color),
358            )?;
359            writeln!(
360                self.writer,
361                "  {} Use a secure secret manager or environment variables.",
362                colorize("3.", "1;32", self.color),
363            )?;
364
365            let end_border = colorize(
366                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
367                "90",
368                self.color,
369            );
370            writeln!(self.writer, "\n  {}\n", end_border)?;
371        }
372        self.flush_writer()
373    }
374}
375
376impl<W: Write + Send> WriterBackedReporter for TextReporter<W> {
377    type Writer = W;
378
379    fn writer_mut(&mut self) -> &mut Self::Writer {
380        &mut self.writer
381    }
382}
383
384fn format_severity(severity: Severity, color: bool) -> String {
385    let (label, style) = match severity {
386        Severity::Critical => ("CRITICAL", "1;31"),
387        Severity::High => ("HIGH", "31"),
388        Severity::Medium => ("MEDIUM", "33"),
389        Severity::Low => ("LOW", "36"),
390        // Dim cyan + 2;36 (faint cyan). The label is wider than the
391        // others (10 vs 8 chars) so right-pad to 10; the surrounding
392        // `:>8` width fmt is fine for shorter labels but the constant
393        // here matches the longest variant so all bordered boxes line
394        // up regardless of which severity fires.
395        Severity::ClientSafe => ("CLIENT-SAFE", "2;36"),
396        Severity::Info => ("INFO", "90"),
397    };
398    colorize(&format!("{:>11}", label), style, color)
399}
400
401fn format_verification(result: &VerificationResult, color: bool) -> String {
402    match result {
403        VerificationResult::Live => colorize("LIVE", "1;31;43", color),
404        VerificationResult::Revoked => colorize("revoked", "1;33", color),
405        VerificationResult::Dead => colorize("dead", "32", color),
406        VerificationResult::RateLimited => colorize("limited", "33", color),
407        VerificationResult::Error(_) => colorize("error", "33", color),
408        VerificationResult::Unverifiable | VerificationResult::Skipped => String::new(),
409    }
410}
411
412fn format_location(location: &MatchLocation) -> String {
413    match (&location.file_path, location.line) {
414        (Some(path), Some(line)) => format!("{}:{}", strip_unc_prefix(path), line),
415        (Some(path), None) => strip_unc_prefix(path).to_string(),
416        _ => location.source.to_string(),
417    }
418}
419
420/// Strip the Windows extended-length path prefix `\\?\` from a
421/// display string. Paths that start with `\\?\` come from
422/// canonicalize() on Windows and look like `\\?\C:\Users\...` -
423/// technically valid but ugly in CLI output. The prefix is purely
424/// a Win32 escape hatch for >260-char paths; for display, stripping
425/// it gives the user the path they actually typed.
426fn strip_unc_prefix(path: &str) -> &str {
427    path.strip_prefix(r"\\?\").unwrap_or(path)
428}
429
430fn highlight(text: &str, color: bool) -> String {
431    colorize(text, "1", color)
432}
433
434fn dim(text: &str, color: bool) -> String {
435    colorize(text, "90", color)
436}
437
438fn colorize(text: &str, ansi: &str, color: bool) -> String {
439    if color {
440        format!("\x1b[{ansi}m{text}\x1b[0m")
441    } else {
442        text.to_string()
443    }
444}