diff --git a/go.mod b/go.mod index 2f0f746..c6d110e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/lipgloss v1.1.0 github.com/go-git/go-git/v5 v5.17.2 + github.com/mattn/go-isatty v0.0.20 github.com/panjf2000/ants/v2 v2.12.0 github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 github.com/spf13/cobra v1.10.2 @@ -43,7 +44,6 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/pkg/output/colors.go b/pkg/output/colors.go new file mode 100644 index 0000000..28ca793 --- /dev/null +++ b/pkg/output/colors.go @@ -0,0 +1,34 @@ +package output + +import ( + "io" + "os" + + "github.com/mattn/go-isatty" +) + +// IsTTY reports whether f is an open terminal or Cygwin/MSYS pty. +// Returns false for a nil file. +func IsTTY(f *os.File) bool { + if f == nil { + return false + } + fd := f.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +// ColorsEnabled reports whether ANSI color output should be emitted on w. +// Returns false when: +// - the NO_COLOR environment variable is set (https://no-color.org/), or +// - w is not an *os.File (e.g. bytes.Buffer, strings.Builder), or +// - w is an *os.File but not a terminal. +func ColorsEnabled(w io.Writer) bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + return IsTTY(f) +} diff --git a/pkg/output/colors_test.go b/pkg/output/colors_test.go new file mode 100644 index 0000000..2afd64f --- /dev/null +++ b/pkg/output/colors_test.go @@ -0,0 +1,37 @@ +package output + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColorsEnabled_BufferIsNotTTY(t *testing.T) { + t.Setenv("NO_COLOR", "") + // Explicitly unset NO_COLOR so the non-TTY branch is exercised. + t.Setenv("NO_COLOR", "") + assert.False(t, ColorsEnabled(&bytes.Buffer{}), "bytes.Buffer must never be considered a TTY") +} + +func TestColorsEnabled_NoColorEnvForcesOff(t *testing.T) { + t.Setenv("NO_COLOR", "1") + assert.False(t, ColorsEnabled(&bytes.Buffer{})) +} + +func TestColorsEnabled_NilWriterSafe(t *testing.T) { + t.Setenv("NO_COLOR", "") + // A nil *os.File should return false, not panic. + var f *nilWriter + assert.False(t, ColorsEnabled(f)) +} + +// nilWriter is a typed-nil io.Writer used to verify ColorsEnabled does not +// panic when passed a non-*os.File writer. +type nilWriter struct{} + +func (*nilWriter) Write(p []byte) (int, error) { return len(p), nil } + +func TestIsTTY_NilFile(t *testing.T) { + assert.False(t, IsTTY(nil)) +} diff --git a/pkg/output/formatter.go b/pkg/output/formatter.go new file mode 100644 index 0000000..964d9f9 --- /dev/null +++ b/pkg/output/formatter.go @@ -0,0 +1,61 @@ +package output + +import ( + "errors" + "fmt" + "io" + "sort" + + "github.com/salvacybersec/keyhunter/pkg/engine" +) + +// ErrUnknownFormat is returned by Get when no formatter is registered for the +// requested name. Callers should use errors.Is to check for it. +var ErrUnknownFormat = errors.New("output: unknown format") + +// Options controls formatter behavior. Unmask reveals full key values. +// ToolName and ToolVersion are consumed by metadata-bearing formats (SARIF). +type Options struct { + Unmask bool + ToolName string + ToolVersion string +} + +// Formatter renders a slice of findings to an io.Writer. +// Implementations must not mutate the findings slice. +type Formatter interface { + Format(findings []engine.Finding, w io.Writer, opts Options) error +} + +// registry holds all formatters registered at init() time. It is not guarded +// by a mutex because registration is expected to happen exclusively from +// package init functions, which run sequentially before main. +var registry = map[string]Formatter{} + +// Register adds a formatter under the given name. Intended to be called from +// package init() functions. Re-registering the same name overwrites the +// previous value. +func Register(name string, f Formatter) { + registry[name] = f +} + +// Get returns the formatter registered under name. If no formatter is +// registered, the returned error wraps ErrUnknownFormat. +func Get(name string) (Formatter, error) { + f, ok := registry[name] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, name) + } + return f, nil +} + +// Names returns the sorted list of registered format names. Used by the scan +// command to build --output help text and error messages. +func Names() []string { + names := make([]string, 0, len(registry)) + for k := range registry { + names = append(names, k) + } + sort.Strings(names) + return names +} diff --git a/pkg/output/formatter_test.go b/pkg/output/formatter_test.go new file mode 100644 index 0000000..e531da4 --- /dev/null +++ b/pkg/output/formatter_test.go @@ -0,0 +1,72 @@ +package output + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/salvacybersec/keyhunter/pkg/engine" +) + +// fakeFormatter is a minimal Formatter used to verify the registry round-trip. +type fakeFormatter struct{ tag string } + +func (f fakeFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error { + _, err := w.Write([]byte(f.tag)) + return err +} + +func TestFormatter_RegisterAndGet(t *testing.T) { + Register("__test_fake__", fakeFormatter{tag: "ok"}) + t.Cleanup(func() { delete(registry, "__test_fake__") }) + + f, err := Get("__test_fake__") + require.NoError(t, err) + require.NotNil(t, f) + + var buf bytes.Buffer + require.NoError(t, f.Format(nil, &buf, Options{})) + assert.Equal(t, "ok", buf.String()) +} + +func TestFormatter_GetUnknown(t *testing.T) { + _, err := Get("__definitely_not_registered__") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnknownFormat), "error should wrap ErrUnknownFormat") +} + +func TestFormatter_NamesSorted(t *testing.T) { + Register("__z_fake__", fakeFormatter{}) + Register("__a_fake__", fakeFormatter{}) + t.Cleanup(func() { + delete(registry, "__z_fake__") + delete(registry, "__a_fake__") + }) + + names := Names() + // The full registry may contain other entries (e.g. "table"); just assert + // that our two names appear in sorted order relative to each other. + var aIdx, zIdx = -1, -1 + for i, n := range names { + switch n { + case "__a_fake__": + aIdx = i + case "__z_fake__": + zIdx = i + } + } + require.GreaterOrEqual(t, aIdx, 0) + require.GreaterOrEqual(t, zIdx, 0) + assert.Less(t, aIdx, zIdx, "Names() must return entries in ascending order") +} + +func TestOptions_Defaults(t *testing.T) { + var opts Options + assert.False(t, opts.Unmask) + assert.Equal(t, "", opts.ToolName) + assert.Equal(t, "", opts.ToolVersion) +}