Skip to content

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 a DOCKER-USER allow-list. Last step, tested with counters. Hand back as ! sudo lines.
  • 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.205 are in net.stratum; the owner added an OUTPUT DROP for 217.60.194.116 (unverified, no root to read iptables).

  • 2026-10-25 false positive: net.stratum fired on 192.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_IPS still 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.