diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bde936..2f4751d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.1] - 2026-04-19 + +### Added + +#### General +- `init` command for generating configuration file + ## [0.1.0] - 2026-04-19 ### Added diff --git a/README.md b/README.md index 66dae0f..37d54e3 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ dockeraudit --version ## Quick Start ```bash +# Create configuration file ($HOME/.config/dockeraudit/dockeraudit.yaml) +dockeraudit init + # Scan a Docker image dockeraudit image nginx:latest @@ -613,7 +616,7 @@ dockeraudit terraform aws/ --fail-on medium | Flag | Default | Description | |------|---------|-------------| | `--verbose` | `false` | Print scan progress to stderr | -| `--config` | `.dockeraudit.yaml` | Path to config file | +| `--config` | `~/.config/dockeraudit/dockeraudit.yaml` | Path to config file | | `--version` | | Print version | @@ -623,15 +626,16 @@ dockeraudit terraform aws/ --fail-on medium dockeraudit supports a YAML configuration file for setting default options. CLI flags always override config file values. -**Config file discovery order:** +**Config File Discovery Order:** 1. Path specified by `--config` flag -2. `.dockeraudit.yaml` in the current working directory -3. `.dockeraudit.yml` in the current working directory +2. `$XDG_CONFIG_HOME/dockeraudit/dockeraudit.yaml` (falls back to `~/.config/dockeraudit/dockeraudit.yaml`) + +Run `dockeraudit init` to generate the global config at the XDG path with default settings. ```yaml -# .dockeraudit.yaml -format: table +# ~/.config/dockeraudit/dockeraudit.yaml +format: markdown fail-on: high verbose: false exclude-check: @@ -645,7 +649,7 @@ eol-file: custom-eol.json | Option | Type | Default | Description | |--------|------|---------|-------------| -| `format` | string | `table` | Output format: `table`, `json`, `markdown`, `sarif`, `junit` | +| `format` | string | `markdown` | Saved file format: `table`, `json`, `markdown`, `sarif`, `junit` (terminal always renders as table) | | `fail-on` | string | `high` | Exit non-zero threshold: `critical`, `high`, `medium`, `low`, `any` | | `verbose` | bool | `false` | Print scan progress to stderr | | `exclude-check` | list | (empty) | Control IDs to exclude from results | @@ -656,14 +660,14 @@ eol-file: custom-eol.json ```yaml # CI/CD (strict) # Development (relaxed) # Compliance audit -format: sarif format: table format: json +format: sarif format: markdown format: json fail-on: critical fail-on: any fail-on: low verbose: true exclude-check: verbose: true - IMAGE-001 - IMAGE-008 ``` -See [.dockeraudit.example.yaml](.dockeraudit.example.yaml) for the full reference. +Run `dockeraudit init` to write the annotated reference config — which documents every option — to `~/.config/dockeraudit/dockeraudit.yaml`. The same file is also viewable in the source at [internal/cmd/dockeraudit.example.yaml](internal/cmd/dockeraudit.example.yaml). ## CI/CD Integration diff --git a/cmd/dockeraudit/main.go b/cmd/dockeraudit/main.go index 0286ad5..a7ed376 100644 --- a/cmd/dockeraudit/main.go +++ b/cmd/dockeraudit/main.go @@ -23,8 +23,7 @@ func init() { func main() { // Persistent flags available on all subcommands. rootCmd.PersistentFlags().Bool("verbose", false, "Print scan progress to stderr") - rootCmd.PersistentFlags().String("config", "", "Path to config file (default: .dockeraudit.yaml)") - + rootCmd.PersistentFlags().String("config", "", "Path to config file (default: ~/.config/dockeraudit/dockeraudit.yaml)") // Set cmd.Verbose, cmd.Version, and load config before any subcommand runs. rootCmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error { v, _ := c.Root().PersistentFlags().GetBool("verbose") @@ -47,6 +46,7 @@ func main() { return nil } + rootCmd.AddCommand(cmd.NewInitCmd()) rootCmd.AddCommand(cmd.NewScanCmd()) rootCmd.AddCommand(cmd.NewImageCmd()) rootCmd.AddCommand(cmd.NewDockerCmd()) diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 9717d9e..9f02de1 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -25,6 +25,11 @@ var Version string // defaultFailOn is the default --fail-on threshold used consistently across all subcommands. const defaultFailOn = "high" +// defaultFormat is the default --format used consistently across all subcommands. +// It controls the format of the saved report file; the terminal always renders +// a human-readable table regardless of this value. +const defaultFormat = "markdown" + // ExitCodeError is returned from RunE handlers to signal a non-zero exit code // without bypassing deferred cleanup. main.go should check for this type and // call os.Exit with the code. @@ -212,8 +217,8 @@ Examples: "Kubernetes manifest file(s) or directories to scan") cmd.Flags().StringSliceVarP(&tfPaths, "tf", "t", nil, "Terraform file(s) or directories to scan") - cmd.Flags().StringVarP(&format, "format", "f", "table", - "Output format: table, json, markdown, sarif, junit") + cmd.Flags().StringVarP(&format, "format", "f", defaultFormat, + "Saved report format: table, json, markdown, sarif, junit (terminal always renders as table)") cmd.Flags().StringVarP(&output, "output", "o", "", "Write results to file (default: stdout)") cmd.Flags().StringVar(&failOn, "fail-on", defaultFailOn, @@ -234,7 +239,7 @@ Examples: return cmd } -// ── image ───────────────────────────────────────────────────────────────────── +// ── image ───────────────────────────────────────────────────────────────────── // func NewImageCmd() *cobra.Command { var ( @@ -349,7 +354,7 @@ Examples: }, } - cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, markdown, sarif, junit") + cmd.Flags().StringVarP(&format, "format", "f", defaultFormat, "Saved report format: table, json, markdown, sarif, junit (terminal always renders as table)") cmd.Flags().StringVarP(&output, "output", "o", "", "Write results to file") cmd.Flags().StringVar(&failOn, "fail-on", defaultFailOn, "Exit non-zero on: critical, high, medium, low, any") cmd.Flags().IntVar(&timeout, "timeout", 180, "Timeout in seconds per image") @@ -428,7 +433,7 @@ Examples: }, } - cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, markdown, sarif, junit") + cmd.Flags().StringVarP(&format, "format", "f", defaultFormat, "Saved report format: table, json, markdown, sarif, junit (terminal always renders as table)") cmd.Flags().StringVarP(&output, "output", "o", "", "Write results to file") cmd.Flags().StringVar(&failOn, "fail-on", defaultFailOn, "Exit non-zero on: critical, high, medium, low, any") cmd.Flags().StringSliceVar(&excludeChecks, "exclude-check", nil, @@ -507,7 +512,7 @@ Examples: }, } - cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, markdown, sarif, junit") + cmd.Flags().StringVarP(&format, "format", "f", defaultFormat, "Saved report format: table, json, markdown, sarif, junit (terminal always renders as table)") cmd.Flags().StringVarP(&output, "output", "o", "", "Write results to file") cmd.Flags().StringVar(&failOn, "fail-on", defaultFailOn, "Exit non-zero on: critical, high, medium, low, any") cmd.Flags().StringSliceVar(&excludeChecks, "exclude-check", nil, @@ -604,7 +609,7 @@ Examples: }, } - cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, markdown, sarif, junit") + cmd.Flags().StringVarP(&format, "format", "f", defaultFormat, "Saved report format: table, json, markdown, sarif, junit (terminal always renders as table)") cmd.Flags().StringVarP(&output, "output", "o", "", "Write results to file") cmd.Flags().StringVar(&failOn, "fail-on", defaultFailOn, "Exit non-zero on: critical, high, medium, low, any") cmd.Flags().StringSliceVar(&excludeChecks, "exclude-check", nil, @@ -749,6 +754,10 @@ func autoSave(results []*types.ScanResult, format, scannerName string) error { // renderAndSave renders results to the requested output writer and — when no // explicit --output path was given — also auto-saves a copy under scans/. +// +// The --format flag controls the saved file format. Interactive stdout always +// renders the human-readable table (with ANSI colour) so the terminal view +// stays readable regardless of the chosen file format. func renderAndSave(results []*types.ScanResult, format, output, scannerName string) error { w, cleanup, err := outputWriter(output) if err != nil { @@ -756,7 +765,14 @@ func renderAndSave(results []*types.ScanResult, format, output, scannerName stri } defer cleanup() - rep := newReporter(format) + // Explicit -o: write the chosen format to that file. + // No -o: stdout gets the table view; the chosen format is auto-saved below. + stdoutFormat := format + if output == "" { + stdoutFormat = string(reporter.FormatTable) + } + + rep := newReporter(stdoutFormat) rep.Output = w rep.Color = output == "" // ANSI colour only for interactive stdout if err := rep.Render(results); err != nil { diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 00ce137..37b9065 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -25,26 +25,22 @@ type Config struct { // when the corresponding CLI flag was not explicitly set. var LoadedConfig *Config -// defaultConfigPaths is the ordered list of paths to search for a config file. -var defaultConfigPaths = []string{ - ".dockeraudit.yaml", - ".dockeraudit.yml", -} - -// LoadConfig reads the configuration from the given path, or searches -// defaultConfigPaths if path is empty. Returns nil (no error) if no config -// file is found. +// LoadConfig reads the configuration from the given path, or falls back to +// the user-level XDG config path ($XDG_CONFIG_HOME/dockeraudit/dockeraudit.yaml, +// or ~/.config/dockeraudit/dockeraudit.yaml) when path is empty. Returns nil +// (no error) if no config file is found. func LoadConfig(path string) (*Config, error) { if path != "" { return loadConfigFile(path) } - // Search default paths - for _, p := range defaultConfigPaths { - if _, err := os.Stat(p); err == nil { - return loadConfigFile(p) - } + userPath, err := userConfigPath() + if err != nil { + return nil, nil //nolint:nilerr // home-dir lookup failure just means no global config + } + if _, err := os.Stat(userPath); err != nil { + return nil, nil //nolint:nilerr // missing global config is not an error } - return nil, nil // no config file found, not an error + return loadConfigFile(userPath) } func loadConfigFile(path string) (*Config, error) { diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index 7a3621e..5ef01ee 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -56,34 +56,26 @@ verbose: true } } -func TestLoadConfig_DefaultPath(t *testing.T) { - // Create a temp directory with a .dockeraudit.yaml and chdir into it. +func TestLoadConfig_XDGFallback(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, ".dockeraudit.yaml") + cfgDir := filepath.Join(dir, "dockeraudit") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } content := `format: sarif fail-on: critical ` - if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - // Save and restore cwd. - origDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - if err := os.Chdir(dir); err != nil { + if err := os.WriteFile(filepath.Join(cfgDir, "dockeraudit.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } + t.Setenv("XDG_CONFIG_HOME", dir) cfg, err := LoadConfig("") if err != nil { t.Fatalf("LoadConfig(\"\") error: %v", err) } if cfg == nil { - t.Fatal("LoadConfig returned nil — should have found .dockeraudit.yaml") + t.Fatal("LoadConfig returned nil — should have found XDG config") return } if cfg.Format != "sarif" { @@ -94,50 +86,11 @@ fail-on: critical } } -func TestLoadConfig_YMLExtension(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, ".dockeraudit.yml") - content := `format: markdown -` - if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - origDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - if err := os.Chdir(dir); err != nil { - t.Fatal(err) - } - - cfg, err := LoadConfig("") - if err != nil { - t.Fatalf("LoadConfig(\"\") error: %v", err) - } - if cfg == nil { - t.Fatal("LoadConfig returned nil — should have found .dockeraudit.yml") - return - } - if cfg.Format != "markdown" { - t.Errorf("Format = %q, want %q", cfg.Format, "markdown") - } -} - func TestLoadConfig_NoConfigFile(t *testing.T) { + // Point XDG + HOME at an empty temp dir so lookup finds nothing. dir := t.TempDir() - - origDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chdir(origDir) }) - - if err := os.Chdir(dir); err != nil { - t.Fatal(err) - } + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", dir) cfg, err := LoadConfig("") if err != nil { diff --git a/.dockeraudit.example.yaml b/internal/cmd/dockeraudit.example.yaml similarity index 93% rename from .dockeraudit.example.yaml rename to internal/cmd/dockeraudit.example.yaml index 218048d..0fa1d4f 100644 --- a/.dockeraudit.example.yaml +++ b/internal/cmd/dockeraudit.example.yaml @@ -1,14 +1,14 @@ # .dockeraudit.example.yaml # Configuration reference for dockeraudit -# Copy to .dockeraudit.yaml and customize for your environment # ----------------------------------------------------------------------------- # Output Format # ----------------------------------------------------------------------------- -# Output format for scan results +# Format for the saved report file (written to scans/ or to --output path). +# The terminal always renders a human-readable table regardless of this value. # Options: table, json, markdown, sarif, junit -# Default: table -format: table +# Default: markdown +format: markdown # ----------------------------------------------------------------------------- # Exit Code Threshold @@ -71,7 +71,7 @@ include-check: # verbose: true # Development (relaxed) - informational scanning -# format: table +# format: markdown # fail-on: any # verbose: false # exclude-check: diff --git a/internal/cmd/init.go b/internal/cmd/init.go new file mode 100644 index 0000000..f6b119c --- /dev/null +++ b/internal/cmd/init.go @@ -0,0 +1,75 @@ +package cmd + +import ( + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +//go:embed dockeraudit.example.yaml +var embeddedExampleConfig []byte + +// userConfigPath returns the XDG-style path for the global dockeraudit config: +// $XDG_CONFIG_HOME/dockeraudit/dockeraudit.yaml, falling back to +// $HOME/.config/dockeraudit/dockeraudit.yaml. +func userConfigPath() (string, error) { + if base := os.Getenv("XDG_CONFIG_HOME"); base != "" { + return filepath.Join(base, "dockeraudit", "dockeraudit.yaml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".config", "dockeraudit", "dockeraudit.yaml"), nil +} + +func NewInitCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "init", + Short: "Write a default dockeraudit config to ~/.config/dockeraudit/dockeraudit.yaml", + Long: `Create a default dockeraudit configuration file at +$XDG_CONFIG_HOME/dockeraudit/dockeraudit.yaml (or ~/.config/dockeraudit/dockeraudit.yaml +when XDG_CONFIG_HOME is unset). The file contents are the annotated example bundled +with the binary, so users installing via ` + "`go install`" + ` or a release archive get +the same reference config as the repository. + +The config is picked up automatically whenever --config is not passed.`, + Example: ` dockeraudit init + dockeraudit init --force`, + RunE: func(c *cobra.Command, _ []string) error { + path, err := userConfigPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil && !force { + return fmt.Errorf("config already exists at %s (use --force to overwrite)", path) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", path, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + if err := os.WriteFile(path, embeddedExampleConfig, 0o600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + out := c.OutOrStdout() + fmt.Println("") + //nolint:errcheck // stdout write; broken pipe not recoverable + fmt.Fprintf(out, "Wrote configuration file to:\n %s\n\n", path) + //nolint:errcheck + fmt.Fprintf(out, "This file is loaded automatically when --config is not specified.\n") + //nolint:errcheck + fmt.Fprintf(out, "Edit this file to customize default settings.\n") + fmt.Println("") + return nil + }, + } + cmd.Flags().BoolVar(&force, "force", false, "Overwrite an existing config file") + return cmd +} diff --git a/internal/cmd/init_test.go b/internal/cmd/init_test.go new file mode 100644 index 0000000..5193cc8 --- /dev/null +++ b/internal/cmd/init_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUserConfigPath_XDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/xdg") + + got, err := userConfigPath() + if err != nil { + t.Fatalf("userConfigPath() error: %v", err) + } + want := filepath.Join("/custom/xdg", "dockeraudit", "dockeraudit.yaml") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestUserConfigPath_HomeFallback(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "/fake/home") + + got, err := userConfigPath() + if err != nil { + t.Fatalf("userConfigPath() error: %v", err) + } + want := filepath.Join("/fake/home", ".config", "dockeraudit", "dockeraudit.yaml") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// runInit executes the init command with the given args and returns captured +// stdout plus any RunE error. The command writes using c.OutOrStdout(), so +// SetOut fully captures the success message without touching the real stdout. +func runInit(t *testing.T, args ...string) (string, error) { + t.Helper() + c := NewInitCmd() + var buf bytes.Buffer + c.SetOut(&buf) + c.SetErr(io.Discard) + c.SetArgs(args) + err := c.Execute() + return buf.String(), err +} + +func TestInitCmd_FreshWrite(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + out, err := runInit(t) + if err != nil { + t.Fatalf("init failed: %v", err) + } + + path := filepath.Join(dir, "dockeraudit", "dockeraudit.yaml") + got, err := os.ReadFile(path) // #nosec G304 -- test-constructed path + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !bytes.Equal(got, embeddedExampleConfig) { + t.Errorf("written config does not match embedded example (%d vs %d bytes)", len(got), len(embeddedExampleConfig)) + } + if !strings.Contains(out, path) { + t.Errorf("success message does not mention path; got: %s", out) + } +} + +func TestInitCmd_CreatesParentDir(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "a", "b", "c") // does not exist yet + t.Setenv("XDG_CONFIG_HOME", nested) + + if _, err := runInit(t); err != nil { + t.Fatalf("init failed: %v", err) + } + if _, err := os.Stat(filepath.Join(nested, "dockeraudit", "dockeraudit.yaml")); err != nil { + t.Errorf("expected config at nested XDG path, got: %v", err) + } +} + +func TestInitCmd_RefuseOverwrite(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + // First run succeeds. + if _, err := runInit(t); err != nil { + t.Fatalf("first init failed: %v", err) + } + + // Stamp the file so we can detect whether a second run overwrote it. + path := filepath.Join(dir, "dockeraudit", "dockeraudit.yaml") + sentinel := []byte("# user edits — do not clobber\n") + if err := os.WriteFile(path, sentinel, 0o644); err != nil { + t.Fatal(err) + } + + _, err := runInit(t) + if err == nil { + t.Fatal("expected error when config already exists, got nil") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("error should mention --force, got: %v", err) + } + + got, err := os.ReadFile(path) // #nosec G304 -- test-constructed path + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, sentinel) { + t.Error("refused run still overwrote the file") + } +} + +func TestInitCmd_Force(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + path := filepath.Join(dir, "dockeraudit", "dockeraudit.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("# stale\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := runInit(t, "--force"); err != nil { + t.Fatalf("init --force failed: %v", err) + } + + got, err := os.ReadFile(path) // #nosec G304 -- test-constructed path + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, embeddedExampleConfig) { + t.Error("--force did not replace file contents with embedded example") + } +} + +func TestInitCmd_HonorsHomeFallback(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", "") // force HOME fallback + t.Setenv("HOME", dir) + + if _, err := runInit(t); err != nil { + t.Fatalf("init failed: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".config", "dockeraudit", "dockeraudit.yaml")); err != nil { + t.Errorf("expected config under HOME fallback path, got: %v", err) + } +}