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