14 KiB
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 |
|
|
true |
|
|
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>.
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>