docs(06): create phase 6 plans — output formats + key management
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user