Docker's default networking behaviour punches holes in your host firewall without telling you. A container publishing a port on 0.0.0.0 bypasses UFW entirely, because Docker writes its own iptables rules and they are evaluated before your DENY rules. This page documents the 5 checks in the CtrlOps Network and Supply Chain audit, in the order the audit runs them.
The audit covers two surfaces that share a theme: what your containers expose to the network, and what you actually know about the code inside them. Every threshold below is transcribed from the audit script itself, not from general advice.
Key takeaways
Five checks, three HIGH, and the first one is the finding that surprises experienced administrators most often.
- Three are rated HIGH: published ports, secrets in environment variables and pending engine updates. Two are MEDIUM.
- Four need root, or docker group membership. The engine update check reads package metadata, which any account can do.
- Docker bypasses UFW by design. Check 1 is not looking for a bug, it is looking for the documented behaviour that surprises people: your firewall rules and Docker's iptables rules are not the same rule set.
- Only check 4 can fail on Debian and Ubuntu. Separating security updates from ordinary ones is an apt capability, so on RHEL family, SUSE and Alpine every pending container-stack update reports as a warning regardless of what it fixes.
- The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Published container ports | HIGH | Ports bound to all interfaces, past the host firewall | Yes |
| 2 | Inter-container connectivity | MEDIUM | Containers on the default bridge with ICC on | Yes |
| 3 | Secrets in env | HIGH | Credentials visible in docker inspect | Yes |
| 4 | Docker engine updates | HIGH | Pending docker, containerd or runc patches | No |
| 5 | Unpinned running images | MEDIUM | latest or untagged images in production, and no scanner | Yes |
Check 1: Are containers publishing ports on all interfaces?
When you run docker run -p 8080:80, Docker binds port 8080 on 0.0.0.0, which includes your public interface. It enforces that with iptables rules inserted ahead of the chains UFW manages, so the host firewall can show DENY for that port while the container behind it answers the internet.
The audit reads NetworkSettings.Ports for every running container and flags any host binding on 0.0.0.0 or ::, naming the container and the binding it found.
How to check manually
docker ps --format '{{.Names}} {{.Ports}}' | grep '0.0.0.0:'| Result | When | What it means |
|---|---|---|
| PASS | No container publishes a port on all interfaces | Every published port is bound to a specific address. |
| WARN | One or more containers publish on 0.0.0.0 or :: | Each container is named with the binding it published. Docker iptables rules bypass UFW, so these are internet-reachable despite host firewall DENY rules. Bind to 127.0.0.1 or install ufw-docker. |
| SKIP | No running containers to evaluate | Nothing to check. |
This is a HIGH severity check that warns, and the reason is that publishing on all interfaces is correct for some containers: a reverse proxy is supposed to answer the world. The audit cannot know which container is your front door, so it names every one and leaves the judgement with you.
How to fix it
# Binds to every interface, including the public one
docker run -p 8080:80 nginx
# Binds to localhost only
docker run -p 127.0.0.1:8080:80 nginxservices:
app:
ports:
- "127.0.0.1:8080:80"Put anything that genuinely needs to be public behind a reverse proxy that terminates TLS, and bind the application containers to localhost. For a host-wide fix that makes Docker respect UFW:
sudo wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart dockerdocker ps on a production host right now. If you see 0.0.0.0:5432->5432 next to a database container, that database is answering the internet and your UFW deny rule is not protecting it. This is the single most common way a container host ends up with an exposed data store.Check 2: Are containers sharing the default bridge with inter-container communication?
The default bridge network connects every container attached to it with inter-container communication enabled. Any container on it can reach any other by IP, with no access control, no segmentation and no logging. One compromised container is then one hop from all the others.
The audit lists which running containers are attached to the bridge network, then reads com.docker.network.bridge.enable_icc on that network. Containers on the default bridge only warn when ICC is actually on.
How to check manually
docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{end}}'
docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'| Result | When | What it means |
|---|---|---|
| PASS | No container sits on the default bridge, or containers are on it but ICC is disabled | Two distinct passes. The first means user-defined networks are in use. The second names how many containers are on the bridge and confirms inter-container traffic is blocked. |
| WARN | Containers share the default bridge with ICC enabled | The count and up to five container names are listed. Any one of them can reach the others, so move them onto scoped user-defined networks. |
| SKIP | The daemon is not reachable, or no containers are running | Nothing to evaluate. |
Anything started by Docker Compose gets a user-defined network automatically, so this check tends to fire on hosts where containers were started by hand with docker run and never moved.
How to fix it
Give each group of containers a network scoped to what it actually needs to talk to:
services:
web:
networks: [frontend]
api:
networks: [frontend, backend]
db:
networks: [backend]
networks:
frontend:
backend:web reaches api, api reaches db, and web cannot reach db at all. To disable ICC on the default bridge globally, add this to /etc/docker/daemon.json and restart the daemon:
{
"icc": false
}Check 3: Are secrets passed as container environment variables?
Environment variables are the most common way to pass secrets to containers and the least protected. docker inspect prints every one in plain text to anyone with Docker access, which includes every member of the docker group. Child processes inherit them, some runtimes log them on crash, and they stay in the container metadata long after the process has exited.
The audit reads Config.Env for every running container and matches names against PASSWORD, PASSWD, SECRET, API_KEY, ACCESS_KEY, TOKEN and PRIVATE_KEY, including prefixed forms. It ignores variables whose value is empty or an obvious placeholder such as changeme or example, and it reports variable names only, never values.
How to check manually
docker ps -q | while read c; do
name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
keys=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$c" \
| grep -iE '^(.*_)?(PASSWORD|PASSWD|SECRET|API_?KEY|ACCESS_?KEY|TOKEN|PRIVATE_?KEY)=' \
| sed 's/=.*//' | head -3 | tr '\n' ',')
[ -n "$keys" ] && echo "$name: $keys"
done| Result | When | What it means |
|---|---|---|
| PASS | No secret-like environment variable names are found | Nothing matched the patterns. Verify manually if your naming convention is unusual, because this check matches names rather than detecting secret-shaped values. |
| WARN | Secret-like environment variables are detected | Each container is named with up to three of its matching variable names, never the values. Move to Docker secrets or a secrets manager. |
| SKIP | No running containers to evaluate | Nothing to check. |
Both directions of error are worth knowing. A variable called DB_CONN holding a full connection string will not match, and a variable called TOKEN_PATH holding a harmless path will. Read the named variables rather than the count.
How to fix it
# Compose with file-based secrets
services:
app:
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./db_password.txtThen read the file rather than the variable in the application:
import os
path = os.environ.get('DB_PASSWORD_FILE', '')
if path and os.path.exists(path):
with open(path) as f:
db_password = f.read().strip()The file path in the environment is the part that is safe to expose. The credential itself stays on a mounted file that only the container can read.
docker inspect on one production container. If you can read a database password in the Env array, so can everyone in the docker group, and that group is root-equivalent. This is the check that most often turns up a credential nobody realised was readable.Check 4: Are Docker engine updates pending?
Docker, containerd and runc share the host kernel with every container. A vulnerability in runc, such as the Leaky Vessels family in CVE-2024-21626, gives an attacker inside a container full root on the host. These are not theoretical: they ship with public exploits and get used.
The audit queries whichever package manager the host has, apt, dnf, yum, zypper or apk, for pending updates to docker, containerd and runc.
How to check manually
# Debian and Ubuntu
apt-get -s upgrade 2>/dev/null | grep '^Inst ' | grep -iE 'docker|containerd|runc'
# RHEL family
dnf check-update 2>/dev/null | grep -iE 'docker|containerd|runc'
# Alpine
apk version -l '<' 2>/dev/null | grep -iE 'docker|containerd|runc'| Result | When | What it means |
|---|---|---|
| PASS | No pending docker, containerd or runc updates | The container stack is current as far as the package metadata already on the host goes. |
| FAIL | Pending updates are marked as security updates | Only reachable on apt-based hosts, where the source is visible. runc escape CVEs give host root, so this is the one to patch first. |
| WARN | Pending container-stack updates that are not identified as security updates | The count is named. On RHEL family, SUSE and Alpine every pending update lands here, because those package managers do not separate security updates in this query. |
| SKIP | Docker is not installed, or no supported package manager was found | Nothing to check. |
Two caveats carry real weight. A warning on a Rocky or Alma host is not evidence that nothing security-related is waiting: run dnf updateinfo list security to find out. And the check never refreshes package metadata, because every check in this catalog is strictly read-only and apt update writes to disk. Refresh it yourself before trusting a pass.
How to fix it
# Debian and Ubuntu
sudo apt-get update && sudo apt-get upgrade docker-ce containerd.io runc -y
# RHEL family
sudo dnf update docker-ce containerd.io runc -y
sudo systemctl restart dockerRunning containers keep using the old runtime until they are recreated, so finish the job:
docker compose down && docker compose up -dCheck 5: Are running containers using unpinned images?
A running container on latest or on an untagged image means you do not know exactly what is in production, and the next pull or restart can change it silently. Combine that with no scanner anywhere on the host and you have unknown code running with no way to find out whether it is vulnerable.
The audit counts running containers whose image ends in :latest or carries no tag or digest at all, then looks for trivy, grype, docker scout, clair or snyk on the host. The two signals are graded together, which is why this check has three distinct warnings.
How to check manually
docker ps --format '{{.Image}}' | grep -cE '(:latest$|^[^:@]+$)'
for tool in trivy grype snyk; do command -v "$tool" >/dev/null && echo "$tool available"; done
docker scout version >/dev/null 2>&1 && echo "docker scout available"| Result | When | What it means |
|---|---|---|
| PASS | Images are version-pinned and at least one scanner is installed | Reproducible deploys plus the ability to check them. The scanners found are named. |
| WARN | Unpinned images, no scanner, or both | Three distinct warnings. Unpinned with a scanner asks you to pin. Pinned with no scanner asks you to add scanning to the pipeline. Unpinned with no scanner names both, and is the combination worth fixing first. |
| SKIP | The Docker daemon is not reachable | Nothing to evaluate. |
This check reads what is running, which is what distinguishes it from the floating tag check in the Image Vulnerabilities audit. That one reads images on disk, including ones nothing has started for months. This one reads production.
How to fix it
# Resolve the digest for what you are running now, then pin to it
docker pull nginx:1.27-alpine
docker inspect --format '{{index .RepoDigests 0}}' nginx:1.27-alpine
# Install a scanner
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/binservices:
web:
image: nginx:1.27.1-alpineWhat this audit does not cover
This audit reads container networking, secret handling and image hygiene on running containers. It does not check:
- Container-level hardening. Privileged mode, capabilities, the runtime user and read-only filesystems are the Container Hardening audit.
- Daemon-level controls. Socket exposure, rootless mode and log rotation are the Daemon and Socket audit.
- Image contents. CVE scanning, embedded secrets and EOL base images are the Image Vulnerabilities audit.
- Runtime resource limits. Seccomp, AppArmor and memory, CPU and PID caps are the Runtime and Resources audit.
- Host firewall configuration. UFW rules, iptables chains and cloud security groups are the Firewall and Network audit.
The manual audit problem at scale
These five checks move between container inspection, network inspection, package manager queries and a PATH search. On one host that is about four minutes. On ten servers with different firewall setups and package managers it is forty minutes of context switching, and the most important finding, a port bound to 0.0.0.0 that your firewall is not protecting, is the one easiest to scroll past.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 5 checks | About 30 minutes | About 95 seconds |
| UFW bypass detection | Manual comparison per container | Automatic, per container and per binding |
| Package manager support | Remember the right command | Auto-detects apt, dnf, yum, zypper and apk |
| Get a scored report | Not available | Automatic |
| Re-audit after fixing | Another 30 minutes | Another 95 seconds |
How CtrlOps runs all 5 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Network and Supply Chain audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Network & Supply Chain
Choose it from the Docker category. All 5 checks are listed with their descriptions, severity levels and the estimated run time, about 12 seconds in total.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table naming the container and port combination for every exposed binding
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here: changing a port binding means recreating the container, and updating runc means restarting the daemon.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the score move. Check 4 is the one that reappears on its own, because new container-stack updates land regardless of what you change on the host.
Conclusion
The theme of this audit is the gap between what you believe is exposed and what actually is. The firewall says the port is closed and the container answers anyway. The secret is "in the environment", which is another way of saying it is in docker inspect output for everyone in the docker group. The image is "the current one", which is only true until the next pull.
Three of these five are HIGH severity and each has a fix that takes minutes. The CtrlOps Security Audit runs the set in about 12 seconds per server and names the specific container, port and variable in every finding.
The neighbouring checklists cover the other container layers: container hardening, daemon and socket, image vulnerabilities, volume permissions and runtime and resources.
Frequently asked questions
Yes. Publishing a port makes Docker write iptables rules that are evaluated before the chains UFW manages, so a container started with -p 8080:80 answers the internet even when UFW denies 8080. Bind to 127.0.0.1 explicitly, or install ufw-docker so Docker's rules respect your firewall policy.
Bind published ports to 127.0.0.1 and put anything public behind a reverse proxy. Use user-defined bridge networks instead of the default bridge, scoped so that containers can only reach what they need. Disable inter-container communication on the default bridge with "icc": false for anything still attached to it.
Because they are visible in docker inspect in plain text, inherited by every child process, present in container metadata for the container's whole life and logged by some runtimes on crash. Anyone with Docker access can read them, and Docker access is root-equivalent. Use Docker secrets, a mounted secret file, or a secrets manager, and pass the path rather than the value.
On Debian and Ubuntu: apt-get -s upgrade | grep '^Inst ' | grep -iE 'docker|containerd|runc'. On RHEL family: dnf check-update | grep -iE 'docker|containerd|runc'. Run sudo apt update or sudo dnf makecache first, because the audit reads existing package metadata and never refreshes it.
Because apt exposes whether an update comes from a security pocket and the others do not in this query. On apt hosts the audit can separate security updates, which fail, from ordinary ones, which warn. On RHEL family, SUSE and Alpine everything lands as a warning. A warning there is not proof that nothing security-related is pending: check dnf updateinfo list security yourself.
Yes. It reads every running container's port bindings and flags any published on 0.0.0.0 or ::, naming the container and the specific binding. It reports this as a warning rather than a failure because some containers, a reverse proxy in particular, are supposed to answer the world, and only you know which one that is.
ICC lets any container on the default bridge reach any other by IP with no access control. If one container is compromised, every other container on that bridge is one hop away. Disable it with "icc": false in daemon.json, or, better, move containers onto user-defined networks scoped to the connections they actually need.
No. It matches variable names against known patterns such as PASSWORD, SECRET, API_KEY and TOKEN, so a credential in a variable called DB_CONN or SERVICE_CFG does not match. It also ignores empty values and placeholders like changeme. Read it as a fast sweep for the obvious cases rather than as proof that no secrets are in your environment.
This one reads the images that running containers were started from, which is production. The image audit reads the images stored on disk, including ones nothing has started for months. A host can pass one and warn on the other, and the version that matters for incident response is this one.
Check 1, if anything other than your reverse proxy is publishing on all interfaces. An exposed database or admin interface is reachable right now, while the other four describe how much damage a compromise causes once it starts. Check 4 is second when it reports a security update, because a runc escape hands over the host.