diff --git a/pkg/output/csv.go b/pkg/output/csv.go new file mode 100644 index 0000000..650b94a --- /dev/null +++ b/pkg/output/csv.go @@ -0,0 +1,60 @@ +package output + +import ( + "encoding/csv" + "io" + "strconv" + "time" + + "github.com/salvacybersec/keyhunter/pkg/engine" +) + +func init() { + Register("csv", CSVFormatter{}) +} + +// CSVFormatter renders findings as comma-separated values with a fixed +// header row. The id column is the zero-based index of the finding within +// the slice -- the storage-layer rowid is intentionally not used here so +// the formatter can run against in-memory results from a scan that has +// not been persisted yet. Standard encoding/csv quoting rules apply, so +// fields containing commas, quotes, or newlines are escaped automatically. +type CSVFormatter struct{} + +// csvHeader is the exact, ordered header row written before any findings. +// Column order must remain stable: downstream consumers may parse by index. +// Columns: id, provider, confidence, key, source, line, detected_at, verified, verify_status +var csvHeader = []string{ + "id", "provider", "confidence", "key", "source", + "line", "detected_at", "verified", "verify_status", +} + +// Format implements the Formatter interface. +func (CSVFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error { + cw := csv.NewWriter(w) + if err := cw.Write(csvHeader); err != nil { + return err + } + for i, f := range findings { + key := f.KeyMasked + if opts.Unmask { + key = f.KeyValue + } + row := []string{ + strconv.Itoa(i), + f.ProviderName, + f.Confidence, + key, + f.Source, + strconv.Itoa(f.LineNumber), + f.DetectedAt.Format(time.RFC3339), + strconv.FormatBool(f.Verified), + f.VerifyStatus, + } + if err := cw.Write(row); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +}