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(&sanitize_terminal(&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                sanitize_terminal(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                sanitize_terminal(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                sanitize_terminal(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(
235                    &format!("{:<11}", format!("{}:", sanitize_terminal(key))),
236                    self.color
237                ),
238                sanitize_terminal(value),
239            )?;
240        }
241
242        if !finding.additional_locations.is_empty() {
243            writeln!(
244                self.writer,
245                "  {} {} (+{} more locations)",
246                colorize("│", border_ansi, self.color),
247                dim("Extra:     ", self.color),
248                finding.additional_locations.len(),
249            )?;
250        }
251
252        // Remediation
253        let remediation = match finding.severity {
254            Severity::Critical | Severity::High => "Revoke immediately and rotate.",
255            Severity::Medium => "Review usage and rotate if active.",
256            Severity::ClientSafe => {
257                "Public by design (client bundle key); verify scope restrictions."
258            }
259            _ => "Remove from codebase.",
260        };
261        writeln!(
262            self.writer,
263            "  {} {} {}",
264            colorize("│", border_ansi, self.color),
265            dim("Action:    ", self.color),
266            colorize(remediation, "3;32", self.color),
267        )?;
268
269        // Bottom border
270        writeln!(
271            self.writer,
272            "  {}\n",
273            colorize(
274                "└─────────────────────────────────────────────",
275                border_ansi,
276                self.color,
277            ),
278        )?;
279
280        Ok(())
281    }
282
283    fn finish(&mut self) -> Result<(), ReportError> {
284        if self.count == 0 {
285            self.print_header()?;
286            if self.example_suppressions > 0 {
287                let plural = if self.example_suppressions == 1 {
288                    ""
289                } else {
290                    "s"
291                };
292                let msg = if self.dogfood_active {
293                    format!(
294                        "No real secrets, but {} example/test key{} suppressed (see --dogfood output above for the full list).",
295                        self.example_suppressions, plural
296                    )
297                } else {
298                    format!(
299                        "No real secrets, but {} example/test key{} suppressed. Pass --dogfood to see them.",
300                        self.example_suppressions, plural
301                    )
302                };
303                writeln!(self.writer, "  {}\n", colorize(&msg, "1;33", self.color))?;
304            } else {
305                writeln!(
306                    self.writer,
307                    "  {}\n",
308                    colorize("No secrets found. Your code is clean.", "1;32", self.color),
309                )?;
310            }
311        } else {
312            let summary_border = colorize(
313                "━━━ Results ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
314                "90",
315                self.color,
316            );
317            writeln!(self.writer, "  {}", summary_border)?;
318
319            let plural = if self.count == 1 { "" } else { "s" };
320
321            let mut parts = vec![highlight(
322                &format!("{} secret{plural} found", self.count),
323                self.color,
324            )];
325            if self.live_count > 0 {
326                parts.push(colorize(
327                    &format!("{} live", self.live_count),
328                    "1;31",
329                    self.color,
330                ));
331            }
332            if self.dead_count > 0 {
333                parts.push(colorize(
334                    &format!("{} dead", self.dead_count),
335                    "32",
336                    self.color,
337                ));
338            }
339            let unverified = self.count - self.live_count - self.dead_count;
340            if unverified > 0 {
341                parts.push(colorize(
342                    &format!("{unverified} unverified"),
343                    "33",
344                    self.color,
345                ));
346            }
347
348            writeln!(self.writer, "  {}", parts.join(" · "))?;
349
350            // Next steps
351            writeln!(self.writer)?;
352            writeln!(
353                self.writer,
354                "  {} Revoke active secrets in the provider's dashboard.",
355                colorize("1.", "1;31", self.color),
356            )?;
357            writeln!(
358                self.writer,
359                "  {} Remove credentials from codebase and git history.",
360                colorize("2.", "1;33", self.color),
361            )?;
362            writeln!(
363                self.writer,
364                "  {} Use a secure secret manager or environment variables.",
365                colorize("3.", "1;32", self.color),
366            )?;
367
368            let end_border = colorize(
369                "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
370                "90",
371                self.color,
372            );
373            writeln!(self.writer, "\n  {}\n", end_border)?;
374        }
375        self.flush_writer()
376    }
377}
378
379impl<W: Write + Send> WriterBackedReporter for TextReporter<W> {
380    type Writer = W;
381
382    fn writer_mut(&mut self) -> &mut Self::Writer {
383        &mut self.writer
384    }
385}
386
387fn format_severity(severity: Severity, color: bool) -> String {
388    let (label, style) = match severity {
389        Severity::Critical => ("CRITICAL", "1;31"),
390        Severity::High => ("HIGH", "31"),
391        Severity::Medium => ("MEDIUM", "33"),
392        Severity::Low => ("LOW", "36"),
393        // Dim cyan + 2;36 (faint cyan). The label is wider than the
394        // others (10 vs 8 chars) so right-pad to 10; the surrounding
395        // `:>8` width fmt is fine for shorter labels but the constant
396        // here matches the longest variant so all bordered boxes line
397        // up regardless of which severity fires.
398        Severity::ClientSafe => ("CLIENT-SAFE", "2;36"),
399        Severity::Info => ("INFO", "90"),
400    };
401    colorize(&format!("{:>11}", label), style, color)
402}
403
404fn format_verification(result: &VerificationResult, color: bool) -> String {
405    match result {
406        VerificationResult::Live => colorize("LIVE", "1;31;43", color),
407        VerificationResult::Revoked => colorize("revoked", "1;33", color),
408        VerificationResult::Dead => colorize("dead", "32", color),
409        VerificationResult::RateLimited => colorize("limited", "33", color),
410        VerificationResult::Error(_) => colorize("error", "33", color),
411        VerificationResult::Unverifiable | VerificationResult::Skipped => String::new(),
412    }
413}
414
415fn format_location(location: &MatchLocation) -> String {
416    match (&location.file_path, location.line) {
417        (Some(path), Some(line)) => {
418            format!("{}:{}", sanitize_terminal(strip_unc_prefix(path)), line)
419        }
420        (Some(path), None) => sanitize_terminal(strip_unc_prefix(path)).into_owned(),
421        _ => sanitize_terminal(&location.source).into_owned(),
422    }
423}
424
425/// Strip the Windows extended-length path prefix `\\?\` from a
426/// display string. Paths that start with `\\?\` come from
427/// canonicalize() on Windows and look like `\\?\C:\Users\...` -
428/// technically valid but ugly in CLI output. The prefix is purely
429/// a Win32 escape hatch for >260-char paths; for display, stripping
430/// it gives the user the path they actually typed.
431fn strip_unc_prefix(path: &str) -> &str {
432    path.strip_prefix(r"\\?\").unwrap_or(path)
433}
434
435fn highlight(text: &str, color: bool) -> String {
436    colorize(text, "1", color)
437}
438
439fn dim(text: &str, color: bool) -> String {
440    colorize(text, "90", color)
441}
442
443fn colorize(text: &str, ansi: &str, color: bool) -> String {
444    if color {
445        format!("\x1b[{ansi}m{text}\x1b[0m")
446    } else {
447        text.to_string()
448    }
449}
450
451/// True for bytes that can drive a terminal rather than display as text: the C0
452/// controls (0x00-0x1F, incl. ESC/CR/LF/TAB), DEL (0x7F), and the C1 range
453/// (0x80-0x9F). A crafted git author, file path, metadata value, or redacted
454/// credential carrying these would otherwise inject ANSI escapes, cursor moves,
455/// or CR-overwrites into the operator's terminal via the default `text` reporter.
456fn is_terminal_control(c: char) -> bool {
457    let u = c as u32;
458    u < 0x20 || c == '\u{7F}' || (0x80..=0x9F).contains(&u)
459}
460
461/// Replace terminal control characters in an untrusted display value with the
462/// visible replacement char `U+FFFD`, so scan-derived strings cannot inject
463/// escape sequences into the terminal. Borrows on the common clean path.
464fn sanitize_terminal(s: &str) -> std::borrow::Cow<'_, str> {
465    if s.chars().any(is_terminal_control) {
466        std::borrow::Cow::Owned(
467            s.chars()
468                .map(|c| {
469                    if is_terminal_control(c) {
470                        '\u{FFFD}'
471                    } else {
472                        c
473                    }
474                })
475                .collect(),
476        )
477    } else {
478        std::borrow::Cow::Borrowed(s)
479    }
480}