CL-0013: Sensitive host path exposed¶
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 /run or /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 not devices:, see below. The inert character devices /dev/null, /dev/zero, /dev/full, /dev/random and /dev/urandom are excluded: they disclose no host state and grant no access
- /home itself, 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/containerd and /var/lib — the same paths writable are CL-0025 at CRITICAL
- a writable /etc/localtime or /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 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:
- /sys provides direct interfaces to hardware, cgroups, and kernel modules
- /dev conveys 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, ~/.docker and ~/.kube are flagged on their own, without the rest of the tree
- a read-only /etc still leaks shadow, sudoers and SSH host keys
- a read-only /proc exposes kernel parameters and process environments, including secrets
- a read-only /root leaks authorized_keys and shell history
- a read-only /boot discloses the kernel and initramfs in use
- a read-only /var/lib/docker discloses other containers' filesystems and image layers
- a writable /etc/localtime or /etc/timezone lets 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 |