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")) }