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"))
|
||||||
|
}
|
||||||
@@ -33,11 +33,7 @@ var reconCmd = &cobra.Command{
|
|||||||
RunE: notImplemented("recon", "Phase 9"),
|
RunE: notImplemented("recon", "Phase 9"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var keysCmd = &cobra.Command{
|
// keysCmd is implemented in cmd/keys.go (Phase 6).
|
||||||
Use: "keys",
|
|
||||||
Short: "Manage stored keys (list, export, delete) (Phase 6)",
|
|
||||||
RunE: notImplemented("keys", "Phase 6"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var serveCmd = &cobra.Command{
|
var serveCmd = &cobra.Command{
|
||||||
Use: "serve",
|
Use: "serve",
|
||||||
|
|||||||
Reference in New Issue
Block a user