From c9114e4142ec1255309ae9d405843b728f2a82fe Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 23:41:38 +0300 Subject: [PATCH] feat(06-06): wire scan --output to formatter registry and exit-code contract - Replace inline jsonFinding switch with output.Get() dispatch - Add renderScanOutput helper used by RunE and tests - Introduce version var + versionString() for SARIF tool metadata - Update --output help to list table, json, sarif, csv - Change root Execute to os.Exit(2) on RunE errors per OUT-06 (exit 0=clean, 1=findings, 2=tool error) --- cmd/root.go | 11 ++++++++- cmd/scan.go | 66 +++++++++++++++++++++++++++++------------------------ 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 62f0350..50d11be 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,9 +21,18 @@ Supports 108+ providers with Aho-Corasick pre-filtering and regex + entropy dete } // Execute is the entry point called by main.go. +// +// OUT-06 exit-code contract: +// - 0: clean scan (no findings) +// - 1: findings present (emitted directly by scanCmd via os.Exit(1)) +// - 2: scan/tool error (any RunE returning a non-nil error) +// +// Cobra prints the error message itself; we only translate the non-nil return +// into exit code 2 so CI consumers can distinguish "found leaks" from "scan +// failed". func Execute() { if err := rootCmd.Execute(); err != nil { - os.Exit(1) + os.Exit(2) } } diff --git a/cmd/scan.go b/cmd/scan.go index 62e3155..98f266b 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -3,11 +3,12 @@ package cmd import ( "context" "encoding/hex" - "encoding/json" "fmt" + "io" "os" "path/filepath" "runtime" + "strings" "time" "github.com/salvacybersec/keyhunter/pkg/config" @@ -172,36 +173,13 @@ var scanCmd = &cobra.Command{ } } - // Output - switch flagOutput { - case "json": - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - type jsonFinding struct { - Provider string `json:"provider"` - KeyMasked string `json:"key_masked"` - Confidence string `json:"confidence"` - Source string `json:"source"` - Line int `json:"line"` - } - out := make([]jsonFinding, 0, len(findings)) - for _, f := range findings { - out = append(out, jsonFinding{ - Provider: f.ProviderName, - KeyMasked: f.KeyMasked, - Confidence: f.Confidence, - Source: f.Source, - Line: f.LineNumber, - }) - } - if err := enc.Encode(out); err != nil { - return fmt.Errorf("encoding JSON output: %w", err) - } - default: - output.PrintFindings(findings, flagUnmask) + // Output via the formatter registry (OUT-01..04). + if err := renderScanOutput(findings, flagOutput, flagUnmask, os.Stdout); err != nil { + return err } - // Exit code semantics (CLI-05 / OUT-06): 0=clean, 1=found, 2=error + // OUT-06 exit codes: 0=clean, 1=findings, 2=error (errors returned via + // RunE -> root.Execute -> os.Exit(2)). if len(findings) > 0 { os.Exit(1) } @@ -209,6 +187,34 @@ var scanCmd = &cobra.Command{ }, } +// renderScanOutput dispatches findings through the formatter registry. It is +// the single entry point used by scan RunE (and tests) to format output. +// +// Returns an error wrapping output.ErrUnknownFormat when name does not match a +// registered formatter; the error message includes the valid format list. +func renderScanOutput(findings []engine.Finding, name string, unmask bool, w io.Writer) error { + formatter, err := output.Get(name) + if err != nil { + return fmt.Errorf("%w (valid: %s)", err, strings.Join(output.Names(), ", ")) + } + if err := formatter.Format(findings, w, output.Options{ + Unmask: unmask, + ToolName: "keyhunter", + ToolVersion: versionString(), + }); err != nil { + return fmt.Errorf("rendering %s output: %w", name, err) + } + return nil +} + +// version is the compiled tool version. Override with: +// +// go build -ldflags "-X github.com/salvacybersec/keyhunter/cmd.version=1.2.3" +var version = "dev" + +// versionString returns the compiled tool version (used by SARIF output). +func versionString() string { return version } + // sourceFlags captures the CLI inputs that control source selection. // Extracted into a struct so selectSource is straightforward to unit test. type sourceFlags struct { @@ -324,7 +330,7 @@ func init() { scanCmd.Flags().IntVar(&flagWorkers, "workers", 0, "number of worker goroutines (default: CPU*8)") scanCmd.Flags().BoolVar(&flagVerify, "verify", false, "actively verify found keys (opt-in, Phase 5)") scanCmd.Flags().BoolVar(&flagUnmask, "unmask", false, "show full key values (default: masked)") - scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json (full JSON output in Phase 6)") + scanCmd.Flags().StringVar(&flagOutput, "output", "table", "output format: table, json, sarif, csv") scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "extra glob patterns to exclude (e.g. *.min.js)") // Phase 4 source-selection flags.