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
2 changes: 1 addition & 1 deletion .github/release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.21.1"
".": "1.0.0"
}
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
uses: ./
with:
image: check-image:scan
checks: size,root-user,ports,secrets
checks: size,user,ports,secrets
max-size: '20'

- name: Log in to GitHub Container Registry
Expand Down
16 changes: 15 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,26 @@ repos:
hooks:
- id: actionlint

# Go-specific hooks
# Go formatting and modernization
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1
hooks:
- id: go-fmt
args: [-w]

- repo: local
hooks:
- id: go-fix
name: go-fix
entry: go fix ./...
language: system
pass_filenames: false
types: [go]

# Go analysis, linting, and testing
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1
hooks:
- id: go-mod-tidy
- id: go-vet-mod
- id: golangci-lint-mod
Expand Down
19 changes: 7 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The `UpdateResult()` helper in `root.go` enforces this precedence. The iota orde
- Controlled by the `--output`/`-o` global flag (values: `text` default, `json`)
- Color output controlled by the `--color` global flag (values: `auto` default, `always`, `never`); only applies to `--output=text`
- `internal/output/format.go`: Defines `Format` type, `ParseFormat()`, and `RenderJSON()` helper
- `internal/output/results.go`: Result structs (`CheckResult`, `AgeDetails`, `SizeDetails`, `PortsDetails`, `RegistryDetails`, `RootUserDetails`, `HealthcheckDetails`, `SecretsDetails`, `LabelsDetails`, `AllResult`, `Summary`, `VersionResult`)
- `internal/output/results.go`: Result structs (`CheckResult`, `AgeDetails`, `SizeDetails`, `PortsDetails`, `RegistryDetails`, `HealthcheckDetails`, `SecretsDetails`, `LabelsDetails`, `AllResult`, `Summary`, `VersionResult`)
- `cmd/check-image/commands/render.go`: Text renderers for each check; `renderResult()` dispatches to JSON or text based on `OutputFmt`
- `cmd/check-image/commands/styles.go`: Lip Gloss styles (`PassStyle`, `FailStyle`, `headerStyle`, `keyStyle`, `valueStyle`, `dimStyle`); `initRenderer(colorMode, out)` configures the renderer and updates all styles; `statusPrefix(passed)` returns colored ✓/✗; called from `PersistentPreRunE` after `--color` is parsed
- In JSON mode, `main.go` suppresses the final "Validation succeeded/failed" text message (it's already in the JSON)
Expand Down Expand Up @@ -160,10 +160,6 @@ Implemented with `authn.NewMultiKeychain(staticKC, authn.DefaultKeychain)`.
- File format: `{"allowed-ports": [80, 443]}`
- Parses ports from image config's `ExposedPorts` field (format: "8080/tcp")

**root-user**: Validates that image runs as non-root
- No flags
- Checks if `config.Config.User` is empty or "root"

**healthcheck**: Validates that the image has a healthcheck defined
- No flags
- Checks if `config.Config.Healthcheck` is not nil, has a non-empty test command, and test is not `["NONE"]` (explicitly disabled)
Expand Down Expand Up @@ -208,7 +204,7 @@ Implemented with `authn.NewMultiKeychain(staticKC, authn.DefaultKeychain)`.

**user**: Validates that the image user meets security requirements
- Flags: `--user-policy` (optional, JSON or YAML file), `--min-uid` (optional), `--max-uid` (optional), `--blocked-users` (comma-separated, optional), `--require-numeric` (optional)
- Without flags/policy: basic non-root check (same behavior as `root-user`)
- Without flags/policy: basic non-root check (rejects empty user, "root", and UID 0)
- With policy file or flags: enforces UID ranges, blocked usernames, numeric UID requirements
- CLI flags override policy file values; `cmd.Flags().Changed()` distinguishes "not set" from "explicitly set to 0"
- Validates raw `config.Config.User` string only (no `/etc/passwd` resolution available)
Expand All @@ -217,14 +213,13 @@ Implemented with `authn.NewMultiKeychain(staticKC, authn.DefaultKeychain)`.
- Collects all violations (no short-circuit); reports machine-readable `rule` and human-readable `message`
- Returns `UserDetails` with `user`, `is-numeric`, `uid`, violations, and policy constraints
- Implementation: `internal/user/` package (`policy.go`, `validator.go`), `cmd/check-image/commands/user.go`
- Coexists with `root-user` in `all` — no automatic exclusion; user manages with `--skip`/`--include`
- Sample config files: `config/user-policy.yaml`, `config/user-policy.json`

**all**: Runs all validation checks on a container image at once
- Flags: `--config` (`-c`, config file), `--include` (comma-separated checks to run), `--skip` (comma-separated checks to skip), `--fail-fast` (stop on first failure), plus all individual check flags (`--max-age`, `--max-size`, `--max-layers`, `--allowed-ports`, `--allowed-platforms`, `--registry-policy`, `--labels-policy`, `--secrets-policy`, `--skip-env-vars`, `--skip-files`, `--allow-shell-form`, `--user-policy`, `--min-uid`, `--max-uid`, `--blocked-users`, `--require-numeric`)
- `--include` and `--skip` are mutually exclusive
- Precedence: CLI flags > config file values > defaults; `--include` and `--skip` always take precedence over config file check selection
- Without `--config`: runs all 11 checks with defaults (except skipped, or only included)
- Without `--config`: runs all 10 checks with defaults (except skipped, or only included)
- With `--config`: only runs checks present in the config file (except skipped); `--include` overrides config check selection
- Uses `applyConfigValues()` with `cmd.Flags().Changed()` to respect CLI overrides
- Wrappers: `runPortsForAll()` calls `parseAllowedPorts()` before `runPorts()`; `runPlatformForAll()` calls `parseAllowedPlatforms()` before `runPlatform()`
Expand Down Expand Up @@ -390,7 +385,7 @@ Runs on push to `main`, PRs to `main`, and weekly (Monday 06:00 UTC). Performs C
Runs on every push to `main`. Three chained jobs:
1. **release-please**: Creates/updates a release PR with changelog and version bump. On merge, creates the git tag and GitHub release. Exports `releases_created` and `tag_name`.
2. **goreleaser**: Depends on release-please. Only runs when `releases_created == 'true'`. Builds binaries for linux/darwin (amd64/arm64) and windows (amd64 only), uploads to the GitHub release via `mode: append`.
3. **docker**: Depends on both release-please and goreleaser. Only runs when `releases_created == 'true'`. Lints Dockerfile with hadolint, builds single-arch image for Trivy security scanning (CRITICAL/HIGH), validates image with check-image (dogfooding: size, root-user, ports, secrets), then builds and pushes multi-arch image (linux/amd64, linux/arm64) to GHCR with semver tags via `docker/metadata-action`.
3. **docker**: Depends on both release-please and goreleaser. Only runs when `releases_created == 'true'`. Lints Dockerfile with hadolint, builds single-arch image for Trivy security scanning (CRITICAL/HIGH), validates image with check-image (dogfooding: size, user, ports, secrets), then builds and pushes multi-arch image (linux/amd64, linux/arm64) to GHCR with semver tags via `docker/metadata-action`.

All release jobs must be in the same workflow because tags created by `GITHUB_TOKEN` do not trigger other workflows (GitHub limitation to prevent infinite loops).

Expand Down Expand Up @@ -470,11 +465,11 @@ All release jobs must be in the same workflow because tags created by `GITHUB_TO

#### Formatting and Tooling
- Format code with `gofmt`.
- Run `go fix ./...` to apply any API migration rewrites.
- Run `go vet` and `golangci-lint` to ensure idiomatic Go.
- Run `go fix ./...` to apply any API migration rewrites (e.g., inlining `//go:fix inline` functions to use `new(v)` syntax). This is enforced by the `go-fix` pre-commit hook.
- Run `go vet` and `golangci-lint` to ensure idiomatic Go. The `modernize` linter (enabled by default in golangci-lint v2) detects code that `go fix` would rewrite, catching missed modernization in CI.
- Keep `go.mod` tidy.
- Run `pre-commit run --all-files` explicitly before committing and fix any reported issues before proceeding.
- Pre-commit hooks enforce these requirements automatically on `git commit` as well.
- Pre-commit hooks enforce these requirements automatically on `git commit` as well. The `go-fix` hook runs `go fix ./...` to apply code modernization rewrites before linting and testing.
- See `.golangci.yml` for linter configuration (balanced settings).
- Install hooks with: `pre-commit install && pre-commit install --hook-type commit-msg`.

Expand Down
32 changes: 12 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ docker run --rm ghcr.io/jarfernandez/check-image age nginx:latest --max-age 30
# Check image size
docker run --rm ghcr.io/jarfernandez/check-image size nginx:latest --max-size 100

# Check for root user
docker run --rm ghcr.io/jarfernandez/check-image root-user nginx:latest
# Check user security requirements
docker run --rm ghcr.io/jarfernandez/check-image user nginx:latest

# Run all checks with JSON output
docker run --rm ghcr.io/jarfernandez/check-image all nginx:latest -o json
Expand Down Expand Up @@ -179,7 +179,7 @@ The config file determines which checks to run and their parameters. See [All Ch
- uses: jarfernandez/[email protected] # x-release-please-version
with:
image: nginx:latest
checks: age,size,root-user
checks: age,size,user
max-age: '30'
max-size: '200'
```
Expand Down Expand Up @@ -388,13 +388,6 @@ check-image ports <image> --allowed-ports <ports>
Options:
- `--allowed-ports`: Comma-separated list of allowed ports or `@<file>` with JSON/YAML array

#### `root-user`
Validates that the image runs as non-root user.

```bash
check-image root-user <image>
```

#### `healthcheck`
Validates that the image has a healthcheck defined.

Expand Down Expand Up @@ -482,14 +475,12 @@ Options:
- `--blocked-users`: Comma-separated list of blocked usernames (optional)
- `--require-numeric`: Require user to be a numeric UID (optional)

Without any flags or policy file, performs a basic non-root check (same behavior as `root-user`). With flags or a policy file, enforces UID ranges, blocked usernames, and numeric UID requirements.
Without any flags or policy file, performs a basic non-root check (rejects empty user, "root", and UID 0). With flags or a policy file, enforces UID ranges, blocked usernames, and numeric UID requirements.

Precedence: CLI flags override policy file values. When both are provided, the policy file is loaded first, then CLI flags are overlaid on top.

**Limitation:** Without the image's `/etc/passwd`, username-to-UID resolution is not possible. The command validates the raw `User` field string only. UID range checks (`--min-uid`, `--max-uid`) only apply when the user is a numeric value.

The `user` command coexists with `root-user` — both can run in the `all` command simultaneously. Use `--skip` or `--include` to control which checks run.

#### `all`
Runs all validation checks on a container image at once.

Expand All @@ -499,8 +490,8 @@ check-image all <image> [flags]

Options:
- `--config`, `-c`: Path to configuration file (JSON or YAML)
- `--include`: Comma-separated list of checks to run (age, size, ports, registry, root-user, healthcheck, secrets, labels, entrypoint, platform, user)
- `--skip`: Comma-separated list of checks to skip (age, size, ports, registry, root-user, healthcheck, secrets, labels, entrypoint, platform, user)
- `--include`: Comma-separated list of checks to run (age, size, ports, registry, healthcheck, secrets, labels, entrypoint, platform, user)
- `--skip`: Comma-separated list of checks to skip (age, size, ports, registry, healthcheck, secrets, labels, entrypoint, platform, user)
- `--max-age`, `-a`: Maximum age in days (default: 90)
- `--max-size`, `-m`: Maximum size in MB (default: 500)
- `--max-layers`, `-y`: Maximum number of layers (default: 20)
Expand All @@ -522,7 +513,7 @@ Options:
Note: `--include` and `--skip` are mutually exclusive.

Precedence rules:
1. Without `--config`: all 11 checks run with defaults, except those in `--skip`
1. Without `--config`: all 10 checks run with defaults, except those in `--skip`
2. With `--config`: only checks present in the config file run, except those in `--skip`
3. `--include` overrides config file check selection (runs only specified checks)
4. CLI flags override config file values
Expand Down Expand Up @@ -599,7 +590,7 @@ echo "mytoken" | check-image age my-registry.example.com/private-image:latest \
--password-stdin

# Or read from a file
check-image root-user my-registry.example.com/private-image:latest \
check-image user my-registry.example.com/private-image:latest \
--username myuser \
--password-stdin < ~/.secrets/registry-token
```
Expand Down Expand Up @@ -945,7 +936,7 @@ go install ./cmd/check-image

### Pre-Commit Hooks

This project uses pre-commit hooks to enforce code quality and formatting standards before each commit. The hooks automatically run `gofmt`, `go vet`, `golangci-lint`, `go mod tidy`, execute tests with `go-test-mod`, and validate commit messages follow Conventional Commits format.
This project uses pre-commit hooks to enforce code quality and formatting standards before each commit. The hooks automatically run `gofmt`, `go fix`, `go vet`, `golangci-lint`, `go mod tidy`, execute tests with `go-test-mod`, and validate commit messages follow Conventional Commits format.

#### Installation

Expand Down Expand Up @@ -1004,6 +995,7 @@ The hooks run automatically on `git commit`. You can also:
- Config validation: YAML and JSON syntax
- Go formatting: `gofmt`
- Go tidying: `go mod tidy`
- Go modernization: `go fix` (applies API migration rewrites, e.g., `//go:fix inline`)
- Go analysis: `go vet`
- Go linting: `golangci-lint` (see `.golangci.yml` for configuration)
- Go tests: `go test` via `go-test-mod`
Expand Down Expand Up @@ -1071,7 +1063,7 @@ go tool cover -html=coverage.out
- **internal/secrets**: 97.4% coverage
- **internal/fileutil**: 90.0% coverage
- **internal/imageutil**: 88.7% coverage
- **cmd/check-image/commands**: 91.5% coverage
- **cmd/check-image/commands**: 91.6% coverage
- **cmd/check-image**: 73.9% coverage

All tests are deterministic, fast, and run without requiring Docker daemon, registry access, or network connectivity. Tests use in-memory images, temporary directories, and OCI layout structures for validation.
Expand Down Expand Up @@ -1118,7 +1110,7 @@ Releases are fully automated using [release-please](https://github.com/googleapi
- **docker job**:
- Lints `Dockerfile` with hadolint
- Builds a single-arch image (`linux/amd64`) for Trivy security scanning (CRITICAL/HIGH vulnerabilities)
- Validates the image with check-image itself (dogfooding: size, root-user, ports, secrets)
- Validates the image with check-image itself (dogfooding: size, user, ports, secrets)
- Builds and pushes a multi-arch image (`linux/amd64`, `linux/arm64`) to GHCR with semver tags (`major.minor.patch`, `major.minor`, `major`, `latest`)

### Supported Platforms
Expand Down
6 changes: 1 addition & 5 deletions cmd/check-image/commands/all_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const (
checkSize = "size"
checkPorts = "ports"
checkRegistry = "registry"
checkRootUser = "root-user"
checkSecrets = "secrets"
checkHealthcheck = "healthcheck"
checkLabels = "labels"
Expand All @@ -29,7 +28,7 @@ const (

// validCheckNames lists all check names recognized by the all command.
var validCheckNames = []string{
checkAge, checkSize, checkPorts, checkRegistry, checkRootUser,
checkAge, checkSize, checkPorts, checkRegistry,
checkSecrets, checkHealthcheck, checkLabels, checkEntrypoint, checkPlatform,
checkUser,
}
Expand All @@ -44,7 +43,6 @@ type allChecksConfig struct {
Size *sizeCheckConfig `json:"size,omitempty" yaml:"size,omitempty"`
Ports *portsCheckConfig `json:"ports,omitempty" yaml:"ports,omitempty"`
Registry *registryCheckConfig `json:"registry,omitempty" yaml:"registry,omitempty"`
RootUser *rootUserCheckConfig `json:"root-user,omitempty" yaml:"root-user,omitempty"`
Secrets *secretsCheckConfig `json:"secrets,omitempty" yaml:"secrets,omitempty"`
Healthcheck *healthcheckCheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck,omitempty"`
Labels *labelsCheckConfig `json:"labels,omitempty" yaml:"labels,omitempty"`
Expand All @@ -70,8 +68,6 @@ type registryCheckConfig struct {
RegistryPolicy any `json:"registry-policy,omitempty" yaml:"registry-policy,omitempty"`
}

type rootUserCheckConfig struct{}

type healthcheckCheckConfig struct{}

type entrypointCheckConfig struct {
Expand Down
20 changes: 10 additions & 10 deletions cmd/check-image/commands/all_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ func TestParseCheckNameList(t *testing.T) {
},
{
name: "all checks",
input: "age,size,ports,registry,root-user,secrets",
expected: map[string]bool{"age": true, "size": true, "ports": true, "registry": true, "root-user": true, "secrets": true},
input: "age,size,ports,registry,secrets",
expected: map[string]bool{"age": true, "size": true, "ports": true, "registry": true, "secrets": true},
},
{
name: "with whitespace",
Expand Down Expand Up @@ -88,7 +88,7 @@ func TestLoadAllConfig(t *testing.T) {
size:
max-size: 200
max-layers: 10
root-user: {}
user: {}
`
err := os.WriteFile(cfgFile, []byte(content), 0600)
require.NoError(t, err)
Expand All @@ -107,7 +107,7 @@ func TestLoadAllConfig(t *testing.T) {
require.NotNil(t, cfg.Checks.Size.MaxLayers)
assert.Equal(t, uint(10), *cfg.Checks.Size.MaxLayers)

require.NotNil(t, cfg.Checks.RootUser)
require.NotNil(t, cfg.Checks.User)

assert.Nil(t, cfg.Checks.Ports)
assert.Nil(t, cfg.Checks.Registry)
Expand Down Expand Up @@ -136,21 +136,21 @@ func TestLoadAllConfig(t *testing.T) {
require.NotNil(t, cfg.Checks.Secrets)
assert.Nil(t, cfg.Checks.Size)
assert.Nil(t, cfg.Checks.Registry)
assert.Nil(t, cfg.Checks.RootUser)
assert.Nil(t, cfg.Checks.User)
})

t.Run("root-user with empty object", func(t *testing.T) {
t.Run("user with empty object", func(t *testing.T) {
tmpDir := t.TempDir()
cfgFile := filepath.Join(tmpDir, "config.yaml")
content := `checks:
root-user: {}
user: {}
`
err := os.WriteFile(cfgFile, []byte(content), 0600)
require.NoError(t, err)

cfg, err := loadAllConfig(cfgFile)
require.NoError(t, err)
require.NotNil(t, cfg.Checks.RootUser)
require.NotNil(t, cfg.Checks.User)
})

t.Run("nonexistent file", func(t *testing.T) {
Expand Down Expand Up @@ -898,7 +898,7 @@ func TestLoadAllConfig_Healthcheck(t *testing.T) {
content := `checks:
age:
max-age: 30
root-user: {}
user: {}
healthcheck: {}
`
err := os.WriteFile(cfgFile, []byte(content), 0600)
Expand All @@ -907,7 +907,7 @@ func TestLoadAllConfig_Healthcheck(t *testing.T) {
cfg, err := loadAllConfig(cfgFile)
require.NoError(t, err)
require.NotNil(t, cfg.Checks.Age)
require.NotNil(t, cfg.Checks.RootUser)
require.NotNil(t, cfg.Checks.User)
require.NotNil(t, cfg.Checks.Healthcheck)
assert.Nil(t, cfg.Checks.Size)
assert.Nil(t, cfg.Checks.Ports)
Expand Down
Loading
Loading