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:
136
cmd/scan.go
136
cmd/scan.go
@@ -8,32 +8,37 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/config"
|
"github.com/salvacybersec/keyhunter/pkg/config"
|
||||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||||
"github.com/salvacybersec/keyhunter/pkg/engine/sources"
|
"github.com/salvacybersec/keyhunter/pkg/engine/sources"
|
||||||
"github.com/salvacybersec/keyhunter/pkg/output"
|
"github.com/salvacybersec/keyhunter/pkg/output"
|
||||||
"github.com/salvacybersec/keyhunter/pkg/providers"
|
"github.com/salvacybersec/keyhunter/pkg/providers"
|
||||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
"github.com/salvacybersec/keyhunter/pkg/storage"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
flagWorkers int
|
flagWorkers int
|
||||||
flagVerify bool
|
flagVerify bool
|
||||||
flagUnmask bool
|
flagUnmask bool
|
||||||
flagOutput string
|
flagOutput string
|
||||||
flagExclude []string
|
flagExclude []string
|
||||||
|
flagGit bool
|
||||||
|
flagURL string
|
||||||
|
flagClipboard bool
|
||||||
|
flagSince string
|
||||||
|
flagMaxFileSize int64
|
||||||
|
flagInsecure bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var scanCmd = &cobra.Command{
|
var scanCmd = &cobra.Command{
|
||||||
Use: "scan <path>",
|
Use: "scan [path|stdin|-]",
|
||||||
Short: "Scan a file or directory for leaked API keys",
|
Short: "Scan files, directories, git history, stdin, URLs, or clipboard for leaked API keys",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
target := args[0]
|
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
if viper.GetInt("scan.workers") > 0 {
|
if viper.GetInt("scan.workers") > 0 {
|
||||||
@@ -55,9 +60,18 @@ var scanCmd = &cobra.Command{
|
|||||||
return fmt.Errorf("loading providers: %w", err)
|
return fmt.Errorf("loading providers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize engine
|
// Initialize engine and select source based on flags/args.
|
||||||
eng := engine.NewEngine(reg)
|
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{
|
scanCfg := engine.ScanConfig{
|
||||||
Workers: workers,
|
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.
|
// 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.
|
// 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".
|
// 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(&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().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().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"))
|
_ = viper.BindPFlag("scan.workers", scanCmd.Flags().Lookup("workers"))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user