From 06594afc57ad2febbaca8a42dd29453912f9ff8c Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 23:37:25 +0300 Subject: [PATCH] feat(06-05): implement keys command tree (list/show/export/copy/delete/verify) - Add cmd/keys.go with six subcommands backed by the Plan 04 query layer - keys list prints masked findings with id/provider/confidence/source columns and supports --provider/--verified/--limit/--unmask filters - keys show renders a finding fully unmasked with verify metadata - keys export --format=json|csv reuses the formatter registry, atomic file writes when --output is set - keys copy uses atotto/clipboard for clipboard handoff - keys delete prompts via cmd.InOrStdin unless --yes is passed - keys verify gates on verify.EnsureConsent, then updates the stored row inline via UPDATE findings SET verify_* using db.SQL() - Remove the keysCmd stub from cmd/stubs.go (single declaration) - All subcommands read config via openDBWithKey() mirroring scan.go --- cmd/keys.go | 456 +++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/stubs.go | 6 +- 2 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 cmd/keys.go diff --git a/cmd/keys.go b/cmd/keys.go new file mode 100644 index 0000000..e41d421 --- /dev/null +++ b/cmd/keys.go @@ -0,0 +1,456 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/salvacybersec/keyhunter/pkg/config" + "github.com/salvacybersec/keyhunter/pkg/engine" + "github.com/salvacybersec/keyhunter/pkg/output" + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/salvacybersec/keyhunter/pkg/storage" + "github.com/salvacybersec/keyhunter/pkg/verify" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// keys subcommand flags. Declared as package-level vars so tests can reset +// them between runs. +var ( + flagKeysUnmask bool + flagKeysProvider string + flagKeysVerified bool + flagKeysLimit int + flagKeysFormat string + flagKeysOutFile string + flagKeysYes bool +) + +// keysCmd is the root of the "keyhunter keys" command tree (Phase 6, KEYS-01..06). +var keysCmd = &cobra.Command{ + Use: "keys", + Short: "Manage stored API key findings (list, show, export, copy, delete, verify)", + Long: `The keys command tree operates on findings already persisted in the local +KeyHunter database. All subcommands derive the encryption key from the stored +per-installation salt, so no passphrase prompt is required unless one has been +explicitly configured.`, +} + +var keysListCmd = &cobra.Command{ + Use: "list", + Short: "List stored findings (masked by default)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + f := storage.Filters{ + Provider: flagKeysProvider, + Limit: flagKeysLimit, + } + if cmd.Flag("verified").Changed { + v := flagKeysVerified + f.Verified = &v + } + + findings, err := db.ListFindingsFiltered(encKey, f) + if err != nil { + return fmt.Errorf("listing findings: %w", err) + } + + w := cmd.OutOrStdout() + for _, sf := range findings { + key := sf.KeyMasked + if flagKeysUnmask { + key = sf.KeyValue + } + verified := "unverified" + if sf.Verified { + if sf.VerifyStatus != "" { + verified = sf.VerifyStatus + } else { + verified = "verified" + } + } + fmt.Fprintf(w, "[%d] %-16s %-6s %s %s:%d %s\n", + sf.ID, sf.ProviderName, sf.Confidence, key, + sf.SourcePath, sf.LineNumber, verified) + } + fmt.Fprintf(w, "%d key(s).\n", len(findings)) + return nil + }, +} + +var keysShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show full details for a single finding (unmasked)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + f, err := db.GetFinding(id, encKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("no finding with id %d", id) + } + return fmt.Errorf("getting finding: %w", err) + } + renderFinding(cmd.OutOrStdout(), f) + return nil + }, +} + +var keysExportCmd = &cobra.Command{ + Use: "export", + Short: "Export all findings as JSON or CSV (unmasked) to stdout or --output file", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + format := strings.ToLower(strings.TrimSpace(flagKeysFormat)) + if format == "" { + format = "json" + } + if format != "json" && format != "csv" { + return fmt.Errorf("keys export: unsupported format %q (supported: json, csv; SARIF is scan-only)", format) + } + + formatter, err := output.Get(format) + if err != nil { + return fmt.Errorf("keys export: %w", err) + } + + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + stored, err := db.ListFindingsFiltered(encKey, storage.Filters{}) + if err != nil { + return fmt.Errorf("listing findings: %w", err) + } + findings := make([]engine.Finding, 0, len(stored)) + for _, sf := range stored { + findings = append(findings, storageToEngine(sf)) + } + + opts := output.Options{ + Unmask: true, // exports are full-fidelity (KEYS-03) + ToolName: "keyhunter", + ToolVersion: "0.1.0", + } + + var target io.Writer = cmd.OutOrStdout() + if flagKeysOutFile != "" { + tmp := flagKeysOutFile + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("creating export file: %w", err) + } + if err := formatter.Format(findings, f, opts); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("formatting export: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("closing export file: %w", err) + } + if err := os.Rename(tmp, flagKeysOutFile); err != nil { + os.Remove(tmp) + return fmt.Errorf("renaming export file: %w", err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Exported %d finding(s) to %s\n", len(findings), flagKeysOutFile) + return nil + } + return formatter.Format(findings, target, opts) + }, +} + +var keysCopyCmd = &cobra.Command{ + Use: "copy ", + Short: "Copy a stored key's plaintext value to the system clipboard", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + f, err := db.GetFinding(id, encKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("no finding with id %d", id) + } + return fmt.Errorf("getting finding: %w", err) + } + if err := clipboard.WriteAll(f.KeyValue); err != nil { + return fmt.Errorf("writing to clipboard: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Copied key for finding #%d (%s, %s) to clipboard.\n", + f.ID, f.ProviderName, f.KeyMasked) + return nil + }, +} + +var keysDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a finding from the database (prompts unless --yes)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + f, err := db.GetFinding(id, encKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + fmt.Fprintf(cmd.OutOrStdout(), "No finding with id %d.\n", id) + return nil + } + return fmt.Errorf("getting finding: %w", err) + } + + if !flagKeysYes { + fmt.Fprintf(cmd.OutOrStdout(), "Delete finding #%d (%s, %s)? [y/N]: ", + f.ID, f.ProviderName, f.KeyMasked) + reader := bufio.NewReader(cmd.InOrStdin()) + line, _ := reader.ReadString('\n') + ans := strings.ToLower(strings.TrimSpace(line)) + if ans != "y" && ans != "yes" { + fmt.Fprintln(cmd.OutOrStdout(), "Cancelled.") + return nil + } + } + + n, err := db.DeleteFinding(id) + if err != nil { + return fmt.Errorf("deleting finding: %w", err) + } + if n == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No finding with id %d.\n", id) + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted finding #%d.\n", id) + return nil + }, +} + +var keysVerifyCmd = &cobra.Command{ + Use: "verify ", + Short: "Re-verify a stored finding against its provider endpoint", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + db, encKey, err := openDBWithKey() + if err != nil { + return err + } + defer db.Close() + + f, err := db.GetFinding(id, encKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("no finding with id %d", id) + } + return fmt.Errorf("getting finding: %w", err) + } + + granted, consentErr := verify.EnsureConsent(db, cmd.InOrStdin(), cmd.ErrOrStderr()) + if consentErr != nil { + return fmt.Errorf("consent check: %w", consentErr) + } + if !granted { + fmt.Fprintln(cmd.ErrOrStderr(), "Verification skipped (consent not granted). Run `keyhunter legal` for details.") + return nil + } + + reg, err := providers.NewRegistry() + if err != nil { + return fmt.Errorf("loading providers: %w", err) + } + + ef := storageToEngine(*f) + verifier := verify.NewHTTPVerifier(10 * time.Second) + results := verifier.VerifyAll(context.Background(), []engine.Finding{ef}, reg, 1) + var r = <-results + // Drain remainder (should already be closed). + for range results { + } + + // Persist updated verification state inline. + var metaJSON interface{} + if r.Metadata != nil { + b, _ := json.Marshal(r.Metadata) + metaJSON = string(b) + } else { + metaJSON = sql.NullString{} + } + if _, err := db.SQL().Exec( + `UPDATE findings SET verified=1, verify_status=?, verify_http_code=?, verify_metadata_json=? WHERE id=?`, + r.Status, r.HTTPCode, metaJSON, id, + ); err != nil { + return fmt.Errorf("updating verify state: %w", err) + } + + // Reload and render. + updated, err := db.GetFinding(id, encKey) + if err != nil { + return fmt.Errorf("reloading finding: %w", err) + } + renderFinding(cmd.OutOrStdout(), updated) + return nil + }, +} + +// parseID parses a positional id argument into an int64. +func parseID(s string) (int64, error) { + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid id %q: must be a positive integer", s) + } + if id <= 0 { + return 0, fmt.Errorf("invalid id %d: must be a positive integer", id) + } + return id, nil +} + +// storageToEngine converts a persisted storage.Finding into the +// engine.Finding shape used by verifiers and formatters. +func storageToEngine(f storage.Finding) engine.Finding { + return engine.Finding{ + ProviderName: f.ProviderName, + KeyValue: f.KeyValue, + KeyMasked: f.KeyMasked, + Confidence: f.Confidence, + Source: f.SourcePath, + SourceType: f.SourceType, + LineNumber: f.LineNumber, + DetectedAt: f.CreatedAt, + Verified: f.Verified, + VerifyStatus: f.VerifyStatus, + VerifyHTTPCode: f.VerifyHTTPCode, + VerifyMetadata: f.VerifyMetadata, + } +} + +// renderFinding writes a full, unmasked, human-readable representation of a +// finding. Used by `keys show` and `keys verify`. +func renderFinding(w io.Writer, f *storage.Finding) { + fmt.Fprintf(w, "ID: %d\n", f.ID) + fmt.Fprintf(w, "Provider: %s\n", f.ProviderName) + fmt.Fprintf(w, "Confidence: %s\n", f.Confidence) + fmt.Fprintf(w, "Key: %s\n", f.KeyValue) + fmt.Fprintf(w, "KeyMasked: %s\n", f.KeyMasked) + fmt.Fprintf(w, "Source: %s\n", f.SourcePath) + fmt.Fprintf(w, "SourceType: %s\n", f.SourceType) + fmt.Fprintf(w, "Line: %d\n", f.LineNumber) + if !f.CreatedAt.IsZero() { + fmt.Fprintf(w, "CreatedAt: %s\n", f.CreatedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "Verified: %t\n", f.Verified) + if f.VerifyStatus != "" { + fmt.Fprintf(w, "VerifyStatus: %s\n", f.VerifyStatus) + } + if f.VerifyHTTPCode != 0 { + fmt.Fprintf(w, "VerifyHTTP: %d\n", f.VerifyHTTPCode) + } + if len(f.VerifyMetadata) > 0 { + fmt.Fprintln(w, "VerifyMetadata:") + keys := make([]string, 0, len(f.VerifyMetadata)) + for k := range f.VerifyMetadata { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, " %s: %s\n", k, f.VerifyMetadata[k]) + } + } +} + +// openDBWithKey opens the KeyHunter SQLite database using the same precedence +// rules as the scan command (viper database.path, then config.Load default) +// and derives the per-installation encryption key. +func openDBWithKey() (*storage.DB, []byte, error) { + cfg := config.Load() + dbPath := viper.GetString("database.path") + if dbPath == "" { + dbPath = cfg.DBPath + } + if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil { + return nil, nil, fmt.Errorf("creating database directory: %w", err) + } + db, err := storage.Open(dbPath) + if err != nil { + return nil, nil, fmt.Errorf("opening database: %w", err) + } + passphrase := viper.GetString("encryption.passphrase") + if passphrase == "" { + passphrase = os.Getenv("KEYHUNTER_PASSPHRASE") + } + if passphrase == "" { + passphrase = cfg.Passphrase + } + encKey, err := loadOrCreateEncKey(db, passphrase) + if err != nil { + db.Close() + return nil, nil, fmt.Errorf("preparing encryption key: %w", err) + } + return db, encKey, nil +} + +func init() { + // list flags + keysListCmd.Flags().BoolVar(&flagKeysUnmask, "unmask", false, "show full key values") + keysListCmd.Flags().StringVar(&flagKeysProvider, "provider", "", "filter by provider name") + keysListCmd.Flags().BoolVar(&flagKeysVerified, "verified", false, "filter: verified only (use --verified=false for unverified only)") + keysListCmd.Flags().IntVar(&flagKeysLimit, "limit", 0, "max rows (0 = unlimited)") + + // export flags + keysExportCmd.Flags().StringVar(&flagKeysFormat, "format", "json", "export format: json, csv") + keysExportCmd.Flags().StringVar(&flagKeysOutFile, "output", "", "write to file instead of stdout") + + // delete flags + keysDeleteCmd.Flags().BoolVar(&flagKeysYes, "yes", false, "skip confirmation prompt") + + // subcommand wiring + keysCmd.AddCommand(keysListCmd, keysShowCmd, keysExportCmd, keysCopyCmd, keysDeleteCmd, keysVerifyCmd) + + _ = viper.BindPFlag("keys.unmask", keysListCmd.Flags().Lookup("unmask")) +} diff --git a/cmd/stubs.go b/cmd/stubs.go index 5b93ff7..f1222af 100644 --- a/cmd/stubs.go +++ b/cmd/stubs.go @@ -33,11 +33,7 @@ var reconCmd = &cobra.Command{ RunE: notImplemented("recon", "Phase 9"), } -var keysCmd = &cobra.Command{ - Use: "keys", - Short: "Manage stored keys (list, export, delete) (Phase 6)", - RunE: notImplemented("keys", "Phase 6"), -} +// keysCmd is implemented in cmd/keys.go (Phase 6). var serveCmd = &cobra.Command{ Use: "serve",