Skip to main content
RunBook Academy

← All checklists in Docker & Containers

QuarterlyNetworking

Checklist: Network exposure review

23 items ·15 critical ·8 warn ·0 info

When to run this

Once a quarter, and additionally after any change that adds a container, a publish flag, a network, or a rule to the host firewall. Budget 45 minutes; most of that is the external scan.

This review answers one question: is what is reachable what you intended? It is not a hardening exercise. It is a comparison between an inventory you build from the host and a probe you run from somewhere else, and the finding is always the gap between them.

What you need before you start

Two machines. The Docker host, and a second machine on the network segment a real client would sit on. The second machine is not optional and cannot be substituted with a shell on the first one.

Set these in the shell on each side:

# On the Docker host
EXT_IF=eth0
CONTAINER=api
IMAGE=registry.example.com/platform/api:REPLACE_ME

# On the probe host
HOST_IP=203.0.113.10
HOST_IP6=2001:db8::10

Why a firewall rule is not evidence

Docker publishes a port by writing a DNAT rule into the nat table. The packet arriving for a published port is rewritten in PREROUTING and then follows the FORWARD path into the container. It does not pass through INPUT.

This matters because INPUT is where ufw puts almost everything, and where most hand-written iptables rules go. The Docker documentation states it plainly: container traffic is routed in the nat table, so packets are diverted before reaching the INPUT and OUTPUT chains that ufw uses.

The practical consequence is a host where ufw status reads 5432 DENY IN Anywhere and nmap from the next rack reports 5432/tcp open postgresql. Both outputs are correct. The ruleset is real and it is simply not on the path.

Rules that do affect container traffic go in DOCKER-USER, which is evaluated before Docker’s own chains. That is why five of the items on this list are about that one chain, and why none of them is allowed to close on the ruleset alone.

Reading the results

Several commands are written so that no output means pass: bind-address, default-bridge-empty, datastores-not-published, edge-only-http, api-socket-no-tcp-listener and api-socket-not-mounted print only the things that are wrong. If one prints a line, that line is the finding.

docker-user-exists is the opposite: output is expected, and the failure looks reassuring. A chain containing only -A DOCKER-USER -j RETURN is the shipped default. It is a chain that exists and restricts nothing, and it is easy to file as evidence that the chain is configured.

Evidence and sign-off

File the external scan output alongside the inventory, both dated. Next quarter’s review is a diff against these two files, so a review with no artefacts is a review that has to start over.

  • Operator: _________________ Date: ___________
  • Probe host used: _________________
  • Reviewer: ________________ Date: ___________

Critical15 items

  1. docker ps -q | xargs -r docker inspect --format '{{.Name}}{{range $p, $c := .NetworkSettings.Ports}}{{range $c}} {{$p}} on {{.HostIp}}:{{.HostPort}}{{end}}{{end}}'
  2. docker ps --format '{{.Names}} {{.Ports}}' | grep -E '0\.0\.0\.0:|\[::\]:'
  3. nmap -Pn -n -sT --open -p 1-65535 "$HOST_IP"   # run this ON $PROBE_HOST, not on the Docker host
  4. sudo iptables -t nat -S DOCKER; sudo iptables -t nat -S PREROUTING
  5. sudo ufw status verbose 2>/dev/null || sudo firewall-cmd --list-all
  6. sudo iptables -S DOCKER-USER
  7. sudo iptables -S DOCKER-USER | tail -3
  8. systemctl is-enabled netfilter-persistent 2>/dev/null || systemctl is-enabled iptables 2>/dev/null; uptime -s
  9. docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{end}}'
  10. docker ps --format '{{.Names}} {{.Image}} {{.Ports}}' | grep -Ei 'postgres|mysql|mariadb|redis|mongo|rabbitmq|elastic|memcached' | grep -F -- "->"
  11. ss -ltnp | grep -E ':(2375|2376)\b'
  12. jq -r '(.hosts // []) | .[]' /etc/docker/daemon.json 2>/dev/null; systemctl cat docker | grep -oE -- "-H [^ ]+"
  13. curl -sS --max-time 5 "https://$HOST_IP:2376/version"   # run ON $PROBE_HOST; must fail the TLS handshake
  14. docker ps -q | xargs -r docker inspect --format '{{.Name}}{{range .Mounts}} {{.Source}}{{end}}' | grep -F "docker.sock"

Warning8 items

  1. docker image inspect "$IMAGE" --format 'exposed={{json .Config.ExposedPorts}}'; docker port "$CONTAINER"
  2. sudo iptables -S DOCKER-USER | grep -c -- "-i $EXT_IF"
  3. docker network ls -q | xargs -r docker inspect --format '{{.Name}} internal={{.Internal}}'
  4. docker ps --format '{{.Names}} {{.Ports}}' | grep -F -- "->" | grep -vE '^(traefik|nginx|caddy|haproxy)'
  5. nmap -6 -Pn -n -sT --open -p 1-65535 "$HOST_IP6"   # run this ON $PROBE_HOST
  6. docker ps --format '{{.Names}} {{.Ports}}' | sort | tee "exposure-$(date +%Y%m%d).txt"; diff exposure-PREVIOUS.txt "exposure-$(date +%Y%m%d).txt"