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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ jobs:
run: bash ./scripts/test-prebuilt-image.sh edgewatch:prebuilt-amd64 linux/amd64 v0.0.0-ci
- name: Verify container runtime capability matrix
run: ./scripts/verify-container-runtime.sh edgewatch:prebuilt-amd64
- name: Verify scanner sandbox with real scans
run: ./scripts/verify-scanner-sandbox.sh edgewatch:prebuilt-amd64
- name: Build prebuilt ARM64 image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ jobs:
docker run --rm --entrypoint /bin/sh "$image" -c 'test -s /usr/share/licenses/edgewatch/LICENSE && test -s /usr/share/licenses/edgewatch/LICENSE.md && test -s /usr/share/licenses/edgewatch/THIRD_PARTY_LICENSES.md && test -s /usr/share/licenses/naabu/LICENSE.md'
- name: Verify container runtime capability matrix
run: ./scripts/verify-container-runtime.sh "$IMAGE"
- name: Verify scanner sandbox with real scans
run: ./scripts/verify-scanner-sandbox.sh "$IMAGE"
- name: Verify binary version and configuration
run: |
expected="EdgeWatch ${GITHUB_REF_NAME#v}"
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ It uses a Go backend, SQLite storage, and a React/TypeScript web console.
| `internal/app/` | Application coordination, scan lifecycle, and resumable work |
| `internal/config/` | Configuration validation and scanner profiles |
| `internal/scanner/` | Nmap and Naabu execution, parsing, and scan plans |
| `internal/sandbox/` | The unprivileged identity and capabilities scanner processes start with |
| `internal/engine/` | Baseline comparison and change detection |
| `internal/model/` | Shared domain types |
| `internal/store/` | SQLite queries, migrations, history, backup, and restore |
Expand Down Expand Up @@ -69,6 +70,7 @@ Documentation-only changes need a diff review and checks of referenced paths and
| Database schema | Store migration tests and `./scripts/check-schema-docs.sh` |
| Compose configuration | Run `docker compose config --quiet` and `docker compose -f compose.yaml -f compose.syn.yaml config --quiet`, then verify the rendered capability, hardening, image, and storage policies described in `docs/src/content/docs/deployment/container-hardening.md` and the CI `Validate Compose deployment` step |
| Scanner dependency pin | `./scripts/verify-naabu-pin.sh` |
| Scanner execution or sandbox | Build the image and run `./scripts/verify-scanner-sandbox.sh IMAGE`, which needs Docker; it compares real sandboxed and unconfined scans of local listeners |
| Release helper scripts | `./scripts/test-release-artifacts.sh`; this uses fixture binaries and does not build a release candidate |
| Release workflow or GoReleaser configuration | Follow the exact GoReleaser check and immutable-candidate gates in `.github/workflows/release.yml`; the candidate, publication, image, and runtime smoke gates run only for tags |

Expand All @@ -92,6 +94,7 @@ Report the checks you ran and any failures or checks you could not run.
- Use controlled listeners for integration scans and scan only authorized targets.
- Preserve target exclusions, probe budgets, cancellation, and resumable scan behavior.
- Keep scanner execution shell-free on fixed executables with validated argument arrays and `exec.CommandContext`; preserve the minimal environment, private temporary inputs and outputs, bounded diagnostic and structured output, and child termination when those bounds are exceeded.
- Start every Nmap and Naabu process through the scanner's sandbox policy (`internal/sandbox`): confine the command, and pass private files with `InheritFile` rather than by path. Confined processes keep only `NET_RAW` and `NET_ADMIN` as ambient capabilities, and the bundled Compose capability set stays exact.
- Keep UDP scans on Nmap and require Nmap confirmation before Naabu discoveries enter baselines or incidents.
- Preserve job profile revisions so profile edits do not silently change scheduled jobs.
- Preserve baseline state for failed or incomplete observations and retain scan history when users accept changes.
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ RUN if [ "$PREBUILT_EDGEWATCH" = "1" ]; then \
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache ca-certificates=20260909-r0 gcompat=1.1.0-r4 nmap=7.99-r0 nmap-scripts=7.99-r0 tzdata=2026e-r0 \
&& mkdir -p /etc/edgewatch /var/lib/edgewatch /run/secrets \
&& chmod 0750 /etc/edgewatch /var/lib/edgewatch /run/secrets
&& chmod 0750 /etc/edgewatch /var/lib/edgewatch /run/secrets \
&& addgroup -S -g 65532 edgewatch-scanner \
&& adduser -S -D -H -u 65532 -G edgewatch-scanner -h /nonexistent -s /sbin/nologin edgewatch-scanner
COPY --from=build /out/edgewatch /usr/local/bin/edgewatch
COPY --from=naabu /out/naabu /usr/local/bin/naabu
COPY LICENSE LICENSE.md THIRD_PARTY_LICENSES.md /usr/share/licenses/edgewatch/
Expand Down
17 changes: 14 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,22 @@ implausible number of repeated records. Connect discovery is the least
privileged default; SYN discovery additionally requires the explicitly opted-in
`NET_ADMIN` and `NET_RAW` container capabilities.

The final image intentionally retains UID 0 because the supported Docker
The daemon intentionally retains UID 0 because the supported Docker
capability model does not reliably expose raw packet privileges to an
unprivileged process. Nmap UDP/SYN and Naabu SYN fail closed without those
privileges. The compatibility matrix, bind-mount ownership guidance, and
reconsideration criteria are maintained in
privileges. The scanner processes themselves run in a sandbox: with the
default `scanner.sandbox: auto` and the bundled Compose capabilities
(`NET_RAW`, `SETUID`, `SETGID`, `KILL`), EdgeWatch starts Nmap and Naabu as
UID and GID 65532 with no supplementary groups and only `NET_RAW` (and
`NET_ADMIN` when granted) as ambient capabilities. They read their target list
and write their results through inherited file descriptors, cannot list the
UID 0 data directory or reach `config.yaml`, and so cannot read the database
or the encryption keys. When the container does not grant those capabilities,
`auto` runs them unconfined as UID 0 and warns in the log, `edgewatch health`,
and the console; `scanner.sandbox: required` refuses to scan instead. All
sandboxed scanner processes share UID 65532, and the notification child
process is not sandboxed. The compatibility matrix, the sandbox, bind-mount
ownership guidance, and the criteria for a non-root daemon are maintained in
[`docs/src/content/docs/deployment/container-hardening.md`](docs/src/content/docs/deployment/container-hardening.md).

The administration console is bound to a loopback address by default and uses
Expand Down
61 changes: 56 additions & 5 deletions cmd/edgewatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/crypt0rr/edgewatch/internal/config"
"github.com/crypt0rr/edgewatch/internal/model"
"github.com/crypt0rr/edgewatch/internal/notify"
"github.com/crypt0rr/edgewatch/internal/sandbox"
"github.com/crypt0rr/edgewatch/internal/store"
"github.com/crypt0rr/edgewatch/internal/web"
"github.com/robfig/cron/v3"
Expand Down Expand Up @@ -131,6 +132,16 @@ func run(args []string) error {
return err
}
}
// The daemon and the scan command start scanner processes. A required
// sandbox refuses them, before the database is opened, when the runtime
// cannot confine those processes.
var scannerSandbox *sandbox.Policy
if cmd == "daemon" || cmd == "scan" {
scannerSandbox = sandbox.Detect(cfg.Scanner.Sandbox)
if err := scannerSandbox.Require(); err != nil {
return err
}
}
ctx, stop := contextWithSignals(context.Background())
defer stop()
if cmd == "restore" {
Expand Down Expand Up @@ -265,11 +276,14 @@ func run(args []string) error {
// Only the daemon imports notification URLs from config.yaml, after
// the migrations above and before its notifier and delivery worker
// start. Host commands keep using the configured URLs until then.
application, err = app.NewWithOptions(cfg, s, *nmapPath, logger, app.Options{ImportNotificationURLs: cmd == "daemon"})
application, err = app.NewWithOptions(cfg, s, *nmapPath, logger, app.Options{ImportNotificationURLs: cmd == "daemon", Sandbox: scannerSandbox})
if err != nil {
return err
}
application.Version = version
if scannerSandbox != nil {
logScannerSandbox(logger, scannerSandbox.Status())
}
}
switch cmd {
case "daemon":
Expand Down Expand Up @@ -343,17 +357,21 @@ func run(args []string) error {
return err
case "health":
health, err := s.System().HealthStatus(ctx)
// The sandbox is detected for this container, which grants the
// health command the daemon's capabilities and configuration.
scannerSandbox := sandbox.Detect(cfg.Scanner.Sandbox).Status()
health.Warnings = append(health.Warnings, scannerSandboxWarnings(scannerSandbox)...)
if err != nil {
if *output == "json" {
// Keep stdout parseable for monitoring: report the failure
// as a document, then exit non-zero with the reason on stderr.
if printErr := printValue(*output, unhealthyStatus{HealthStatus: health, Status: "unhealthy", Error: err.Error()}); printErr != nil {
if printErr := printValue(*output, unhealthyStatus{HealthStatus: health, ScannerSandbox: scannerSandbox, Status: "unhealthy", Error: err.Error()}); printErr != nil {
return printErr
}
}
return err
}
return printValue(*output, health)
return printValue(*output, healthReport{HealthStatus: health, ScannerSandbox: scannerSandbox})
default:
return usage()
}
Expand Down Expand Up @@ -695,8 +713,41 @@ type notifyTestResult struct {
// healthy. Its status field replaces the embedded one.
type unhealthyStatus struct {
store.HealthStatus
Status string `json:"status"`
Error string `json:"error"`
ScannerSandbox sandbox.Status `json:"scanner_sandbox"`
Status string `json:"status"`
Error string `json:"error"`
}

// healthReport is the health command's document: the daemon's health and how
// scanner processes start in this container.
type healthReport struct {
store.HealthStatus
ScannerSandbox sandbox.Status `json:"scanner_sandbox"`
}

// scannerSandboxWarnings reports a sandbox that auto mode could not enforce
// while scanner processes run as UID 0. A daemon that runs as another user
// starts scanner processes as that user, which the sandbox would not improve
// on.
func scannerSandboxWarnings(status sandbox.Status) []string {
if status.State != sandbox.StateUnavailable || status.ProcessUID != 0 {
return nil
}
return []string{"scanner processes run unconfined as UID 0: " + status.Reason}
}

// logScannerSandbox records how scanner processes start.
func logScannerSandbox(logger *slog.Logger, status sandbox.Status) {
switch {
case status.State == sandbox.StateEnforced:
logger.Info("scanner processes are sandboxed", "uid", status.UID, "gid", status.GID, "capabilities", status.Capabilities, "no_new_privileges", status.NoNewPrivileges)
case status.State == sandbox.StateUnavailable && status.ProcessUID == 0:
logger.Warn("scanner processes run unconfined as UID 0; see the container hardening guide", "reason", status.Reason)
case status.State == sandbox.StateUnavailable:
logger.Info("scanner processes run as the daemon's user", "uid", status.ProcessUID, "reason", status.Reason)
default:
logger.Info("scanner sandbox is off; scanner processes run unconfined", "uid", status.ProcessUID)
}
}

func printValue(format string, v any) error {
Expand Down
12 changes: 10 additions & 2 deletions cmd/edgewatch/main_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,15 +690,23 @@ func TestHealthCommandNamesMissingDaemonHeartbeat(t *testing.T) {
t.Fatalf("health error = %v, want a named missing-daemon-heartbeat error", err)
}
var document struct {
Status string `json:"status"`
Error string `json:"error"`
Status string `json:"status"`
Error string `json:"error"`
ScannerSandbox struct {
Mode string `json:"mode"`
State string `json:"state"`
} `json:"scanner_sandbox"`
}
if decodeErr := json.Unmarshal([]byte(stdout), &document); decodeErr != nil {
t.Fatalf("unhealthy health output is not one JSON document: %v\n%s", decodeErr, stdout)
}
if document.Status != "unhealthy" || document.Error != err.Error() {
t.Fatalf("unhealthy health document = %+v, want status unhealthy and the error %q", document, err)
}
// A test binary is never confined, so the sandbox is reported unavailable.
if document.ScannerSandbox.Mode != "auto" || document.ScannerSandbox.State != "unavailable" {
t.Fatalf("health scanner sandbox = %+v, want auto and unavailable", document.ScannerSandbox)
}

stdout, _, err = captureCLIOutput(t, func() error {
return run([]string{"health", "--config", configPath})
Expand Down
69 changes: 69 additions & 0 deletions cmd/edgewatch/scanner_sandbox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"bytes"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"

"github.com/crypt0rr/edgewatch/internal/sandbox"
)

func TestDaemonRefusesARequiredSandboxBeforeOpeningTheDatabase(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "edgewatch.db")
configPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(configPath, []byte("database: "+database+"\nscanner:\n sandbox: required\n"), 0o600); err != nil {
t.Fatal(err)
}
// A test process can never confine scanner processes, so a required
// sandbox must stop the daemon before it creates or migrates anything.
err := run([]string{"daemon", "--config", configPath})
if !errors.Is(err, sandbox.ErrUnavailable) || !strings.Contains(err.Error(), "set scanner.sandbox to auto") {
t.Fatalf("daemon with a required sandbox = %v, want ErrUnavailable", err)
}
if _, statErr := os.Stat(database); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("refused daemon left a database behind: %v", statErr)
}
}

func TestScannerSandboxWarningsOnlyForUnconfinedRoot(t *testing.T) {
t.Parallel()
unavailable := sandbox.Status{Mode: sandbox.ModeAuto, State: sandbox.StateUnavailable, ProcessUID: 0, Reason: "the container does not grant KILL"}
if got := scannerSandboxWarnings(unavailable); len(got) != 1 || got[0] != "scanner processes run unconfined as UID 0: the container does not grant KILL" {
t.Fatalf("root warnings = %q", got)
}
notRoot := unavailable
notRoot.ProcessUID = 1000
for name, status := range map[string]sandbox.Status{
"not root": notRoot,
"enforced": {State: sandbox.StateEnforced, ProcessUID: sandbox.UID},
"disabled": {State: sandbox.StateDisabled},
} {
if got := scannerSandboxWarnings(status); len(got) != 0 {
t.Errorf("%s warnings = %q, want none", name, got)
}
}
}

func TestLogScannerSandboxNamesTheOutcome(t *testing.T) {
t.Parallel()
for name, test := range map[string]struct {
status sandbox.Status
want string
}{
"enforced": {status: sandbox.NewEnforced().Status(), want: `"level":"INFO","msg":"scanner processes are sandboxed","uid":65532`},
"unavailable root": {status: sandbox.Status{State: sandbox.StateUnavailable, Reason: "no KILL"}, want: `"level":"WARN","msg":"scanner processes run unconfined as UID 0; see the container hardening guide","reason":"no KILL"`},
"unavailable user": {status: sandbox.Status{State: sandbox.StateUnavailable, ProcessUID: 1000, Reason: "not root"}, want: `"level":"INFO","msg":"scanner processes run as the daemon's user","uid":1000`},
"off": {status: sandbox.Status{State: sandbox.StateDisabled}, want: `"msg":"scanner sandbox is off; scanner processes run unconfined"`},
} {
var output bytes.Buffer
logScannerSandbox(slog.New(slog.NewJSONHandler(&output, nil)), test.status)
if !strings.Contains(output.String(), test.want) {
t.Errorf("%s log = %s, want %s", name, output.String(), test.want)
}
}
}
2 changes: 2 additions & 0 deletions compose.syn.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
# default Naabu CONNECT profile without NET_ADMIN.
services:
edgewatch:
# Compose merges this list with the base file's; NET_ADMIN is the only
# addition. A sandboxed Naabu keeps it alongside NET_RAW.
cap_add:
- NET_RAW
- NET_ADMIN
7 changes: 7 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ services:
TMPDIR: /var/lib/edgewatch/tmp
cap_drop:
- ALL
# NET_RAW is the raw-packet privilege of Nmap and Naabu. SETUID and SETGID
# let EdgeWatch start them as the unprivileged sandbox identity (UID
# 65532), which keeps only NET_RAW and cannot read ./data, and KILL lets
# it stop them; without these three they run unconfined as UID 0.
cap_add:
- NET_RAW
- SETUID
- SETGID
- KILL
security_opt:
- no-new-privileges:true
read_only: true
Expand Down
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ scanner:
- 169.254.0.0/16
- ::1/128
- fe80::/10
# Nmap and Naabu run as the unprivileged UID 65532 with only their raw-packet
# capabilities, so they cannot read the database or the keys. auto (the
# default) does so when the container grants SETUID, SETGID and KILL, as the
# bundled compose.yaml does, and otherwise runs them unconfined as UID 0
# with a warning. required refuses to scan without the sandbox; off never
# uses it.
sandbox: auto

# Check the latest stable GitHub release at startup and every three hours.
# Set enabled: false for offline or privacy-sensitive deployments. A check
Expand Down
Loading
Loading