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)
This commit is contained in:
salvacybersec
2026-04-05 23:41:38 +03:00
parent 3b89bde38d
commit c9114e4142
2 changed files with 46 additions and 31 deletions

View File

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

View File

@@ -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.