Files

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
06-output-reporting 05 execute 2
06-01
06-02
06-03
06-04
cmd/keys.go
cmd/keys_test.go
cmd/stubs.go
cmd/root.go
true
KEYS-01
KEYS-02
KEYS-03
KEYS-04
KEYS-05
KEYS-06
truths artifacts key_links
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
path provides contains
cmd/keys.go keysCmd + list/show/export/copy/delete/verify subcommands keysCmd
from to via pattern
cmd/keys.go pkg/storage/queries.go db.ListFindingsFiltered / GetFinding / DeleteFinding ListFindingsFiltered|GetFinding|DeleteFinding
from to via pattern
cmd/keys.go pkg/output/formatter.go output.Get for json/csv export output.Get(
from to via pattern
cmd/keys.go github.com/atotto/clipboard clipboard.WriteAll for keys copy clipboard.WriteAll
from to via pattern
cmd/root.go cmd/keys.go AddCommand(keysCmd) AddCommand(keysCmd)
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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:

func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error)

From Plan 06-01..03:

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>.

Task 1: Implement keys command tree (list/show/export/copy/delete/verify) cmd/keys.go, cmd/stubs.go, cmd/root.go - 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) 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"))
   }
   ```
cd /home/salva/Documents/apikey && go build ./... && go vet ./cmd/... - `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 Task 2: Integration tests for keys list/show/export/delete against in-memory DB cmd/keys_test.go - cmd/keys.go (from Task 1) - pkg/storage/queries.go - 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 ` 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=; 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. 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.
cd /home/salva/Documents/apikey && go test ./cmd/... -run "TestKeysList|TestKeysShow|TestKeysExport|TestKeysDelete" -count=1 - 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) - `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

<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>
After completion, create `.planning/phases/06-output-reporting/06-05-SUMMARY.md`.