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:
salvacybersec
2026-04-05 12:26:36 +03:00
parent d0396bb384
commit 9da0b68129
8 changed files with 626 additions and 4 deletions

28
pkg/config/config.go Normal file
View 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
View 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
View 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
}