This is the script from the open-ports guide, on its own page
The reasoning behind it — why ss -e beats sudo for identification, how docker-proxy hides the real application, what to do when there is no ss at all, and how to probe one port from outside — is in the full guide: List Open Ports on Linux. This page is the copy-paste home for the audit script and what it printed when run here.
A port list you read once and close is a photograph. The listener that matters is the one that was not in the photograph: the http.server a developer left on 8099, the container that published a port after a compose change, the shell an intruder bound last night. None of them announce themselves. This script takes the photograph every hour and tells you when the picture changed.
The Script
Save as ports-audit.sh. It needs ss (iproute2) and getent (glibc), both present on any Linux box from the last decade. No root required — see the FAQ for what root adds.
What Does the Output Look Like?
Run here as an unprivileged user on 2026-09-10. Notes go to stderr, the CSV goes to stdout, so > ports.csv captures the data and leaves the commentary on the terminal:
The first twelve of those 35 rows, proto,address,port,service,owner:
Three kinds of owner appear in one run, and that is the point of the -e flag. openclaw-gatewa and localsend_app are process names — those sockets belong to the user running the script (the name is truncated to 15 characters by the kernel, not by the script). cgroup:docker.service and cgroup:ollama.service are systemd units: those sockets belong to root, so -p cannot name the process, but the cgroup identifies the service anyway. The service column comes from /etc/services; unknown on port 3000 or 11434 is normal, because the name column is a lookup table, never an identity.
How Does --diff Catch a New Listener?
Each run moves the last current.csv to previous.csv before writing a new one, so --diff is a whole-line set difference between two files. The second run, a second later, found nothing:
Then a throwaway python3 -m http.server 8099 --bind 127.0.0.1 & and a third run with ALERT_CMD pointed at a file. Exit code 3, one report on stderr, and the identical report appended to the file:
Kill the server and run once more: 35 sockets, exit 3 again, and the same line under GONE:
A vanished listener is alert-worthy too. A database that stopped listening is an outage that a "new ports only" audit would never mention.
Why Is the Sort Order Fixed?
export LC_ALL=C and the explicit sort -t, -k1,1 -k3,3n -k2,2 -k5,5 -u exist because the diff is textual. If Tuesday's cron run sorted under en_US.UTF-8 and Wednesday's under C — cron environments are minimal and drift — every line would move and every run would alert. Fixing the locale and the key order means the only thing that can change a line is a real change to a socket. The -u drops any exact duplicate line, so one socket can never count twice.
How Do I Schedule It?
Hourly, from root's crontab if you want process names for every socket, from any user's if the cgroup is enough:
>/dev/null discards the CSV on stdout; stderr still reaches cron's mail, so keep 2>&1 off unless you want the not-root note every hour. ALERT_CMD receives the report on stdin and can be anything that reads it — mail, a curl to a Slack webhook, tee -a /var/log/ports-changes.log. The exit codes are the contract for anything wrapping it:
| Exit | Meaning |
|---|---|
| 0 | plain run, or --diff with no change |
| 3 | --diff found a listener that appeared or vanished |
| other | ss or mkdir failed under set -e |
Once the audit names a port you cannot explain, the kill process on port script takes it from PID to SIGTERM to SIGKILL. And the one-file port list is still the right tool when you need to read the whole picture by eye rather than diff it.
Running an audit is a snapshot; keeping one running unattended for a year is a different job — a lock so two hourly runs cannot race on current.csv, a log that proves it fired, an alert path that fails loudly. That layer is packaged once as bashlib.sh in The Production Bash Toolkit.
Frequently Asked Questions
Why does the owner column show cgroup:docker.service instead of a process name?
Without root, ss -p only names processes you own. The -e flag adds the systemd cgroup for every socket regardless, so the script falls back to that. cgroup:docker.service means a docker-proxy for a published container port; docker ps shows which container is behind it. Run the script as root to get the process name on every row.
How do I get an email when a new port opens?
Set ALERT_CMD to any command that reads stdin — ALERT_CMD='mail -s "listener change on $(hostname)" you@example.com' — and run with --diff from cron. The NEW/GONE report is piped to that command only when the listener set changed since the previous run, so a quiet host sends nothing.
What does exit code 3 from ports-audit.sh mean?
The set of listening sockets changed since the previous run: something appeared or vanished. Exit 0 means no change, or a plain run without --diff. A cron wrapper or CI step can branch on the code without reading the report.
Does this script need root?
No. ss lists every listening socket unprivileged; root only adds the process name for sockets owned by other users. Non-root runs show the owning systemd unit (cgroup:) or uid instead, which identifies almost every listener on a systemd host.
Why does the same port appear twice, once for 0.0.0.0 and once for [::]?
Those are two sockets: one bound to all IPv4 interfaces and one to all IPv6 interfaces. Many daemons open both. Each is its own line because each is its own listener, and a change to either one belongs in the diff.
Part of the bash snippets collection
Related Scripts
- List All Open Ports on Linux — the read-it-by-eye version:
ss -tlnp,ss -ulnp, andlsofin one run - Kill Process on Port — from an unexplained port to a stopped process, SIGTERM first
- File Permissions Security Audit — the filesystem half of the same recurring review