diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bedf8f1b..f1b9b23d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,6 +380,40 @@ jobs: - name: Verify prebuilt ARM64 image run: bash ./scripts/test-prebuilt-image.sh edgewatch:prebuilt-arm64 linux/arm64 v0.0.0-ci + # The release also publishes an ARM64 image. Its seccomp filter numbers the + # system calls differently, and its Landlock restriction runs on another + # kernel. The container job builds that image only under emulation, where + # neither runs, so this job runs the sandbox tests and the real scans on an + # ARM64 kernel. + arm64-sandbox: + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache: true + - name: Test the sandbox, scanner, and notification packages + run: go test -race -timeout=15m ./internal/sandbox/ ./internal/scanner/ ./internal/notify/ + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build the ARM64 image natively + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + platforms: linux/arm64 + tags: edgewatch:arm64 + build-args: | + VERSION=v0.0.0-ci + cache-from: type=gha,scope=container-native-arm64 + cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max,scope=container-native-arm64' || '' }} + - name: Verify scanner sandbox with real scans + run: ./scripts/verify-scanner-sandbox.sh edgewatch:arm64 + # GitHub Actions caches are ref-isolated, so release tags cannot restore a # cache written by main. Seed separate main and release GHCR caches here; # tags refresh only the release cache. Grant package writes only to trusted diff --git a/AGENTS.md b/AGENTS.md index 8ab05c5c..8cabf2c3 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, tries Landlock escapes, and checks the seccomp filter, core limits, and a sandboxed notification delivery | +| 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; CI runs it on AMD64 and ARM64 runners | | 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 | diff --git a/docs/src/content/docs/deployment/container-hardening.md b/docs/src/content/docs/deployment/container-hardening.md index 95450ce4..0854ca02 100644 --- a/docs/src/content/docs/deployment/container-hardening.md +++ b/docs/src/content/docs/deployment/container-hardening.md @@ -80,7 +80,10 @@ 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. 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. +than the daemon, and that neither they nor the daemon can dump core. The +seccomp filter and the Landlock restriction depend on the architecture and +the kernel, so CI also runs the sandbox tests and these real scans on an +ARM64 runner, against an ARM64 image built there. ## Data ownership and upgrades diff --git a/internal/scanner/sandbox_test.go b/internal/scanner/sandbox_test.go index 40dd3fa7..20855bae 100644 --- a/internal/scanner/sandbox_test.go +++ b/internal/scanner/sandbox_test.go @@ -9,31 +9,42 @@ import ( "path/filepath" "reflect" "strings" + "syscall" "testing" "github.com/crypt0rr/edgewatch/internal/sandbox" "golang.org/x/sys/unix" ) +// writeScript writes a script that the test then runs. A process that a +// parallel test forks while the script is open for writing inherits the +// descriptor until it executes its own program, and running the script +// meanwhile fails with "text file busy". Go forks only while it holds +// syscall.ForkLock exclusively, so holding it shared while the script is +// written keeps every fork out of that window. +func writeScript(t *testing.T, path, script string) { + t.Helper() + syscall.ForkLock.RLock() + defer syscall.ForkLock.RUnlock() + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } +} + // writeFakeHelper writes a stand-in for the EdgeWatch executable whose // sandbox-exec command records its arguments and inherited descriptor in dir // and then runs body. func writeFakeHelper(t *testing.T, dir, body string) string { t.Helper() path := filepath.Join(dir, "edgewatch") - script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + filepath.Join(dir, "args") + "\n" + body - if err := os.WriteFile(path, []byte(script), 0o700); err != nil { - t.Fatal(err) - } + writeScript(t, path, "#!/bin/sh\nprintf '%s\\n' \"$@\" > "+filepath.Join(dir, "args")+"\n"+body) return path } func writeFakeScanner(t *testing.T, name string) string { t.Helper() path := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 99\n"), 0o700); err != nil { - t.Fatal(err) - } + writeScript(t, path, "#!/bin/sh\nexit 99\n") return path }