What this check reads
For each running container the check inspects two fields, .HostConfig.CapAdd and .HostConfig.CapDrop. A container whose CapAdd matches ALL, SYS_ADMIN or NET_ADMIN on a case-insensitive grep goes into the ADDALL list, and a container whose CapDrop is empty, meaning [], <no value> or blank, goes into the NODROP list. The two lists are applied in priority order: a non-empty ADDALL is a FAIL, otherwise a non-empty NODROP is a WARN, and only when both are empty does the check PASS.
When it applies
Needs a reachable daemon and at least one running container; otherwise SKIP. It reads two fields per running container, .HostConfig.CapAdd and .HostConfig.CapDrop, and applies them in priority order: adding ALL, SYS_ADMIN or NET_ADMIN (case-insensitive substring match) is a FAIL; otherwise an empty CapDrop - meaning the container keeps Docker's full default capability set - is a WARN. Note the consequences of that logic: any non-empty CapDrop satisfies the PASS branch, so dropping a single harmless capability passes just as well as --cap-drop ALL. Other dangerous additions (SYS_PTRACE, SYS_MODULE, DAC_READ_SEARCH) are not in the FAIL list and only avoid a finding if something was dropped.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | No running containers | Nothing was verified: no capability set was read, so no container has been cleared. |
| FAIL | Dangerous capability added | A running container was granted ALL, SYS_ADMIN or NET_ADMIN, which puts it within reach of root on the host. |
| WARN | Some containers keep the full default set (no `--cap-drop`) | The containers named still hold Docker's 14 default capabilities because nothing was dropped. |
| PASS | Every container drops capabilities | Every running container drops at least one capability, which is not the same as dropping all of them. |
Why it matters
Docker grants 14 capabilities by default (CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE). Most services need none of them as a non-root user. CAP_SYS_ADMIN is "the new root" (mounts, namespaces, BPF on older kernels); CAP_NET_ADMIN allows interface/iptables manipulation and sniffing; CAP_NET_RAW (default) enables ARP spoofing between containers. OWASP Docker Cheat Sheet Rule #3 "Limit capabilities"; CIS Docker 5.3 "Ensure that Linux kernel capabilities are restricted within containers"; capabilities(7).
Why it fails, and when it is wrong
- Legitimate
NET_ADMINusers: VPN/WireGuard containers, Pi-hole in some modes, network tools.SYS_ADMIN: FUSE mounts, some backup agents, DinD. Each should be paired with--cap-drop ALLand only the named capability. - The WARN for "no
--cap-drop" fires on practically every default deployment. It is advisory; prioritise--cap-drop ALLon internet-facing containers first. --privilegedimplies all capabilities butCapAddstays empty; that case is covered by the Privileged Containers check.CapDropcontaining anything non-empty (even a singleNET_RAW) counts as "drops capabilities".
How to fix it
Start from --cap-drop ALL
docker run --cap-drop ALL ...In Compose:
cap_drop: [ALL]Add back only what fails
Run the workload and watch the logs for EPERM or "Operation not permitted". Each failure points at a capability to add back, so add them one at a time. Typical needs are CHOWN, SETUID, SETGID and NET_BIND_SERVICE, and capsh --print or pscap inside a working copy shows the current set as a starting point.
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]Narrow any SYS_ADMIN, NET_ADMIN or ALL grant
These are what turn the result into a FAIL. Remove them where the workload does not need them. Where one is genuinely required, such as NET_ADMIN for a VPN container, keep only that capability on top of --cap-drop ALL. The check will still report it, so record the exception.
Recreate the container
cap_drop and cap_add are applied when the container is created.
docker compose up -d --force-recreateVerify the fix
docker inspect -f '{{.Name}} add={{.HostConfig.CapAdd}} drop={{.HostConfig.CapDrop}}' $(docker ps -q)
# expected: drop=[ALL] with at most a short, specific add list
# What the container's process really holds (the authoritative view):
docker exec <name> sh -c 'grep CapEff /proc/1/status'
capsh --decode=$(docker exec <name> sh -c 'grep CapEff /proc/1/status' | awk '{print $2}')
# expected: a small set, or cap_ empty for a fully dropped containerDebugging
This one message covers two different cases: the audit could not reach the Docker daemon, or it reached it and nothing was running. Run docker ps as the audit user, then sudo docker ps. If both fail, the daemon is unreachable: check systemctl status docker, then run the audit as root or give the account sudo. If they work but list nothing, there is genuinely nothing to evaluate, and a stopped container is never checked. The Privileged Containers check reports the two cases as separate messages, so its result tells you which one you hit.
The known weakness of this check: dropping anything satisfies it. Confirm the real capability set with the capsh --decode command above, and prefer --cap-drop ALL plus explicit adds.
VPN, firewall and network-tooling containers do need it. There is no allow-list, so this stays a finding; keep the container otherwise minimal (non-root, read-only, no socket mount) and record the exception.
Start from --cap-drop ALL, run the workload, and add back only what fails. capsh --decode on a working container shows the current set as a starting point; typical needs are CHOWN, SETUID, SETGID and NET_BIND_SERVICE.
cap_drop/cap_add are HostConfig settings applied at creation. Recreate the container (docker compose up -d --force-recreate), then re-inspect.
Capabilities are moot; the Privileged Containers check covers that and should be fixed first.
Sources
- Docker: Runtime privilege and Linux capabilities (default list)
- Docker: docker run
--cap-add/--cap-drop - capabilities(7) man page
- OWASP Docker Security Cheat Sheet (Rule #3)
- Docker Compose: cap_add / cap_drop
How the script reads this
Next
Re-run the Container Hardening audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 5 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 5 Container Hardening fixes