v0.16.0
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 itSYS_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'smemory.maxismaxand itscpu.maxis
max 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-wide
perf_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) alongside
read-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 cover
PERFMONorSYS_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:./data
resolves 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:ro
applies 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- /./:/host
are 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, so
cap_drop: [CAP_ALL]andcap_drop: [" ALL "]are read the same way
cap_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/docker
does — 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_add
scored the declaration rather than the runtime state. The effect was that
hardening a service made it fail:cap_drop: [ALL]plus
cap_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 excludedMKNODand
SYS_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 to
Not 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 with
chown("/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.