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
This commit is contained in:
salvacybersec
2026-04-05 23:27:53 +03:00
parent 8c37252c1b
commit 8e4db5db09

View File

@@ -2,6 +2,7 @@ package output
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"sort" "sort"
"strings" "strings"
@@ -10,32 +11,32 @@ import (
"github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/engine"
) )
var ( func init() {
styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green Register("table", TableFormatter{})
styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
styleHeader = lipgloss.NewStyle().Bold(true).Underline(true)
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.
//
// 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) {
if len(findings) == 0 {
fmt.Println("No API keys found.")
return
} }
// TableFormatter renders findings as a colored terminal table.
//
// 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 {
_, err := fmt.Fprintln(w, "No API keys found.")
return err
}
styles := newTableStyles(ColorsEnabled(w))
anyVerified := false anyVerified := false
for _, f := range findings { for _, f := range findings {
if f.Verified { if f.Verified {
@@ -44,60 +45,67 @@ func PrintFindings(findings []engine.Finding, unmask bool) {
} }
} }
// Header
if anyVerified { if anyVerified {
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %-5s %s\n", if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %-5s %s\n",
styleHeader.Render("PROVIDER"), styles.header.Render("PROVIDER"),
styleHeader.Render("KEY"), styles.header.Render("KEY"),
styleHeader.Render("CONFIDENCE"), styles.header.Render("CONFIDENCE"),
styleHeader.Render("SOURCE"), styles.header.Render("SOURCE"),
styleHeader.Render("LINE"), styles.header.Render("LINE"),
styleHeader.Render("VERIFY"), styles.header.Render("VERIFY"),
) ); err != nil {
} else { return err
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %s\n", }
styleHeader.Render("PROVIDER"), } else {
styleHeader.Render("KEY"), if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %s\n",
styleHeader.Render("CONFIDENCE"), styles.header.Render("PROVIDER"),
styleHeader.Render("SOURCE"), styles.header.Render("KEY"),
styleHeader.Render("LINE"), 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 { for _, f := range findings {
keyDisplay := f.KeyMasked keyDisplay := f.KeyMasked
if unmask { if opts.Unmask {
keyDisplay = f.KeyValue keyDisplay = f.KeyValue
} }
confStyle := styleLow confStyle := styles.low
switch f.Confidence { switch f.Confidence {
case "high": case "high":
confStyle = styleHigh confStyle = styles.high
case "medium": case "medium":
confStyle = styleMedium confStyle = styles.medium
} }
if anyVerified { 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, f.ProviderName,
keyDisplay, keyDisplay,
confStyle.Render(f.Confidence), confStyle.Render(f.Confidence),
truncate(f.Source, 28), truncate(f.Source, 28),
f.LineNumber, f.LineNumber,
verifySymbol(f), verifySymbolStyled(f, styles),
) ); err != nil {
return err
}
} else { } else {
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %d\n", if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %d\n",
f.ProviderName, f.ProviderName,
keyDisplay, keyDisplay,
confStyle.Render(f.Confidence), confStyle.Render(f.Confidence),
truncate(f.Source, 28), truncate(f.Source, 28),
f.LineNumber, f.LineNumber,
) ); err != nil {
return err
}
} }
// Indented metadata line with deterministic key order. // 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)) parts = append(parts, fmt.Sprintf("%s: %s", k, v))
} }
sort.Strings(parts) 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. // tableStyles bundles every lipgloss.Style used by TableFormatter so that
// Returns an empty string for unverified findings so the VERIFY column is // callers can construct either a colored or a plain (no-op) style set via
// simply blank in mixed-result tables. // newTableStyles.
func verifySymbol(f engine.Finding) string { 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 { if !f.Verified {
return "" return ""
} }
switch f.VerifyStatus { switch f.VerifyStatus {
case "live": case "live":
return styleVerifyLive.Render("✓ live") return s.verifyLive.Render("✓ live")
case "dead": case "dead":
return styleVerifyDead.Render("✗ dead") return s.verifyDead.Render("✗ dead")
case "rate_limited": case "rate_limited":
return styleVerifyRate.Render("⚠ rate") return s.verifyRate.Render("⚠ rate")
case "error": case "error":
return styleVerifyErr.Render("! err") return s.verifyErr.Render("! err")
default: 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 { func truncate(s string, max int) string {
if len(s) <= max { if len(s) <= max {
return s return s