feat(04-05): wire all Phase 4 sources through scan command
- Add --git, --url, --clipboard, --since, --max-file-size, --insecure flags - Introduce selectSource dispatcher with sourceFlags struct - Dispatch to Dir/File/Git/Stdin/URL/Clipboard sources based on args+flags - Reject mutually exclusive source selectors with clear error - Forward --exclude patterns into DirSource - Args changed to MaximumNArgs(1) to allow --url/--clipboard without positional
This commit is contained in:
126
cmd/scan.go
126
cmd/scan.go
@@ -8,15 +8,16 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -25,15 +26,19 @@ var (
|
||||
flagUnmask bool
|
||||
flagOutput string
|
||||
flagExclude []string
|
||||
flagGit bool
|
||||
flagURL string
|
||||
flagClipboard bool
|
||||
flagSince string
|
||||
flagMaxFileSize int64
|
||||
flagInsecure bool
|
||||
)
|
||||
|
||||
var scanCmd = &cobra.Command{
|
||||
Use: "scan <path>",
|
||||
Short: "Scan a file or directory for leaked API keys",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "scan [path|stdin|-]",
|
||||
Short: "Scan files, directories, git history, stdin, URLs, or clipboard for leaked API keys",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := args[0]
|
||||
|
||||
// Load config
|
||||
cfg := config.Load()
|
||||
if viper.GetInt("scan.workers") > 0 {
|
||||
@@ -55,9 +60,18 @@ var scanCmd = &cobra.Command{
|
||||
return fmt.Errorf("loading providers: %w", err)
|
||||
}
|
||||
|
||||
// Initialize engine
|
||||
// Initialize engine and select source based on flags/args.
|
||||
eng := engine.NewEngine(reg)
|
||||
src := sources.NewFileSource(target)
|
||||
src, err := selectSource(args, sourceFlags{
|
||||
Git: flagGit,
|
||||
URL: flagURL,
|
||||
Clipboard: flagClipboard,
|
||||
Since: flagSince,
|
||||
Excludes: flagExclude,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanCfg := engine.ScanConfig{
|
||||
Workers: workers,
|
||||
@@ -148,6 +162,89 @@ var scanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// sourceFlags captures the CLI inputs that control source selection.
|
||||
// Extracted into a struct so selectSource is straightforward to unit test.
|
||||
type sourceFlags struct {
|
||||
Git bool
|
||||
URL string
|
||||
Clipboard bool
|
||||
Since string
|
||||
Excludes []string
|
||||
}
|
||||
|
||||
// selectSource inspects positional args and source flags, validates that
|
||||
// exactly one source is specified, and returns the appropriate Source.
|
||||
//
|
||||
// Dispatch rules:
|
||||
// - --url / --clipboard: no positional arg, mutually exclusive with --git and each other
|
||||
// - --git <path>: uses GitSource (optionally filtered by --since=YYYY-MM-DD)
|
||||
// - target == "stdin" or "-": uses StdinSource
|
||||
// - target is a directory: uses DirSource (forwards --exclude patterns)
|
||||
// - target is a file: uses FileSource
|
||||
func selectSource(args []string, f sourceFlags) (sources.Source, error) {
|
||||
// Count explicit source selectors that are mutually exclusive.
|
||||
explicitCount := 0
|
||||
if f.URL != "" {
|
||||
explicitCount++
|
||||
}
|
||||
if f.Clipboard {
|
||||
explicitCount++
|
||||
}
|
||||
if f.Git {
|
||||
explicitCount++
|
||||
}
|
||||
if explicitCount > 1 {
|
||||
return nil, fmt.Errorf("scan: --git, --url, and --clipboard are mutually exclusive")
|
||||
}
|
||||
|
||||
// Clipboard and URL take no positional argument.
|
||||
if f.Clipboard {
|
||||
if len(args) > 0 {
|
||||
return nil, fmt.Errorf("scan: --clipboard does not accept a positional argument")
|
||||
}
|
||||
return sources.NewClipboardSource(), nil
|
||||
}
|
||||
if f.URL != "" {
|
||||
if len(args) > 0 {
|
||||
return nil, fmt.Errorf("scan: --url does not accept a positional argument")
|
||||
}
|
||||
return sources.NewURLSource(f.URL), nil
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("scan: missing target (path, stdin, -, or a source flag)")
|
||||
}
|
||||
target := args[0]
|
||||
|
||||
if target == "stdin" || target == "-" {
|
||||
if f.Git {
|
||||
return nil, fmt.Errorf("scan: --git cannot be combined with stdin")
|
||||
}
|
||||
return sources.NewStdinSource(), nil
|
||||
}
|
||||
|
||||
if f.Git {
|
||||
gs := sources.NewGitSource(target)
|
||||
if f.Since != "" {
|
||||
t, err := time.Parse("2006-01-02", f.Since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: --since must be YYYY-MM-DD: %w", err)
|
||||
}
|
||||
gs.Since = t
|
||||
}
|
||||
return gs, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: stat %q: %w", target, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return sources.NewDirSource(target, f.Excludes...), nil
|
||||
}
|
||||
return sources.NewFileSource(target), 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".
|
||||
@@ -181,6 +278,15 @@ func init() {
|
||||
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)")
|
||||
scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "extra glob patterns to exclude (e.g. *.min.js)")
|
||||
|
||||
// Phase 4 source-selection flags.
|
||||
scanCmd.Flags().BoolVar(&flagGit, "git", false, "treat target as a git repo and scan full history")
|
||||
scanCmd.Flags().StringVar(&flagURL, "url", "", "fetch and scan a remote http(s) URL (no positional arg)")
|
||||
scanCmd.Flags().BoolVar(&flagClipboard, "clipboard", false, "scan current clipboard contents")
|
||||
scanCmd.Flags().StringVar(&flagSince, "since", "", "for --git: only scan commits after YYYY-MM-DD")
|
||||
scanCmd.Flags().Int64Var(&flagMaxFileSize, "max-file-size", 0, "max file size in bytes to scan (0 = unlimited)")
|
||||
scanCmd.Flags().BoolVar(&flagInsecure, "insecure", false, "for --url: skip TLS certificate verification")
|
||||
|
||||
_ = viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user