A network security audit reviews your server's firewall state, allow rules, port exposure and network policy to find the misconfigurations that let traffic reach services it should never touch. This page documents the 6 checks in the CtrlOps Firewall & Network audit, in the order the audit runs them.
Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.
Key takeaways
Six checks separate a filtered network perimeter from a wide-open one. Each takes two to five minutes to verify by hand, which is fine for one server and stops being fine somewhere around the third.
- Two checks are rated HIGH: firewall state and Docker firewall bypass. If you only have five minutes, do those two.
- Four need root: firewall state, the allow-rule audit, the Docker port check and the outbound policy. Open ports and IPv6 coverage run as any user.
- The whole audit takes about 14 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 minutes by hand.
- Docker silently punches through your firewall. A container publishing on
0.0.0.0bypasses UFW entirely, and most teams find this out after a breach rather than before.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Firewall state | HIGH | No active firewall, unfiltered inbound traffic | Yes |
| 2 | Firewall allow rules | MEDIUM | Non-web ports open from Anywhere | Yes |
| 3 | Docker firewall bypass | HIGH | Containers publishing on all interfaces | Yes |
| 4 | Open ports | MEDIUM | High-risk services exposed externally | No |
| 5 | IPv6 coverage | MEDIUM | IPv6 traffic bypassing the firewall | No |
| 6 | Outbound policy | LOW | Default allow-outgoing enables exfiltration | Yes |
ufw status numbered does.Check 1: Is a firewall active?
A server without an active firewall accepts all inbound traffic on every port. Every service listening on an external interface is reachable by anyone on the internet, whether you meant it to be public or not. The firewall is the first filter between the network and your applications, and without it the only thing between an attacker and your database is whether the database happens to require a password.
The audit checks UFW, firewalld, nftables and raw iptables in that order. For iptables it inspects the INPUT chain specifically, which is the detail that matters on any container host: iptables -S dumps every chain, and Docker installs FORWARD and DOCKER-USER rules on every machine it runs on. Counting those as a firewall would report PASS for a box whose INPUT policy is ACCEPT with no ingress filtering at all.
How to check manually
# UFW
sudo ufw status
# firewalld
sudo firewall-cmd --state
# nftables
sudo nft list ruleset | grep table
# iptables, INPUT chain only
sudo iptables -S INPUT| Result | When | What it means |
|---|---|---|
| PASS | UFW active, firewalld running, an nftables ruleset present, or the iptables INPUT policy is DROP/REJECT or carries explicit drop rules | Something is filtering inbound traffic. The tool and the reason are named in the result. |
| FAIL | A firewall tool is installed but not active, or the INPUT policy is ACCEPT with no drop rules | Inbound traffic is unfiltered. Allow SSH, then enable the firewall. |
| SKIP | No firewall tool is installed, or root was unavailable and the state could not be read another way | Install ufw, or re-run with sudo. |
Two things about this check are worth knowing. First, it distinguishes "no firewall installed" from "firewall installed but inactive", and the second is both more common and more dangerous, because it creates confidence rather than a known gap: someone installed UFW during initial setup, never enabled it, and moved on.
Second, without root the check does not simply give up. If systemctl is available it asks systemctl is-active ufw (or firewalld), which any user can run, and reports PASS or FAIL from that with a note that listing the rules still needs root. It only SKIPs when there is no systemctl to ask, or when the firewall is iptables, whose chains genuinely cannot be read unprivileged.
How to fix it
# Install UFW (Debian/Ubuntu)
sudo apt install ufw
# Allow SSH BEFORE enabling. Order matters.
sudo ufw allow ssh
sudo ufw enable
# Verify
sudo ufw status verboseufw enable over SSH, and the connection drops the moment the default deny policy takes effect. If you moved SSH off port 22, allow that port by number rather than by the ssh service name.Check 2: Are any non-web ports open to the world?
An allow rule that accepts traffic from Anywhere on a non-web port means that service is reachable by every IP address on the internet. On ports 80 and 443 that is the point. On a database port, a Redis instance, an admin panel or an internal API, it is an exposure that outlives the "just for today" reason it was created.
The audit reads UFW's numbered rule list, counts the ALLOW rules, and flags any of them sourced from Anywhere that is not on port 80 or 443. Counting rules alone could only ever produce a PASS, which is why the check looks at scope rather than volume.
How to check manually
sudo ufw status numberedLook for ALLOW rules where the source column reads Anywhere or Anywhere (v6) on ports other than 80/tcp and 443/tcp.
| Result | When | What it means |
|---|---|---|
| PASS | Every ALLOW rule is either on port 80/443 or scoped to a specific source address | The total allow-rule count is reported. Nothing non-web is open to the world. |
| WARN | One or more ALLOW rules accept traffic from Anywhere on a non-web port | The affected ports are listed. Scope each to a source address unless it is genuinely meant to be public. |
| SKIP | Root was unavailable, no firewall is active, or the active firewall is not UFW | Each reason is reported separately. Rule-level auditing is automated for UFW only. |
This warns rather than fails because "open to the world" is not automatically wrong. A public API on port 8080 is a deliberate decision. The audit cannot tell that apart from a debugging rule someone left behind at 2am, so it surfaces the list and leaves the judgement to you.
How to fix it
# Add the scoped replacement FIRST
sudo ufw allow from 203.0.113.0/24 to any port 5432 proto tcp
# Then delete the wide-open rule, using the number from `ufw status numbered`
sudo ufw delete 5sudo ufw status numbered after every delete: removing a rule renumbers everything below it, so a second ufw delete 5 will not hit what you expect.Check 3: Are Docker containers bypassing the firewall?
Docker's default networking writes its own iptables rules in the FORWARD chain, where UFW's INPUT rules never see them. A container started with -p 3306:3306 publishes MySQL on every interface, reachable from the internet, even though UFW has no rule allowing port 3306 and ufw status looks perfectly clean.
This is the single most common "but I have a firewall" surprise in production, and it is the reason this check is rated HIGH while the rule audit above it is MEDIUM. The audit reads every running container's port bindings and flags any published on 0.0.0.0 or [::].
How to check manually
docker ps --format '{{.Names}} {{.Ports}}'Look for 0.0.0.0:PORT->PORT or [::]:PORT->PORT rather than 127.0.0.1:PORT->PORT.
| Result | When | What it means |
|---|---|---|
| PASS | No running container publishes a port on all interfaces | Container ports are either bound to 127.0.0.1 or not published at all. |
| WARN | One or more containers publish on 0.0.0.0 or [::] | The container names are listed. Rebind to 127.0.0.1 unless the port is meant to be public. |
| SKIP | Docker is not installed, or the daemon could not be queried | Querying the daemon needs root, sudo, or membership of the docker group. |
The check is marked as needing root, but that is the fallback rather than the requirement: it tries docker ps directly first, so a user in the docker group gets a real result without sudo. That is worth knowing because being in the docker group is itself effectively root on the host.
It warns rather than fails for the same reason as check 2: a reverse proxy container publishing 80 and 443 on all interfaces is exactly right. The finding is the list, not the verdict.
How to fix it
# Instead of publishing on every interface:
docker run -p 3306:3306 mysql
# Bind to loopback:
docker run -p 127.0.0.1:3306:3306 mysqlFor Compose, the same change goes in the ports mapping:
ports:
- "127.0.0.1:3306:3306"ufw deny 3306 will close a port the container published. The fix has to happen at the container level: bind to 127.0.0.1, or set "iptables": false in /etc/docker/daemon.json and take on managing the rules yourself. The second option is a real commitment, not a config toggle, because container networking then stops working until you write the rules Docker was writing for you.Check 4: Which ports are listening on external interfaces?
Every port listening on a non-loopback address is attack surface. The audit lists TCP and UDP listeners on external interfaces and fails on a specific set of high-risk ports: telnet (23), FTP (21), RDP (3389), VNC (5900 and 5901), memcached (11211), unauthenticated rsync (873), NFS (2049) and the RPC portmapper (111).
UDP matters as much as TCP here, which is why the check reads both. An exposed memcached, DNS or NTP socket is not only an access risk, it is a reflection amplifier that lets someone DDoS a third party from your IP address and your bandwidth bill.
How to check manually
# TCP listeners on external interfaces
ss -tln | grep -v '127.0.0.1\|::1'
# UDP listeners on external interfaces
ss -uln | grep -v '127.0.0.1\|::1'| Result | When | What it means |
|---|---|---|
| PASS | No external listeners at all, or five or fewer with none on the high-risk list | The TCP and UDP port lists are reported. Confirm each one is intended and keep admin tools on 127.0.0.1. |
| WARN | More than five external listeners, TCP and UDP combined | The full port list is reported. That is a wide surface for one host; close anything not deliberately published. |
| FAIL | Any of ports 23, 21, 3389, 5900, 5901, 11211, 873, 2049 or 111 is reachable externally, over TCP or UDP | These are unauthenticated or legacy-plaintext services. Firewall them or bind them to 127.0.0.1. |
| SKIP | Neither ss nor netstat is installed | Install iproute2. Nothing was measured, so this is not a pass. |
The five-listener threshold is a drift signal rather than a security threshold. There is nothing dangerous about the sixth port in itself; the point is that hosts accumulate listeners one deployment at a time, and a count climbing past five is usually the visible edge of something nobody decided to do on purpose.
How to fix it
# Block at the firewall
sudo ufw deny 11211
# Better: bind the service to loopback in its own config.
# For memcached, in /etc/memcached.conf:
-l 127.0.0.1Check 5: Does the firewall cover IPv6 traffic?
A server with a global IPv6 address and a firewall filtering only IPv4 is half-firewalled. UFW and iptables treat the two address families as separate rulesets, so every rule you wrote has no effect on IPv6 traffic unless IPv6 support was explicitly turned on. Someone scanning your IPv6 address reaches every service your IPv4 rules block. Most cloud providers hand out IPv6 addresses by default, so this is not an edge case.
The audit checks whether the host has a global IPv6 address at all. If it does, it reads /etc/default/ufw for IPV6=yes. If that file does not exist, meaning this is not a UFW host, it warns and asks you to verify by hand.
How to check manually
# Does this host have a global IPv6 address?
ip -6 addr show scope global
# Is UFW filtering IPv6?
grep '^IPV6=' /etc/default/ufw| Result | When | What it means |
|---|---|---|
| PASS | No global IPv6 address exists, or /etc/default/ufw has IPV6=yes | IPv6 is either absent or covered by the ruleset. |
| FAIL | A global IPv6 address exists and /etc/default/ufw says anything other than yes | IPv6 traffic is completely unfiltered. Set IPV6=yes and reload. |
| WARN | A global IPv6 address exists and there is no /etc/default/ufw to read | This is not a UFW host. Verify that ip6tables or nft is filtering v6 traffic yourself. |
| SKIP | iproute2 is not installed | Whether this host has IPv6 could not be determined. Reporting PASS here would claim there is no IPv6 when the truth is that nothing was measured. |
One limit worth stating: this check reads the UFW config file, not the live ruleset. IPV6=yes on a UFW that is installed but not active still passes here. Check 1 is what catches that, which is why the two are read together rather than in isolation.
How to fix it
# In /etc/default/ufw, set:
IPV6=yes
# Reload the ruleset
sudo ufw disable && sudo ufw enableufw disable drops all filtering for the moment between the two commands, so confirm your SSH allow rule exists before you run it. Afterwards, run sudo ufw status and check that each allow rule now appears twice, once plain and once marked (v6). Rules written against a specific IPv4 address do not get an IPv6 twin automatically, so those need writing by hand.Check 6: Does the outbound policy allow everything?
UFW's default outbound policy is allow, which means any process on the server can connect to any address on the internet. For a web server pulling packages and calling APIs, that is usually fine. For a host handling sensitive data, it means a compromised process can exfiltrate to any endpoint or phone home to a command-and-control server with nothing in the way.
The audit reads UFW's verbose status for the default outgoing policy. deny or reject passes. The allow default warns rather than fails, because restricting egress on a general-purpose server breaks legitimate traffic and needs careful allow-listing first.
How to check manually
sudo ufw status verbose | grep -i outgoing| Result | When | What it means |
|---|---|---|
| PASS | The outgoing policy is deny or reject | Egress is restricted to explicitly allowed destinations. The policy is named in the result. |
| WARN | The outgoing policy is allow | The permissive default. A compromised process can reach any address it likes. |
| SKIP | UFW is not installed, root was unavailable, UFW is inactive, or the default policy could not be read | Each reason is reported separately. An inactive UFW has no egress policy to audit, which belongs to check 1 rather than this one. |
How to fix it
# Set the default
sudo ufw default deny outgoing
# Then allow what the host genuinely needs
sudo ufw allow out 53 # DNS
sudo ufw allow out 80/tcp # HTTP
sudo ufw allow out 443/tcp # HTTPS
sudo ufw allow out 123/udp # NTPsudo ufw logging high, leave it for a week, build the allow list from what the logs actually show, and only then flip the default. Egress filtering is the last line of defence after a compromise, and it is also the firewall change most likely to cause an outage, which is exactly why the audit warns instead of failing.What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds. This audit reads firewall configuration and network state from inside the host. It does not test reachability from the outside.
- Rule-level auditing beyond UFW. Firewalld zones, nftables rulesets and raw iptables chains are detected and reported, but not parsed rule by rule. Checks 2 and 6 skip with a note on those hosts rather than guessing.
- No external scan. Check 4 reads the local listener list via
ss. It never connects from outside, so a cloud security group or network ACL that blocks a port upstream of the host is invisible to it, in both directions: a port listed here may be unreachable in practice, and a port blocked here may still be reachable through a load balancer. - No application-layer inspection. A port that appears in the listener list may be perfectly well protected by application authentication. This audit does not test whether the service behind a port asks for credentials. The application security checks cover database credentials specifically.
- Docker networking beyond port bindings. Check 3 catches containers publishing on all interfaces. It does not inspect network mode, bridge configuration, or inter-container communication rules.
- Live IPv6 ruleset. Check 5 reads UFW's config file, not
ip6tables -S. A host whose IPv6 rules are managed outside UFW warns rather than passing.
The manual audit problem at scale
Running these 6 checks on one server takes about 15 minutes, including reading each output and deciding whether it passed, warned or failed. For one server that is manageable.
Then multiply it. A freelancer with 8 client servers: two hours. An agency running 20 staging and production environments: over five hours. And this is not a once-a-year job. Every time someone adds a container, opens a port for debugging or provisions a new VPS, the network surface changes underneath you.
The manual version looks like this:
- SSH into server #1
- Run 6 commands across UFW, iptables,
ssand Docker - Read each output and interpret whether it passed, warned or failed
- Write the findings down somewhere
- SSH into server #2
- Repeat steps 2 to 4
- Continue for every server in the fleet
- Compare the results to work out which server needs attention first
- Go back to the worst one and start fixing
No score. No report you can hand a client. No record of last month's state, so no way to prove a fix improved anything. Just terminal scrollback and a spreadsheet you will forget to update.
The Docker blind spot is what makes this category worse than the others. A developer adds a container with -p 5432:5432 on a staging box, ufw status shows nothing unusual, and PostgreSQL is open to the internet until somebody happens to run docker ps. Nothing in the firewall output ever mentions it. That is the same server management drift problem that compounds across every audit category, except here the tool you would normally check actively tells you everything is fine.
How CtrlOps runs all 6 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Firewall & Network 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 & 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. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Firewall & Network
Choose the Firewall & Network audit from the catalog. All 6 checks are listed with their descriptions, severity levels and the estimated run time (about 14 seconds in total). Toggle off any check you want to skip.
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 you can sort and filter
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.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. That delta is the thing a spreadsheet never gave you: evidence that the fix landed.
The whole cycle, audit then fix then verify, happens in one window. No switching terminals, no copy-pasting commands out of a checklist, no scoring by hand.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 6 checks | About 2 hours | About 2 minutes |
| Catch a Docker port bypass | Only if you thought to run docker ps | Automatic |
| Get a scored report | Not available | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
| Re-audit after fixing | Another 2 hours | Another 2 minutes |
Conclusion: securing your server perimeter
A network security audit is not a one-time setup task, it is an ongoing defence against drift. In a modern Linux environment a single docker-compose file or a forgotten debug rule can expose a database engine, a cache store or an admin panel to the public internet, bypassing UFW entirely and without triggering any obvious warning.
Applying these 6 checks systematically, monitoring firewall state, scoping allow rules, locking down Docker bindings, auditing listening ports, covering IPv6 and filtering outbound traffic, is what keeps the perimeter tight. Whether you do it by hand with the commands above or automate it across the fleet with the CtrlOps Security Audit, the thing that matters is that something re-checks.
Once the perimeter is right, the neighbouring checklists cover what sits behind it: file system permissions, SSH and access control, and the application and database layer.
Frequently asked questions
A network security audit checklist is a structured set of checks run against a server's firewall, port exposure and network policy. It covers whether a firewall is active, which ports accept traffic from any address, whether Docker containers bypass the firewall rules, which services listen on external interfaces, whether IPv6 is filtered, and what the outbound policy allows. The goal is to find the network-layer misconfigurations that expose services to unauthorised access.
Docker writes its own iptables rules in the FORWARD chain, which operates independently of the INPUT chain where UFW writes its rules. When a container publishes a port with -p 3306:3306, Docker routes external traffic straight to the container through FORWARD, never touching UFW's INPUT deny rules. The fix is to bind container ports to loopback (-p 127.0.0.1:3306:3306), or to set "iptables": false in /etc/docker/daemon.json and manage every rule yourself.
Telnet (23), FTP (21), RDP (3389), VNC (5900 and 5901), memcached (11211), unauthenticated rsync (873), NFS (2049) and the RPC portmapper (111) should never face the internet. Each is either an unencrypted plaintext protocol, unauthenticated by default, or both, and those are exactly the ports this audit fails on. Database ports (3306, 5432, 27017, 6379) belong on the same list in practice, restricted to specific source IPs or bound to 127.0.0.1 unless you have a documented reason to publish them.
Four of the six: firewall state, the allow-rule audit, the Docker port check and the outbound policy. Open ports and IPv6 coverage read public information and run as any user. Two of the four degrade gracefully rather than skipping outright: firewall state falls back to systemctl is-active, which any user can run, and the Docker check works without sudo for anyone in the docker group.
After every infrastructure change: a new container, a service deployment, a firewall rule edit, a new server. Beyond that, monthly is a reasonable production baseline. The driver is configuration drift rather than new vulnerabilities. A port opened for debugging during a 2am incident is still open three months later unless something re-checks it, and nobody remembers opening it.
Inbound (ingress) policy controls what traffic reaches your server from the internet. Outbound (egress) policy controls what your server can send out. Most Linux servers default to deny-inbound and allow-outbound. The inbound policy protects your services from unauthorised access; the outbound policy limits what an attacker can do with access they already have, specifically data exfiltration and command-and-control traffic.
It depends what the host handles. On a general-purpose web server the default allow-outgoing is usually acceptable, because too many legitimate things need outbound access: package updates, webhooks, API calls, monitoring agents. On a host processing payments or storing personal data, restricting egress to named destinations is worth the maintenance cost. Either way, start by logging outbound connections for a week and build the allow list from the logs rather than from guesswork.
Because UFW and iptables filter IPv4 and IPv6 as separate rulesets. A server with a global IPv6 address and IPV6=no in /etc/default/ufw has a fully working IPv4 firewall and zero IPv6 filtering, so every service your IPv4 rules block is reachable over IPv6. Most cloud providers assign IPv6 addresses by default, which makes this common rather than exotic.
No. This audit reads configuration from inside the server: firewall state, listener list, Docker port bindings. A penetration test connects from outside and tries to exploit what it finds. They answer different questions. This one answers "is the firewall configured correctly"; a pentest answers "can an attacker actually get through". Run this weekly or monthly, and schedule a pentest annually or after major infrastructure changes.
nmap scans from outside and tells you which ports respond. This audit reads from inside and tells you why they are open: which rules allow them, what is listening, and whether Docker is bypassing the firewall entirely. nmap shows the symptom, this shows the cause. They are complementary: use nmap to confirm what the internet actually sees, and this audit to understand and fix the configuration behind it.
If your workloads run on Kubernetes, a serverless platform such as Lambda or Cloud Functions, or a managed PaaS where you do not control the network layer, this audit does not apply. Those platforms handle network security through security groups, network policies or service mesh rules rather than host firewalls. This audit is for servers you administer over SSH, where the firewall configuration is yours to change.