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:
- Would we ship this rule today? A rule retained mainly because it already exists is the candidate.
- Is its container-context grounding genuinely container-context? The CL-0022/CL-0023 pattern: generic host hardening that a container's defaults already neutralize.
- 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). - 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
/devmounted as a writable tmpfs withoutnoexec(rw,nosuid, mode 755, root-owned), underread_only: trueincluded.cp /bin/busybox /dev/x && /dev/xsucceeds in a--read-onlycontainer. Against the default uid,noexecon a user tmpfs therefore never denies a write-then-execute path. memfd_create+execveruns a dropped binary without touching any mount, for any uid, at default seccomp. Proven (MEMFD_EXEC).- The
ld.soloader trick is dead on both libcs: musl refuses (Not a valid dynamic program) and glibc fails tommapPROT_EXECfrom anoexecmount (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
tmpfsentries across the 5,417-file snapshot: 5 carryexec, and zero carrydevorsuidin 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:EPERMfrom the cgroup on:dev,EACCESfrom the mount onnodev. - Non-root workloads. On
tmpfs:deva uid-1000 process cannotmknodat all —EPERM, even for an allow-listed node, and even withcap_add: MKNOD, because a non-root process'sCapEffis empty. The option is unreachable for the uid every other rule pushes a file toward. - Allow-listed nodes.
devdoes change the outcome for the devices Docker allow-lists (null,tty,tun, …) — and to no effect:/dev/net/tuncreated on atmpfs:devopens and refusesTUNSETIFFwithEPERMat default capabilities, identically to the same node created in/dev, and both succeed withNET_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: hostanduserns_mode: hostwere 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 thedocs/examples/read-only-in-tensionworked 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-onexists 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 wasdevgains a pass.execandsuidare 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_LOCKfriction in CL-0029;/dev/shmand/dev/hugepagesas/devdescendants 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_rulesrule, as part of this sweep. Out of scope: the sweep decides what leaves, not what joins. Both are recorded as declined (CL-0016 row;devanalysis) so they are not re-proposed without new evidence.