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
This commit is contained in:
456
cmd/keys.go
Normal file
456
cmd/keys.go
Normal file
@@ -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 <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"))
|
||||
}
|
||||
Reference in New Issue
Block a user