CLI reference¶
The compose-lint command line lints Docker Compose files for security
misconfigurations: check reports findings, fix previews and applies the
auto-remediations, and init writes a starter .compose-lint.yml. check is
the default — a bare compose-lint works, and auto-detects the Compose file.
The authoritative help is compose-lint --help (and fix --help /
init --help); this page is its web copy.
compose-lint [check] [OPTIONS] [FILE ...] Lint files (default; bare invocation works)
compose-lint fix [OPTIONS] [FILE ...] Auto-remediate auto-fixable findings
compose-lint init [OPTIONS] FILE Generate a starter .compose-lint.yml
compose-lint --version Show version and exit (top level only)
check options:
--format {text,json,sarif} Output format (default: text)
--fail-on {low,medium,high,critical}
Minimum severity to trigger exit 1 (default: high)
-v, --verbose Repeat the fix block and reference on every finding (text mode)
-q, --quiet One line per finding — no fix, reference, or excerpt (text mode)
--skip-suppressed Hide suppressed findings from output
--allow-partial-coverage Grade what could be linted when part of the stack
could not be (a coverage gap), instead of failing
(exit 2)
--no-merge-overrides Lint each file alone instead of merging the
`compose.override.yml` Compose merges beside it
--no-env Ignore the env files beside the Compose file: the
sibling `.env` (which Compose reads for COMPOSE_FILE
and `${VAR}` values) and every `env_file:` a service
names, which leaves CL-0020/CL-0021 blind to them
--config PATH Path to config file (default: .compose-lint.yml,
then .compose-lint.yaml, beside the first Compose
file, else in the working directory)
--strict-config Treat config diagnostics (unknown rule id or key, inert reason
or severity, stale exclude_services name, both config
spellings present) as errors, not warnings
--explain CL-XXXX Print the full documentation for a single rule
(through a pager on an interactive terminal)
--no-pager Print --explain output directly, bypassing the pager
--version Show version and exit (`compose-lint --version`; not
accepted after the `check` subcommand)
fix options:
--apply Write fixes in place (default: print a dry-run diff)
--only CL-XXXX Restrict fixes to the named rule(s); repeatable
--no-merge-overrides Fix each file alone instead of merging the
`compose.override.yml` Compose merges beside it
--no-env Ignore the sibling `.env` and every `env_file:`
--config PATH Path to config file (suppressions are honored)
--strict-config Treat config diagnostics (unknown rule id or key, inert reason
or severity, both config spellings present) and an
`--only` id that names no rule as errors, not warnings
init options:
-o, --output PATH Where to write the config (default: .compose-lint.yml
beside FILE, where check looks for it first)
--force Overwrite an existing config file
--no-merge-overrides Baseline the file alone instead of merging the
`compose.override.yml` Compose merges beside it
--no-env Ignore the sibling `.env` and every `env_file:`
init grades its FILE exactly as check grades it — the sibling override
merged, the .env consulted for COMPOSE_FILE, every env_file: read — so
the suppressions it writes are the findings check will report. Baseline
with the same --no-merge-overrides / --no-env you gate with, or the two
disagree.
--explain CL-XXXX prints one rule's documentation and exits. It refuses a
FILE argument and --format json or --format sarif (exit 2): there is
nothing to lint and no machine form of the doc. Every other check option is
accepted and ignored, including --config, which is not read. --no-pager
without --explain does nothing.
Values are case-insensitive wherever the CLI takes a fixed set or a rule id:
--fail-on HIGH, --format JSON, --explain cl-0001 and --only cl-0007
all work, and so do a severity: CRITICAL in .compose-lint.yml and
FORCE_COLOR=FALSE. Rule ids used as keys in .compose-lint.yml are matched exactly, as
written, because they are keys in a document you author; cl-0001: there is
reported as an unknown rule id.
Exit codes¶
Every subcommand exits 0, 1 or 2, and nothing else
(ADR-006). fix and init produce an artifact
rather than a verdict, so neither exits 1: for them, findings are the input,
not the failure.
check |
fix |
init |
|
|---|---|---|---|
| 0 | No finding at or above --fail-on. |
The diff was printed, or the fixes were written, or there was nothing to fix. Findings left to fix by hand, and coverage gaps it reported, do not change this. | The config was written, or there were no findings to write one from. |
| 1 | A finding at or above --fail-on. |
Never. | Never. |
| 2 | compose-lint could not run, or could not see the whole stack: bad arguments; a file missing, not valid Compose, or over a size limit; a crashed rule; a config error, or a config warning under --strict-config; or a coverage gap not accepted with --allow-partial-coverage. |
Bad arguments; a file missing or not valid Compose; a crashed rule or fixer; the 20,000-findings limit; a computed fix that fails the re-parse or verify check; a refused write (read-only file, symlink, hard link, unwritable directory); a config error. Nothing is written to that file, and the rest of the batch still runs. | Bad arguments; the file missing or not valid Compose; a crashed rule; the 20,000-findings limit; an existing config without --force; a failed write. |
Color¶
Color is on when stdout is a terminal. It covers check's report, --explain on a terminal (see Pager), and fix's dry-run diff, which is colored the way git diff colors one: removals red, additions green, @@ lines cyan, the behavior-changing caveats yellow. fix's status lines on stderr follow the same scheme — applied N fix(es) green, N to fix by hand: bold, rule ids dim as in check — and are colored when stderr, rather than stdout, is a terminal, so fix > changes.diff on a terminal still colors the status around the plain patch. Set NO_COLOR to any non-empty value to
disable it (even on a terminal); it wins over everything. FORCE_COLOR, when
set, overrides terminal detection: 0 or false, in any case, turns color off, and any other
value, the empty string included, turns it on. That is the convention of
no-color.org and of Node's supports-color. Use it to
keep color through a pipe, e.g. into less -R or a CI log that renders ANSI.
On Windows, a classic console window (the cmd.exe and PowerShell default on
Windows 10 and Server) shows color only once a program asks it to interpret
escape sequences; compose-lint asks. A console that cannot — Windows 8.1, or
Windows 10 before version 1511 — gets plain text instead of raw ←[31m codes.
Windows Terminal, VS Code's terminal and Git Bash render color as on any other
platform.
Pager¶
--explain pages its rule doc through less -RFX when stdout is a terminal
(ADR-034) — -F means a doc that fits
one screen prints and exits with no pager interaction. The default pager
labels its controls in the status line (CL-XXXX · Space next · b back ·
q quit) instead of less's bare :; a custom PAGER keeps its own prompt. PAGER selects a
different pager, and a blank PAGER disables paging; so do --no-pager, a
non-empty NO_PAGER, and an unset or dumb TERM; a pager binary that isn't installed falls back to a plain
dump. Piped or redirected output never pages and is byte-identical to the
pre-pager behavior — the rule doc's raw markdown — so scripts and CI need no
changes. The findings report itself never pages.
On a terminal the doc is also rendered rather than dumped, whether or not it
pages: headings and **bold** print bold, `code` cyan, a link keeps its
text (a web link adds its address, dimmed), tables line up or become one record
per row, and prose re-wraps to the terminal, at most 100 columns. Color follows
the Color rules; without it the markup is still stripped, and code
keeps its backticks.
End of options¶
-- marks the end of options: everything after it is a file path, never a
flag, and never a subcommand — compose-lint -- init lints a file named
init. That matters to any integration that assembles a command line from
repository content. The pre-commit hook ships args: [--] because pre-commit
builds the command as entry + args + filenames — without the separator, a
repository directory named --config=cfgdir holding a compose.yml arrives
as --config=cfgdir/compose.yml and installs an attacker-authored policy for
the run. compose-lint cannot insert the separator itself: flags after a
positional (compose-lint init docker-compose.yml -o ci.yml) are documented
and must keep working, so placing -- is the caller's job. If you set
args: in your pre-commit config, keep -- last.
Automation and agent use¶
Everything below is also true of a shell script; it is written out because a
coding agent driving compose-lint tends to pattern-match on "linter" and get
these five wrong. Nothing here enumerates rules — --explain CL-XXXX is the
single source for what a rule means and how to fix it, it reads the rule docs
shipped inside the wheel, and it needs no network.
Exit 2 is not "the lint failed". Exit 1 means findings at or above the
threshold — that is the failure to act on. Exit 2 means compose-lint could not
run, or could not see the whole stack. An unresolved include: or cross-file
extends:, a .env Compose reads that compose-lint could not, or any other
coverage gap means part of
the stack was never graded, so the run cannot honestly report a verdict. An
include: or cross-file extends: whose target resolves inside the project
directory is followed and merged, so it is not a gap; the message on one
that is says which condition it hit. Treating exit 2 as a findings failure
invents remediation work that does not exist. Either resolve the coverage gap
or downgrade it deliberately with --allow-partial-coverage, which demotes it
to a warning (on stderr, and in warnings[]) as long as something is left to
grade. fix reports gaps without failing on them, except at the
20,000-findings limit, where it writes nothing to that file and exits 2; it is
not the gate.
fix is a dry run by default, and its refusals are the safety property.
A bare compose-lint fix prints a diff and writes nothing; --apply writes
in place through an atomic swap, guarded by a re-parse and a verify-apply pass.
It deliberately refuses regions it cannot rewrite safely — anchors, merge keys,
${VAR} interpolation — and reports findings whose remediation is
context-dependent (CL-0006 capability lists, CL-0001 socket mounts) as fixes
to make by hand, each with its rule's guidance, rather than guessing at a value only the operator can choose.
That is the contract, not an unfinished fixer: see the fix design
contract. When fix declines a finding, hand-editing the same change
into place discards the guard that made the decline correct.
Two consequences for an agent reporting on a fix run. An edit that alters
runtime behavior carries a ⚠ behavior-changing line naming what breaks —
surface it, because the label is the mitigation; nothing else withholds the
risky fix. And the diff goes to stdout while status goes to stderr, so
compose-lint fix file.yml > changes.diff captures the diff without the
summary. The ⚠ lines lead that stdout block, above the diff, so the redirect
captures them too; repeat them to the reader. The dry-run diff is for review,
not a patch format: a line a CI runner could read as a workflow command is
escaped, so it is not promised to git apply. To change the files, run
fix --apply; to get a patch, run git diff after it. Under --apply there
is no diff, and the ⚠ lines go to stderr with the rest of the status.
Suppression has one shape. Findings are suppressed in .compose-lint.yml
with a reason, which flows through to suppression_reason in JSON,
justification in SARIF, and after SUPPRESSED in text — the suppressed
finding is still reported, so a suppression stays visible rather than
disappearing. There are no inline suppression comments, and no comment syntax
to guess at. compose-lint init generates a baseline config from the findings check
reports for a file — the merged stack, override and env files included — as
per-service exclude_services entries with placeholder reasons to replace. Deleting the offending service to clear a finding is not a
fix; neither is a global enabled: false where the finding is about one
service.
Severity is derived, not chosen. Each rule's severity is what the two-axis
matrix in severity.md produces for its cell, under a stated
attacker baseline and Docker posture. Report the severity the tool emits and
quote --explain for the reasoning; re-ranking a finding because it feels more
or less urgent in context is exactly the failure mode the model exists to
prevent. If it genuinely reads wrong, that is an issue worth filing, not a
number worth adjusting in a report.
Parse --format json, never the text output. JSON is a versioned envelope
(ADR-015): a top-level
version, a tool block, findings, and the errors and warnings
channels, each entry tagged with a kind (see
configuration.md). New
top-level fields are additive and do not bump version, so a consumer can read
what it knows and ignore the rest. Text output is for humans and is not a
contract — its banner, summaries and verdict go to stdout only in text mode, so
JSON and SARIF redirects stay clean. Piped output is never paged or colored,
so no flag is needed to make a run scriptable.
Two smaller things worth knowing when assembling a command line
programmatically: pass -- before the file paths (see End of
options) whenever any part of the command comes from
repository content, and pin a version if the run gates CI, because new and
tightened rules ship in MINOR releases.