docs(05): create phase 5 verification engine plans
This commit is contained in:
320
.planning/phases/05-verification-engine/05-05-PLAN.md
Normal file
320
.planning/phases/05-verification-engine/05-05-PLAN.md
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
phase: 05-verification-engine
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [05-01, 05-02, 05-03, 05-04]
|
||||
files_modified:
|
||||
- cmd/scan.go
|
||||
- cmd/scan_test.go
|
||||
- pkg/output/table.go
|
||||
- pkg/output/table_test.go
|
||||
autonomous: true
|
||||
requirements: [VRFY-01, VRFY-04, VRFY-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "keyhunter scan --verify triggers EnsureConsent before any verify HTTP calls; declined consent skips verification but still prints findings"
|
||||
- "Verified findings have VerifyStatus populated and are persisted via SaveFinding with verify_* columns set"
|
||||
- "--verify-timeout=30s changes the per-key HTTP timeout from default 10s"
|
||||
- "--verify-workers=N sets the ants pool size for parallel verification"
|
||||
- "Output table shows a VERIFY column: ✓ live / ✗ dead / ⚠ rate-limited / ? unknown / ! error"
|
||||
- "Verification only runs after scan completes (batch mode) — all findings collected, then verified"
|
||||
artifacts:
|
||||
- path: "cmd/scan.go"
|
||||
provides: "--verify wiring: consent -> verifier -> save -> display"
|
||||
contains: "verify.EnsureConsent"
|
||||
- path: "pkg/output/table.go"
|
||||
provides: "Verification status column"
|
||||
contains: "VERIFY"
|
||||
key_links:
|
||||
- from: "cmd/scan.go"
|
||||
to: "pkg/verify.HTTPVerifier.VerifyAll"
|
||||
via: "after scan findings collected"
|
||||
pattern: "VerifyAll"
|
||||
- from: "cmd/scan.go"
|
||||
to: "pkg/verify.EnsureConsent"
|
||||
via: "gate before verification"
|
||||
pattern: "EnsureConsent"
|
||||
- from: "cmd/scan.go"
|
||||
to: "storage.SaveFinding"
|
||||
via: "persists verified findings with VerifyStatus populated"
|
||||
pattern: "storeFinding.VerifyStatus"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire Plans 05-02/03/04 together into the scan command. Add `--verify-timeout` and `--verify-workers` flags, gate verification behind consent, run the verifier over collected findings, persist verify results, and render a new verify column in the output table.
|
||||
|
||||
Purpose: End-user visible feature — this is where VRFY-01 (prompt), VRFY-04 (metadata display), and VRFY-05 (configurable timeout) come together.
|
||||
Output: Working `keyhunter scan --verify` command that prompts on first use and displays verification status.
|
||||
</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/05-verification-engine/05-CONTEXT.md
|
||||
@cmd/scan.go
|
||||
@pkg/output/table.go
|
||||
@pkg/engine/finding.go
|
||||
|
||||
<interfaces>
|
||||
Available after Wave 1 completes:
|
||||
|
||||
```go
|
||||
// pkg/verify/consent.go (Plan 05-02)
|
||||
func EnsureConsent(db *storage.DB, in io.Reader, out io.Writer) (bool, error)
|
||||
|
||||
// pkg/verify/verifier.go (Plan 05-03)
|
||||
func NewHTTPVerifier(timeout time.Duration) *HTTPVerifier
|
||||
func (v *HTTPVerifier) VerifyAll(ctx, []engine.Finding, *providers.Registry, workers int) <-chan Result
|
||||
|
||||
// pkg/verify/result.go
|
||||
type Result struct {
|
||||
ProviderName string
|
||||
KeyMasked string
|
||||
Status string // StatusLive/Dead/RateLimited/Error/Unknown
|
||||
HTTPCode int
|
||||
Metadata map[string]string
|
||||
RetryAfter time.Duration
|
||||
ResponseTime time.Duration
|
||||
Error string
|
||||
}
|
||||
|
||||
// pkg/storage/findings.go (Plan 05-01)
|
||||
type Finding struct {
|
||||
// ... existing ...
|
||||
Verified bool
|
||||
VerifyStatus string
|
||||
VerifyHTTPCode int
|
||||
VerifyMetadata map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
Current scan command already has `flagVerify bool`. This plan extends with `flagVerifyTimeout time.Duration` and `flagVerifyWorkers int`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Wire verifier into cmd/scan.go with consent and flags</name>
|
||||
<files>cmd/scan.go, cmd/scan_test.go</files>
|
||||
<behavior>
|
||||
- New flags registered: `--verify-timeout` (default 10s), `--verify-workers` (default 10)
|
||||
- When --verify is set: collect all findings, call EnsureConsent(db, os.Stdin, os.Stderr)
|
||||
- If consent declined: print notice to stderr, skip verification, still display + persist unverified findings
|
||||
- If consent granted: run NewHTTPVerifier(timeout).VerifyAll(ctx, findings, reg, workers), read results from channel, match back to findings by (provider+KeyMasked), update Finding.Verified/VerifyStatus/VerifyHTTPCode/VerifyMetadata
|
||||
- SaveFinding is called AFTER verification so verify_* columns are persisted in the same row (refactor current loop: collect first, verify second, save third)
|
||||
- On scan errors or no findings: verification path is a no-op
|
||||
</behavior>
|
||||
<action>
|
||||
1. In `cmd/scan.go`:
|
||||
|
||||
a. Add imports: `"io"` (may already exist), `"github.com/salvacybersec/keyhunter/pkg/verify"`, `"time"` (already there).
|
||||
|
||||
b. Add new package-level flag variables near existing flagVerify:
|
||||
```go
|
||||
var (
|
||||
flagVerifyTimeout time.Duration
|
||||
flagVerifyWorkers int
|
||||
)
|
||||
```
|
||||
|
||||
c. In the `init()` function add:
|
||||
```go
|
||||
scanCmd.Flags().DurationVar(&flagVerifyTimeout, "verify-timeout", 10*time.Second, "per-key verification HTTP timeout (default 10s)")
|
||||
scanCmd.Flags().IntVar(&flagVerifyWorkers, "verify-workers", 10, "parallel workers for key verification (default 10)")
|
||||
```
|
||||
|
||||
d. Refactor the scan loop in `RunE`. Currently the loop saves each finding as it comes from the channel. Change to:
|
||||
```go
|
||||
// Collect findings first (no immediate save) so verification can populate
|
||||
// verify_* fields before persistence.
|
||||
var findings []engine.Finding
|
||||
for f := range ch {
|
||||
findings = append(findings, f)
|
||||
}
|
||||
```
|
||||
|
||||
e. After the collection loop, add the verification block:
|
||||
```go
|
||||
if flagVerify && len(findings) > 0 {
|
||||
granted, err := verify.EnsureConsent(db, os.Stdin, os.Stderr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consent check: %w", err)
|
||||
}
|
||||
if !granted {
|
||||
fmt.Fprintln(os.Stderr, "Verification skipped (consent not granted). Run `keyhunter legal` for details.")
|
||||
} else {
|
||||
verifier := verify.NewHTTPVerifier(flagVerifyTimeout)
|
||||
resultsCh := verifier.VerifyAll(context.Background(), findings, reg, flagVerifyWorkers)
|
||||
// Build an index for back-assignment
|
||||
idx := make(map[string]int, len(findings))
|
||||
for i, f := range findings {
|
||||
key := f.ProviderName + "|" + f.KeyMasked
|
||||
idx[key] = i
|
||||
}
|
||||
for r := range resultsCh {
|
||||
if i, ok := idx[r.ProviderName+"|"+r.KeyMasked]; ok {
|
||||
findings[i].Verified = true
|
||||
findings[i].VerifyStatus = r.Status
|
||||
findings[i].VerifyHTTPCode = r.HTTPCode
|
||||
findings[i].VerifyMetadata = r.Metadata
|
||||
if r.Error != "" {
|
||||
findings[i].VerifyError = r.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
f. Then persist all findings (moved out of collection loop) with verify fields now populated:
|
||||
```go
|
||||
for _, f := range findings {
|
||||
storeFinding := storage.Finding{
|
||||
ProviderName: f.ProviderName,
|
||||
KeyValue: f.KeyValue,
|
||||
KeyMasked: f.KeyMasked,
|
||||
Confidence: f.Confidence,
|
||||
SourcePath: f.Source,
|
||||
SourceType: f.SourceType,
|
||||
LineNumber: f.LineNumber,
|
||||
Verified: f.Verified,
|
||||
VerifyStatus: f.VerifyStatus,
|
||||
VerifyHTTPCode: f.VerifyHTTPCode,
|
||||
VerifyMetadata: f.VerifyMetadata,
|
||||
}
|
||||
if _, err := db.SaveFinding(storeFinding, encKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
g. Leave the output rendering call unchanged (Task 2 handles the display column).
|
||||
|
||||
2. Create `cmd/scan_test.go` (or append if present) with:
|
||||
- `TestScan_VerifyFlag_DeclinedConsent_SkipsVerification` — set up a scan command with --verify, provide stdin reader "no\n", run against a test file with one fake key, assert that the resulting in-memory finding has Verified=false and the scan still completes
|
||||
- `TestScan_VerifyFlag_GrantedConsent_PopulatesStatus` — pre-seed settings "verify.consent" = "granted", run scan --verify against a file containing a test pattern, assert at least one finding has Verified=true after the run
|
||||
|
||||
These tests will likely need to refactor scanCmd to accept injected stdin and a test helper to invoke the command function directly (not via cobra execution). If that's too invasive, scope Task 1 tests to:
|
||||
- `TestScan_VerifyFlags_Registered` — ensure --verify-timeout and --verify-workers flags exist on scanCmd with correct defaults (call `scanCmd.Flags().Lookup("verify-timeout")` and assert non-nil + default "10s")
|
||||
|
||||
Prefer the lightweight flag-registration test to avoid pulling the full scan path into tests. Add at least one behavioral integration test if straightforward; otherwise document the limitation in the task-level SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build ./... && go test ./cmd/... -run VerifyFlag -v</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q 'verify.EnsureConsent' cmd/scan.go`
|
||||
- `grep -q 'verifier.VerifyAll\|NewHTTPVerifier' cmd/scan.go`
|
||||
- `grep -q 'verify-timeout' cmd/scan.go`
|
||||
- `grep -q 'verify-workers' cmd/scan.go`
|
||||
- `go build ./...` succeeds
|
||||
- `go run . scan --help` shows --verify, --verify-timeout, --verify-workers flags
|
||||
- scan_test.go flag-registration test passes
|
||||
</acceptance_criteria>
|
||||
<done>Scan command orchestrates consent → verification → save with configurable timeout and workers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Output table shows verification status column and metadata</name>
|
||||
<files>pkg/output/table.go, pkg/output/table_test.go</files>
|
||||
<behavior>
|
||||
- When any finding has Verified=true, PrintFindings renders an extra VERIFY column
|
||||
- Symbol mapping: "live"=✓ (green), "dead"=✗ (red), "rate_limited"=⚠ (yellow), "error"=! (red), "unknown"=? (gray), "" (unverified)=empty cell
|
||||
- When any finding has VerifyMetadata, a second summary line per finding shows key: value pairs (e.g. " org: Acme Corp, tier: plus")
|
||||
- When no findings are verified, table layout is unchanged from Phase 1 (backward compat)
|
||||
</behavior>
|
||||
<action>
|
||||
1. In `pkg/output/table.go`, modify `PrintFindings`:
|
||||
|
||||
a. Compute `anyVerified := false` by scanning findings once before printing.
|
||||
|
||||
b. If anyVerified, add a VERIFY column header between KEY and CONFIDENCE (or after LINE — pick after LINE for minimal disruption to column widths):
|
||||
```go
|
||||
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %-5s %s\n",
|
||||
styleHeader.Render("PROVIDER"),
|
||||
styleHeader.Render("KEY"),
|
||||
styleHeader.Render("CONFIDENCE"),
|
||||
styleHeader.Render("SOURCE"),
|
||||
styleHeader.Render("LINE"),
|
||||
styleHeader.Render("VERIFY"),
|
||||
)
|
||||
```
|
||||
|
||||
c. Add helper:
|
||||
```go
|
||||
func verifySymbol(f engine.Finding) string {
|
||||
if !f.Verified {
|
||||
return ""
|
||||
}
|
||||
switch f.VerifyStatus {
|
||||
case "live":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("✓ live")
|
||||
case "dead":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("✗ dead")
|
||||
case "rate_limited":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("⚠ rate")
|
||||
case "error":
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("! err")
|
||||
default:
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("? unk")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
d. In the per-finding loop, when anyVerified, append verifySymbol(f) as the final column. When len(f.VerifyMetadata) > 0, print a second indented line:
|
||||
```go
|
||||
if len(f.VerifyMetadata) > 0 {
|
||||
parts := make([]string, 0, len(f.VerifyMetadata))
|
||||
for k, v := range f.VerifyMetadata {
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", k, v))
|
||||
}
|
||||
sort.Strings(parts) // deterministic order
|
||||
fmt.Fprintf(os.Stdout, " ↳ %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
```
|
||||
Add the `sort` and `strings` imports.
|
||||
|
||||
2. Create `pkg/output/table_test.go`:
|
||||
- `TestPrintFindings_NoVerification_Unchanged` — findings with Verified=false, capture stdout via os.Pipe redirect, assert output does not contain "VERIFY" header (backward compat)
|
||||
- `TestPrintFindings_LiveVerification_ShowsCheck` — finding with Verified=true, VerifyStatus="live", assert stdout contains "VERIFY" and "live"
|
||||
- `TestPrintFindings_Metadata_Rendered` — finding with VerifyMetadata={"org":"Acme","tier":"plus"}, assert stdout contains "org: Acme" and "tier: plus" on the indented metadata line
|
||||
|
||||
Capture stdout using the `os.Pipe` + `os.Stdout = w` swap pattern, restore after test. Strip ANSI escape sequences before asserting content (lipgloss output contains them). Use a small helper `stripANSI(s string) string` with a regex `\x1b\[[0-9;]*m`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/output/... -v</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -q 'VERIFY' pkg/output/table.go`
|
||||
- `grep -q 'verifySymbol\|VerifyStatus' pkg/output/table.go`
|
||||
- All 3 table tests pass
|
||||
- `go build ./...` succeeds
|
||||
- Manual: `go run . scan ./testdata` (without --verify) output is unchanged; with --verify shows VERIFY column
|
||||
</acceptance_criteria>
|
||||
<done>Output table renders verify column and metadata line when findings are verified; backward compatible when not.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go build ./...` clean
|
||||
- `go test ./... -v` across all modified packages green
|
||||
- `go run . scan --help` shows `--verify`, `--verify-timeout`, `--verify-workers`
|
||||
- Manual smoke: create a file with a fake `sk-proj-...` string, run `go run . scan file.txt --verify`, first run prompts for consent, subsequent runs skip prompt
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- VRFY-01: consent prompt gates --verify on first use
|
||||
- VRFY-04: metadata displayed under finding when extracted
|
||||
- VRFY-05: --verify-timeout and --verify-workers flags work
|
||||
- Unverified scans unchanged from Phase 4 behavior
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/05-verification-engine/05-05-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user