Skip to content

feat: run Nmap and Naabu in a scanner sandbox - #1250

Draft
crypt0rr wants to merge 2 commits into
mainfrom
feat/scanner-sandbox
Draft

crypt0rr wants to merge 2 commits into
mainfrom
feat/scanner-sandbox

Conversation

@crypt0rr

@crypt0rr crypt0rr commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Nmap, its NSE scripts and Naabu parse responses from the networks they scan, and so far they ran as UID 0 inside the container, with read access to the SQLite database and both encryption keys. This PR runs them in a scanner sandbox:

  • Identity: UID and GID 65532 (edgewatch-scanner), with no supplementary groups.
  • Capabilities: only NET_RAW, plus NET_ADMIN when the container grants it, held as ambient capabilities.
  • Files: the target list and the XML output are passed as inherited file descriptors. The sandboxed process cannot list the UID 0 data directory (mode 0750) or reach config.yaml.

The daemon itself still runs as UID 0. The blocker the docs gave for going non-root, Docker stripping NET_RAW from a non-root container process, doesn't apply here: the daemon switches the identity for each child process. Bind-mount ownership and rollback are therefore unchanged.

How it works

  • internal/sandbox:
    • Detect(mode) checks that the process is euid 0 and holds SETUID, SETGID and KILL. Without KILL, UID 0 cannot signal another UID once capabilities are dropped, so cancellation and timeouts would break.
    • It then runs a short confined probe (edgewatch version as UID 65532 with the ambient capabilities) before enforcing.
    • Confine(cmd) sets the credentials and ambient capabilities and keeps the pty's Setsid/Setctty.
    • InheritFile shares a file with only the needed other-permission bits: 0604 to read, 0602 to write.
    • The probe is skipped inside Go test binaries.
  • Scanner:
    • A confined Nmap gets --privileged, because Nmap otherwise assumes a non-root process cannot send raw packets. The flag goes only into the executed argv; the recorded command fingerprint is computed without it.
    • Nmap writes its XML through /dev/fd/N; Naabu reads its targets through /dev/fd/N.
    • Naabu needs no flag: its privilege check tests for CAP_NET_RAW (verified at the pinned v2.6.1 commit).
  • Config: scanner.sandbox
    • auto (default): confine when possible, otherwise run unconfined with a warning;
    • required: refuse to start the daemon or scan before the database is opened;
    • off: previous behaviour.
  • Visibility:
    • startup log;
    • edgewatch health gains scanner_sandbox (state, process_uid, capabilities, reason), with a warning when scanners run unconfined as UID 0;
    • /api/v1/status reports it to administrators and operators;
    • the Overview shows it in the deployment footprint and warns administrators when scanners run unconfined as UID 0;
    • a daemon that runs as a non-root user is not warned about, because its scanners already run without root.
  • Deployment:
    • compose.yaml adds SETUID, SETGID and KILL;
    • verify-compose-policy.sh now requires that exact set (plus NET_ADMIN for the SYN override), and its tests are updated;
    • the image adds the edgewatch-scanner user and group (65532). It has no setuid or file-capability binaries (checked).

Proof

scripts/verify-scanner-sandbox.sh IMAGE is new and runs in the CI container job and in the release smoke stage. It starts real daemons with real scanners against local listeners:

  • Results: Nmap TCP SYN and UDP scans, and Naabu full-range discovery followed by Nmap confirmation, give identical results with the sandbox enforced and with it off.
  • Running identity: the live Nmap process has Uid 65532, Gid 65532, no groups, and CapEff 0000000000002000 (NET_RAW only).
  • Cancellation: cancelling the scan through the API stops Nmap.
  • File access: UID 65532 cannot list /var/lib/edgewatch or read /etc/edgewatch/config.yaml.
  • Health states: health reports enforced 65532 NET_RAW, then NET_RAW,NET_ADMIN with the SYN override, then disabled when off.

Naabu SYN results aren't compared, because SYN discovery of loopback drops ports at any rate, even unconfined. The SYN sandbox is covered by its capability report and by a manual run: a sandboxed Naabu reported "Running SYN scan with CAP_NET_RAW privileges" and found the open ports.

Compatibility

  • Results: no scan result changes are expected; the check above compares them.
  • Existing deployments: a compose.yaml from an earlier release lacks the three capabilities, so auto keeps scanning unconfined as UID 0 and warns. The docs explain how to add them (container hardening, updates and troubleshooting guides).
  • Single-UID runtimes: a runtime whose user namespace does not map UID 65532 reports the sandbox as unavailable; scanning continues.
  • API additions: the scanner.sandbox config key, scanner_sandbox in the health and status documents, and the edgewatch-scanner user in the image.
  • Version: this is a new config key and a deployment change, so it fits v0.27.0. The docs refer to v0.27.0 in the upgrade note. Not released.

Not covered (documented)

  • The daemon still runs as UID 0.
  • All sandboxed scanners share UID 65532, so a compromised scanner could observe concurrent scans.
  • Scanners keep network access.
  • The notification child process is not yet sandboxed.

Validation

  • gofmt, go vet ./..., and GOOS=darwin go build ./.... As in internal/store, only Linux installs the sandbox hooks; other platforms report it unavailable.
  • go test -race -timeout=25m ./...: every package passed.
    • internal/webui failed once because I rebuilt the frontend while the suite was compiling; it passed on rerun.
    • The new internal/sandbox package has 97% coverage.
  • npm run lint, npm run build, npm run test:coverage: 45 files, 500 tests, gates passed.
  • docker compose config --quiet for both renders; verify-compose-policy.sh base|syn; node --test scripts/verify-compose-policy.test.mjs (100 tests); node --test scripts/release-workflow.test.mjs.
  • npm --prefix docs run build: 1296 links verified.
  • ./scripts/verify-scanner-sandbox.sh against a locally built image: passed twice, about 1 minute per run.

Nmap, its NSE scripts and Naabu parse responses from the networks they
scan, and ran as UID 0 with read access to the database and both
encryption keys. They now start as UID and GID 65532 with no
supplementary groups, keeping only NET_RAW (and NET_ADMIN when the
container grants it) as ambient capabilities. They read their target
list and write their XML through inherited file descriptors, so they
can neither list the UID 0 data directory nor reach config.yaml.

- scanner.sandbox: auto (default) confines scanner processes when the
  container allows it and otherwise runs them unconfined with a
  warning; required refuses to start the daemon or a scan without the
  sandbox; off keeps the previous behaviour.
- At startup EdgeWatch checks for SETUID, SETGID and KILL and runs a
  short confined probe. The bundled compose.yaml adds the three
  capabilities; an older compose.yaml keeps scanning unconfined.
- Nmap is started with --privileged so it uses its ambient NET_RAW;
  scan results and recorded command fingerprints are unchanged.
- edgewatch health and the Overview report the sandbox state, and warn
  when scanner processes run unconfined as UID 0.
- The image names UID 65532 edgewatch-scanner.
- CI and the release smoke stage run real Nmap SYN, UDP and Naabu scans
  of local listeners through EdgeWatch with the sandbox enforced and
  off, require identical results, and check the running Nmap's
  identity, cancellation and data-directory access.
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying edgewatch with  Cloudflare Pages  Cloudflare Pages

Latest commit: 952fd1d
Status: ✅  Deploy successful!
Preview URL: https://aaf7ff77.edgewatch-cpd.pages.dev
Branch Preview URL: https://feat-scanner-sandbox.edgewatch-cpd.pages.dev

View logs

Follow the store's platform pattern: the common file defines Detect,
Confine and the capability names with hooks that only Linux installs, so
no file is excluded by build constraints on the platform that measures
coverage.
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