# 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

- **Own Dockerfile parser**: line continuations (with comments inside), heredocs (`RUN <<EOF`, `<<-`, quoted delimiters, multiple per instruction), `# syntax=` and `# escape=` directives, JSON vs shell form, `ONBUILD`, `COPY --from`, multi-stage `FROM ... AS name`, and `ARG`/`ENV` expansion (`${V:-default}`, `${V:+alt}`) with Docker's scoping rules.
- **Shell analysis inside RUN**: quote-aware splitting on `&&`, `;`, `||`, pipes and newlines, and tool recognition (apt-get, apk, yum/dnf, pip, npm, yarn, curl, wget, chmod, useradd, ...) with flag handling.
- **48 Dockerfile rules and 18 Compose rules** ([catalog](docs/rules.md)): unpinned/latest/undigested bases, trusted registries, running as root, apt/apk/yum/pip/npm hygiene, `curl | sh`, `ADD` misuse, secrets in ENV/ARG and COPY of `.env`/keys, healthchecks, `EXPOSE 22`, `chmod 777`, relative WORKDIR, duplicate CMD/ENTRYPOINT, shell-form ENTRYPOINT, cache-busting `COPY .`, build-context size and `.dockerignore` checks, multi-stage opportunities, `sudo`, `cd`, mergeable RUNs, and more.
- **Compose analysis**: privileged, host network/PID, `docker.sock` mounts, missing limits, `latest` tags, secrets in `environment`, databases published on `0.0.0.0`, missing healthchecks and `depends_on` conditions, restart policy, dangerous capabilities, missing `env_file`, with `${VAR:-default}` interpolation using the adjacent `.env`.
- **Auto-fix** (`dockwise fix`): adds flags, merges apt layers with cleanup, moves lockfile `COPY` before dependency installs (npm/yarn/pnpm/pip/poetry/go/bundler), JSON-form CMD/ENTRYPOINT, ADD to COPY, MAINTAINER to LABEL. Idempotent, diff by default, `--write` to apply.
- **Layer optimizer** (`dockwise optimize`): per-layer invalidation counts from `git log` of the copied paths, with ordering recommendations and estimated rebuilds saved.
- **Output**: rich terminal, JSON, SARIF 2.1.0 (GitHub code scanning ready). Config via `.dockwise.toml`, inline `# dockwise ignore=DW012`, CI-friendly exit codes.

## Install

Requires Python 3.12+.

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

## Quickstart

```bash
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`:

```text
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):

```text
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):

```json
{
  "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`:

```diff
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:

```diff
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](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):

```text
/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`):

```toml
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:

```dockerfile
# 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

```yaml
- 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:

```bash
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/design.md), [docs/fixer.md](docs/fixer.md), rule list: [docs/rules.md](docs/rules.md) (regenerate with `python scripts/gen_rules_doc.py`).

## Tests

```bash
.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

- Static analysis only: build args passed with `--build-arg`, base-image ENV and the contents of the build context beyond the scanned names are unknown.
- Shell analysis is token based, not a full shell parser; constructs like nested `$(...)` are approximated.
- The fixer does not touch Compose files and only reorders `COPY . .` when the install RUN contains nothing but the install (and cache cleanup).
- Compose interpolation reads `.env` next to the file, not the calling shell's environment.
- Optimizer history counts commits, not bytes or build time; it ignores branches other than the checked-out one.
- Digest pinning (DW002) is informational and cannot look up digests for you (no network access by design).
