Docker & ContainersXXXV Β· Production HardeningHardening
Auditing the running fleet β container controls and their proof
What you'll learn
- Read the effective capability set of a running container from the kernel
- Distinguish a requested control from an enforced one
- Produce a repeatable pass/fail audit across every container on a host
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The daemon lesson checked settings that apply to the whole host. This one checks the settings that apply to one container at a time, which is where hardening actually drifts: the daemon is configured once and rarely changed, while containers are created continuously by CI, Compose, and people in a hurry.
The distinction that carries the lesson is between a control that was
requested and one that is enforced. docker inspect reports
the request. /proc/<pid>/status inside the container reports what the
kernel did with it, and the two disagree more often than you would
like.
The eight per-container controls
| Control | Requested via | Read the request | Read the enforcement |
|---|---|---|---|
| Not privileged | --privileged absent | .HostConfig.Privileged | CapEff in /proc/1/status |
| Capabilities dropped | --cap-drop=ALL | .HostConfig.CapDrop | CapBnd in /proc/1/status |
| Non-root user | --user or image USER | .Config.User | id inside the container |
| Read-only root | --read-only | .HostConfig.ReadonlyRootfs | a write attempt |
| No privilege escalation | --security-opt | .HostConfig.SecurityOpt | NoNewPrivs in /proc/1/status |
| MAC profile applied | daemon default | .AppArmorProfile | /proc/1/attr/current |
| Host namespaces not shared | flags absent | .HostConfig.PidMode, IpcMode, NetworkMode | /proc/1/ns/* compared to host |
| Process count bounded | --pids-limit | .HostConfig.PidsLimit | pids.max in the cgroup |
Capabilities: what the container really holds
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
grep -E 'CapEff|CapBnd|NoNewPrivs' "/proc/$PID/status"CapBnd: 00000000a80425fb
CapEff: 0000000000000000
NoNewPrivs: 1CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
BND=$(awk '/^CapBnd/ {print $2}' "/proc/$PID/status")
capsh --decode="$BND"0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap0xa80425fb is Dockerβs default set: fourteen capabilities granted
to every container that does not say otherwise. Learn to recognise that
constant on sight β seeing it in an audit means nobody dropped
anything.
Two of the fourteen deserve naming:
cap_dac_overrideignores file permission checks entirely. A process holding it reads any file in the container regardless of mode, which turns a path-traversal bug into a full filesystem read.cap_setuidandcap_setgidlet a process become any user in the container, which is how βwe run as non-rootβ gets undone from inside.
A hardened container shows something much shorter:
0x0000000000000400=cap_net_bind_service
Or, for a container running as a non-root user with everything
dropped, CapEff: 0000000000000000 β the capture at the top of this
section, which is what good looks like.
User: the request and the reality
docker ps -q | while read -r c; do
printf '%-28s user=%s\n' \
"$(docker inspect --format '{{.Name}}' "$c")" \
"$(docker inspect --format '{{.Config.User}}' "$c")"
done/api user=10001
/web user=10001
/legacy-batch user=Illustrative output
An empty value is not a pass and not a fail β it means βlook at the imageβ. Resolve it:
IMG=$(docker inspect --format '{{.Config.Image}}' legacy-batch)
docker image inspect "$IMG" --format 'image USER={{.Config.User}}'image USER=Illustrative output
Both empty means the container is running as root. The kernelβs answer is the definitive one:
CID=legacy-batch
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
awk '/^Uid:/ {print "real uid:", $2, " effective uid:", $3}' "/proc/$PID/status"real uid: 0 effective uid: 0Illustrative output
Reading it from /proc rather than with docker exec ... id matters
for two reasons: it works on distroless images that have no shell, and
it cannot be affected by anything inside the container.
Namespace sharing
docker ps -q | while read -r c; do
docker inspect --format \
'{{.Name}} net={{.HostConfig.NetworkMode}} pid={{.HostConfig.PidMode}} ipc={{.HostConfig.IpcMode}} uts={{.HostConfig.UTSMode}}' "$c"
done/api net=app-net pid= ipc=private uts=
/monitoring-agent net=host pid=host ipc=private uts=hostIllustrative output
An empty pid is the default (private). pid=host means the
container sees and can signal every process on the machine, including
dockerd and sshd. net=host removes network isolation and
publishes every listening port straight onto the host.
These are sometimes correct β a node exporter genuinely needs
pid=host β but each one is a documented exception, not a default.
The auditβs job is to make sure the list of exceptions is a list
somebody approved.
The audit script
Everything above, as one pass over the host:
#!/usr/bin/env bash
# docker-container-audit.sh - per-container hardening evidence.
# Read-only. Exits non-zero if any container fails a control.
set -uo pipefail
DEFAULT_CAPS=00000000a80425fb
fail=0
for c in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d /)
pid=$(docker inspect --format '{{.State.Pid}}' "$c")
priv=$(docker inspect --format '{{.HostConfig.Privileged}}' "$c")
user=$(docker inspect --format '{{.Config.User}}' "$c")
ro=$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$c")
aa=$(docker inspect --format '{{.AppArmorProfile}}' "$c")
bnd=$(awk '/^CapBnd/ {print $2}' "/proc/$pid/status")
nnp=$(awk '/^NoNewPrivs/ {print $2}' "/proc/$pid/status")
uid=$(awk '/^Uid:/ {print $2}' "/proc/$pid/status")
problems=""
[ "$priv" = "true" ] && problems="$problems privileged"
[ "$bnd" = "$DEFAULT_CAPS" ] && problems="$problems default-caps"
[ "$uid" = "0" ] && problems="$problems runs-as-root"
[ "$nnp" != "1" ] && problems="$problems no-new-privs-off"
[ "$ro" != "true" ] && problems="$problems writable-rootfs"
[ -z "$aa" ] && problems="$problems no-apparmor"
if [ -n "$problems" ]; then
printf 'FAIL %-24s%s\n' "$name" "$problems"
fail=1
else
printf 'PASS %-24suid=%s caps=%s\n' "$name" "$uid" "$bnd"
fi
done
exit "$fail"
Sample output from a host mid-remediation:
PASS api uid=10001 caps=0000000000000400
PASS web uid=10001 caps=0000000000000400
FAIL legacy-batch default-caps runs-as-root writable-rootfs
FAIL monitoring-agent default-caps runs-as-root
Two findings, both real, both with a name attached. That output is the artefact β paste it into the change ticket, and paste the clean run next to it when the work is done.
Sanity check
Knowledge check Β· 4 questions
Q1. A container shows `CapBnd: 00000000a80425fb`. What have you learned?
Q2. Why read the container user from `/proc/<pid>/status` rather than with `docker exec ... id`?
Q3. Which findings should an audit treat as failures even when other controls pass? Select all that apply.
Q4. A container started with `--cap-drop=ALL` but running as root is adequately hardened against filesystem access inside the container.
Passing score: 75%. Answers are checked in this browser.