Skip to content

Severity Matrix

compose-lint uses four severity levels: LOW, MEDIUM, HIGH, and CRITICAL. A rule's default severity is derived — it is the value a two-axis matrix produces for the rule's cell, under a stated baseline, adjusted by at most one qualifier and one modifier.

The derivation is never altered to reach a desired number. When the derived severity is right about the risk but wrong for the product, the rule ships a different value as a declared override (see Calibration overrides) — a reviewable decision, rather than an invisibly re-chosen cell.

Severities are configurable via .compose-lint.yml if the defaults don't match your environment.

Baselines

A severity is unanswerable without a fixed attacker starting position. Mounting docker.sock is not itself an exploit; it is a primitive used after a foothold. Two baselines cover the rule set.

Baseline A — post-foothold (default).

The attacker already has arbitrary code execution inside the affected container, as whatever uid the workload runs as, and can reach from the network any port the file publishes. Given that, what does this line add?

Baseline B — pre-foothold reader. Used by the disclosure and supply-chain rules (CL-0004, CL-0019, CL-0020, CL-0021), whose story starts before any foothold:

The attacker can read the compose file, or can influence what the named image resolves to. They have no code execution anywhere yet.

Score each rule in isolation, assuming every other key is at its secure default. Never worst-case a sibling key — that privilege is scored by the rule that owns it. A rule whose honest score varies with its siblings is either two rules (split it) or has a scoping assumption it must state.

Grounded posture

Adopted in ADR-020.

Every rule's premise and severity assume rootful Docker Engine on Linux at default configuration — the most severe supported posture. Rules are not scored against hardened or loosened daemons, and other runtimes that consume compose files (Podman, nerdctl) are out of scope. Other Docker postures (rootless, Docker Desktop's LinuxKit VM, Swarm) reduce these severities rather than increase them.

The ceiling extends below the daemon. Where a premise depends on a kernel setting rather than on Docker configuration, it is grounded at the upstream kernel default. A distribution shipping a stricter value is hardening, and reads the severity down the same way rootless mode does — there is no neutral "host default" to ground on, since real hosts depart from mainline in both directions (Debian 13 ships perf_event_paranoid=3 against mainline's 2, while Arch ships yama.ptrace_scope=1 against mainline's 0).

A rule that departs from this baseline states the departure on its own page in a Daemon assumptions: field. That field is an exception list, not something every rule repeats.

Analysis reads files only — the compose file(s) named on the command line plus .compose-lint.yml. compose-lint does not read the registry, the daemon, or image contents. Grounding (premise validation, scripts/validate_rule_premises.py) is the separate phase that measures live containers at the posture above.

If your daemon is not at defaults, some findings become unreliable:

Non-default daemon setting Consequence
--no-new-privileges on CL-0003 becomes a false positive — the daemon already applies it
permissive --seccomp-profile several CL-0011 capabilities are under-scored; their syscall gates are gone
--icc=false CL-0006's cross-container reach is removed
--userns-remap userns_mode: host becomes a real finding rather than a no-op
--default-runtime other than runc any rule's premise may be rewritten before runc sees the spec
kernel.perf_event_paranoid above 2 PERFMON (CL-0028) grants nothing — Debian and Ubuntu ship 3
kernel.yama.ptrace_scope above 0 SYS_PTRACE's same-uid reach narrows; its cross-uid reach is unaffected

Axes

Axis 1 — attacker precondition

What else has to be true for the attacker, standing at the baseline, to turn this line into impact?

Level Decision test
Direct Supported API or CLI calls alone realise the impact. No published technique, no second defect.
Technique A published technique against the primitive this line grants. The technique needs no software vulnerability in any victim.
Second flaw Needs a defect or event outside this file — an application RCE, a setuid binary in the image, a compromised registry, an operator action.
Removes a mitigation Grants no primitive at all. Deletes a control, or declines to add one.

Two clarifications settle most disputes:

  • "Removes a mitigation" is not a synonym for "absence rule." Use the named-primitive test: if the missing directive leaves the attacker a concrete, nameable primitive to use (a setuid-root binary, NET_RAW), the rule is Second flaw or Technique. If nothing can be named and the absence only widens the blast radius of some other failure, it is Removes a mitigation.
  • Reachability is not a level — it is a modifier (below).

Axis 2 — impact scope

What can the attacker reach once the precondition is met?

Level Definition
Host Full control of the Docker host, or equivalent reach into it.
Cross-container Escape from the compromised container, or a pivot to other containers.
Single container Impact is contained within the affected container.

Matrix

Host Cross-container Single container
Direct CRITICAL CRITICAL HIGH
Technique CRITICAL HIGH HIGH
Second flaw HIGH HIGH MEDIUM
Removes a mitigation HIGH MEDIUM LOW

Qualifiers and modifiers

Apply at most one qualifier and at most one modifier. They exist because the impact axis measures reach and cannot express what kind of harm — it has nowhere to put host read, host denial of service, or host corruption, which is how a resource-exhaustion rule once ended up labelled Cross-container. The three qualifiers are the three kinds of loss: confidentiality (read-only), availability (availability-only) and integrity (integrity-only). A rule whose impact reaches the host but grants no control over it takes the one that fits, rather than being dropped from its rule's pricing.

Adjustment Effect When
read-only (qualifier) one tier down The realised impact is disclosure or observation, with no write and no execution.
availability-only (qualifier) one tier down The realised impact is denial of service, with no confidentiality or integrity loss.
integrity-only (qualifier) one tier down The realised impact is corruption of host or container state, with no disclosure and no control gained.
pre-foothold reach (modifier) one tier up The impact is reachable by an attacker who has no foothold anywhere — scored on Baseline A because the rule's subject is a runtime primitive, not the file's contents.

Tiers are clamped at CRITICAL and LOW.

Tier definitions

Tier Meaning
CRITICAL Under the grounded default posture, the configuration hands the host over outright, with no further attacker technique — verified by mechanism (socket → daemon API; privileged/SYS_MODULE → host code execution; raw block device → host disk; writable / or /proc → host root). Triage: fix these first.
HIGH Strong compromise — host or cross-container — but it needs a published technique or a second flaw to land.
MEDIUM Real but bounded: single-container compromise, a host-adjacent effect short of takeover, or a derived-HIGH rule calibrated down by a declared override.
LOW Removes a defence-in-depth measure, or yields disclosure only. No primitive is granted.

Mature scanners that grade Kubernetes manifests cap several of our CRITICAL primitives at HIGH. That is a convention of their scales, not a disagreement about the mechanism: the tier here is an internal triage distinction — "game over" versus "serious" — and all severities are default-posture, so hardened deployments read them down (see Grounded posture). It is not a claim that Compose is more dangerous than Kubernetes. The full argument, including the framing that was withdrawn, is in ADR-021.

The rule set is mapped to MITRE ATT&CK for Containers (pinned to v18) and the scoring axes are crosswalked against CVSS 4.0 — see ADR-022.

Calibration overrides

Adopted in ADR-020.

A rule may ship a severity different from the one its cell derives, but only by declaring it:

Derived: <precondition> × <impact> = <SEVERITY>
Shipped: <SEVERITY>   (override: <reason> — <one line>, <ADR or issue link>)

The reason must come from this closed list. Anything else needs an ADR extending it.

Reason Use when
detection-precision The matcher flags a superset of the dangerous case and cannot yet tell the two apart. The override must name the case being over-covered, so there is a concrete path off it.
pending-split The rule holds members at different tiers; the row is priced at its most dangerous member until the split lands.
pending-move Transitional. The derivation has been corrected and ratified, and the code relabel (or removal) lands in a later change of the same release train. Must be empty once that train completes.

There is deliberately no "this rule is too noisy" reason. How often a rule fires is a property of the world, not of the risk, and --fail-on already exists to decide how much risk a given pipeline tolerates. Pricing a gate threshold into a severity would make severity a function of corpus prevalence and leave the override with no exit condition. Where a frequent rule genuinely should ship lower, it is nearly always because the matcher over-covers — detection-precision, which names something fixable.

No row currently carries pending-split or pending-move: the splits landed and the relabels with them, so every remaining override is a standing decision rather than a step in a migration.

Every override row carries a reason and a link, both enforced by tests/test_severity_matrix.py. An override is a reviewable decision; a re-chosen cell is not.

Scoring procedure for a new rule

  1. Complete the sentence: "Given the baseline foothold, this key lets the attacker ____."
  2. Name the furthest thing reached, and cite evidence. Doc-derived claims are not evidence — add a premise check to scripts/validate_rule_premises.py before assigning a severity.
  3. Apply at most one qualifier and one modifier.
  4. Write all the derivation fields before looking at the number you wanted.
  5. If the result disagrees with instinct, fix an axis definition or file an override — never try a different cell. Evaluating combinations until one produces your target number is the documented failure mode this model exists to prevent.
  6. Neighbour check. List every rule in your cell. Find the closest rule that grants strictly more, and confirm it scores at least as high.
  7. Heterogeneity check. Do your members land in different cells? Then it is two rules. Split now.

Edge rules

  • One rule, one severity. SARIF advertises security-severity on the rule descriptor, so a two-severity rule misreports one of them in GitHub no matter what the finding says. Split; never branch.
  • Heterogeneous members score at their most dangerous member until the split lands, and the assignment row says so via pending-split.
  • No context sensitivity. If the honest score varies with sibling keys, split the rule or state the excluded case as a scoping assumption.

Per-rule derivation block

Every docs/rules/CL-XXXX.md carries these fields immediately after **Severity:**: Baseline, Precondition, Impact, Qualifier/modifier, Derived, Shipped (plus override, if any), Scoping assumptions, and Evidence — a premise check or a captured observation that backs the impact claim. A rule that cannot name evidence for its furthest-reach claim is not ready to be scored. Daemon assumptions: appears only where a rule departs from the grounded posture.

CIS Docker Benchmark version

CIS reference numbers in rule docs are pinned to CIS Docker Benchmark v1.7.0 unless otherwise noted. Numbers shift between benchmark versions; if a citation looks wrong against your benchmark copy, check the version first.

Current rule assignments

Sorted by rule ID. Derived is the matrix result for the row's cell after any qualifier or modifier; Shipped is the severity the rule actually emits. Where they differ, the Override column carries a reason from the closed list and a link. tests/test_severity_matrix.py enforces all four properties.

Rule Baseline Precondition Impact Qualifier Derived Shipped Override
CL-0001 A Direct Host CRITICAL CRITICAL
CL-0002 A Direct Host CRITICAL CRITICAL
CL-0003 A Second flaw Single container MEDIUM MEDIUM
CL-0004 B Second flaw Single container MEDIUM MEDIUM
CL-0005 A Second flaw Single container pre-foothold reach HIGH MEDIUM detection-precision — flags every 0.0.0.0 bind, not only the not-meant-to-be-public subset (ADR-020)
CL-0006 A Technique Cross-container HIGH MEDIUM detection-precision — fires on every service, not only those with a reachable neighbour (ADR-020)
CL-0007 A Removes a mitigation Single container LOW LOW
CL-0008 A Technique Cross-container HIGH HIGH
CL-0009 A Second flaw Cross-container HIGH HIGH
CL-0010 A Technique Cross-container HIGH HIGH
CL-0011 A Technique Cross-container HIGH HIGH
CL-0013 A Direct Host read-only HIGH HIGH
CL-0014 A Removes a mitigation Single container LOW LOW
CL-0016 A Direct Host CRITICAL CRITICAL
CL-0017 A Second flaw Single container read-only LOW LOW
CL-0018 A Second flaw Single container MEDIUM MEDIUM
CL-0019 B Second flaw Single container MEDIUM MEDIUM
CL-0020 B Direct Single container HIGH HIGH
CL-0021 B Direct Single container HIGH HIGH
CL-0022 A Removes a mitigation Single container LOW LOW
CL-0024 A Direct Host CRITICAL CRITICAL
CL-0025 A Direct Host CRITICAL CRITICAL
CL-0026 A Removes a mitigation Host availability-only MEDIUM MEDIUM
CL-0027 A Second flaw Single container MEDIUM MEDIUM
CL-0028 A Direct Host integrity-only HIGH HIGH
CL-0029 A Direct Host availability-only HIGH HIGH
CL-0030 A Direct Host read-only HIGH HIGH

Notes on individual derivations

  • CL-0003 — the named primitive is a setuid-root binary in the image, which the missing no-new-privileges stops blocking. Present in most base images, but still a defect outside this file, so: Second flaw.
  • CL-0004 / CL-0019 — scored on Baseline B. The earlier "supply chain" pseudo-level and its Host blast radius are withdrawn: they worst-cased the rest of the compose file, a licence no other rule has. A poisoned image runs code in its own container; anything further is scored by the rule that owns that privilege.
  • CL-0005 — the impact is a single container's exposed service, but it is reachable with no foothold at all, which is what the pre-foothold reach modifier prices. Docker's iptables manipulation bypasses UFW and firewalld, so a 0.0.0.0 bind is exposed even behind a host firewall.
  • CL-0006 — Technique is ARP cache poisoning against the NET_RAW primitive Docker grants by default; the impact ceiling with default capabilities is interception plus denial of service against an L2 neighbour on the same Docker network. Transparent MITM relay needs NET_ADMIN, which is not in the default set. The override is detection-precision because the derived impact needs a co-resident neighbour emitting interceptable traffic, and the matcher — which only asks whether cap_drop: [ALL] is present — cannot see whether one exists. Services and networks are both declared in the file, so this is a matcher that can be sharpened rather than a permanent limitation.
  • CL-0008 / CL-0010 — Technique rather than Direct: host networking and host PID/IPC hand over visibility immediately, but converting that visibility into impact (sniffing, ARP spoofing, abusing a loopback-trusted service, host /dev/shm) takes a published technique rather than a supported API call.
  • CL-0011 / CL-0024 / CL-0027 / CL-0028cap_add is graded by what the capability grants, across four rules rather than one branching rule, because SARIF advertises security-severity on the rule descriptor. CL-0011 keeps the strong host-adjacent tier; ALL, SYS_ADMIN, SYS_MODULE and SYS_RAWIO move to CL-0024, PERFMON and SYS_TIME to CL-0028, and SYS_PTRACE and DAC_READ_SEARCH to CL-0027. Resolves the descriptor/finding mismatch in #503 for capabilities.
  • CL-0027 vs CL-0028 — the two were one rule, and should not have been. CL-0027's members convert into impact only where the image supplies something this file cannot see, and stay inside the container; CL-0028's reach the host with no sibling key and nothing from the image. Different cells, so one rule, one severity requires two rules. Priced as one, the rule had to set SYS_TIME and PERFMON aside as scoping assumptions — a clause reserved for reach that depends on a sibling key, which neither does.
  • CL-0013 / CL-0025 — host paths are graded by what the mount grants, not by which directory it names. A writable mount of a root-equivalent path is host root through ordinary file writes (CL-0025); the same path read-only discloses without granting write, which is the read-only qualifier stepping Direct × Host down one tier to CL-0013's HIGH. /sys and /dev sit in CL-0013 in either mode, because writing to them adds no host-root path: /sys's uevent_helper is not writable at default capabilities, and a /dev bind is gated by the device cgroup.
  • CL-0001 — owns any mount that exposes a host control socket, including a directory that merely contains one (/run, /var/run, /run/containerd, /run/systemd), and is mode-independent because :ro applies to the socket file rather than the API behind it.
  • CL-0017 — the leg that works unaided is host → container: the container passively receives whatever the host later mounts under the shared path. It needs no capability, but it also needs a host operator action that no attacker controls (Second flaw), and it conveys visibility rather than write access (read-only). The container → host leg needs CAP_SYS_ADMIN and apparmor=unconfined, both flagged separately at HIGH.
  • CL-0018 — scored with no host bind mount present. With one, container root writes host-root-owned files and the impact is Host; that combination is scored by the mount rule.
  • CL-0020 / CL-0021 — Baseline B, and Direct: reading the file is the attack. Impact is Single container because the credential authenticates to the service it belongs to; a credential that unlocks a neighbour is that neighbour's finding.

Rule categories

Rules fall into two categories with very different real-world hit rates. Both are by design — neither is a bug.

Absence rules — fire when a hardening directive is missing

These rules trigger when a service does not declare a recommended hardening directive. The trigger condition is essentially if 'foo' not in service_config: yield finding, so they fire on the vast majority of unhardened services in the wild.

  • CL-0003security_opt: [no-new-privileges:true] not set
  • CL-0004image: not pinned to a tag
  • CL-0006cap_drop: [ALL] not declared
  • CL-0007read_only: true not set
  • CL-0019image: not pinned to a digest

CL-0001 (runtime socket) and CL-0002 (privileged) are technically presence-based, but the underlying patterns (mounting the socket, running privileged) are common enough that, in practice, they cluster with absence rules in frequency.

Explicit-disable rules — fire only when a service opts into a dangerous configuration

These rules trigger only when a developer wrote something specifically dangerous — a config value that explicitly turns off a protection or grants unusual access. Real-world hit rates are very low (corpus testing on 1,554 real compose files showed several of these never firing). That is the design: they trade frequency for precision against deeply dangerous configurations, and a zero-hit run does not mean the rule is broken.

  • CL-0014logging.driver: none
  • CL-0016devices: mapping a sensitive host device (e.g. /dev/mem, /dev/kmem)
  • CL-0017volumes: using :rshared (shared mount propagation)
  • CL-0022tmpfs mount passing exec, suid, or dev (re-enabling Docker's default noexec,nosuid,nodev)

Other rules (CL-0005, CL-0008, CL-0009, CL-0010, CL-0011, CL-0013, CL-0018) are also presence-based but target patterns common enough in real compose files that they do not need this caveat.

Overriding defaults

# .compose-lint.yml
rules:
  CL-0005:
    severity: medium    # downgrade if your ports are intentionally public
  CL-0006:
    severity: high      # upgrade if you require strict capability control