Cloud Security · lesson 3 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
Also part of: Docker
CIS Docker Benchmark Checks with docker-bench-security
Run the CIS Docker Benchmark's container checks against a container you start, read each WARN, fix privileged mode and missing limits, and re-run to PASS.
By SkillByExample · Published
The CIS Docker Benchmark is the hardening baseline most audits use for containers.
docker-bench-security checks a host and its containers against it and prints PASS or WARN per
recommendation. The host and daemon sections describe whatever machine Docker runs on, which on
Docker Desktop is a hidden Linux VM, so this lesson uses the part that means the same everywhere:
section 5, the runtime settings of containers you start.
Tested with docker-bench-security v1.6.1 (its banner says v1.6.0), CIS Docker Benchmark 1.6.0,
nginx:1.29-alpine and Docker Engine 29.8.2 on Docker Desktop. The project’s own image is years
old and its Docker client is too old for Engine 29, so build a runner on the current CLI:
# Dockerfile: docker-bench-security v1.6.1 with a current Docker CLI
FROM docker:29-cli
RUN apk add --no-cache bash git procps
RUN git clone --depth 1 --branch v1.6.1 \
https://github.com/docker/docker-bench-security.git /bench
WORKDIR /bench
ENTRYPOINT ["bash", "docker-bench-security.sh"]
$ docker build -q -t local/docker-bench:1.6.1 .
sha256:a3f32510f0630bde8f71824e0a4b73b6915a03db1a2ae1500519019ea64c13ca
The runner only needs the Docker socket, read-only, to inspect containers. On Git Bash for Windows,
prefix the docker run commands below with MSYS_NO_PATHCONV=1 so the socket and tmpfs paths
aren’t rewritten.
Scan a container started the careless way
--privileged is the classic shortcut when something “needs permissions”:
$ docker run -d --name shop --privileged nginx:1.29-alpine
fae189bb2d9eceddb8f57de77e7cc0fd336d28106f7377d779a81eb9d5bd45f7
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_5,check_5_11,check_5_26,check_5_29 2>/dev/null
# --------------------------------------------------------------------------------------------
# Docker Bench for Security v1.6.0
#
# Docker, Inc. (c) 2015-2026
#
# Checks for dozens of common best-practices around deploying Docker containers in production.
# Based on the CIS Docker Benchmark 1.6.0.
# --------------------------------------------------------------------------------------------
Initializing 2026-10-14T15:03:45
Section A - Check results
[WARN] 5.5 - Ensure that privileged containers are not used (Automated)
[WARN] * Container running in Privileged mode: shop
[WARN] 5.11 - Ensure that the memory usage for containers is limited (Automated)
[WARN] * Container running without memory restrictions: shop
[WARN] 5.26 - Ensure that the container is restricted from acquiring additional privileges (Automated)
[WARN] * Privileges not restricted: shop
[WARN] 5.29 - Ensure that the PIDs cgroup limit is used (Automated)
[WARN] * PIDs limit not set: shop
Section C - Score
[INFO] Checks: 4
[INFO] Score: -4
-b turns off colour, -i shop limits the scan to containers whose name contains shop, and -c
picks four checks by id. All four warn:
- 5.5: privileged mode gives the container every capability and the host’s devices.
- 5.11: with no memory limit, one leaking container can exhaust the host.
- 5.26: without
no-new-privileges, a setuid binary inside can still raise privileges. - 5.29: with no PID limit, a fork bomb in one container takes down the others.
The score is +1 per PASS and -1 per WARN. The 2>/dev/null hides a harmless quirk: v1.6.1 parses
docker images output, Docker 29 changed its columns, and the script prints error: no such object: 98.4MB lines for each image size before the checks run. The checks themselves use
docker inspect and are unaffected.
Fix the run flags and re-scan
None of the four needs code changes, only how the container is started:
$ docker rm -f shop
shop
$ docker run -d --name shop --memory 256m --pids-limit 100 --security-opt no-new-privileges nginx:1.29-alpine
f8c78168dfa101b73490f5dbfddef348fcbae6dde982fc866522f9547d911100
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_5,check_5_11,check_5_26,check_5_29 2>/dev/null | sed -n '/Section A/,$p'
Section A - Check results
[PASS] 5.5 - Ensure that privileged containers are not used (Automated)
[PASS] 5.11 - Ensure that the memory usage for containers is limited (Automated)
[PASS] 5.26 - Ensure that the container is restricted from acquiring additional privileges (Automated)
[PASS] 5.29 - Ensure that the PIDs cgroup limit is used (Automated)
Section C - Score
[INFO] Checks: 4
[INFO] Score: 4
$ docker exec shop wget -qO- http://127.0.0.1/ | grep -o "<title>.*</title>"
<title>Welcome to nginx!</title>
Four PASSes, score 4, and nginx still serves its page. It never needed --privileged: it binds
port 80 as root inside its own namespace and then drops to the nginx user, which the default
capability set allows. That is the usual story with privileged mode. It is added to make an error
go away, and the real requirement is one capability or none.
The whole runtime section
Four checks were the ones that mattered most. The full section shows what is left:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c container_runtime 2>/dev/null | grep -E '^\[(WARN|NOTE)\] [0-9]|^\[INFO\] (Checks|Score)'
[WARN] 5.2 - Ensure that, if applicable, an AppArmor Profile is enabled (Automated)
[WARN] 5.12 - Ensure that CPU priority is set appropriately on containers (Automated)
[WARN] 5.13 - Ensure that the container's root filesystem is mounted as read only (Automated)
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[NOTE] 5.23 - Ensure that docker exec commands are not used with the privileged option (Automated)
[NOTE] 5.24 - Ensure that docker exec commands are not used with the user=root option (Manual)
[WARN] 5.27 - Ensure that container health is checked at runtime (Automated)
[INFO] Checks: 32
[INFO] Score: 16
Of 32 checks, five still warn. They aren’t equal. A read-only root filesystem (5.13) is a real
reduction in what an attacker can change. A health check (5.27) and CPU shares (5.12) are
operational hygiene. The restart policy (5.15) is a judgement call. AppArmor (5.2) can’t pass on
Docker Desktop at all: its VM offers seccomp but no AppArmor, so the container has no AppArmor
profile to report. On an Ubuntu server, Docker applies its docker-default profile and 5.2 passes.
Run the container section locally and the host sections on the real servers.
Practice
- Start a second container with
--read-onlyalone to clear 5.13. What happens to nginx?
Solution
$ docker run -d --name shop-ro --read-only nginx:1.29-alpine >/dev/null
$ docker ps -a --filter name=shop-ro --format '{{.Status}}'
Exited (1) 2 seconds ago
$ docker logs shop-ro 2>&1 | tail -2
2026/10/14 15:05:45 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx needs to write its cache and PID file. A read-only root filesystem breaks it until you give it writable scratch space for exactly those paths, which is the next task.
- Clear 5.12, 5.13 and 5.27 on
shop-rowhile keeping the four fixes from the lesson.
Solution
Mount tmpfs for the two paths nginx writes, set CPU shares and add a health check:
$ docker rm -f shop-ro
shop-ro
$ docker run -d --name shop-ro --read-only --tmpfs /var/cache/nginx --tmpfs /run --memory 256m --pids-limit 100 --security-opt no-new-privileges --cpu-shares 512 --health-cmd "wget -q -O /dev/null http://127.0.0.1/ || exit 1" nginx:1.29-alpine >/dev/null
$ sleep 35
$ docker ps --filter name=shop-ro --format '{{.Status}}'
Up 35 seconds (healthy)
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop-ro -c check_5_12,check_5_13,check_5_27 2>/dev/null | sed -n '/Section A/,$p'
Section A - Check results
[PASS] 5.12 - Ensure that CPU priority is set appropriately on containers (Automated)
[PASS] 5.13 - Ensure that the container's root filesystem is mounted as read only (Automated)
[PASS] 5.27 - Ensure that container health is checked at runtime (Automated)
Section C - Score
[INFO] Checks: 3
[INFO] Score: 3
The root filesystem is read-only, yet nginx is up and healthy. Anything an attacker writes now lands in a tmpfs that vanishes on restart.
- Why can 5.2 pass on an Ubuntu server but not on Docker Desktop? Find the evidence with
docker info.
Solution
$ docker info --format '{{.OperatingSystem}} | {{.SecurityOptions}}'
Docker Desktop | [name=seccomp,profile=builtin]
Only seccomp is listed. On an Ubuntu host with AppArmor enabled the list also contains
name=apparmor, and Docker attaches the docker-default profile to every container, which is
what 5.2 checks for. A benchmark result is always a statement about a particular host.
- With both
shopandshop-rorunning, what does-i shopscan? How do you scan onlyshop?
Solution
-i is an extended regular expression matched against container names, so shop matches both.
Neither container sets a restart policy, so check 5.15 shows it:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i shop -c check_5_15 2>/dev/null | grep -E '^\[WARN\]'
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[WARN] * MaximumRetryCount is not set to 5: shop-ro
[WARN] * MaximumRetryCount is not set to 5: shop
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro local/docker-bench:1.6.1 -b -i '^shop$' -c check_5_15 2>/dev/null | grep -E '^\[WARN\]'
[WARN] 5.15 - Ensure that the 'on-failure' container restart policy is set to '5' (Automated)
[WARN] * MaximumRetryCount is not set to 5: shop
Anchor the pattern. Adding -x ro doesn’t help: in v1.6.1 the script checks -i first and ignores
-x whenever -i is set, so -i shop -x ro still scans both. Read the container names in the
WARN lines before trusting a result.