security-watch — the intrusion and exposure watch¶
Built after the 2026-10-18 Plex cryptominer (see plex-server Log). Procedure: SOP-049. Agent: security (~/.claude/agents/security.md).
Shape¶
| Piece | What | Where |
|---|---|---|
sec-watch |
read-only checks, ntfy on a finding (once, again after 24 h) | ~/.local/bin/sec-watch, cron */5 via cron-run sec-watch |
| router allow-list | external ports the router may forward: 51820 TCP, 51820 UDP |
~/.config/sec-watch/allowed-forwards |
| listener baseline | wildcard listeners reviewed on 2026-10-31 (42; temporary Chrome ports removed) | ~/.config/sec-watch/listeners.baseline; rewrite with sec-watch --baseline after a deliberate change |
| UDP owner rules | exact executable + UID + temporary UDP port range; currently Chrome only | ~/.config/sec-watch/udp-owners.json |
| listener evidence | socket metadata and verified owners for the last 48 finding snapshots; private directory/files 0700/0600 | ~/.local/state/sec-watch/listener-evidence/ |
| state | what was alerted and when; CPU streak | ~/.local/state/sec-watch/state.json |
diun |
image-update alerts: watches every running container's image daily 06:17, ntfy once per new version (first check silent: 62 images baselined 2026-10-18). Read-only on docker.sock, user 1000, read_only, cap_drop ALL, 256 MB; never pulls or restarts |
~/diun/docker-compose.yml (own project, not the torrentvpn stack), data ~/diun/data/diun.db; test: docker exec diun diun notif test |
| alert path | ntfy topic plex-hgviaey (same as alerting); a missing run is caught by cron-watch |
Checks and severities are in SOP-049 (router.forward, host.listener, proc.miner, net.stratum, plex.prefs, plex.codec, plex.version, cpu.sustained, router.unreachable). Router forwards are read over UPnP IGD SOAP at http://192.168.4.1:1900/ipc (eero); a forward not visible through UPnP is not seen.
Tested 2026-10-18¶
Clean run: no findings. Fake xmrig --donate-level process: proc.miner. Fake wildcard listener: host.listener, one live ntfy push, no repeat on the next run, cleared when the listener stopped. Empty allow-list: router.forward for 51820. Plex version label parsed (1.43.4.10903 ≥ fix 1.43.3.10861; 1.42.2.10156 would alert). Not yet seen: a real incident.
Not yet built (owner approval needed)¶
- Egress allow-list (needs root): move Plex off
network_mode: host, publish only 32400, then aDOCKER-USERallow-list. Last step, tested with counters. Hand back as! sudolines. - Falco 0.45.0 (syscall sensor, miner rules, via falcosidekick to ntfy): free and containerised, but needs privileged access on the live server and is untested on kernel 7.0.0-38. Add only after a test and the owner's yes.
-
Attacker IPs
45.194.92.222,217.60.194.116,77.90.185.205are innet.stratum; the owner added an OUTPUT DROP for217.60.194.116(unverified, no root to read iptables). -
2026-10-25 false positive:
net.stratumfired on192.168.4.87:5555, the Insignia Fire TV that Home Assistant's androidtv integration talks to over ADB. Port-only miner matches now skip private IPs (BAD_IPSstill checked). New listeners 8123/6054/18555 are Home Assistant + go2rtc. Source: plex-server Log 2026-10-25.
Listener review 2026-10-31¶
SOP-049 triage confirmed the stale-baseline alert using ss -H -lntupe (socket cgroups), docker inspect homeassistant, and Home Assistant process-to-socket inode mapping:
- TCP 8123/6054: Home Assistant Python; TCP 18555: its go2rtc process.
- UDP 1900/44510/57202/59072: Home Assistant.
- UDP 35823/44516/56222/58684:
cloudflared-plex.service. - UDP 45588/51175:
avahi-daemon.service; UDP 38467:nordvpnd.service. - Additional transient UDP sockets during triage belonged to Chrome (PID 634064).
Saved socket evidence and the previous baseline under ~/.local/state/sec-watch/review-2026-10-31/; refreshed with sec-watch --baseline (48 entries). Follow-up sec-watch --check: ok: no findings. No unexpected UPnP-visible router forwards or other watched indicators. This initial port-number refresh did not address temporary browser sockets; the follow-up below does.
Owner-aware UDP follow-up 2026-10-31¶
Repeated six-port alerts were transient and had closed before interactive inspection. The historical monitor retained only port numbers, so their precise ownership cannot be recovered. The earlier live review did verify Chrome's changing UDP sockets.
sec-watch now takes one ss -tulnHpe snapshot. A narrow rule allows UDP ports 32768–60999 only when every visible owning PID/fd still matches the snapshot's socket inode, UID 1000, and exact executable /opt/google/chrome/chrome (root-owned). No names, cgroups alone, unknown owners, TCP sockets or all-high-port exceptions are trusted. Six previously reviewed Chrome port numbers were removed from the numeric baseline, and future baseline refreshes exclude verified temporary Chrome sockets. Other services keep their explicit port baseline and continue to alert on new ports. This rule identifies the application, not whether the application itself is compromised.
New listener findings include verified executable/PID/UID, or owner unverified, and cron saves socket/cgroup/inode evidence before notification even if notification is suppressed. Evidence is bounded to 48 snapshots. --check does not write evidence or send notifications. Failed socket collection now fails the job rather than looking clean.
Proof: python3 ~/.local/bin/test_sec_watch.py (6 tests, including rotating ports, spoofed/inaccessible/mixed owners, TCP, IPv4/IPv6, evidence permissions/retention, baseline exclusion). A real temporary wildcard Python UDP socket on 40199 was detected with its executable and evidence survived closure; follow-up sec-watch --check clean. No test notification sent. Implementation/review stayed local because the available native helper route could not enforce the SOP-025 tool restrictions; no helper model ran.
Change impact¶
Changing a forward at the router, adding a published port to any compose file, or installing a service that listens on the network will raise router.forward or host.listener. Update the allow-list or baseline in the same session and say why in the project note.