34 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 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-foundation | 05 | execute | 3 |
|
|
true |
|
|
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,stubs}.go, pkg/config/config.go, pkg/output/table.go.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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)package sources func NewFileSource(path string) *FileSource
type Finding struct { ProviderName string KeyValue string KeyMasked string Confidence string Source string LineNumber int }
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)
func (db *DB) GetSetting(key string) (string, bool, error) func (db *DB) SetSetting(key string, value string) error
package providers func NewRegistry() (*Registry, error) func (r *Registry) List() []Provider func (r *Registry) Get(name string) (Provider, bool) func (r *Registry) Stats() RegistryStats
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)
"database.path" → DBPath "scan.workers" → Workers "encryption.passphrase" → Passphrase (sensitive — warn in help)
Columns: PROVIDER | MASKED KEY | CONFIDENCE | SOURCE | LINE Colors: use lipgloss.NewStyle().Foreground() for confidence: high=green, medium=yellow, low=red
On first scan, call storage.NewSalt(), hex-encode it, store in settings table with key "encryption.salt". On subsequent scans, read the salt from the settings table. This ensures all users have a unique per-installation salt instead of a shared hardcoded salt. The helper function loadOrCreateSalt(db *storage.DB) ([]byte, error) handles both cases.
Task 1: Config package, output table, root command, and settings helpers pkg/config/config.go, pkg/output/table.go, cmd/root.go, pkg/storage/settings.go - /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) - /home/salva/Documents/apikey/pkg/storage/db.go (DB struct, to add GetSetting/SetSetting) Create **pkg/config/config.go**: ```go package configimport ( "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/storage/settings.go** — adds GetSetting/SetSetting to the storage package:
```go
package storage
import (
"database/sql"
"fmt"
)
// GetSetting retrieves a value from the settings table.
// Returns (value, true, nil) if found, ("", false, nil) if not found, ("", false, err) on error.
func (db *DB) GetSetting(key string) (string, bool, error) {
var value string
err := db.sql.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("getting setting %q: %w", key, err)
}
return value, true, nil
}
// SetSetting inserts or updates a key-value pair in the settings table.
func (db *DB) SetSetting(key, value string) error {
_, err := db.sql.Exec(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
key, value,
)
if err != nil {
return fmt.Errorf("setting %q: %w", key, err)
}
return nil
}
Create pkg/output/table.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):
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)
// Stub commands for future phases (per CLI-01 requirement of 11 commands)
rootCmd.AddCommand(verifyCmd)
rootCmd.AddCommand(importCmd)
rootCmd.AddCommand(reconCmd)
rootCmd.AddCommand(keysCmd)
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(dorksCmd)
rootCmd.AddCommand(hookCmd)
rootCmd.AddCommand(scheduleCmd)
}
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
}
import ( "context" "encoding/hex" "encoding/json" "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 ", 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 using a per-installation salt stored in settings table.
// On first run, NewSalt() generates a random salt and stores it.
// On subsequent runs, the same salt is loaded — ensuring consistent encryption.
encKey, err := loadOrCreateEncKey(db, cfg.Passphrase)
if err != nil {
return fmt.Errorf("preparing encryption key: %w", err)
}
// 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":
// Return valid empty JSON array when no findings; full JSON in Phase 6.
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
type jsonFinding struct {
Provider string `json:"provider"`
KeyMasked string `json:"key_masked"`
Confidence string `json:"confidence"`
Source string `json:"source"`
Line int `json:"line"`
}
out := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
out = append(out, jsonFinding{
Provider: f.ProviderName,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
Source: f.Source,
Line: f.LineNumber,
})
}
if err := enc.Encode(out); err != nil {
return fmt.Errorf("encoding JSON output: %w", err)
}
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
},
}
// loadOrCreateEncKey loads the per-installation salt from the settings table. // On first run it generates a new random salt with storage.NewSalt() and persists it. // The salt is hex-encoded in the settings table under key "encryption.salt". func loadOrCreateEncKey(db *storage.DB, passphrase string) ([]byte, error) { const saltKey = "encryption.salt" saltHex, found, err := db.GetSetting(saltKey) if err != nil { return nil, fmt.Errorf("reading salt from settings: %w", err) } var salt []byte if !found { // First run: generate and persist a new random salt. salt, err = storage.NewSalt() if err != nil { return nil, fmt.Errorf("generating salt: %w", err) } if err := db.SetSetting(saltKey, hex.EncodeToString(salt)); err != nil { return nil, fmt.Errorf("storing salt: %w", err) } } else { salt, err = hex.DecodeString(saltHex) if err != nil { return nil, fmt.Errorf("decoding stored salt: %w", err) } } return storage.DeriveKey([]byte(passphrase), salt), 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 (full JSON output 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:
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)
}
Create cmd/stubs.go — stub commands for the 8 phases not yet implemented. These satisfy CLI-01 (11 commands) and print a clear "not implemented" message so users know the command exists but is pending a future phase.
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// notImplemented returns a RunE function that prints a "not yet implemented" message.
// Each stub command is registered in root.go and satisfies CLI-01's 11-command requirement.
func notImplemented(name, phase string) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
fmt.Printf("%s: not implemented in this phase (coming in %s)\n", name, phase)
return nil
}
}
var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Actively verify found API keys (Phase 5)",
RunE: notImplemented("verify", "Phase 5"),
}
var importCmd = &cobra.Command{
Use: "import",
Short: "Import findings from TruffleHog or Gitleaks output (Phase 7)",
RunE: notImplemented("import", "Phase 7"),
}
var reconCmd = &cobra.Command{
Use: "recon",
Short: "Run OSINT recon across internet sources (Phase 9+)",
RunE: notImplemented("recon", "Phase 9"),
}
var keysCmd = &cobra.Command{
Use: "keys",
Short: "Manage stored keys (list, export, delete) (Phase 6)",
RunE: notImplemented("keys", "Phase 6"),
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the web dashboard (Phase 18)",
RunE: notImplemented("serve", "Phase 18"),
}
var dorksCmd = &cobra.Command{
Use: "dorks",
Short: "Manage and run dork queries (Phase 8)",
RunE: notImplemented("dorks", "Phase 8"),
}
var hookCmd = &cobra.Command{
Use: "hook",
Short: "Install or manage git pre-commit hooks (Phase 7)",
RunE: notImplemented("hook", "Phase 7"),
}
var scheduleCmd = &cobra.Command{
Use: "schedule",
Short: "Manage scheduled recurring scans (Phase 17)",
RunE: notImplemented("schedule", "Phase 17"),
}
-
cd /home/salva/Documents/apikey && go test ./... -v -count=1Expected: All tests PASS, zero FAIL, zero SKIP (except original stubs now filled) -
./keyhunter scan testdata/samples/openai_key.txtExpected: Exit code 1, table printed with 1 row showing "openai" provider, masked key -
./keyhunter scan testdata/samples/no_keys.txtExpected: Exit code 0, "No API keys found." printed -
./keyhunter scan --output json testdata/samples/no_keys.txtExpected: Exit code 0, valid JSON printed:[] -
./keyhunter providers listExpected: Table with openai, anthropic, huggingface rows -
./keyhunter providers info openaiExpected: Name, Tier 1, Keywords including "sk-proj-", Pattern regex shown -
./keyhunter config initExpected: "Config initialized: ~/.keyhunter.yaml" and the file exists -
./keyhunter config set scan.workers 16 && ./keyhunter config get scan.workersExpected: "Set scan.workers = 16" then "16" -
./keyhunter verifyExpected: "verify: not implemented in this phase (coming in Phase 5)" -
Build the binary with production flags:
CGO_ENABLED=0 go build -ldflags="-s -w" -o keyhunter-prod .Expected: Builds without error, binary produced Type "approved" if all 10 checks pass, or describe which check failed and what output you saw.
<success_criteria>
- Cobra CLI with all 11 commands: scan, verify, import, recon, keys, serve, dorks, hook, schedule, providers, config (CLI-01)
keyhunter config initcreates ~/.keyhunter.yaml (CLI-02)keyhunter config set key valuepersists (CLI-03)keyhunter providers list/info/statswork (CLI-04)- scan flags: --workers, --verify, --unmask, --output, --exclude (CLI-05)
- Per-installation salt stored in settings table; no hardcoded salt in production code
- JSON output returns valid JSON (not a comment string)
- All Phase 1 success criteria from ROADMAP.md satisfied:
keyhunter scan ./somefileruns three-stage pipeline and returns findings with provider names- Findings persisted to SQLite with AES-256 encrypted key_value; raw db does not contain plaintext
keyhunter config initandconfig setworkkeyhunter providers list/inforeturn provider metadata from YAML- Provider YAML has format_version and last_verified, validated at load time </success_criteria>