16 KiB
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 |
|
true |
|
|
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.
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>