docs(06): create phase 6 plans — output formats + key management
This commit is contained in:
@@ -139,7 +139,15 @@ Plans:
|
||||
3. `keyhunter keys list` shows all stored keys masked; `keyhunter keys show <id>` shows full unmasked detail
|
||||
4. `keyhunter keys export --format=json` produces a JSON file with full key values; `--format=csv` produces a CSV
|
||||
5. `keyhunter keys copy <id>` copies the full key to clipboard; `keyhunter keys delete <id>` removes the key from the database
|
||||
**Plans**: TBD
|
||||
**Plans**: 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 06-01-PLAN.md — Wave 0: Formatter interface, colors.go (TTY/NO_COLOR), refactor TableFormatter
|
||||
- [ ] 06-02-PLAN.md — JSONFormatter + CSVFormatter (full Finding fields, Unmask option)
|
||||
- [ ] 06-03-PLAN.md — SARIF 2.1.0 formatter with custom structs (rule dedup, level mapping)
|
||||
- [ ] 06-04-PLAN.md — pkg/storage/queries.go: Filters, ListFindingsFiltered, GetFinding, DeleteFinding
|
||||
- [ ] 06-05-PLAN.md — cmd/keys.go command tree: list/show/export/copy/delete/verify (KEYS-01..06)
|
||||
- [ ] 06-06-PLAN.md — scan --output registry dispatch + exit codes 0/1/2 (OUT-05, OUT-06)
|
||||
|
||||
### Phase 7: Import Adapters & CI/CD Integration
|
||||
**Goal**: Users can import findings from TruffleHog and Gitleaks into KeyHunter's database, and use KeyHunter in pre-commit hooks and CI/CD pipelines with SARIF output uploadable to GitHub Security
|
||||
|
||||
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>
|
||||
275
.planning/phases/06-output-reporting/06-02-PLAN.md
Normal file
275
.planning/phases/06-output-reporting/06-02-PLAN.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: [06-01]
|
||||
files_modified:
|
||||
- pkg/output/json.go
|
||||
- pkg/output/csv.go
|
||||
- pkg/output/json_test.go
|
||||
- pkg/output/csv_test.go
|
||||
autonomous: true
|
||||
requirements: [OUT-02, OUT-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scan results can be rendered as well-formed JSON (one array of finding objects)"
|
||||
- "scan results can be rendered as CSV with a stable header row"
|
||||
- "Both formatters honor the Unmask option for KeyValue exposure"
|
||||
- "Both formatters are registered in output.Registry under 'json' and 'csv'"
|
||||
artifacts:
|
||||
- path: pkg/output/json.go
|
||||
provides: "JSONFormatter implementing Formatter"
|
||||
contains: "type JSONFormatter struct"
|
||||
- path: pkg/output/csv.go
|
||||
provides: "CSVFormatter implementing Formatter"
|
||||
contains: "type CSVFormatter struct"
|
||||
key_links:
|
||||
- from: pkg/output/json.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "init() Register(\"json\", JSONFormatter{})"
|
||||
pattern: "Register\\(\"json\""
|
||||
- from: pkg/output/csv.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "init() Register(\"csv\", CSVFormatter{})"
|
||||
pattern: "Register\\(\"csv\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement JSONFormatter (full Finding serialization) and CSVFormatter (header row + flat rows) so `keyhunter scan --output=json` and `--output=csv` work end to end after Plan 06 wires the scan command.
|
||||
|
||||
Purpose: Machine-readable outputs for pipelines and spreadsheets. Addresses OUT-02 and OUT-04.
|
||||
Output: `pkg/output/json.go`, `pkg/output/csv.go`, 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/phases/06-output-reporting/06-CONTEXT.md
|
||||
@.planning/phases/06-output-reporting/06-01-PLAN.md
|
||||
@pkg/engine/finding.go
|
||||
|
||||
<interfaces>
|
||||
From Plan 06-01 (pkg/output/formatter.go):
|
||||
```go
|
||||
type Formatter interface {
|
||||
Format(findings []engine.Finding, w io.Writer, opts Options) error
|
||||
}
|
||||
type Options struct {
|
||||
Unmask bool
|
||||
ToolName string
|
||||
ToolVersion string
|
||||
}
|
||||
func Register(name string, f Formatter)
|
||||
```
|
||||
From pkg/engine/finding.go: full Finding struct with Verify* fields.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: JSONFormatter with full finding fields</name>
|
||||
<files>pkg/output/json.go, pkg/output/json_test.go</files>
|
||||
<read_first>
|
||||
- pkg/output/formatter.go (Formatter interface, Options)
|
||||
- pkg/engine/finding.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Output is a JSON array: `[{...}, {...}]` with 2-space indent.
|
||||
- Each element includes: provider, key (full when Unmask, masked when not), key_masked, confidence, source, source_type, line, offset, detected_at (RFC3339), verified, verify_status, verify_http_code, verify_metadata, verify_error.
|
||||
- Empty findings slice -> `[]\n`.
|
||||
- Uses encoding/json Encoder with SetIndent("", " ").
|
||||
- Tests: (a) empty slice -> "[]\n"; (b) one finding round-trips through json.Unmarshal with key==KeyMasked when Unmask=false; (c) Unmask=true sets key==KeyValue; (d) verify fields present when Verified=true.
|
||||
</behavior>
|
||||
<action>
|
||||
Create pkg/output/json.go:
|
||||
|
||||
```go
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("json", JSONFormatter{})
|
||||
}
|
||||
|
||||
// JSONFormatter renders findings as a JSON array with 2-space indent.
|
||||
type JSONFormatter struct{}
|
||||
|
||||
type jsonFinding struct {
|
||||
Provider string `json:"provider"`
|
||||
Key string `json:"key"`
|
||||
KeyMasked string `json:"key_masked"`
|
||||
Confidence string `json:"confidence"`
|
||||
Source string `json:"source"`
|
||||
SourceType string `json:"source_type"`
|
||||
Line int `json:"line"`
|
||||
Offset int64 `json:"offset"`
|
||||
DetectedAt string `json:"detected_at"`
|
||||
Verified bool `json:"verified"`
|
||||
VerifyStatus string `json:"verify_status,omitempty"`
|
||||
VerifyHTTPCode int `json:"verify_http_code,omitempty"`
|
||||
VerifyMetadata map[string]string `json:"verify_metadata,omitempty"`
|
||||
VerifyError string `json:"verify_error,omitempty"`
|
||||
}
|
||||
|
||||
func (JSONFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
|
||||
out := make([]jsonFinding, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
key := f.KeyMasked
|
||||
if opts.Unmask {
|
||||
key = f.KeyValue
|
||||
}
|
||||
out = append(out, jsonFinding{
|
||||
Provider: f.ProviderName,
|
||||
Key: key,
|
||||
KeyMasked: f.KeyMasked,
|
||||
Confidence: f.Confidence,
|
||||
Source: f.Source,
|
||||
SourceType: f.SourceType,
|
||||
Line: f.LineNumber,
|
||||
Offset: f.Offset,
|
||||
DetectedAt: f.DetectedAt.Format(time.RFC3339),
|
||||
Verified: f.Verified,
|
||||
VerifyStatus: f.VerifyStatus,
|
||||
VerifyHTTPCode: f.VerifyHTTPCode,
|
||||
VerifyMetadata: f.VerifyMetadata,
|
||||
VerifyError: f.VerifyError,
|
||||
})
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
```
|
||||
|
||||
Create pkg/output/json_test.go with tests for empty, masked, unmask, verify fields. Use json.Unmarshal to assert field values.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestJSONFormatter" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- TestJSONFormatter_* all pass
|
||||
- `grep -q "Register(\"json\"" pkg/output/json.go`
|
||||
- `go build ./...` succeeds
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: CSVFormatter with stable header row</name>
|
||||
<files>pkg/output/csv.go, pkg/output/csv_test.go</files>
|
||||
<read_first>
|
||||
- pkg/output/formatter.go
|
||||
- pkg/engine/finding.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Header: id,provider,confidence,key,source,line,detected_at,verified,verify_status
|
||||
- id is the zero-based index within the findings slice (scan-time id; DB id not available here).
|
||||
- key column renders KeyMasked when Unmask=false, KeyValue when Unmask=true.
|
||||
- verified column is "true"/"false".
|
||||
- Uses encoding/csv Writer; flushes on return.
|
||||
- Empty findings still writes header row only.
|
||||
- Tests: header presence; masked vs unmask; CSV quoting of comma in Source; verify_status column populated.
|
||||
</behavior>
|
||||
<action>
|
||||
Create pkg/output/csv.go:
|
||||
|
||||
```go
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("csv", CSVFormatter{})
|
||||
}
|
||||
|
||||
// CSVFormatter renders findings as comma-separated values with a fixed header row.
|
||||
type CSVFormatter struct{}
|
||||
|
||||
var csvHeader = []string{
|
||||
"id", "provider", "confidence", "key", "source",
|
||||
"line", "detected_at", "verified", "verify_status",
|
||||
}
|
||||
|
||||
func (CSVFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
|
||||
cw := csv.NewWriter(w)
|
||||
if err := cw.Write(csvHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, f := range findings {
|
||||
key := f.KeyMasked
|
||||
if opts.Unmask {
|
||||
key = f.KeyValue
|
||||
}
|
||||
row := []string{
|
||||
strconv.Itoa(i),
|
||||
f.ProviderName,
|
||||
f.Confidence,
|
||||
key,
|
||||
f.Source,
|
||||
strconv.Itoa(f.LineNumber),
|
||||
f.DetectedAt.Format(time.RFC3339),
|
||||
strconv.FormatBool(f.Verified),
|
||||
f.VerifyStatus,
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
```
|
||||
|
||||
Create pkg/output/csv_test.go:
|
||||
- TestCSVFormatter_HeaderOnly: empty findings -> single header line.
|
||||
- TestCSVFormatter_Row: one finding, parse with csv.NewReader, assert fields.
|
||||
- TestCSVFormatter_QuotesCommaInSource: Source="a, b.txt" round-trips via csv reader.
|
||||
- TestCSVFormatter_Unmask: Unmask=true puts KeyValue into key column.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestCSVFormatter" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- TestCSVFormatter_* all pass
|
||||
- `grep -q "Register(\"csv\"" pkg/output/csv.go`
|
||||
- Header exactly matches: `grep -q 'id", "provider", "confidence", "key", "source"' pkg/output/csv.go`
|
||||
- `go build ./...` succeeds
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./pkg/output/... -count=1` all green
|
||||
- Both formats registered: `grep -h "Register(" pkg/output/*.go` shows table, json, csv (sarif added in Plan 03)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- JSONFormatter and CSVFormatter implement Formatter
|
||||
- Both are registered on package init
|
||||
- Unmask option propagates to key column
|
||||
- All unit tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-02-SUMMARY.md`.
|
||||
</output>
|
||||
302
.planning/phases/06-output-reporting/06-03-PLAN.md
Normal file
302
.planning/phases/06-output-reporting/06-03-PLAN.md
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: [06-01]
|
||||
files_modified:
|
||||
- pkg/output/sarif.go
|
||||
- pkg/output/sarif_test.go
|
||||
autonomous: true
|
||||
requirements: [OUT-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scan results can be rendered as SARIF 2.1.0 JSON suitable for GitHub Security upload"
|
||||
- "one SARIF rule is emitted per distinct provider observed in the findings"
|
||||
- "each result maps confidence to SARIF level (high=error, medium=warning, low=note)"
|
||||
- "each result has a physicalLocation with artifactLocation.uri and region.startLine"
|
||||
artifacts:
|
||||
- path: pkg/output/sarif.go
|
||||
provides: "SARIFFormatter + SARIF 2.1.0 structs"
|
||||
contains: "SARIFFormatter"
|
||||
key_links:
|
||||
- from: pkg/output/sarif.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "init() Register(\"sarif\", SARIFFormatter{})"
|
||||
pattern: "Register\\(\"sarif\""
|
||||
- from: pkg/output/sarif.go
|
||||
to: "SARIF 2.1.0 schema"
|
||||
via: "$schema + version fields"
|
||||
pattern: "2.1.0"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement a SARIF 2.1.0 formatter using hand-rolled structs (CLAUDE.md constraint: no SARIF library). Emits a schema-valid report that GitHub's code scanning accepts on upload.
|
||||
|
||||
Purpose: CI/CD integration (CICD-02 downstream in Phase 7 depends on this). Addresses OUT-03.
|
||||
Output: `pkg/output/sarif.go`, `pkg/output/sarif_test.go`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/06-output-reporting/06-CONTEXT.md
|
||||
@.planning/phases/06-output-reporting/06-01-PLAN.md
|
||||
@pkg/engine/finding.go
|
||||
|
||||
<interfaces>
|
||||
From Plan 06-01:
|
||||
```go
|
||||
type Formatter interface {
|
||||
Format(findings []engine.Finding, w io.Writer, opts Options) error
|
||||
}
|
||||
type Options struct {
|
||||
Unmask bool
|
||||
ToolName string // "keyhunter"
|
||||
ToolVersion string // e.g. "0.6.0"
|
||||
}
|
||||
```
|
||||
|
||||
SARIF 2.1.0 reference minimal shape:
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"version": "2.1.0",
|
||||
"runs": [{
|
||||
"tool": { "driver": { "name": "...", "version": "...", "rules": [{"id":"...","name":"...","shortDescription":{"text":"..."}}] } },
|
||||
"results": [{
|
||||
"ruleId": "...",
|
||||
"level": "error|warning|note",
|
||||
"message": { "text": "..." },
|
||||
"locations": [{
|
||||
"physicalLocation": {
|
||||
"artifactLocation": { "uri": "..." },
|
||||
"region": { "startLine": 1 }
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: SARIF 2.1.0 struct definitions + SARIFFormatter</name>
|
||||
<files>pkg/output/sarif.go, pkg/output/sarif_test.go</files>
|
||||
<read_first>
|
||||
- pkg/output/formatter.go
|
||||
- pkg/output/json.go (for consistent encoding style)
|
||||
- pkg/engine/finding.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- SARIFFormatter implements Formatter.
|
||||
- Output JSON contains top-level $schema="https://json.schemastore.org/sarif-2.1.0.json" and version="2.1.0".
|
||||
- runs[0].tool.driver.name = opts.ToolName (fallback "keyhunter"), version = opts.ToolVersion (fallback "dev").
|
||||
- rules are deduped by provider name; rule.id == provider name; rule.name == provider name; rule.shortDescription.text = "Leaked <provider> API key".
|
||||
- results: one per finding. ruleId = providerName. level: high->"error", medium->"warning", low->"note", default "warning".
|
||||
- message.text: "Detected <provider> key (<confidence>): <key>" where key is masked unless opts.Unmask.
|
||||
- locations[0].physicalLocation.artifactLocation.uri = f.Source (unchanged path).
|
||||
- locations[0].physicalLocation.region.startLine = max(1, f.LineNumber) (SARIF requires >= 1).
|
||||
- Empty findings: still emit a valid SARIF doc with empty rules and empty results.
|
||||
- Tests:
|
||||
* TestSARIF_Empty: parse output, assert version=="2.1.0", len(runs)==1, len(results)==0, len(rules)==0.
|
||||
* TestSARIF_DedupRules: two findings same provider -> len(rules)==1.
|
||||
* TestSARIF_LevelMapping: high/medium/low -> error/warning/note.
|
||||
* TestSARIF_LineFloor: f.LineNumber=0 -> region.startLine==1.
|
||||
* TestSARIF_Masking: opts.Unmask=false -> message.text contains KeyMasked, not KeyValue.
|
||||
* TestSARIF_ToolVersionFallback: empty Options uses "keyhunter"/"dev".
|
||||
</behavior>
|
||||
<action>
|
||||
Create pkg/output/sarif.go:
|
||||
|
||||
```go
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register("sarif", SARIFFormatter{})
|
||||
}
|
||||
|
||||
// SARIFFormatter emits SARIF 2.1.0 JSON suitable for CI uploads.
|
||||
type SARIFFormatter struct{}
|
||||
|
||||
type sarifDoc struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version string `json:"version"`
|
||||
Runs []sarifRun `json:"runs"`
|
||||
}
|
||||
|
||||
type sarifRun struct {
|
||||
Tool sarifTool `json:"tool"`
|
||||
Results []sarifResult `json:"results"`
|
||||
}
|
||||
|
||||
type sarifTool struct {
|
||||
Driver sarifDriver `json:"driver"`
|
||||
}
|
||||
|
||||
type sarifDriver struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Rules []sarifRule `json:"rules"`
|
||||
}
|
||||
|
||||
type sarifRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortDescription sarifText `json:"shortDescription"`
|
||||
}
|
||||
|
||||
type sarifText struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type sarifResult struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
Level string `json:"level"`
|
||||
Message sarifText `json:"message"`
|
||||
Locations []sarifLocation `json:"locations"`
|
||||
}
|
||||
|
||||
type sarifLocation struct {
|
||||
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
|
||||
}
|
||||
|
||||
type sarifPhysicalLocation struct {
|
||||
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
|
||||
Region sarifRegion `json:"region"`
|
||||
}
|
||||
|
||||
type sarifArtifactLocation struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
type sarifRegion struct {
|
||||
StartLine int `json:"startLine"`
|
||||
}
|
||||
|
||||
func (SARIFFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
|
||||
toolName := opts.ToolName
|
||||
if toolName == "" {
|
||||
toolName = "keyhunter"
|
||||
}
|
||||
toolVersion := opts.ToolVersion
|
||||
if toolVersion == "" {
|
||||
toolVersion = "dev"
|
||||
}
|
||||
|
||||
// Dedup rules by provider, preserving first-seen order.
|
||||
seen := map[string]bool{}
|
||||
rules := make([]sarifRule, 0)
|
||||
for _, f := range findings {
|
||||
if seen[f.ProviderName] {
|
||||
continue
|
||||
}
|
||||
seen[f.ProviderName] = true
|
||||
rules = append(rules, sarifRule{
|
||||
ID: f.ProviderName,
|
||||
Name: f.ProviderName,
|
||||
ShortDescription: sarifText{Text: fmt.Sprintf("Leaked %s API key", f.ProviderName)},
|
||||
})
|
||||
}
|
||||
|
||||
results := make([]sarifResult, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
key := f.KeyMasked
|
||||
if opts.Unmask {
|
||||
key = f.KeyValue
|
||||
}
|
||||
startLine := f.LineNumber
|
||||
if startLine < 1 {
|
||||
startLine = 1
|
||||
}
|
||||
results = append(results, sarifResult{
|
||||
RuleID: f.ProviderName,
|
||||
Level: sarifLevel(f.Confidence),
|
||||
Message: sarifText{Text: fmt.Sprintf("Detected %s key (%s): %s", f.ProviderName, f.Confidence, key)},
|
||||
Locations: []sarifLocation{{
|
||||
PhysicalLocation: sarifPhysicalLocation{
|
||||
ArtifactLocation: sarifArtifactLocation{URI: f.Source},
|
||||
Region: sarifRegion{StartLine: startLine},
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
doc := sarifDoc{
|
||||
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
Version: "2.1.0",
|
||||
Runs: []sarifRun{{
|
||||
Tool: sarifTool{Driver: sarifDriver{
|
||||
Name: toolName,
|
||||
Version: toolVersion,
|
||||
Rules: rules,
|
||||
}},
|
||||
Results: results,
|
||||
}},
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(doc)
|
||||
}
|
||||
|
||||
func sarifLevel(confidence string) string {
|
||||
switch confidence {
|
||||
case "high":
|
||||
return "error"
|
||||
case "medium":
|
||||
return "warning"
|
||||
case "low":
|
||||
return "note"
|
||||
default:
|
||||
return "warning"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create pkg/output/sarif_test.go implementing all six test cases listed in <behavior>. Use json.Unmarshal into sarifDoc to assert structural fields.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestSARIF" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All TestSARIF_* tests pass
|
||||
- `grep -q "Register(\"sarif\"" pkg/output/sarif.go`
|
||||
- `grep -q '"2.1.0"' pkg/output/sarif.go`
|
||||
- `grep -q "sarifLevel" pkg/output/sarif.go` and covers high/medium/low
|
||||
- `go build ./...` succeeds
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./pkg/output/... -count=1` all green
|
||||
- All four formatters (table, json, csv, sarif) are registered
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SARIFFormatter produces 2.1.0-compliant documents
|
||||
- Rules deduped per provider
|
||||
- Confidence -> level mapping is deterministic
|
||||
- Ready for CI/CD integration in Phase 7
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-03-SUMMARY.md`.
|
||||
</output>
|
||||
287
.planning/phases/06-output-reporting/06-04-PLAN.md
Normal file
287
.planning/phases/06-output-reporting/06-04-PLAN.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- pkg/storage/queries.go
|
||||
- pkg/storage/queries_test.go
|
||||
autonomous: true
|
||||
requirements: [KEYS-01, KEYS-02, KEYS-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "the keys command (Plan 05) can list findings with filters"
|
||||
- "the keys command can fetch a single finding by ID"
|
||||
- "the keys command can delete a finding by ID"
|
||||
- "existing db.ListFindings remains backward compatible"
|
||||
artifacts:
|
||||
- path: pkg/storage/queries.go
|
||||
provides: "Filters, ListFindingsFiltered, GetFinding, DeleteFinding"
|
||||
exports: ["Filters", "ListFindingsFiltered", "GetFinding", "DeleteFinding"]
|
||||
key_links:
|
||||
- from: pkg/storage/queries.go
|
||||
to: pkg/storage/findings.go
|
||||
via: "Reuses encrypt/decrypt + Finding struct"
|
||||
pattern: "Decrypt\\(encrypted, encKey\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a thin query layer on top of findings.go providing filtered list, single-record lookup, and delete. These are the DB primitives the `keyhunter keys` command tree (Plan 05) will call.
|
||||
|
||||
Purpose: Key management (KEYS-01, KEYS-02, KEYS-06 foundation; KEYS-03/04/05 built on top in Plan 05).
|
||||
Output: `pkg/storage/queries.go` + tests using in-memory SQLite (":memory:").
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/06-output-reporting/06-CONTEXT.md
|
||||
@pkg/storage/findings.go
|
||||
@pkg/storage/db.go
|
||||
|
||||
<interfaces>
|
||||
From pkg/storage/findings.go:
|
||||
```go
|
||||
type Finding struct {
|
||||
ID int64
|
||||
ScanID int64
|
||||
ProviderName string
|
||||
KeyValue string // plaintext after decrypt
|
||||
KeyMasked string
|
||||
Confidence string
|
||||
SourcePath string
|
||||
SourceType string
|
||||
LineNumber int
|
||||
CreatedAt time.Time
|
||||
Verified bool
|
||||
VerifyStatus string
|
||||
VerifyHTTPCode int
|
||||
VerifyMetadata map[string]string
|
||||
}
|
||||
func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error)
|
||||
func (db *DB) ListFindings(encKey []byte) ([]Finding, error)
|
||||
```
|
||||
DB schema columns for findings table: id, scan_id, provider_name, key_value (encrypted BLOB), key_masked, confidence, source_path, source_type, line_number, verified, verify_status, verify_http_code, verify_metadata_json, created_at.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Filters struct, ListFindingsFiltered, GetFinding, DeleteFinding</name>
|
||||
<files>pkg/storage/queries.go, pkg/storage/queries_test.go</files>
|
||||
<read_first>
|
||||
- pkg/storage/findings.go (row scan helpers, columns)
|
||||
- pkg/storage/db.go (Open, schema usage)
|
||||
- pkg/storage/keys.go or wherever DeriveKey/NewSalt live (for test setup)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Filters: { Provider string, Verified *bool, Limit int, Offset int }.
|
||||
- ListFindingsFiltered(encKey, filters) returns decrypted findings matching:
|
||||
* Provider (exact match, empty = no filter)
|
||||
* Verified (nil = no filter; ptr-bool matches 1/0)
|
||||
* ORDER BY created_at DESC, id DESC
|
||||
* Limit/Offset applied only when Limit > 0
|
||||
- GetFinding(id, encKey) returns *Finding or (nil, sql.ErrNoRows) when absent.
|
||||
- DeleteFinding(id) runs DELETE; returns (rowsAffected int64, error). Zero rows affected is not an error (caller decides).
|
||||
- Tests (using ":memory:" DB + DeriveKey with a test salt):
|
||||
* Seed 3 findings across 2 providers with mixed verified status.
|
||||
* TestListFindingsFiltered_ByProvider: filter provider=="openai" returns only openai rows.
|
||||
* TestListFindingsFiltered_Verified: Verified=&true returns only verified rows.
|
||||
* TestListFindingsFiltered_Pagination: Limit=1, Offset=1 returns second row.
|
||||
* TestGetFinding_Hit: returns row with decrypted KeyValue matching original.
|
||||
* TestGetFinding_Miss: returns nil, sql.ErrNoRows for id=9999.
|
||||
* TestDeleteFinding_Hit: rowsAffected==1, subsequent Get returns sql.ErrNoRows.
|
||||
* TestDeleteFinding_Miss: rowsAffected==0, no error.
|
||||
</behavior>
|
||||
<action>
|
||||
Create pkg/storage/queries.go:
|
||||
|
||||
```go
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filters selects a subset of findings for ListFindingsFiltered.
|
||||
// Empty Provider means "any provider". Nil Verified means "any verified state".
|
||||
// Limit <= 0 disables pagination.
|
||||
type Filters struct {
|
||||
Provider string
|
||||
Verified *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListFindingsFiltered returns findings matching the given filters, newest first.
|
||||
// Key values are decrypted before return. encKey must match the key used at save time.
|
||||
func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error) {
|
||||
var (
|
||||
where []string
|
||||
args []interface{}
|
||||
)
|
||||
if f.Provider != "" {
|
||||
where = append(where, "provider_name = ?")
|
||||
args = append(args, f.Provider)
|
||||
}
|
||||
if f.Verified != nil {
|
||||
where = append(where, "verified = ?")
|
||||
if *f.Verified {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
q := `SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
|
||||
source_path, source_type, line_number,
|
||||
verified, verify_status, verify_http_code, verify_metadata_json,
|
||||
created_at
|
||||
FROM findings`
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
q += " ORDER BY created_at DESC, id DESC"
|
||||
if f.Limit > 0 {
|
||||
q += " LIMIT ? OFFSET ?"
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
}
|
||||
rows, err := db.sql.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying findings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Finding
|
||||
for rows.Next() {
|
||||
f, err := scanFindingRow(rows, encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFinding returns a single finding by id. Returns sql.ErrNoRows if absent.
|
||||
func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error) {
|
||||
row := db.sql.QueryRow(
|
||||
`SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
|
||||
source_path, source_type, line_number,
|
||||
verified, verify_status, verify_http_code, verify_metadata_json,
|
||||
created_at
|
||||
FROM findings WHERE id = ?`, id)
|
||||
f, err := scanFindingRowFromRow(row, encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// DeleteFinding removes the finding with the given id.
|
||||
// Returns the number of rows affected (0 if no such id).
|
||||
func (db *DB) DeleteFinding(id int64) (int64, error) {
|
||||
res, err := db.sql.Exec(`DELETE FROM findings WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting finding %d: %w", id, err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// scanFindingRow reads one Finding from *sql.Rows and decrypts its key.
|
||||
func scanFindingRow(rows *sql.Rows, encKey []byte) (Finding, error) {
|
||||
var f Finding
|
||||
var encrypted []byte
|
||||
var createdAt string
|
||||
var scanID sql.NullInt64
|
||||
var verifiedInt int
|
||||
var metaJSON sql.NullString
|
||||
if err := rows.Scan(
|
||||
&f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked,
|
||||
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber,
|
||||
&verifiedInt, &f.VerifyStatus, &f.VerifyHTTPCode, &metaJSON,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return f, fmt.Errorf("scanning finding row: %w", err)
|
||||
}
|
||||
return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey)
|
||||
}
|
||||
|
||||
func scanFindingRowFromRow(row *sql.Row, encKey []byte) (Finding, error) {
|
||||
var f Finding
|
||||
var encrypted []byte
|
||||
var createdAt string
|
||||
var scanID sql.NullInt64
|
||||
var verifiedInt int
|
||||
var metaJSON sql.NullString
|
||||
if err := row.Scan(
|
||||
&f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked,
|
||||
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber,
|
||||
&verifiedInt, &f.VerifyStatus, &f.VerifyHTTPCode, &metaJSON,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return f, err // includes sql.ErrNoRows — let caller detect
|
||||
}
|
||||
return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey)
|
||||
}
|
||||
|
||||
func hydrateFinding(f Finding, encrypted []byte, scanID sql.NullInt64, verifiedInt int, metaJSON sql.NullString, createdAt string, encKey []byte) (Finding, error) {
|
||||
if scanID.Valid {
|
||||
f.ScanID = scanID.Int64
|
||||
}
|
||||
f.Verified = verifiedInt != 0
|
||||
if metaJSON.Valid && metaJSON.String != "" {
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON.String), &m); err != nil {
|
||||
return f, fmt.Errorf("unmarshaling verify metadata for finding %d: %w", f.ID, err)
|
||||
}
|
||||
f.VerifyMetadata = m
|
||||
}
|
||||
plain, err := Decrypt(encrypted, encKey)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("decrypting finding %d: %w", f.ID, err)
|
||||
}
|
||||
f.KeyValue = string(plain)
|
||||
f.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
return f, nil
|
||||
}
|
||||
```
|
||||
|
||||
Create pkg/storage/queries_test.go with the seven tests from <behavior>. Use `storage.Open(":memory:")`, generate salt, DeriveKey, SaveFinding seed rows with distinct providers/verified flags, then exercise each query. Use testify assertions.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/storage/... -run "TestListFindingsFiltered|TestGetFinding|TestDeleteFinding" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All seven query tests pass
|
||||
- `grep -q "ListFindingsFiltered\|GetFinding\|DeleteFinding" pkg/storage/queries.go`
|
||||
- `go build ./...` succeeds
|
||||
- Existing `pkg/storage/...` tests still pass (no regressions in ListFindings)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./pkg/storage/... -count=1` all green
|
||||
- `grep -q "type Filters struct" pkg/storage/queries.go`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Filters struct supports provider, verified, pagination
|
||||
- GetFinding returns sql.ErrNoRows on miss
|
||||
- DeleteFinding returns rows affected
|
||||
- All tests green with in-memory DB
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-04-SUMMARY.md`.
|
||||
</output>
|
||||
316
.planning/phases/06-output-reporting/06-05-PLAN.md
Normal file
316
.planning/phases/06-output-reporting/06-05-PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01, 06-02, 06-03, 06-04]
|
||||
files_modified:
|
||||
- cmd/keys.go
|
||||
- cmd/keys_test.go
|
||||
- cmd/stubs.go
|
||||
- cmd/root.go
|
||||
autonomous: true
|
||||
requirements: [KEYS-01, KEYS-02, KEYS-03, KEYS-04, KEYS-05, KEYS-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "keyhunter keys list prints stored findings (masked by default)"
|
||||
- "keyhunter keys show <id> prints a single finding in full detail"
|
||||
- "keyhunter keys export --format=json|csv writes all findings to stdout or --output file"
|
||||
- "keyhunter keys copy <id> places the full key on the system clipboard"
|
||||
- "keyhunter keys delete <id> removes a finding with confirmation (bypassed by --yes)"
|
||||
- "keyhunter keys verify <id> re-runs HTTPVerifier against the stored key"
|
||||
artifacts:
|
||||
- path: cmd/keys.go
|
||||
provides: "keysCmd + list/show/export/copy/delete/verify subcommands"
|
||||
contains: "keysCmd"
|
||||
key_links:
|
||||
- from: cmd/keys.go
|
||||
to: pkg/storage/queries.go
|
||||
via: "db.ListFindingsFiltered / GetFinding / DeleteFinding"
|
||||
pattern: "ListFindingsFiltered|GetFinding|DeleteFinding"
|
||||
- from: cmd/keys.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "output.Get for json/csv export"
|
||||
pattern: "output\\.Get\\("
|
||||
- from: cmd/keys.go
|
||||
to: github.com/atotto/clipboard
|
||||
via: "clipboard.WriteAll for keys copy"
|
||||
pattern: "clipboard\\.WriteAll"
|
||||
- from: cmd/root.go
|
||||
to: cmd/keys.go
|
||||
via: "AddCommand(keysCmd)"
|
||||
pattern: "AddCommand\\(keysCmd\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Replace the `keys` stub with a real command tree implementing KEYS-01..06. Reuses the storage query layer from Plan 04 and the formatter registry from Plans 01-03.
|
||||
|
||||
Purpose: Fulfils all six key-management requirements.
|
||||
Output: `cmd/keys.go` with subcommands, removal of stub, tests for list/show/export/delete using in-memory DB.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/06-output-reporting/06-CONTEXT.md
|
||||
@.planning/phases/06-output-reporting/06-01-PLAN.md
|
||||
@.planning/phases/06-output-reporting/06-04-PLAN.md
|
||||
@cmd/scan.go
|
||||
@cmd/stubs.go
|
||||
@cmd/root.go
|
||||
@pkg/storage/findings.go
|
||||
|
||||
<interfaces>
|
||||
From Plan 06-04:
|
||||
```go
|
||||
type Filters struct { Provider string; Verified *bool; Limit, Offset int }
|
||||
func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error)
|
||||
func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error)
|
||||
func (db *DB) DeleteFinding(id int64) (int64, error)
|
||||
```
|
||||
|
||||
From cmd/scan.go, reusable helper:
|
||||
```go
|
||||
func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error)
|
||||
```
|
||||
|
||||
From Plan 06-01..03:
|
||||
```go
|
||||
output.Get("json"|"csv"|"sarif"|"table") (Formatter, error)
|
||||
```
|
||||
|
||||
clipboard: github.com/atotto/clipboard (already in go.mod) — clipboard.WriteAll(string) error.
|
||||
|
||||
verify package (Phase 5): verify.NewHTTPVerifier(timeout), VerifyAll(ctx, findings, reg, workers) — for `keys verify <id>`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement keys command tree (list/show/export/copy/delete/verify)</name>
|
||||
<files>cmd/keys.go, cmd/stubs.go, cmd/root.go</files>
|
||||
<read_first>
|
||||
- cmd/stubs.go (remove keysCmd stub)
|
||||
- cmd/scan.go (loadOrCreateEncKey, db open pattern, verify wiring)
|
||||
- cmd/root.go (AddCommand registration)
|
||||
- pkg/storage/queries.go (Plan 04 output)
|
||||
- pkg/output/formatter.go (Get, Options)
|
||||
</read_first>
|
||||
<action>
|
||||
1. Delete the `keysCmd` stub from cmd/stubs.go (keep the other stubs). Leave a comment if needed.
|
||||
|
||||
2. Create cmd/keys.go with the full command tree. Skeleton:
|
||||
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/salvacybersec/keyhunter/pkg/config"
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
"github.com/salvacybersec/keyhunter/pkg/output"
|
||||
"github.com/salvacybersec/keyhunter/pkg/providers"
|
||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||
"github.com/salvacybersec/keyhunter/pkg/verify"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
flagKeysUnmask bool
|
||||
flagKeysProvider string
|
||||
flagKeysVerified bool
|
||||
flagKeysVerifiedSet bool
|
||||
flagKeysLimit int
|
||||
flagKeysFormat string
|
||||
flagKeysOutFile string
|
||||
flagKeysYes bool
|
||||
)
|
||||
|
||||
var keysCmd = &cobra.Command{
|
||||
Use: "keys",
|
||||
Short: "Manage stored API key findings",
|
||||
}
|
||||
|
||||
// ... list/show/export/copy/delete/verify subcommands below
|
||||
```
|
||||
|
||||
3. Implement subcommands:
|
||||
|
||||
- `keys list`:
|
||||
* Flags: --unmask, --provider=string, --verified (tri-state via Changed()), --limit=int
|
||||
* Opens DB via same pattern as scan.go; derives encKey via loadOrCreateEncKey.
|
||||
* Builds storage.Filters. If cmd.Flag("verified").Changed, set Verified=&flagKeysVerified.
|
||||
* Calls db.ListFindingsFiltered.
|
||||
* Converts storage.Finding -> engine.Finding (inline helper: storageToEngine(f)).
|
||||
* Prepends ID column by printing a preamble line per finding or by printing a compact table. Simplest: iterate and print `[ID] provider confidence masked/unmask source:line verify_status` to stdout. Use lipgloss only if output.ColorsEnabled(os.Stdout).
|
||||
* Footer: "N key(s).".
|
||||
* Exit code 0 always for list (no findings is not an error).
|
||||
|
||||
- `keys show <id>`:
|
||||
* Args: cobra.ExactArgs(1). Parse id as int64.
|
||||
* db.GetFinding(id, encKey). If sql.ErrNoRows: "no finding with id N", exit 1.
|
||||
* ALWAYS unmasked (per KEYS-02).
|
||||
* Print labeled fields: ID, Provider, Confidence, Key (full), Source, Line, SourceType, CreatedAt, Verified, VerifyStatus, VerifyHTTPCode, VerifyMetadata (sorted keys), VerifyError.
|
||||
|
||||
- `keys export`:
|
||||
* Flags: --format=json|csv (default json), --output=file (default stdout).
|
||||
* Rejects format != "json" && != "csv" with clear error (mentions SARIF is scan-only, for now).
|
||||
* Looks up formatter via output.Get(flagKeysFormat). Unmask=true (export implies full keys per KEYS-03).
|
||||
* If --output set: atomic write: write to <file>.tmp then os.Rename. Use 0600 perms.
|
||||
* Else write to os.Stdout.
|
||||
|
||||
- `keys copy <id>`:
|
||||
* Args: ExactArgs(1).
|
||||
* GetFinding; if not found exit 1.
|
||||
* clipboard.WriteAll(f.KeyValue).
|
||||
* Print "Copied key for finding #<id> (<provider>, <masked>) to clipboard."
|
||||
|
||||
- `keys delete <id>`:
|
||||
* Args: ExactArgs(1).
|
||||
* GetFinding first to show masked preview.
|
||||
* If !flagKeysYes: prompt `"Delete finding #%d (%s, %s)? [y/N]: "` reading from stdin (bufio.NewReader). Accept "y"/"Y"/"yes".
|
||||
* db.DeleteFinding(id). Print "Deleted finding #<id>." or "No finding with id <id>.".
|
||||
|
||||
- `keys verify <id>`:
|
||||
* Args: ExactArgs(1).
|
||||
* GetFinding; load providers.NewRegistry(); build one engine.Finding from the stored row.
|
||||
* Use verify.EnsureConsent(db, os.Stdin, os.Stderr); if not granted, exit 2.
|
||||
* verifier := verify.NewHTTPVerifier(10*time.Second); results := verifier.VerifyAll(ctx, []engine.Finding{f}, reg, 1).
|
||||
* Read single result, apply to the stored record (re-SaveFinding with updated verify fields? — simpler: use a new helper `db.UpdateFindingVerify(id, status, httpCode, metadata, errMsg)`; if that helper doesn't exist, do it inline via `db.SQL().Exec("UPDATE findings SET verified=?, verify_status=?, verify_http_code=?, verify_metadata_json=? WHERE id=?", ...)` with JSON-marshaled metadata).
|
||||
* Print the updated finding using the "show" rendering.
|
||||
|
||||
4. Helper: `storageToEngine(f storage.Finding) engine.Finding` — maps fields. Put it in cmd/keys.go as unexported.
|
||||
|
||||
5. Helper: `openDBWithKey() (*storage.DB, []byte, error)` that mirrors scan.go's DB-open sequence (config load, mkdir, storage.Open, loadOrCreateEncKey). Extract this so all keys subcommands share one path.
|
||||
|
||||
6. In cmd/root.go the existing `rootCmd.AddCommand(keysCmd)` line already registers the stub's keysCmd. Since cmd/keys.go now declares `var keysCmd`, ensure the old declaration in cmd/stubs.go is removed (Task 1 step 1) so there is exactly one declaration. Run `go build ./cmd/...` to confirm.
|
||||
|
||||
7. Register subcommands in an init() in cmd/keys.go:
|
||||
```go
|
||||
func init() {
|
||||
// list
|
||||
keysListCmd.Flags().BoolVar(&flagKeysUnmask, "unmask", false, "show full key values")
|
||||
keysListCmd.Flags().StringVar(&flagKeysProvider, "provider", "", "filter by provider name")
|
||||
keysListCmd.Flags().BoolVar(&flagKeysVerified, "verified", false, "filter: verified only (use --verified=false for unverified only)")
|
||||
keysListCmd.Flags().IntVar(&flagKeysLimit, "limit", 0, "max rows (0 = unlimited)")
|
||||
// export
|
||||
keysExportCmd.Flags().StringVar(&flagKeysFormat, "format", "json", "export format: json, csv")
|
||||
keysExportCmd.Flags().StringVar(&flagKeysOutFile, "output", "", "write to file instead of stdout")
|
||||
// delete
|
||||
keysDeleteCmd.Flags().BoolVar(&flagKeysYes, "yes", false, "skip confirmation")
|
||||
// wiring
|
||||
keysCmd.AddCommand(keysListCmd, keysShowCmd, keysExportCmd, keysCopyCmd, keysDeleteCmd, keysVerifyCmd)
|
||||
_ = viper.BindPFlag("keys.unmask", keysListCmd.Flags().Lookup("unmask"))
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `go build ./...` succeeds
|
||||
- `cmd/stubs.go` no longer declares keysCmd
|
||||
- `cmd/keys.go` declares keysCmd + 6 subcommands
|
||||
- `grep -q "keysListCmd\|keysShowCmd\|keysExportCmd\|keysCopyCmd\|keysDeleteCmd\|keysVerifyCmd" cmd/keys.go`
|
||||
- `keyhunter keys --help` (via `go run ./ keys --help`) lists all 6 subcommands
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Integration tests for keys list/show/export/delete against in-memory DB</name>
|
||||
<files>cmd/keys_test.go</files>
|
||||
<read_first>
|
||||
- cmd/keys.go (from Task 1)
|
||||
- pkg/storage/queries.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Tests use a temp file SQLite DB (not :memory: because cobra commands open by path).
|
||||
- Each test sets viper.Set("database.path", tmpPath) and config passphrase via env, seeds findings, then invokes the cobra subcommand via rootCmd.SetArgs() + Execute() OR directly invokes the RunE function with captured stdout.
|
||||
- Prefer direct RunE invocation with cmd.SetOut/SetErr buffers to isolate from global os.Stdout.
|
||||
- Seed: 3 findings (2 openai, 1 anthropic; one verified).
|
||||
- Tests:
|
||||
* TestKeysList_Default: output contains both providers and all 3 ids, key column masked.
|
||||
* TestKeysList_FilterProvider: --provider=openai shows only 2 rows.
|
||||
* TestKeysShow_Hit: `keys show <id1>` output contains the full plaintext KeyValue, not masked.
|
||||
* TestKeysShow_Miss: `keys show 9999` returns a non-nil error.
|
||||
* TestKeysExport_JSON: --format=json to stdout parses as JSON array of length 3, unmasked keys present.
|
||||
* TestKeysExport_CSVFile: --format=csv --output=<tmp>; file exists, header row matches, 3 data rows.
|
||||
* TestKeysDelete_WithYes: --yes deletes finding; subsequent list returns 2.
|
||||
- Skip TestKeysCopy (clipboard not available in test env) and TestKeysVerify (requires network). Document skip with a comment.
|
||||
</behavior>
|
||||
<action>
|
||||
Create cmd/keys_test.go using testify. Use t.TempDir() for DB path. Seed findings with a helper similar to storage/queries_test. For each test, reset viper state and flag vars between runs.
|
||||
|
||||
Example scaffold:
|
||||
```go
|
||||
func seedDB(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "k.db")
|
||||
viper.Set("database.path", dbPath)
|
||||
t.Setenv("KEYHUNTER_PASSPHRASE", "test-pass")
|
||||
db, err := storage.Open(dbPath)
|
||||
require.NoError(t, err)
|
||||
encKey, err := loadOrCreateEncKey(db, "test-pass")
|
||||
require.NoError(t, err)
|
||||
seed := []storage.Finding{
|
||||
{ProviderName: "openai", KeyValue: "sk-aaaaaaaaaaaaaaaaaaaa", KeyMasked: "sk-aaaa...aaaa", Confidence: "high", SourcePath: "a.go", LineNumber: 10},
|
||||
{ProviderName: "openai", KeyValue: "sk-bbbbbbbbbbbbbbbbbbbb", KeyMasked: "sk-bbbb...bbbb", Confidence: "medium", SourcePath: "b.go", LineNumber: 20, Verified: true, VerifyStatus: "live"},
|
||||
{ProviderName: "anthropic", KeyValue: "sk-ant-cccccccccccccccc", KeyMasked: "sk-ant-c...cccc", Confidence: "high", SourcePath: "c.go", LineNumber: 30},
|
||||
}
|
||||
for _, f := range seed {
|
||||
_, err := db.SaveFinding(f, encKey)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, db.Close())
|
||||
return dbPath, func() { viper.Reset() }
|
||||
}
|
||||
```
|
||||
|
||||
Capture output by using `cmd.SetOut(buf); cmd.SetErr(buf)` then `cmd.Execute()` on a copy of keysCmd, OR directly call `keysListCmd.RunE(keysListCmd, []string{})` after redirecting `os.Stdout` to a pipe (prefer SetOut if subcommands write via `cmd.OutOrStdout()`; update keys.go to use that helper in Task 1 so tests are clean).
|
||||
|
||||
NOTE: If Task 1 wrote fmt.Fprintln(os.Stdout, ...), adjust it to use `cmd.OutOrStdout()` to make these tests hermetic. This is a cheap refactor — do it during Task 2 if missed in Task 1.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestKeysList|TestKeysShow|TestKeysExport|TestKeysDelete" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All listed keys tests pass
|
||||
- `go test ./cmd/... -count=1` has no regressions
|
||||
- Test file uses cmd.OutOrStdout() pattern (cmd/keys.go updated if needed)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go build ./...` succeeds
|
||||
- `go test ./cmd/... ./pkg/storage/... ./pkg/output/... -count=1` all green
|
||||
- Manual smoke: `go run . keys --help` lists list/show/export/copy/delete/verify
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All of KEYS-01..06 are implemented
|
||||
- keys export reuses the formatter registry (JSON/CSV)
|
||||
- keys copy uses atotto/clipboard
|
||||
- keys delete requires confirmation unless --yes
|
||||
- Integration tests cover list/show/export/delete
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-05-SUMMARY.md`.
|
||||
</output>
|
||||
242
.planning/phases/06-output-reporting/06-06-PLAN.md
Normal file
242
.planning/phases/06-output-reporting/06-06-PLAN.md
Normal file
@@ -0,0 +1,242 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01, 06-02, 06-03]
|
||||
files_modified:
|
||||
- cmd/scan.go
|
||||
- cmd/scan_output_test.go
|
||||
autonomous: true
|
||||
requirements: [OUT-05, OUT-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "scan --output accepts table, json, sarif, csv and dispatches to the registered formatter"
|
||||
- "invalid --output values fail with a clear error listing valid formats"
|
||||
- "scan exit code is 0 on clean scans, 1 on findings, 2 on scan errors"
|
||||
- "key masking is the default; --unmask propagates through opts.Unmask to all formatters"
|
||||
artifacts:
|
||||
- path: cmd/scan.go
|
||||
provides: "Refactored output dispatch via output.Get + exit-code handling"
|
||||
contains: "output.Get(flagOutput"
|
||||
key_links:
|
||||
- from: cmd/scan.go
|
||||
to: pkg/output/formatter.go
|
||||
via: "output.Get(flagOutput).Format(findings, os.Stdout, Options{Unmask, ToolName, ToolVersion})"
|
||||
pattern: "output\\.Get\\(flagOutput"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the scan command to the formatter registry established in Plans 01-03. Replace the inline json/table switch with `output.Get(name)` and finalize exit-code semantics (0/1/2).
|
||||
|
||||
Purpose: Surfaces all four output formats through `scan --output=` and enforces OUT-06 exit-code contract for CI/CD consumers.
|
||||
Output: Updated `cmd/scan.go`, a small test file covering format dispatch and exit codes.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/06-output-reporting/06-CONTEXT.md
|
||||
@.planning/phases/06-output-reporting/06-01-PLAN.md
|
||||
@cmd/scan.go
|
||||
@cmd/root.go
|
||||
|
||||
<interfaces>
|
||||
From Plans 06-01..03:
|
||||
```go
|
||||
output.Get(name string) (Formatter, error) // returns ErrUnknownFormat wrapped
|
||||
output.Names() []string
|
||||
output.Options{ Unmask, ToolName, ToolVersion }
|
||||
```
|
||||
|
||||
Current scan.go ends with:
|
||||
```go
|
||||
switch flagOutput {
|
||||
case "json":
|
||||
// inline jsonFinding encoder
|
||||
default:
|
||||
output.PrintFindings(findings, flagUnmask)
|
||||
}
|
||||
if len(findings) > 0 { os.Exit(1) }
|
||||
return nil
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Replace scan output switch with formatter registry and finalize exit codes</name>
|
||||
<files>cmd/scan.go</files>
|
||||
<read_first>
|
||||
- cmd/scan.go (current dispatch)
|
||||
- pkg/output/formatter.go (Get, Options, Names)
|
||||
- cmd/root.go (for version constant — if none exists, use "dev")
|
||||
</read_first>
|
||||
<action>
|
||||
1. Remove the inline `jsonFinding` struct and the `switch flagOutput` block.
|
||||
|
||||
2. Replace with:
|
||||
|
||||
```go
|
||||
// Output via the formatter registry (OUT-01..04).
|
||||
formatter, err := output.Get(flagOutput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w (valid: %s)", err, strings.Join(output.Names(), ", "))
|
||||
}
|
||||
if err := formatter.Format(findings, os.Stdout, output.Options{
|
||||
Unmask: flagUnmask,
|
||||
ToolName: "keyhunter",
|
||||
ToolVersion: versionString(), // see step 4
|
||||
}); err != nil {
|
||||
return fmt.Errorf("rendering %s output: %w", flagOutput, err)
|
||||
}
|
||||
|
||||
// OUT-06 exit codes: 0=clean, 1=findings, 2=error (errors returned via RunE -> root.Execute).
|
||||
if len(findings) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
```
|
||||
|
||||
Add `"strings"` import if missing.
|
||||
|
||||
3. Update the --output flag help text:
|
||||
```go
|
||||
scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json, sarif, csv")
|
||||
```
|
||||
|
||||
4. Version helper: if cmd/root.go doesn't already export a version constant, add (in cmd/scan.go or a new cmd/version.go):
|
||||
```go
|
||||
// versionString returns the compiled tool version. Set via -ldflags "-X github.com/salvacybersec/keyhunter/cmd.version=...".
|
||||
var version = "dev"
|
||||
func versionString() string { return version }
|
||||
```
|
||||
If a version constant already exists elsewhere, reuse it instead of adding a new one.
|
||||
|
||||
5. Confirm the rootCmd.Execute() path in cmd/root.go already handles errors by `os.Exit(1)` on non-nil. For the OUT-06 "exit 2 on error" requirement, update cmd/root.go Execute():
|
||||
```go
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
// cobra already prints the error message. Exit 2 signals scan/tool error
|
||||
// per OUT-06. (Exit 1 is reserved for "findings present".)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
```
|
||||
This is a one-line change from `os.Exit(1)` to `os.Exit(2)`. Any subcommand returning an error will now exit 2, which matches the CI contract (findings=1, error=2, clean=0).
|
||||
|
||||
6. Verify the old inline jsonFinding struct (and its imports — check if "encoding/json" is still used anywhere in scan.go; if not, remove the import).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `go build ./...` succeeds
|
||||
- `grep -q "output\\.Get(flagOutput)" cmd/scan.go`
|
||||
- `grep -q "output\\.Options{" cmd/scan.go`
|
||||
- `grep -vq "jsonFinding" cmd/scan.go` (inline struct removed)
|
||||
- `grep -q "os\\.Exit(2)" cmd/root.go`
|
||||
- Help text lists all four formats
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Tests for unknown format error and exit-code contract</name>
|
||||
<files>cmd/scan_output_test.go</files>
|
||||
<read_first>
|
||||
- cmd/scan.go (updated dispatch)
|
||||
- pkg/output/formatter.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 1: TestScanOutput_UnknownFormat — setting flagOutput="bogus" and invoking a minimal code path that calls output.Get(flagOutput) returns an error whose message contains "unknown format" and lists valid names.
|
||||
- Test 2: TestScanOutput_FormatNamesIncludeAll — output.Names() returns a slice containing "table", "json", "csv", "sarif".
|
||||
- Rather than invoking the whole RunE (which requires a real scan target), isolate the dispatch logic into a small helper `renderScanOutput(findings, name string, unmask bool, w io.Writer) error` inside cmd/scan.go. Update Task 1 if not already done so the helper exists. Test exercises the helper directly.
|
||||
- Test 3: TestRenderScanOutput_JSONSucceeds — passes an empty findings slice with name="json"; asserts output is valid JSON array `[]`.
|
||||
- Test 4: TestRenderScanOutput_UnknownReturnsError — name="bogus"; asserts errors.Is(err, output.ErrUnknownFormat).
|
||||
</behavior>
|
||||
<action>
|
||||
1. If not done in Task 1, add `renderScanOutput(findings []engine.Finding, name string, unmask bool, w io.Writer) error` to cmd/scan.go and use it from the RunE.
|
||||
|
||||
2. Create cmd/scan_output_test.go:
|
||||
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
"github.com/salvacybersec/keyhunter/pkg/output"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScanOutput_FormatNamesIncludeAll(t *testing.T) {
|
||||
names := output.Names()
|
||||
for _, want := range []string{"table", "json", "csv", "sarif"} {
|
||||
assert.Contains(t, names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderScanOutput_UnknownReturnsError(t *testing.T) {
|
||||
err := renderScanOutput(nil, "bogus", false, &bytes.Buffer{})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, output.ErrUnknownFormat))
|
||||
assert.Contains(t, err.Error(), "valid:")
|
||||
}
|
||||
|
||||
func TestRenderScanOutput_JSONSucceeds(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := renderScanOutput([]engine.Finding{}, "json", false, &buf)
|
||||
require.NoError(t, err)
|
||||
var out []any
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &out))
|
||||
assert.Len(t, out, 0)
|
||||
}
|
||||
|
||||
func TestRenderScanOutput_TableEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := renderScanOutput(nil, "table", false, &buf)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.Contains(buf.String(), "No API keys found"))
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestScanOutput|TestRenderScanOutput" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All four tests pass
|
||||
- `grep -q "func renderScanOutput" cmd/scan.go`
|
||||
- errors.Is(err, output.ErrUnknownFormat) works from cmd package
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go build ./...` succeeds
|
||||
- `go test ./... -count=1` all green
|
||||
- Manual smoke: `go run . scan --output=bogus /tmp` prints "unknown format" and exits 2
|
||||
- Manual smoke: `go run . scan --output=sarif testdata/` prints SARIF JSON
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- scan --output dispatches to the formatter registry for all four formats
|
||||
- Unknown format error lists valid names
|
||||
- Exit codes: clean=0, findings=1, error=2
|
||||
- OUT-05 masking default respected via flagUnmask -> Options.Unmask
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-06-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user