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:
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user