CLI · commit 5b16a97

dockwise

Dockerfile & Compose linter, hardener and layer optimizer

Download dockwise.zip
Quickstart
unzip dockwise.zip && cd dockwise
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

dockwise

dockwise is a Dockerfile and Compose linter, hardener and layer optimizer. It parses Dockerfiles with its own parser (continuations, heredocs, directives, ARG/ENV scoping across stages), analyses what happens inside RUN scripts, and reports security problems, cache-busting layer order, image bloat and unpinned versions, each with an explanation and a concrete fix. dockwise fix applies the safe rewrites and prints a diff, and dockwise optimize uses your git history to estimate how often each layer is invalidated.

Features

Install

Requires Python 3.12+.

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/dockwise --help

Quickstart

dockwise lint .                          # lint every Dockerfile / compose file under the current directory
dockwise lint Dockerfile --explain       # include the reason behind each finding
dockwise fix Dockerfile                  # show the diff of safe fixes
dockwise fix Dockerfile --write          # apply them
dockwise optimize Dockerfile             # layer cache report (uses git history when available)
dockwise lint . --format sarif -o dockwise.sarif
dockwise rules DW012                     # explain one rule; `dockwise rules` lists all

Usage

lint

dockwise lint [PATHS...] accepts files and directories (searched recursively for Dockerfile*, *.Dockerfile, Containerfile* and Compose files; node_modules, .git and virtualenvs are skipped). Real output for examples/node/bad/Dockerfile:

examples/node/bad/Dockerfile
  1      warning DW001 Base image 'node' has no tag, which means ':latest'.
           fix: Pin a specific version tag, for example python:3.12-slim-bookworm.
  1      warning DW004 No USER instruction in the final stage: the container runs as root.
           fix: Create an unprivileged user and switch to it before CMD/ENTRYPOINT: RUN useradd -r -u 10001 app, then USER 10001.
  1      info    DW017 The image EXPOSEs a port but defines no HEALTHCHECK.
           fix: Add HEALTHCHECK --interval=30s CMD ["probe", ...] using a binary that exists in the image (distroless images have no curl, so let the app expose a probe flag).
  1      info    DW027 Final stage contains compile step ('npm run build'); use a multi-stage build.
           fix: Build in a dedicated stage and COPY --from=builder only the artifacts into a slim final stage.
  3-4    warning DW024 'COPY . .' precedes the npm install (line 4); copy package.json, package-lock.json first.
           fix: COPY only the manifest/lockfile (package*.json, requirements.txt, go.mod/go.sum...) first, run the install, then COPY the rest.  [auto-fixable]
  3      warning DW025 COPY . . copies the whole context but there is no .dockerignore in examples/node/bad.
           fix: Create a .dockerignore listing .git, node_modules, .env*, build output and virtualenvs.
  3      warning DW026 Build context includes: node_modules (50 B).
           fix: Add the listed paths to .dockerignore.
  4-6    info    DW030 3 consecutive RUNs (lines 4-6) can be merged, saving 2 layers.
           fix: Chain them with && and a trailing backslash into a single RUN.
  4      warning DW035 Use 'npm ci' instead of 'npm install'.
           fix: Use npm ci (add --omit=dev for production images).
  5      warning DW037 Unpinned npm package(s): pm2, typescript.
           fix: Pin the version: npm install -g typescript@5.4.5.
  7      error   DW015 ENV NPM_TOKEN looks like a secret and will leak through the image metadata.
           fix: Use BuildKit secret mounts (RUN --mount=type=secret) at build time and inject runtime secrets via the orchestrator.
  8      warning DW029 Use WORKDIR instead of 'cd' in RUN.
           fix: Use WORKDIR to set the directory for subsequent instructions.
  10     warning DW023 ENTRYPOINT in shell form does not receive signals.
           fix: Use exec form: ENTRYPOINT ["./app", "--flag"].  [auto-fixable]

13 issue(s) in 1 file(s): 1 error, 9 warning, 3 info

Compose files get the DWC rules (messages only, abridged):

examples/compose/docker-compose.yml
  1      info    DWC018 The 'version' key is obsolete in the Compose Specification.
  3      warning DWC004 Service 'web' has no memory limit.
  3      info    DWC008 Service 'web' defines no healthcheck.
  3      info    DWC010 Service 'web' has no restart policy.
  3      info    DWC013 Service 'web' does not set no-new-privileges.
  3      info    DWC017 Variable ${SENTRY_DSN} has no default and is not defined in .env.
  4      warning DWC005 Service 'web' uses 'myorg/web:latest' which is not pinned.
  8      error   DWC006 Service 'web' embeds a password in the URL of DATABASE_URL.
  9      error   DWC006 Service 'web' sets SECRET_KEY to a literal value.
  11     warning DWC009 'web' depends on 'db' without condition: service_healthy.
  14     error   DWC003 Service 'web' mounts the Docker socket.
  15     warning DWC016 Service 'web' references missing env_file ./web.env.
  17     warning DWC004 Service 'db' has no memory limit.
  17     info    DWC010 Service 'db' has no restart policy.
  19     error   DWC001 Service 'db' runs privileged.
  20     warning DWC002 Service 'db' uses network_mode: host.
  22     error   DWC007 Database service 'db' publishes port 5432 on all interfaces.
  24     error   DWC006 Service 'db' sets POSTGRES_PASSWORD to a literal value.
  28     warning DWC004 Service 'cache' has no memory limit.
  28     info    DWC008 Service 'cache' defines no healthcheck.
  28     info    DWC010 Service 'cache' has no restart policy.
  28     info    DWC013 Service 'cache' does not set no-new-privileges.
  29     warning DWC005 Service 'cache' uses 'redis' which is not pinned.
  31     error   DWC007 Database service 'cache' publishes port 6379 on all interfaces.
  33     warning DWC011 Service 'cache' adds capability SYS_ADMIN.

25 issue(s) in 1 file(s): 7 error, 9 warning, 9 info

Options: --format rich|json|sarif, --output FILE, --explain, --ignore ID[,ID], --select ID[,ID], --fail-on error|warning|info|none, --config FILE, --no-config, --context DIR (build context for .dockerignore and size checks, default is the Dockerfile's directory).

JSON (first finding only):

{
  "tool": {
    "name": "dockwise",
    "version": "0.1.0"
  },
  "files": [
    {
      "path": "examples/node/bad/Dockerfile",
      "kind": "dockerfile",
      "error": null,
      "findings": [
        {
          "rule": "DW001",
          "name": "base-image-latest",
          "severity": "warning",
          "message": "Base image 'node' has no tag, which means ':latest'.",
          "file": "examples/node/bad/Dockerfile",
          "line": 1,
          "end_line": 1,
          "explanation": "An untagged or :latest base image changes under you: the same Dockerfile builds different images on different days, breaking reproducibility and silently pulling in breaking upgrades or new CVEs.",
          "fix": "Pin a specific version tag, for example python:3.12-slim-bookworm.",
          "fixable": false
        }
      ]
    }
  ],
  "summary": {
    "error": 1,
    "warning": 9,
    "info": 3,
    "total": 13
  }
}

SARIF output is a single run with the rules that fired in tool.driver.rules and one result per finding with startLine/endLine; levels map error/warning/info to error/warning/note.

fix

dockwise fix rewrites only what it can prove safe and prints a unified diff. Real output for examples/node/bad/Dockerfile:

fixed DW024: copy package.json, package-lock.json and run the npm install before
'COPY . '
fixed DW023: ENTRYPOINT converted to exec (JSON) form

--- a/examples/node/bad/Dockerfile
+++ b/examples/node/bad/Dockerfile
@@ -1,10 +1,11 @@
 FROM node
 WORKDIR /usr/src/app
+COPY package.json package-lock.json ./
+RUN npm install
 COPY . .
-RUN npm install
 RUN npm install -g pm2 typescript
 RUN npm run build
 ENV NPM_TOKEN=npm_abc123
 RUN cd /usr/src/app && ls
 EXPOSE 3000
-ENTRYPOINT node dist/server.js
+ENTRYPOINT ["node", "dist/server.js"]

The same command on examples/python-django/bad/Dockerfile merges the apt layers, adds -y --no-install-recommends and the lists cleanup, adds --no-cache-dir, turns ADD into COPY, moves requirements.txt ahead of COPY . ., and converts CMD to exec form:

fixed DW039: MAINTAINER replaced by LABEL
fixed DW008: merged 2 apt RUN layers into one
fixed DW006: added 'rm -rf /var/lib/apt/lists/*' to the same layer
fixed DW005: added --no-install-recommends
fixed DW010: added --no-cache-dir
fixed DW014: ADD replaced by COPY
fixed DW024: copy requirements.txt and run the pip install before 'COPY . '
fixed DW010: added --no-cache-dir
fixed DW031: CMD converted to exec (JSON) form

--- a/examples/python-django/bad/Dockerfile
+++ b/examples/python-django/bad/Dockerfile
@@ -1,15 +1,17 @@
 FROM python:latest
-MAINTAINER dev@example.com
+LABEL org.opencontainers.image.authors="dev@example.com"
 ENV DJANGO_SECRET_KEY=changeme DB_PASSWORD=hunter2
-RUN apt-get update
-RUN apt-get install -y build-essential libpq-dev curl
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends build-essential libpq-dev curl && \
+    rm -rf /var/lib/apt/lists/*
 RUN apt-get upgrade -y
 WORKDIR /app
-ADD . .
-RUN pip install -r requirements.txt
-RUN pip install whitenoise
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+RUN pip install --no-cache-dir whitenoise
 RUN curl -sSL https://example.com/install.sh | sh
 RUN chmod -R 777 /app
 COPY .env /app/.env
 EXPOSE 22 8000
-CMD python manage.py runserver 0.0.0.0:8000
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

After --write, the Django example drops from 26 findings to 16, node from 13 to 10, go from 10 to 7 and multistage from 4 to 2; the remaining findings need human decisions (secrets, base image pinning, multi-stage split). --check exits 1 when fixes are available. See docs/fixer.md.

optimize

dockwise optimize counts how many commits in the window (default 90 days, --since N) touched the paths each layer depends on. A layer is rebuilt whenever any earlier COPY/ADD in its stage changed, so a COPY . . at the top makes every layer below it as volatile as the whole repository. Real output on a generated demo repository (scripts/make_demo_repo.sh: 24 feature commits touching src/, one lockfile bump):

/tmp/demo_repo/Dockerfile  source: git history, window: 90 days, 27 commits touching the context
stage 0                                                                                        
┏━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓
┃ Line ┃ Instruction                       ┃ Kind    ┃ Own changes (commits) ┃ Layer rebuilds ┃
┡━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━┩
│    3 │ COPY . .                          │ copy    │                    27 │             27 │
│    4 │ RUN npm install                   │ install │                     - │             27 │
│    5 │ RUN npm install -g pm2 typescript │ install │                     - │             27 │
│    6 │ RUN npm run build                 │ run     │                     - │             27 │
│    8 │ RUN cd /usr/src/app && ls         │ run     │                     - │             27 │
└──────┴───────────────────────────────────┴─────────┴───────────────────────┴────────────────┘

Recommendations
  line 5: This layer does not read copied files but is invalidated 27x by the COPY at line 3: move it above 
that COPY (saves ~27 rebuilds).
  line 4: npm install is invalidated 27x but its inputs (package.json, package-lock.json) changed 2x: copy 
only those files before the install, then COPY the rest (saves ~25 rebuilds).

Outside a git repository it falls back to a static volatility heuristic (source trees volatile, lockfiles stable) and says so. Paths excluded by .dockerignore are excluded from the history count. --format json is available.

Configuration

.dockwise.toml is searched from the file's directory upward (or pass --config):

ignore = ["DW002"]                       # never report these rules
trusted-registries = ["docker.io/library", "gcr.io/my-project"]   # enables DW003
fail-on = "warning"                      # error | warning | info | none

[severity]                               # override default severities
DW010 = "error"
DW004 = "info"

[per-file-ignores]                       # glob on path or file name
"Dockerfile.legacy" = ["DW001", "DW005"]

Inline suppression applies to the instruction (or Compose key) it is attached to, either as a trailing comment or as comment lines directly above it:

# dockwise ignore=DW012
RUN curl -fsSL https://example.com/setup.sh | sh

# dockwise ignore-file=DW001,DW002 anywhere in a file disables rules for the whole file. Compose findings can also be suppressed from a comment above the service name.

Exit codes: 0 no finding at or above fail-on, 1 findings at or above it (or fix --check with pending fixes), 2 usage, config or parse errors.

CI

- run: pip install dockwise
- run: dockwise lint . --format sarif -o dockwise.sarif || true
- uses: github/codeql-action/upload-sarif@v3
  with: { sarif_file: dockwise.sarif }
- run: dockwise lint . --fail-on error

Examples

examples/ holds a bad and a good Dockerfile (each with its own build context) for Python/Django, Node, Go and a multi-stage build, a Compose file with many issues next to a clean one, and with-config/ showing .dockwise.toml. The good ones lint with zero findings:

dockwise lint examples/*/good examples/compose/docker-compose.good.yml
# 0 issue(s) in 5 file(s): 0 error, 0 warning, 0 info

Architecture

Module Role
parser.py Dockerfile parser, stages, variable scoping and expansion
shell.py RUN script splitting and per-tool helpers
deps.py Recognises dependency-install commands and their manifest files
context.py .dockerignore matching, heavy-directory and secret scan
rules/dockerfile.py, rules/compose.py Rule implementations, registered with @rule
compose.py YAML loading with line tracking and ${VAR} interpolation
engine.py Runs rules, applies config, severity overrides and suppressions
fixer.py Pass-based, idempotent rewrites and diff
optimizer.py Git-history layer invalidation analysis
report.py rich, JSON and SARIF renderers
config.py, cli.py .dockwise.toml loading and the click CLI

Design notes with worked examples: docs/design.md, docs/fixer.md, rule list: docs/rules.md (regenerate with python scripts/gen_rules_doc.py).

Tests

.venv/bin/pytest -q

Covers the parser, shell analysis, every rule (positive and negative), Compose rules, config and suppression, fixer idempotency and "lints cleaner" on all bad examples, the optimizer against real temporary git repositories, SARIF structure, and the CLI end to end.

Limitations