Files
keyhunter/.planning/phases/06-output-reporting/06-01-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-output-reporting 01 execute 0
pkg/output/formatter.go
pkg/output/colors.go
pkg/output/table.go
pkg/output/table_test.go
pkg/output/colors_test.go
pkg/output/formatter_test.go
go.mod
true
OUT-01
OUT-06
truths artifacts key_links
pkg/output exposes a Formatter interface all formats implement
TableFormatter renders findings with colors only on TTY
Non-TTY stdout produces no ANSI escape sequences
An output.Registry maps format names to Formatter implementations
path provides exports
pkg/output/formatter.go Formatter interface, Registry, Options struct
Formatter
Options
Register
Get
ErrUnknownFormat
path provides exports
pkg/output/colors.go TTY detection + profile selection
IsTTY
ColorsEnabled
path provides contains
pkg/output/table.go Refactored TableFormatter implementing Formatter type TableFormatter struct
from to via pattern
pkg/output/table.go pkg/output/formatter.go TableFormatter implements Formatter.Format(findings, w, opts) func (.*TableFormatter) Format
from to via pattern
pkg/output/table.go pkg/output/colors.go Strips lipgloss colors when ColorsEnabled(w) is false ColorsEnabled
Establish the Formatter interface and refactor the existing table output to implement it. Add TTY detection so colored output is only emitted to real terminals, and introduce a Registry so scan.go (Plan 06) can select formatters by name. This is the foundation all other formatter plans build on.

Purpose: Unify output paths under one interface so JSON/SARIF/CSV formatters (Plans 02-03) can be added in parallel. Output: pkg/output/formatter.go, pkg/output/colors.go, refactored pkg/output/table.go, unit tests.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-output-reporting/06-CONTEXT.md @pkg/output/table.go @pkg/engine/finding.go From pkg/engine/finding.go: ```go type Finding struct { ProviderName string KeyValue string KeyMasked string Confidence string // "high"|"medium"|"low" Source string SourceType string LineNumber int Offset int64 DetectedAt time.Time Verified bool VerifyStatus string VerifyHTTPCode int VerifyMetadata map[string]string VerifyError string } func MaskKey(key string) string ```

isatty is ALREADY available as an indirect dep in go.mod (github.com/mattn/go-isatty v0.0.20 via lipgloss). This plan promotes it to a direct dep via go get.

Task 1: Create Formatter interface, Options, Registry, and colors helper pkg/output/formatter.go, pkg/output/colors.go, pkg/output/formatter_test.go, pkg/output/colors_test.go, go.mod - pkg/output/table.go (current API, styles) - pkg/engine/finding.go (Finding struct) - go.mod (isatty already indirect) - Formatter interface: Format(findings []engine.Finding, w io.Writer, opts Options) error - Options: { Unmask bool, ToolName string, ToolVersion string } - Registry: Register(name string, f Formatter), Get(name string) (Formatter, error). ErrUnknownFormat sentinel. - ColorsEnabled(w io.Writer) bool: returns true only if w is *os.File pointing at a TTY AND NO_COLOR env var is unset. - IsTTY(f *os.File) bool: wraps isatty.IsTerminal(f.Fd()). - Test: Register + Get round-trip; Get("nope") returns ErrUnknownFormat. - Test: ColorsEnabled on bytes.Buffer returns false. - Test: ColorsEnabled with NO_COLOR=1 returns false even if TTY would be true (use t.Setenv). 1. Promote isatty to a direct dependency: `go get github.com/mattn/go-isatty@v0.0.20` (already resolved, this just moves it from indirect to direct in go.mod).
2. Create pkg/output/formatter.go:
   ```go
   package output

   import (
       "errors"
       "fmt"
       "io"

       "github.com/salvacybersec/keyhunter/pkg/engine"
   )

   // ErrUnknownFormat is returned by Get when no formatter is registered for the given name.
   var ErrUnknownFormat = errors.New("output: unknown format")

   // Options controls formatter behavior. Unmask reveals full key values.
   // ToolName/ToolVersion are used by SARIF and similar metadata-bearing formats.
   type Options struct {
       Unmask      bool
       ToolName    string
       ToolVersion string
   }

   // Formatter renders a slice of findings to an io.Writer.
   // Implementations must not mutate findings.
   type Formatter interface {
       Format(findings []engine.Finding, w io.Writer, opts Options) error
   }

   var registry = map[string]Formatter{}

   // Register adds a formatter under the given name. Safe to call from init().
   func Register(name string, f Formatter) {
       registry[name] = f
   }

   // Get returns the formatter registered under name, or ErrUnknownFormat.
   func Get(name string) (Formatter, error) {
       f, ok := registry[name]
       if !ok {
           return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, name)
       }
       return f, nil
   }

   // Names returns the sorted list of registered format names (for --help and errors).
   func Names() []string {
       names := make([]string, 0, len(registry))
       for k := range registry {
           names = append(names, k)
       }
       // Sort to stabilize help output.
       for i := 1; i < len(names); i++ {
           for j := i; j > 0 && names[j-1] > names[j]; j-- {
               names[j-1], names[j] = names[j], names[j-1]
           }
       }
       return names
   }
   ```

3. Create pkg/output/colors.go:
   ```go
   package output

   import (
       "io"
       "os"

       "github.com/mattn/go-isatty"
   )

   // IsTTY reports whether f is an open terminal.
   func IsTTY(f *os.File) bool {
       if f == nil {
           return false
       }
       return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
   }

   // ColorsEnabled reports whether ANSI color output should be emitted on w.
   // Returns false when w is not an *os.File, when it is not a TTY, or when
   // the NO_COLOR environment variable is set (https://no-color.org/).
   func ColorsEnabled(w io.Writer) bool {
       if _, ok := os.LookupEnv("NO_COLOR"); ok {
           return false
       }
       f, ok := w.(*os.File)
       if !ok {
           return false
       }
       return IsTTY(f)
   }
   ```

4. Create pkg/output/formatter_test.go with table-driven tests covering Register/Get/ErrUnknownFormat/Names.

5. Create pkg/output/colors_test.go: ColorsEnabled(&bytes.Buffer{})==false; NO_COLOR via t.Setenv forces false.
cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestFormatter|TestColors" -count=1 - `go build ./...` succeeds - `go test ./pkg/output/... -run "TestFormatter|TestColors"` passes - `grep -q "github.com/mattn/go-isatty" go.mod` confirms direct dep - `grep -q "type Formatter interface" pkg/output/formatter.go` - `grep -q "ErrUnknownFormat" pkg/output/formatter.go` Task 2: Refactor table.go into TableFormatter, strip colors for non-TTY, register under "table" pkg/output/table.go, pkg/output/table_test.go - pkg/output/formatter.go (from Task 1) - pkg/output/colors.go (from Task 1) - pkg/output/table.go (current impl) - TableFormatter{} implements Formatter. - Writes to the provided io.Writer (not os.Stdout). - When ColorsEnabled(w)==false, no ANSI escape sequences appear in output (strip by using lipgloss.SetColorProfile or by constructing plain styles). - Preserves existing layout: PROVIDER/KEY/CONFIDENCE/SOURCE/LINE columns; VERIFY column when any finding is verified; indented metadata line. - Empty slice -> "No API keys found.\n". - Non-empty -> footer "\n{N} key(s) found.\n". - Respects opts.Unmask: Unmask=true uses KeyValue, false uses KeyMasked. - Keeps PrintFindings(findings, unmask) as a thin backward-compatible wrapper that delegates to TableFormatter.Format(findings, os.Stdout, Options{Unmask: unmask}) — existing scan.go calls still compile until Plan 06. - Tests assert: (a) empty case string equality; (b) verified+unverified columns; (c) NO_COLOR=1 output contains no "\x1b["; (d) metadata line is sorted. Rewrite pkg/output/table.go:
```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 automatically stripped when the writer is not a TTY or
// when NO_COLOR is set.
type TableFormatter struct{}

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
    }

    colored := ColorsEnabled(w)
    style := newTableStyles(colored)

    anyVerified := false
    for _, f := range findings {
        if f.Verified {
            anyVerified = true
            break
        }
    }

    if anyVerified {
        fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %-5s  %s\n",
            style.header.Render("PROVIDER"),
            style.header.Render("KEY"),
            style.header.Render("CONFIDENCE"),
            style.header.Render("SOURCE"),
            style.header.Render("LINE"),
            style.header.Render("VERIFY"),
        )
    } else {
        fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %s\n",
            style.header.Render("PROVIDER"),
            style.header.Render("KEY"),
            style.header.Render("CONFIDENCE"),
            style.header.Render("SOURCE"),
            style.header.Render("LINE"),
        )
    }
    fmt.Fprintln(w, style.divider.Render(strings.Repeat("─", 106)))

    for _, f := range findings {
        keyDisplay := f.KeyMasked
        if opts.Unmask {
            keyDisplay = f.KeyValue
        }
        confStyle := style.low
        switch f.Confidence {
        case "high":
            confStyle = style.high
        case "medium":
            confStyle = style.medium
        }
        if anyVerified {
            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, style),
            )
        } else {
            fmt.Fprintf(w, "%-20s  %-40s  %-10s  %-30s  %d\n",
                f.ProviderName, keyDisplay, confStyle.Render(f.Confidence),
                truncate(f.Source, 28), f.LineNumber,
            )
        }
        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)
            fmt.Fprintf(w, "    ↳ %s\n", strings.Join(parts, ", "))
        }
    }
    fmt.Fprintf(w, "\n%d key(s) found.\n", len(findings))
    return nil
}

type tableStyles struct {
    header, divider, high, medium, low         lipgloss.Style
    verifyLive, verifyDead, verifyRate, verifyErr, verifyUnk lipgloss.Style
}

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")),
        medium:     lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
        low:        lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
        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")),
    }
}

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 is a backward-compatible wrapper for existing callers.
// Deprecated: use TableFormatter.Format directly.
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:]
}
```

Create pkg/output/table_test.go:
- TestTableFormatter_Empty: asserts exact string "No API keys found.\n" to bytes.Buffer.
- TestTableFormatter_NoColorInBuffer: two findings, one verified; asserts output does NOT contain "\x1b[" (bytes.Buffer is not a TTY).
- TestTableFormatter_UnverifiedLayout: asserts header line does not contain "VERIFY".
- TestTableFormatter_VerifiedLayout: asserts header includes "VERIFY" and row contains "live" when VerifyStatus=="live".
- TestTableFormatter_Masking: Unmask=false renders f.KeyMasked; Unmask=true renders f.KeyValue.
- TestTableFormatter_MetadataSorted: VerifyMetadata={"z":"1","a":"2"} renders "a: 2, z: 1".
cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestTableFormatter" -count=1 && go build ./... - All TestTableFormatter_* tests pass - `go build ./...` succeeds (scan.go still uses PrintFindings wrapper) - `grep -q "Register(\"table\"" pkg/output/table.go` - `grep -q "TableFormatter" pkg/output/table.go` - Test output confirms no "\x1b[" when writing to bytes.Buffer - `go build ./...` succeeds - `go test ./pkg/output/... -count=1` all green - `grep -q "ErrUnknownFormat\|type Formatter interface\|TableFormatter" pkg/output/*.go` - isatty is a direct dependency: `grep -E "^\tgithub.com/mattn/go-isatty" go.mod` (not in indirect block)

<success_criteria>

  • Formatter interface + Registry + Options exist and are tested
  • TableFormatter implements Formatter, registered as "table"
  • Colors stripped when writer is not a TTY or NO_COLOR set
  • Existing PrintFindings wrapper keeps scan.go compiling
  • Foundation ready for JSON/SARIF/CSV formatters in Wave 1 </success_criteria>
After completion, create `.planning/phases/06-output-reporting/06-01-SUMMARY.md`.