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:
@@ -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
|
// TableFormatter renders findings as a colored terminal table.
|
||||||
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
|
// Colors are emitted only when the destination writer is a TTY and the
|
||||||
// the table and per-finding VerifyMetadata (if present) is rendered on an
|
// NO_COLOR environment variable is not set. When writing to a non-TTY
|
||||||
// indented secondary line. Backward compatible: if no finding is verified,
|
// (pipes, files, bytes.Buffer in tests), all styles collapse to plain
|
||||||
// the layout matches the pre-Phase-5 output exactly.
|
// text so no ANSI escape sequences appear in the output.
|
||||||
func PrintFindings(findings []engine.Finding, unmask bool) {
|
//
|
||||||
|
// 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 {
|
if len(findings) == 0 {
|
||||||
fmt.Println("No API keys found.")
|
_, err := fmt.Fprintln(w, "No API keys found.")
|
||||||
return
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %s\n",
|
if _, err := fmt.Fprintf(w, "%-20s %-40s %-10s %-30s %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"),
|
||||||
)
|
); 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
|
||||||
|
|||||||
Reference in New Issue
Block a user