From b151e88a29af9c6b4febf1f5360872a42d9bf37e Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 15:23:12 +0300 Subject: [PATCH] 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 --- cmd/scan.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 121 insertions(+), 15 deletions(-) diff --git a/cmd/scan.go b/cmd/scan.go index c9fc9a2..ede62d0 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,32 +8,37 @@ 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 ( - flagWorkers int - flagVerify bool - flagUnmask bool - flagOutput string - flagExclude []string + flagWorkers int + flagVerify bool + flagUnmask bool + flagOutput string + flagExclude []string + flagGit bool + flagURL string + flagClipboard bool + flagSince string + flagMaxFileSize int64 + flagInsecure bool ) var scanCmd = &cobra.Command{ - Use: "scan ", - 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 : 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")) }