feat(01-05): add CLI root command, config package, output table, and settings helpers
- cmd/root.go: Cobra root with all 11 subcommands, viper config loading - cmd/stubs.go: 8 stub commands for future phases (verify, import, recon, keys, serve, dorks, hook, schedule) - cmd/scan.go: scan command wiring engine + storage + output with per-installation salt - cmd/providers.go: providers list/info/stats subcommands - cmd/config.go: config init/set/get subcommands - pkg/config/config.go: Config struct with Load() and defaults - pkg/output/table.go: lipgloss terminal table for PrintFindings - pkg/storage/settings.go: GetSetting/SetSetting for settings table CRUD
This commit is contained in:
77
cmd/config.go
Normal file
77
cmd/config.go
Normal file
@@ -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 <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)
|
||||||
|
}
|
||||||
98
cmd/providers.go
Normal file
98
cmd/providers.go
Normal file
@@ -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 <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)
|
||||||
|
}
|
||||||
76
cmd/root.go
76
cmd/root.go
@@ -1,8 +1,76 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
// Execute is a stub. The real command tree is built in Plan 05.
|
"github.com/spf13/cobra"
|
||||||
func Execute() {
|
"github.com/spf13/viper"
|
||||||
_ = os.Args
|
)
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
186
cmd/scan.go
Normal file
186
cmd/scan.go
Normal file
@@ -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 <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 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"))
|
||||||
|
}
|
||||||
64
cmd/stubs.go
Normal file
64
cmd/stubs.go
Normal file
@@ -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"),
|
||||||
|
}
|
||||||
28
pkg/config/config.go
Normal file
28
pkg/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
68
pkg/output/table.go
Normal file
68
pkg/output/table.go
Normal file
@@ -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:]
|
||||||
|
}
|
||||||
33
pkg/storage/settings.go
Normal file
33
pkg/storage/settings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user