docs(06): create phase 6 plans — output formats + key management
This commit is contained in:
441
.planning/phases/06-output-reporting/06-01-PLAN.md
Normal file
441
.planning/phases/06-output-reporting/06-01-PLAN.md
Normal file
@@ -0,0 +1,441 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- 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
|
||||
autonomous: true
|
||||
requirements: [OUT-01, OUT-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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"
|
||||
artifacts:
|
||||
- path: pkg/output/formatter.go
|
||||
provides: "Formatter interface, Registry, Options struct"
|
||||
exports: ["Formatter", "Options", "Register", "Get", "ErrUnknownFormat"]
|
||||
- path: pkg/output/colors.go
|
||||
provides: "TTY detection + profile selection"
|
||||
exports: ["IsTTY", "ColorsEnabled"]
|
||||
- path: pkg/output/table.go
|
||||
provides: "Refactored TableFormatter implementing Formatter"
|
||||
contains: "type TableFormatter struct"
|
||||
key_links:
|
||||
- from: pkg/output/table.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "TableFormatter implements Formatter.Format(findings, w, opts)"
|
||||
pattern: "func \\(.*TableFormatter\\) Format"
|
||||
- from: pkg/output/table.go
|
||||
to: pkg/output/colors.go
|
||||
via: "Strips lipgloss colors when ColorsEnabled(w) is false"
|
||||
pattern: "ColorsEnabled"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
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`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create Formatter interface, Options, Registry, and colors helper</name>
|
||||
<files>pkg/output/formatter.go, pkg/output/colors.go, pkg/output/formatter_test.go, pkg/output/colors_test.go, go.mod</files>
|
||||
<read_first>
|
||||
- pkg/output/table.go (current API, styles)
|
||||
- pkg/engine/finding.go (Finding struct)
|
||||
- go.mod (isatty already indirect)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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).
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestFormatter|TestColors" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `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`
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Refactor table.go into TableFormatter, strip colors for non-TTY, register under "table"</name>
|
||||
<files>pkg/output/table.go, pkg/output/table_test.go</files>
|
||||
<read_first>
|
||||
- pkg/output/formatter.go (from Task 1)
|
||||
- pkg/output/colors.go (from Task 1)
|
||||
- pkg/output/table.go (current impl)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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.
|
||||
</behavior>
|
||||
<action>
|
||||
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".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestTableFormatter" -count=1 && go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- 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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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)
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-01-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user