From 8e4db5db097b061eae1d67a812b240ca140045bf Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 23:27:53 +0300 Subject: [PATCH] feat(06-01): refactor table output into TableFormatter - TableFormatter implements Formatter interface, registered as "table" - Writes to arbitrary io.Writer instead of hardcoded os.Stdout - Strips ANSI colors when writer is not a TTY or NO_COLOR is set - Uses bundled tableStyles so plain/colored paths share one renderer - PrintFindings retained as backward-compat wrapper delegating to Format --- pkg/output/table.go | 184 +++++++++++++++++++++++++++++--------------- 1 file changed, 124 insertions(+), 60 deletions(-) diff --git a/pkg/output/table.go b/pkg/output/table.go index 1dee2d2..95c8b31 100644 --- a/pkg/output/table.go +++ b/pkg/output/table.go @@ -2,6 +2,7 @@ package output import ( "fmt" + "io" "os" "sort" "strings" @@ -10,32 +11,32 @@ import ( "github.com/salvacybersec/keyhunter/pkg/engine" ) -var ( - styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - styleHeader = lipgloss.NewStyle().Bold(true).Underline(true) +func init() { + Register("table", TableFormatter{}) +} - styleVerifyLive = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - styleVerifyDead = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - styleVerifyRate = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - styleVerifyErr = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - styleVerifyUnk = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // gray -) - -// PrintFindings writes findings as a colored terminal table to stdout. -// If unmask is true, KeyValue is shown; otherwise KeyMasked is shown. +// TableFormatter renders findings as a colored terminal table. // -// When any finding has Verified=true, an extra VERIFY column is appended to -// the table and per-finding VerifyMetadata (if present) is rendered on an -// indented secondary line. Backward compatible: if no finding is verified, -// the layout matches the pre-Phase-5 output exactly. -func PrintFindings(findings []engine.Finding, unmask bool) { +// Colors are emitted only when the destination writer is a TTY and the +// NO_COLOR environment variable is not set. When writing to a non-TTY +// (pipes, files, bytes.Buffer in tests), all styles collapse to plain +// text so no ANSI escape sequences appear in the output. +// +// When any finding has Verified=true, an extra VERIFY column is appended +// and per-finding VerifyMetadata (if present) is rendered on an indented +// secondary line. Backward compatible: if no finding is verified, the +// layout matches the pre-Phase-5 output exactly. +type TableFormatter struct{} + +// Format implements the Formatter interface. +func (TableFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error { if len(findings) == 0 { - fmt.Println("No API keys found.") - return + _, err := fmt.Fprintln(w, "No API keys found.") + return err } + styles := newTableStyles(ColorsEnabled(w)) + anyVerified := false for _, f := range findings { if f.Verified { @@ -44,60 +45,67 @@ func PrintFindings(findings []engine.Finding, unmask bool) { } } - // Header if anyVerified { - fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %-5s %s\n", - styleHeader.Render("PROVIDER"), - styleHeader.Render("KEY"), - styleHeader.Render("CONFIDENCE"), - styleHeader.Render("SOURCE"), - styleHeader.Render("LINE"), - styleHeader.Render("VERIFY"), - ) + if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %-5s %s\n", + styles.header.Render("PROVIDER"), + styles.header.Render("KEY"), + styles.header.Render("CONFIDENCE"), + styles.header.Render("SOURCE"), + styles.header.Render("LINE"), + styles.header.Render("VERIFY"), + ); err != nil { + return err + } } else { - fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %s\n", - styleHeader.Render("PROVIDER"), - styleHeader.Render("KEY"), - styleHeader.Render("CONFIDENCE"), - styleHeader.Render("SOURCE"), - styleHeader.Render("LINE"), - ) + if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %s\n", + styles.header.Render("PROVIDER"), + styles.header.Render("KEY"), + styles.header.Render("CONFIDENCE"), + styles.header.Render("SOURCE"), + styles.header.Render("LINE"), + ); err != nil { + return err + } + } + if _, err := fmt.Fprintln(w, styles.divider.Render(strings.Repeat("─", 106))); err != nil { + return err } - fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render( - "──────────────────────────────────────────────────────────────────────────────────────────────────────────", - )) for _, f := range findings { keyDisplay := f.KeyMasked - if unmask { + if opts.Unmask { keyDisplay = f.KeyValue } - confStyle := styleLow + confStyle := styles.low switch f.Confidence { case "high": - confStyle = styleHigh + confStyle = styles.high case "medium": - confStyle = styleMedium + confStyle = styles.medium } if anyVerified { - fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %-5d %s\n", + if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %-5d %s\n", f.ProviderName, keyDisplay, confStyle.Render(f.Confidence), truncate(f.Source, 28), f.LineNumber, - verifySymbol(f), - ) + verifySymbolStyled(f, styles), + ); err != nil { + return err + } } else { - fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %d\n", + if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %d\n", f.ProviderName, keyDisplay, confStyle.Render(f.Confidence), truncate(f.Source, 28), f.LineNumber, - ) + ); err != nil { + return err + } } // Indented metadata line with deterministic key order. @@ -107,33 +115,89 @@ func PrintFindings(findings []engine.Finding, unmask bool) { parts = append(parts, fmt.Sprintf("%s: %s", k, v)) } sort.Strings(parts) - fmt.Fprintf(os.Stdout, " ↳ %s\n", strings.Join(parts, ", ")) + if _, err := fmt.Fprintf(w, " ↳ %s\n", strings.Join(parts, ", ")); err != nil { + return err + } } } - fmt.Printf("\n%d key(s) found.\n", len(findings)) + if _, err := fmt.Fprintf(w, "\n%d key(s) found.\n", len(findings)); err != nil { + return err + } + return nil } -// verifySymbol maps a finding's verification status to a colored glyph. -// Returns an empty string for unverified findings so the VERIFY column is -// simply blank in mixed-result tables. -func verifySymbol(f engine.Finding) string { +// tableStyles bundles every lipgloss.Style used by TableFormatter so that +// callers can construct either a colored or a plain (no-op) style set via +// newTableStyles. +type tableStyles struct { + header lipgloss.Style + divider lipgloss.Style + high lipgloss.Style + medium lipgloss.Style + low lipgloss.Style + verifyLive lipgloss.Style + verifyDead lipgloss.Style + verifyRate lipgloss.Style + verifyErr lipgloss.Style + verifyUnk lipgloss.Style +} + +// newTableStyles returns a styled set when colored is true, or a fully plain +// set (every field == zero lipgloss.Style) when false. A zero lipgloss.Style +// passes text through verbatim, which guarantees no ANSI escape sequences +// are emitted on non-TTY writers. +func newTableStyles(colored bool) tableStyles { + if !colored { + plain := lipgloss.NewStyle() + return tableStyles{ + header: plain, divider: plain, + high: plain, medium: plain, low: plain, + verifyLive: plain, verifyDead: plain, verifyRate: plain, + verifyErr: plain, verifyUnk: plain, + } + } + return tableStyles{ + header: lipgloss.NewStyle().Bold(true).Underline(true), + divider: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + high: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), // green + medium: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), // yellow + low: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), // red + verifyLive: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), + verifyDead: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), + verifyRate: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), + verifyErr: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), + verifyUnk: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + } +} + +// verifySymbolStyled maps a finding's verification status to a glyph, styled +// via the provided tableStyles. Returns an empty string for unverified +// findings so the VERIFY column is simply blank in mixed-result tables. +func verifySymbolStyled(f engine.Finding, s tableStyles) string { if !f.Verified { return "" } switch f.VerifyStatus { case "live": - return styleVerifyLive.Render("✓ live") + return s.verifyLive.Render("✓ live") case "dead": - return styleVerifyDead.Render("✗ dead") + return s.verifyDead.Render("✗ dead") case "rate_limited": - return styleVerifyRate.Render("⚠ rate") + return s.verifyRate.Render("⚠ rate") case "error": - return styleVerifyErr.Render("! err") + return s.verifyErr.Render("! err") default: - return styleVerifyUnk.Render("? unk") + return s.verifyUnk.Render("? unk") } } +// PrintFindings writes findings as a colored terminal table to stdout. +// Deprecated: kept for backward compatibility with scan.go. New callers should +// use TableFormatter{}.Format directly or look the formatter up via Get("table"). +func PrintFindings(findings []engine.Finding, unmask bool) { + _ = TableFormatter{}.Format(findings, os.Stdout, Options{Unmask: unmask}) +} + func truncate(s string, max int) string { if len(s) <= max { return s