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