diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcd594b2..3606c26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -362,6 +362,8 @@ jobs: run: bash ./scripts/test-prebuilt-image.sh edgewatch:prebuilt-amd64 linux/amd64 v0.0.0-ci - name: Verify container runtime capability matrix run: ./scripts/verify-container-runtime.sh edgewatch:prebuilt-amd64 + - name: Verify scanner sandbox with real scans + run: ./scripts/verify-scanner-sandbox.sh edgewatch:prebuilt-amd64 - name: Build prebuilt ARM64 image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f802b380..55ac4e95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -483,6 +483,8 @@ jobs: docker run --rm --entrypoint /bin/sh "$image" -c 'test -s /usr/share/licenses/edgewatch/LICENSE && test -s /usr/share/licenses/edgewatch/LICENSE.md && test -s /usr/share/licenses/edgewatch/THIRD_PARTY_LICENSES.md && test -s /usr/share/licenses/naabu/LICENSE.md' - name: Verify container runtime capability matrix run: ./scripts/verify-container-runtime.sh "$IMAGE" + - name: Verify scanner sandbox with real scans + run: ./scripts/verify-scanner-sandbox.sh "$IMAGE" - name: Verify binary version and configuration run: | expected="EdgeWatch ${GITHUB_REF_NAME#v}" diff --git a/AGENTS.md b/AGENTS.md index 35b1cbaf..2e7cb6ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ It uses a Go backend, SQLite storage, and a React/TypeScript web console. | `internal/app/` | Application coordination, scan lifecycle, and resumable work | | `internal/config/` | Configuration validation and scanner profiles | | `internal/scanner/` | Nmap and Naabu execution, parsing, and scan plans | +| `internal/sandbox/` | The unprivileged identity and capabilities scanner processes start with | | `internal/engine/` | Baseline comparison and change detection | | `internal/model/` | Shared domain types | | `internal/store/` | SQLite queries, migrations, history, backup, and restore | @@ -69,6 +70,7 @@ Documentation-only changes need a diff review and checks of referenced paths and | Database schema | Store migration tests and `./scripts/check-schema-docs.sh` | | Compose configuration | Run `docker compose config --quiet` and `docker compose -f compose.yaml -f compose.syn.yaml config --quiet`, then verify the rendered capability, hardening, image, and storage policies described in `docs/src/content/docs/deployment/container-hardening.md` and the CI `Validate Compose deployment` step | | Scanner dependency pin | `./scripts/verify-naabu-pin.sh` | +| Scanner execution or sandbox | Build the image and run `./scripts/verify-scanner-sandbox.sh IMAGE`, which needs Docker; it compares real sandboxed and unconfined scans of local listeners | | Release helper scripts | `./scripts/test-release-artifacts.sh`; this uses fixture binaries and does not build a release candidate | | Release workflow or GoReleaser configuration | Follow the exact GoReleaser check and immutable-candidate gates in `.github/workflows/release.yml`; the candidate, publication, image, and runtime smoke gates run only for tags | @@ -92,6 +94,7 @@ Report the checks you ran and any failures or checks you could not run. - Use controlled listeners for integration scans and scan only authorized targets. - Preserve target exclusions, probe budgets, cancellation, and resumable scan behavior. - Keep scanner execution shell-free on fixed executables with validated argument arrays and `exec.CommandContext`; preserve the minimal environment, private temporary inputs and outputs, bounded diagnostic and structured output, and child termination when those bounds are exceeded. +- Start every Nmap and Naabu process through the scanner's sandbox policy (`internal/sandbox`): confine the command, and pass private files with `InheritFile` rather than by path. Confined processes keep only `NET_RAW` and `NET_ADMIN` as ambient capabilities, and the bundled Compose capability set stays exact. - Keep UDP scans on Nmap and require Nmap confirmation before Naabu discoveries enter baselines or incidents. - Preserve job profile revisions so profile edits do not silently change scheduled jobs. - Preserve baseline state for failed or incomplete observations and retain scan history when users accept changes. diff --git a/Dockerfile b/Dockerfile index 83a17f8e..1489e929 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,7 +56,9 @@ RUN if [ "$PREBUILT_EDGEWATCH" = "1" ]; then \ FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 RUN apk add --no-cache ca-certificates=20260909-r0 gcompat=1.1.0-r4 nmap=7.99-r0 nmap-scripts=7.99-r0 tzdata=2026e-r0 \ && mkdir -p /etc/edgewatch /var/lib/edgewatch /run/secrets \ - && chmod 0750 /etc/edgewatch /var/lib/edgewatch /run/secrets + && chmod 0750 /etc/edgewatch /var/lib/edgewatch /run/secrets \ + && addgroup -S -g 65532 edgewatch-scanner \ + && adduser -S -D -H -u 65532 -G edgewatch-scanner -h /nonexistent -s /sbin/nologin edgewatch-scanner COPY --from=build /out/edgewatch /usr/local/bin/edgewatch COPY --from=naabu /out/naabu /usr/local/bin/naabu COPY LICENSE LICENSE.md THIRD_PARTY_LICENSES.md /usr/share/licenses/edgewatch/ diff --git a/SECURITY.md b/SECURITY.md index c2619e93..74e2ac7f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,11 +17,22 @@ implausible number of repeated records. Connect discovery is the least privileged default; SYN discovery additionally requires the explicitly opted-in `NET_ADMIN` and `NET_RAW` container capabilities. -The final image intentionally retains UID 0 because the supported Docker +The daemon intentionally retains UID 0 because the supported Docker capability model does not reliably expose raw packet privileges to an unprivileged process. Nmap UDP/SYN and Naabu SYN fail closed without those -privileges. The compatibility matrix, bind-mount ownership guidance, and -reconsideration criteria are maintained in +privileges. The scanner processes themselves run in a sandbox: with the +default `scanner.sandbox: auto` and the bundled Compose capabilities +(`NET_RAW`, `SETUID`, `SETGID`, `KILL`), EdgeWatch starts Nmap and Naabu as +UID and GID 65532 with no supplementary groups and only `NET_RAW` (and +`NET_ADMIN` when granted) as ambient capabilities. They read their target list +and write their results through inherited file descriptors, cannot list the +UID 0 data directory or reach `config.yaml`, and so cannot read the database +or the encryption keys. When the container does not grant those capabilities, +`auto` runs them unconfined as UID 0 and warns in the log, `edgewatch health`, +and the console; `scanner.sandbox: required` refuses to scan instead. All +sandboxed scanner processes share UID 65532, and the notification child +process is not sandboxed. The compatibility matrix, the sandbox, bind-mount +ownership guidance, and the criteria for a non-root daemon are maintained in [`docs/src/content/docs/deployment/container-hardening.md`](docs/src/content/docs/deployment/container-hardening.md). The administration console is bound to a loopback address by default and uses diff --git a/cmd/edgewatch/main.go b/cmd/edgewatch/main.go index 04e9dda5..c7dc95e4 100644 --- a/cmd/edgewatch/main.go +++ b/cmd/edgewatch/main.go @@ -21,6 +21,7 @@ import ( "github.com/crypt0rr/edgewatch/internal/config" "github.com/crypt0rr/edgewatch/internal/model" "github.com/crypt0rr/edgewatch/internal/notify" + "github.com/crypt0rr/edgewatch/internal/sandbox" "github.com/crypt0rr/edgewatch/internal/store" "github.com/crypt0rr/edgewatch/internal/web" "github.com/robfig/cron/v3" @@ -131,6 +132,16 @@ func run(args []string) error { return err } } + // The daemon and the scan command start scanner processes. A required + // sandbox refuses them, before the database is opened, when the runtime + // cannot confine those processes. + var scannerSandbox *sandbox.Policy + if cmd == "daemon" || cmd == "scan" { + scannerSandbox = sandbox.Detect(cfg.Scanner.Sandbox) + if err := scannerSandbox.Require(); err != nil { + return err + } + } ctx, stop := contextWithSignals(context.Background()) defer stop() if cmd == "restore" { @@ -265,11 +276,14 @@ func run(args []string) error { // Only the daemon imports notification URLs from config.yaml, after // the migrations above and before its notifier and delivery worker // start. Host commands keep using the configured URLs until then. - application, err = app.NewWithOptions(cfg, s, *nmapPath, logger, app.Options{ImportNotificationURLs: cmd == "daemon"}) + application, err = app.NewWithOptions(cfg, s, *nmapPath, logger, app.Options{ImportNotificationURLs: cmd == "daemon", Sandbox: scannerSandbox}) if err != nil { return err } application.Version = version + if scannerSandbox != nil { + logScannerSandbox(logger, scannerSandbox.Status()) + } } switch cmd { case "daemon": @@ -343,17 +357,21 @@ func run(args []string) error { return err case "health": health, err := s.System().HealthStatus(ctx) + // The sandbox is detected for this container, which grants the + // health command the daemon's capabilities and configuration. + scannerSandbox := sandbox.Detect(cfg.Scanner.Sandbox).Status() + health.Warnings = append(health.Warnings, scannerSandboxWarnings(scannerSandbox)...) if err != nil { if *output == "json" { // Keep stdout parseable for monitoring: report the failure // as a document, then exit non-zero with the reason on stderr. - if printErr := printValue(*output, unhealthyStatus{HealthStatus: health, Status: "unhealthy", Error: err.Error()}); printErr != nil { + if printErr := printValue(*output, unhealthyStatus{HealthStatus: health, ScannerSandbox: scannerSandbox, Status: "unhealthy", Error: err.Error()}); printErr != nil { return printErr } } return err } - return printValue(*output, health) + return printValue(*output, healthReport{HealthStatus: health, ScannerSandbox: scannerSandbox}) default: return usage() } @@ -695,8 +713,41 @@ type notifyTestResult struct { // healthy. Its status field replaces the embedded one. type unhealthyStatus struct { store.HealthStatus - Status string `json:"status"` - Error string `json:"error"` + ScannerSandbox sandbox.Status `json:"scanner_sandbox"` + Status string `json:"status"` + Error string `json:"error"` +} + +// healthReport is the health command's document: the daemon's health and how +// scanner processes start in this container. +type healthReport struct { + store.HealthStatus + ScannerSandbox sandbox.Status `json:"scanner_sandbox"` +} + +// scannerSandboxWarnings reports a sandbox that auto mode could not enforce +// while scanner processes run as UID 0. A daemon that runs as another user +// starts scanner processes as that user, which the sandbox would not improve +// on. +func scannerSandboxWarnings(status sandbox.Status) []string { + if status.State != sandbox.StateUnavailable || status.ProcessUID != 0 { + return nil + } + return []string{"scanner processes run unconfined as UID 0: " + status.Reason} +} + +// logScannerSandbox records how scanner processes start. +func logScannerSandbox(logger *slog.Logger, status sandbox.Status) { + switch { + case status.State == sandbox.StateEnforced: + logger.Info("scanner processes are sandboxed", "uid", status.UID, "gid", status.GID, "capabilities", status.Capabilities, "no_new_privileges", status.NoNewPrivileges) + case status.State == sandbox.StateUnavailable && status.ProcessUID == 0: + logger.Warn("scanner processes run unconfined as UID 0; see the container hardening guide", "reason", status.Reason) + case status.State == sandbox.StateUnavailable: + logger.Info("scanner processes run as the daemon's user", "uid", status.ProcessUID, "reason", status.Reason) + default: + logger.Info("scanner sandbox is off; scanner processes run unconfined", "uid", status.ProcessUID) + } } func printValue(format string, v any) error { diff --git a/cmd/edgewatch/main_extra_test.go b/cmd/edgewatch/main_extra_test.go index 6206ef60..7cdbf5f6 100644 --- a/cmd/edgewatch/main_extra_test.go +++ b/cmd/edgewatch/main_extra_test.go @@ -690,8 +690,12 @@ func TestHealthCommandNamesMissingDaemonHeartbeat(t *testing.T) { t.Fatalf("health error = %v, want a named missing-daemon-heartbeat error", err) } var document struct { - Status string `json:"status"` - Error string `json:"error"` + Status string `json:"status"` + Error string `json:"error"` + ScannerSandbox struct { + Mode string `json:"mode"` + State string `json:"state"` + } `json:"scanner_sandbox"` } if decodeErr := json.Unmarshal([]byte(stdout), &document); decodeErr != nil { t.Fatalf("unhealthy health output is not one JSON document: %v\n%s", decodeErr, stdout) @@ -699,6 +703,10 @@ func TestHealthCommandNamesMissingDaemonHeartbeat(t *testing.T) { if document.Status != "unhealthy" || document.Error != err.Error() { t.Fatalf("unhealthy health document = %+v, want status unhealthy and the error %q", document, err) } + // A test binary is never confined, so the sandbox is reported unavailable. + if document.ScannerSandbox.Mode != "auto" || document.ScannerSandbox.State != "unavailable" { + t.Fatalf("health scanner sandbox = %+v, want auto and unavailable", document.ScannerSandbox) + } stdout, _, err = captureCLIOutput(t, func() error { return run([]string{"health", "--config", configPath}) diff --git a/cmd/edgewatch/scanner_sandbox_test.go b/cmd/edgewatch/scanner_sandbox_test.go new file mode 100644 index 00000000..807c5048 --- /dev/null +++ b/cmd/edgewatch/scanner_sandbox_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/crypt0rr/edgewatch/internal/sandbox" +) + +func TestDaemonRefusesARequiredSandboxBeforeOpeningTheDatabase(t *testing.T) { + dir := t.TempDir() + database := filepath.Join(dir, "edgewatch.db") + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte("database: "+database+"\nscanner:\n sandbox: required\n"), 0o600); err != nil { + t.Fatal(err) + } + // A test process can never confine scanner processes, so a required + // sandbox must stop the daemon before it creates or migrates anything. + err := run([]string{"daemon", "--config", configPath}) + if !errors.Is(err, sandbox.ErrUnavailable) || !strings.Contains(err.Error(), "set scanner.sandbox to auto") { + t.Fatalf("daemon with a required sandbox = %v, want ErrUnavailable", err) + } + if _, statErr := os.Stat(database); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("refused daemon left a database behind: %v", statErr) + } +} + +func TestScannerSandboxWarningsOnlyForUnconfinedRoot(t *testing.T) { + t.Parallel() + unavailable := sandbox.Status{Mode: sandbox.ModeAuto, State: sandbox.StateUnavailable, ProcessUID: 0, Reason: "the container does not grant KILL"} + if got := scannerSandboxWarnings(unavailable); len(got) != 1 || got[0] != "scanner processes run unconfined as UID 0: the container does not grant KILL" { + t.Fatalf("root warnings = %q", got) + } + notRoot := unavailable + notRoot.ProcessUID = 1000 + for name, status := range map[string]sandbox.Status{ + "not root": notRoot, + "enforced": {State: sandbox.StateEnforced, ProcessUID: sandbox.UID}, + "disabled": {State: sandbox.StateDisabled}, + } { + if got := scannerSandboxWarnings(status); len(got) != 0 { + t.Errorf("%s warnings = %q, want none", name, got) + } + } +} + +func TestLogScannerSandboxNamesTheOutcome(t *testing.T) { + t.Parallel() + for name, test := range map[string]struct { + status sandbox.Status + want string + }{ + "enforced": {status: sandbox.NewEnforced().Status(), want: `"level":"INFO","msg":"scanner processes are sandboxed","uid":65532`}, + "unavailable root": {status: sandbox.Status{State: sandbox.StateUnavailable, Reason: "no KILL"}, want: `"level":"WARN","msg":"scanner processes run unconfined as UID 0; see the container hardening guide","reason":"no KILL"`}, + "unavailable user": {status: sandbox.Status{State: sandbox.StateUnavailable, ProcessUID: 1000, Reason: "not root"}, want: `"level":"INFO","msg":"scanner processes run as the daemon's user","uid":1000`}, + "off": {status: sandbox.Status{State: sandbox.StateDisabled}, want: `"msg":"scanner sandbox is off; scanner processes run unconfined"`}, + } { + var output bytes.Buffer + logScannerSandbox(slog.New(slog.NewJSONHandler(&output, nil)), test.status) + if !strings.Contains(output.String(), test.want) { + t.Errorf("%s log = %s, want %s", name, output.String(), test.want) + } + } +} diff --git a/compose.syn.yaml b/compose.syn.yaml index 2c64d547..41180365 100644 --- a/compose.syn.yaml +++ b/compose.syn.yaml @@ -10,6 +10,8 @@ # default Naabu CONNECT profile without NET_ADMIN. services: edgewatch: + # Compose merges this list with the base file's; NET_ADMIN is the only + # addition. A sandboxed Naabu keeps it alongside NET_RAW. cap_add: - NET_RAW - NET_ADMIN diff --git a/compose.yaml b/compose.yaml index 41c920d0..5e633f07 100644 --- a/compose.yaml +++ b/compose.yaml @@ -21,8 +21,15 @@ services: TMPDIR: /var/lib/edgewatch/tmp cap_drop: - ALL + # NET_RAW is the raw-packet privilege of Nmap and Naabu. SETUID and SETGID + # let EdgeWatch start them as the unprivileged sandbox identity (UID + # 65532), which keeps only NET_RAW and cannot read ./data, and KILL lets + # it stop them; without these three they run unconfined as UID 0. cap_add: - NET_RAW + - SETUID + - SETGID + - KILL security_opt: - no-new-privileges:true read_only: true diff --git a/config.example.yaml b/config.example.yaml index 57538fb7..1cbcdfe8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -62,6 +62,13 @@ scanner: - 169.254.0.0/16 - ::1/128 - fe80::/10 + # Nmap and Naabu run as the unprivileged UID 65532 with only their raw-packet + # capabilities, so they cannot read the database or the keys. auto (the + # default) does so when the container grants SETUID, SETGID and KILL, as the + # bundled compose.yaml does, and otherwise runs them unconfined as UID 0 + # with a warning. required refuses to scan without the sandbox; off never + # uses it. + sandbox: auto # Check the latest stable GitHub release at startup and every three hours. # Set enabled: false for offline or privacy-sensitive deployments. A check diff --git a/docs/src/content/docs/deployment/container-hardening.md b/docs/src/content/docs/deployment/container-hardening.md index faba5178..ab3e3637 100644 --- a/docs/src/content/docs/deployment/container-hardening.md +++ b/docs/src/content/docs/deployment/container-hardening.md @@ -3,19 +3,26 @@ title: Container runtime hardening description: Review scanner capability requirements, runtime controls, and data ownership. --- -EdgeWatch deliberately keeps the final container process as UID 0 in the -current release. This is a compatibility decision, not a requirement for the -web application: Nmap UDP and SYN scans, and Naabu SYN discovery, require raw -packet privileges. On the supported Docker engines tested for EdgeWatch, -`--cap-add NET_RAW` (and, for Naabu SYN, `NET_ADMIN`) is effective for the -root container process but is removed when Docker starts the process directly -as an unprivileged UID. A non-root default would therefore make existing UDP -and TCP SYN jobs fail or require a job-dependent privilege transition. +The EdgeWatch daemon runs as UID 0 inside the container, and the scanner +processes it starts do not. Nmap, its NSE scripts, and Naabu parse responses +from the networks they scan, so EdgeWatch starts each of them in a +[sandbox](#scanner-sandbox): as the unprivileged UID 65532, with no +supplementary groups and only the raw-packet capabilities a scan needs. A +compromised scanner process cannot read the database or the encryption keys. + +The daemon itself keeps UID 0 for compatibility. Nmap UDP and SYN scans, and +Naabu SYN discovery, need raw-packet privileges. On the supported Docker +engines, `--cap-add NET_RAW` (and, for Naabu SYN, `NET_ADMIN`) is effective for +the root container process but is removed when Docker starts the process +directly as an unprivileged UID. The daemon therefore receives the +capabilities and hands only the raw-packet ones to each scanner process. The image and Compose deployment still apply the following controls: - all capabilities are dropped first; -- the base deployment adds only `NET_RAW`; +- the base deployment adds `NET_RAW` for the scanners, and `SETUID`, `SETGID` + and `KILL` so the daemon can start scanner processes as the sandbox identity + and stop them; - `compose.syn.yaml` is an explicit administrator opt-in for `NET_ADMIN`; - `no-new-privileges` is enabled; - the container is not privileged, sets no `unconfined` seccomp or AppArmor @@ -49,8 +56,9 @@ published: | Runtime | Capabilities | Supported work | Result | | --- | --- | --- | --- | -| UID 0, base Compose | `NET_RAW` | Nmap TCP SYN/connect, Nmap UDP, Naabu connect | supported | -| UID 0, `compose.syn.yaml` | `NET_RAW`, `NET_ADMIN` | Naabu SYN in addition to the base modes | supported | +| UID 0 daemon, base Compose | `NET_RAW`, `SETUID`, `SETGID`, `KILL` | Nmap TCP SYN/connect, Nmap UDP, Naabu connect, each in the sandbox as UID 65532 with `NET_RAW` | supported | +| UID 0 daemon, `compose.syn.yaml` | the above and `NET_ADMIN` | Naabu SYN in addition to the base modes, in the sandbox with `NET_RAW` and `NET_ADMIN` | supported | +| UID 0 daemon, `NET_RAW` only | `NET_RAW` | the base modes, unconfined as UID 0 | supported; the sandbox is unavailable and EdgeWatch warns | | UID 65532, experimental probe | none effective (even when `NET_RAW` is requested) | Naabu connect and Nmap TCP connect only | supported for those modes; not a supported default | | UID 65532, experimental probe | none effective | Nmap SYN or UDP, Naabu SYN | rejected by the scanner or unavailable | @@ -58,7 +66,10 @@ The matrix also runs the image with a disposable read-only root filesystem and a writable data bind mount owned by the identity mapped to container UID 0. It verifies that the normal root deployment can create SQLite state without world-writable permissions and that scanner capability checks fail closed -instead of guessing from the UID. +instead of guessing from the UID. It runs real Nmap SYN, Nmap UDP and Naabu +scans of local listeners through EdgeWatch with the sandbox enforced and with +it off, requires the same results from both, and checks that the sandbox +identity cannot read the data directory. ## Data ownership and upgrades @@ -78,12 +89,79 @@ you have tested ownership and every configured scan mode with a copy of the data directory. Never use mode `0777` as a workaround; it masks ownership errors and weakens protection for databases and encryption keys. -## Reconsidering a non-root default +## Scanner sandbox + +`scanner.sandbox` in `config.yaml` selects how scanner processes start: + +| Value | Behaviour | +| --- | --- | +| `auto` (default) | Starts Nmap and Naabu in the sandbox when the container allows it. Otherwise they start unconfined as UID 0, and EdgeWatch logs a warning, adds it to `edgewatch health`, and shows it on the Overview for administrators. | +| `required` | Refuses to start the daemon, or `edgewatch scan`, when the sandbox is unavailable. | +| `off` | Starts scanner processes unconfined, as releases before the sandbox did. | + +A sandboxed scanner process: + +- runs as UID and GID 65532 (`edgewatch-scanner`) with no supplementary + groups; +- keeps `NET_RAW`, and `NET_ADMIN` when the container grants it, as ambient + capabilities and no other capability; +- cannot read or list `./data`, which belongs to UID 0 with mode `0750`, or + read `config.yaml`; it reads its target list and writes its results through + file descriptors that EdgeWatch passes to it; +- inherits `no-new-privileges`, and the image contains no setuid or file-capability + binary it could use to gain privileges. + +Nmap is started with `--privileged`, because Nmap otherwise assumes that a +process other than UID 0 cannot send raw packets and falls back to connect +scans. Naabu detects its capabilities itself. Scan results are the same as +without the sandbox. + +At startup EdgeWatch checks that the container grants `SETUID`, `SETGID` and +`KILL`, and starts a short test process as UID 65532 to confirm that the +runtime allows the change. Check the outcome with: + +```sh +docker compose exec edgewatch edgewatch health --config /etc/edgewatch/config.yaml --output json +``` + +`scanner_sandbox.state` is `enforced`, `disabled` or `unavailable`, and +`reason` explains a sandbox that is not enforced. The Overview's deployment +footprint shows the same state. + +The sandbox needs the user namespace of the container to map UID 65532. Standard +rootful Docker, rootless Docker, and user-namespace remapping with a full +subordinate range all do. A runtime that maps only a few UIDs reports the +sandbox as unavailable. + +### Upgrading an existing deployment + +A `compose.yaml` from an earlier release adds only `NET_RAW`. With it, +`auto` keeps scanning unconfined as UID 0 and warns. Add the three +capabilities to enable the sandbox: + +```yaml + cap_add: + - NET_RAW + - SETUID + - SETGID + - KILL +``` + +Then recreate the container with `docker compose up -d`. No data migration is +needed; `./data` keeps its UID 0 ownership. + +### What the sandbox does not cover + +- The daemon, which serves the console and holds the database and keys, still + runs as UID 0 with the container's capabilities. +- All sandboxed scanner processes share UID 65532, so a compromised scanner + process could observe other scans that run at the same time. +- A scanner process keeps its network access; target exclusions and probe + budgets are enforced by EdgeWatch before it starts. +- Notification delivery runs in its own child process, which is not yet + sandboxed. -A future hardening change can revisit this decision after all supported Docker -and rootless/containerd combinations provide a documented way to grant the -minimum raw-packet capabilities to a non-root scanner process. It must also -cover Nmap UDP, Naabu SYN, read-only filesystems, bind-mounted ownership, and -upgrade rollback before changing the default. Until then, retaining UID 0 -with the controls above is safer than shipping a default that only works for -TCP connect scans. +A non-root daemon remains unsupported until the supported Docker and +rootless or containerd combinations provide a documented way to grant it the +capabilities above, with read-only filesystems, bind-mounted ownership and +upgrade rollback covered. diff --git a/docs/src/content/docs/deployment/updates.md b/docs/src/content/docs/deployment/updates.md index 9e2011ac..d8e47bd4 100644 --- a/docs/src/content/docs/deployment/updates.md +++ b/docs/src/content/docs/deployment/updates.md @@ -61,9 +61,14 @@ listed under [Business units](/administration/business-units/). ## Runtime privileges The image uses a read-only root filesystem, drops all capabilities, and adds -`NET_RAW` for the default scanner modes. Host networking is intentional, and the -administration listener accepts only loopback addresses. Keep the service on -the Docker host or reach it through an authenticated SSH tunnel. +`NET_RAW` for the default scanner modes, plus `SETUID`, `SETGID` and `KILL` so +EdgeWatch can run Nmap and Naabu in the +[scanner sandbox](/deployment/container-hardening/#scanner-sandbox). A +`compose.yaml` from a release before v0.27.0 lacks the last three; add them +when you update, or the scanners keep running unconfined as UID 0 and +EdgeWatch warns. Host networking is intentional, and the administration +listener accepts only loopback addresses. Keep the service on the Docker host +or reach it through an authenticated SSH tunnel. Naabu SYN discovery additionally needs `NET_ADMIN`. The normal Compose setup uses connect discovery and does not grant that capability. If you have reviewed the diff --git a/docs/src/content/docs/operations/troubleshooting.md b/docs/src/content/docs/operations/troubleshooting.md index 1a9ab772..534c715d 100644 --- a/docs/src/content/docs/operations/troubleshooting.md +++ b/docs/src/content/docs/operations/troubleshooting.md @@ -26,6 +26,23 @@ private to their owner. Run the actual read/write preflight in Docker mode. Stop the service before correcting an existing directory's ownership. World-readable or world-writable permissions are not a remedy. +## Scanner processes run unconfined as UID 0 + +EdgeWatch logs this warning, adds it to `edgewatch health`, and shows it on the +Overview when `scanner.sandbox` is `auto` and Nmap and Naabu cannot run in the +[scanner sandbox](/deployment/container-hardening/#scanner-sandbox). The +reason names the cause: + +- **The container does not grant SETUID, SETGID, KILL.** Your `compose.yaml` + predates the sandbox. Add the three capabilities to `cap_add`, as in the + bundled file, and recreate the container with `docker compose up -d`. +- **A test process could not start as UID 65532.** The container runtime does + not map UID 65532 into the container's user namespace. Use a full + subordinate UID range for rootless Docker or user-namespace remapping. + +Scans keep working unconfined in either case. Set `scanner.sandbox: required` +to refuse to scan without the sandbox instead. + ## Proxy hostname rejected A `421 Misdirected Request` when loopback requests succeed usually means the diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 8f8cfe8e..92e94038 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -109,7 +109,12 @@ document with `"status": "unhealthy"` and the reason in `error`, so a monitoring script always receives JSON; the text output prints nothing on stdout in that case. A healthy or starting daemon prints its status, and `warnings` list actions that do not stop EdgeWatch, such as removing imported notification URLs -from `config.yaml`. A database that cannot be opened at all, for example one +from `config.yaml`, or scanner processes that run unconfined as UID 0. Both +documents include `scanner_sandbox`, which reports how scanner processes start +in this container: `state` is `enforced`, `disabled`, or `unavailable`, +`process_uid` is the UID they run as, `capabilities` lists what a sandboxed +scanner keeps, and `reason` explains a sandbox that is not enforced. See +[the scanner sandbox](/deployment/container-hardening/#scanner-sandbox). A database that cannot be opened at all, for example one with a newer schema, still fails before any document is printed. ## Notification tests diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 61a3d6af..269924b4 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -18,6 +18,7 @@ validated schema. | `log.level` | YAML | Log verbosity: `debug`, `info`, `warn`, or `error`. | | `scheduler.*` | YAML | Concurrent scans and probe budgets. | | `scanner.target_exclusions` | YAML | Addresses that may never be scanned. | +| `scanner.sandbox` | YAML | Whether Nmap and Naabu run as an unprivileged identity; see [the scanner sandbox](/deployment/container-hardening/#scanner-sandbox). | | `enrichment.rdap.enabled` | YAML | Enable or disable on-demand public network-registration lookups. | | `updates.enabled` | YAML | Enable or disable the three-hour stable-release check. | | `notifications.encryption_key_file` | YAML/secrets | Optional separate key for the encrypted notification destinations. | @@ -38,6 +39,7 @@ recreated and reviewed explicitly in the console. | `scheduler.max_concurrent_scans` | `1` | 1 to 64. | | `scheduler.max_probe_count` | `5000000` | 1 to 100000000; `0` is rejected. | | `scheduler.max_naabu_probe_count` | `20000000` | 1 to 100000000; `0` is rejected. | +| `scanner.sandbox` | `auto` | `auto`, `required`, or `off`. | | `web.auth_key_file` | `auth.key` next to the database | A regular file of 32 raw bytes or 64 hexadecimal characters, without group or other permissions. | | `notifications.encryption_key_file` | `notification.key` next to the database | A regular file of 32 raw bytes or 64 hexadecimal characters with mode `0400` or `0600`. | | `web.source_url` | The exact Git tag of an official build | An absolute HTTPS URL without credentials, a query, or a fragment, at most 2048 bytes. | @@ -127,6 +129,11 @@ Jobs are configured in the console, which enforces these limits: replaces the defaults, so keep the default ranges when you add entries. Change `scanner.target_exclusions` only when you understand the host-network exposure. +- `scanner.sandbox: auto` starts Nmap and Naabu as UID 65532 with only their + raw-packet capabilities when the container grants `SETUID`, `SETGID` and + `KILL`, as the bundled `compose.yaml` does. Otherwise they run unconfined as + UID 0 and EdgeWatch warns. Set `required` to refuse to scan without the + sandbox. - RDAP is enabled by default and is requested only when an authenticated user opens a public host. Private and special-use addresses are never queried. Set `enrichment.rdap.enabled: false` for isolated or privacy-sensitive diff --git a/internal/app/app.go b/internal/app/app.go index bda342ba..42ec613a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,6 +18,7 @@ import ( "github.com/crypt0rr/edgewatch/internal/engine" "github.com/crypt0rr/edgewatch/internal/model" "github.com/crypt0rr/edgewatch/internal/notify" + "github.com/crypt0rr/edgewatch/internal/sandbox" "github.com/crypt0rr/edgewatch/internal/scanner" "github.com/crypt0rr/edgewatch/internal/store" "github.com/crypt0rr/edgewatch/internal/updatecheck" @@ -35,7 +36,9 @@ type App struct { Engine *engine.Engine Notifier *notify.Notifier Logger *slog.Logger - active sync.Map + // sandbox is the policy the scanner's processes start with. + sandbox *sandbox.Policy + active sync.Map // managedReservations closes the window between an HTTP manual-run request // and the goroutine reaching runJob. Scheduled work checks this map too, so // a queued manual run receives the slot deterministically instead of two @@ -385,6 +388,14 @@ type Options struct { // encrypted web-managed destinations before the notifier loads its // destinations. Host commands leave it unset, so they never import. ImportNotificationURLs bool + // Sandbox confines the scanner's Nmap and Naabu processes. Nil starts + // them unconfined. + Sandbox *sandbox.Policy +} + +// ScannerSandbox reports how the scanner's Nmap and Naabu processes start. +func (a *App) ScannerSandbox() sandbox.Status { + return a.sandbox.Status() } // NewWithOptions is New with daemon-only startup work selected by options. @@ -461,6 +472,7 @@ func newApp(cfg *config.Config, s *store.Store, nmapPath, naabuPath string, logg logger.Warn("config.yaml sets obsolete settings that are ignored; business units are always on, so remove the experimental section", "settings", obsolete) } sc := scanner.NewWithNaabu(nmapPath, naabuPath) + sc.SetSandbox(options.Sandbox) if err := sc.SetTargetExclusions(cfg.Scanner.TargetExclusions); err != nil { return nil, fmt.Errorf("configure scanner target exclusions: %w", err) } @@ -469,7 +481,7 @@ func newApp(cfg *config.Config, s *store.Store, nmapPath, naabuPath string, logg } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return &App{Version: "dev", Config: cfg, Store: s, Scanner: sc, Engine: &engine.Engine{Store: s}, Notifier: n, Logger: logger, ReleaseChecker: updatecheck.NewClient(), UpdateInterval: updatecheck.CheckInterval, slots: newSlotPool(cfg.Scheduler.MaxConcurrent, nil), nmapVersion: sc.Version(ctx), naabuVersion: sc.NaabuVersion(ctx), entries: map[string]cron.EntryID{}, scheduleSpecs: map[string]string{}, scheduleWake: make(chan struct{}, 1), deliveryWake: make(chan struct{}, 1), heartbeatInterval: 30 * time.Second, clock: time.Now}, nil + return &App{Version: "dev", Config: cfg, Store: s, Scanner: sc, Engine: &engine.Engine{Store: s}, Notifier: n, Logger: logger, sandbox: options.Sandbox, ReleaseChecker: updatecheck.NewClient(), UpdateInterval: updatecheck.CheckInterval, slots: newSlotPool(cfg.Scheduler.MaxConcurrent, nil), nmapVersion: sc.Version(ctx), naabuVersion: sc.NaabuVersion(ctx), entries: map[string]cron.EntryID{}, scheduleSpecs: map[string]string{}, scheduleWake: make(chan struct{}, 1), deliveryWake: make(chan struct{}, 1), heartbeatInterval: 30 * time.Second, clock: time.Now}, nil } // importConfiguredNotifications imports the notification URLs in config.yaml diff --git a/internal/app/scanner_sandbox_test.go b/internal/app/scanner_sandbox_test.go new file mode 100644 index 00000000..8a42d1a1 --- /dev/null +++ b/internal/app/scanner_sandbox_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "io" + "log/slog" + "testing" + "time" + + "github.com/crypt0rr/edgewatch/internal/config" + "github.com/crypt0rr/edgewatch/internal/sandbox" + "github.com/crypt0rr/edgewatch/internal/scanner" + "github.com/crypt0rr/edgewatch/internal/store" + "github.com/crypt0rr/edgewatch/internal/store/storetest" +) + +func TestNewWithOptionsInstallsTheScannerSandbox(t *testing.T) { + t.Parallel() + newApp := func(options Options) *App { + t.Helper() + s, err := store.Open(storetest.FreshPath(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + cfg := &config.Config{Version: 1, Database: "test", Retention: config.Duration(24 * time.Hour), Scheduler: config.Scheduler{MaxConcurrent: 1}, Web: config.Web{Listen: "127.0.0.1:8080"}} + a, err := NewWithOptions(cfg, s, "missing", slog.New(slog.NewTextHandler(io.Discard, nil)), options) + if err != nil { + t.Fatal(err) + } + return a + } + confined := newApp(Options{Sandbox: sandbox.NewEnforced()}) + if got := confined.ScannerSandbox(); got.State != sandbox.StateEnforced || got.UID != sandbox.UID { + t.Fatalf("sandboxed application status = %+v", got) + } + if _, ok := confined.Scanner.(*scanner.Nmap); !ok { + t.Fatalf("scanner = %T, want the Nmap scanner that received the policy", confined.Scanner) + } + if got := newApp(Options{}).ScannerSandbox(); got.State != sandbox.StateDisabled { + t.Fatalf("application without a policy reports %+v, want disabled", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b2e6e0d1..b04523be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,8 +74,19 @@ type Config struct { // targets. type ScannerConfig struct { TargetExclusions []string `yaml:"target_exclusions"` + // Sandbox is how scanner processes start: auto (the default) runs them as + // an unprivileged identity when the runtime allows it, required refuses + // scanner work otherwise, and off never confines them. + Sandbox string `yaml:"sandbox"` } +// The scanner.sandbox values. +const ( + ScannerSandboxAuto = "auto" + ScannerSandboxRequired = "required" + ScannerSandboxOff = "off" +) + // LogConfig controls the minimum level emitted by the daemon's structured // JSON logger. The default is info; debug is useful while diagnosing a // request or scan, while warn/error keep routine appliance output quiet. @@ -573,6 +584,10 @@ func applyDefaults(c *Config) { if c.Scanner.TargetExclusions == nil { c.Scanner.TargetExclusions = DefaultTargetExclusions() } + c.Scanner.Sandbox = strings.ToLower(strings.TrimSpace(c.Scanner.Sandbox)) + if c.Scanner.Sandbox == "" { + c.Scanner.Sandbox = ScannerSandboxAuto + } if strings.TrimSpace(c.Log.Level) == "" { c.Log.Level = "info" } @@ -981,6 +996,11 @@ func (c Config) ValidateDeployment() error { if _, err := ParseTargetExclusions(c.Scanner.TargetExclusions); err != nil { return fmt.Errorf("scanner.target_exclusions: %w", err) } + switch c.Scanner.Sandbox { + case "", ScannerSandboxAuto, ScannerSandboxRequired, ScannerSandboxOff: + default: + return fmt.Errorf("scanner.sandbox must be auto, required, or off") + } if level := strings.ToLower(strings.TrimSpace(c.Log.Level)); level != "" && level != "debug" && level != "info" && level != "warn" && level != "error" { return fmt.Errorf("log.level must be one of debug, info, warn, or error") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 61d4cf94..97b73aa4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,6 +72,9 @@ jobs: if cfg.Scheduler.MaxNaabuProbeCount != DefaultNaabuMaxProbeCount { t.Fatalf("Naabu probe budget default %d", cfg.Scheduler.MaxNaabuProbeCount) } + if cfg.Scanner.Sandbox != ScannerSandboxAuto { + t.Fatalf("scanner sandbox default %q", cfg.Scanner.Sandbox) + } if cfg.LogLevel() != "info" || cfg.Log.Level != "info" { t.Fatalf("log level default = %q", cfg.Log.Level) } @@ -913,3 +916,29 @@ func TestValidateJobRejectsNaabuConnectHostDiscovery(t *testing.T) { } } } + +func TestScannerSandboxModes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + load := func(mode string) (*Config, error) { + t.Helper() + path := filepath.Join(dir, "config-"+strings.TrimSpace(mode)+".yaml") + yaml := "database: " + filepath.Join(dir, "db.sqlite") + "\nscanner:\n sandbox: \"" + mode + "\"\n" + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + return Load(path) + } + for mode, want := range map[string]string{"auto": ScannerSandboxAuto, " Required ": ScannerSandboxRequired, "OFF": ScannerSandboxOff} { + cfg, err := load(mode) + if err != nil { + t.Fatalf("scanner.sandbox %q: %v", mode, err) + } + if cfg.Scanner.Sandbox != want { + t.Fatalf("scanner.sandbox %q = %q, want %q", mode, cfg.Scanner.Sandbox, want) + } + } + if _, err := load("strict"); err == nil || !strings.Contains(err.Error(), "scanner.sandbox must be auto, required, or off") { + t.Fatalf("unknown scanner.sandbox error = %v", err) + } +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 00000000..03a4fc80 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,210 @@ +// Package sandbox starts scanner processes as an unprivileged identity that +// keeps only the network capabilities a scan needs. +// +// Nmap, its NSE scripts, and Naabu parse responses from the networks they +// scan. When the daemon runs as UID 0, as the container does, a scanner +// process started the ordinary way inherits that identity and can read the +// database and the encryption keys. A confined scanner process runs as UID +// and GID 65532 with no supplementary groups. It holds the container's +// NET_RAW, and NET_ADMIN when the container grants it, as ambient +// capabilities and nothing else. The data directory and the key files belong +// to UID 0, so a confined process can read none of them. The private files a +// scanner reads or writes are passed to it as inherited descriptors. +package sandbox + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// The scanner.sandbox modes. Auto confines scanner processes when the runtime +// allows it and otherwise starts them as before, with a warning. Required +// refuses to start scanner work without confinement. Off never confines them. +const ( + ModeAuto = "auto" + ModeRequired = "required" + ModeOff = "off" +) + +// The states a Status reports. +const ( + StateEnforced = "enforced" + StateDisabled = "disabled" + StateUnavailable = "unavailable" +) + +// UID and GID identify confined scanner processes. They own no file in the +// image or the data directory; the container runtime matrix tests the image +// with the same unprivileged identity. +const ( + UID = 65532 + GID = 65532 +) + +// ErrUnavailable reports that scanner.sandbox is required but scanner +// processes cannot be confined. +var ErrUnavailable = errors.New("the scanner sandbox is unavailable") + +// Status describes how scanner processes start. +type Status struct { + // Mode is the configured scanner.sandbox mode. + Mode string `json:"mode"` + // State is enforced, disabled, or unavailable. + State string `json:"state"` + // UID and GID are the identity of confined scanner processes. + UID int `json:"uid,omitempty"` + GID int `json:"gid,omitempty"` + // ProcessUID is the UID scanner processes run as: UID when confined, + // otherwise the daemon's own. + ProcessUID int `json:"process_uid"` + // Capabilities are the capabilities a confined scanner process keeps. + Capabilities []string `json:"capabilities,omitempty"` + // NoNewPrivileges reports whether the daemon runs with no_new_privs, + // which every scanner process inherits. + NoNewPrivileges bool `json:"no_new_privileges,omitempty"` + // Reason explains a disabled or unavailable sandbox. + Reason string `json:"reason,omitempty"` +} + +// The platform hooks. Only Linux installs them, because the sandbox relies on +// Linux identity changes and ambient capabilities; elsewhere Detect reports +// the sandbox as unavailable and Confine leaves commands unchanged. +var ( + detectPlatform func(mode string) *Policy + confineProcess func(cmd *exec.Cmd, ambient []uintptr) + nameCapability func(capability uintptr) string +) + +// Detect decides how scanner processes start in this runtime for the +// configured scanner.sandbox mode. Unless the mode is off, it starts a +// short-lived confined process to confirm that the runtime allows the +// identity change and the capabilities. +func Detect(mode string) *Policy { + mode = normalizedMode(mode) + if detectPlatform != nil { + return detectPlatform(mode) + } + status := Status{Mode: mode, State: StateUnavailable, ProcessUID: os.Geteuid(), Reason: "the scanner sandbox requires Linux"} + if mode == ModeOff { + status.State, status.Reason = StateDisabled, "scanner.sandbox is off" + } + return &Policy{status: status} +} + +// Confine makes cmd start its process confined. It keeps any process +// attributes already set, such as the session and controlling terminal of a +// pseudo-terminal. When the policy is not enforced, cmd is unchanged. +func (p *Policy) Confine(cmd *exec.Cmd) { + if !p.Enforced() || confineProcess == nil { + return + } + confineProcess(cmd, p.ambient) +} + +func capabilityName(capability uintptr) string { + if nameCapability != nil { + return nameCapability(capability) + } + return fmt.Sprintf("CAP_%d", capability) +} + +// Policy decides how scanner processes start. A nil Policy, like a policy +// that is not enforced, starts them unconfined. +type Policy struct { + status Status + enforce bool + ambient []uintptr +} + +// Access is how a confined process uses a file it inherits. +type Access int + +const ( + // Read lets the process open the file for reading. + Read Access = iota + // Write lets the process open the file for writing. + Write +) + +// ValidMode reports whether mode is a scanner.sandbox value. The empty value +// is auto. +func ValidMode(mode string) bool { + switch normalizedMode(mode) { + case ModeAuto, ModeRequired, ModeOff: + return true + default: + return false + } +} + +func normalizedMode(mode string) string { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + return ModeAuto + } + return mode +} + +// NewEnforced returns a policy that confines scanner processes with the given +// ambient capabilities, without checking that the runtime allows it. Detect +// is the entry point that checks. +func NewEnforced(ambient ...uintptr) *Policy { + names := make([]string, 0, len(ambient)) + for _, capability := range ambient { + names = append(names, capabilityName(capability)) + } + return &Policy{ + status: Status{Mode: ModeAuto, State: StateEnforced, UID: UID, GID: GID, ProcessUID: UID, Capabilities: names}, + enforce: true, + ambient: append([]uintptr(nil), ambient...), + } +} + +// Enforced reports whether scanner processes start confined. +func (p *Policy) Enforced() bool { + return p != nil && p.enforce +} + +// Status reports how scanner processes start. +func (p *Policy) Status() Status { + if p == nil { + return Status{Mode: ModeOff, State: StateDisabled, ProcessUID: os.Geteuid(), Reason: "scanner processes are not confined"} + } + status := p.status + status.Capabilities = append([]string(nil), p.status.Capabilities...) + return status +} + +// Require reports ErrUnavailable, with the reason, when scanner.sandbox is +// required and scanner processes cannot be confined. +func (p *Policy) Require() error { + if p == nil || p.status.Mode != ModeRequired || p.enforce { + return nil + } + return fmt.Errorf("%w: %s; set scanner.sandbox to auto to start scanner processes unconfined", ErrUnavailable, p.status.Reason) +} + +// InheritFile passes f to the process cmd starts and returns the path by which +// that process opens it. A confined process cannot reach the scanner's +// temporary directory inside the data directory, so it opens the file through +// its inherited descriptor, and the file's other-permission bits allow exactly +// the access it needs. The caller keeps f open until the process has exited. +// When the policy is not enforced, the process opens f by its name, as +// before. +func (p *Policy) InheritFile(cmd *exec.Cmd, f *os.File, access Access) (string, error) { + if !p.Enforced() { + return f.Name(), nil + } + mode := os.FileMode(0o604) + if access == Write { + mode = 0o602 + } + if err := f.Chmod(mode); err != nil { + return "", fmt.Errorf("share scanner file: %w", err) + } + cmd.ExtraFiles = append(cmd.ExtraFiles, f) + return fmt.Sprintf("/dev/fd/%d", 2+len(cmd.ExtraFiles)), nil +} diff --git a/internal/sandbox/sandbox_linux.go b/internal/sandbox/sandbox_linux.go new file mode 100644 index 00000000..d0e81e35 --- /dev/null +++ b/internal/sandbox/sandbox_linux.go @@ -0,0 +1,178 @@ +//go:build linux + +package sandbox + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// controlCapabilities are the capabilities the daemon needs to start a +// scanner process as another identity and to stop it: KILL is what lets UID 0 +// signal a process of another UID once filesystem and process capabilities +// are dropped. +var controlCapabilities = []uintptr{unix.CAP_SETUID, unix.CAP_SETGID, unix.CAP_KILL} + +// networkCapabilities are the capabilities a confined scanner keeps when the +// daemon holds them: the same raw-packet privileges an unconfined scanner +// started by UID 0 would have. +var networkCapabilities = []uintptr{unix.CAP_NET_RAW, unix.CAP_NET_ADMIN} + +const probeTimeout = 10 * time.Second + +// environment is what Detect inspects, so tests can describe a runtime. +type environment struct { + euid int + effective uint64 + permitted uint64 + capErr error + noNewPrivs bool + // probe starts a short-lived process confined with ambient and reports + // whether it ran. + probe func(ambient []uintptr) error +} + +func init() { + detectPlatform = func(mode string) *Policy { return detect(mode, systemEnvironment()) } + confineProcess = confineLinux + nameCapability = linuxCapabilityName +} + +func detect(mode string, env environment) *Policy { + mode = normalizedMode(mode) + status := Status{Mode: mode, ProcessUID: env.euid} + if mode == ModeOff { + status.State = StateDisabled + status.Reason = "scanner.sandbox is off" + return &Policy{status: status} + } + unavailable := func(reason string) *Policy { + status.State = StateUnavailable + status.Reason = reason + return &Policy{status: status} + } + if !ValidMode(mode) { + return unavailable(fmt.Sprintf("scanner.sandbox %q is not auto, required, or off", mode)) + } + if env.euid != 0 { + return unavailable(fmt.Sprintf("EdgeWatch runs as UID %d rather than 0, so it cannot start scanner processes as another identity", env.euid)) + } + if env.capErr != nil { + return unavailable(fmt.Sprintf("the daemon's capabilities could not be read: %v", env.capErr)) + } + var missing []string + for _, capability := range controlCapabilities { + if env.effective&(1< 0 { + return unavailable(fmt.Sprintf("the container does not grant %s, which EdgeWatch needs to start and stop scanner processes as UID %d; add them to cap_add", strings.Join(missing, ", "), UID)) + } + var ambient []uintptr + for _, capability := range networkCapabilities { + if env.permitted&(1< 0 { @@ -822,7 +846,7 @@ func (n *Nmap) scanProtocolBatchDetailedProgressWithTemplate(ctx context.Context } var lastOutput string lastFraction := 0.0 - stdout, stderr, err := runNmapInvocation(ctx, cmd, func(line string, fraction float64) { + stdout, stderr, err := runNmapInvocation(ctx, cmd, n.sandbox, func(line string, fraction float64) { lastOutput = line if fraction > lastFraction { lastFraction = fraction @@ -1197,13 +1221,16 @@ func sanitizeStderr(v string) string { // --stats-every is supplied. Run the fixed child under a private pseudo-terminal // so the daemon receives the same supported progress stream as an interactive // operator, while XML remains file-backed and bounded. -func runNmapInvocation(ctx context.Context, cmd *exec.Cmd, onOutput func(string, float64), onHeartbeat func()) ([]byte, string, error) { - xmlPath, err := prepareNmapXMLOutput(cmd) +func runNmapInvocation(ctx context.Context, cmd *exec.Cmd, policy *sandbox.Policy, onOutput func(string, float64), onHeartbeat func()) ([]byte, string, error) { + xmlPath, releaseXML, err := prepareNmapXMLOutput(cmd, policy) if err != nil { return nil, "", err } if xmlPath != "" { - defer func() { _ = os.Remove(xmlPath) }() + defer func() { + releaseXML() + _ = os.Remove(xmlPath) + }() } var callbackMu sync.Mutex emitOutput := func(line string, fraction float64) { @@ -1403,25 +1430,38 @@ func nmapXMLOutputExceeded(path string, limit int) (bool, error) { // prepareNmapXMLOutput redirects the internal "-oX -" destination emitted by // EdgeWatch's validated templates to a private file. User-defined argument // arrays cannot provide alternate output destinations, so rewriting this -// exact pair cannot broaden the scanner's command surface. -func prepareNmapXMLOutput(cmd *exec.Cmd) (string, error) { +// exact pair cannot broaden the scanner's command surface. It returns the +// file's path, which the daemon reads, and a release function to call once +// the process has exited. A confined Nmap cannot reach the temporary +// directory, so it writes the file through an inherited descriptor. +func prepareNmapXMLOutput(cmd *exec.Cmd, policy *sandbox.Policy) (string, func(), error) { for index := 0; index+1 < len(cmd.Args); index++ { if cmd.Args[index] != "-oX" || cmd.Args[index+1] != "-" { continue } file, err := os.CreateTemp("", "edgewatch-nmap-*.xml") if err != nil { - return "", fmt.Errorf("create nmap progress file: %w", err) + return "", nil, fmt.Errorf("create nmap progress file: %w", err) } path := file.Name() - if err := file.Close(); err != nil { + if !policy.Enforced() { + if err := file.Close(); err != nil { + _ = os.Remove(path) + return "", nil, fmt.Errorf("prepare nmap progress file: %w", err) + } + cmd.Args[index+1] = path + return path, func() {}, nil + } + childPath, err := policy.InheritFile(cmd, file, sandbox.Write) + if err != nil { + _ = file.Close() _ = os.Remove(path) - return "", fmt.Errorf("prepare nmap progress file: %w", err) + return "", nil, fmt.Errorf("prepare nmap progress file: %w", err) } - cmd.Args[index+1] = path - return path, nil + cmd.Args[index+1] = childPath + return path, func() { _ = file.Close() }, nil } - return "", nil + return "", func() {}, nil } // readCappedFile reads a scanner result without allowing a malformed child to diff --git a/internal/scanner/nmap_execution_coverage_test.go b/internal/scanner/nmap_execution_coverage_test.go index 97b385b2..fb2b84f2 100644 --- a/internal/scanner/nmap_execution_coverage_test.go +++ b/internal/scanner/nmap_execution_coverage_test.go @@ -173,14 +173,17 @@ func TestNmapOutputBoundariesAndProgressWriters(t *testing.T) { } cmd := exec.Command("nmap", "--reason") - if got, err := prepareNmapXMLOutput(cmd); err != nil || got != "" || cmd.Args[1] != "--reason" { + if got, release, err := prepareNmapXMLOutput(cmd, nil); err != nil || got != "" || cmd.Args[1] != "--reason" { t.Fatalf("command without XML output = %q, %v, %#v", got, err, cmd.Args) + } else { + release() } cmd = exec.Command("nmap", "-oX", "-") - path, err := prepareNmapXMLOutput(cmd) - if err != nil || path == "" || cmd.Args[2] == "-" { + path, release, err := prepareNmapXMLOutput(cmd, nil) + if err != nil || path == "" || cmd.Args[2] != path || len(cmd.ExtraFiles) != 0 { t.Fatalf("command XML output rewrite = %q, %v, %#v", path, err, cmd.Args) } + release() if _, err := os.Stat(path); err != nil { t.Fatalf("prepared XML path missing: %v", err) } diff --git a/internal/scanner/sandbox_test.go b/internal/scanner/sandbox_test.go new file mode 100644 index 00000000..918ea26f --- /dev/null +++ b/internal/scanner/sandbox_test.go @@ -0,0 +1,94 @@ +//go:build linux + +package scanner + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + + "github.com/crypt0rr/edgewatch/internal/sandbox" + "golang.org/x/sys/unix" +) + +func TestConfinedNmapGetsPrivilegedAndAnInheritedXMLFile(t *testing.T) { + t.Parallel() + n := New("/usr/bin/nmap") + policy := sandbox.NewEnforced(unix.CAP_NET_RAW) + n.SetSandbox(policy) + args := []string{"-n", "-oX", "-", "-p", "22", "192.0.2.1"} + cmd := exec.Command(n.Path, args...) + n.confineNmap(cmd) + if len(cmd.Args) < 2 || cmd.Args[1] != "--privileged" { + t.Fatalf("confined Nmap args = %q, want --privileged first", cmd.Args) + } + if args[0] != "-n" || len(args) != 6 { + t.Fatalf("confining changed the scan's own arguments, which the fingerprint uses: %q", args) + } + if cmd.SysProcAttr == nil || cmd.SysProcAttr.Credential == nil || cmd.SysProcAttr.Credential.Uid != sandbox.UID { + t.Fatalf("confined Nmap attributes = %+v", cmd.SysProcAttr) + } + path, release, err := prepareNmapXMLOutput(cmd, policy) + if err != nil { + t.Fatal(err) + } + defer os.Remove(path) + if !strings.Contains(strings.Join(cmd.Args, " "), "-oX /dev/fd/3") || len(cmd.ExtraFiles) != 1 || cmd.ExtraFiles[0].Name() != path { + t.Fatalf("confined XML output args = %q, extra files %v", cmd.Args, cmd.ExtraFiles) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o602 { + t.Fatalf("XML file mode = %o, want write-only for the confined process", info.Mode().Perm()) + } + release() + if err := cmd.ExtraFiles[0].Close(); err == nil { + t.Fatal("release left the inherited XML file open") + } +} + +func TestUnconfinedNmapKeepsItsArguments(t *testing.T) { + t.Parallel() + n := New("/usr/bin/nmap") + cmd := exec.Command(n.Path, "-n", "-oX", "-", "192.0.2.1") + n.confineNmap(cmd) + if strings.Contains(strings.Join(cmd.Args, " "), "--privileged") || cmd.SysProcAttr != nil { + t.Fatalf("unconfined Nmap = %q %+v", cmd.Args, cmd.SysProcAttr) + } + path, release, err := prepareNmapXMLOutput(cmd, nil) + if err != nil { + t.Fatal(err) + } + defer os.Remove(path) + release() + if cmd.Args[len(cmd.Args)-2] != path || len(cmd.ExtraFiles) != 0 { + t.Fatalf("unconfined XML output = %q, extra %v", cmd.Args, cmd.ExtraFiles) + } +} + +func TestConfinedNaabuReadsTargetsThroughItsDescriptor(t *testing.T) { + t.Parallel() + if os.Geteuid() == 0 { + t.Skip("as UID 0 the confined start succeeds; the container matrix covers it") + } + n := NewWithNaabu("nmap", "/bin/true") + n.SetSandbox(sandbox.NewEnforced(unix.CAP_NET_RAW)) + // An unprivileged test process cannot change identity, so the confined + // start is refused after the targets were shared, never run unconfined. + _, _, err := n.runNaabu(context.Background(), testNaabuOptions(), nil, []string{"192.0.2.1"}, true) + if err == nil || !strings.Contains(err.Error(), "operation not permitted") { + t.Fatalf("confined Naabu start = %v, want the identity change refused", err) + } +} + +func TestNaabuArgsUseTheInheritedTargetsPath(t *testing.T) { + t.Parallel() + args := naabuArgsWithTemplate(testNaabuOptions(), "/dev/fd/3", true, nil) + if len(args) < 2 || args[0] != "-list" || args[1] != "/dev/fd/3" { + t.Fatalf("Naabu args = %q", args) + } +} diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index cfcf4911..9f2c3fa0 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -101,6 +101,9 @@ func (s *Server) adminStatus(w http.ResponseWriter, r *http.Request, session sto "version": s.Version, "retention": s.App.Config.Retention.Value().String(), "rdap_enabled": s.App.Config.RDAPEnabled(), + // How scanner processes start is deployment-wide and names no + // unit's data. + "scanner_sandbox": s.App.ScannerSandbox(), } // The scan capacity is the tenant's own, as the scheduler enforces it // for its runs: the deployment's slots and probe budgets, lowered to the diff --git a/internal/web/scanner_sandbox_status_test.go b/internal/web/scanner_sandbox_status_test.go new file mode 100644 index 00000000..6dc6a086 --- /dev/null +++ b/internal/web/scanner_sandbox_status_test.go @@ -0,0 +1,46 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/crypt0rr/edgewatch/internal/store" +) + +func TestAdminStatusReportsTheScannerSandbox(t *testing.T) { + t.Parallel() + server, db, admin := newUsersTestServer(t) + viewerUser, err := defaultTenant(db).CreateUser(context.Background(), store.User{Username: "viewer", DisplayName: "Read only", Role: store.RoleViewer, PasswordHash: "hash", Enabled: true}, store.AuditEntry{}) + if err != nil { + t.Fatal(err) + } + status := func(session store.Session) map[string]json.RawMessage { + t.Helper() + rec := httptest.NewRecorder() + server.adminStatus(rec, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), session, defaultTenantStore(server)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body + } + // The test application has no sandbox policy, so its scanner processes + // start unconfined. + var sandbox struct { + Mode string `json:"mode"` + State string `json:"state"` + } + if err := json.Unmarshal(status(admin)["scanner_sandbox"], &sandbox); err != nil || sandbox.State != "disabled" { + t.Fatalf("administrator scanner_sandbox = %+v (%v), want disabled", sandbox, err) + } + viewer := store.Session{UserID: viewerUser.ID, Username: viewerUser.Username, Role: store.RoleViewer} + if _, ok := status(viewer)["scanner_sandbox"]; ok { + t.Fatal("a viewer's status names the scanner sandbox") + } +} diff --git a/scripts/verify-compose-policy.sh b/scripts/verify-compose-policy.sh index 2e255fcb..2d45463b 100755 --- a/scripts/verify-compose-policy.sh +++ b/scripts/verify-compose-policy.sh @@ -76,13 +76,21 @@ if service.get("network_mode") != "host": caps = {str(cap) for cap in service.get("cap_add") or []} if "NET_RAW" not in caps: raise SystemExit("NET_RAW is required for Nmap and Naabu") +# The daemon needs SETUID and SETGID to start Nmap and Naabu as the +# unprivileged sandbox identity, and KILL to stop them; without them the +# scanner processes would run unconfined as UID 0. +missing_sandbox_caps = sorted({"SETUID", "SETGID", "KILL"} - caps) +if missing_sandbox_caps: + raise SystemExit(f"the scanner sandbox requires cap_add: {', '.join(missing_sandbox_caps)}") if mode == "base" and "NET_ADMIN" in caps: raise SystemExit("base Compose must not grant NET_ADMIN") if mode == "syn" and "NET_ADMIN" not in caps: raise SystemExit("SYN override must grant NET_ADMIN") # The documented policy is an exact set, not a minimum: any extra entry, # including ALL, would restore capabilities that cap_drop removed. -allowed_caps = {"NET_RAW", "NET_ADMIN"} if mode == "syn" else {"NET_RAW"} +allowed_caps = {"NET_RAW", "SETUID", "SETGID", "KILL"} +if mode == "syn": + allowed_caps.add("NET_ADMIN") unexpected_caps = sorted(caps - allowed_caps) if unexpected_caps: raise SystemExit( diff --git a/scripts/verify-compose-policy.test.mjs b/scripts/verify-compose-policy.test.mjs index 8ecc2c7b..9f8baf21 100644 --- a/scripts/verify-compose-policy.test.mjs +++ b/scripts/verify-compose-policy.test.mjs @@ -22,7 +22,7 @@ const service = ({ syn = false } = {}) => ({ image: 'ghcr.io/crypt0rr/edgewatch:latest', network_mode: 'host', cap_drop: ['ALL'], - cap_add: ['NET_RAW', ...(syn ? ['NET_ADMIN'] : [])], + cap_add: ['NET_RAW', 'SETUID', 'SETGID', 'KILL', ...(syn ? ['NET_ADMIN'] : [])], security_opt: ['no-new-privileges:true'], read_only: true, tmpfs: ['/tmp:size=128m,mode=1777'], @@ -110,20 +110,22 @@ for (const [mode, label, mutate, expected] of [ ['base', 'missing no-new-privileges', (value) => { delete value.security_opt }, /no-new-privileges must be enabled/], ['base', 'missing /tmp tmpfs', (value) => { delete value.tmpfs }, /\/tmp must be backed by a bounded tmpfs/], ['base', 'missing host networking', (value) => { value.network_mode = 'bridge' }, /host networking is required/], - ['base', 'missing NET_RAW', (value) => { value.cap_add = [] }, /NET_RAW is required/], - ['base', 'NET_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'NET_ADMIN'] }, /base Compose must not grant NET_ADMIN/], - ['base', 'cap_add ALL', (value) => { value.cap_add = ['NET_RAW', 'ALL'] }, /base Compose must add only NET_RAW; unexpected cap_add: ALL/], - ['base', 'cap_add SYS_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'SYS_ADMIN'] }, /unexpected cap_add: SYS_ADMIN/], - ['base', 'cap_add SYS_ADMIN and SYS_PTRACE', (value) => { value.cap_add = ['NET_RAW', 'SYS_ADMIN', 'SYS_PTRACE'] }, /unexpected cap_add: SYS_ADMIN, SYS_PTRACE/], + ['base', 'missing NET_RAW', (value) => { value.cap_add = ['SETUID', 'SETGID', 'KILL'] }, /NET_RAW is required/], + ['base', 'missing KILL', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID'] }, /the scanner sandbox requires cap_add: KILL/], + ['base', 'missing SETUID and SETGID', (value) => { value.cap_add = ['NET_RAW', 'KILL'] }, /the scanner sandbox requires cap_add: SETGID, SETUID/], + ['base', 'NET_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'NET_ADMIN'] }, /base Compose must not grant NET_ADMIN/], + ['base', 'cap_add ALL', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'ALL'] }, /base Compose must add only KILL, NET_RAW, SETGID, SETUID; unexpected cap_add: ALL/], + ['base', 'cap_add SYS_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'SYS_ADMIN'] }, /unexpected cap_add: SYS_ADMIN/], + ['base', 'cap_add SYS_ADMIN and SYS_PTRACE', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'SYS_ADMIN', 'SYS_PTRACE'] }, /unexpected cap_add: SYS_ADMIN, SYS_PTRACE/], ['base', 'privileged mode', (value) => { value.privileged = true }, /privileged mode is not allowed/], ['base', 'seccomp=unconfined', (value) => { value.security_opt.push('seccomp=unconfined') }, /security_opt seccomp=unconfined is not allowed/], ['base', 'apparmor:unconfined', (value) => { value.security_opt.push('apparmor:unconfined') }, /security_opt apparmor:unconfined is not allowed/], ['base', 'pid: host', (value) => { value.pid = 'host' }, /pid: host is not allowed/], ['base', 'ipc: host', (value) => { value.ipc = 'host' }, /ipc: host is not allowed/], ['base', 'userns_mode: host', (value) => { value.userns_mode = 'host' }, /userns_mode: host is not allowed/], - ['syn', 'missing NET_ADMIN', (value) => { value.cap_add = ['NET_RAW'] }, /SYN override must grant NET_ADMIN/], - ['syn', 'cap_add SYS_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'NET_ADMIN', 'SYS_ADMIN'] }, /syn Compose must add only NET_ADMIN, NET_RAW; unexpected cap_add: SYS_ADMIN/], - ['syn', 'cap_add ALL', (value) => { value.cap_add = ['NET_RAW', 'NET_ADMIN', 'ALL'] }, /unexpected cap_add: ALL/], + ['syn', 'missing NET_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL'] }, /SYN override must grant NET_ADMIN/], + ['syn', 'cap_add SYS_ADMIN', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'NET_ADMIN', 'SYS_ADMIN'] }, /syn Compose must add only KILL, NET_ADMIN, NET_RAW, SETGID, SETUID; unexpected cap_add: SYS_ADMIN/], + ['syn', 'cap_add ALL', (value) => { value.cap_add = ['NET_RAW', 'SETUID', 'SETGID', 'KILL', 'NET_ADMIN', 'ALL'] }, /unexpected cap_add: ALL/], ['syn', 'privileged mode', (value) => { value.privileged = true }, /privileged mode is not allowed/], ['syn', 'seccomp=unconfined', (value) => { value.security_opt.push('seccomp=unconfined') }, /security_opt seccomp=unconfined is not allowed/], ]) { diff --git a/scripts/verify-scanner-sandbox.sh b/scripts/verify-scanner-sandbox.sh new file mode 100755 index 00000000..28ddccd8 --- /dev/null +++ b/scripts/verify-scanner-sandbox.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run real Nmap TCP SYN and UDP scans and a Naabu discovery of local +# listeners through EdgeWatch, with the scanner sandbox enforced and with it +# off, and require the same results from both. With the sandbox enforced, also +# require that a running Nmap is UID 65532 holding only NET_RAW, that +# cancelling it works, and that the sandbox identity can neither list the data +# directory nor read the configuration. +image=${1:?usage: verify-scanner-sandbox.sh IMAGE} + +workdir=$(mktemp -d) +container= +listener_pid= +cleanup() { + if [ -n "$container" ]; then + docker logs "$container" >"$workdir/last-daemon.log" 2>&1 || true + docker rm -f "$container" >/dev/null 2>&1 || true + fi + if [ -n "$listener_pid" ]; then + kill "$listener_pid" >/dev/null 2>&1 || true + fi + for data in "$workdir"/data-*; do + [ -d "$data" ] || continue + docker run --rm --volume "$data:/data" --entrypoint /bin/sh "$image" \ + -c 'rm -rf /data/* /data/.[!.]* /data/..?*' >/dev/null 2>&1 || true + done + rm -rf "$workdir" +} +trap cleanup EXIT +fail() { + echo "scanner sandbox check failed: $*" >&2 + if [ -n "$container" ]; then + docker logs "$container" 2>&1 | tail -40 >&2 || true + fi + exit 1 +} + +free_port() { + python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])' +} +tcp_open=$(free_port) +tcp_closed=$(free_port) +udp_open=$(free_port) +python3 - "$tcp_open" "$udp_open" <<'PY' & +import socket +import sys +import time + +tcp = socket.socket() +tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +tcp.bind(("127.0.0.1", int(sys.argv[1]))) +tcp.listen(16) +udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp.bind(("127.0.0.1", int(sys.argv[2]))) +time.sleep(3600) +PY +listener_pid=$! + +cat >"$workdir/driver.py" <<'PY' +"""Drive the EdgeWatch API for verify-scanner-sandbox.sh.""" +import json +import sys +import time +import urllib.error +import urllib.request + +PASSWORD = "sandbox-verification-password" + + +def request(base, state, method, path, body=None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(base + "/api/v1" + path, data=data, method=method) + req.add_header("Content-Type", "application/json") + if state.get("cookie"): + req.add_header("Cookie", state["cookie"]) + if state.get("csrf") and method != "GET": + req.add_header("X-CSRF-Token", state["csrf"]) + try: + with urllib.request.urlopen(req, timeout=30) as response: + raw = response.read() + cookie = response.headers.get("Set-Cookie") + if cookie: + state["cookie"] = cookie.split(";", 1)[0] + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as error: + raise SystemExit(f"{method} {path}: {error.code} {error.read().decode(errors='replace')}") + + +def load(path): + with open(path, encoding="utf-8") as handle: + return json.load(handle) + + +def save(path, state): + with open(path, "w", encoding="utf-8") as handle: + json.dump(state, handle) + + +def create_job(base, state, name, tcp_ports, udp_ports, timing): + job = { + "name": name, "schedule": "0 0 1 1 *", "timezone": "UTC", "targets": ["127.0.0.1"], + "tcp": {"ports": tcp_ports, "mode": "syn", "service_detection": False, "engine": "nmap"}, + "assume_alive": True, "timing": timing, "timeout": "10m", "baseline_samples": 1, + "change_confirmations": 1, "enabled": True, "notification_destinations": [], + } + if udp_ports: + job["udp"] = {"ports": udp_ports, "service_detection": False, "engine": "nmap"} + return request(base, state, "POST", "/jobs", job)["id"] + + +def create_naabu_job(base, state, name): + # A fast connect profile keeps the full-range discovery of one loopback + # address to seconds. SYN discovery of loopback drops ports at any rate + # whether or not it is sandboxed, so it cannot give comparable results. + profile = request(base, state, "POST", "/scanner-profiles", { + "name": name + "-profile", "engine": "naabu_nmap", "password": PASSWORD, + "naabu": {"scan_type": "connect", "rate": 10000, "workers": 100, "retries": 2, "timeout_ms": 1000, + "warm_up_seconds": 1, "verify": False, "address_batch_size": 16}, + }) + job = { + "name": name, "schedule": "0 0 1 1 *", "timezone": "UTC", "targets": ["127.0.0.1"], + "tcp": {"ports": "1-65535", "mode": "syn", "service_detection": False, "engine": "naabu_nmap", + "profile_id": profile["id"], "profile_revision": profile["revision"]}, + "assume_alive": True, "timing": "fast", "timeout": "10m", "baseline_samples": 1, + "change_confirmations": 1, "enabled": True, "notification_destinations": [], + } + return request(base, state, "POST", "/jobs", job)["id"] + + +def wait_for_scan(base, state, job_id, statuses, timeout=240): + deadline = time.time() + timeout + while time.time() < deadline: + scans = request(base, state, "GET", f"/jobs/{job_id}/scans")["scans"] + for scan in scans: + if scan["status"] in statuses: + return scan + time.sleep(1) + raise SystemExit(f"job {job_id} produced no scan with status {statuses}") + + +def main(): + command, base, state_path = sys.argv[1:4] + args = sys.argv[4:] + state = {} if command == "setup" else load(state_path) + if command == "setup": + request(base, state, "POST", "/setup", {"token": args[0], "password": PASSWORD}) + request(base, state, "POST", "/auth/login", {"username": "admin", "password": PASSWORD}) + state["csrf"] = request(base, state, "GET", "/auth/session")["csrf_token"] + elif command in ("scan", "naabu"): + if command == "scan": + name, tcp_ports, udp_ports = args + job_id = create_job(base, state, name, tcp_ports, udp_ports, "fast") + else: + name, wanted = args + job_id = create_naabu_job(base, state, name) + request(base, state, "POST", f"/jobs/{job_id}/run") + scan = wait_for_scan(base, state, job_id, {"success", "incomplete", "failed", "timed_out", "canceled"}) + if scan["status"] != "success": + raise SystemExit(f"scan {scan['id']} ended {scan['status']}: {scan.get('error', '')}") + results = request(base, state, "GET", f"/jobs/{job_id}/scans/{scan['id']}/results?limit=100")["results"] + observed = sorted( + (unit["protocol"], port["port"], port["state"]) + for unit in results for port in unit.get("ports") or [] + ) + if command == "naabu": + # A full-range discovery of loopback also finds the host's own + # services, which can change between runs. Report the listener. + observed = [entry for entry in observed if entry[1] == int(wanted)] + print(json.dumps(observed)) + elif command == "start": + name, tcp_ports = args + job_id = create_job(base, state, name, tcp_ports, "", "conservative") + request(base, state, "POST", f"/jobs/{job_id}/run") + deadline = time.time() + 60 + while time.time() < deadline: + active = request(base, state, "GET", "/scans/active")["scans"] + for scan in active: + if scan.get("job_id") == job_id: + print(json.dumps({"job_id": job_id, "scan_id": scan["id"]})) + save(state_path, state) + return + time.sleep(0.5) + raise SystemExit("the slow scan never became active") + elif command == "cancel": + job_id, scan_id = args + request(base, state, "POST", f"/scans/{scan_id}/cancel") + scan = wait_for_scan(base, state, job_id, {"canceled", "success", "failed", "timed_out", "incomplete"}, timeout=60) + print(scan["status"]) + else: + raise SystemExit(f"unknown command {command}") + save(state_path, state) + + +main() +PY + +# run_daemon NAME MODE [CAPABILITY...] starts a daemon whose configuration +# sets scanner.sandbox to MODE, with the bundled Compose capabilities and any +# extra ones, waits until it serves requests, and signs in as its +# administrator. +run_daemon() { + local name=$1 mode=$2 + shift 2 + local extra_caps=() + for capability in "$@"; do + extra_caps+=(--cap-add "$capability") + done + local data="$workdir/data-$name" + web_port=$(free_port) + install -d -m 0750 "$data" + ./scripts/prepare-container-smoke-data.sh "$image" "$data" + cat >"$workdir/config-$name.yaml" </dev/null; then + break + fi + if [ -z "$(docker ps -q --filter "id=$container")" ]; then + fail "the $name daemon exited during startup" + fi + sleep 1 + if [ "$attempt" = 60 ]; then + fail "the $name daemon did not start serving requests" + fi + done + token=$(docker logs "$container" 2>&1 | sed -n 's/.*"setup_token":"\([A-Z0-9]*\)".*/\1/p' | tail -1) + [ -n "$token" ] || fail "the $name daemon logged no setup token" + state="$workdir/state-$name.json" + python3 "$workdir/driver.py" setup "$base" "$state" "$token" +} + +stop_daemon() { + docker rm -f "$container" >/dev/null + container= +} + +health_state() { + docker exec "$container" edgewatch health --config /etc/edgewatch/config.yaml --output json | + python3 -c 'import json, sys; sandbox = json.load(sys.stdin)["scanner_sandbox"]; print(sandbox["state"], sandbox.get("process_uid"), ",".join(sandbox.get("capabilities") or []))' +} + +tcp_ports="$tcp_open,$tcp_closed" +udp_ports="$udp_open" + +# The bundled Compose deployment: the sandbox keeps NET_RAW only. +run_daemon base auto +[ "$(health_state)" = "enforced 65532 NET_RAW" ] || fail "health reports the sandbox as '$(health_state)', want 'enforced 65532 NET_RAW'" +sandboxed=$(python3 "$workdir/driver.py" scan "$base" "$state" sandboxed "$tcp_ports" "$udp_ports") + +# The sandbox identity cannot list the data directory or reach the +# configuration, whatever the files' own modes. +if docker exec --user 65532:65532 "$container" ls /var/lib/edgewatch >/dev/null 2>&1; then + fail "UID 65532 listed the data directory" +fi +if docker exec --user 65532:65532 "$container" cat /etc/edgewatch/config.yaml >/dev/null 2>&1; then + fail "UID 65532 read the configuration" +fi + +# Observe a running Nmap: it must be UID 65532 with only NET_RAW effective, +# and cancelling the scan must stop it. +started=$(python3 "$workdir/driver.py" start "$base" "$state" slow "1-20000") +job_id=$(printf '%s' "$started" | python3 -c 'import json, sys; print(json.load(sys.stdin)["job_id"])') +scan_id=$(printf '%s' "$started" | python3 -c 'import json, sys; print(json.load(sys.stdin)["scan_id"])') +nmap_identity= +for attempt in $(seq 1 60); do + nmap_identity=$(docker exec "$container" /bin/sh -c 'for status in /proc/[0-9]*/status; do if grep -q "^Name:[[:space:]]*nmap$" "$status" 2>/dev/null; then awk "/^Uid:/{uid=\$2} /^Gid:/{gid=\$2} /^Groups:/{groups=\$2} /^CapEff:/{cap=\$2} END{print uid, gid, (groups == \"\" ? \"-\" : groups), cap}" "$status"; break; fi; done' 2>/dev/null || true) + [ -n "$nmap_identity" ] && break + sleep 0.5 +done +[ "$nmap_identity" = "65532 65532 - 0000000000002000" ] || fail "running Nmap identity is '$nmap_identity', want '65532 65532 - 0000000000002000'" +cancelled=$(python3 "$workdir/driver.py" cancel "$base" "$state" "$job_id" "$scan_id") +[ "$cancelled" = canceled ] || fail "the cancelled scan ended '$cancelled'" +if docker exec "$container" /bin/sh -c 'grep -l "^Name:[[:space:]]*nmap$" /proc/[0-9]*/status' >/dev/null 2>&1; then + fail "Nmap kept running after the scan was cancelled" +fi +stop_daemon + +# The SYN override: a sandboxed Naabu keeps NET_RAW and NET_ADMIN, and +# discovers ports with the target list it reads through its descriptor. +run_daemon syn auto NET_ADMIN +[ "$(health_state)" = "enforced 65532 NET_RAW,NET_ADMIN" ] || fail "health reports the SYN sandbox as '$(health_state)', want 'enforced 65532 NET_RAW,NET_ADMIN'" +naabu_sandboxed=$(python3 "$workdir/driver.py" naabu "$base" "$state" naabu-sandboxed "$tcp_open") +stop_daemon + +run_daemon off off NET_ADMIN +[ "$(health_state)" = "disabled 0 " ] || fail "health reports the sandbox as '$(health_state)', want 'disabled 0'" +unconfined=$(python3 "$workdir/driver.py" scan "$base" "$state" unconfined "$tcp_ports" "$udp_ports") +naabu_unconfined=$(python3 "$workdir/driver.py" naabu "$base" "$state" naabu-unconfined "$tcp_open") +stop_daemon + +[ "$sandboxed" = "$unconfined" ] || fail "sandboxed Nmap results $sandboxed differ from unconfined results $unconfined" +printf '%s\n' "$sandboxed" | grep -q "\"tcp\", $tcp_open, \"open\"" || fail "the open TCP listener was not reported open: $sandboxed" +[ "$naabu_sandboxed" = "$naabu_unconfined" ] || fail "sandboxed Naabu results $naabu_sandboxed differ from unconfined results $naabu_unconfined" +[ "$naabu_sandboxed" = "[[\"tcp\", $tcp_open, \"open\"]]" ] || fail "Naabu did not report the open TCP listener: $naabu_sandboxed" + +echo "scanner sandbox verified for $image: Nmap $sandboxed, Naabu $naabu_sandboxed" diff --git a/src/api.ts b/src/api.ts index 2ad89e3e..eadd702a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -167,7 +167,10 @@ export type SessionUser = { user_id: string; username: string; display_name?: st export type UntrustedProxy = { peer: string; header: string; last_seen_at: string } // untrusted_proxy is present for the administrators of a deployment with one // unit; with more, only the platform status has it. -export type AdminStatus = { configured?: boolean; username?: string; display_name?: string; role?: Role; permissions?: string[]; version: string; version_release_url?: string; legacy_yaml_jobs?: string[]; notification_destinations?: number; notifications?: NotificationStatus; retention?: string; max_concurrent_scans?: number; max_probe_count?: number; max_naabu_probe_count?: number; rdap_enabled?: boolean; public_dashboard_enabled?: boolean; live_updates?: { history_size: number; dropped_events: number }; updates?: ApplicationUpdateStatus; telemetry?: DeploymentTelemetry; untrusted_proxy?: UntrustedProxy } +// How the scanner's Nmap and Naabu processes start. A confined process runs +// as process_uid 65532 with only the listed capabilities. +export type ScannerSandboxStatus = { mode: 'auto' | 'required' | 'off' | string; state: 'enforced' | 'disabled' | 'unavailable' | string; uid?: number; gid?: number; process_uid: number; capabilities?: string[]; no_new_privileges?: boolean; reason?: string } +export type AdminStatus = { configured?: boolean; username?: string; display_name?: string; role?: Role; permissions?: string[]; version: string; version_release_url?: string; legacy_yaml_jobs?: string[]; notification_destinations?: number; notifications?: NotificationStatus; retention?: string; max_concurrent_scans?: number; max_probe_count?: number; max_naabu_probe_count?: number; rdap_enabled?: boolean; public_dashboard_enabled?: boolean; live_updates?: { history_size: number; dropped_events: number }; updates?: ApplicationUpdateStatus; telemetry?: DeploymentTelemetry; untrusted_proxy?: UntrustedProxy; scanner_sandbox?: ScannerSandboxStatus } // platform_setup_available is present once the first administrator exists, // and true while the host's platform setup token can create the first // platform administrator. diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx index 5cf16d44..d9c38ba1 100644 --- a/src/pages/Dashboard.test.tsx +++ b/src/pages/Dashboard.test.tsx @@ -440,6 +440,41 @@ describe('dashboard', () => { expect(heading?.textContent).not.toContain('notification destination configured') }) + it('shows the enforced scanner sandbox in the deployment footprint', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW'], no_new_privileges: true } }) + await renderDashboard() + await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxEnforced · NET_RAW'), { timeout: 1000 }) + expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() + }) + + it('warns an administrator when scanner processes run unconfined as UID 0', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 0, reason: 'the container does not grant KILL, which EdgeWatch needs to start and stop scanner processes as UID 65532; add them to cap_add' } }) + await renderDashboard() + await vi.waitFor(() => expect(container.querySelector('.scanner-sandbox-warning')?.textContent).toContain('Scanner processes run unconfined as UID 0. the container does not grant KILL'), { timeout: 1000 }) + expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxUnavailable') + }) + + it('does not warn when scanner processes already run without root', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'unavailable', process_uid: 1000, reason: 'EdgeWatch runs as UID 1000 rather than 0' } }) + await renderDashboard() + await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxUnavailable'), { timeout: 1000 }) + expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() + }) + + it('labels a scanner sandbox that is off', async () => { + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'off', state: 'disabled', process_uid: 0, reason: 'scanner.sandbox is off' } }) + await renderDashboard() + await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxOff'), { timeout: 1000 }) + expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() + }) + + it('does not show the scanner sandbox warning to an operator', async () => { + vi.mocked(getSession).mockResolvedValue({ ...session('operator'), permissions: ['overview.read', 'jobs.read', 'jobs.write', 'scans.read', 'incidents.read'] }) + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'off', state: 'disabled', process_uid: 0 } }) + await renderDashboard() + expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() + }) + it('tells an operator that an administrator configures notifications', async () => { vi.mocked(getSession).mockResolvedValue({ ...session('operator'), permissions: ['overview.read', 'jobs.read', 'jobs.write', 'scans.read', 'incidents.read'] }) vi.mocked(adminStatus).mockResolvedValue({ ...status, notification_destinations: 0 }) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 98d7ce63..ad985f4c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { useState } from 'react' -import { Activity, AlertTriangle, Bell, CheckCircle2, Clock3, Database, Play, Radar } from 'lucide-react' +import { Activity, AlertTriangle, Bell, CheckCircle2, Clock3, Database, Play, Radar, ShieldAlert } from 'lucide-react' import { activeScans, adminStatus, cancelQueuedRun, cancelScan, getSession, listIncidents, listJobs, listScans, notificationTest, runJob } from '../api' import { Link, useNavigate } from 'react-router-dom' import { formatDate, formatDateTime, formatRetention, formatTime } from '../format' @@ -39,6 +39,10 @@ export function Dashboard() { const slots = setup.data?.max_concurrent_scans const policy = setup.data?.retention ? `Retention ${formatRetention(setup.data.retention)}${slots === undefined ? '' : ` · ${slots} scan${slots === 1 ? '' : 's'} at a time`}.` : '' const telemetry = setup.data?.telemetry + const scannerSandbox = setup.data?.scanner_sandbox + // Warn only when scanner processes run as UID 0: a daemon that runs as + // another user already starts them without root. + const scannerUnconfinedAsRoot = isAdmin && scannerSandbox?.state === 'unavailable' && scannerSandbox.process_uid === 0 const jobsMetricState = metricState(jobs) const scansMetricState = metricState(scans, canReadScans && !!session.data) const incidentsMetricState = metricState(incidents, canOperate && !!session.data) @@ -81,9 +85,9 @@ export function Dashboard() { } return

Monitoring console

Good day, {displayName}

A calm view of your network’s expected surface. {!canOperate ? '' : notificationCount ? `${notificationCount} notification destination${notificationCount === 1 ? '' : 's'} configured.` : !canManageNotifications ? 'Notifications are configured by an administrator.' : ''} {policy}

{isAdmin && }{canOperate && }
- {notifyState &&
{notifyState.state === 'success' ? : }{notifyState.text}
}{canManageNotifications && notificationCount === 0 &&
No active notification destinations — alerts are not being delivered. Add a destination.
}{runError &&
{runError}
}{setup.error && setup.refetch()} />}{canOperate && active.error && active.refetch()} />}{canOperate && incidents.error && incidents.refetch()} />}{canOperate && setup.data?.legacy_yaml_jobs?.length ?
Legacy YAML jobs are inactive. Recreate {setup.data.legacy_yaml_jobs.join(', ')} in the console to resume scheduling.
: null} + {notifyState &&
{notifyState.state === 'success' ? : }{notifyState.text}
}{canManageNotifications && notificationCount === 0 &&
No active notification destinations — alerts are not being delivered. Add a destination.
}{runError &&
{runError}
}{setup.error && setup.refetch()} />}{canOperate && active.error && active.refetch()} />}{canOperate && incidents.error && incidents.refetch()} />}{canOperate && setup.data?.legacy_yaml_jobs?.length ?
Legacy YAML jobs are inactive. Recreate {setup.data.legacy_yaml_jobs.join(', ')} in the console to resume scheduling.
: null}{scannerUnconfinedAsRoot &&
Scanner processes run unconfined as UID 0. {scannerSandbox?.reason ? `${scannerSandbox.reason}.` : ''} See “Container runtime hardening” in the EdgeWatch documentation.
}
} label="Active jobs" value={activeJobs} detail={`${ready} baselines ready`} tone="blue" status={jobsMetricState} onRetry={() => jobs.refetch()} />} label="Healthy baselines" value={ready} detail="Stable monitoring scopes" tone="green" status={jobsMetricState} onRetry={() => jobs.refetch()} />{canOperate && } label="Open incidents" value={incidentTotal} detail="Confirmed changes" tone="amber" status={incidentsMetricState} onRetry={() => incidents.refetch()} />}} label="Scan history" value={scanTotal} detail="Retained scan records" tone="purple" status={scansMetricState} onRetry={() => scans.refetch()} />
- {isAdmin && telemetry &&

Deployment footprint

Cached storage and operational scale indicators.

{telemetry.database_bytes !== undefined && }
Collected {formatTime(telemetry.collected_at, { hour: '2-digit', minute: '2-digit' })}
} + {isAdmin && telemetry &&

Deployment footprint

Cached storage and operational scale indicators.

{telemetry.database_bytes !== undefined && }{scannerSandbox && }
Collected {formatTime(telemetry.collected_at, { hour: '2-digit', minute: '2-digit' })}
} {canOperate && ((active.data?.scans.length ?? 0) > 0 || queuedRuns.length > 0) ?

{queuedRuns.length ? 'Scans in progress or queued' : 'Scans in progress'}

Broad scans can take time; progress follows Nmap task updates when available and reports process liveness between them.

{queuedRuns.map(run => )}{(active.data?.scans ?? []).map(scan => )}
: null}

Jobs at a glance

{canOperate ? 'Run or inspect any saved job.' : 'Inspect saved jobs and their baselines.'}

{jobs.isLoading ?
: jobs.error ? jobs.refetch()} /> : jobs.data?.jobs.length ?
{jobs.data.jobs.slice(0, 5).map(job =>
{job.job.name}{job.job.targets.length} targets · {job.job.schedule}
{canOperate && }
)}
:
No jobs configured yet.
}

Latest activity

The most recent scan outcomes.

{scans.isLoading ?
: scans.error ? scans.refetch()} /> : scans.data?.scans.length ?
{scans.data.scans.slice(0, 6).map(scan => )}
:
Your first scan will appear here.
}
@@ -156,6 +160,12 @@ function BaselinePill({ baseline }: { baseline: BaselineStatusInfo }) { return {presentation.label} } +function scannerSandboxLabel(status: NonNullable>['scanner_sandbox']>) { + if (status.state === 'enforced') return status.capabilities?.length ? `Enforced · ${status.capabilities.join(', ')}` : 'Enforced' + if (status.state === 'disabled') return 'Off' + return 'Unavailable' +} + function TelemetryMetric({ label, value }: { label: string; value: string }) { return
{label}{value}
} function formatBytes(value: number) {