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'
path
provides
contains
pkg/output/json.go
JSONFormatter implementing Formatter
type JSONFormatter struct
path
provides
contains
pkg/output/csv.go
CSVFormatter implementing Formatter
type CSVFormatter struct
from
to
via
pattern
pkg/output/json.go
pkg/output/formatter.go
init() Register("json", JSONFormatter{})
Register("json"
from
to
via
pattern
pkg/output/csv.go
pkg/output/formatter.go
init() Register("csv", CSVFormatter{})
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.
@.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)
<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>
After completion, create `.planning/phases/06-output-reporting/06-02-SUMMARY.md`.