CL-0013: Sensitive host path exposed
August 25, 2026 · View on GitHub
Severity: HIGH
Derivation (see severity model):
- Baseline: A — the attacker already has code execution in this container, as the workload uid
- Precondition: Direct — reading a mounted host path needs no technique and no second defect
- Impact: Host — host configuration, kernel interfaces, credentials and user data
- Qualifier/modifier: read-only — what these mounts grant is disclosure and a weakened boundary, not host write. A writable mount of a root-equivalent path is CL-0025, one tier up
- Derived: Direct × Host + read-only = HIGH
- Shipped: HIGH
- Evidence:
_cl0013inscripts/validate_rule_premises.py— a host bind mount exposes the host path. Member claims are graded individually rather than assumed:_cl0013_dev_bind_is_gatedproves a/devbind is refused by the device cgroup, and_cl0016_raw_diskproves the same read succeeds throughdevices:. One member claim is a captured observation rather than a check:/sys'suevent_helperis not writable at default capabilities (Docker 29.1.3), which is why/sysis disclosure and a weakened boundary rather than escape
References:
- OWASP Docker Security Rule #8
- CIS Docker Benchmark 5.6 — Ensure sensitive host system directories are not mounted on containers
What it detects
Services that bind-mount a host path whose exposure is disclosure or a weakened boundary rather than host root:
/sys— kernel device and driver interfaces, in either mode- a path strictly below
/runor/var/run— host runtime state: the system bus (/var/run/dbus), the libvirt control socket, udev's device database,utmp. The directories themselves and anything above them are CL-0001 at CRITICAL, because those hold the daemon control socket; what sits below does not /dev— the entire host device tree, in either mode. The device nodes become visible but stay unusable — a bind is notdevices:, see below. The inert character devices/dev/null,/dev/zero,/dev/full,/dev/randomand/dev/urandomare excluded: they disclose no host state and grant no access/homeitself, or a single user's home directory (/home/alice) — the whole tree, with credentials, source code and shell history, in either mode- a credential directory inside a home directory —
.ssh,.docker,.aws,.kube,.gnupg, and anything below them — in either mode - read-only mounts of the root-equivalent paths
/etc,/proc,/boot,/root,/var/lib/docker,/var/lib/containerdand/var/lib— the same paths writable are CL-0025 at CRITICAL. CL-0025's executable- and library-tree members (/usr,/usr/bin,/usr/local/bin,/bin,/sbin,/lib/modules,/usr/lib,/lib,/lib64) are not graded here read-only: they are world-readable by design, so:rodiscloses nothing and the grant is write-only - a writable
/etc/localtimeor/etc/timezone— see the timezone exception below
Subpaths (e.g., /etc/passwd, /root/.ssh) are also flagged. Both short syntax (/etc:/host-etc) and long syntax (source: /etc, target: /host-etc) are checked. The long syntax is recognized whether type: bind is set explicitly or omitted — Compose treats absolute-path sources as bind mounts either way. Named volumes are not flagged.
The same paths handed over as a secrets: or configs: file: are graded here too, always as read-only: outside swarm that entry is a read-only bind of the host file (measured — a write is refused even with mode: 0666), so configs: shadow: file: /etc/shadow is the read-only /etc disclosure above. A project-relative file: ./secrets/…, the pattern CL-0020 recommends, is not a host path and is not graded.
The home tree is matched by depth, not by descent. /home and /home/alice are the user's data; /home/alice/projects/app/data is the application's own directory and is not flagged. This distinction matters because a relative bind source resolves against the compose file's directory — ./data becomes an absolute path under wherever the file sits, which for most real projects is under /home. Matching the whole subtree would flag the commonest bind idiom in Compose. Credential directories keep the subtree match, because their grant does not weaken further down.
Read-only and read-write mounts are graded differently. :ro on a root-equivalent path lands here at HIGH — readable secrets, no host modification — while the same path writable is CL-0025 at CRITICAL. For /sys, /dev and the home-tree paths the mode does not change the tier. Suppress with reason: if your use case genuinely only reads non-sensitive subdirectories, or scope the mount narrower.
Handled by another rule. A whole-root mount (/), the control-socket directories /run, /var/run, /run/containerd and /run/systemd, and any directory above them are CL-0001 at CRITICAL in either mode, because they expose the daemon control socket whether or not the mount is writable. /var/lib/kubelet is no longer flagged at all: its danger is entirely conditional on Kubernetes being present, so it cannot be premise-checked on the grounded target and it fails the same bar that removed CL-0023 (ADR-020).
The timezone exception
- /etc/localtime:/etc/localtime:ro (and /etc/timezone) is exempt. Read-only
it exposes the host's UTC offset and nothing else, and it appears on a large
share of otherwise-hardened files — flagging it HIGH failed the default gate on
services that had done everything else right (issue #509).
Written without :ro it still fires here, at HIGH: the container can then
overwrite what the host reads as local time. It is deliberately not
CL-0025's CRITICAL, because writing that one file is not host
root — grading it there would repeat #509's mistake one tier up.
Why it matters
Mounting these paths hands the container host state it has no business reading:
/sysprovides direct interfaces to hardware, cgroups, and kernel modules/devconveys the host's entire device inventory — the nodes become visible, though the device cgroup still denies use; see the note below- a home directory typically contains SSH keys, cloud credentials, and shell history — which is also why
~/.ssh,~/.aws,~/.dockerand~/.kubeare flagged on their own, without the rest of the tree - a read-only
/etcstill leaksshadow,sudoersand SSH host keys - a read-only
/procexposes kernel parameters and process environments, including secrets - a read-only
/rootleaksauthorized_keysand shell history - a read-only
/bootdiscloses the kernel and initramfs in use - a read-only
/var/lib/dockerdiscloses other containers' filesystems and image layers - a writable
/etc/localtimeor/etc/timezonelets the container change what the host reads as local time
Writable, the five root-equivalent paths are host root rather than disclosure, which is why CL-0025 owns them at CRITICAL.
A /dev bind mount is not devices:
Bind-mounting /dev conveys the device nodes, but not permission to use them. Docker's per-container device cgroup still denies access to any device not granted with devices: or --device, so the nodes are visible and unusable. Verified on Docker 29.1.3, same host, same device:
via a /dev bind mount: dd: can't open '/hostdev/nvme0n1': Operation not permitted
via --device: 512 bytes copied
An earlier revision of this page called a /dev bind "equivalent to many of CL-0016's individual flags". It is not: CL-0016 flags devices: entries, which pass the cgroup gate, and that is why CL-0016 is the rule that owns raw device access. The /dev bind is still worth flagging — it discloses the host's device inventory and it is a strong signal of intent — but it does not by itself grant raw block-device I/O.
Fix
Remove the bind mount. If the container needs specific host files, copy them into the image at build time or use a named volume containing only the required data:
# Before
services:
app:
image: myapp:1.0
volumes:
- /etc:/host-etc
# After — use a named volume or COPY in Dockerfile
services:
app:
image: myapp:1.0
volumes:
- app-config:/app/config
/dev/shm and /dev/hugepages
For these two, "remove the mount" on its own is not followable — the workload wants the facility, not the host's copy of it. Compose can give it the facility without the host exposure, and the finding names the alternative directly.
/dev/shm. A container already has its own /dev/shm, 64 MiB by default.
If the bind exists only because that is too small — the usual reason, and why
Chrome and Selenium images ask for it — resize it:
# Before
services:
browser:
image: selenium/standalone-chrome:130.0
volumes:
- /dev/shm:/dev/shm
# After — a bigger private segment, host's untouched
services:
browser:
image: selenium/standalone-chrome:130.0
shm_size: 2gb
If two services genuinely need to share one segment, scope the sharing to them rather than to the host:
services:
owner:
image: myapp:1.0
ipc: shareable
worker:
image: myapp:1.0
ipc: service:owner
Both services then see one shared /dev/shm; the host and every other
container are excluded. Sharing the host's namespace instead — ipc: host —
is CL-0010, and is the thing this avoids.
/dev/hugepages. A local volume backed by hugetlbfs mounts a fresh
hugetlbfs instance, so the workload keeps huge pages without seeing the host's:
services:
spdk:
image: myapp:1.0
volumes:
- hugepages:/dev/hugepages
deploy:
resources:
limits:
memory: 4G
volumes:
hugepages:
driver: local
driver_opts:
type: hugetlbfs
device: hugetlbfs
A file created inside that volume is invisible on the host's /dev/hugepages
and through a bind of it (verified on Docker 29.4.3), so the container cannot
read or corrupt pages another workload mapped. What it does not change is
the size of the pool: huge pages are a host-wide resource, so bound the service
with deploy.resources.limits as well.
If the workload really does need the host's own huge-page files — some DPDK and
SPDK deployments coordinate through them — that is a case for suppressing the
finding with a reason:, not for pretending the mount is safe.
ATT&CK coverage
Remediating this finding contributes to mitigating the following MITRE ATT&CK techniques (pinned to ATT&CK v18). compose-lint is a static analyser, so this is mitigation coverage — it detects nothing at runtime.
| Technique | Tactic |
|---|---|
| T1552.001 Unsecured Credentials: Credentials In Files | Credential Access |