276 lines
9.3 KiB
Markdown
276 lines
9.3 KiB
Markdown
---
|
|
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>
|