docs(01-foundation): create phase 1 plan — 5 plans across 3 execution waves
Wave 0: module init + test scaffolding (01-01) Wave 1: provider registry (01-02) + storage layer (01-03) in parallel Wave 2: scan engine pipeline (01-04, depends on 01-02) Wave 3: CLI wiring + integration checkpoint (01-05, depends on all) Covers all 16 Phase 1 requirements: CORE-01 through CORE-07, STOR-01 through STOR-03, CLI-01 through CLI-05, PROV-10. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
748
.planning/phases/01-foundation/01-05-PLAN.md
Normal file
748
.planning/phases/01-foundation/01-05-PLAN.md
Normal file
@@ -0,0 +1,748 @@
|
||||
---
|
||||
phase: 01-foundation
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [01-02, 01-03, 01-04]
|
||||
files_modified:
|
||||
- cmd/root.go
|
||||
- cmd/scan.go
|
||||
- cmd/providers.go
|
||||
- cmd/config.go
|
||||
- pkg/config/config.go
|
||||
- pkg/output/table.go
|
||||
autonomous: false
|
||||
requirements: [CLI-01, CLI-02, CLI-03, CLI-04, CLI-05]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "`keyhunter scan ./testdata/samples/openai_key.txt` runs the pipeline and prints a finding"
|
||||
- "`keyhunter providers list` prints a table with at least 3 providers"
|
||||
- "`keyhunter providers info openai` prints OpenAI provider details"
|
||||
- "`keyhunter config init` creates ~/.keyhunter.yaml without error"
|
||||
- "`keyhunter config set workers 16` persists the value to ~/.keyhunter.yaml"
|
||||
- "`keyhunter --help` shows all top-level commands: scan, providers, config"
|
||||
artifacts:
|
||||
- path: "cmd/root.go"
|
||||
provides: "Cobra root command with PersistentPreRunE config loading"
|
||||
contains: "cobra.Command"
|
||||
- path: "cmd/scan.go"
|
||||
provides: "scan command wiring Engine + FileSource + output table"
|
||||
exports: ["scanCmd"]
|
||||
- path: "cmd/providers.go"
|
||||
provides: "providers list/info/stats subcommands using Registry"
|
||||
exports: ["providersCmd"]
|
||||
- path: "cmd/config.go"
|
||||
provides: "config init/set/get subcommands using Viper"
|
||||
exports: ["configCmd"]
|
||||
- path: "pkg/config/config.go"
|
||||
provides: "Config struct with Load() and defaults"
|
||||
exports: ["Config", "Load"]
|
||||
- path: "pkg/output/table.go"
|
||||
provides: "lipgloss terminal table for printing Findings"
|
||||
exports: ["PrintFindings"]
|
||||
key_links:
|
||||
- from: "cmd/scan.go"
|
||||
to: "pkg/engine/engine.go"
|
||||
via: "engine.NewEngine(registry).Scan() called in RunE"
|
||||
pattern: "engine\\.NewEngine"
|
||||
- from: "cmd/scan.go"
|
||||
to: "pkg/storage/db.go"
|
||||
via: "storage.Open() called, SaveFinding for each result"
|
||||
pattern: "storage\\.Open"
|
||||
- from: "cmd/root.go"
|
||||
to: "github.com/spf13/viper"
|
||||
via: "viper.SetConfigFile in PersistentPreRunE"
|
||||
pattern: "viper\\.SetConfigFile"
|
||||
- from: "cmd/providers.go"
|
||||
to: "pkg/providers/registry.go"
|
||||
via: "Registry.List(), Registry.Get(), Registry.Stats() called"
|
||||
pattern: "registry\\.List|registry\\.Get|registry\\.Stats"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire all subsystems together through the Cobra CLI: scan command (engine + storage + output), providers list/info/stats commands, and config init/set/get commands. This is the integration layer — all business logic lives in pkg/, cmd/ only wires.
|
||||
|
||||
Purpose: Satisfies all Phase 1 CLI requirements and delivers the first working `keyhunter scan` command that completes the end-to-end success criteria.
|
||||
Output: cmd/{root,scan,providers,config}.go, pkg/config/config.go, pkg/output/table.go.
|
||||
</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/01-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation/01-02-SUMMARY.md
|
||||
@.planning/phases/01-foundation/01-03-SUMMARY.md
|
||||
@.planning/phases/01-foundation/01-04-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Engine (from Plan 04) -->
|
||||
package engine
|
||||
type ScanConfig struct { Workers int; Verify bool; Unmask bool }
|
||||
func NewEngine(registry *providers.Registry) *Engine
|
||||
func (e *Engine) Scan(ctx context.Context, src sources.Source, cfg ScanConfig) (<-chan Finding, error)
|
||||
|
||||
<!-- FileSource (from Plan 04) -->
|
||||
package sources
|
||||
func NewFileSource(path string) *FileSource
|
||||
|
||||
<!-- Finding type (from Plan 04) -->
|
||||
type Finding struct {
|
||||
ProviderName string
|
||||
KeyValue string
|
||||
KeyMasked string
|
||||
Confidence string
|
||||
Source string
|
||||
LineNumber int
|
||||
}
|
||||
|
||||
<!-- Storage (from Plan 03) -->
|
||||
package storage
|
||||
func Open(path string) (*DB, error)
|
||||
func (db *DB) SaveFinding(f Finding, encKey []byte) (int64, error)
|
||||
func DeriveKey(passphrase []byte, salt []byte) []byte
|
||||
func NewSalt() ([]byte, error)
|
||||
|
||||
<!-- Registry (from Plan 02) -->
|
||||
package providers
|
||||
func NewRegistry() (*Registry, error)
|
||||
func (r *Registry) List() []Provider
|
||||
func (r *Registry) Get(name string) (Provider, bool)
|
||||
func (r *Registry) Stats() RegistryStats
|
||||
|
||||
<!-- Config defaults -->
|
||||
DBPath: ~/.keyhunter/keyhunter.db
|
||||
ConfigPath: ~/.keyhunter.yaml
|
||||
Workers: runtime.NumCPU() * 8
|
||||
Passphrase: (prompt if not in env KEYHUNTER_PASSPHRASE — Phase 1: use empty string as dev default)
|
||||
|
||||
<!-- Viper config keys -->
|
||||
"database.path" → DBPath
|
||||
"scan.workers" → Workers
|
||||
"encryption.passphrase" → Passphrase (sensitive — warn in help)
|
||||
|
||||
<!-- lipgloss table output -->
|
||||
Columns: PROVIDER | MASKED KEY | CONFIDENCE | SOURCE | LINE
|
||||
Colors: use lipgloss.NewStyle().Foreground() for confidence: high=green, medium=yellow, low=red
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Config package, output table, and root command</name>
|
||||
<files>pkg/config/config.go, pkg/output/table.go, cmd/root.go</files>
|
||||
<read_first>
|
||||
- /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-01, CLI-02, CLI-03 rows, Standard Stack: cobra v1.10.2 + viper v1.21.0)
|
||||
- /home/salva/Documents/apikey/pkg/engine/finding.go (Finding struct fields for output)
|
||||
</read_first>
|
||||
<action>
|
||||
Create **pkg/config/config.go**:
|
||||
```go
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Config holds all KeyHunter runtime configuration.
|
||||
// Values are populated from ~/.keyhunter.yaml, environment variables, and CLI flags (in that precedence order).
|
||||
type Config struct {
|
||||
DBPath string // path to SQLite database file
|
||||
ConfigPath string // path to config YAML file
|
||||
Workers int // number of scanner worker goroutines
|
||||
Passphrase string // encryption passphrase (sensitive)
|
||||
}
|
||||
|
||||
// Load returns a Config with defaults applied.
|
||||
// Callers should override individual fields after Load() using viper-bound values.
|
||||
func Load() Config {
|
||||
home, _ := os.UserHomeDir()
|
||||
return Config{
|
||||
DBPath: filepath.Join(home, ".keyhunter", "keyhunter.db"),
|
||||
ConfigPath: filepath.Join(home, ".keyhunter.yaml"),
|
||||
Workers: runtime.NumCPU() * 8,
|
||||
Passphrase: "", // Phase 1: empty passphrase; Phase 6+ will prompt
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create **pkg/output/table.go**:
|
||||
```go
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
var (
|
||||
styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green
|
||||
styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow
|
||||
styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red
|
||||
styleHeader = lipgloss.NewStyle().Bold(true).Underline(true)
|
||||
)
|
||||
|
||||
// PrintFindings writes findings as a colored terminal table to stdout.
|
||||
// If unmask is true, KeyValue is shown; otherwise KeyMasked is shown.
|
||||
func PrintFindings(findings []engine.Finding, unmask bool) {
|
||||
if len(findings) == 0 {
|
||||
fmt.Println("No API keys found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Header
|
||||
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %s\n",
|
||||
styleHeader.Render("PROVIDER"),
|
||||
styleHeader.Render("KEY"),
|
||||
styleHeader.Render("CONFIDENCE"),
|
||||
styleHeader.Render("SOURCE"),
|
||||
styleHeader.Render("LINE"),
|
||||
)
|
||||
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(
|
||||
"──────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||||
))
|
||||
|
||||
for _, f := range findings {
|
||||
keyDisplay := f.KeyMasked
|
||||
if unmask {
|
||||
keyDisplay = f.KeyValue
|
||||
}
|
||||
|
||||
confStyle := styleLow
|
||||
switch f.Confidence {
|
||||
case "high":
|
||||
confStyle = styleHigh
|
||||
case "medium":
|
||||
confStyle = styleMedium
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "%-20s %-40s %-10s %-30s %d\n",
|
||||
f.ProviderName,
|
||||
keyDisplay,
|
||||
confStyle.Render(f.Confidence),
|
||||
truncate(f.Source, 28),
|
||||
f.LineNumber,
|
||||
)
|
||||
}
|
||||
fmt.Printf("\n%d key(s) found.\n", len(findings))
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return "..." + s[len(s)-max+3:]
|
||||
}
|
||||
```
|
||||
|
||||
Create **cmd/root.go** (replaces the stub from Plan 01):
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
// rootCmd is the base command when called without any subcommands.
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "keyhunter",
|
||||
Short: "KeyHunter — detect leaked LLM API keys across 108+ providers",
|
||||
Long: `KeyHunter scans files, git history, and internet sources for leaked LLM API keys.
|
||||
Supports 108+ providers with Aho-Corasick pre-filtering and regex + entropy detection.`,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Execute is the entry point called by main.go.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.keyhunter.yaml)")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
rootCmd.AddCommand(providersCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: cannot determine home directory:", err)
|
||||
return
|
||||
}
|
||||
viper.SetConfigName(".keyhunter")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(home)
|
||||
viper.AddConfigPath(".")
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("KEYHUNTER")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Defaults
|
||||
viper.SetDefault("scan.workers", 0) // 0 = auto (CPU*8)
|
||||
viper.SetDefault("database.path", filepath.Join(mustHomeDir(), ".keyhunter", "keyhunter.db"))
|
||||
|
||||
// Config file is optional — ignore if not found
|
||||
_ = viper.ReadInConfig()
|
||||
}
|
||||
|
||||
func mustHomeDir() string {
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build ./... && ./keyhunter --help 2>&1 | grep -E "scan|providers|config" && echo "HELP OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go build ./...` exits 0
|
||||
- `./keyhunter --help` shows "scan", "providers", and "config" in command list
|
||||
- pkg/config/config.go exports Config and Load
|
||||
- pkg/output/table.go exports PrintFindings
|
||||
- cmd/root.go declares rootCmd, Execute(), scanCmd, providersCmd, configCmd referenced
|
||||
- `grep -q 'viper\.SetConfigFile\|viper\.SetConfigName' cmd/root.go` exits 0
|
||||
- lipgloss used for header and confidence coloring
|
||||
</acceptance_criteria>
|
||||
<done>Root command, config package, and output table exist. `keyhunter --help` shows the three top-level commands.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: scan, providers, and config subcommands</name>
|
||||
<files>cmd/scan.go, cmd/providers.go, cmd/config.go</files>
|
||||
<read_first>
|
||||
- /home/salva/Documents/apikey/.planning/phases/01-foundation/01-RESEARCH.md (CLI-04, CLI-05 rows, Pattern 2 pipeline usage)
|
||||
- /home/salva/Documents/apikey/cmd/root.go (rootCmd, viper setup)
|
||||
- /home/salva/Documents/apikey/pkg/engine/engine.go (Engine.Scan, ScanConfig)
|
||||
- /home/salva/Documents/apikey/pkg/storage/db.go (Open, SaveFinding)
|
||||
- /home/salva/Documents/apikey/pkg/providers/registry.go (NewRegistry, List, Get, Stats)
|
||||
</read_first>
|
||||
<action>
|
||||
Create **cmd/scan.go**:
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/salvacybersec/keyhunter/pkg/config"
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine/sources"
|
||||
"github.com/salvacybersec/keyhunter/pkg/output"
|
||||
"github.com/salvacybersec/keyhunter/pkg/providers"
|
||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
flagWorkers int
|
||||
flagVerify bool
|
||||
flagUnmask bool
|
||||
flagOutput string
|
||||
flagExclude []string
|
||||
)
|
||||
|
||||
var scanCmd = &cobra.Command{
|
||||
Use: "scan <path>",
|
||||
Short: "Scan a file or directory for leaked API keys",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := args[0]
|
||||
|
||||
// Load config
|
||||
cfg := config.Load()
|
||||
if viper.GetInt("scan.workers") > 0 {
|
||||
cfg.Workers = viper.GetInt("scan.workers")
|
||||
}
|
||||
|
||||
// Workers flag overrides config
|
||||
workers := flagWorkers
|
||||
if workers <= 0 {
|
||||
workers = cfg.Workers
|
||||
}
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU() * 8
|
||||
}
|
||||
|
||||
// Initialize registry
|
||||
reg, err := providers.NewRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading providers: %w", err)
|
||||
}
|
||||
|
||||
// Initialize engine
|
||||
eng := engine.NewEngine(reg)
|
||||
src := sources.NewFileSource(target)
|
||||
|
||||
scanCfg := engine.ScanConfig{
|
||||
Workers: workers,
|
||||
Verify: flagVerify,
|
||||
Unmask: flagUnmask,
|
||||
}
|
||||
|
||||
// Open database (ensure directory exists)
|
||||
dbPath := viper.GetString("database.path")
|
||||
if dbPath == "" {
|
||||
dbPath = cfg.DBPath
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
|
||||
return fmt.Errorf("creating database directory: %w", err)
|
||||
}
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Derive encryption key (Phase 1: empty passphrase with fixed dev salt)
|
||||
salt := []byte("keyhunter-dev-s0") // Phase 1 placeholder — Phase 6 replaces with proper salt storage
|
||||
encKey := storage.DeriveKey([]byte(cfg.Passphrase), salt)
|
||||
|
||||
// Run scan
|
||||
ch, err := eng.Scan(context.Background(), src, scanCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting scan: %w", err)
|
||||
}
|
||||
|
||||
var findings []engine.Finding
|
||||
for f := range ch {
|
||||
findings = append(findings, f)
|
||||
// Persist to storage
|
||||
storeFinding := storage.Finding{
|
||||
ProviderName: f.ProviderName,
|
||||
KeyValue: f.KeyValue,
|
||||
KeyMasked: f.KeyMasked,
|
||||
Confidence: f.Confidence,
|
||||
SourcePath: f.Source,
|
||||
SourceType: f.SourceType,
|
||||
LineNumber: f.LineNumber,
|
||||
}
|
||||
if _, err := db.SaveFinding(storeFinding, encKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
switch flagOutput {
|
||||
case "json":
|
||||
// Phase 6 — basic JSON for now
|
||||
fmt.Printf("[] # JSON output: Phase 6\n")
|
||||
default:
|
||||
output.PrintFindings(findings, flagUnmask)
|
||||
}
|
||||
|
||||
// Exit code semantics (CLI-05 / OUT-06): 0=clean, 1=found, 2=error
|
||||
if len(findings) > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().IntVar(&flagWorkers, "workers", 0, "number of worker goroutines (default: CPU*8)")
|
||||
scanCmd.Flags().BoolVar(&flagVerify, "verify", false, "actively verify found keys (opt-in, Phase 5)")
|
||||
scanCmd.Flags().BoolVar(&flagUnmask, "unmask", false, "show full key values (default: masked)")
|
||||
scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json (more in Phase 6)")
|
||||
scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "glob patterns to exclude (e.g. *.min.js)")
|
||||
viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers"))
|
||||
}
|
||||
```
|
||||
|
||||
Create **cmd/providers.go**:
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/salvacybersec/keyhunter/pkg/providers"
|
||||
)
|
||||
|
||||
var providersCmd = &cobra.Command{
|
||||
Use: "providers",
|
||||
Short: "Manage and inspect provider definitions",
|
||||
}
|
||||
|
||||
var providersListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all loaded provider definitions",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
reg, err := providers.NewRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bold := lipgloss.NewStyle().Bold(true)
|
||||
fmt.Fprintf(os.Stdout, "%-20s %-6s %-8s %s\n",
|
||||
bold.Render("NAME"), bold.Render("TIER"), bold.Render("PATTERNS"), bold.Render("KEYWORDS"))
|
||||
fmt.Println(strings.Repeat("─", 70))
|
||||
for _, p := range reg.List() {
|
||||
fmt.Fprintf(os.Stdout, "%-20s %-6d %-8d %s\n",
|
||||
p.Name, p.Tier, len(p.Patterns), strings.Join(p.Keywords, ", "))
|
||||
}
|
||||
stats := reg.Stats()
|
||||
fmt.Printf("\nTotal: %d providers\n", stats.Total)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var providersInfoCmd = &cobra.Command{
|
||||
Use: "info <name>",
|
||||
Short: "Show detailed info for a provider",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
reg, err := providers.NewRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, ok := reg.Get(args[0])
|
||||
if !ok {
|
||||
return fmt.Errorf("provider %q not found", args[0])
|
||||
}
|
||||
fmt.Printf("Name: %s\n", p.Name)
|
||||
fmt.Printf("Display Name: %s\n", p.DisplayName)
|
||||
fmt.Printf("Tier: %d\n", p.Tier)
|
||||
fmt.Printf("Last Verified: %s\n", p.LastVerified)
|
||||
fmt.Printf("Keywords: %s\n", strings.Join(p.Keywords, ", "))
|
||||
fmt.Printf("Patterns: %d\n", len(p.Patterns))
|
||||
for i, pat := range p.Patterns {
|
||||
fmt.Printf(" [%d] regex=%s confidence=%s entropy_min=%.1f\n",
|
||||
i+1, pat.Regex, pat.Confidence, pat.EntropyMin)
|
||||
}
|
||||
if p.Verify.URL != "" {
|
||||
fmt.Printf("Verify URL: %s %s\n", p.Verify.Method, p.Verify.URL)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var providersStatsCmd = &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show provider statistics",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
reg, err := providers.NewRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats := reg.Stats()
|
||||
fmt.Printf("Total providers: %d\n", stats.Total)
|
||||
fmt.Printf("By tier:\n")
|
||||
for tier := 1; tier <= 9; tier++ {
|
||||
if count := stats.ByTier[tier]; count > 0 {
|
||||
fmt.Printf(" Tier %d: %d\n", tier, count)
|
||||
}
|
||||
}
|
||||
fmt.Printf("By confidence:\n")
|
||||
for conf, count := range stats.ByConfidence {
|
||||
fmt.Printf(" %s: %d\n", conf, count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
providersCmd.AddCommand(providersListCmd)
|
||||
providersCmd.AddCommand(providersInfoCmd)
|
||||
providersCmd.AddCommand(providersStatsCmd)
|
||||
}
|
||||
```
|
||||
|
||||
Create **cmd/config.go**:
|
||||
```go
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage KeyHunter configuration",
|
||||
}
|
||||
|
||||
var configInitCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create default configuration file at ~/.keyhunter.yaml",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine home directory: %w", err)
|
||||
}
|
||||
configPath := filepath.Join(home, ".keyhunter.yaml")
|
||||
|
||||
// Set defaults before writing
|
||||
viper.SetDefault("scan.workers", 0)
|
||||
viper.SetDefault("database.path", filepath.Join(home, ".keyhunter", "keyhunter.db"))
|
||||
|
||||
if err := viper.WriteConfigAs(configPath); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
fmt.Printf("Config initialized: %s\n", configPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var configSetCmd = &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set a configuration value",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
key, value := args[0], args[1]
|
||||
viper.Set(key, value)
|
||||
if err := viper.WriteConfig(); err != nil {
|
||||
// If config file doesn't exist yet, create it
|
||||
home, _ := os.UserHomeDir()
|
||||
configPath := filepath.Join(home, ".keyhunter.yaml")
|
||||
if err2 := viper.WriteConfigAs(configPath); err2 != nil {
|
||||
return fmt.Errorf("writing config: %w", err2)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Set %s = %s\n", key, value)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var configGetCmd = &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Get a configuration value",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
val := viper.Get(args[0])
|
||||
if val == nil {
|
||||
return fmt.Errorf("key %q not found", args[0])
|
||||
}
|
||||
fmt.Printf("%v\n", val)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(configInitCmd)
|
||||
configCmd.AddCommand(configSetCmd)
|
||||
configCmd.AddCommand(configGetCmd)
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/salva/Documents/apikey && go build -o keyhunter . && ./keyhunter providers list && ./keyhunter providers info openai && echo "PROVIDERS OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go build -o keyhunter .` exits 0
|
||||
- `./keyhunter --help` shows scan, providers, config commands
|
||||
- `./keyhunter providers list` prints table with >= 3 rows including "openai"
|
||||
- `./keyhunter providers info openai` prints Name, Tier, Keywords, Patterns, Verify URL
|
||||
- `./keyhunter providers stats` prints "Total providers: 3" or more
|
||||
- `./keyhunter config init` creates or updates ~/.keyhunter.yaml
|
||||
- `./keyhunter config set scan.workers 16` exits 0
|
||||
- `./keyhunter scan testdata/samples/openai_key.txt` exits 1 (keys found) and prints a table row with "openai"
|
||||
- `./keyhunter scan testdata/samples/no_keys.txt` exits 0 and prints "No API keys found."
|
||||
- `grep -q 'viper\.BindPFlag' cmd/scan.go` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Full CLI works: scan finds and persists keys, providers list/info/stats work, config init/set/get work. Phase 1 success criteria all met.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
Complete Phase 1 implementation:
|
||||
- Provider registry with 3 YAML definitions, Aho-Corasick automaton, schema validation
|
||||
- Storage layer with AES-256-GCM encryption, Argon2id key derivation, SQLite WAL mode
|
||||
- Three-stage scan engine: keyword pre-filter → regex + entropy detector → finding channel
|
||||
- CLI: keyhunter scan, providers list/info/stats, config init/set/get
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run these commands from the project root and confirm each expected output:
|
||||
|
||||
1. `cd /home/salva/Documents/apikey && go test ./... -v -count=1`
|
||||
Expected: All tests PASS, zero FAIL, zero SKIP (except original stubs now filled)
|
||||
|
||||
2. `./keyhunter scan testdata/samples/openai_key.txt`
|
||||
Expected: Exit code 1, table printed with 1 row showing "openai" provider, masked key
|
||||
|
||||
3. `./keyhunter scan testdata/samples/no_keys.txt`
|
||||
Expected: Exit code 0, "No API keys found." printed
|
||||
|
||||
4. `./keyhunter providers list`
|
||||
Expected: Table with openai, anthropic, huggingface rows
|
||||
|
||||
5. `./keyhunter providers info openai`
|
||||
Expected: Name, Tier 1, Keywords including "sk-proj-", Pattern regex shown
|
||||
|
||||
6. `./keyhunter config init`
|
||||
Expected: "Config initialized: ~/.keyhunter.yaml" and the file exists
|
||||
|
||||
7. `./keyhunter config set scan.workers 16 && ./keyhunter config get scan.workers`
|
||||
Expected: "Set scan.workers = 16" then "16"
|
||||
|
||||
8. Build the binary with production flags:
|
||||
`CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .`
|
||||
Expected: Builds without error, binary produced
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all 8 checks pass, or describe which check failed and what output you saw.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Full Phase 1 integration check:
|
||||
- `go test ./... -count=1` exits 0
|
||||
- `./keyhunter scan testdata/samples/openai_key.txt` exits 1 with findings table
|
||||
- `./keyhunter scan testdata/samples/no_keys.txt` exits 0 with "No API keys found."
|
||||
- `./keyhunter providers list` shows 3+ providers
|
||||
- `./keyhunter config init` creates ~/.keyhunter.yaml
|
||||
- `CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .` exits 0
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Cobra CLI with scan, providers, config commands (CLI-01)
|
||||
- `keyhunter config init` creates ~/.keyhunter.yaml (CLI-02)
|
||||
- `keyhunter config set key value` persists (CLI-03)
|
||||
- `keyhunter providers list/info/stats` work (CLI-04)
|
||||
- scan flags: --workers, --verify, --unmask, --output, --exclude (CLI-05)
|
||||
- All Phase 1 success criteria from ROADMAP.md satisfied:
|
||||
1. `keyhunter scan ./somefile` runs three-stage pipeline and returns findings with provider names
|
||||
2. Findings persisted to SQLite with AES-256 encrypted key_value
|
||||
3. `keyhunter config init` and `config set` work
|
||||
4. `keyhunter providers list/info` return provider metadata from YAML
|
||||
5. Provider YAML has format_version and last_verified, validated at load time
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` following the summary template.
|
||||
</output>
|
||||
Reference in New Issue
Block a user