Watch who your machine talks to without capturing a packet
Shipped
netwatch 0.1.0 is a small CLI that snapshots every live network connection on the machine, groups it by the process that owns it and the network block it reaches, and classifies each flow against a baseline the user built themselves. It reads connection metadata only; there is no packet capture anywhere in it, and no command in it needs sudo. This guide walks through building that watcher, because the interesting part is not the parsing; it is the two boundaries the tool commits to before the first line of code.
Two boundaries before any code
The first boundary is what you read. lsof with -i “selects the listing of files any of whose Internet address matches the address specified”; with no address it lists every Internet connection on the box, per process, as ordinary user. That is metadata: who is connected to whom, on which port, in which state. A packet capture would give you payloads too, and that is exactly why you should not reach for it here: payloads can contain credentials, cookies, and other people’s traffic, which turns a casual audit tool into something you have to secure. Metadata cannot leak what it never saw.
The second boundary is what you say. A connection to an address you do not recognize looks identical whether it is Apple push notifications or something you should worry about; nothing in the snapshot can tell you which. So the classifier gets exactly two words: known, meaning a baseline entry you wrote covers this flow, and unrecognized, meaning none does. There is no dangerous. The tool reports facts and leaves the judgment to the person reading it, and it enforces that split in code, as you will see.
Capture the snapshot
One shell command, no elevation:
mkdir -p ~/netwatch-demo && cd ~/netwatch-demo
{ echo '===== lsof ====='; lsof -nP -i;
echo '===== ps ====='; ps -axo pid=,comm=; } > capture.txt
wc -l capture.txt
-n and -P matter: the man page says -n “inhibits the conversion of network numbers to host names” and -P does the same for port names. You want raw IPs and numeric ports, both because it is faster and because a name lookup at capture time would itself generate network traffic in the middle of your snapshot. The ps section is there because lsof truncates and sometimes mangles command names; more on that in the gotchas.
Parse it into flows
Save this as netwatch.js. It walks the lsof section, keeps established TCP connections with a remote endpoint, and joins in the clean process name from ps:
import { readFileSync } from 'node:fs';
export function parseCapture(text) {
const [, lsofPart = '', psPart = ''] = text.split(/^===== (?:lsof|ps) =====$/m);
const names = new Map();
for (const line of psPart.split('\n')) {
const m = line.match(/^\s*(\d+)\s+(.+)$/);
if (m) names.set(m[1], m[2].split('/').pop());
}
const flows = [];
for (const line of lsofPart.split('\n')) {
// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
const m = line.match(/^(\S+)\s+(\d+)\s+\S+.*\s(\S+)->(\S+?):(\d+)\s+\((\w+)\)\s*$/);
if (!m) continue;
const [, cmd, pid, , rhostRaw, rport, state] = m;
const rhost = rhostRaw.replace(/^\[|\]$/g, '');
flows.push({ process: names.get(pid) ?? cmd, pid, rhost, rport, state });
}
if (flows.length === 0) {
throw new Error('capture holds zero connections; refusing to report an empty snapshot as all clear');
}
return flows;
}
That final throw is a design decision, not defensiveness. A snapshot taken while nothing was talking, or a capture command that silently failed, produces the same empty file as a perfectly quiet machine, and only one of those deserves a clean bill of health. An empty capture means “I saw nothing”, so the tool refuses to dress it up as “nothing is wrong”.
Name the network, classify the flow
The classifier needs two small pieces. First, a factual lookup that names the obvious address families. Add both remaining pieces to the same netwatch.js; the private IPv4 ranges come from RFC 1918 and the IPv6 link-local prefix fe80::/10 from RFC 4291:
export function networkOf(host) {
if (host.startsWith('fe80:')) return 'link-local (the LAN)';
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return 'private (RFC 1918)';
if (host === '127.0.0.1' || host === '::1') return 'loopback';
return 'public internet';
}
That string is a fact about the address, the same thing whois would tell you a block is registered as. It never changes a flow’s status. Second, the classifier itself, plus the refusal that keeps the two-word vocabulary honest:
export function classify(flows, baseline) {
return flows.map((f) => ({
...f,
network: networkOf(f.rhost),
status: baseline.some((e) => f.rhost.startsWith(e.host)) ? 'known' : 'unrecognized',
}));
}
const args = process.argv.slice(2);
for (const banned of ['--verdict', '--severity', '--danger']) {
if (args.some((a) => a.startsWith(banned))) {
console.error(`${banned} is refused: a flow is known or unrecognized, never dangerous`);
process.exit(2);
}
}
const baseline = args[1] ? JSON.parse(readFileSync(args[1], 'utf8')) : [];
const rows = classify(parseCapture(readFileSync(args[0], 'utf8')), baseline);
for (const r of rows.sort((a, b) => a.status.localeCompare(b.status)))
console.log([r.status.padEnd(12), r.process.padEnd(18), `${r.rhost}:${r.rport}`.padEnd(40), r.network].join(' '));
Refusing the flag looks theatrical until the first time someone, including future you, wires this into a report and wants one column of editorial. The vocabulary is the contract; the refusal is what keeps it from eroding one flag at a time.
Run it, then teach it your normal
node netwatch.js capture.txt
Here is the real output from the capture on my machine, trimmed to the first rows:
unrecognized identityservicesd fe80:13::aa35:70b7:7318:8427:1024 link-local (the LAN)
unrecognized claude 216.24.57.7:443 public internet
unrecognized claude 160.79.104.10:443 public internet
On a first run everything reads unrecognized, and that is the correct starting point, not an alarm; it means “not in your baseline”, and you have no baseline yet. Pick a flow from your own output that you can vouch for and write its entry; on my machine that Anthropic API connection is the Claude app doing its job:
echo '[{"host": "160.79.104.", "note": "Anthropic API (the Claude app)"}]' > baseline.json
node netwatch.js capture.txt baseline.json
Re-run and that flow now reads known. The note field is the part your future self needs: a bare host prefix is precise and unreadable, and a baseline nobody can interpret is one nobody will dare to prune.
Gotchas
Plain lsof output breaks on command names with spaces. The columns are whitespace-separated, and macOS process names like Google Chrome Helper shatter across them. The demo regex expects digits in the second column, so a spaced name shifts the columns and the line silently fails to match: those connections just vanish from your report, which is worse than a parse error because nothing tells you. netwatch itself uses lsof -F field output, which the man page describes as output “for processing by another program”, one field per line with a type prefix, immune to whatever a process named itself. If you extend the parser past a demo, switch to -F first.
lsof and ps disagree about process names. In a real capture, lsof reported the Claude desktop app’s command as 2.1.228, a version string, while ps -axo pid=,comm= gave the usable name. That is why the capture includes the ps section and the parser prefers it; keep the lsof name only as the fallback.
An empty capture will happen to you, and it will look like success. The first time the capture command has a typo, or runs in a sandbox with no network visibility, you get zero flows and a report with nothing to complain about. The flows.length === 0 throw exists because “I saw nothing” and “there is nothing” are different claims, and only code can be trusted to keep them apart at 5pm on a Friday.
Sources
- lsof manual page — the
-i,-n,-P, and-Fbehavior the capture and parser rely on - RFC 1918, Address Allocation for Private Internets — the private IPv4 ranges the network lookup names
- RFC 4291, IP Version 6 Addressing Architecture — the
fe80::/10link-local prefix
Changelog
- feat(netwatch): analyze live network connections, grounded, no hunch-verdicts (#210) (ed05c76)