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:
11
cmd/root.go
11
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.
|
// 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() {
|
func Execute() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
cmd/scan.go
66
cmd/scan.go
@@ -3,11 +3,12 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/config"
|
"github.com/salvacybersec/keyhunter/pkg/config"
|
||||||
@@ -172,36 +173,13 @@ var scanCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output
|
// Output via the formatter registry (OUT-01..04).
|
||||||
switch flagOutput {
|
if err := renderScanOutput(findings, flagOutput, flagUnmask, os.Stdout); err != nil {
|
||||||
case "json":
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
if len(findings) > 0 {
|
||||||
os.Exit(1)
|
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.
|
// sourceFlags captures the CLI inputs that control source selection.
|
||||||
// Extracted into a struct so selectSource is straightforward to unit test.
|
// Extracted into a struct so selectSource is straightforward to unit test.
|
||||||
type sourceFlags struct {
|
type sourceFlags struct {
|
||||||
@@ -324,7 +330,7 @@ func init() {
|
|||||||
scanCmd.Flags().IntVar(&flagWorkers, "workers", 0, "number of worker goroutines (default: CPU*8)")
|
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(&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().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)")
|
scanCmd.Flags().StringSliceVar(&flagExclude, "exclude", nil, "extra glob patterns to exclude (e.g. *.min.js)")
|
||||||
|
|
||||||
// Phase 4 source-selection flags.
|
// Phase 4 source-selection flags.
|
||||||
|
|||||||
Reference in New Issue
Block a user