--- 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\"" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/06-output-reporting/06-CONTEXT.md @.planning/phases/06-output-reporting/06-01-PLAN.md @pkg/engine/finding.go 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. Task 1: JSONFormatter with full finding fields pkg/output/json.go, pkg/output/json_test.go - pkg/output/formatter.go (Formatter interface, Options) - pkg/engine/finding.go - 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. 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. cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestJSONFormatter" -count=1 - TestJSONFormatter_* all pass - `grep -q "Register(\"json\"" pkg/output/json.go` - `go build ./...` succeeds Task 2: CSVFormatter with stable header row pkg/output/csv.go, pkg/output/csv_test.go - pkg/output/formatter.go - pkg/engine/finding.go - 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. 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. cd /home/salva/Documents/apikey && go test ./pkg/output/... -run "TestCSVFormatter" -count=1 - 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 - `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) - JSONFormatter and CSVFormatter implement Formatter - Both are registered on package init - Unmask option propagates to key column - All unit tests pass After completion, create `.planning/phases/06-output-reporting/06-02-SUMMARY.md`.