Skip to content

feat(snitchwatch): confined Snitchwatch blocklists in the daemon patch - #87

Closed
bearyjd wants to merge 1 commit into
fix/snitchwatch-bridge-socket-wantsfrom
feat/snitchwatch-daemon-blocklists
Closed

bearyjd wants to merge 1 commit into
fix/snitchwatch-bridge-socket-wantsfrom
feat/snitchwatch-daemon-blocklists

Conversation

@bearyjd

@bearyjd bearyjd commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #86. This adds daemon-side support for Snitchwatch blocklists (#45 PR B) to the OpenSnitch patch. The patch grows from 35 to 44 files, sha256 c1ac91ac….

What the daemon accepts

  • A lists.domains/lists.ips operand from the UI channel, but only:
    • via CHANGE_RULE;
    • in a z00-blocklist:<list> rule whose data is exactly /var/lib/snitchwatch/blocklists/<list>/<kind>;
    • up to 64 such rules.
  • AskRule replies and every other lists operand are refused, as before. The notification allowlist is still CHANGE_RULE/DELETE_RULE only (now confirmed as policy).

How the files are read

  • Confined reader: a component-wise openat walk with O_NOFOLLOW that refuses FUSE, FIFOs and anything other than a regular, single-link file.
  • Caps: 80 MiB per file, 1M accepted entries per file (4M raw lines).
  • Budget: a shared 640 MiB, enough for the contract's 2M hosts plus one list reloading. The daemon also sets a soft Go memory limit of 896 MiB unless GOMEMLIMIT is set.
  • Over budget: the list keeps its last good version, retries with backoff (30 s up to 10 min) and logs an error. The bridge is not told. That was the owner's decision on 2026-10-08; a log line counts as "loud" for now.
  • Reloads: rate-limited per path (30 s).
  • Unchanged rules: a replaced or reloaded rule that hasn't changed keeps its loaded list, so it is never briefly empty. Monitors start only after the rule is committed, and a stopped or displaced monitor never reads.
  • Parser: normalises entries (lowercase, trailing dot stripped, canonical IP form) and allows _. It refuses empty or hyphen-edged labels. In ips lists it refuses multi-field lines, loopback and unspecified addresses.

Threat model (wording agreed with Snitchwatch)

Upstream's list parser panicked on a malformed line such as a bare 127.0.0.1. Such a line can come only from:

  • a compromised bridge;
  • anything running as snitchwatch;
  • an edited or leftover file.
    The honest bridge writes only 0.0.0.0 <host> or canonical IPv4 lines, atomically (tmp + rename), with no comments. It does not resend unchanged rules.

Review

Five security and five code review rounds (opus). Notable findings, all fixed:

  • Round 1: the parser panic.
  • Round 2: the cap bypass and budget accounting.
  • Round 3: budget sizing against the contract; monitors piling up on the read slot.
  • Round 4: a disabled-then-deleted rule leaked its budget.
  • Round 5: a retry storm after a refusal.
    Final state: security APPROVE (confirmed with re-run probes), code APPROVE. The executor's mutation tests killed every guard added in the last two rounds except two, both documented as redundant backstops or timing-dependent.

Known and accepted:

  • An honest delete and re-add of the same list within 30 s leaves it empty for up to 30 s.
  • Reload does not hand lists over (there is a comment; the bridge can't reach it).
  • Two concurrent always↔temporary Replaces can still delete each other's file. That is upstream behaviour.

Test plan

  • just test-snitchwatch-daemon-patch: PASS. It applies the patch to upstream b404c4c, runs gofmt on the patched files and vet, and runs go test -race for root, netfilter, nftables, ./rule (124 s) and ./ui, plus the plain pass and the C harness. Confined tests are required (SNITCHWATCH_REQUIRE_CONFINED_TESTS=1).
  • Python suites (test-snitchwatch-image-build-daemon.py with 44 patched files, test-snitchwatch-image-build.py, test-snitchwatch-system.py); shellcheck; just check
  • CI green
  • r10 image + VM gate on Snitchwatch c088132: PASS (R10-VM-ACCEPTANCE-RESULT.json):
    • subscribe to a small list: "Rule installed", and a listed host is blocked;
    • ls -la modes; 0 AVCs;
    • a bridge restart doesn't resend and mtime is unchanged;
    • unsubscribe clears the list.

Snitchwatch #45 PR B lets the system bridge install blocklist rules. The
44-file OpenSnitch patch now accepts a lists.domains/lists.ips operand from
the UI channel only via CHANGE_RULE, only for a z00-blocklist:<list> rule
bound to /var/lib/snitchwatch/blocklists/<list>/<kind>, and at most 64 of
them; AskRule replies and any other lists operand are refused.

Those files are read through a confined reader (component-wise openat with
O_NOFOLLOW, no FUSE, regular single-link files, 80 MiB and 1M entries per
file) under a 640 MiB budget that admits the contract's 2M hosts plus one
reload, with a soft 896 MiB Go memory limit unless GOMEMLIMIT is set. A list
over budget keeps its last good version, retries with backoff and logs an
error. Reloads are rate-limited per path, a replaced or reloaded unchanged
rule keeps its loaded list, and stopped or displaced monitors never read.
Upstream's list parser panicked on a bare-IP line; only a compromised
bridge, something running as snitchwatch, or an edited or leftover file can
write one (the honest bridge writes only '0.0.0.0 <host>' or IPv4 lines).

Five security and code review rounds. The patch gate now sets
SNITCHWATCH_REQUIRE_CONFINED_TESTS=1 so those tests fail instead of
skipping when no non-FUSE temporary directory exists.
@bearyjd

bearyjd commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Included via #92 (merge commit af32644): this PR's commits are in main as part of the stacked merge; the r12 image was VM-accepted at 21adea3.

@bearyjd bearyjd closed this Oct 9, 2026
@bearyjd
bearyjd deleted the feat/snitchwatch-daemon-blocklists branch October 10, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant