317 lines
14 KiB
Markdown
317 lines
14 KiB
Markdown
---
|
|
phase: 06-output-reporting
|
|
plan: 05
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [06-01, 06-02, 06-03, 06-04]
|
|
files_modified:
|
|
- cmd/keys.go
|
|
- cmd/keys_test.go
|
|
- cmd/stubs.go
|
|
- cmd/root.go
|
|
autonomous: true
|
|
requirements: [KEYS-01, KEYS-02, KEYS-03, KEYS-04, KEYS-05, KEYS-06]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "keyhunter keys list prints stored findings (masked by default)"
|
|
- "keyhunter keys show <id> prints a single finding in full detail"
|
|
- "keyhunter keys export --format=json|csv writes all findings to stdout or --output file"
|
|
- "keyhunter keys copy <id> places the full key on the system clipboard"
|
|
- "keyhunter keys delete <id> removes a finding with confirmation (bypassed by --yes)"
|
|
- "keyhunter keys verify <id> re-runs HTTPVerifier against the stored key"
|
|
artifacts:
|
|
- path: cmd/keys.go
|
|
provides: "keysCmd + list/show/export/copy/delete/verify subcommands"
|
|
contains: "keysCmd"
|
|
key_links:
|
|
- from: cmd/keys.go
|
|
to: pkg/storage/queries.go
|
|
via: "db.ListFindingsFiltered / GetFinding / DeleteFinding"
|
|
pattern: "ListFindingsFiltered|GetFinding|DeleteFinding"
|
|
- from: cmd/keys.go
|
|
to: pkg/output/formatter.go
|
|
via: "output.Get for json/csv export"
|
|
pattern: "output\\.Get\\("
|
|
- from: cmd/keys.go
|
|
to: github.com/atotto/clipboard
|
|
via: "clipboard.WriteAll for keys copy"
|
|
pattern: "clipboard\\.WriteAll"
|
|
- from: cmd/root.go
|
|
to: cmd/keys.go
|
|
via: "AddCommand(keysCmd)"
|
|
pattern: "AddCommand\\(keysCmd\\)"
|
|
---
|
|
|
|
<objective>
|
|
Replace the `keys` stub with a real command tree implementing KEYS-01..06. Reuses the storage query layer from Plan 04 and the formatter registry from Plans 01-03.
|
|
|
|
Purpose: Fulfils all six key-management requirements.
|
|
Output: `cmd/keys.go` with subcommands, removal of stub, tests for list/show/export/delete using in-memory DB.
|
|
</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
|
|
@.planning/phases/06-output-reporting/06-04-PLAN.md
|
|
@cmd/scan.go
|
|
@cmd/stubs.go
|
|
@cmd/root.go
|
|
@pkg/storage/findings.go
|
|
|
|
<interfaces>
|
|
From Plan 06-04:
|
|
```go
|
|
type Filters struct { Provider string; Verified *bool; Limit, Offset int }
|
|
func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error)
|
|
func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error)
|
|
func (db *DB) DeleteFinding(id int64) (int64, error)
|
|
```
|
|
|
|
From cmd/scan.go, reusable helper:
|
|
```go
|
|
func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error)
|
|
```
|
|
|
|
From Plan 06-01..03:
|
|
```go
|
|
output.Get("json"|"csv"|"sarif"|"table") (Formatter, error)
|
|
```
|
|
|
|
clipboard: github.com/atotto/clipboard (already in go.mod) — clipboard.WriteAll(string) error.
|
|
|
|
verify package (Phase 5): verify.NewHTTPVerifier(timeout), VerifyAll(ctx, findings, reg, workers) — for `keys verify <id>`.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Implement keys command tree (list/show/export/copy/delete/verify)</name>
|
|
<files>cmd/keys.go, cmd/stubs.go, cmd/root.go</files>
|
|
<read_first>
|
|
- cmd/stubs.go (remove keysCmd stub)
|
|
- cmd/scan.go (loadOrCreateEncKey, db open pattern, verify wiring)
|
|
- cmd/root.go (AddCommand registration)
|
|
- pkg/storage/queries.go (Plan 04 output)
|
|
- pkg/output/formatter.go (Get, Options)
|
|
</read_first>
|
|
<action>
|
|
1. Delete the `keysCmd` stub from cmd/stubs.go (keep the other stubs). Leave a comment if needed.
|
|
|
|
2. Create cmd/keys.go with the full command tree. Skeleton:
|
|
|
|
```go
|
|
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/salvacybersec/keyhunter/pkg/config"
|
|
"github.com/salvacybersec/keyhunter/pkg/engine"
|
|
"github.com/salvacybersec/keyhunter/pkg/output"
|
|
"github.com/salvacybersec/keyhunter/pkg/providers"
|
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
|
"github.com/salvacybersec/keyhunter/pkg/verify"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
var (
|
|
flagKeysUnmask bool
|
|
flagKeysProvider string
|
|
flagKeysVerified bool
|
|
flagKeysVerifiedSet bool
|
|
flagKeysLimit int
|
|
flagKeysFormat string
|
|
flagKeysOutFile string
|
|
flagKeysYes bool
|
|
)
|
|
|
|
var keysCmd = &cobra.Command{
|
|
Use: "keys",
|
|
Short: "Manage stored API key findings",
|
|
}
|
|
|
|
// ... list/show/export/copy/delete/verify subcommands below
|
|
```
|
|
|
|
3. Implement subcommands:
|
|
|
|
- `keys list`:
|
|
* Flags: --unmask, --provider=string, --verified (tri-state via Changed()), --limit=int
|
|
* Opens DB via same pattern as scan.go; derives encKey via loadOrCreateEncKey.
|
|
* Builds storage.Filters. If cmd.Flag("verified").Changed, set Verified=&flagKeysVerified.
|
|
* Calls db.ListFindingsFiltered.
|
|
* Converts storage.Finding -> engine.Finding (inline helper: storageToEngine(f)).
|
|
* Prepends ID column by printing a preamble line per finding or by printing a compact table. Simplest: iterate and print `[ID] provider confidence masked/unmask source:line verify_status` to stdout. Use lipgloss only if output.ColorsEnabled(os.Stdout).
|
|
* Footer: "N key(s).".
|
|
* Exit code 0 always for list (no findings is not an error).
|
|
|
|
- `keys show <id>`:
|
|
* Args: cobra.ExactArgs(1). Parse id as int64.
|
|
* db.GetFinding(id, encKey). If sql.ErrNoRows: "no finding with id N", exit 1.
|
|
* ALWAYS unmasked (per KEYS-02).
|
|
* Print labeled fields: ID, Provider, Confidence, Key (full), Source, Line, SourceType, CreatedAt, Verified, VerifyStatus, VerifyHTTPCode, VerifyMetadata (sorted keys), VerifyError.
|
|
|
|
- `keys export`:
|
|
* Flags: --format=json|csv (default json), --output=file (default stdout).
|
|
* Rejects format != "json" && != "csv" with clear error (mentions SARIF is scan-only, for now).
|
|
* Looks up formatter via output.Get(flagKeysFormat). Unmask=true (export implies full keys per KEYS-03).
|
|
* If --output set: atomic write: write to <file>.tmp then os.Rename. Use 0600 perms.
|
|
* Else write to os.Stdout.
|
|
|
|
- `keys copy <id>`:
|
|
* Args: ExactArgs(1).
|
|
* GetFinding; if not found exit 1.
|
|
* clipboard.WriteAll(f.KeyValue).
|
|
* Print "Copied key for finding #<id> (<provider>, <masked>) to clipboard."
|
|
|
|
- `keys delete <id>`:
|
|
* Args: ExactArgs(1).
|
|
* GetFinding first to show masked preview.
|
|
* If !flagKeysYes: prompt `"Delete finding #%d (%s, %s)? [y/N]: "` reading from stdin (bufio.NewReader). Accept "y"/"Y"/"yes".
|
|
* db.DeleteFinding(id). Print "Deleted finding #<id>." or "No finding with id <id>.".
|
|
|
|
- `keys verify <id>`:
|
|
* Args: ExactArgs(1).
|
|
* GetFinding; load providers.NewRegistry(); build one engine.Finding from the stored row.
|
|
* Use verify.EnsureConsent(db, os.Stdin, os.Stderr); if not granted, exit 2.
|
|
* verifier := verify.NewHTTPVerifier(10*time.Second); results := verifier.VerifyAll(ctx, []engine.Finding{f}, reg, 1).
|
|
* Read single result, apply to the stored record (re-SaveFinding with updated verify fields? — simpler: use a new helper `db.UpdateFindingVerify(id, status, httpCode, metadata, errMsg)`; if that helper doesn't exist, do it inline via `db.SQL().Exec("UPDATE findings SET verified=?, verify_status=?, verify_http_code=?, verify_metadata_json=? WHERE id=?", ...)` with JSON-marshaled metadata).
|
|
* Print the updated finding using the "show" rendering.
|
|
|
|
4. Helper: `storageToEngine(f storage.Finding) engine.Finding` — maps fields. Put it in cmd/keys.go as unexported.
|
|
|
|
5. Helper: `openDBWithKey() (*storage.DB, []byte, error)` that mirrors scan.go's DB-open sequence (config load, mkdir, storage.Open, loadOrCreateEncKey). Extract this so all keys subcommands share one path.
|
|
|
|
6. In cmd/root.go the existing `rootCmd.AddCommand(keysCmd)` line already registers the stub's keysCmd. Since cmd/keys.go now declares `var keysCmd`, ensure the old declaration in cmd/stubs.go is removed (Task 1 step 1) so there is exactly one declaration. Run `go build ./cmd/...` to confirm.
|
|
|
|
7. Register subcommands in an init() in cmd/keys.go:
|
|
```go
|
|
func init() {
|
|
// list
|
|
keysListCmd.Flags().BoolVar(&flagKeysUnmask, "unmask", false, "show full key values")
|
|
keysListCmd.Flags().StringVar(&flagKeysProvider, "provider", "", "filter by provider name")
|
|
keysListCmd.Flags().BoolVar(&flagKeysVerified, "verified", false, "filter: verified only (use --verified=false for unverified only)")
|
|
keysListCmd.Flags().IntVar(&flagKeysLimit, "limit", 0, "max rows (0 = unlimited)")
|
|
// export
|
|
keysExportCmd.Flags().StringVar(&flagKeysFormat, "format", "json", "export format: json, csv")
|
|
keysExportCmd.Flags().StringVar(&flagKeysOutFile, "output", "", "write to file instead of stdout")
|
|
// delete
|
|
keysDeleteCmd.Flags().BoolVar(&flagKeysYes, "yes", false, "skip confirmation")
|
|
// wiring
|
|
keysCmd.AddCommand(keysListCmd, keysShowCmd, keysExportCmd, keysCopyCmd, keysDeleteCmd, keysVerifyCmd)
|
|
_ = viper.BindPFlag("keys.unmask", keysListCmd.Flags().Lookup("unmask"))
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/...</automated>
|
|
</verify>
|
|
<done>
|
|
- `go build ./...` succeeds
|
|
- `cmd/stubs.go` no longer declares keysCmd
|
|
- `cmd/keys.go` declares keysCmd + 6 subcommands
|
|
- `grep -q "keysListCmd\|keysShowCmd\|keysExportCmd\|keysCopyCmd\|keysDeleteCmd\|keysVerifyCmd" cmd/keys.go`
|
|
- `keyhunter keys --help` (via `go run ./ keys --help`) lists all 6 subcommands
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Integration tests for keys list/show/export/delete against in-memory DB</name>
|
|
<files>cmd/keys_test.go</files>
|
|
<read_first>
|
|
- cmd/keys.go (from Task 1)
|
|
- pkg/storage/queries.go
|
|
</read_first>
|
|
<behavior>
|
|
- Tests use a temp file SQLite DB (not :memory: because cobra commands open by path).
|
|
- Each test sets viper.Set("database.path", tmpPath) and config passphrase via env, seeds findings, then invokes the cobra subcommand via rootCmd.SetArgs() + Execute() OR directly invokes the RunE function with captured stdout.
|
|
- Prefer direct RunE invocation with cmd.SetOut/SetErr buffers to isolate from global os.Stdout.
|
|
- Seed: 3 findings (2 openai, 1 anthropic; one verified).
|
|
- Tests:
|
|
* TestKeysList_Default: output contains both providers and all 3 ids, key column masked.
|
|
* TestKeysList_FilterProvider: --provider=openai shows only 2 rows.
|
|
* TestKeysShow_Hit: `keys show <id1>` output contains the full plaintext KeyValue, not masked.
|
|
* TestKeysShow_Miss: `keys show 9999` returns a non-nil error.
|
|
* TestKeysExport_JSON: --format=json to stdout parses as JSON array of length 3, unmasked keys present.
|
|
* TestKeysExport_CSVFile: --format=csv --output=<tmp>; file exists, header row matches, 3 data rows.
|
|
* TestKeysDelete_WithYes: --yes deletes finding; subsequent list returns 2.
|
|
- Skip TestKeysCopy (clipboard not available in test env) and TestKeysVerify (requires network). Document skip with a comment.
|
|
</behavior>
|
|
<action>
|
|
Create cmd/keys_test.go using testify. Use t.TempDir() for DB path. Seed findings with a helper similar to storage/queries_test. For each test, reset viper state and flag vars between runs.
|
|
|
|
Example scaffold:
|
|
```go
|
|
func seedDB(t *testing.T) (string, func()) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "k.db")
|
|
viper.Set("database.path", dbPath)
|
|
t.Setenv("KEYHUNTER_PASSPHRASE", "test-pass")
|
|
db, err := storage.Open(dbPath)
|
|
require.NoError(t, err)
|
|
encKey, err := loadOrCreateEncKey(db, "test-pass")
|
|
require.NoError(t, err)
|
|
seed := []storage.Finding{
|
|
{ProviderName: "openai", KeyValue: "sk-aaaaaaaaaaaaaaaaaaaa", KeyMasked: "sk-aaaa...aaaa", Confidence: "high", SourcePath: "a.go", LineNumber: 10},
|
|
{ProviderName: "openai", KeyValue: "sk-bbbbbbbbbbbbbbbbbbbb", KeyMasked: "sk-bbbb...bbbb", Confidence: "medium", SourcePath: "b.go", LineNumber: 20, Verified: true, VerifyStatus: "live"},
|
|
{ProviderName: "anthropic", KeyValue: "sk-ant-cccccccccccccccc", KeyMasked: "sk-ant-c...cccc", Confidence: "high", SourcePath: "c.go", LineNumber: 30},
|
|
}
|
|
for _, f := range seed {
|
|
_, err := db.SaveFinding(f, encKey)
|
|
require.NoError(t, err)
|
|
}
|
|
require.NoError(t, db.Close())
|
|
return dbPath, func() { viper.Reset() }
|
|
}
|
|
```
|
|
|
|
Capture output by using `cmd.SetOut(buf); cmd.SetErr(buf)` then `cmd.Execute()` on a copy of keysCmd, OR directly call `keysListCmd.RunE(keysListCmd, []string{})` after redirecting `os.Stdout` to a pipe (prefer SetOut if subcommands write via `cmd.OutOrStdout()`; update keys.go to use that helper in Task 1 so tests are clean).
|
|
|
|
NOTE: If Task 1 wrote fmt.Fprintln(os.Stdout, ...), adjust it to use `cmd.OutOrStdout()` to make these tests hermetic. This is a cheap refactor — do it during Task 2 if missed in Task 1.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestKeysList|TestKeysShow|TestKeysExport|TestKeysDelete" -count=1</automated>
|
|
</verify>
|
|
<done>
|
|
- All listed keys tests pass
|
|
- `go test ./cmd/... -count=1` has no regressions
|
|
- Test file uses cmd.OutOrStdout() pattern (cmd/keys.go updated if needed)
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `go build ./...` succeeds
|
|
- `go test ./cmd/... ./pkg/storage/... ./pkg/output/... -count=1` all green
|
|
- Manual smoke: `go run . keys --help` lists list/show/export/copy/delete/verify
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- All of KEYS-01..06 are implemented
|
|
- keys export reuses the formatter registry (JSON/CSV)
|
|
- keys copy uses atotto/clipboard
|
|
- keys delete requires confirmation unless --yes
|
|
- Integration tests cover list/show/export/delete
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/06-output-reporting/06-05-SUMMARY.md`.
|
|
</output>
|