diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..0746982 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,77 @@ +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 ", + 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 ", + 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) +} diff --git a/cmd/providers.go b/cmd/providers.go new file mode 100644 index 0000000..538a5c3 --- /dev/null +++ b/cmd/providers.go @@ -0,0 +1,98 @@ +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 ", + 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) +} diff --git a/cmd/root.go b/cmd/root.go index 0a22153..81d62ad 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,8 +1,76 @@ package cmd -import "os" +import ( + "fmt" + "os" + "path/filepath" -// Execute is a stub. The real command tree is built in Plan 05. -func Execute() { - _ = os.Args + "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 } diff --git a/cmd/scan.go b/cmd/scan.go new file mode 100644 index 0000000..c9fc9a2 --- /dev/null +++ b/cmd/scan.go @@ -0,0 +1,186 @@ +package cmd + +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": + 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")) +} diff --git a/cmd/stubs.go b/cmd/stubs.go new file mode 100644 index 0000000..5b93ff7 --- /dev/null +++ b/cmd/stubs.go @@ -0,0 +1,64 @@ +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"), +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..d4680e7 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,28 @@ +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 + } +} diff --git a/pkg/output/table.go b/pkg/output/table.go new file mode 100644 index 0000000..219bedb --- /dev/null +++ b/pkg/output/table.go @@ -0,0 +1,68 @@ +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:] +} diff --git a/pkg/storage/settings.go b/pkg/storage/settings.go new file mode 100644 index 0000000..6184975 --- /dev/null +++ b/pkg/storage/settings.go @@ -0,0 +1,33 @@ +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 +}