Skip to content

Audit remediation: cgroup fail-closed + exec race, EINTR, error surfacing, API consistency, test & CI hardening - #12

Merged
nyo16 merged 1 commit into
masterfrom
audit-remediation
Sep 1, 2026
Merged

nyo16 merged 1 commit into
masterfrom
audit-remediation

Conversation

@nyo16

@nyo16 nyo16 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Fixes all 39 findings from the 2026-08-31 project-health audit (2 high, 13 medium, 24 low). Reports under .claude/audit/ (not committed).

Security — shepherd/NIF boundary

  • cgroup isolation fails closed (HIGH). cgroup_setup now checks the cgroup.procs write and flush — migration errors (EPERM/EBUSY/ENOENT) surface at fclose
    behind stdio buffering and previously succeeded silently, leaving the child unconfined. Every failure sends a descriptive MSG_ERROR. A pre-existing cgroup leaf dir is
    fatal (teardown wouldn't be owned) and the dir is created 0700.
  • cgroup attach no longer races execvp (HIGH). The child blocks on a sync pipe after fork and execs only once the shepherd has migrated it into the cgroup, so
    early-forked descendants can't escape limits or cgroup.kill teardown. Async-signal-safe calls only between fork and exec; non-cgroup spawns are byte-for-byte
    unchanged. Applies to both pipe and PTY paths.
  • Pid-reuse guard on direct kills. The Nif.nif_kill fallback in NetRunner.Process and the Watcher probe only fire when the shepherd port is dead; while it
    lives, the shepherd holds the child as a zombie and is the only safe signaller.
  • No uninitialized-heap exposure. nif_read's >64 KiB shrink handles enif_realloc_binary failure via alloc+copy fallback.

Fixed

  • EINTR retried in NIF read(2)/write(2) loops and shepherd I/O — a transient signal could previously wedge a drain loop permanently.
  • Shepherd diagnostics surface: spawn-stage MSG_ERROR now returns {:error, {:shepherd_error, msg}} (was a misleading fd-count mismatch); post-spawn errors are
    logged and recorded in state.
  • Shepherd UDS writes nonblocking with bounded poll(POLLOUT) so a wedged peer can't park the shepherd post-reap and skip cgroup cleanup.

Changed

  • NetRunner.Process.Nif → NetRunner.Nif (internal; ERL_NIF_INIT updated to match).
  • Wire protocol centralized in NetRunner.Process.Protocol — no inline frame bytes remain.
  • Option-validation convention unified: malformed options raise ArgumentError at every entry point; runtime spawn failures return {:error, reason}; stream!/2
    wraps spawn-stage reasons uniformly as %NetRunner.Error{reason: {:spawn_failed, reason}}. Mildly breaking for callers matching the old tuple returns on invalid
    options.
  • Daemon.os_pid/1 answers from cached state (was a blocking double GenServer hop).
  • Full @spec coverage on NetRunner.Process; public Stats.t(); duplicate validator lifted to InputWriter.
  • Docs corrected: --token-fd handshake (never argv), Watcher's deliberate single-SIGTERM/no-escalation design, Layer-3 cleanup via NIF owner monitor (not GC),
    write-budget loop exit, 1 MiB read cap, non-FIFO reader wakeup; LINUX_VERIFICATION.md refs refreshed to function anchors.

Tests (236 → 242 passing)

  • Vacuity/flake fixes: FD-leak margin +30 → +3 with settled baseline, callback-crash sync via assert_receive, self-relative supervisor sampling, relative latency
    probe with absolute floor.
  • Single eventually/2 polling primitive, retrying transient :noproc/:timeout exits.
  • New coverage: PTY resize observed via stty size, Daemon forwarder-death, mid-stream stream! raise, iodata + stats-ledger property, MSG_ERROR receive path over a
    real UDS pair, cgroup fail-closed assertion (:linux_only).

CI / deps

  • Dependabot now covers GitHub Actions; every workflow action SHA-pinned (tag kept as comment).
  • Committed .credo.exs; mix deps.unlock --check-unused gate; musl container pinned by digest.
  • Linux test job delegates /sys/fs/cgroup/net_runner so the cgroup positive path actually executes in CI for the first time.
  • elixir_make "~> 0.10.0" (was ~> 0.9, spanning breaking 0.x minors); stream_data scoped only: :test.

Verification

  • mix format --check-formatted ✓ · mix compile --warnings-as-errors ✓ · mix credo --strict 0 issues · mix dialyzer 0 errors
  • 242 passed (237 tests + 5 properties), 2 excluded :linux_only, stable across 3 seeds (macOS)
  • Shepherd smoke-tested over a live UDS: pipe, PTY, token-fd, and cgroup-gated spawn paths; frame formats byte-identical
  • ⚠️ The cgroup fixes' Linux-only branch was compiled standalone under -Werror on macOS — watch the first Linux CI run, which now exercises the real cgroup2
    positive path.

@nyo16
nyo16 force-pushed the audit-remediation branch 2 times, most recently from ded757b to 2c401b2 Compare September 1, 2026 02:41
Security (shepherd/NIF):
- cgroup isolation fails closed: cgroup.procs write/flush checked, failures
  surface as MSG_ERROR; pre-existing leaf dir fatal; mkdir 0700
- sync pipe gates child execvp on cgroup attach (both pipe and PTY paths);
  descendants can no longer escape limits or cgroup.kill teardown
- direct nif_kill / Watcher probe only fire when the shepherd port is dead
  (pid-reuse safety); nif_read realloc failure can't expose uninitialized heap

Fixed:
- EINTR retried in NIF read/write and shepherd I/O
- spawn-stage shepherd MSG_ERROR returns {:error, {:shepherd_error, msg}};
  post-spawn errors logged and recorded
- shepherd UDS writes nonblocking with bounded poll(POLLOUT)

Changed:
- NetRunner.Process.Nif -> NetRunner.Nif; wire protocol centralized in
  NetRunner.Process.Protocol; option validation unified (ArgumentError for
  malformed opts, {:error, _} for runtime failures; stream! wraps uniformly)
- Daemon.os_pid cached; full @SPEC coverage; docs drift corrected
  (--token-fd handshake, Watcher no-escalation, owner-monitor cleanup)

Tests: vacuity/margin fixes, single eventually/2 primitive, new coverage
(PTY resize, forwarder death, mid-stream raise, iodata+stats property,
MSG_ERROR receive path, cgroup fail-closed). 236 -> 242 passing.

CI/deps: Dependabot actions ecosystem, SHA-pinned actions, .credo.exs,
deps.unlock gate, alpine digest pin, delegated cgroup subtree for the
Linux positive path; elixir_make ~> 0.10.0; stream_data only: :test.

Verified: format, compile --warnings-as-errors, credo --strict, dialyzer,
242 tests green (3 seeds).
@nyo16
nyo16 force-pushed the audit-remediation branch from 2c401b2 to 84c1075 Compare September 1, 2026 02:54
@nyo16
nyo16 merged commit 1116a77 into master Sep 1, 2026
15 checks passed
@nyo16
nyo16 deleted the audit-remediation branch September 1, 2026 02:58
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