Docker Bench for Security
Verified against The project's `docker-bench-security.sh` script and README at · official docs
What it is and where it fits 🎯#
Docker Bench for Security is a shell script that audits a Docker host's runtime and daemon configuration against the CIS Docker Benchmark — host settings, how the Docker daemon itself is configured, image/build practices, and how containers are actually being run right now. It's the direct Docker-daemon counterpart to kube-bench (its own cheat sheet, same CIS-benchmark philosophy applied to Kubernetes instead) — and it deliberately checks a different layer than Trivy or Grype, which scan inside an image for known-vulnerable packages. Docker Bench doesn't care what's inside an image at all; it cares whether the container running that image was started with a privileged flag, whether the Docker socket is exposed somewhere it shouldn't be, and whether the daemon's own logging/auditing is configured per the benchmark.
This is a genuinely common and easy distinction to blur, so it's worth stating precisely: Trivy scans
what's inside an image; Docker Bench audits how a container is actually run and how the host/daemon
serving it is configured. A perfectly clean Trivy scan (zero known CVEs) says nothing about whether that
same image was then started with --privileged and a bind-mounted /var/run/docker.sock — exactly the
kind of runtime misconfiguration Docker Bench exists to catch.
What Docker Bench actually inspects, section by section#
The "Container Runtime" section is where most real-world findings concentrate — it's evaluated against currently-running containers, so the same host can score very differently before and after a dangerous container is actually started, unlike the mostly-static host/daemon sections.
Installation and invocation#
git clone https://github.com/docker/docker-bench-security.git
cd docker-bench-security
sudo sh docker-bench-security.shDocker Bench needs to inspect the host's Docker daemon state directly (socket access, host filesystem
paths), which is why it's normally run as a plain shell script with sudo on the host itself, rather than
purely from inside an isolated container — running it as a container is supported by the project but
requires deliberately mounting the host's Docker socket and several host paths in, which itself reproduces
exactly the kind of privileged-access pattern the benchmark is checking for elsewhere.
Core concepts#
| Concept | What it means |
|---|---|
| CIS-based check | Named check_<section>_<number> (e.g. check_2_6) — maps directly to a numbered control in the published CIS Docker Benchmark |
| Community check | Named check_c_<number> — useful, Docker-Bench-maintainer-curated checks that aren't part of the official CIS document itself |
| INFO vs. WARN vs. PASS | WARN is an actual finding; INFO is informational/manual-review; PASS means the check succeeded |
| Section grouping | Checks are grouped (host_configuration, container_images, container_runtime, ...) — filterable as a group, not just by individual check ID |
Running the full audit and reading output#
sudo sh docker-bench-security.sh # full run, all sections
sudo sh docker-bench-security.sh -b # disable colored output (useful piping into a log parser)
sudo sh docker-bench-security.sh -l /var/log/docker-bench.log # write output to a specific log fileSample output shape (representative — exact WARN/PASS counts depend entirely on the host and running containers at scan time):
[INFO] 5 - Container Runtime
[WARN] 5.4 - Ensure that privileged containers are not used
[WARN] * Container running in Privileged mode: dangerous-app
[PASS] 5.9 - Ensure that the host's network namespace is not shared
[WARN] 5.31 - Ensure that the Docker socket is not mounted inside any containers
[WARN] * Container running with docker socket mounted: dangerous-app: /var/run/docker.sock
== Summary ==
Checks: 92
Score: 61
A [WARN] entry that names a specific running container by name (as 5.4 and 5.31 do above) is the
single most actionable output shape Docker Bench produces — it tells you exactly which workload to fix,
not just which control category has a gap somewhere.
Filtering to specific checks or sections#
sudo sh docker-bench-security.sh -c check_2_2 # run only check 2.2
sudo sh docker-bench-security.sh -e check_2_2 # run everything EXCEPT check 2.2
sudo sh docker-bench-security.sh -c container_images,container_runtime # run only these two sections
sudo sh docker-bench-security.sh -c container_images -e check_4_5 # a section, minus one specific check within it
sudo sh docker-bench-security.sh -i my-app-name # scope container/image checks to names matching this pattern
sudo sh docker-bench-security.sh -x noisy-sidecar-container # exclude a specific container/image by name from the scanThe -i/-x include/exclude-by-name filters matter in practice on a host running many containers, where
scrolling through findings for infrastructure sidecars you don't directly control just adds noise to the
findings that are actually yours to fix.
Understanding check naming and where to focus first#
| Naming pattern | Meaning | Where these findings usually cluster |
|---|---|---|
check_1_x | Host configuration | Kernel parameters, separate partition for Docker's data directory, auditd rules |
check_2_x | Docker daemon configuration | Logging driver, live-restore, default ulimits, TLS on the daemon socket |
check_3_x | Docker daemon files | Ownership/permissions on daemon.json, TLS certs, the Docker socket file itself |
check_4_x | Container images and build | Non-root USER in the Dockerfile, HEALTHCHECK present, no secrets baked into image layers |
check_5_x | Container runtime | Privileged mode, capability drops, namespace sharing, resource limits — see the WARNING below |
check_c_x | Community checks | Useful, maintainer-curated additions not part of the official CIS document |
In practice, section 5 (container runtime) is where a security review should spend the most attention first — it's evaluated against containers that are actually running right now, so it directly reflects real operational risk rather than a static host setting configured once and rarely revisited.
Real-world scenario: auditing a shared CI runner host#
A self-hosted CI runner is a genuinely high-value target for this kind of audit: it routinely builds and runs untrusted, PR-triggered code, often with more privileges than a typical production host because "it's just CI":
sudo sh docker-bench-security.sh -c container_runtime,container_images -l /var/log/ci-runner-audit.log
grep -c '^\[WARN\]' /var/log/ci-runner-audit.log- Confirm no long-lived container on the runner has
--privilegedset "just in case a job needs it." - Confirm the Docker socket is never bind-mounted into a job's build container — a compromised dependency in a PR build with socket access can escalate to full host compromise.
- Confirm build images run their actual build steps as a non-root
USER, not root by default. - Schedule this audit to run automatically after every runner AMI/image update, not just once at initial provisioning — a "hardened" base image can silently regress with an update.
Caution
A CI runner is one of the most attractive privilege-escalation targets in most companies' infrastructure precisely because it's designed to build and execute code from pull requests — including, potentially, a malicious one from an external contributor on a public repo. Treat Docker Bench findings on a CI runner host with at least the same urgency as findings on a production host, not less.
Real-world scenario: baselining before and after a risky container#
Reproducing the exact before/after comparison the tool is built to demonstrate:
sudo sh docker-bench-security.sh -c container_runtime # baseline — note which runtime checks currently PASS
docker run -d --name dangerous-app --privileged \
-v /var/run/docker.sock:/var/run/docker.sock \
nginx:latest
sudo sh docker-bench-security.sh -c container_runtime # re-run — 5.4 and 5.31 now WARN, naming "dangerous-app" directlyWarning
A container with the Docker socket bind-mounted in (-v /var/run/docker.sock:/var/run/docker.sock)
has an effective path to root on the host, regardless of what user the container process itself runs
as — anything with that socket can launch a brand-new, fully-privileged container of its own. This is
one of the single most severe, most commonly-seen misconfigurations Docker Bench flags, and it shows up
surprisingly often in CI runners and monitoring sidecars that "just needed to check container status."
Real-world scenario: CI gate on a build host, not just production#
# .github/workflows/docker-bench.yml
name: Docker Bench for Security
on:
schedule:
- cron: "0 6 * * *" # daily — this audits the HOST'S daemon config, not a specific build artifact
jobs:
audit:
runs-on: self-hosted # must run on a real Docker host — GitHub-hosted runners don't expose daemon config the same way
steps:
- name: Clone and run Docker Bench
run: |
git clone https://github.com/docker/docker-bench-security.git /tmp/dbs
cd /tmp/dbs
sudo sh docker-bench-security.sh -b -l /tmp/dbs-results.log
- name: Fail on any WARN in the container_runtime section
run: |
! grep -q "^\[WARN\]" /tmp/dbs-results.logDocker Bench audits the host, not a single artifact — it belongs on a recurring schedule against your actual build/production hosts, not as a per-PR gate the way Trivy's image scan is, since nothing about a single PR's diff changes the host's own daemon configuration.
Comparing findings across two hosts#
sudo sh docker-bench-security.sh -l /tmp/host-a.log
diff <(grep '^\[WARN\]' /tmp/host-a.log) <(grep '^\[WARN\]' /tmp/host-b.log)A raw score alone (see the pitfall below) hides which checks actually differ — diffing the WARN lines between a known-good baseline host and a newly-provisioned one is a fast way to catch configuration drift introduced by a change to a shared provisioning script, before it reaches every host in a fleet.
Shell completion and log limiting#
sudo sh docker-bench-security.sh -n 50 # cap the number of items listed per finding in JSON output (default: unlimited)-n matters specifically on a host running a large number of containers/images — an unbounded findings
list for a single check (e.g. "every image missing a HEALTHCHECK") can otherwise dwarf the rest of the
report and make the genuinely actionable findings harder to spot at a glance.
Common pitfalls#
- Confusing this with an image content scanner. See the "What it is" section above — Docker Bench finds zero CVEs by design; that's Trivy/Grype's job, not this tool's.
- Running it inside an unprivileged container and expecting full results. Several checks need direct host filesystem/daemon access; a naive containerized run of Docker Bench itself often under-reports findings unless the exact host mounts the project documents are deliberately included.
- Ignoring
[WARN]findings that name a legitimate infrastructure container (a monitoring agent that genuinely needs--privileged) instead of documenting the exception. Use-x/-ito scope future runs around a reviewed, accepted exception rather than re-triaging the same known finding every time. - Treating the numeric "Score" alone as meaningful without reading which specific checks failed. Two hosts with the same score can have completely different actual risk profiles depending on which checks are the ones failing.
Interpreting remediation guidance with -p#
sudo sh docker-bench-security.sh -p # print remediation guidance alongside every WARN/INFO findingBy default, Docker Bench's console output states what failed but not always the exact fix inline — -p
surfaces the CIS Benchmark's own remediation text directly next to each finding, the same "don't make the
reader go find the fix in a separate document" principle kube-bench's == Remediation == block follows.
Real-world scenario: a pre-production release checklist#
Teams that treat Docker Bench as a recurring gate rather than a one-off audit typically fold it into a release checklist alongside the image-content scanning this series already covers:
- Run
trivy image(its own cheat sheet) against the release candidate image — content-level CVEs. - Run
docker-bench-security.sh -c container_imagesagainst the same image while it's the only thing running on a clean host — build/image-level CIS checks (non-rootUSER, no secrets in layers). - Deploy the image to a staging host and re-run
docker-bench-security.sh -c container_runtime— catches a misconfigured--privileged/socket-mount flag introduced at the orchestration layer, which an image scan alone can never see since it isn't part of the image itself. - Only promote to production once both layers — image content and runtime configuration — pass.
This two-layer checklist is the concrete answer to "why do we need both a scanner and Docker Bench" — each one is structurally blind to what the other catches.
Running via Docker itself (containerized invocation)#
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /etc:/etc:ro \
-v /usr/bin/containerd:/usr/bin/containerd:ro \
-v /usr/bin/runc:/usr/bin/runc:ro \
-v /usr/lib/systemd:/usr/lib/systemd:ro \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--label docker_bench_security \
docker-bench-security # built locally via `docker build -t docker-bench-security .` from the cloned repoNotice this exact invocation mounts the host's Docker socket and several host paths read-only into the
scanning container itself — a concrete, real illustration of the tension named earlier in this cheat
sheet: even a security-auditing tool needs privileged-adjacent access to do its job, which is exactly why
5.31's "Docker socket not mounted inside any containers" check exists for application containers, not
for a deliberately-scoped, read-only, security-tooling exception like this one.
Trusted users and Docker Content Trust#
sudo sh docker-bench-security.sh -u alice,bob # only these OS users are treated as legitimately trusted to run dockerCheck 4.5 ("Content trust for Docker is enabled") also checks the DOCKER_CONTENT_TRUST environment
variable — enabling it means docker pull/docker run refuse an image whose publisher didn't sign it,
the same signature-verification principle Cosign (its own cheat sheet) applies at the CI/build stage,
here enforced one layer closer to the runtime pull itself.
Exit codes and when to reach for something else#
The script itself doesn't fail the shell process on a WARN finding — build pass/fail logic from parsing
its log output (as in the CI recipe above), the same pattern kube-bench's JSON output requires. For
Kubernetes cluster configuration hardening rather than the underlying Docker/container-runtime host, reach
for kube-bench instead (its own cheat sheet) — note that a Kubernetes worker node still runs a
container runtime underneath, so the two audits are complementary rather than redundant on a
self-managed Kubernetes cluster running Docker/containerd directly on its nodes. For what's actually
inside the images being run, pair this with Trivy or Grype, already covered in this series.