docs(06): create phase 6 plans — output formats + key management
This commit is contained in:
287
.planning/phases/06-output-reporting/06-04-PLAN.md
Normal file
287
.planning/phases/06-output-reporting/06-04-PLAN.md
Normal file
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: 06-output-reporting
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- pkg/storage/queries.go
|
||||
- pkg/storage/queries_test.go
|
||||
autonomous: true
|
||||
requirements: [KEYS-01, KEYS-02, KEYS-06]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "the keys command (Plan 05) can list findings with filters"
|
||||
- "the keys command can fetch a single finding by ID"
|
||||
- "the keys command can delete a finding by ID"
|
||||
- "existing db.ListFindings remains backward compatible"
|
||||
artifacts:
|
||||
- path: pkg/storage/queries.go
|
||||
provides: "Filters, ListFindingsFiltered, GetFinding, DeleteFinding"
|
||||
exports: ["Filters", "ListFindingsFiltered", "GetFinding", "DeleteFinding"]
|
||||
key_links:
|
||||
- from: pkg/storage/queries.go
|
||||
to: pkg/storage/findings.go
|
||||
via: "Reuses encrypt/decrypt + Finding struct"
|
||||
pattern: "Decrypt\\(encrypted, encKey\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a thin query layer on top of findings.go providing filtered list, single-record lookup, and delete. These are the DB primitives the `keyhunter keys` command tree (Plan 05) will call.
|
||||
|
||||
Purpose: Key management (KEYS-01, KEYS-02, KEYS-06 foundation; KEYS-03/04/05 built on top in Plan 05).
|
||||
Output: `pkg/storage/queries.go` + tests using in-memory SQLite (":memory:").
|
||||
</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
|
||||
@pkg/storage/findings.go
|
||||
@pkg/storage/db.go
|
||||
|
||||
<interfaces>
|
||||
From pkg/storage/findings.go:
|
||||
```go
|
||||
type Finding struct {
|
||||
ID int64
|
||||
ScanID int64
|
||||
ProviderName string
|
||||
KeyValue string // plaintext after decrypt
|
||||
KeyMasked string
|
||||
Confidence string
|
||||
SourcePath string
|
||||
SourceType string
|
||||
LineNumber int
|
||||
CreatedAt time.Time
|
||||
Verified bool
|
||||
VerifyStatus string
|
||||
VerifyHTTPCode int
|
||||
VerifyMetadata map[string]string
|
||||
}
|
||||
func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error)
|
||||
func (db *DB) ListFindings(encKey []byte) ([]Finding, error)
|
||||
```
|
||||
DB schema columns for findings table: id, scan_id, provider_name, key_value (encrypted BLOB), key_masked, confidence, source_path, source_type, line_number, verified, verify_status, verify_http_code, verify_metadata_json, created_at.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Filters struct, ListFindingsFiltered, GetFinding, DeleteFinding</name>
|
||||
<files>pkg/storage/queries.go, pkg/storage/queries_test.go</files>
|
||||
<read_first>
|
||||
- pkg/storage/findings.go (row scan helpers, columns)
|
||||
- pkg/storage/db.go (Open, schema usage)
|
||||
- pkg/storage/keys.go or wherever DeriveKey/NewSalt live (for test setup)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Filters: { Provider string, Verified *bool, Limit int, Offset int }.
|
||||
- ListFindingsFiltered(encKey, filters) returns decrypted findings matching:
|
||||
* Provider (exact match, empty = no filter)
|
||||
* Verified (nil = no filter; ptr-bool matches 1/0)
|
||||
* ORDER BY created_at DESC, id DESC
|
||||
* Limit/Offset applied only when Limit > 0
|
||||
- GetFinding(id, encKey) returns *Finding or (nil, sql.ErrNoRows) when absent.
|
||||
- DeleteFinding(id) runs DELETE; returns (rowsAffected int64, error). Zero rows affected is not an error (caller decides).
|
||||
- Tests (using ":memory:" DB + DeriveKey with a test salt):
|
||||
* Seed 3 findings across 2 providers with mixed verified status.
|
||||
* TestListFindingsFiltered_ByProvider: filter provider=="openai" returns only openai rows.
|
||||
* TestListFindingsFiltered_Verified: Verified=&true returns only verified rows.
|
||||
* TestListFindingsFiltered_Pagination: Limit=1, Offset=1 returns second row.
|
||||
* TestGetFinding_Hit: returns row with decrypted KeyValue matching original.
|
||||
* TestGetFinding_Miss: returns nil, sql.ErrNoRows for id=9999.
|
||||
* TestDeleteFinding_Hit: rowsAffected==1, subsequent Get returns sql.ErrNoRows.
|
||||
* TestDeleteFinding_Miss: rowsAffected==0, no error.
|
||||
</behavior>
|
||||
<action>
|
||||
Create pkg/storage/queries.go:
|
||||
|
||||
```go
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filters selects a subset of findings for ListFindingsFiltered.
|
||||
// Empty Provider means "any provider". Nil Verified means "any verified state".
|
||||
// Limit <= 0 disables pagination.
|
||||
type Filters struct {
|
||||
Provider string
|
||||
Verified *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListFindingsFiltered returns findings matching the given filters, newest first.
|
||||
// Key values are decrypted before return. encKey must match the key used at save time.
|
||||
func (db *DB) ListFindingsFiltered(encKey []byte, f Filters) ([]Finding, error) {
|
||||
var (
|
||||
where []string
|
||||
args []interface{}
|
||||
)
|
||||
if f.Provider != "" {
|
||||
where = append(where, "provider_name = ?")
|
||||
args = append(args, f.Provider)
|
||||
}
|
||||
if f.Verified != nil {
|
||||
where = append(where, "verified = ?")
|
||||
if *f.Verified {
|
||||
args = append(args, 1)
|
||||
} else {
|
||||
args = append(args, 0)
|
||||
}
|
||||
}
|
||||
q := `SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
|
||||
source_path, source_type, line_number,
|
||||
verified, verify_status, verify_http_code, verify_metadata_json,
|
||||
created_at
|
||||
FROM findings`
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
q += " ORDER BY created_at DESC, id DESC"
|
||||
if f.Limit > 0 {
|
||||
q += " LIMIT ? OFFSET ?"
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
}
|
||||
rows, err := db.sql.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying findings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Finding
|
||||
for rows.Next() {
|
||||
f, err := scanFindingRow(rows, encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFinding returns a single finding by id. Returns sql.ErrNoRows if absent.
|
||||
func (db *DB) GetFinding(id int64, encKey []byte) (*Finding, error) {
|
||||
row := db.sql.QueryRow(
|
||||
`SELECT id, scan_id, provider_name, key_value, key_masked, confidence,
|
||||
source_path, source_type, line_number,
|
||||
verified, verify_status, verify_http_code, verify_metadata_json,
|
||||
created_at
|
||||
FROM findings WHERE id = ?`, id)
|
||||
f, err := scanFindingRowFromRow(row, encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// DeleteFinding removes the finding with the given id.
|
||||
// Returns the number of rows affected (0 if no such id).
|
||||
func (db *DB) DeleteFinding(id int64) (int64, error) {
|
||||
res, err := db.sql.Exec(`DELETE FROM findings WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting finding %d: %w", id, err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// scanFindingRow reads one Finding from *sql.Rows and decrypts its key.
|
||||
func scanFindingRow(rows *sql.Rows, encKey []byte) (Finding, error) {
|
||||
var f Finding
|
||||
var encrypted []byte
|
||||
var createdAt string
|
||||
var scanID sql.NullInt64
|
||||
var verifiedInt int
|
||||
var metaJSON sql.NullString
|
||||
if err := rows.Scan(
|
||||
&f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked,
|
||||
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber,
|
||||
&verifiedInt, &f.VerifyStatus, &f.VerifyHTTPCode, &metaJSON,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return f, fmt.Errorf("scanning finding row: %w", err)
|
||||
}
|
||||
return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey)
|
||||
}
|
||||
|
||||
func scanFindingRowFromRow(row *sql.Row, encKey []byte) (Finding, error) {
|
||||
var f Finding
|
||||
var encrypted []byte
|
||||
var createdAt string
|
||||
var scanID sql.NullInt64
|
||||
var verifiedInt int
|
||||
var metaJSON sql.NullString
|
||||
if err := row.Scan(
|
||||
&f.ID, &scanID, &f.ProviderName, &encrypted, &f.KeyMasked,
|
||||
&f.Confidence, &f.SourcePath, &f.SourceType, &f.LineNumber,
|
||||
&verifiedInt, &f.VerifyStatus, &f.VerifyHTTPCode, &metaJSON,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return f, err // includes sql.ErrNoRows — let caller detect
|
||||
}
|
||||
return hydrateFinding(f, encrypted, scanID, verifiedInt, metaJSON, createdAt, encKey)
|
||||
}
|
||||
|
||||
func hydrateFinding(f Finding, encrypted []byte, scanID sql.NullInt64, verifiedInt int, metaJSON sql.NullString, createdAt string, encKey []byte) (Finding, error) {
|
||||
if scanID.Valid {
|
||||
f.ScanID = scanID.Int64
|
||||
}
|
||||
f.Verified = verifiedInt != 0
|
||||
if metaJSON.Valid && metaJSON.String != "" {
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON.String), &m); err != nil {
|
||||
return f, fmt.Errorf("unmarshaling verify metadata for finding %d: %w", f.ID, err)
|
||||
}
|
||||
f.VerifyMetadata = m
|
||||
}
|
||||
plain, err := Decrypt(encrypted, encKey)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("decrypting finding %d: %w", f.ID, err)
|
||||
}
|
||||
f.KeyValue = string(plain)
|
||||
f.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
return f, nil
|
||||
}
|
||||
```
|
||||
|
||||
Create pkg/storage/queries_test.go with the seven tests from <behavior>. Use `storage.Open(":memory:")`, generate salt, DeriveKey, SaveFinding seed rows with distinct providers/verified flags, then exercise each query. Use testify assertions.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go test ./pkg/storage/... -run "TestListFindingsFiltered|TestGetFinding|TestDeleteFinding" -count=1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All seven query tests pass
|
||||
- `grep -q "ListFindingsFiltered\|GetFinding\|DeleteFinding" pkg/storage/queries.go`
|
||||
- `go build ./...` succeeds
|
||||
- Existing `pkg/storage/...` tests still pass (no regressions in ListFindings)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./pkg/storage/... -count=1` all green
|
||||
- `grep -q "type Filters struct" pkg/storage/queries.go`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Filters struct supports provider, verified, pagination
|
||||
- GetFinding returns sql.ErrNoRows on miss
|
||||
- DeleteFinding returns rows affected
|
||||
- All tests green with in-memory DB
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-output-reporting/06-04-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user