compose-lint
Version updated for https://github.com/tmatens/compose-lint to version v0.16.0.
- This action is used across all versions by 1 repositories.
Action Type
This is a Composite action.
Go to the GitHub Marketplace to find the latest changes.
Action Summary
This GitHub Action is a static-analysis linter for Docker Compose files that checks for security vulnerabilities such as privileged containers, unpinned images, host-network sharing, sensitive bind mounts, hard-coded credentials, and more. It catches these issues before they reach production and provides auto-fixes for unambiguous problems during a dry-run. The action leverages OWASP and CIS Docker Benchmarks to ensure compliance with best practices.
What’s Changed
Upgrading from 0.15.x
Your CI verdict may change in both directions, on files you have not touched. This release re-derived every severity and moved findings between rules, so a gate that passed may fail and a gate that failed may pass. Nothing here is a parser change: the same file is being read the same way and priced differently.
The hazard worth reading twice: a waiver can still parse and no longer
cover anything. A retired rule id warns on load —
config: unknown rule id 'CL-0012'; the override has no effect — and
--strict-config turns that into an error. But a waiver naming a rule that
still exists is silent, even when the finding it was written for has moved:
rules:
CL-0011:
reason: "we need SYS_ADMIN for the FUSE mount" # no longer covers it
SYS_ADMIN is CL-0024 now. The config is valid, nothing warns, and the finding
comes back at CRITICAL. Check every waiver against the table below.
Where findings moved
Generated by linting one trigger per row under both versions.
| Trigger | 0.15.2 | 0.16.0 |
|---|---|---|
cap_add: ALL | CL-0011 CRITICAL | CL-0024 CRITICAL |
cap_add: SYS_ADMIN / SYS_MODULE / SYS_RAWIO | CL-0011 HIGH | CL-0024 CRITICAL |
cap_add: PERFMON / SYS_TIME | CL-0011 HIGH | CL-0028 HIGH |
cap_add: SYS_PTRACE / DAC_READ_SEARCH | CL-0011 HIGH | CL-0027 MEDIUM |
cap_add: NET_ADMIN / BPF / SYS_BOOT | CL-0011 HIGH | CL-0011 HIGH (unchanged) |
cap_add: DAC_OVERRIDE | CL-0011 HIGH | none — Docker default |
whole-root mount /, either mode | CL-0013 CRITICAL | CL-0001 CRITICAL |
writable /etc, /root, /boot, /proc | CL-0013 HIGH | CL-0025 CRITICAL |
read-only /etc and friends | CL-0013 HIGH | CL-0013 HIGH (unchanged) |
devices: /dev/sda and other host disks | CL-0016 HIGH | CL-0016 CRITICAL |
devices: /dev/fuse | CL-0016 HIGH | none — needs SYS_ADMIN, which CL-0024 flags |
userns_mode: host | CL-0010 HIGH | none — a no-op at Docker’s default posture |
Newly flagged — a passing file can now fail
| Trigger | 0.16.0 |
|---|---|
writable /var/lib or /var/lib/containerd | CL-0025 CRITICAL |
/run or /var/run mounted whole | CL-0001 CRITICAL |
a path below them — /run/udev, /var/run/libvirt/libvirt-sock | CL-0013 HIGH |
~/.ssh, ~/.aws, ~/.docker, ~/.kube, ~/.gnupg | CL-0013 HIGH |
a relative source that climbs out — ../../../.. | CL-0001 CRITICAL |
a bind-backed named volume (driver_opts: {device: …, o: bind}) | CL-0001 CRITICAL |
devices: /dev/md0, /dev/vd*, /dev/xvd*, /dev/mmcblk* | CL-0016 CRITICAL |
No longer flagged — a failing file can now pass
cap_add: DAC_OVERRIDE · userns_mode: host · devices: /dev/fuse ·
/dev/null, /dev/zero, /dev/full, /dev/random, /dev/urandom ·
a project directory under a home dir (/home/alice/proj/data) ·
/var/lib/mysql, /var/lib/postgresql/data and other service state dirs ·
anything that was CL-0012, CL-0015 or CL-0023.
Check your own files rather than reasoning about this list
compose-lint check --format json . > before.json # on 0.15.x
pip install --upgrade compose-lint
compose-lint check --strict-config . # dead rule ids become errors
compose-lint check --format json . > after.json
diff <(jq -S '[.findings[]|{rule_id,line,service}]' before.json) \
<(jq -S '[.findings[]|{rule_id,line,service}]' after.json)
The diff catches the moved-waiver case, which no warning can reach.
Added
- CL-0026 — no memory or CPU resource limits (MEDIUM). Docker imposes
neither by default: a container’s
memory.maxismaxand itscpu.maxismax 100000unless a limit is set. Fires when a service declares no memory limit, no CPU limit, or neither, and names which is missing. Reservations (mem_reservation,cpu_shares) express priority under contention and do not satisfy it;cpu_quotadoes. Covers both halves of ATT&CK T1496 Resource Hijacking — the memory-exhaustion denial of service and the CPU-bound cryptomining that a memory limit does not bound at all. - Every rule page carries a derivation block — baseline, precondition, impact, qualifier, derived, shipped, and an Evidence line naming a premise check or a captured observation. A test asserts the page and the severity table state the same derivation.
scripts/validate_rule_premises.pyasserts the daemon under test is at Docker’s defaults before measuring anything, and aborts if it is not — a premise measured against a hardened or loosened daemon returns a confidently wrong answer. Five new premise checks: a:rosocket is still a working API endpoint, a raw host-disk read at default capabilities, the/dev-bind negative control for it,core_patternwritable through an rw/procbind, and memory/CPU unbounded by default.- CI smoke-tests
.pre-commit-hooks.yamlwith the real tool (precommit-smoke).action.yml, the image and the wheel each had an end-to-end smoke job; the pre-commit hook had none, which is how issue #465 — afilespattern that made the hook unable to pass — reached a user.pre-commit try-reporuns the manifest from the working tree, soentry,languageand hook installation are exercised on the PR that changes them..pre-commit-hooks.yamlwas also missing from thecodepath filter, so a manifest-only edit previously skipped the jobs that check it.
Changed
BREAKING — the severity model was rebuilt, and rule ids moved with it. Severities are now derived from a documented two-axis matrix under a stated attacker baseline and a stated Docker posture, and any rule shipping a different value declares an override from a closed reason list. See
docs/severity.md, ADR-020, ADR-021 and ADR-022.Severity changes: CL-0016 HIGH → CRITICAL; CL-0005 HIGH → MEDIUM; CL-0007, CL-0014 and CL-0017 MEDIUM → LOW. Only CL-0005 crosses the default
--fail-on highgate: a file whose only finding at or above HIGH was an all-interfaces port bind now passes. That is deliberate — CI stops failing on intended-public exposure. Use--fail-on medium, or override CL-0005 back to HIGH in.compose-lint.yml, to keep the old behaviour.Rules split.
cap_addis now four rules by what the capability grants: CL-0024 (CRITICAL:ALL,SYS_ADMIN,SYS_MODULE,SYS_RAWIO), CL-0011 (HIGH, unchanged id:NET_ADMIN,BPF,SYS_BOOT), CL-0028 (HIGH:PERFMON,SYS_TIME) and CL-0027 (MEDIUM:SYS_PTRACE,DAC_READ_SEARCH). CL-0028 is new: both its members reach the host with no other key in the file and nothing from the image —SYS_TIMEwrites the host’s wall clock, because Docker does not namespaceCLOCK_REALTIME, andPERFMONopens a host-wideperf_event_openat the upstream kernel default. A service adding either now crosses the default gate where it previously reported MEDIUM. The severity model gains anintegrity-onlyqualifier (one tier down) alongsideread-onlyandavailability-only, which is what the impact axis was missing for a host effect that corrupts without disclosing or granting control.Host paths are two rules: CL-0025 (CRITICAL) for writable mounts of
/etc,/root,/boot,/proc,/var/lib/docker,/var/lib/containerdand/var/lib, and CL-0013 (HIGH, unchanged id) for/sys,/dev, the home tree and read-only mounts of CL-0025’s paths. A whole-root mount (/) is CL-0001’s in either mode, because it contains the daemon control socket. Neither rule branches severity any more, which fixes the SARIF descriptor/finding mismatch in #503.Suppression migration. A
CL-0011waiver now covers onlyNET_ADMIN,BPFandSYS_BOOT; the other capabilities move to CL-0024, CL-0027 and CL-0028 and are no longer covered by it. ACL-0027waiver does not coverPERFMONorSYS_TIME— re-waive as CL-0028. ACL-0013waiver no longer covers a writable root-equivalent path (CL-0025) or a/run-family mount (CL-0001), and a waiver of a whole-root mount moves to CL-0001 (from CL-0025 when writable, or from CL-0013 when read-only). Waivers for CL-0012, CL-0015 and/var/lib/kubeletare dead and can be deleted.CL-0013 matches the home tree by depth, not by subtree.
/homeand a single user’s home directory (/home/alice) are still flagged in either mode, and so are the credential directories~/.ssh,~/.docker,~/.aws,~/.kubeand~/.gnupgtogether with everything below them. A deeper project path —/home/alice/projects/app/data— is the application’s own directory and is no longer flagged. Fewer findings on absolute/home/<user>/<project>/…mounts, new findings on~/.ssh-style credential mounts. This pairs with relative-source resolution below:./dataresolves to an absolute path under wherever the compose file sits, which for most projects is under/home, so a subtree match would have flagged the commonest bind idiom in Compose.CL-0016’s device list is reconciled with what a device actually grants. It gains
/dev/vd*,/dev/xvd*,/dev/mmcblk*and/dev/md*— the host root disks of KVM and Proxmox guests, EC2 instances, Raspberry Pis and mdraid arrays, which needed no capability and were not flagged at all. It drops/dev/mem,/dev/portand/dev/fuse, each live only alongside a capability CL-0024 or CL-0009 already flags, and/dev/kmemand/dev/raw, for which Docker refuses to create the container. Suppressions for the dropped devices are dead and can be deleted.CL-0001 flags any mount that exposes a host control socket, including a directory that merely contains one —
/run,/var/run,/run/containerd,/run/systemd, or the whole root/— and is mode-independent, because:roapplies to the socket file rather than to the read-write API behind it. A read-only/used to be graded CL-0013 HIGH, a tier below the socket it exposes. It also matches a socket name on the host side of a mount only:- /tmp/fake:/var/run/docker.sockis no longer reported as a socket mount, since the container path is where a socket would land, not where it comes from.Host paths are normalised before the mount rules match them, so
.and..segments no longer hide a mount.- /.:/host,- /..:/hostand- /./:/hostare whole-root mounts and now report CL-0001 CRITICAL instead of passing clean;/run/.is matched like/run, and/etc/..is treated as root rather than as CL-0013’s HIGH.CL-0026 no longer accepts a non-positive value hidden in an interpolation default.
mem_limit: ${MEM:-0}andcpus: ${CPUS:-0}describe an unbounded container and are now flagged; a bare${MEM}still counts as a limit, because its value is genuinely unknowable from the file.CL-0006 and the
cap_addrules share one capability normaliser, socap_drop: [CAP_ALL]andcap_drop: [" ALL "]are read the same waycap_addreads them.cap_add: [CAP_ALL]is no longer flagged at all: Docker rejects that spelling outright, so the file could never start.
Removed
CL-0012 (PIDs cgroup limit disabled) — the premise does not hold. On the grounded target,
pids_limit: -1,pids_limit: 0and omitting the key all produce the samepids.max(systemd’sDefaultTasksMax), so the explicit opt-out the rule flagged does not leave the process count unbounded.CL-0015 (healthcheck disabled) — no runtime delta, and its citations mandate the case it declines to flag.
uts: hostanduserns_mode: hostfrom CL-0010. Both are no-ops under the grounded posture:sethostname()needsCAP_SYS_ADMIN, which is not in Docker’s default set, anduserns_modeonly means anything against a--userns-remapdaemon./var/lib/kubeletfrom CL-0013 — its danger is entirely conditional on Kubernetes being present, so it cannot be premise-checked on the grounded target.The ids CL-0012, CL-0015 and CL-0023 stay fallow and will not be reused.
Fixed
The mount rules see host paths they were missing. Each of these mounted a real host path and reported clean:
- A relative or
~source. Compose resolves a relative mount source against the compose file’s directory and expands a leading~; the source was matched as written, so- ../../../../../..:/hostmounted the host root filesystem and reported nothing../data:/dataand other in-project mounts are unaffected. - An interpolated default. With no
.envand no exported variable, Compose substitutes the default, so${DOCKER_SOCKET_PATH:-/var/run/docker.sock}mounts the live control socket. A reference with no default (${VAR},${VAR:?err},$VAR) is still left alone — the host path is not knowable from the file, and guessing one would invent a finding. - A bind-backed named volume.
driver_opts: {type: none, device: <host path>, o: bind}is the standard way to pin a bind mount’s options, and the host path lives in the top-levelvolumes:block, which the mount rules never read.external: truevolumes are left alone, since their host path is not in the file. - A writable
/var/lib(CL-0025 CRITICAL; read-only, CL-0013 HIGH). It contains the container store, so a mount of it grants what/var/lib/dockerdoes — verified on Docker 29.4.3, a container given only-v /var/libread and modified a second container’s files. It is matched exactly, because its grant comes from what it contains rather than from what lies below it:-v /var/lib/mysql,/var/lib/postgresql/dataand other service data directories are not flagged./var/lib/containerdis a member in its own right and is matched by descent. - A path below
/runor/var/run(CL-0013 HIGH) —/var/run/dbus, which reaches systemd and PolicyKit;/var/run/libvirt/libvirt-sock, which is VM control;/run/udev,/var/run/utmp,/run/systemd/journal. CL-0001 owns those directories and their ancestors, because those hold the control socket; what sits strictly below holds host service state instead. A descendant that is a socket stays CL-0001’s at CRITICAL. /dev/md/<name>(CL-0016). mdadm creates a named symlink per array alongside the numeric node, and^/dev/md\dcannot match it — the character aftermdis/, not a digit — so a named array passed clean while/dev/md0beside it was CRITICAL. Added as a second pattern rather than by loosening the first, which is what keeps/dev/mdadmout.
- A relative or
/dev/nulland the other inert character devices are no longer flagged (CL-0013)./dev/null,/dev/zero,/dev/full,/dev/randomand/dev/urandomdisclose no host state and grant no access — mounting/dev/nullover a config file the image expects is a near-universal idiom, and the/devdescent match priced it HIGH. The rest of/dev, including/dev/shm, is unchanged.CL-0011 no longer flags
DAC_OVERRIDE, which inverted the default gate (issue #492).DAC_OVERRIDEis one of Docker’s 14 default capabilities, so a container holds it whether or not the file names it — flagging it oncap_addscored the declaration rather than the runtime state. The effect was that hardening a service made it fail:cap_drop: [ALL]pluscap_add: [DAC_OVERRIDE]— one capability — exited 1 at the default--fail-on high, while the same service with nocap_dropat all — fourteen capabilities,DAC_OVERRIDEamong them — exited 0. The fastest way back to green was to delete the hardening. CL-0011 already excludedMKNODandSYS_CHROOTfor exactly this reason;DAC_OVERRIDEwas the one default capability the list still carried. CL-0006 now names it among the retained defaults, so both rules describe the same capability the same way.CL-0020 and CL-0021 no longer skip credentials containing
$$, Compose’s escape for a literal dollar (issue #502). CL-0020’s variable-reference regex read the second dollar ofpa$$w0rdas starting a$w0rdsubstitution, and CL-0021 exempted any value containing$at all — so exactly the passwords a careful user escaped correctly went unchecked, and the two rules disagreed on values likehunter2$. Both now share one classifier that consumes$$escapes left-to-right, as Compose does, before testing for a reference.Handing compose-lint its own config file no longer fails the run (issue #499).
.compose-lint.ymlparses as YAML but has noservices:key, and its shape matched neither of ADR-013’s not-applicable buckets, so it fell through toNot a valid Compose fileand exit 2. It is now recognised as a third not-applicable shape and skipped with exit 0, like fragments and Compose v1 files. This is the root cause behind issue #465:compose-lint initfollowed by a pre-commit sweep could never pass. Genuinely malformed Compose files still exit 2; the check requires every non-meta top-level key to be a config key, so it cannot swallow a broken file.Fourteen false claims across the rule docs, each re-verified against a live daemon. The worst was CL-0006’s documented
## Fix, which crash-looped:cap_drop: [ALL]pluscap_add: [NET_BIND_SERVICE]exits withchown("/var/cache/nginx/client_temp") failed (Operation not permitted). Also corrected: seccomp and AppArmor do surviveexecveof a setuid binary;bpfandinit_moduleare capability-gated rather than blocked outright;SYS_BOOTdoes not load a kernel via kexec;pid: hostdoes not expose/proc/[pid]/environat default capabilities;uts: hostcannot change the hostname; a/devbind is not equivalent todevices:;read_onlydoes not prevent persistence through a volume; anduser: rootdoes not undo a gosu/su-exec image’s privilege drop.CL-0019 was ungrounded — its only citation contained no digest guidance at all. It now cites Docker’s pull-by-digest documentation and CIS 5.28.