Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions cmd/edgewatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions cmd/edgewatch/scanner_sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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"`},
Expand Down Expand Up @@ -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"`,
Expand Down
57 changes: 51 additions & 6 deletions docs/src/content/docs/deployment/container-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/src/content/docs/operations/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading