Skip to content

Repository files navigation

portdiff

portdiff - what is actually reachable on this machine, and what changed since yesterday

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.

Install

npx portdiff audit          # no install
npm install -g portdiff     # or keep it around

Needs 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.

Watching for changes

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.

What it reports

Event When Severity
exposure_change a port stayed up but its bind widened, e.g. 127.0.0.10.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.

The ledger

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 list

Direction 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.

Having a model interpret the findings

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 nothing

Command 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"] } }

Sending it somewhere

{
  "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.

Prometheus

portdiff metrics > /var/lib/node_exporter/textfile_collector/portdiff.prom

A 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".

Docker

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 audit

GitHub Action

Fail 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.

Configuration

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.

Commands

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.

Running it continuously

Cron is enough:

*/5 * * * * portdiff scan --quiet

With --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.

What this is not

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.

Privacy

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.

License

MIT

About

A zero-dependency port watcher for Linux: plain-language alerts when a host's listening ports change, plus an 'audit' that correlates bind scope + firewall + Docker to say what is actually reachable.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages