Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

</details>
Expand All @@ -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:
Expand All @@ -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 |
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions cmd/dockeraudit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -47,6 +46,7 @@ func main() {
return nil
}

rootCmd.AddCommand(cmd.NewInitCmd())
rootCmd.AddCommand(cmd.NewScanCmd())
rootCmd.AddCommand(cmd.NewImageCmd())
rootCmd.AddCommand(cmd.NewDockerCmd())
Expand Down
32 changes: 24 additions & 8 deletions internal/cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -234,7 +239,7 @@ Examples:
return cmd
}

// ── image ─────────────────────────────────────────────────────────────────────
// ── image ───────────────────────────────────────────────────────────────────── //

func NewImageCmd() *cobra.Command {
var (
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -749,14 +754,25 @@ 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 {
return err
}
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 {
Expand Down
26 changes: 11 additions & 15 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
69 changes: 11 additions & 58 deletions internal/cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -71,7 +71,7 @@ include-check:
# verbose: true

# Development (relaxed) - informational scanning
# format: table
# format: markdown
# fail-on: any
# verbose: false
# exclude-check:
Expand Down
Loading
Loading