diff --git a/AGENTS.md b/AGENTS.md index 98592ef..8ab05c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +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 and a kernel with Landlock; it compares real sandboxed, Landlock-only, and unconfined scans of local listeners and tries Landlock escapes | +| Scanner execution or sandbox | Build the image and run `./scripts/verify-scanner-sandbox.sh IMAGE`, which needs Docker and a kernel with Landlock; it compares real sandboxed, Landlock-only, and unconfined scans of local listeners, tries Landlock escapes, and checks the seccomp filter, core limits, and a sandboxed notification delivery | | 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 | @@ -96,6 +96,7 @@ Report the checks you ran and any failures or checks you could not run. - 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`): pass private files with `InheritFile` rather than by path, read-only or write-only as the scanner uses them, and confine the command after that, because the Landlock restriction lets a scanner reopen only the files it inherited. Confined processes keep only `NET_RAW` and `NET_ADMIN` as ambient capabilities, and the bundled Compose capability set stays exact. - Start every notification child through `runNotificationProcess`, which confines it with the policy that `notify.SetSandbox` installed: its own identity without capabilities and the Landlock notifier profile, which writes no file. +- Keep `sandbox.HardenProcess` the first call in `main`, so no EdgeWatch process, including the scanners and the notification child it starts, can dump core. - 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/SECURITY.md b/SECURITY.md index d4c2cb2..bc595b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,6 +35,10 @@ scanner process, whatever its identity, to reading and executing the system directories, reading the `/etc` files a scan needs, writing the files EdgeWatch passes to it, and creating files only below `/tmp`, none of which it can execute; it cannot read the database, the keys, or `config.yaml` even as UID 0. +With Landlock, a seccomp filter also refuses the system calls no scanner or +notification process needs, such as `ptrace`, io_uring, BPF, and namespace and +mount changes. No EdgeWatch process can dump core: each sets a zero core file +size limit, which its children inherit, and is non-dumpable. All sandboxed scanner processes share UID 65532 and `/tmp`. The notification child process, which receives one destination URL, runs in a sandbox of its own with `notifications.sandbox: auto`: as UID and GID 65531 diff --git a/cmd/edgewatch/main.go b/cmd/edgewatch/main.go index 581ee52..fefe9a2 100644 --- a/cmd/edgewatch/main.go +++ b/cmd/edgewatch/main.go @@ -39,6 +39,12 @@ var exitProcess = os.Exit const daemonShutdownTimeout = 6 * time.Minute func main() { + // Every EdgeWatch process can hold a key, a destination URL, or scan + // data, so none dumps core and none can be read by a debugger of its own + // identity. + if err := sandbox.HardenProcess(); err != nil { + fmt.Fprintln(os.Stderr, "edgewatch: warning:", err) + } if err := run(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "edgewatch:", err) os.Exit(1) @@ -826,7 +832,7 @@ func logScannerSandbox(logger *slog.Logger, status sandbox.Status) { } switch { case status.Landlock.State == sandbox.StateEnforced: - logger.Info("scanner processes are restricted with Landlock", "abi", status.Landlock.ABI) + logger.Info("scanner processes are restricted with Landlock", "abi", status.Landlock.ABI, "seccomp", status.Seccomp.State, "seccomp_reason", status.Seccomp.Reason) case status.Landlock.State == sandbox.StateUnavailable: logger.Info("scanner processes start without Landlock", "reason", status.Landlock.Reason) case status.State != sandbox.StateDisabled: @@ -838,7 +844,7 @@ func logScannerSandbox(logger *slog.Logger, status sandbox.Status) { func logNotificationSandbox(logger *slog.Logger, status sandbox.Status) { switch { case status.State == sandbox.StateEnforced: - logger.Info("the notification process is sandboxed", "uid", status.UID, "gid", status.GID, "landlock", status.Landlock.State, "landlock_reason", status.Landlock.Reason) + logger.Info("the notification process is sandboxed", "uid", status.UID, "gid", status.GID, "landlock", status.Landlock.State, "landlock_reason", status.Landlock.Reason, "seccomp", status.Seccomp.State) case status.State == sandbox.StateUnavailable && status.ProcessUID == 0 && status.Landlock.State == sandbox.StateEnforced: logger.Warn("the notification process runs as UID 0, restricted only by Landlock; see the container hardening guide", "reason", status.Reason) case status.State == sandbox.StateUnavailable && status.ProcessUID == 0: diff --git a/cmd/edgewatch/scanner_sandbox_test.go b/cmd/edgewatch/scanner_sandbox_test.go index 8dfbfa1..e3286d3 100644 --- a/cmd/edgewatch/scanner_sandbox_test.go +++ b/cmd/edgewatch/scanner_sandbox_test.go @@ -54,7 +54,7 @@ func TestSandboxExecIsHandledBeforeFlagParsing(t *testing.T) { // The scanner's own flags must reach sandbox-exec untouched; flag // parsing would reject them. Malformed arguments get its usage. err := run([]string{sandbox.ExecCommand, "--config", "x", "-oX", "-"}) - if err == nil || !strings.Contains(err.Error(), "usage: sandbox-exec --profile scanner|notifier --files N -- PROGRAM") { + if err == nil || !strings.Contains(err.Error(), "usage: sandbox-exec --profile scanner|notifier --files N [--seccomp] -- PROGRAM") { t.Fatalf("sandbox-exec = %v, want its usage", err) } } @@ -145,7 +145,7 @@ func TestLogNotificationSandboxNamesTheOutcome(t *testing.T) { status sandbox.Status want string }{ - "enforced": {status: sandbox.NewEnforcedFor(sandbox.Notifier).WithLandlock("/usr/local/bin/edgewatch", 6).Status(), want: `"level":"INFO","msg":"the notification process is sandboxed","uid":65531,"gid":65531,"landlock":"enforced"`}, + "enforced": {status: sandbox.NewEnforcedFor(sandbox.Notifier).WithLandlock("/usr/local/bin/edgewatch", 6).Status(), want: `"level":"INFO","msg":"the notification process is sandboxed","uid":65531,"gid":65531,"landlock":"enforced","landlock_reason":"","seccomp":"unavailable"`}, "root with Landlock only": {status: sandbox.Status{State: sandbox.StateUnavailable, Reason: "no KILL", Landlock: restricted}, want: `"level":"WARN","msg":"the notification process runs as UID 0, restricted only by Landlock; see the container hardening guide","reason":"no KILL"`}, "unavailable root": {status: sandbox.Status{State: sandbox.StateUnavailable, Reason: "no KILL"}, want: `"level":"WARN","msg":"the notification process runs 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", Landlock: restricted}, want: `"level":"INFO","msg":"the notification process runs as the daemon's user","uid":1000,"reason":"not root","landlock":"enforced"`}, @@ -173,7 +173,7 @@ func TestLogScannerSandboxNamesTheOutcome(t *testing.T) { status: sandbox.Status{State: sandbox.StateUnavailable, Reason: "no KILL", Landlock: sandbox.LandlockStatus{State: sandbox.StateEnforced, ABI: 6}}, want: `"level":"WARN","msg":"scanner processes run as UID 0, restricted only by Landlock; see the container hardening guide","reason":"no KILL"`, }, - "Landlock": {status: sandbox.NewEnforced().WithLandlock("/usr/local/bin/edgewatch", 6).Status(), want: `"level":"INFO","msg":"scanner processes are restricted with Landlock","abi":6`}, + "Landlock": {status: sandbox.NewEnforced().WithLandlock("/usr/local/bin/edgewatch", 6).WithSeccomp().Status(), want: `"level":"INFO","msg":"scanner processes are restricted with Landlock","abi":6,"seccomp":"enforced"`}, "no Landlock": { status: sandbox.Status{State: sandbox.StateEnforced, Landlock: sandbox.LandlockStatus{State: sandbox.StateUnavailable, Reason: "old kernel"}}, want: `"level":"INFO","msg":"scanner processes start without Landlock","reason":"old kernel"`, diff --git a/docs/src/content/docs/deployment/container-hardening.md b/docs/src/content/docs/deployment/container-hardening.md index 56be609..95450ce 100644 --- a/docs/src/content/docs/deployment/container-hardening.md +++ b/docs/src/content/docs/deployment/container-hardening.md @@ -11,7 +11,9 @@ supplementary groups and only the raw-packet capabilities a scan needs. A compromised scanner process cannot read the database or the encryption keys. When the kernel provides [Landlock](#landlock), EdgeWatch also limits each scanner process to the files a scan needs, and this holds even for a scanner -process that runs as UID 0. The process that delivers notifications runs in a +process that runs as UID 0. A [seccomp filter](#seccomp-filter) refuses the +system calls no scanner needs, and [no EdgeWatch process dumps +core](#core-dumps). The process that delivers notifications runs in a [sandbox](#notification-sandbox) of its own, without capabilities. The daemon itself keeps UID 0 for compatibility. Nmap UDP and SYN scans, and @@ -60,9 +62,9 @@ published: | Runtime | Capabilities | Supported work | Result | | --- | --- | --- | --- | -| 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`, restricted with Landlock | 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`, restricted with Landlock | supported | -| UID 0 daemon, `NET_RAW` only | `NET_RAW` | the base modes as UID 0, restricted only with Landlock | supported; the identity sandbox is unavailable and EdgeWatch warns | +| 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`, restricted with Landlock and the seccomp filter | 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`, restricted with Landlock and the seccomp filter | supported | +| UID 0 daemon, `NET_RAW` only | `NET_RAW` | the base modes as UID 0, restricted only with Landlock and the seccomp filter | supported; the identity 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 | @@ -76,7 +78,9 @@ only Landlock, and with the sandbox off, and requires the same results from all three. It checks that the sandbox identity cannot read the data directory, and that a process restricted with Landlock cannot read the database, list the data directory, read `config.yaml`, write to the data -directory, or execute a file it wrote, even as UID 0. +directory, or execute a file it wrote, even as UID 0. It requires that a +running Nmap and the notification process each have one seccomp filter more +than the daemon, and that neither they nor the daemon can dump core. ## Data ownership and upgrades @@ -185,6 +189,44 @@ Docker's default seccomp profile permits the Landlock system calls. A custom profile must allow `landlock_create_ruleset`, `landlock_add_rule` and `landlock_restrict_self`, or Landlock is reported unavailable. +### Seccomp filter + +With Landlock, the `sandbox-exec` command also installs a seccomp filter on +top of the container's seccomp profile. The scanner keeps the filter, and so +does every process the scanner starts. The filter: + +- refuses with `EPERM` the system calls no scanner or notification process + needs. These trace another process or read its memory (`ptrace`, + `process_vm_readv`, `process_vm_writev`, `kcmp`), or use io_uring, + `userfaultfd`, `perf_event_open`, or `bpf`. Others reach the kernel + keyring, load kernels or modules, change mounts, namespaces, or the root + directory (`unshare`, `setns`, `chroot`), or control the host's swap, + reboot, accounting, quotas, file handles, and kernel log. On x86-64 it also + refuses port I/O and `uselib`; +- refuses a `clone` that creates a namespace, and makes `clone3`, whose flags + a filter cannot read, fail with `ENOSYS`, so that C libraries use `clone`; +- refuses the x32 ABI on x86-64, and kills a process that makes a system call + of another architecture. + +Docker's default profile already refuses most of these calls. The filter +keeps them refused under a runtime or profile that does not, and it refuses +`ptrace` and io_uring, which recent Docker profiles allow. The startup probe +runs Nmap with the filter. When Nmap cannot start that way, scanners run +with Landlock alone. `scanner_sandbox.seccomp` in `edgewatch health` reports +`enforced`, `unavailable` with the reason, or `disabled`. The filter applies +only with Landlock: `scanner.landlock: off` turns it off too. + +### Core dumps + +Every EdgeWatch process sets its soft and hard core file size limits to zero +and clears its dumpable flag at startup. Its child processes inherit the +limit and cannot raise it. A crash of the daemon, the notification process, +or a scanner therefore leaves no core file. Such a file would hold the keys, +a destination URL, or scan data, and could reach a core handler on the host +outside the container. A non-dumpable process cannot be traced, and its +memory cannot be read, by another process of its identity without +`CAP_SYS_PTRACE`. + ### Upgrading an existing deployment A `compose.yaml` from an earlier release adds only `NET_RAW`. With it, @@ -233,7 +275,10 @@ A sandboxed notification process: scanner process can neither signal it nor read its memory; - is restricted with [Landlock](#landlock), when the kernel provides it, to the same system files as a scanner process, without `/tmp`: it can write no file - at all; + at all. The [seccomp filter](#seccomp-filter) applies as it does to + scanners; +- is non-dumpable for its whole run, so another process of its identity can + neither trace it nor read the destination URL from its memory; - keeps its network access and the proxy, time zone, and certificate authority variables the daemon passes to it: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`, `LANG`, and diff --git a/docs/src/content/docs/operations/troubleshooting.md b/docs/src/content/docs/operations/troubleshooting.md index e090e85..6d9ad4f 100644 --- a/docs/src/content/docs/operations/troubleshooting.md +++ b/docs/src/content/docs/operations/troubleshooting.md @@ -87,7 +87,12 @@ reports `scanner_sandbox.landlock.state` as `unavailable`, when reason ends with Nmap's last diagnostic line. Scans keep working without Landlock, in the identity sandbox when it is -enforced. Set `scanner.landlock: required` to refuse to scan without it +enforced. + +`scanner_sandbox.seccomp.state` is `unavailable` when the kernel offers no +seccomp filters, or the container's seccomp profile blocks them, or when +Nmap could not start with the [seccomp filter](/deployment/container-hardening/#seccomp-filter). +In that case Landlock still applies alone, and the reason names the cause. Set `scanner.landlock: required` to refuse to scan without it instead, or `scanner.landlock: off` to stop trying. ## Proxy hostname rejected diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 175456e..0398db6 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -116,7 +116,8 @@ 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. Its `landlock` object reports the Landlock restriction with its own `state` and -`reason`, and `abi`, the kernel's Landlock version. See +`reason`, and `abi`, the kernel's Landlock version, and its `seccomp` object +reports the seccomp filter with its `state` and `reason`. See [the scanner sandbox](/deployment/container-hardening/#scanner-sandbox). `notification_sandbox` reports the process that delivers notifications in the same form; see diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 5d55c55..74b2ed1 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -140,8 +140,10 @@ Jobs are configured in the console, which enforces these limits: `off` also turns off Landlock. - `scanner.landlock: auto` also restricts Nmap and Naabu with Landlock to the system files a scan reads, the files EdgeWatch passes to them, and `/tmp`, - when the kernel provides it. Set `required` to refuse to scan without it, or - `off` if a scanner needs files outside those paths. + when the kernel provides it, and installs a seccomp filter that refuses the + system calls no scanner needs. Set `required` to refuse to scan without + Landlock, or `off`, which turns off the filter too, if a scanner needs files + outside those paths. - `notifications.sandbox: auto` delivers notifications from a process that runs as UID 65531 without capabilities, restricted with Landlock, when the container grants `SETUID`, `SETGID` and `KILL` and that process can read the diff --git a/internal/sandbox/landlock_linux.go b/internal/sandbox/landlock_linux.go index a7d76a5..f284a8a 100644 --- a/internal/sandbox/landlock_linux.go +++ b/internal/sandbox/landlock_linux.go @@ -145,17 +145,19 @@ func handledScopes(abi int) uint64 { // execLandlocked is the sandbox-exec command: // -// sandbox-exec --profile PROFILE --files N -- PROGRAM [ARGUMENT...] +// sandbox-exec --profile PROFILE --files N [--seccomp] -- PROGRAM [ARGUMENT...] // // It restricts its own process with Landlock to the paths of PROFILE and its -// N inherited descriptors, starting at 3, sets no_new_privs, and then -// executes PROGRAM in place. The process keeps its identity, capabilities, -// descriptors, and environment, and the daemon's handle on it stays valid. +// N inherited descriptors, starting at 3, sets no_new_privs, installs the +// seccomp filter with --seccomp, and then executes PROGRAM in place. The +// process keeps its identity, capabilities, descriptors, and environment, and +// the daemon's handle on it stays valid. func execLandlocked(args []string) error { - profile, files, argv, err := parseExecArgs(args) + request, err := parseExecArgs(args) if err != nil { return err } + profile, files, argv := request.profile, request.files, request.argv // no_new_privs and the Landlock domain belong to the calling thread, and // PROGRAM executes from it, so the goroutine must not move to another // thread. The process ends with the exec, so the thread stays locked. @@ -167,26 +169,62 @@ func execLandlocked(args []string) error { if err := restrictSelf(profilePaths(profile, os.Getenv), inherited); err != nil { return fmt.Errorf("%s: %w", ExecCommand, err) } + if request.seccomp { + if err := installSeccompFilter(); err != nil { + return fmt.Errorf("%s: %w", ExecCommand, err) + } + } if err := syscall.Exec(argv[0], argv, os.Environ()); err != nil { return fmt.Errorf("%s: execute %s: %w", ExecCommand, argv[0], err) } return nil } -func parseExecArgs(args []string) (Profile, int, []string, error) { - usage := fmt.Errorf("usage: %s --profile scanner|notifier --files N -- PROGRAM [ARGUMENT...]", ExecCommand) - if len(args) < 6 || args[0] != "--profile" || args[2] != "--files" || args[4] != "--" { - return "", 0, nil, usage +// execRequest is what the sandbox-exec arguments ask for. +type execRequest struct { + profile Profile + files int + seccomp bool + argv []string +} + +func parseExecArgs(args []string) (execRequest, error) { + usage := fmt.Errorf("usage: %s --profile scanner|notifier --files N [--seccomp] -- PROGRAM [ARGUMENT...]", ExecCommand) + request := execRequest{files: -1} + for index := 0; index < len(args); index++ { + switch args[index] { + case "--profile": + if index+1 >= len(args) || request.profile != "" { + return execRequest{}, usage + } + index++ + request.profile = Profile(args[index]) + case "--files": + if index+1 >= len(args) || request.files >= 0 { + return execRequest{}, usage + } + index++ + files, err := strconv.Atoi(args[index]) + if err != nil || files < 0 || files > maxInheritedFiles { + return execRequest{}, usage + } + request.files = files + case "--seccomp": + request.seccomp = true + case "--": + request.argv = args[index+1:] + index = len(args) + default: + return execRequest{}, usage + } } - profile := Profile(args[1]) - files, err := strconv.Atoi(args[3]) - if !profile.valid() || err != nil || files < 0 || files > maxInheritedFiles { - return "", 0, nil, usage + if !request.profile.valid() || request.files < 0 || len(request.argv) == 0 { + return execRequest{}, usage } - if !filepath.IsAbs(args[5]) { - return "", 0, nil, fmt.Errorf("%s: the program %q is not an absolute path", ExecCommand, args[5]) + if !filepath.IsAbs(request.argv[0]) { + return execRequest{}, fmt.Errorf("%s: the program %q is not an absolute path", ExecCommand, request.argv[0]) } - return profile, files, args[5:], nil + return request, nil } // profilePaths are landlockPaths, the search path files of musl's dynamic diff --git a/internal/sandbox/landlock_linux_test.go b/internal/sandbox/landlock_linux_test.go index 49f0fc7..b151359 100644 --- a/internal/sandbox/landlock_linux_test.go +++ b/internal/sandbox/landlock_linux_test.go @@ -15,8 +15,12 @@ import ( "golang.org/x/sys/unix" ) +// hardenCheck is the argument with which the test binary hardens itself and +// prints its dumpable flag and core file size limits. +const hardenCheck = "harden-check" + // TestMain lets the test binary stand in for the EdgeWatch executable as the -// sandbox-exec command. +// sandbox-exec command, and harden a process of its own. func TestMain(m *testing.M) { if len(os.Args) > 1 && os.Args[1] == ExecCommand { if err := Exec(os.Args[2:]); err != nil { @@ -24,6 +28,23 @@ func TestMain(m *testing.M) { } os.Exit(126) } + if len(os.Args) > 1 && os.Args[1] == hardenCheck { + if err := HardenProcess(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) + var limit unix.Rlimit + if err == nil { + err = unix.Getrlimit(unix.RLIMIT_CORE, &limit) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(dumpable, limit.Cur, limit.Max) + os.Exit(0) + } os.Exit(m.Run()) } @@ -38,12 +59,13 @@ func requireLandlock(t *testing.T) int { func TestParseExecArgs(t *testing.T) { t.Parallel() - profile, files, argv, err := parseExecArgs([]string{"--profile", "scanner", "--files", "2", "--", "/usr/bin/nmap", "--privileged", "-oX", "/dev/fd/3"}) - if err != nil || profile != Scanner || files != 2 || strings.Join(argv, " ") != "/usr/bin/nmap --privileged -oX /dev/fd/3" { - t.Fatalf("parse = %q %d %q %v", profile, files, argv, err) + request, err := parseExecArgs([]string{"--profile", "scanner", "--files", "2", "--", "/usr/bin/nmap", "--privileged", "-oX", "/dev/fd/3"}) + if err != nil || request.profile != Scanner || request.files != 2 || request.seccomp || strings.Join(request.argv, " ") != "/usr/bin/nmap --privileged -oX /dev/fd/3" { + t.Fatalf("parse = %+v %v", request, err) } - if profile, _, _, err := parseExecArgs([]string{"--profile", "notifier", "--files", "0", "--", "/usr/local/bin/edgewatch", "notify-send"}); err != nil || profile != Notifier { - t.Fatalf("notifier parse = %q %v", profile, err) + request, err = parseExecArgs([]string{"--seccomp", "--files", "0", "--profile", "notifier", "--", "/usr/local/bin/edgewatch", "notify-send"}) + if err != nil || request.profile != Notifier || !request.seccomp || request.files != 0 { + t.Fatalf("notifier parse = %+v %v", request, err) } for name, args := range map[string][]string{ "empty": nil, @@ -54,12 +76,18 @@ func TestParseExecArgs(t *testing.T) { "bad count": {"--profile", "scanner", "--files", "x", "--", "/usr/bin/nmap"}, "negative": {"--profile", "scanner", "--files", "-1", "--", "/usr/bin/nmap"}, "too many files": {"--profile", "scanner", "--files", "17", "--", "/usr/bin/nmap"}, + "no count": {"--profile", "scanner", "--", "/usr/bin/nmap"}, + "profile twice": {"--profile", "scanner", "--profile", "notifier", "--files", "0", "--", "/usr/bin/nmap"}, + "count twice": {"--profile", "scanner", "--files", "0", "--files", "1", "--", "/usr/bin/nmap"}, + "dangling flag": {"--profile"}, + "dangling count": {"--profile", "scanner", "--files"}, + "unknown flag": {"--profile", "scanner", "--files", "0", "--network", "--", "/usr/bin/nmap"}, } { - if _, _, _, err := parseExecArgs(args); err == nil || !strings.Contains(err.Error(), "usage: sandbox-exec") { + if _, err := parseExecArgs(args); err == nil || !strings.Contains(err.Error(), "usage: sandbox-exec") { t.Errorf("%s: parse = %v, want usage", name, err) } } - if _, _, _, err := parseExecArgs([]string{"--profile", "scanner", "--files", "0", "--", "nmap"}); err == nil || !strings.Contains(err.Error(), "not an absolute path") { + if _, err := parseExecArgs([]string{"--profile", "scanner", "--files", "0", "--", "nmap"}); err == nil || !strings.Contains(err.Error(), "not an absolute path") { t.Fatalf("relative program = %v", err) } if err := execLandlocked([]string{"--files"}); err == nil || !strings.Contains(err.Error(), "usage") { diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 3cd3f33..8bb4357 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -113,6 +113,10 @@ func profileOrScanner(profile Profile) Profile { return profile } +// seccompWithoutLandlock is why the seccomp filter does not apply: the +// sandbox-exec command that installs it runs only with Landlock. +const seccompWithoutLandlock = "the seccomp filter applies only with Landlock" + // ExecCommand is the hidden EdgeWatch command through which a process // restricted with Landlock starts. const ExecCommand = "sandbox-exec" @@ -144,6 +148,17 @@ type Status struct { // Landlock describes the restriction of the files the processes can // open. Landlock LandlockStatus `json:"landlock"` + // Seccomp describes the filter of the system calls the processes can + // make, which the Landlock restriction installs. + Seccomp SeccompStatus `json:"seccomp"` +} + +// SeccompStatus describes the seccomp filter of the processes. +type SeccompStatus struct { + // State is enforced, disabled, or unavailable. + State string `json:"state"` + // Reason explains a disabled or unavailable filter. + Reason string `json:"reason,omitempty"` } // LandlockStatus describes how the files the processes can open are @@ -202,8 +217,23 @@ var ( confineProcess func(cmd *exec.Cmd, uid, gid int, ambient []uintptr) nameCapability func(capability uintptr) string execRestricted func(args []string) error + hardenProcess func() error ) +// HardenProcess keeps the calling process, and every process it starts, from +// dumping core, and makes the calling process non-dumpable, so that a process +// of the same identity without CAP_SYS_PTRACE can neither trace it nor read +// its memory. A core dump of the daemon, the notification process, or a +// scanner would hold the keys, a destination URL, or scan data, and the +// host's core handler would keep it outside the container. Elsewhere than +// Linux it does nothing. +func HardenProcess() error { + if hardenProcess == nil { + return nil + } + return hardenProcess() +} + // Detect decides how the processes of a profile start in this runtime for the // configured modes. Unless a mode is off, it starts short-lived confined // processes to confirm that the runtime allows the identity change, the @@ -219,6 +249,7 @@ func Detect(options Options) *Policy { status := Status{ Mode: options.Mode, State: StateUnavailable, ProcessUID: os.Geteuid(), Reason: "the sandbox requires Linux", Landlock: LandlockStatus{Mode: options.Landlock, State: StateUnavailable, Reason: "Landlock requires Linux"}, + Seccomp: SeccompStatus{State: StateUnavailable, Reason: seccompWithoutLandlock}, } switch { case options.Mode == ModeOff: @@ -254,17 +285,17 @@ func (p *Policy) Confine(cmd *exec.Cmd) { confineProcess(cmd, spec.uid, spec.gid, p.ambient) } if p.helper != "" { - startThroughHelper(cmd, p.helper, p.profileName()) + startThroughHelper(cmd, p.helper, p.profileName(), p.seccomp) } } // startThroughHelper makes cmd start the sandbox-exec command of the EdgeWatch -// executable helper, which restricts itself for profile and then executes -// cmd's program with cmd's arguments and the descriptors cmd passes. A -// program that does not exist makes cmd's start fail with that error instead, -// so callers still recognize a missing scanner, and never starts it without -// the restriction. -func startThroughHelper(cmd *exec.Cmd, helper string, profile Profile) { +// executable helper, which restricts itself for profile, with the seccomp +// filter when seccomp is set, and then executes cmd's program with cmd's +// arguments and the descriptors cmd passes. A program that does not exist +// makes cmd's start fail with that error instead, so callers still recognize +// a missing scanner, and never starts it without the restriction. +func startThroughHelper(cmd *exec.Cmd, helper string, profile Profile, seccomp bool) { if cmd.Err != nil { return } @@ -272,7 +303,11 @@ func startThroughHelper(cmd *exec.Cmd, helper string, profile Profile) { cmd.Err = err return } - args := []string{helper, ExecCommand, "--profile", string(profile), "--files", strconv.Itoa(len(cmd.ExtraFiles)), "--", cmd.Path} + args := []string{helper, ExecCommand, "--profile", string(profile), "--files", strconv.Itoa(len(cmd.ExtraFiles))} + if seccomp { + args = append(args, "--seccomp") + } + args = append(args, "--", cmd.Path) cmd.Args = append(args, cmd.Args[1:]...) cmd.Path = helper } @@ -294,6 +329,8 @@ type Policy struct { // helper is the EdgeWatch executable whose sandbox-exec command restricts // the processes with Landlock. Empty starts them without it. helper string + // seccomp makes sandbox-exec also install the seccomp filter. + seccomp bool } func (p *Policy) profileName() Profile { @@ -354,6 +391,7 @@ func NewEnforcedFor(profile Profile, ambient ...uintptr) *Policy { status: Status{ Mode: ModeAuto, State: StateEnforced, UID: spec.uid, GID: spec.gid, ProcessUID: spec.uid, Capabilities: names, Landlock: LandlockStatus{Mode: ModeOff, State: StateDisabled, Reason: spec.landlockSetting + " is off"}, + Seccomp: SeccompStatus{State: StateDisabled, Reason: seccompWithoutLandlock}, }, enforce: true, ambient: append([]uintptr(nil), ambient...), @@ -373,11 +411,25 @@ func (p *Policy) WithLandlock(helper string, abi int) *Policy { restricted = &copied } restricted.helper = helper + restricted.seccomp = false restricted.status.NoNewPrivileges = true restricted.status.Landlock = LandlockStatus{Mode: ModeAuto, State: StateEnforced, ABI: abi} + restricted.status.Seccomp = SeccompStatus{State: StateUnavailable, Reason: "the seccomp filter was not requested"} return restricted } +// WithSeccomp returns a copy of p, which must restrict its processes with +// Landlock, whose sandbox-exec command also installs the seccomp filter, +// without checking that the kernel allows it. +func (p *Policy) WithSeccomp() *Policy { + filtered := *p + filtered.status.Capabilities = append([]string(nil), p.status.Capabilities...) + filtered.ambient = append([]uintptr(nil), p.ambient...) + filtered.seccomp = true + filtered.status.Seccomp = SeccompStatus{State: StateEnforced} + return &filtered +} + // Enforced reports whether the processes start as the confined identity with // only the ambient capabilities. func (p *Policy) Enforced() bool { @@ -402,6 +454,7 @@ func (p *Policy) Status() Status { return Status{ Mode: ModeOff, State: StateDisabled, ProcessUID: os.Geteuid(), Reason: "the processes are not confined", Landlock: LandlockStatus{Mode: ModeOff, State: StateDisabled, Reason: "the processes are not confined"}, + Seccomp: SeccompStatus{State: StateDisabled, Reason: "the processes are not confined"}, } } status := p.status diff --git a/internal/sandbox/sandbox_linux.go b/internal/sandbox/sandbox_linux.go index e6a1fd8..0aab267 100644 --- a/internal/sandbox/sandbox_linux.go +++ b/internal/sandbox/sandbox_linux.go @@ -42,6 +42,8 @@ type environment struct { // it has none. landlockABI int landlockErr error + // seccompErr is why the kernel cannot run the seccomp filter, or nil. + seccompErr error // executable is the EdgeWatch executable, or executableErr why it cannot // start sandboxed processes. executable string @@ -89,6 +91,20 @@ func init() { confineProcess = confineLinux nameCapability = linuxCapabilityName execRestricted = execLandlocked + hardenProcess = hardenLinux +} + +// hardenLinux sets a soft and hard core file size limit of zero, which the +// processes this one starts inherit and cannot raise, and clears the dumpable +// flag, which execve sets again for the program it runs. +func hardenLinux() error { + if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{}); err != nil { + return fmt.Errorf("disable core dumps: %w", err) + } + if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { + return fmt.Errorf("make the process non-dumpable: %w", err) + } + return nil } func detect(options Options, env environment) *Policy { @@ -100,6 +116,7 @@ func detect(options Options, env environment) *Policy { status.State = StateDisabled status.Reason = spec.sandboxSetting + " is off" status.Landlock = LandlockStatus{Mode: normalizedMode(options.Landlock), State: StateDisabled, Reason: spec.sandboxSetting + " is off"} + status.Seccomp = SeccompStatus{State: StateDisabled, Reason: seccompWithoutLandlock} return &Policy{profile: profile, status: status} } return detectLandlock(detectIdentity(profile, mode, status, options.IdentityProbe, env), options, env) @@ -159,6 +176,7 @@ func detectLandlock(policy *Policy, options Options, env environment) *Policy { unrestricted := func(state, reason string) *Policy { status.State, status.Reason = state, reason policy.status.Landlock = status + policy.status.Seccomp = SeccompStatus{State: state, Reason: seccompWithoutLandlock} return policy } switch { @@ -173,11 +191,31 @@ func detectLandlock(policy *Policy, options Options, env environment) *Policy { } restricted := policy.WithLandlock(env.executable, env.landlockABI) restricted.status.Landlock.Mode = mode - for _, probe := range options.Probes { - if err := env.runProbe(restricted, probe); err != nil { - return unrestricted(StateUnavailable, fmt.Sprintf("%s could not start with Landlock: %v", probeName(probe), err)) + probe := func(candidate *Policy) (string, error) { + for _, probe := range options.Probes { + if err := env.runProbe(candidate, probe); err != nil { + return probeName(probe), err + } } + return "", nil + } + // The seccomp filter rides on the Landlock restriction. When the probes + // fail with it, Landlock may still apply alone. + seccompReason := "" + if env.seccompErr != nil { + seccompReason = seccompUnavailableReason(env.seccompErr) + } else { + filtered := restricted.WithSeccomp() + name, err := probe(filtered) + if err == nil { + return filtered + } + seccompReason = fmt.Sprintf("%s could not start with the seccomp filter: %v", name, err) + } + if name, err := probe(restricted); err != nil { + return unrestricted(StateUnavailable, fmt.Sprintf("%s could not start with Landlock: %v", name, err)) } + restricted.status.Seccomp = SeccompStatus{State: StateUnavailable, Reason: seccompReason} return restricted } @@ -208,6 +246,7 @@ func confineLinux(cmd *exec.Cmd, uid, gid int, ambient []uintptr) { func systemEnvironment() environment { env := environment{euid: os.Geteuid(), run: runConfined} env.landlockABI, env.landlockErr = landlockVersion() + env.seccompErr = seccompSupport() env.executable, env.executableErr = os.Executable() if env.executableErr == nil { env.executableErr = refuseTestBinary(env.executable) diff --git a/internal/sandbox/sandbox_linux_test.go b/internal/sandbox/sandbox_linux_test.go index cfb22c5..f3ecb0e 100644 --- a/internal/sandbox/sandbox_linux_test.go +++ b/internal/sandbox/sandbox_linux_test.go @@ -64,7 +64,7 @@ func TestDetectEnforcesWithTheContainerNetworkCapabilities(t *testing.T) { if !policy.Enforced() || !policy.Restricted() { t.Fatalf("policy = %+v, want enforced", policy.Status()) } - want := Status{Mode: ModeAuto, State: StateEnforced, UID: UID, GID: GID, ProcessUID: UID, Capabilities: []string{"NET_RAW"}, NoNewPrivileges: true, Landlock: enforcedLandlock} + want := Status{Mode: ModeAuto, State: StateEnforced, UID: UID, GID: GID, ProcessUID: UID, Capabilities: []string{"NET_RAW"}, NoNewPrivileges: true, Landlock: enforcedLandlock, Seccomp: SeccompStatus{State: StateEnforced}} if got := policy.Status(); !reflect.DeepEqual(got, want) { t.Fatalf("status = %+v, want %+v", got, want) } @@ -177,6 +177,58 @@ func TestDetectProbesEachScannerWithLandlock(t *testing.T) { } } +func TestDetectAddsTheSeccompFilterWhenItWorks(t *testing.T) { + t.Parallel() + nmap := []Probe{{Args: []string{"/usr/bin/nmap", "--version"}}} + env, _ := containerEnvironment() + var filtered []bool + env.run = func(policy *Policy, command, _ []string) error { + if policy.Restricted() { + filtered = append(filtered, policy.seccomp) + } + return nil + } + policy := detect(Options{Probes: nmap}, env) + if !policy.seccomp || policy.Status().Seccomp.State != StateEnforced || !reflect.DeepEqual(filtered, []bool{true}) { + t.Fatalf("seccomp = %v %+v, probes with the filter %v", policy.seccomp, policy.Status().Seccomp, filtered) + } + + // A scanner that fails only with the filter keeps Landlock. + env.run = failing(func(policy *Policy) bool { return policy.seccomp }, errors.New("signal: bad system call")) + policy = detect(Options{Probes: nmap}, env) + status := policy.Status() + if policy.seccomp || !policy.Restricted() || status.Landlock.State != StateEnforced || status.Seccomp.State != StateUnavailable || + status.Seccomp.Reason != "nmap could not start with the seccomp filter: signal: bad system call" { + t.Fatalf("status without the filter = %+v", status) + } + + // A kernel without the filter's actions keeps Landlock. + env, _ = containerEnvironment() + env.seccompErr = unix.ENOSYS + if got := detect(Options{Probes: nmap}, env).Status(); got.Landlock.State != StateEnforced || got.Seccomp.State != StateUnavailable || !strings.Contains(got.Seccomp.Reason, "does not provide seccomp filters") { + t.Fatalf("status without seccomp support = %+v", got) + } + + // Without Landlock, nothing installs the filter. + env.landlockErr = unix.ENOSYS + if got := detect(Options{Probes: nmap}, env).Status().Seccomp; got.State != StateUnavailable || got.Reason != "the seccomp filter applies only with Landlock" { + t.Fatalf("seccomp without Landlock = %+v", got) + } + if got := detect(Options{Landlock: ModeOff}, env).Status().Seccomp; got.State != StateDisabled { + t.Fatalf("seccomp with Landlock off = %+v", got) + } + if got := detect(Options{Mode: ModeOff}, env).Status().Seccomp; got.State != StateDisabled { + t.Fatalf("seccomp with the sandbox off = %+v", got) + } + + // The filter reaches sandbox-exec as an argument. + cmd := exec.Command("/bin/true") + NewEnforced().WithLandlock("/usr/local/bin/edgewatch", 6).WithSeccomp().Confine(cmd) + if want := []string{"/usr/local/bin/edgewatch", ExecCommand, "--profile", "scanner", "--files", "0", "--seccomp", "--", "/bin/true"}; !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("filtered command = %q, want %q", cmd.Args, want) + } +} + func TestDetectConfinesTheNotificationProcessWithoutCapabilities(t *testing.T) { t.Parallel() env, _ := containerEnvironment(unix.CAP_NET_ADMIN) diff --git a/internal/sandbox/seccomp_linux.go b/internal/sandbox/seccomp_linux.go new file mode 100644 index 0000000..9f70d7f --- /dev/null +++ b/internal/sandbox/seccomp_linux.go @@ -0,0 +1,246 @@ +//go:build linux + +package sandbox + +import ( + "errors" + "fmt" + "runtime" + "unsafe" + + "golang.org/x/sys/unix" +) + +// seccompDenied are the system calls the seccomp filter refuses with EPERM. +// No scanner or notification process needs them, and each reaches other +// processes or a large part of the kernel: tracing another process and +// reading its memory, io_uring, user-space page faults, performance events, +// BPF programs, the kernel keyring, loading kernels and modules, mounts and +// namespaces, a new root directory, and the host's swap, reboot, accounting, +// quota, file handle, and log controls. Docker's default profile refuses most +// of them already; the filter keeps them refused where it does not. +var seccompDenied = []string{ + "ptrace", "process_vm_readv", "process_vm_writev", "kcmp", + "io_uring_setup", "io_uring_enter", "io_uring_register", + "userfaultfd", "perf_event_open", "bpf", + "keyctl", "add_key", "request_key", + "kexec_load", "kexec_file_load", "init_module", "finit_module", "delete_module", + "mount", "umount2", "pivot_root", "move_mount", "open_tree", "fsopen", "fsconfig", "fsmount", "fspick", "mount_setattr", + "unshare", "setns", "chroot", + "swapon", "swapoff", "reboot", "acct", "quotactl", "quotactl_fd", + "name_to_handle_at", "open_by_handle_at", "lookup_dcookie", "syslog", +} + +// seccompDeniedX86 are the x86-64 calls without an arm64 counterpart: port +// I/O and loading a shared library. +var seccompDeniedX86 = []string{"iopl", "ioperm", "uselib"} + +// cloneNamespaces are the clone flags that create namespaces. clone with any +// of them is refused like unshare. clone3 passes its flags in memory, which a +// filter cannot read, so it fails with ENOSYS and the C libraries fall back to +// clone; Go uses clone3 only for cgroup and time namespace options. +const cloneNamespaces = unix.CLONE_NEWNS | unix.CLONE_NEWCGROUP | unix.CLONE_NEWUTS | unix.CLONE_NEWIPC | + unix.CLONE_NEWUSER | unix.CLONE_NEWPID | unix.CLONE_NEWNET + +// x32Bit marks the system call numbers of the x32 ABI on x86-64. +const x32Bit = 0x40000000 + +// seccompArchitecture is how the filter recognizes one architecture's system +// calls. +type seccompArchitecture struct { + audit uint32 + // x32 refuses the x32 ABI, which numbers its calls from x32Bit. + x32 bool + numbers map[string]uint32 +} + +// seccompArchitectures are the release architectures. Their numbers come from +// the kernel's system call tables, which golang.org/x/sys mirrors; a test +// compares them. +var seccompArchitectures = map[string]seccompArchitecture{ + "amd64": {audit: unix.AUDIT_ARCH_X86_64, x32: true, numbers: map[string]uint32{ + "ptrace": 101, "process_vm_readv": 310, "process_vm_writev": 311, "kcmp": 312, + "io_uring_setup": 425, "io_uring_enter": 426, "io_uring_register": 427, + "userfaultfd": 323, "perf_event_open": 298, "bpf": 321, + "keyctl": 250, "add_key": 248, "request_key": 249, + "kexec_load": 246, "kexec_file_load": 320, "init_module": 175, "finit_module": 313, "delete_module": 176, + "mount": 165, "umount2": 166, "pivot_root": 155, "move_mount": 429, "open_tree": 428, "fsopen": 430, "fsconfig": 431, "fsmount": 432, "fspick": 433, "mount_setattr": 442, + "unshare": 272, "setns": 308, "chroot": 161, + "swapon": 167, "swapoff": 168, "reboot": 169, "acct": 163, "quotactl": 179, "quotactl_fd": 443, + "name_to_handle_at": 303, "open_by_handle_at": 304, "lookup_dcookie": 212, "syslog": 103, + "iopl": 172, "ioperm": 173, "uselib": 134, + "clone": 56, "clone3": 435, + }}, + "arm64": {audit: unix.AUDIT_ARCH_AARCH64, numbers: map[string]uint32{ + "ptrace": 117, "process_vm_readv": 270, "process_vm_writev": 271, "kcmp": 272, + "io_uring_setup": 425, "io_uring_enter": 426, "io_uring_register": 427, + "userfaultfd": 282, "perf_event_open": 241, "bpf": 280, + "keyctl": 219, "add_key": 217, "request_key": 218, + "kexec_load": 104, "kexec_file_load": 294, "init_module": 105, "finit_module": 273, "delete_module": 106, + "mount": 40, "umount2": 39, "pivot_root": 41, "move_mount": 429, "open_tree": 428, "fsopen": 430, "fsconfig": 431, "fsmount": 432, "fspick": 433, "mount_setattr": 442, + "unshare": 97, "setns": 268, "chroot": 51, + "swapon": 224, "swapoff": 225, "reboot": 142, "acct": 89, "quotactl": 60, "quotactl_fd": 443, + "name_to_handle_at": 264, "open_by_handle_at": 265, "lookup_dcookie": 18, "syslog": 116, + "clone": 220, "clone3": 435, + }}, +} + +// The offsets of struct seccomp_data that the filter reads. Both release +// architectures are little-endian, so the clone flags are the low word of the +// first argument. +const ( + seccompNumber = 0 + seccompArch = 4 + seccompFirstArgLow = 16 +) + +// bpfStep is one filter instruction whose conditional jumps name labels; an +// empty label continues with the next instruction. +type bpfStep struct { + label string + instruction unix.SockFilter + jumpTrue string + jumpFalse string +} + +// seccompFilter compiles the filter for arch: a call of another architecture +// kills the process, a denied call or a namespace-creating clone fails with +// EPERM, clone3 fails with ENOSYS, and every other call is allowed. +func seccompFilter(arch seccompArchitecture) ([]unix.SockFilter, error) { + load := func(offset uint32) bpfStep { + return bpfStep{instruction: unix.SockFilter{Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: offset}} + } + jump := func(code uint16, value uint32, jumpTrue, jumpFalse string) bpfStep { + return bpfStep{instruction: unix.SockFilter{Code: unix.BPF_JMP | code | unix.BPF_K, K: value}, jumpTrue: jumpTrue, jumpFalse: jumpFalse} + } + result := func(label string, value uint32) bpfStep { + return bpfStep{label: label, instruction: unix.SockFilter{Code: unix.BPF_RET | unix.BPF_K, K: value}} + } + number := func(name string) (uint32, error) { + value, ok := arch.numbers[name] + if !ok { + return 0, fmt.Errorf("no system call number for %s", name) + } + return value, nil + } + steps := []bpfStep{load(seccompArch), jump(unix.BPF_JEQ, arch.audit, "", "kill"), load(seccompNumber)} + denied := seccompDenied + if arch.x32 { + steps = append(steps, jump(unix.BPF_JGE, x32Bit, "deny", "")) + denied = append(append([]string(nil), seccompDenied...), seccompDeniedX86...) + } + for _, name := range denied { + value, err := number(name) + if err != nil { + return nil, err + } + steps = append(steps, jump(unix.BPF_JEQ, value, "deny", "")) + } + clone3, err := number("clone3") + if err != nil { + return nil, err + } + clone, err := number("clone") + if err != nil { + return nil, err + } + steps = append(steps, + jump(unix.BPF_JEQ, clone3, "nosys", ""), + jump(unix.BPF_JEQ, clone, "", "allow"), + load(seccompFirstArgLow), + jump(unix.BPF_JSET, cloneNamespaces, "deny", "allow"), + result("allow", unix.SECCOMP_RET_ALLOW), + result("deny", unix.SECCOMP_RET_ERRNO|uint32(unix.EPERM)), + result("nosys", unix.SECCOMP_RET_ERRNO|uint32(unix.ENOSYS)), + result("kill", unix.SECCOMP_RET_KILL_PROCESS), + ) + return resolveBPF(steps) +} + +// resolveBPF turns the labels of steps into the forward jump offsets that +// classic BPF requires. +func resolveBPF(steps []bpfStep) ([]unix.SockFilter, error) { + labels := map[string]int{} + for index, step := range steps { + if step.label != "" { + labels[step.label] = index + } + } + offset := func(from int, label string) (uint8, error) { + if label == "" { + return 0, nil + } + target, ok := labels[label] + distance := target - from - 1 + if !ok || distance < 0 || distance > 255 { + return 0, fmt.Errorf("cannot jump from instruction %d to %q", from, label) + } + return uint8(distance), nil + } + filter := make([]unix.SockFilter, len(steps)) + for index, step := range steps { + instruction := step.instruction + var err error + if instruction.Jt, err = offset(index, step.jumpTrue); err != nil { + return nil, err + } + if instruction.Jf, err = offset(index, step.jumpFalse); err != nil { + return nil, err + } + filter[index] = instruction + } + return filter, nil +} + +// currentSeccompArchitecture is the filter's description of this build's +// architecture. +func currentSeccompArchitecture() (seccompArchitecture, error) { + arch, ok := seccompArchitectures[runtime.GOARCH] + if !ok { + return seccompArchitecture{}, fmt.Errorf("EdgeWatch has no seccomp filter for %s", runtime.GOARCH) + } + return arch, nil +} + +// seccompSupport reports whether the kernel accepts the filter's actions. +func seccompSupport() error { + if _, err := currentSeccompArchitecture(); err != nil { + return err + } + for _, action := range []uint32{unix.SECCOMP_RET_ERRNO, unix.SECCOMP_RET_KILL_PROCESS} { + if _, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_GET_ACTION_AVAIL, 0, uintptr(unsafe.Pointer(&action))); errno != 0 { + return errno + } + } + return nil +} + +// seccompUnavailableReason explains why the filter cannot be used. +func seccompUnavailableReason(err error) string { + switch { + case errors.Is(err, unix.ENOSYS), errors.Is(err, unix.EINVAL): + return "the kernel does not provide seccomp filters, or the container's seccomp profile blocks them" + case errors.Is(err, unix.EOPNOTSUPP): + return "the kernel lacks the seccomp actions the filter uses" + default: + return fmt.Sprintf("seccomp could not be detected: %v", err) + } +} + +// installSeccompFilter installs the filter on the calling thread, which must +// have no_new_privs set. The program it executes inherits the filter. +func installSeccompFilter() error { + arch, err := currentSeccompArchitecture() + if err != nil { + return err + } + filter, err := seccompFilter(arch) + if err != nil { + return err + } + program := unix.SockFprog{Len: uint16(len(filter)), Filter: &filter[0]} + if _, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, 0, uintptr(unsafe.Pointer(&program))); errno != 0 { + return fmt.Errorf("install the seccomp filter: %w", errno) + } + return nil +} diff --git a/internal/sandbox/seccomp_linux_test.go b/internal/sandbox/seccomp_linux_test.go new file mode 100644 index 0000000..2e19bae --- /dev/null +++ b/internal/sandbox/seccomp_linux_test.go @@ -0,0 +1,271 @@ +//go:build linux + +package sandbox + +import ( + "encoding/binary" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +// TestSeccompNumbersMatchTheSystemCallTables compares the filter's numbers +// with the tables golang.org/x/sys generates from the kernel, for every +// release architecture, whatever this test runs on. +func TestSeccompNumbersMatchTheSystemCallTables(t *testing.T) { + t.Parallel() + output, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "golang.org/x/sys").Output() + if err != nil { + t.Skipf("the golang.org/x/sys source is not available: %v", err) + } + directory := strings.TrimSpace(string(output)) + definition := regexp.MustCompile(`SYS_([A-Z0-9_]+)\s*=\s*(\d+)`) + for goarch, arch := range seccompArchitectures { + source, err := os.ReadFile(filepath.Join(directory, "unix", "zsysnum_linux_"+goarch+".go")) + if err != nil { + t.Fatal(err) + } + table := map[string]uint32{} + for _, match := range definition.FindAllStringSubmatch(string(source), -1) { + number, _ := strconv.ParseUint(match[2], 10, 32) + table[strings.ToLower(match[1])] = uint32(number) + } + names := append(append([]string{"clone", "clone3"}, seccompDenied...), seccompDeniedX86...) + for _, name := range names { + want, exists := table[name] + got, listed := arch.numbers[name] + if exists != listed || got != want { + t.Errorf("%s %s = %d (listed %v), want %d (exists %v)", goarch, name, got, listed, want, exists) + } + } + } +} + +// runBPF evaluates a seccomp filter for one system call, as the kernel would. +func runBPF(t *testing.T, filter []unix.SockFilter, data [64]byte) uint32 { + t.Helper() + var accumulator uint32 + for pc := 0; pc < len(filter); pc++ { + instruction := filter[pc] + switch instruction.Code { + case unix.BPF_LD | unix.BPF_W | unix.BPF_ABS: + accumulator = binary.LittleEndian.Uint32(data[instruction.K:]) + case unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, unix.BPF_JMP | unix.BPF_JGE | unix.BPF_K, unix.BPF_JMP | unix.BPF_JSET | unix.BPF_K: + var taken bool + switch instruction.Code &^ (unix.BPF_JMP | unix.BPF_K) { + case unix.BPF_JEQ: + taken = accumulator == instruction.K + case unix.BPF_JGE: + taken = accumulator >= instruction.K + default: + taken = accumulator&instruction.K != 0 + } + if taken { + pc += int(instruction.Jt) + } else { + pc += int(instruction.Jf) + } + case unix.BPF_RET | unix.BPF_K: + return instruction.K + default: + t.Fatalf("unexpected instruction %#x", instruction.Code) + } + } + t.Fatal("the filter ended without a result") + return 0 +} + +func seccompData(arch, number, firstArg uint32) [64]byte { + var data [64]byte + binary.LittleEndian.PutUint32(data[seccompNumber:], number) + binary.LittleEndian.PutUint32(data[seccompArch:], arch) + binary.LittleEndian.PutUint32(data[seccompFirstArgLow:], firstArg) + return data +} + +func TestSeccompFilterDecidesEachSystemCall(t *testing.T) { + t.Parallel() + deny := unix.SECCOMP_RET_ERRNO | uint32(unix.EPERM) + for goarch, arch := range seccompArchitectures { + filter, err := seccompFilter(arch) + if err != nil { + t.Fatalf("%s: %v", goarch, err) + } + decide := func(number, firstArg uint32) uint32 { + return runBPF(t, filter, seccompData(arch.audit, number, firstArg)) + } + for _, name := range seccompDenied { + if got := decide(arch.numbers[name], 0); got != deny { + t.Errorf("%s %s = %#x, want EPERM", goarch, name, got) + } + } + // read and write are 0 and 1 on amd64, and 63 and 64 on arm64. + for _, number := range []uint32{0, 1, 63, 64} { + if got := decide(number, 0); got != unix.SECCOMP_RET_ALLOW { + t.Errorf("%s call %d = %#x, want allowed", goarch, number, got) + } + } + clone := arch.numbers["clone"] + if got := decide(clone, unix.CLONE_VM|unix.CLONE_FS|unix.CLONE_FILES|unix.CLONE_SIGHAND|unix.CLONE_THREAD); got != unix.SECCOMP_RET_ALLOW { + t.Errorf("%s clone of a thread = %#x, want allowed", goarch, got) + } + for _, flag := range []uint32{unix.CLONE_NEWUSER, unix.CLONE_NEWNS, unix.CLONE_NEWNET, unix.CLONE_NEWPID} { + if got := decide(clone, flag|unix.CLONE_VFORK); got != deny { + t.Errorf("%s clone with %#x = %#x, want EPERM", goarch, flag, got) + } + } + if got := decide(arch.numbers["clone3"], 0); got != unix.SECCOMP_RET_ERRNO|uint32(unix.ENOSYS) { + t.Errorf("%s clone3 = %#x, want ENOSYS", goarch, got) + } + if got := runBPF(t, filter, seccompData(unix.AUDIT_ARCH_I386, 1, 0)); got != unix.SECCOMP_RET_KILL_PROCESS { + t.Errorf("%s call of another architecture = %#x, want the process killed", goarch, got) + } + x32 := decide(x32Bit|1, 0) + if arch.x32 && x32 != deny { + t.Errorf("%s x32 call = %#x, want EPERM", goarch, x32) + } + if !arch.x32 && x32 != unix.SECCOMP_RET_ALLOW { + t.Errorf("%s high call number = %#x, want allowed", goarch, x32) + } + } + if _, err := seccompFilter(seccompArchitecture{audit: unix.AUDIT_ARCH_X86_64, numbers: map[string]uint32{}}); err == nil { + t.Fatal("a filter without numbers compiled") + } + missingClone := seccompArchitecture{numbers: map[string]uint32{}} + for name, number := range seccompArchitectures["arm64"].numbers { + if name != "clone" && name != "clone3" { + missingClone.numbers[name] = number + } + } + if _, err := seccompFilter(missingClone); err == nil || !strings.Contains(err.Error(), "clone3") { + t.Fatalf("a filter without clone3 = %v", err) + } + missingClone.numbers["clone3"] = 435 + if _, err := seccompFilter(missingClone); err == nil || !strings.Contains(err.Error(), "for clone") { + t.Fatalf("a filter without clone = %v", err) + } +} + +func TestResolveBPFRefusesBadJumps(t *testing.T) { + t.Parallel() + ret := unix.SockFilter{Code: unix.BPF_RET | unix.BPF_K} + jump := unix.SockFilter{Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K} + if _, err := resolveBPF([]bpfStep{{instruction: jump, jumpTrue: "missing"}, {instruction: ret}}); err == nil { + t.Fatal("a jump to a missing label resolved") + } + if _, err := resolveBPF([]bpfStep{{label: "back", instruction: ret}, {instruction: jump, jumpFalse: "back"}}); err == nil { + t.Fatal("a backward jump resolved") + } + far := []bpfStep{{instruction: jump, jumpTrue: "end"}} + for range 300 { + far = append(far, bpfStep{instruction: ret}) + } + far = append(far, bpfStep{label: "end", instruction: ret}) + if _, err := resolveBPF(far); err == nil { + t.Fatal("a jump beyond 255 instructions resolved") + } +} + +func TestSeccompUnavailableReasons(t *testing.T) { + t.Parallel() + for err, want := range map[error]string{ + unix.ENOSYS: "the kernel does not provide seccomp filters, or the container's seccomp profile blocks them", + unix.EINVAL: "the kernel does not provide seccomp filters, or the container's seccomp profile blocks them", + unix.EOPNOTSUPP: "the kernel lacks the seccomp actions the filter uses", + errors.New("strange"): "seccomp could not be detected: strange", + } { + if got := seccompUnavailableReason(err); got != want { + t.Errorf("reason for %v = %q, want %q", err, got, want) + } + } +} + +// TestInstalledSeccompFilterRefusesTheDeniedCalls installs the filter on a +// locked thread, which ends with its goroutine. +func TestInstalledSeccompFilterRefusesTheDeniedCalls(t *testing.T) { + t.Parallel() + if err := seccompSupport(); err != nil { + t.Skipf("this kernel cannot run the filter: %v", err) + } + results := make(chan map[string]error, 1) + go func() { + runtime.LockOSThread() + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + results <- map[string]error{"no_new_privs": err} + return + } + if err := installSeccompFilter(); err != nil { + results <- map[string]error{"install": err} + return + } + call := func(number uintptr, args ...uintptr) error { + args = append(args, 0, 0, 0) + if _, _, errno := unix.RawSyscall6(number, args[0], args[1], args[2], 0, 0, 0); errno != 0 { + return errno + } + return nil + } + got := map[string]error{ + "getpid": call(unix.SYS_GETPID), + "ptrace": call(unix.SYS_PTRACE, unix.PTRACE_PEEKUSR, uintptr(os.Getppid()), 0), + "unshare": call(unix.SYS_UNSHARE, unix.CLONE_NEWUSER), + // The kernel refuses CLONE_NEWUSER with CLONE_FS, so this clone + // never creates a process; only the filter turns its EINVAL + // into EPERM. + "clone": call(unix.SYS_CLONE, unix.CLONE_NEWUSER|unix.CLONE_FS), + "clone3": call(unix.SYS_CLONE3, 0, 0), + "io_uring_setup": call(unix.SYS_IO_URING_SETUP, 0, 0), + } + if runtime.GOARCH == "amd64" { + got["x32"] = call(x32Bit | unix.SYS_GETPID) + } + results <- got + }() + got := <-results + for _, step := range []string{"no_new_privs", "install"} { + if err := got[step]; err != nil { + t.Fatalf("%s: %v", step, err) + } + } + if got["getpid"] != nil { + t.Fatalf("getpid = %v, want allowed", got["getpid"]) + } + for _, name := range []string{"ptrace", "unshare", "clone", "io_uring_setup", "x32"} { + if err, ok := got[name]; ok && !errors.Is(err, unix.EPERM) { + t.Errorf("%s = %v, want EPERM", name, err) + } + } + if !errors.Is(got["clone3"], unix.ENOSYS) { + t.Errorf("clone3 = %v, want ENOSYS", got["clone3"]) + } +} + +func TestSeccompArchitectureOfThisBuild(t *testing.T) { + t.Parallel() + arch, err := currentSeccompArchitecture() + if _, known := seccompArchitectures[runtime.GOARCH]; known != (err == nil) { + t.Fatalf("architecture %s = %+v, %v", runtime.GOARCH, arch, err) + } +} + +// TestHardenProcessStopsCoreDumps runs the hardening in a test binary of its +// own, which then reports its dumpable flag and core file size limits. +func TestHardenProcessStopsCoreDumps(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + output, err := exec.Command(executable, hardenCheck).CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "0 0 0" { + t.Fatalf("hardened process = %q, %v; want non-dumpable with no core dumps", output, err) + } +} diff --git a/scripts/verify-scanner-sandbox.sh b/scripts/verify-scanner-sandbox.sh index 8567030..5505abf 100755 --- a/scripts/verify-scanner-sandbox.sh +++ b/scripts/verify-scanner-sandbox.sh @@ -10,7 +10,8 @@ set -euo pipefail # can neither list the data directory nor read the configuration, and that a # process restricted with Landlock cannot do so even as UID 0. Deliver a test # notification to a local webhook and require that the notification process -# runs as UID 65531 without capabilities. +# runs as UID 65531 without capabilities. Require that both run with the +# seccomp filter, one more than the daemon's, and that no process dumps core. image=${1:?usage: verify-scanner-sandbox.sh IMAGE} workdir=$(mktemp -d) @@ -297,7 +298,21 @@ stop_daemon() { # and the Landlock state that the health command reports. 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 []) or "-", "landlock:" + sandbox["landlock"]["state"])' + python3 -c 'import json, sys; sandbox = json.load(sys.stdin)["scanner_sandbox"]; print(sandbox["state"], sandbox.get("process_uid"), ",".join(sandbox.get("capabilities") or []) or "-", "landlock:" + sandbox["landlock"]["state"], "seccomp:" + sandbox["seccomp"]["state"])' +} + +# process_hardening PID prints the number of seccomp filters of a process in +# the daemon's container and its soft and hard core file size limits. +process_hardening() { + docker exec "$container" /bin/sh -c 'awk "/^Seccomp_filters:/{print \$2}" "/proc/$1/status"; awk "/^Max core file size/{print \$5, \$6}" "/proc/$1/limits"' sh "$1" | tr '\n' ' ' | sed 's/ $//' +} + +# expect_filtered NAME HARDENING requires a process's hardening to be one +# seccomp filter more than the daemon's and no core dumps. +expect_filtered() { + local name=$1 got=$2 daemon_filters + daemon_filters=$(process_hardening 1 | cut -d' ' -f1) + [ "$got" = "$((daemon_filters + 1)) 0 0" ] || fail "the $name process has seccomp filters and core limits '$got', want '$((daemon_filters + 1)) 0 0'" } expect_health() { @@ -310,7 +325,7 @@ expect_health() { # and its Landlock state that the health command reports. notification_health_state() { docker exec "$container" edgewatch health --config /etc/edgewatch/config.yaml --output json | - python3 -c 'import json, sys; sandbox = json.load(sys.stdin)["notification_sandbox"]; print(sandbox["state"], sandbox.get("process_uid"), "landlock:" + sandbox["landlock"]["state"])' + python3 -c 'import json, sys; sandbox = json.load(sys.stdin)["notification_sandbox"]; print(sandbox["state"], sandbox.get("process_uid"), "landlock:" + sandbox["landlock"]["state"], "seccomp:" + sandbox["seccomp"]["state"])' } expect_notification_health() { @@ -337,8 +352,11 @@ deliver_notification() { python3 "$workdir/driver.py" notify "$base" "$state" "$name" "generic://127.0.0.1:$webhook_port/hook?disabletls=yes&template=json" >"$workdir/notify-$name.json" & sent=$! for attempt in $(seq 1 40); do - identity=$(docker exec "$container" /bin/sh -c 'for process in /proc/[0-9]*; do if [ "$(tr "\0" " " <"$process/cmdline" 2>/dev/null)" = "/usr/local/bin/edgewatch notify-send " ]; then awk "/^Uid:/{uid=\$2} /^Gid:/{gid=\$2} /^Groups:/{groups=\$2} /^CapEff:/{cap=\$2} END{print uid, gid, (groups == \"\" ? \"-\" : groups), cap}" "$process/status"; break; fi; done' 2>/dev/null || true) - [ -n "$identity" ] && break + identity=$(docker exec "$container" /bin/sh -c 'for process in /proc/[0-9]*; do if [ "$(tr "\0" " " <"$process/cmdline" 2>/dev/null)" = "/usr/local/bin/edgewatch notify-send " ]; then awk "/^Uid:/{uid=\$2} /^Gid:/{gid=\$2} /^Groups:/{groups=\$2} /^CapEff:/{cap=\$2} END{print uid, gid, (groups == \"\" ? \"-\" : groups), cap}" "$process/status"; echo "${process#/proc/}"; break; fi; done' 2>/dev/null || true) + if [ -n "$identity" ]; then + identity="$(printf '%s\n' "$identity" | head -1) | $(process_hardening "$(printf '%s\n' "$identity" | tail -1)")" + break + fi sleep 0.25 done wait "$sent" || fail "the $name test notification failed: $(cat "$workdir/notify-$name.json" 2>/dev/null)" @@ -351,7 +369,8 @@ udp_ports="$udp_open" # The bundled Compose deployment: the sandbox keeps NET_RAW only. run_daemon base auto "${bundled_caps[@]}" -expect_health "enforced 65532 NET_RAW landlock:enforced" +expect_health "enforced 65532 NET_RAW landlock:enforced seccomp:enforced" +[ "$(process_hardening 1 | cut -d' ' -f2-)" = "0 0" ] || fail "the daemon may dump core: $(process_hardening 1)" sandboxed=$(python3 "$workdir/driver.py" scan "$base" "$state" sandboxed "$tcp_ports" "$udp_ports") # Landlock restricts a process whatever its identity: as UID 0, which owns @@ -381,9 +400,10 @@ done # A test notification reaches the webhook from a notification process that # runs as UID 65531 with no capabilities. -expect_notification_health "enforced 65531 landlock:enforced" -notifier_identity=$(deliver_notification base) -[ "$notifier_identity" = "65531 65531 - 0000000000000000" ] || fail "the notification process identity is '$notifier_identity', want '65531 65531 - 0000000000000000'" +expect_notification_health "enforced 65531 landlock:enforced seccomp:enforced" +notifier=$(deliver_notification base) +[ "${notifier%% | *}" = "65531 65531 - 0000000000000000" ] || fail "the notification process identity is '${notifier%% | *}', want '65531 65531 - 0000000000000000'" +expect_filtered notification "${notifier##* | }" # A certificate authority the notification identity cannot read keeps the # notification process as UID 0, so its TLS destinations keep working, though @@ -412,11 +432,14 @@ job_id=$(printf '%s' "$started" | python3 -c 'import json, sys; print(json.load( 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) + 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"; pid=${status#/proc/}; echo "${pid%/status}"; break; fi; done' 2>/dev/null || true) [ -n "$nmap_identity" ] && break sleep 0.5 done +nmap_pid=$(printf '%s\n' "$nmap_identity" | tail -1) +nmap_identity=$(printf '%s\n' "$nmap_identity" | head -1) [ "$nmap_identity" = "65532 65532 - 0000000000002000" ] || fail "running Nmap identity is '$nmap_identity', want '65532 65532 - 0000000000002000'" +expect_filtered Nmap "$(process_hardening "$nmap_pid")" 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 @@ -427,22 +450,22 @@ 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 "${bundled_caps[@]}" NET_ADMIN -expect_health "enforced 65532 NET_RAW,NET_ADMIN landlock:enforced" +expect_health "enforced 65532 NET_RAW,NET_ADMIN landlock:enforced seccomp:enforced" naabu_sandboxed=$(python3 "$workdir/driver.py" naabu "$base" "$state" naabu-sandboxed "$tcp_open") stop_daemon # A Compose file from before v0.27.0 grants no SETUID, SETGID, or KILL: # scanner processes stay UID 0, restricted only by Landlock. run_daemon legacy auto NET_RAW NET_ADMIN -expect_health "unavailable 0 - landlock:enforced" -expect_notification_health "unavailable 0 landlock:enforced" +expect_health "unavailable 0 - landlock:enforced seccomp:enforced" +expect_notification_health "unavailable 0 landlock:enforced seccomp:enforced" deliver_notification legacy >/dev/null landlock_only=$(python3 "$workdir/driver.py" scan "$base" "$state" landlock-only "$tcp_ports" "$udp_ports") naabu_landlock_only=$(python3 "$workdir/driver.py" naabu "$base" "$state" naabu-landlock-only "$tcp_open") stop_daemon run_daemon off off "${bundled_caps[@]}" NET_ADMIN -expect_health "disabled 0 - landlock:disabled" +expect_health "disabled 0 - landlock:disabled seccomp:disabled" 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 diff --git a/src/api.ts b/src/api.ts index 422f5b0..70ee1ec 100644 --- a/src/api.ts +++ b/src/api.ts @@ -170,7 +170,7 @@ export type UntrustedProxy = { peer: string; header: string; last_seen_at: strin // How the scanner's Nmap and Naabu processes start. A confined process runs // as process_uid 65532 with only the listed capabilities. export type ScannerLandlockStatus = { mode: 'auto' | 'required' | 'off' | string; state: 'enforced' | 'disabled' | 'unavailable' | string; abi?: number; reason?: string } -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; landlock?: ScannerLandlockStatus } +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; landlock?: ScannerLandlockStatus; seccomp?: { state: 'enforced' | 'disabled' | 'unavailable' | string; 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; notification_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 diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx index da4b460..7392f5b 100644 --- a/src/pages/Dashboard.test.tsx +++ b/src/pages/Dashboard.test.tsx @@ -454,9 +454,9 @@ describe('dashboard', () => { }) it('shows Landlock beside the enforced scanner sandbox', async () => { - vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW', 'NET_ADMIN'], no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 } } }) + vi.mocked(adminStatus).mockResolvedValue({ ...status, scanner_sandbox: { mode: 'auto', state: 'enforced', uid: 65532, gid: 65532, process_uid: 65532, capabilities: ['NET_RAW', 'NET_ADMIN'], no_new_privileges: true, landlock: { mode: 'auto', state: 'enforced', abi: 6 }, seccomp: { state: 'enforced' } } }) await renderDashboard() - await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxEnforced · NET_RAW, NET_ADMIN · Landlock'), { timeout: 1000 }) + await vi.waitFor(() => expect(container.querySelector('.deployment-telemetry')?.textContent).toContain('Scanner sandboxEnforced · NET_RAW, NET_ADMIN · Landlock · seccomp'), { timeout: 1000 }) expect(container.querySelector('.scanner-sandbox-warning')).toBeNull() }) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index a6a1023..2e60924 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -163,7 +163,8 @@ function BaselinePill({ baseline }: { baseline: BaselineStatusInfo }) { function sandboxLabel(status: NonNullable>['scanner_sandbox']>) { const landlock = status.landlock?.state === 'enforced' - if (status.state === 'enforced') return [status.capabilities?.length ? `Enforced · ${status.capabilities.join(', ')}` : 'Enforced', ...(landlock ? ['Landlock'] : [])].join(' · ') + const restrictions = [...(landlock ? ['Landlock'] : []), ...(status.seccomp?.state === 'enforced' ? ['seccomp'] : [])] + if (status.state === 'enforced') return [status.capabilities?.length ? `Enforced · ${status.capabilities.join(', ')}` : 'Enforced', ...restrictions].join(' · ') if (landlock) return 'Landlock only' if (status.state === 'disabled') return 'Off' return 'Unavailable'