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: ___________