Skip to content

ADR-028: Final Rule-ID Sweep Before 1.0

Status: Accepted (closes the pre-1.0 reclamation window opened by ADR-005 and used by ADR-016)

Context: From 1.0, rule IDs are permanent: never reused, never retired without a deprecation grace period (ADR-005, compatibility.md). Before 1.0 a rule that should not have shipped may be removed and its ID left fallow — the rule that freed CL-0012, CL-0015 and CL-0023. ADR-016 names the stakes: a false-positive-prone rule that ships into 1.0 is a long-lived liability and erodes trust in every other finding.

#645 asked for a deliberate last call over the 27 registered rules — recorded, so that "we checked" is verifiable later rather than folklore. It is not a re-audit: the grounding bar (ADR-002 + ADR-016) and the derived severity model (ADR-020, Milestone 3.5) are already met by every rule. Each rule was asked the four questions the issue set, which are the failure modes reclamation exists for:

  1. Would we ship this rule today? A rule retained mainly because it already exists is the candidate.
  2. Is its container-context grounding genuinely container-context? The CL-0022/CL-0023 pattern: generic host hardening that a container's defaults already neutralize.
  3. Is it false-positive-prone in a way the premise check does not capture? The premise suite proves the mechanism; it does not measure how often a rule fires on reasonable real files. Corpus data is the evidence here — the 5,417-file snapshot from state-of-compose.md, re-linted on 2026-08-23 with the current rule set (run 20260823T163118Z, 5,248 files linted).
  4. Is the ID itself right? The post-split rules (CL-0011/24/27/28 from cap_add; CL-0013/25 from host paths) are where a numbering regret would live.

Decision: Every one of the 27 rules is kept. No ID is reclaimed. One rule is narrowed: CL-0022 stops flagging the dev tmpfs option, which live measurement shows grants nothing under any Docker posture (the CL-0023 failure mode, inside a rule that survived it once already). The reclamation window closes with this ADR; CL-0012, CL-0015 and CL-0023 stay fallow permanently, enforced by tests/test_rule_surfaces.py::test_retired_ids_are_not_reused.

Per-rule dispositions

Columns: Ground is the admissibility arm under ADR-016 — S = container- context source (CIS §5 or OWASP Docker cheat sheet demonstrating the need in a container), R = runtime premise check in scripts/validate_rule_premises.py, R* = a premise check exists but covers only part of the rule, with the load-bearing member resting on a captured observation the rule's own doc labels as unchecked, N = listed in _NON_RUNTIME (no observable container state; source-only).

Files is the share of the 5,248 linted corpus files the rule fires on, over the archived 5,417-file snapshot. The snapshot was linted twice — once with 0.22.0 (run 20260823T163118Z) and once with this branch, 0.23.0 plus the CL-0022 narrowing below (run 20260823T225725Z) — and every rule's hit count, file count and the whole severity distribution are identical between them. The figures therefore describe the current rule set, and the CL-0022 change moves nothing on real files.

One caveat the identical runs make precise rather than hide. CL-0020 and CL-0021 are floors against the real world, not against the tool version: 0.23.0 reads env_file: targets (ADR-027), but the corpus stores compose files by content hash and does not carry the env files they name, so there is nothing for the new behaviour to read here. Where those files exist next to a compose file, the 0.23.0 changelog measures roughly a coin flip per project gaining a finding. No other row is exposed to this.

Rule Shipped Ground Files Disposition Reasoning
CL-0001 CRITICAL S+R 7.5% Keep Socket → daemon API is the canonical container escape; mode-independent by mechanism. A corpus differential before release removed its over-match on /run descendants; the narrowing and its regression test are in the 0.16.0 changelog.
CL-0002 CRITICAL S+R 2.3% Keep privileged is the control every other rule is a subset of.
CL-0003 MEDIUM S+R 89.4% Keep Absence rule, near-universal by design (the hardening pass nobody runs). Premise suite proves the setuid bit is inert and silent under no-new-privileges, and that a root privilege-drop is unaffected — the two claims that decide whether the fix is safe.
CL-0004 MEDIUM S, N 47.9% Keep CL-0019 covers a superset of its input space at the same tier; the recorded reason for keeping two IDs is suppression legitimacy — digest pinning is a materially higher operational bar (Renovate/Dependabot), and a merged rule would let declining it silently disable :latest detection on ~46% of files. Fix text already leads with the digest.
CL-0005 MEDIUM S+R 64.3% Keep Carries the model's only structural override (detection-precision: every 0.0.0.0 bind, not only the not-meant-to-be-public subset). Loud, but the override names the fix path. Protocol-collision defect fixed before 1.0 (#647).
CL-0006 MEDIUM S+R 90.6% Keep detection-precision override with a named sharpening path (a reachable neighbour is declared in the file). Eleven symptom-table mappings re-proven live on every CI run.
CL-0007 LOW S+R 90.7% Keep Honest LOW: removes a defence-in-depth measure, no primitive granted. The flagship absence rule; 99.8% of the LOW tier. Its bypass characteristics (a root workload always has /dev as a writable tmpfs; memfd_create needs no file) are the same ones examined for CL-0022 below and do not change the cell.
CL-0008 HIGH S+R 2.6% Keep Host networking hands over host interfaces immediately; Technique rather than Direct, argued in docs/severity.md.
CL-0009 HIGH S+R 1.1% Keep seccomp:unconfined (69 hits) and apparmor:unconfined (40) are explicit; each is one of the two gates the CL-0017 leg and the /dev/fuse class need.
CL-0010 HIGH S+R 0.5% Keep Survived its own sweep: the uts: host and userns_mode: host branches were dropped as verified no-ops under Docker defaults; pid: (20 hits) and ipc: (7) remain.
CL-0011 HIGH S+R 3.7% Keep The cap_add parent after the four-way split; holds the Technique × Cross-container members (NET_ADMIN, BPF, SYS_BOOT). DAC_OVERRIDE (a Docker default) was removed in #505; membership is guarded by tests/test_rule_membership.py against all 41 kernel capabilities.
CL-0013 HIGH S+R 2.4% Keep The host-path parent after the split. Its one measured false-positive class — grant-by-containment matched by descent, which inflated it from 209 to 5,004 findings on the corpus — was caught by a corpus differential and fixed before it shipped (0.16.0 changelog). The figures come from that differential's own diff, not from this run. Timezone files are exempted read-only. /dev/shm and /dev/hugepages as /dev descendants (39 findings) remain a defensible, recorded open question; not an ID question.
CL-0014 LOW N 0.2% Keep — a ratified divergence The only rule with no container-context citation — a Docker configuration page and OWASP's application-logging sheet, neither making a container-context argument, which is the bar that removed CL-0023. Its premise does hold (docker logs under driver: none returns configured logging driver does not support reading), but there is no attacker in the story: the operator disabled their own telemetry. A pre-1.0 audit recommended dropping it on exactly that reasoning and the maintainer declined, keeping it at LOW as a hygiene and tamper-PR signal, with the "forensics impossible" overclaim deleted and the label corrected to Removes a mitigation × Single container. This is the one rule in the set retained on judgment rather than on the grounding bar, and it is recorded here rather than left in a review file so a later reader can weigh the call instead of rediscovering it. Explicit-disable only (15 hits), so it cannot be noisy.
CL-0016 CRITICAL S+R* 0.1% Keep Raw block device → host disk at default capabilities, proven live (_cl0016_raw_disk); loop-device allocation at --cap-drop ALL is a captured observation, not a check (automating it would create and remove a host loop device on every CI run) — hence R*. The capability-gated tier (/dev/mem, /dev/fuse — live only with SYS_RAWIO/SYS_ADMIN, which CL-0024 flags) was deliberately not created, and /dev/kmem//dev/raw are not claimed because Docker refuses to create the container.
CL-0017 LOW S+R* 0.1% Keep CIS 5.20 is a genuine container-context citation. Propagation was measured live in both directions (revising an earlier drop recommendation): host → container needs no capability but needs an operator action (Second flaw, read-only); container → host needs two gates flagged separately at HIGH. R*: the registered check proves only that a shared bind is flagged shared: in the container's mountinfo — both legs are captured observations, as the rule's own Evidence line states. Three hits.
CL-0018 MEDIUM S+R 1.4% Keep Scored with no host bind present (stated scoping assumption); the bind combination is priced by the mount rules. Known sharp edge, doc not ID: the fix text still leads with "remove user:", which on an image whose USER is root clears the finding and changes nothing — worth reordering, not worth an ID.
CL-0019 MEDIUM S, N 48.1% Keep Baseline B, Second flaw (a compromised registry). The companion of CL-0004 — see that row.
CL-0020 HIGH S, N 21.9% Keep Baseline B, Direct: reading the file is the attack. The most common acute finding. Precision work is continuous and pre-1.0 ($$ literals #504, quantity-knob exemptions #685/#681, env_file: routing ADR-027); none of it touches the ID or the cell.
CL-0021 HIGH S, N 3.5% Keep Same story, connection-string form; RFC 3986 userinfo parsing.
CL-0022 LOW R 0.1% Keep, narrowed — drop the dev token See below. exec and suid remove a real default and are honest Removes a mitigation × Single container. dev removes nothing observable under any posture.
CL-0024 CRITICAL S+R 1.3% Keep ALL, SYS_ADMIN, SYS_MODULE, SYS_RAWIO: host code execution with no further technique.
CL-0025 CRITICAL S+R 0.6% Keep Writable root-equivalent host path. Its corpus over-match (37 → 62 → 38 after fix) was caught in the same differential as CL-0013's.
CL-0026 MEDIUM S+R 89.5% Keep Replaced the refuted CL-0012 (pids_limit: -1 produced a cgroup state byte-identical to omitting the key). Memory and CPU unbounded by default is proven live; availability-only steps Host down to MEDIUM.
CL-0027 MEDIUM S+R 0.2% Keep SYS_PTRACE / DAC_READ_SEARCH: convert into impact only where the image supplies something this file cannot see. Split from CL-0028 because the two sat in different cells.
CL-0028 HIGH S+R <0.1% Keep PERFMON / SYS_TIME: reach the host with no sibling key and nothing from the image; integrity-only prices it. One corpus hit — specific, not dead.
CL-0029 HIGH S+R 0.5% Keep — with a watch item SYS_NICE (38 hits), IPC_LOCK (34), LEASE: host-global subsystems, Direct, availability-only. Watch: IPC_LOCK is the vendor-recommended cap_add for Vault and Elasticsearch, so it will be the member users argue with. The grant is real and the recommended fix (a memory limit instead of the capability) is correct, so this is friction, not a false positive — if it ever warrants an override it is detection-precision naming the bounded-by-limit case, not a reclamation.
CL-0030 HIGH S+R 0.0% Keep SYSLOG → host kernel ring buffer, read-only. Zero corpus hits on the 2026-08-23 run — no file in the snapshot adds SYSLOG. A zero-hit explicit-grant rule is specific, not broken (the docs/severity.md "explicit-disable" caveat applies).

Question 4 — are the post-split IDs right?

Yes, and the direction of each split is the safer one for existing users. In both splits the original ID kept the less dangerous tier (CL-0011 HIGH, CL-0013 HIGH) and the CRITICAL members moved to new IDs (CL-0024, CL-0025). A suppression someone wrote against CL-0011 or CL-0013 before the split therefore does not silently suppress the new CRITICAL rule — the suppression-migration note in the 0.16.0 changelog is a widening of coverage, never a narrowing. Had the CRITICAL tier kept the parent ID, every pre-split CL-0011: enabled: false would have become a CRITICAL blind spot. The second-generation splits (CL-0027 → CL-0028, then CL-0029/CL-0030 from the ungraded remainder) followed the same rule. IDs are opaque by ADR-005 — they do not encode severity or family — so the non-contiguous numbering is not a defect, and no renumbering is worth the suppression breakage it would cause on every 0.x user.

The three fallow IDs are fallow forever. tests/test_rule_surfaces.py carries the set and fails the suite if any reappears in the registry.

CL-0022 — what the measurement showed

The issue named CL-0022 as the obvious rule to re-examine: it is the rule that was reworked rather than removed in the CL-0022/CL-0023 episode, its premise check covers only one of its three tokens, and its OWASP citation (Rule #8) turns out to say nothing about noexec, nosuid or nodev at all — it recommends --read-only --tmpfs /tmp as a pattern and stops there. Docker's tmpfs page lists the options without recommending any. So CL-0022 is admitted under ADR-016's runtime arm alone, and the runtime arm's test for a presence rule is that the flagged configuration actually produces the insecure behaviour. Each token was measured on rootful Docker Engine 29.1.3 at defaults (runc, builtin seccomp, AppArmor, cgroup v2, Debian 13, kernel 6.12), 2026-08-23:

exec. Default is noexec; :exec removes it (_cl0022, _t22_exec_tmpfs, both in CI). What noexec protects is narrower than the doc implied:

  • A root workload — Docker's default — always has /dev mounted as a writable tmpfs without noexec (rw,nosuid, mode 755, root-owned), under read_only: true included. cp /bin/busybox /dev/x && /dev/x succeeds in a --read-only container. Against the default uid, noexec on a user tmpfs therefore never denies a write-then-execute path.
  • memfd_create + execve runs a dropped binary without touching any mount, for any uid, at default seccomp. Proven (MEMFD_EXEC).
  • The ld.so loader trick is dead on both libcs: musl refuses (Not a valid dynamic program) and glibc fails to mmap PROT_EXEC from a noexec mount (failed to map segment). Interpreters (sh /tmp/x.sh) of course still run.

So for a non-root workload under read_only: true — the posture every other rule drives a file toward — noexec on the tmpfs blocks the commodity "curl to /tmp, chmod +x, run" playbook and nothing deeper. That is exactly the Removes a mitigation × Single container = LOW cell, the same cell CL-0007 occupies with the same bypass profile. Keep.

suid. Default is nosuid; with :exec,suid, a setuid-root binary staged on the tmpfs by a root process runs as euid=0 for nobody (measured with bash -p on debian:bookworm-slim; busybox applets drop privileges and are not a valid probe). The conditions are all second-flaw: a root process must stage the file (a non-root attacker cannot chown it — measured, Operation not permitted, and a setuid bit on their own file grants nothing), and no-new-privileges (CL-0003's fix) neutralizes it entirely. The rootfs at default posture honours setuid anyway; under read_only: true the tmpfs becomes the only suid-capable writable path, which is the case the token describes. Thin, but a real default removed. Keep.

dev. Default is nodev; :dev removes it — and nothing changes:

Posture mknod /d/sda b 8 0 then open() Why
default, tmpfs /d (nodev) EACCES — mount refuses the flag the rule credits
default, tmpfs /d:dev EPERM — device cgroup refuses the node exists; the cgroup allow-list does not include 8:0
default, rootfs (rw,relatime, no nodev) EPERM — device cgroup refuses the rootfs never had nodev; MKNOD is a default capability
default, /dev (rw,nosuid, no nodev) EPERM — device cgroup refuses same
privileged + read_only succeeds via /dev the cgroup is off, and /dev already permits it — the tmpfs adds nothing

The device cgroup gates every non-allow-listed device regardless of which mount holds the node, and the two mounts every container already has — the rootfs and /dev — carry no nodev either. Removing nodev from a third mount changes the errno, not the outcome; where the cgroup is off (privileged, scored CRITICAL by CL-0002; device_cgroup_rules, unscored), /dev is already a writable, device-capable tmpfs. A non-root workload cannot mknod anywhere (no CAP_MKNOD in its effective set). There is no posture in which :dev produces an attacker-relevant state. That is the CL-0023 failure mode — a finding on a config that changes nothing — and ADR-016's runtime arm declines it. The token is removed from the rule, with a premise check (_cl0022_dev_inert) that proves the cgroup refusal on both legs so it cannot be re-added on a doc citation alone.

Four follow-up checks were run before the change landed, each of which could have refuted the drop:

  • Corpus inputs, not just findings. 126 short-form tmpfs entries across the 5,417-file snapshot: 5 carry exec, and zero carry dev or suid in any combination. No corpus finding changes — measured from the inputs rather than inferred from the output.
  • Character devices. mknod c 1 1 (/dev/mem) behaves exactly as the block case: EPERM from the cgroup on :dev, EACCES from the mount on nodev.
  • Non-root workloads. On tmpfs:dev a uid-1000 process cannot mknod at all — EPERM, even for an allow-listed node, and even with cap_add: MKNOD, because a non-root process's CapEff is empty. The option is unreachable for the uid every other rule pushes a file toward.
  • Allow-listed nodes. dev does change the outcome for the devices Docker allow-lists (null, tty, tun, …) — and to no effect: /dev/net/tun created on a tmpfs:dev opens and refuses TUNSETIFF with EPERM at default capabilities, identically to the same node created in /dev, and both succeed with NET_ADMIN — which is CL-0011's finding, not this rule's.

Limit of the measurement: every leg above was run on cgroup v2. Cgroup v1 is deprecated in Docker 29 but supported until May 2029, so it remains inside the grounded posture and is not measured here. The mechanism is the same default device allow-list applied through the v1 devices controller rather than an eBPF program, so the same refusal is expected; the premise check runs on whatever mode CI's runner provides and will fail if that expectation is wrong. Recorded as an open assumption rather than a proof.

The rule's references are corrected at the same time: OWASP Rule #8 is cited for the read_only + tmpfs pattern the rule protects, not for the mount options, and the doc states what noexec does and does not stop.

Rationale

  • The sweep is the last cheap moment, and the answer is recorded rather than assumed. A reader in 2028 can see that each rule was asked the four questions, what evidence answered them, and which single rule diverges from the bar and why.
  • Reclamation was already used where it was warranted. CL-0012 and CL-0015 had refuted premises; CL-0023 described no attacker-relevant state; uts: host and userns_mode: host were verified no-ops and left CL-0010 without taking its ID. The 2026-08 review found the candidates, tested each live, and the maintainer ratified the calls. This ADR re-checked the survivors rather than relitigating them.
  • Narrowing CL-0022 instead of reclaiming it keeps the two tokens that remove a real Docker default — the one rule that catches a deliberate weakening of the read_only + tmpfs pattern OWASP recommends — and drops the one that does not. Reclaiming the whole ID to purge one inert member would cost a real (if LOW) signal and the docs/examples/read-only-in-tension worked example that depends on it.
  • "Noisy" is not a reclamation reason. Four rules fire on ~90% of corpus files. Each is an honest absence rule in the cell the model derives, and --fail-on exists to price gate tolerance. Pricing prevalence into existence, like pricing it into severity, would make the rule set a function of the corpus.

Consequences

  • Behaviour change, MINOR: tmpfs: [/x:dev] no longer produces a CL-0022 finding. A file that was clean stays clean; a file whose only CL-0022 finding was dev gains a pass. exec and suid are unchanged. The rule's name becomes "tmpfs mount re-enables exec/suid".
  • The reclamation window is closed. After this ADR the only way a rule leaves the registry is the deprecation lifecycle in compatibility.md. AGENTS.md's pre-1.0 clause now points here.
  • Watch items carried forward, none blocking 1.0: IPC_LOCK friction in CL-0029; /dev/shm and /dev/hugepages as /dev descendants in CL-0013; CL-0014's source-only grounding. Each is a precision or override question on a rule that stays, and each is recorded on its row above. CL-0014's is the one with no post-1.0 exit under ADR-032 as first written: its premise holds, so the evidence bar can never be met, and the thin part is the grounding. ADR-032 condition 1 was widened before the tag to admit withdrawal-on-judgment for exactly the rules this table records as admitted on judgment — a closed set, currently {CL-0014}.

Amendment (pre-1.0): one rule, two evidence shapes (#882). CL-0016 now reads device_cgroup_rules: as well as devices:. A block rule with read or write access, plus a node the container can obtain, is the same raw disk access in the same Direct × Host cell as a mapped disk, so it extends CL-0016 rather than taking a new ID. This follows this ADR's own line: rules split by severity tier, never by the key a grant is spelled in. Its evidence is <type> <major>:<minor> where devices: evidence is a host path, which makes CL-0016 the first rule with two evidence shapes. That is admitted when both shapes grant the same capability in the same severity cell, and each shape is pinned in EVIDENCE_CONTRACT (ADR-024). One exclusion covers both spellings of the same grant.

Alternatives rejected

  • Reclaim CL-0022 outright. Its citation over-claimed and one token was inert; but the remaining tokens are measured, the cell is honest, and the worked example that teaches "buy a read-only root with one LOW" is built on it. Removing a precise LOW rule to fix a citation is disproportionate.
  • Reclaim CL-0014 per the review's recommendation. The maintainer's 2026-08-09 call stands. The honest record is a visible divergence, not a reversal by a later sweep with no new evidence.
  • Merge CL-0004 into CL-0019. Rejected on suppression legitimacy in the August review; nothing since has changed that argument.
  • Renumber so severity or family is legible from the ID. ADR-005 made IDs opaque for a reason; every renumbering breaks every existing suppression, and the split direction already protects users from the dangerous case.
  • Add the capability-gated device tier, or a device_cgroup_rules rule, as part of this sweep. Out of scope: the sweep decides what leaves, not what joins. Both are recorded as declined (CL-0016 row; dev analysis) so they are not re-proposed without new evidence.