From ddc4c0ac79c8aa32b9688e89403c7487dd3b28c3 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:23:06 +0200 Subject: [PATCH 1/2] feat: run Nmap and Naabu in a scanner sandbox Nmap, its NSE scripts and Naabu parse responses from the networks they scan, and ran as UID 0 with read access to the database and both encryption keys. They now start as UID and GID 65532 with no supplementary groups, keeping only NET_RAW (and NET_ADMIN when the container grants it) as ambient capabilities. They read their target list and write their XML through inherited file descriptors, so they can neither list the UID 0 data directory nor reach config.yaml. - scanner.sandbox: auto (default) confines scanner processes when the container allows it and otherwise runs them unconfined with a warning; required refuses to start the daemon or a scan without the sandbox; off keeps the previous behaviour. - At startup EdgeWatch checks for SETUID, SETGID and KILL and runs a short confined probe. The bundled compose.yaml adds the three capabilities; an older compose.yaml keeps scanning unconfined. - Nmap is started with --privileged so it uses its ambient NET_RAW; scan results and recorded command fingerprints are unchanged. - edgewatch health and the Overview report the sandbox state, and warn when scanner processes run unconfined as UID 0. - The image names UID 65532 edgewatch-scanner. - CI and the release smoke stage run real Nmap SYN, UDP and Naabu scans of local listeners through EdgeWatch with the sandbox enforced and off, require identical results, and check the running Nmap's identity, cancellation and data-directory access. --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 2 + AGENTS.md | 3 + Dockerfile | 4 +- SECURITY.md | 17 +- cmd/edgewatch/main.go | 61 +++- cmd/edgewatch/main_extra_test.go | 12 +- cmd/edgewatch/scanner_sandbox_test.go | 69 ++++ compose.syn.yaml | 2 + compose.yaml | 7 + config.example.yaml | 7 + .../docs/deployment/container-hardening.md | 118 +++++-- docs/src/content/docs/deployment/updates.md | 11 +- .../docs/operations/troubleshooting.md | 17 + docs/src/content/docs/reference/cli.md | 7 +- .../content/docs/reference/configuration.md | 7 + internal/app/app.go | 16 +- internal/app/scanner_sandbox_test.go | 42 +++ internal/config/config.go | 20 ++ internal/config/config_test.go | 29 ++ internal/sandbox/sandbox.go | 168 ++++++++++ internal/sandbox/sandbox_linux.go | 184 ++++++++++ internal/sandbox/sandbox_linux_test.go | 254 ++++++++++++++ internal/sandbox/sandbox_other.go | 27 ++ internal/scanner/naabu.go | 22 +- internal/scanner/nmap.go | 64 +++- .../scanner/nmap_execution_coverage_test.go | 9 +- internal/scanner/sandbox_test.go | 94 ++++++ internal/web/auth_handlers.go | 3 + internal/web/scanner_sandbox_status_test.go | 46 +++ scripts/verify-compose-policy.sh | 10 +- scripts/verify-compose-policy.test.mjs | 20 +- scripts/verify-scanner-sandbox.sh | 315 ++++++++++++++++++ src/api.ts | 5 +- src/pages/Dashboard.test.tsx | 35 ++ src/pages/Dashboard.tsx | 16 +- 36 files changed, 1657 insertions(+), 68 deletions(-) create mode 100644 cmd/edgewatch/scanner_sandbox_test.go create mode 100644 internal/app/scanner_sandbox_test.go create mode 100644 internal/sandbox/sandbox.go create mode 100644 internal/sandbox/sandbox_linux.go create mode 100644 internal/sandbox/sandbox_linux_test.go create mode 100644 internal/sandbox/sandbox_other.go create mode 100644 internal/scanner/sandbox_test.go create mode 100644 internal/web/scanner_sandbox_status_test.go create mode 100755 scripts/verify-scanner-sandbox.sh 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..3422b951 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,168 @@ +// 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"` +} + +// 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..2ee7d51e --- /dev/null +++ b/internal/sandbox/sandbox_linux.go @@ -0,0 +1,184 @@ +//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 +} + +// 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 { + return detect(mode, systemEnvironment()) +} + +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) { From 952fd1dc78f13fde509a3d7a4ff900c991d2d81e Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:32:51 +0200 Subject: [PATCH 2/2] refactor: install the sandbox's Linux behaviour through platform hooks Follow the store's platform pattern: the common file defines Detect, Confine and the capability names with hooks that only Linux installs, so no file is excluded by build constraints on the platform that measures coverage. --- internal/sandbox/sandbox.go | 42 ++++++++++++++++++++++++++ internal/sandbox/sandbox_linux.go | 24 ++++++--------- internal/sandbox/sandbox_linux_test.go | 26 ++++++++++++++++ internal/sandbox/sandbox_other.go | 27 ----------------- 4 files changed, 77 insertions(+), 42 deletions(-) delete mode 100644 internal/sandbox/sandbox_other.go diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 3422b951..03a4fc80 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -69,6 +69,48 @@ type Status struct { 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 { diff --git a/internal/sandbox/sandbox_linux.go b/internal/sandbox/sandbox_linux.go index 2ee7d51e..d0e81e35 100644 --- a/internal/sandbox/sandbox_linux.go +++ b/internal/sandbox/sandbox_linux.go @@ -43,12 +43,10 @@ type environment struct { probe func(ambient []uintptr) error } -// 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 { - return detect(mode, systemEnvironment()) +func init() { + detectPlatform = func(mode string) *Policy { return detect(mode, systemEnvironment()) } + confineProcess = confineLinux + nameCapability = linuxCapabilityName } func detect(mode string, env environment) *Policy { @@ -97,20 +95,16 @@ func detect(mode string, env environment) *Policy { return policy } -// 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() { - return - } +// confineLinux starts cmd's process as UID and GID with no supplementary +// groups and the ambient capabilities. +func confineLinux(cmd *exec.Cmd, ambient []uintptr) { if cmd.SysProcAttr == nil { cmd.SysProcAttr = &syscall.SysProcAttr{} } // An empty, non-nil Groups clears the supplementary groups. Keeping UID // 0's groups would give the process the root group's access. cmd.SysProcAttr.Credential = &syscall.Credential{Uid: UID, Gid: GID, Groups: []uint32{}} - cmd.SysProcAttr.AmbientCaps = append([]uintptr(nil), p.ambient...) + cmd.SysProcAttr.AmbientCaps = append([]uintptr(nil), ambient...) } func systemEnvironment() environment { @@ -166,7 +160,7 @@ func probeProcess(ambient []uintptr) error { return cmd.Run() } -func capabilityName(capability uintptr) string { +func linuxCapabilityName(capability uintptr) string { switch capability { case unix.CAP_NET_RAW: return "NET_RAW" diff --git a/internal/sandbox/sandbox_linux_test.go b/internal/sandbox/sandbox_linux_test.go index 86a581da..63c609ed 100644 --- a/internal/sandbox/sandbox_linux_test.go +++ b/internal/sandbox/sandbox_linux_test.go @@ -252,3 +252,29 @@ func TestProbeRunsTheBinaryConfined(t *testing.T) { t.Fatalf("unprivileged Detect = %+v, want unavailable", policy.Status()) } } + +// TestDetectWithoutThePlatformHooks covers the platforms that install no +// hooks. It replaces package hooks, so it must not run in parallel. +func TestDetectWithoutThePlatformHooks(t *testing.T) { + detect, confine, name := detectPlatform, confineProcess, nameCapability + t.Cleanup(func() { detectPlatform, confineProcess, nameCapability = detect, confine, name }) + detectPlatform, confineProcess, nameCapability = nil, nil, nil + + if got := Detect(""); got.Enforced() || got.Status().State != StateUnavailable || got.Status().Reason != "the scanner sandbox requires Linux" || got.Status().ProcessUID != os.Geteuid() { + t.Fatalf("auto without hooks = %+v", got.Status()) + } + if got := Detect("off").Status(); got.State != StateDisabled || got.Reason != "scanner.sandbox is off" { + t.Fatalf("off without hooks = %+v", got) + } + if err := Detect(ModeRequired).Require(); !errors.Is(err, ErrUnavailable) { + t.Fatalf("required without hooks = %v", err) + } + cmd := exec.Command("/bin/true") + NewEnforced(unix.CAP_NET_RAW).Confine(cmd) + if cmd.SysProcAttr != nil { + t.Fatalf("Confine without hooks changed the command: %+v", cmd.SysProcAttr) + } + if got := capabilityName(unix.CAP_NET_RAW); got != "CAP_13" { + t.Fatalf("capability name without hooks = %q", got) + } +} diff --git a/internal/sandbox/sandbox_other.go b/internal/sandbox/sandbox_other.go deleted file mode 100644 index 6c839f39..00000000 --- a/internal/sandbox/sandbox_other.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !linux - -package sandbox - -import ( - "fmt" - "os" - "os/exec" -) - -// Detect reports that scanner processes cannot be confined: the sandbox -// relies on Linux identity changes and ambient capabilities. -func Detect(mode string) *Policy { - mode = normalizedMode(mode) - if mode == ModeOff { - return &Policy{status: Status{Mode: mode, State: StateDisabled, ProcessUID: os.Geteuid(), Reason: "scanner.sandbox is off"}} - } - return &Policy{status: Status{Mode: mode, State: StateUnavailable, ProcessUID: os.Geteuid(), Reason: "the scanner sandbox requires Linux"}} -} - -// Confine leaves cmd unchanged; scanner processes are never confined outside -// Linux. -func (p *Policy) Confine(*exec.Cmd) {} - -func capabilityName(capability uintptr) string { - return fmt.Sprintf("CAP_%d", capability) -}