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:] }