1use std::io::IsTerminal;
4use std::io::Write;
5
6use crate::{MatchLocation, Severity, VerificationResult, VerifiedFinding};
7
8use super::{ReportError, Reporter, WriterBackedReporter};
9
10pub struct TextReporter<W: Write + Send> {
21 writer: W,
22 count: usize,
23 color: bool,
24 live_count: usize,
25 dead_count: usize,
26 example_suppressions: usize,
32 dogfood_active: bool,
38}
39
40impl<W: Write + Send> TextReporter<W> {
41 pub fn new(writer: W) -> Self {
52 Self::with_color(writer, std::io::stdout().is_terminal())
53 }
54
55 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 pub fn set_example_suppressions(&mut self, n: usize) {
83 self.example_suppressions = n;
84 }
85
86 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 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 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 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 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 writeln!(
175 self.writer,
176 " {} {} {}",
177 colorize("│", border_ansi, self.color),
178 dim("Location: ", self.color),
179 location,
180 )?;
181
182 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 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 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 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 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 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 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
425fn 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
451fn is_terminal_control(c: char) -> bool {
457 let u = c as u32;
458 u < 0x20 || c == '\u{7F}' || (0x80..=0x9F).contains(&u)
459}
460
461fn 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}