Commit graph

2 commits

Author SHA1 Message Date
Hound
85234842f9 houndd: gate covers writes too; inotify becomes the fallback
Completes Phase 1. The gate now asks for FAN_CLOSE_WRITE alongside the
permission events, so a threat written to disk is quarantined and a
threat being executed is refused — one mechanism, one mark, no
watch-descriptor ceiling and no blind spots outside a configured list.

Verified live. The nicest evidence is an error message:

  $ chmod +x /tmp/hound-live/malware.sh
  chmod: cannot access '/tmp/hound-live/malware.sh': No such file or directory

Hound had already quarantined it. `hound quarantine list` shows the
entry, the clean binary beside it still runs, and CapPrm/CapEff/CapBnd
read 000000000020000e.

Three bugs, each of which looked like working code:

* A file descriptor number is not an identity. The kernel allocates an
  fd per event and recycles the number the moment we close it, so one
  write arrives as FAN_OPEN_PERM on fd 6 and then FAN_CLOSE_WRITE on fd
  6 again. Idempotency keyed on the fd treated the second as a duplicate
  of the first and dropped it — detection ran, matched EICAR, and threw
  the result away. Events now carry a monotonic seq that is never reused.

* rename(2) fails EXDEV across filesystems, and for quarantine that is
  the common case rather than the exotic one: the vault is under
  /var/lib while threats land on /home, in a tmpfs, on a USB stick or
  in a container overlay. Quarantine now falls back to copy-then-unlink,
  unlinking only once the copy is safely down, and seals the stored file
  at 0600 with every execute bit cleared.

* The capability set was too small to do the job. CAP_DAC_READ_SEARCH
  lets us read a threat but not unlink it, so quarantine failed EACCES
  as root. The set is now four capabilities — SYS_ADMIN, DAC_READ_SEARCH,
  DAC_OVERRIDE, FOWNER. DAC_OVERRIDE is close to "write anywhere" and
  that is worth being honest about; an antivirus that quarantines cannot
  avoid it, because the threat is by definition in a directory somebody
  else owns. What the reduction still buys is what it excludes, and
  there is a test asserting SYS_MODULE, SYS_BOOT, SYS_PTRACE, NET_ADMIN,
  NET_RAW, AUDIT_CONTROL and SETUID never creep back in. Narrowing
  further means a separate privileged helper for quarantine.

realtime.rs is now documented as the unprivileged fallback and does not
start when the gate is armed — running both would scan everything twice
and quarantine the same file from two threads.

99 tests pass. HOUNDD_GATE_DEBUG=1 dumps every event and decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:27:34 -05:00
Hound
aa22ddbc38 houndd: execution gate over fanotify, with a fail-open watchdog
FAN_OPEN_EXEC_PERM hands us the open and waits for an answer, so a
binary can be refused before it runs. inotify could only report what
had already happened.

Verified end to end as root against a dedicated tmpfs (examples/
gate-smoke.rs, three phases):

  benign binary          ran      7.2 ms
  EICAR binary           blocked  1.8 ms   never executed
  scanner stalled 5 s    ran      1.6 s    watchdog rescued 3 events

The third phase is the one that matters. A gate that can hold a process
forever is a machine-wedging bug wearing a feature's clothes, so the
watchdog answers ALLOW for anything unanswered past DEADLINE and counts
it. A missed detection is a bad day; a frozen machine ends the product.

Two things this cost, both worth recording:

* Scanning by re-opening the path deadlocks the daemon against itself.
  The open() lands on the watched mount and queues a permission event
  behind the one we are currently answering, and we cannot answer that
  one until we finish this one. Allowing our own pid does not help —
  the thread never gets back to the queue to apply the rule. The gate
  reads through the descriptor the kernel already handed it, with
  pread so the gated process still sees its own file offset. This is
  what hung the first smoke run.

* The watchdog can only rescue events it has been told about, and it
  learns of them when the queue is drained. Scanning on the draining
  thread makes every event behind a slow scan invisible to the
  deadline. Reader and workers are therefore separate threads: the
  reader never blocks on a scan, so every event is registered within
  microseconds of arriving.

Also:

- ScanEngine::scan_bytes — the seam the gate needs, since it must never
  scan by path. Engines that cannot do it return None and simply are
  not usable behind the gate.
- Settings gain exec_gate and exec_gate_paths, defaulting to OFF. It
  needs CAP_SYS_ADMIN and a root-filesystem mark holds every process on
  the box; that is not a default to ship before Phase 2 soak testing.
- ABI constants are defined locally rather than taken from libc, so a
  version bump cannot quietly change what we ask the kernel for.

78 tests pass, up from 57.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:29:01 -05:00