What is actually reachable on this machine, and what changed since yesterday.
ss -tlnp lists what is listening. It does not tell you which of those a
stranger can open. A 0.0.0.0 bind behind a dropping firewall is closed; a
container port your firewall claims to block is wide open, because Docker writes
its own netfilter rules ahead of the ufw chain. portdiff correlates the bind
address, the firewall and Docker, and ranks what is left.
$ sudo npx portdiff audit
3 of 61 listening ports are reachable from outside this network.
61 listeners · 3 internet · 18 local network · 34 firewalled · 6 host-only · 0 unidentified
read: firewall ufw · docker 12 published · public address yes
Worth a look, worst first:
89 THE INTERNET tcp/6379 (redis)
docker container "cache" (redis:7-alpine)
published by container "cache", which writes its own firewall rules ahead
of the host's, so the host firewall does not stop this
· reachable from outside this network
· Redis: the default build has no password and can often write files
· the host firewall says this is blocked, but the container publishes it anyway
54 local network tcp/3389 (ms-wbt-server)
xrdp as root in xrdp.service
bound to every interface and the ufw rules allow it
· reachable from the local network
· RDP is a perennial brute-force target
· bound to a wildcard, so it will also answer on interfaces this host gains later
No baseline, no config, and no daemon. It says something useful on the first run. Exit code is 2 when anything is internet-reachable, 1 for local-network only.
It never probes anything. Every verdict comes from local state, and when a
rule cannot be read it says unknown rather than guessing. Telling you a port
is firewalled when it is not would be the worst thing this tool could do.
npx portdiff audit # no install
npm install -g portdiff # or keep it aroundNeeds Node 20+ and ss (iproute2). No compiled dependencies, no runtime
dependencies at all. The state is two plain files.
Run it as root. Firewall rules and other users' processes both need it; without
root portdiff still works and tells you exactly which parts of the reading it
could not make.
The other half: keep a baseline and report the diff. This is the part that runs on a timer.
$ portdiff scan
CRITICAL tcp/3000 (hbci) exposure_change loopback → public (wider)
node server.js (pid 1041, user deploy, myapp.service)
bound to 0.0.0.0: reachable from every interface on this host
ledger: no entry (nobody wrote this one down)
WARN tcp/9999 (distinct) new_port newly listening
python3 http.server (pid 2213, user sam)
bound to 127.0.0.1: reachable only from this host
ledger: 2026-07-31 open, temporary file server for a demo (sam)
Exit code is 0 when nothing changed, 1 for warnings, 2 for anything critical, so cron and CI can act on it without parsing the text.
| Event | When | Severity |
|---|---|---|
exposure_change |
a port stayed up but its bind widened, e.g. 127.0.0.1 → 0.0.0.0 |
critical when wider, info when narrower |
new_port |
something started listening that was not in the baseline | warn |
lost_port |
a known listener stopped answering | warn |
owner_change |
same port, different program (a restart of the same program is silent) | warn |
flapping |
a port that keeps appearing and disappearing | info |
exposure_change is the one most tools miss. If you key a baseline on port
numbers alone. This is the obvious way to write this, but a service moving from
localhost to every interface produces no diff at all, because the port was
listening before and is listening now.
A new port has to survive two consecutive scans before it is reported, and a missing one has to be gone for three. The first run ever is silent: it learns the baseline instead of alerting on everything you already had.
Every port event is equally suspicious until someone says otherwise. The ledger is where you say otherwise: an append-only note of the ports you opened or closed on purpose:
| 2026-05-11 | 9100 | open | metrics exporter for the dashboard | rae |
| 2026-07-31 | 9999 | open | file server for a demo | sam | 7d |
| 2026-06-04 | 4000 | close | retired the old staging app | rae |
It is a plain markdown table so it can live in your wiki, your notes repo, or next to your runbook, and still be readable and diffable. JSONL works too, for ledgers written by a machine.
portdiff ledger add 9999 open "file server for a demo" --expires 7d
portdiff ledger listDirection matters: an entry saying you closed 4000 does not explain 4000
listening again, and portdiff will say so rather than wave it through. Entries
can expire, so a note about a week-long dev server stops explaining that port
next month.
Anything the ledger does not cover is reported as nobody wrote this one down.
Over time that becomes the only line you actually have to read.
Optional, off unless you ask for it. The detection, the attribution and the severity are all decided before any model is involved. What a model adds is the sentence you actually wanted:
$ portdiff scan --explain
CRITICAL tcp/3000 (hbci) exposure_change loopback → public (wider)
node server.js (pid 1041, user deploy, myapp.service)
bound to 0.0.0.0: reachable from every interface on this host
ledger: no entry (nobody wrote this one down)
llm: investigate. The app moved off localhost, so the admin UI is now
reachable from the LAN; nothing in the ledger says that was intended
try: ss -tlnp sport = :3000
The model is a narrator over evidence, never the thing that decides what is
true. It receives the bundle you can print with --show-prompt and must answer
in a fixed schema: a verdict, one sentence, a confidence, and at most one
read-only command. Anything else is discarded, including a verdict for a port
that was not in the scan. If the model is unreachable, wrong, or returns prose,
the findings are unchanged and print without a verdict.
See what would be sent before sending anything:
portdiff explain 3000 --show-prompt # prints the exact payload, sends nothingCommand lines are redacted first: $HOME becomes ~, --token-style flags
lose their value, and long opaque strings are replaced. Read
PRIVACY.md before turning this on.
Providers: pick one in the config or with --provider:
| Provider | Needs | Notes |
|---|---|---|
anthropic |
npm i @anthropic-ai/sdk, ANTHROPIC_API_KEY |
Default model claude-opus-5; set model for a cheaper one |
openai |
OPENAI_API_KEY |
Also reaches anything OpenAI-compatible via baseUrl |
ollama |
a local Ollama | Nothing leaves the machine |
command |
a program of yours | Gets JSON on stdin, returns JSON on stdout |
The command provider is the escape hatch: if you already have a CLI that can
talk to a model, portdiff does not need to know how.
{ "llm": { "enabled": true, "provider": "command", "command": ["my-llm", "--json"] } }{
"notify": [
{ "type": "ntfy", "topic": "my-server-ports", "minSeverity": "warn" },
{ "type": "telegram", "token": "…", "chatId": "…", "minSeverity": "critical" },
{ "type": "webhook", "url": "https://example.com/hook", "minSeverity": "warn" }
]
}Messages are written for a lock screen: the port and what changed, capped at
eight lines, critical raised to a priority that gets through a quiet phone.
portdiff metrics > /var/lib/node_exporter/textfile_collector/portdiff.promA file for node_exporter's textfile collector rather than an HTTP endpoint. A tool whose job is to complain about listening ports should not open one of its own.
portdiff_listeners{reachability="internet"} 3
portdiff_exposure_score{proto="tcp",port="6379",reachability="internet",service="redis",container="cache"} 89
portdiff_firewall_readable{backend="ufw"} 1
portdiff_running_as_root 1
The last two matter: they tell a dashboard how much of the reading was measured and how much was inferred, so an alert can distinguish "nothing is exposed" from "nothing could be read".
Auditing the host from a container needs the host's network and PID namespaces. Otherwise, you are auditing the container, which is not the question anyone is asking:
docker run --rm --network host --pid host \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
ghcr.io/anilsarginn/portdiff auditFail a job when a machine, or the runner itself, exposes more than it should:
- uses: anilsarginn/portdiff@v1
with:
fail-on: internet # internet | lan | never
ignore: "22,tcp/32768-60999"The findings land in the job summary as a table.
Optional, at ~/.config/portdiff/config.json:
{
"ledger": ["~/notes/port-ledger.md"],
"ignore": ["udp/5353", "tcp/32768-60999"],
"thresholds": { "confirmScans": 2, "lostScans": 3, "cooldownMs": 600000 },
"notify": [{ "type": "webhook", "url": "https://example.com/hook", "minSeverity": "warn" }],
"llm": { "enabled": false, "provider": "anthropic", "model": null, "redact": [] }
}ignore accepts bare ports, proto/port, ranges, and proto/*. The ephemeral
range is the usual first entry on a desktop, where things like mDNS and
KDE Connect open short-lived high ports constantly.
portdiff audit [--all] [--top N] [--markdown] [--json]
what is reachable, worst first
portdiff scan [--json] [--list] [--quiet] [--exit-zero] [--explain]
diff against the baseline
portdiff watch [--interval 60s] keep scanning, report only what changes
portdiff metrics Prometheus text for a textfile collector
portdiff list every listening socket right now
portdiff explain <port> everything known about a port
portdiff events [--limit N] replay what has been recorded
portdiff ledger list | add <port> <open|close|transient> <reason>
portdiff baseline show | reset
State lives in ~/.local/state/portdiff (or /var/lib/portdiff when run as
root); --state-dir moves it.
Cron is enough:
*/5 * * * * portdiff scan --quietWith --quiet it prints nothing when nothing changed, so cron mails you only on
a real diff. packaging/ has a systemd service and timer that do the same.
It watches your own listening sockets. It is not an intrusion detection system, it does not inspect traffic, and it never scans anyone, including itself. It cannot see your router's port forwarding, so "reachable from the internet" means "this host holds a public address and nothing local stops it", and the report says so. It also never touches the machine: no restarts, no firewall rules, no killing processes. It reports, you decide. A monitor that acts on its own findings is a different and much riskier tool.
portdiff is entirely local by default. It reads /proc and runs ss; nothing
leaves the machine unless you configure a webhook. See PRIVACY.md
for exactly what is collected and what any optional integration would send.
MIT
