Files
keyhunter/pkg/output/table.go
salvacybersec 8e4db5db09 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
2026-04-05 23:27:53 +03:00

207 lines
6.0 KiB
Go

package output
import (
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/salvacybersec/keyhunter/pkg/engine"
)
func init() {
Register("table", TableFormatter{})
}
// 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
for _, f := range findings {
if f.Verified {
anyVerified = true
break
}
}
if anyVerified {
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 {
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
}
for _, f := range findings {
keyDisplay := f.KeyMasked
if opts.Unmask {
keyDisplay = f.KeyValue
}
confStyle := styles.low
switch f.Confidence {
case "high":
confStyle = styles.high
case "medium":
confStyle = styles.medium
}
if anyVerified {
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,
verifySymbolStyled(f, styles),
); err != nil {
return err
}
} else {
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.
if len(f.VerifyMetadata) > 0 {
parts := make([]string, 0, len(f.VerifyMetadata))
for k, v := range f.VerifyMetadata {
parts = append(parts, fmt.Sprintf("%s: %s", k, v))
}
sort.Strings(parts)
if _, err := fmt.Fprintf(w, " ↳ %s\n", strings.Join(parts, ", ")); err != nil {
return err
}
}
}
if _, err := fmt.Fprintf(w, "\n%d key(s) found.\n", len(findings)); err != nil {
return err
}
return nil
}
// 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 s.verifyLive.Render("✓ live")
case "dead":
return s.verifyDead.Render("✗ dead")
case "rate_limited":
return s.verifyRate.Render("⚠ rate")
case "error":
return s.verifyErr.Render("! err")
default:
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
}
return "..." + s[len(s)-max+3:]
}