Skip to content

feat: filter sandboxed system calls and stop core dumps - #1258

Merged
crypt0rr merged 1 commit into
mainfrom
feat/process-hardening
Oct 8, 2026
Merged

crypt0rr merged 1 commit into
mainfrom
feat/process-hardening

Conversation

@crypt0rr

@crypt0rr crypt0rr commented Oct 8, 2026

Copy link
Copy Markdown
Owner

What changes

The last layer of the scanner and notification sandboxes: a seccomp filter for the sandboxed processes, and no core dumps or same-identity debugging for any EdgeWatch process.

Seccomp filter

With Landlock, sandbox-exec now also installs a seccomp filter (--seccomp) on its locked thread, after no_new_privs and Landlock and before it executes the scanner or the notification child. The program inherits the filter. The filter:

  • Refuses with EPERM the calls no scanner or notification process needs:
    • process tracing and memory access: ptrace, process_vm_readv, process_vm_writev, kcmp;
    • io_uring, userfaultfd, perf_event_open, bpf;
    • the kernel keyring;
    • loading kernels and modules;
    • mounts, unshare, setns, chroot;
    • swap, reboot, accounting, quotas, file handles, and syslog;
    • on x86-64, port I/O and uselib.
  • Namespaces: refuses a clone with namespace flags. clone3 fails with ENOSYS, since a filter cannot read its flags; C libraries then use clone, and Go uses clone3 only for cgroup and time namespace options.
  • Architecture: refuses the x32 ABI, and kills the process on a system call of another architecture.

Docker's default profile already refuses most of these calls. The filter keeps them refused under runtimes or profiles that don't, and it adds ptrace and io_uring, which recent Docker profiles allow.

Implementation notes:

  • Syscall tables: amd64 and arm64 are both release architectures. Their syscall numbers live in one compiled table, with no build-constrained files that the diff-coverage gate would reject.
  • Table check: a test compares the table with golang.org/x/sys's generated zsysnum_linux_{amd64,arm64}.go, whatever the host architecture.
  • Detection: the kernel must support SECCOMP_GET_ACTION_AVAIL for the filter's actions.
    • The probes run with the filter first.
    • If they fail with it but pass without it, Landlock applies alone, and the reason is reported.
    • Without Landlock, the filter does not apply, because sandbox-exec installs it.
  • Status: scanner_sandbox.seccomp and notification_sandbox.seccomp (state, reason) appear in edgewatch health and GET /api/v1/status, and the logs. The Overview shows Enforced · NET_RAW · Landlock · seccomp.

No core dumps

main now calls sandbox.HardenProcess() first in every EdgeWatch process. It sets the soft and hard RLIMIT_CORE to zero and clears the dumpable flag:

  • The daemon, which holds the keys, and the notification child, which holds one destination URL, leave no core file.
  • A host core handler (systemd-coredump, apport) honours the zero limit.
  • A non-dumpable process cannot be traced, and its memory cannot be read, by a process of its own identity without CAP_SYS_PTRACE. That matters for UID 0 scanners under a pre-v0.27.0 compose.yaml on a kernel without Landlock.
  • Scanners inherit the zero limit and cannot raise it. execve resets dumpability for them, as before; /dev/fd reopen keeps working.

Compatibility

  • No schema, configuration, or Compose change. scanner.landlock: off also turns off the filter.
  • A runtime that refuses seccomp filters keeps the v0.29.0 behaviour; seccomp.state is then unavailable, with the reason.
  • A crash no longer leaves a core file. To debug a crash, run the binary outside EdgeWatch.

Validation

  • gofmt, go vet ./..., go test -race -timeout=25m ./...
  • New tests:
    • Table: the syscall table matches x/sys for both architectures.
    • Filter logic: a small classic-BPF interpreter evaluates the compiled filter for both architectures on any host. It checks every denied call, allowed calls, a thread clone against namespace clones, clone3, another architecture, and x32.
    • Installed filter: on a locked thread, ptrace, unshare(CLONE_NEWUSER), clone(CLONE_NEWUSER|CLONE_FS) (which the kernel would refuse with EINVAL, never creating a process) and io_uring_setup get EPERM, as do x32 calls. clone3 gets ENOSYS, and getpid still works.
    • Detection: the fallback to Landlock alone, unavailable seccomp, and the disabled cases.
    • Hardening: a hardened test-binary process reports dumpable 0, core limits 0 0.
  • ./scripts/verify-scanner-sandbox.sh on a locally built image now also requires:
    • seccomp:enforced in health for the base, SYN, legacy and notification sandboxes;
    • a running Nmap and the notification child each with one more seccomp filter than the daemon, and core limits 0 0;
    • the daemon's own core limits 0 0;
    • Nmap SYN+UDP and Naabu results unchanged with the filter.
  • npm run lint, npm run build, npm run test:coverage, npm run test:e2e
  • npm --prefix docs run build

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Deploying edgewatch with  Cloudflare Pages  Cloudflare Pages

Latest commit: 98c9e79
Status: ✅  Deploy successful!
Preview URL: https://fcb44552.edgewatch-cpd.pages.dev
Branch Preview URL: https://feat-process-hardening.edgewatch-cpd.pages.dev

View logs

@crypt0rr
crypt0rr marked this pull request as ready for review October 8, 2026 10:28
@crypt0rr
crypt0rr merged commit 60c59a8 into main Oct 8, 2026
15 checks passed
@crypt0rr
crypt0rr deleted the feat/process-hardening branch October 8, 2026 10:29
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