Files
keyhunter/cmd/keys.go
salvacybersec 06594afc57 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 <id> 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 <id> uses atotto/clipboard for clipboard handoff
- keys delete <id> prompts via cmd.InOrStdin unless --yes is passed
- keys verify <id> 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
2026-04-05 23:37:25 +03:00

457 lines
13 KiB
Go

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