From 4c42ceca0eec682cc68695d2ac97ec83a35a83be Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Thu, 7 May 2026 12:42:28 +0530 Subject: [PATCH] feat: Add support for Landlock based Sandbox for Linux (#238) * feat: Initial implementation of landlock based sandbox driver * fix: Handle seccom probe failure * fix: Remove unnecessary seccomp probe * fix: Use file based policy load * fix: Keep bpf filter in memory * fix: Use TSYNC for seccom filter * fix: Use TSYNC for seccom filter * fix: Update landlock translator * fix: Landlock sandbox implementation * fix: Landlock + seccomp based sandboxing on Linux * fix: Misc fixes * fix: Cleanup sandbox files * fix: Handle mandatory deny API change post merge * fix: Landlock write access translation * chore: Fix linter issues * ci: Use /tmp for npm cache for landlock --- .github/workflows/pmg-e2e.yml | 81 ++ cmd/landlock/landlock_sandbox_exec_linux.go | 33 + cmd/landlock/landlock_sandbox_exec_other.go | 9 + cmd/landlock/landlock_shim_linux.go | 38 + cmd/landlock/landlock_shim_other.go | 8 + docs/sandbox-landlock.md | 141 ++++ docs/sandbox.md | 60 +- go.mod | 4 +- go.sum | 4 + internal/flows/proxy_flow.go | 6 + main.go | 8 + .../platform/bubblewrap_translator_linux.go | 119 +-- sandbox/platform/glob_linux.go | 110 +++ sandbox/platform/landlock_abi_linux.go | 52 ++ sandbox/platform/landlock_abi_linux_test.go | 102 +++ sandbox/platform/landlock_e2e_linux_test.go | 309 ++++++++ sandbox/platform/landlock_helper_linux.go | 379 ++++++++++ .../platform/landlock_helper_linux_test.go | 163 ++++ sandbox/platform/landlock_linux.go | 162 ++++ sandbox/platform/landlock_linux_test.go | 212 ++++++ sandbox/platform/landlock_seccomp_linux.go | 693 ++++++++++++++++++ .../platform/landlock_seccomp_linux_test.go | 531 ++++++++++++++ sandbox/platform/landlock_shim_linux.go | 149 ++++ sandbox/platform/landlock_translator_linux.go | 506 +++++++++++++ .../landlock_translator_linux_test.go | 572 +++++++++++++++ sandbox/platform/platform_linux.go | 25 +- sandbox/registry_test.go | 2 +- 27 files changed, 4356 insertions(+), 122 deletions(-) create mode 100644 cmd/landlock/landlock_sandbox_exec_linux.go create mode 100644 cmd/landlock/landlock_sandbox_exec_other.go create mode 100644 cmd/landlock/landlock_shim_linux.go create mode 100644 cmd/landlock/landlock_shim_other.go create mode 100644 docs/sandbox-landlock.md create mode 100644 sandbox/platform/glob_linux.go create mode 100644 sandbox/platform/landlock_abi_linux.go create mode 100644 sandbox/platform/landlock_abi_linux_test.go create mode 100644 sandbox/platform/landlock_e2e_linux_test.go create mode 100644 sandbox/platform/landlock_helper_linux.go create mode 100644 sandbox/platform/landlock_helper_linux_test.go create mode 100644 sandbox/platform/landlock_linux.go create mode 100644 sandbox/platform/landlock_linux_test.go create mode 100644 sandbox/platform/landlock_seccomp_linux.go create mode 100644 sandbox/platform/landlock_seccomp_linux_test.go create mode 100644 sandbox/platform/landlock_shim_linux.go create mode 100644 sandbox/platform/landlock_translator_linux.go create mode 100644 sandbox/platform/landlock_translator_linux_test.go diff --git a/.github/workflows/pmg-e2e.yml b/.github/workflows/pmg-e2e.yml index 0637c69..8cb46d3 100644 --- a/.github/workflows/pmg-e2e.yml +++ b/.github/workflows/pmg-e2e.yml @@ -554,6 +554,8 @@ jobs: defaults: run: shell: bash + env: + PMG_SANDBOX_DRIVER: bubblewrap steps: - name: Checkout Source uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 @@ -613,3 +615,82 @@ jobs: - name: Run Package Manager E2E Test run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/pm-e2e.js + + sandbox-e2e-linux-landlock: + name: Sandbox E2E - Linux (Landlock) + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + env: + PMG_SANDBOX_DRIVER: landlock + PMG_LANDLOCK_E2E: "1" + # Redirect npm's cache into /tmp so it sits outside any pre-existing + # state in /home/runner/.npm (which setup-node / the runner image may + # have populated with state the sandbox policy doesn't account for). + # The npm-restrictive profile already grants /tmp/** read+write. + npm_config_cache: /tmp/npm-cache + steps: + - name: Checkout Source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 20 + check-latest: true + + - name: Setup PNPM + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 + with: + version: 10 + + - name: Build PMG + run: make + + - name: Add pmg to PATH + run: echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH + + - name: Setup PMG + run: pmg setup install + + - name: Create Test Directories for Sandbox Permissions Tests + run: mkdir -p ~/.aws ~/.gcloud ~/.kube ~/.ssh ~/.gnupg ~/.docker + + - name: Create Test Files for Sandbox Permissions Tests + run: | + touch ~/.aws/credentials + touch ~/.gcloud/credentials.json + touch ~/.kube/config + touch ~/.ssh/id_rsa + touch ~/.gnupg/pubring.kbx + touch ~/.docker/config.json + touch ./.env + + - name: Disable AppArmor for User Namespaces + run: | + sudo systemctl stop apparmor + sudo systemctl disable apparmor + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Verify Landlock Available + run: | + if [ ! -d /sys/kernel/security/landlock ] && ! grep -q landlock /proc/kallsyms 2>/dev/null; then + echo "Landlock not detected by sysfs probe (continuing — driver will fail loudly if unavailable)" + fi + uname -a + + - name: Run Landlock Helper E2E Tests (Go) + run: go test -count=1 -v -run TestLandlockHelper ./sandbox/platform/... + + - name: Run Sandbox E2E Test + run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/sandbox-e2e.js + + - name: Run Package Manager E2E Test + run: pmg --sandbox --sandbox-enforce --sandbox-profile npm-restrictive npm exec -- node test/pm-e2e.js diff --git a/cmd/landlock/landlock_sandbox_exec_linux.go b/cmd/landlock/landlock_sandbox_exec_linux.go new file mode 100644 index 0000000..e8eeb45 --- /dev/null +++ b/cmd/landlock/landlock_sandbox_exec_linux.go @@ -0,0 +1,33 @@ +//go:build linux + +package landlock + +import ( + "github.com/safedep/pmg/sandbox/platform" + "github.com/spf13/cobra" +) + +// NewLandlockSandboxExecCommand returns the hidden Cobra command used as the +// helper process entry point for the Landlock sandbox driver. +func NewLandlockSandboxExecCommand() *cobra.Command { + var policyFile string + var auditSocket string + + cmd := &cobra.Command{ + Use: "__landlock_sandbox_exec", + Hidden: true, + // Skip parent pmg initialization (config, event log, analytics) — + // RunLandlockHelper sets up its own minimal logger. + PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + RunE: func(cmd *cobra.Command, args []string) error { + return platform.RunLandlockHelper(policyFile, auditSocket, args) + }, + } + + cmd.Flags().StringVar(&policyFile, "policy-file", "", "Path to policy JSON file") + cmd.Flags().StringVar(&auditSocket, "audit-socket", "", "Path to audit unix socket") + _ = cmd.MarkFlagRequired("policy-file") + _ = cmd.MarkFlagRequired("audit-socket") + + return cmd +} diff --git a/cmd/landlock/landlock_sandbox_exec_other.go b/cmd/landlock/landlock_sandbox_exec_other.go new file mode 100644 index 0000000..77e302a --- /dev/null +++ b/cmd/landlock/landlock_sandbox_exec_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package landlock + +import "github.com/spf13/cobra" + +// NewLandlockSandboxExecCommand returns nil on non-Linux platforms where +// Landlock is not available. +func NewLandlockSandboxExecCommand() *cobra.Command { return nil } diff --git a/cmd/landlock/landlock_shim_linux.go b/cmd/landlock/landlock_shim_linux.go new file mode 100644 index 0000000..346b681 --- /dev/null +++ b/cmd/landlock/landlock_shim_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package landlock + +import ( + "github.com/safedep/pmg/sandbox/platform" + "github.com/spf13/cobra" +) + +// NewLandlockShimCommand returns the hidden Cobra command used as the +// inside-user-namespace shim. The helper process (pmg __landlock_sandbox_exec) +// clones a child with CLONE_NEWUSER + uid/gid mapping (0 -> host uid) so the +// shim boots as uid 0 inside the ns with CAP_SYS_ADMIN. The shim installs the +// seccomp filter WITHOUT PR_SET_NO_NEW_PRIVS (allowed by CAP_SYS_ADMIN in the +// ns) and applies Landlock; this keeps the shim (and every descendant) with +// dumpable=1, so the helper can open /proc//mem to resolve openat(2) +// path arguments for seccomp-notify. +func NewLandlockShimCommand() *cobra.Command { + var policyFile string + var notifySocketFd int + + cmd := &cobra.Command{ + Use: "__landlock_shim", + Hidden: true, + DisableFlagParsing: false, + // Skip parent pmg initialization — the shim re-execs almost + // immediately and does not need config/analytics/etc. + PersistentPreRun: func(cmd *cobra.Command, args []string) {}, + RunE: func(cmd *cobra.Command, args []string) error { + return platform.RunLandlockShim(policyFile, notifySocketFd, args) + }, + } + cmd.Flags().StringVar(&policyFile, "policy-file", "", "Path to policy JSON file") + cmd.Flags().IntVar(¬ifySocketFd, "notify-socket-fd", 0, "FD of socketpair end used to send the seccomp notify fd to the supervisor") + _ = cmd.MarkFlagRequired("policy-file") + _ = cmd.MarkFlagRequired("notify-socket-fd") + return cmd +} diff --git a/cmd/landlock/landlock_shim_other.go b/cmd/landlock/landlock_shim_other.go new file mode 100644 index 0000000..6552dfd --- /dev/null +++ b/cmd/landlock/landlock_shim_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package landlock + +import "github.com/spf13/cobra" + +// NewLandlockShimCommand returns nil on non-Linux platforms. +func NewLandlockShimCommand() *cobra.Command { return nil } diff --git a/docs/sandbox-landlock.md b/docs/sandbox-landlock.md new file mode 100644 index 0000000..dcc15ec --- /dev/null +++ b/docs/sandbox-landlock.md @@ -0,0 +1,141 @@ +# Landlock Sandbox: Developer Notes + +How the Linux Landlock driver works and why. User docs: [sandbox.md](./sandbox.md). + +## Why Landlock + seccomp + +Landlock is positive allow-list. Our profiles are negative on top of broad allow: +`allow_read: /` plus implicit deny on `~/.ssh`, `~/.aws`, `.env`, `.git/hooks`. Landlock +cannot subtract from a subtree, so we layer seccomp-notify on top: + +- Landlock: kernel-native allow-list, fast, applies to most syscalls. +- seccomp-notify: intercepts `openat`/`openat2`/`execve`/`execveat`, resolves the path arg + by reading the trapping process's memory, matches against the deny list, responds + `EACCES` or `CONTINUE`. + +## Architecture + +``` + pmg main ──fork+exec──► pmg __landlock_sandbox_exec [helper, unfiltered] + │ runs supervisor loop + │ + clone(CLONE_NEWUSER, uid=0→host) + │ + ▼ + pmg __landlock_shim [single-threaded, + ├ install seccomp (no NNP) uid 0 in ns, + ├ apply Landlock CAP_SYS_ADMIN] + ├ send notify_fd via SCM_RIGHTS + └ execve target + │ + ▼ + target ─► child ─► grandchild [filter inherited, + dumpable=1] +``` + +The helper has no filter on itself, so it can read `/proc//mem` for any descendant +to resolve `openat` paths. + +### Code layout + +| File | Role | +|------|------| +| `cmd/landlock/landlock_sandbox_exec_linux.go` | Helper subcommand wrapper | +| `cmd/landlock/landlock_shim_linux.go` | Shim subcommand wrapper | +| `sandbox/platform/landlock_linux.go` | `Sandbox` impl, command rewrite | +| `sandbox/platform/landlock_translator_linux.go` | PMG policy → `landlockExecPolicy` | +| `sandbox/platform/landlock_helper_linux.go` | Helper: forks shim, runs supervisor | +| `sandbox/platform/landlock_shim_linux.go` | Shim: installs seccomp+Landlock, execve | +| `sandbox/platform/landlock_seccomp_linux.go` | BPF, supervisor loop, deny matchers, memfd cache | +| `sandbox/platform/landlock_abi_linux.go` | Kernel ABI probe | + +## Key decisions + +### Shim runs in `CLONE_NEWUSER` so seccomp can be installed without NNP + +Unprivileged seccomp install requires `PR_SET_NO_NEW_PRIVS`. NNP plus `execve` triggers +`LSM_UNSAFE_NO_NEW_PRIVS` and the kernel sets `dumpable=0`. With `dumpable=0`, +`/proc//mem` opens require `CAP_SYS_PTRACE`, which the helper does not have. +Result: supervisor cannot resolve openat paths for descendants. + +The shim boots inside a fresh user namespace mapped `0 → host_uid`. As uid 0 in the ns +it has `CAP_SYS_ADMIN`, which lets seccomp install skip NNP. No NNP, no dumpable reset, +memfd reads work for the whole tree. The mapping preserves host uid for filesystem +ownership; tools that gate on `getuid()` see no change. + +### Landlock is applied in the shim, after seccomp install + +Earlier the helper installed seccomp first, then ran `landlock.RestrictPaths`. +`BestEffort()` probes via `openat`. Each probe trapped through the supervisor in the +same process. Go's GC stop-the-world needs every thread at a safepoint; a thread +suspended inside `seccomp_do_user_notification` cannot reach one. Helper hung after a +handful of notifications. + +Now seccomp + Landlock both live in the shim, which is single-threaded by virtue of +being just-exec'd Go. No GC pressure during setup. Helper is unfiltered. + +### No `TSYNC` on the filter + +`SECCOMP_FILTER_FLAG_TSYNC` applies the filter to every thread in the group. Go runtime +threads (GC, sysmon, netpoll) routinely `openat`, all would trap and deadlock the same +way the unsandboxed-helper variant did. +Without TSYNC the filter is only on the installing thread. Descendants inherit it via +`clone()` and `execve()` anyway, so we get the same coverage without polluting the Go +runtime threads. + +### `Stop()` wakes the supervisor via an eventfd + +Closing `notifyFd` does not wake an `ioctl(SECCOMP_IOCTL_NOTIF_RECV)` blocker. We +`ppoll` over `notifyFd` + an eventfd; `Stop()` writes to the eventfd. See +`waitForNotif` in `landlock_seccomp_linux.go`. + +### `landlockReadAccess` includes `EXECUTE` + +Bubblewrap's `--ro-bind` permits execve implicitly. Landlock requires explicit +`AccessFSExecute`. Without it `allow_read: /` blocks every binary load. We bake EXECUTE +into read access; deny-exec is still enforced by the seccomp supervisor. + +### Per-PID `/proc//mem` cache, invalidated on execve + +`execve` reshapes the address space; the cached fd returns EOF afterwards. We +invalidate on each execve notification (`seccompPhase.invalidateMemFd`) and lazily +reopen via `memFdFor`. Grandchildren get their own entries. + +### Deny matcher treats a path as its own subtree + +`GetMandatoryDenyPatterns` emits `/home/user/.ssh` (no trailing slash). The matcher +covers the path itself and anything beneath `entry+"/"`, so `~/.ssh/id_rsa` is caught. +Trailing-slash entries still prefix-match. + +## Go-specific nuances + +The Landlock+seccomp pattern was designed around the C/Rust threading model. Go pays a +constant tax that maps to most of the decisions above: + +- **Multi-threaded from `main()`.** Go always has GC, sysmon, netpoll threads. There is + no single-threaded mode. TSYNC turns those threads into traffic for our supervisor. +- **GC stop-the-world vs. seccomp wait.** A goroutine suspended by the kernel inside + a seccomp trap cannot reach a GC safepoint. STW blocks. The supervisor goroutine, + which would unblock the trap, never runs. Rust has no GC, no STW. +- **No code injection between fork and execve.** Go's `exec.Cmd` does + `clone()` + a hardcoded sequence + `execve()`. There is no `PreExecFn` field. The + shim subcommand exists to provide a hookpoint that doesn't exist in `os/exec`. In + Rust this is inline post-`fork()`. +- **`unshare(CLONE_NEWUSER)` rejects multi-threaded callers.** A Go program cannot + enter a new user namespace from `main()`. We route the namespace through + `clone(CLONE_NEWUSER)` on the child path of `cmd.Start` instead. +- **`runtime.LockOSThread` is mandatory** wherever per-thread state matters + (NNP, seccomp install, the supervisor's `ppoll`/`ioctl` loop). Otherwise Go's + scheduler will move the goroutine and the per-thread state goes with the wrong + thread. + +## Limitations + +- **Unprivileged user namespaces required.** On distros that disable them, `clone()` + returns EPERM. We don't yet probe and fall back to bubblewrap (TODO). +- **Network filtering not enforced.** Landlock V4 does TCP ports, not hostnames. Use + proxy-mode. +- **PID/IPC namespace isolation is best-effort.** Retried without on EPERM. +- **Audit events are dropped.** Wired but consumed by `io.Discard`. +- **TOCTOU between path read and deny response.** Microseconds. Adequate for benign + install scripts; not a hardened defense. diff --git a/docs/sandbox.md b/docs/sandbox.md index 9533e76..1602a9c 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -48,7 +48,8 @@ like `${HOME}/**` do not opt out of `${HOME}/.aws`. The unnamed absolute form st ## Requirements -- Bubblewrap on Linux +- Linux kernel 5.13+ with Landlock enabled (default, no external dependencies) +- Bubblewrap on Linux (fallback for kernels < 5.13, or when `PMG_SANDBOX_DRIVER=bubblewrap` is set) - Seatbelt on MacOS
@@ -203,13 +204,66 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in | Platform | Supported | Implementation | | -------- | --------- | ----------------------------------- | | MacOS | Yes | Seatbelt sandbox-exec | -| Linux | Yes | Bubblewrap with namespace isolation | +| Linux | Yes | Landlock (default, kernel 5.13+) or Bubblewrap (fallback) | | Windows | No | Not yet supported | ### Platform-Specific Limitations
-Linux (Bubblewrap) +Linux (Landlock, default) + +**Default sandbox on kernel 5.13+**: Landlock provides kernel-native filesystem access control +without requiring external binaries or unprivileged user namespaces. + +For the architecture, design tradeoffs, and known limitations see +[sandbox-landlock.md](./sandbox-landlock.md). + +**Deny enforcement**: Deny rules (DenyRead, DenyWrite, DenyExec) are enforced via seccomp +user notifications. This introduces a small TOCTOU window (microseconds) between reading +the path and responding. + +**Deny enforcement across the process tree**: seccomp-notify resolves the path argument of +an intercepted `openat(2)` by reading `/proc//mem` of the trapping process. PMG ships +this in a two-stage architecture so enforcement applies to direct targets AND every +descendant (grandchildren, great-grandchildren, etc.): + +1. The helper process (`pmg __landlock_sandbox_exec`) clones a tiny shim + (`pmg __landlock_shim`) with `CLONE_NEWUSER` and a uid/gid map of `0 -> host uid`. + The shim runs as uid 0 inside a fresh user namespace so it has `CAP_SYS_ADMIN` in that + namespace. +2. The shim installs the seccomp-notify filter **without** `PR_SET_NO_NEW_PRIVS` (permitted + by `CAP_SYS_ADMIN` in the ns). It then applies Landlock and `execve`s the real target. +3. Because `NO_NEW_PRIVS` was never set, subsequent `execve` calls in the tree do **not** + reset `dumpable` to 0, so the helper can keep opening `/proc//mem` for any + descendant. Deny rules like `~/.ssh` are enforced for the full process tree. + +The user namespace is purely a capability vehicle. Host uid/gid are preserved through the +mapping, so targets see the same filesystem ownership they normally would. Tools that +refuse to run as root (npm's root-in-container warning) are unaffected because the +outside-view uid never changes. + +**Requirements**: unprivileged user namespaces must be enabled (`unprivileged_userns_clone=1` +on Debian/Ubuntu; default on most modern distros). If disabled, the helper fails with an +EPERM on `clone()` and the sandbox falls back to Bubblewrap. + +**Network filtering**: Not enforced. Landlock supports TCP port filtering only (V4+, no hostname). +Use `--proxy-mode` for network control. + +**PID/IPC namespace isolation**: Applied best-effort via `CLONE_NEWPID|CLONE_NEWIPC|CLONE_NEWNS`. +If unavailable, a warning is printed and the command continues. Set `PMG_SANDBOX_DRIVER=bubblewrap` +to force Bubblewrap if namespace isolation is required. + +**`/proc` access**: The sandbox supervisor requires `/proc` read access. When PID namespace +isolation succeeds, `/proc` is scoped to the child's namespace. When it fails, `/proc` +exposes all system processes. + +**Fallback**: If Landlock is unavailable (kernel < 5.13), Bubblewrap is used automatically. +Set `PMG_SANDBOX_DRIVER=bubblewrap` to force Bubblewrap. + +
+ +
+Linux (Bubblewrap, fallback) **Filesystem permissions are coarse-grained**: [Bubblewrap](https://github.com/containers/bubblewrap) uses bind mounts for filesystem isolation. diff --git a/go.mod b/go.mod index fae4b74..e86202c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/osv-scalibr v0.2.1 github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.6.7 + github.com/landlock-lsm/go-landlock v0.7.0 github.com/posthog/posthog-go v1.5.12 github.com/safedep/dry v0.0.0-20260411074023-b589e91de472 github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 @@ -20,6 +21,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 @@ -81,11 +83,11 @@ require ( golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3acb791..7ea1457 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/landlock-lsm/go-landlock v0.7.0 h1:gXz0+Phg3vddZjpPzXL4pQy/MgsTMHZBs+9zgUIyu/0= +github.com/landlock-lsm/go-landlock v0.7.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -330,6 +332,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 237b777..e1e9ff0 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -424,6 +424,12 @@ func (f *proxyFlow) executeWithProxy( return fmt.Errorf("failed to apply sandbox: %w", err) } + defer func() { + if err := result.Close(); err != nil { + log.Errorf("failed to close sandbox: %v", err) + } + }() + if !result.ShouldRun() { return usefulerror.Useful(). Wrap(fmt.Errorf("sandbox not supported for PTY sessions")). diff --git a/main.go b/main.go index e55f972..344aa48 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/safedep/dry/log" "github.com/safedep/pmg/cmd/cloud" "github.com/safedep/pmg/cmd/executors" + landlockCmd "github.com/safedep/pmg/cmd/landlock" "github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/pypi" "github.com/safedep/pmg/cmd/setup" @@ -136,6 +137,13 @@ func main() { cmd.AddCommand(setup.NewRemoveCommand()) cmd.AddCommand(cloud.NewCloudCommand()) + if subcmd := landlockCmd.NewLandlockSandboxExecCommand(); subcmd != nil { + cmd.AddCommand(subcmd) + } + if subcmd := landlockCmd.NewLandlockShimCommand(); subcmd != nil { + cmd.AddCommand(subcmd) + } + // Print Banner on --help / -h cmd.SetHelpFunc(func(command *cobra.Command, args []string) { fmt.Print(ui.GeneratePMGBanner(appVersion.Version, appVersion.Commit)) diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go index 9df5422..ce123ac 100644 --- a/sandbox/platform/bubblewrap_translator_linux.go +++ b/sandbox/platform/bubblewrap_translator_linux.go @@ -624,125 +624,12 @@ func (t *bubblewrapPolicyTranslator) expandGlobPattern(pattern string, maxDepth return matches, false, nil } -// expandGlobstarPattern expands patterns containing ** (recursive glob). -// This requires custom implementation since filepath.Glob doesn't support **. -func (t *bubblewrapPolicyTranslator) expandGlobstarPattern(pattern string, maxDepth int, maxPaths int) ([]string, error) { - // Split pattern at ** - parts := strings.Split(pattern, "**") - if len(parts) != 2 { - return nil, fmt.Errorf("only one ** globstar supported per pattern") - } - - basePath := strings.TrimSuffix(parts[0], "/") - suffix := strings.TrimPrefix(parts[1], "/") - - // If base path is empty, it would walk from root which is prohibitively expensive. - // Skip such patterns to prevent filesystem scan timeouts. - if basePath == "" { - log.Debugf("Skipping globstar pattern '%s' with empty base path (would walk from root)", pattern) - return []string{}, nil - } - - // Expand base path variables - var err error - basePath, err = util.ExpandVariables(basePath) - if err != nil { - return nil, fmt.Errorf("failed to expand base path: %w", err) - } - - // Check if base path exists - if _, err := os.Stat(basePath); os.IsNotExist(err) { - // Base path doesn't exist yet, return just the base - return []string{basePath}, nil - } - - matches := []string{} - - // Walk the directory tree with depth limiting - err = t.walkWithDepthLimit(basePath, suffix, maxDepth, maxPaths, &matches) - if err != nil { - return nil, fmt.Errorf("failed to walk directory tree: %w", err) - } - - return matches, nil +func (t *bubblewrapPolicyTranslator) expandGlobstarPattern(pattern string, maxDepth, maxPaths int) ([]string, error) { + return expandGlobstarPattern(pattern, maxDepth, maxPaths) } -// walkWithDepthLimit walks a directory tree with depth limiting. -func (t *bubblewrapPolicyTranslator) walkWithDepthLimit(root string, suffix string, maxDepth int, maxPaths int, matches *[]string) error { - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - // Skip paths we can't access - return nil - } - - // Calculate depth relative to root - relPath, err := filepath.Rel(root, path) - if err != nil { - return nil - } - // When relPath is "." (the root itself), depth should be 0 - // strings.Split(".", "/") returns ["."] with length 1, causing off-by-one error - depth := 0 - if relPath != "." { - depth = len(strings.Split(relPath, string(filepath.Separator))) - } - - // Enforce depth limit - if maxDepth > 0 && depth > maxDepth { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - - // Match suffix - if suffix == "" || strings.HasSuffix(path, suffix) { - *matches = append(*matches, path) - - // Enforce path count limit - if len(*matches) >= maxPaths { - return filepath.SkipAll - } - } - - return nil - }) - - return err -} - -// extractParentDir extracts the parent directory from a glob pattern. -// This is used for coarse-grained fallback when glob expansion yields too many paths. -// -// Examples: -// - ${CWD}/node_modules/** → ${CWD}/node_modules -// - ${HOME}/.cache/pnpm/** → ${HOME}/.cache/pnpm -// - /tmp/*.txt → /tmp -// - /usr/lib/**/*.so → /usr/lib -// - ${CWD}/package.json.* → ${CWD} func (t *bubblewrapPolicyTranslator) extractParentDir(pattern string) string { - // Remove trailing /** or /* - pattern = strings.TrimSuffix(pattern, "/**") - pattern = strings.TrimSuffix(pattern, "/*") - - // Remove any remaining glob characters and find the parent directory - idx := strings.IndexAny(pattern, "*?[") - if idx >= 0 { - // Glob found - truncate at glob character and get the directory - pattern = pattern[:idx] - // Get the directory containing the file/pattern - pattern = filepath.Dir(pattern) - } - - // Clean up trailing separator - pattern = strings.TrimSuffix(pattern, string(filepath.Separator)) - - // If pattern is now empty or just a separator, default to current directory - if pattern == "" || pattern == string(filepath.Separator) { - return "." - } - - return pattern + return extractGlobParentDir(pattern) } // addPTYSupport adds arguments for pseudo-terminal support. diff --git a/sandbox/platform/glob_linux.go b/sandbox/platform/glob_linux.go new file mode 100644 index 0000000..9688386 --- /dev/null +++ b/sandbox/platform/glob_linux.go @@ -0,0 +1,110 @@ +//go:build linux + +package platform + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/sandbox/util" +) + +// expandGlobstarPattern expands patterns containing ** (recursive glob), +// which filepath.Glob does not support. Splits the pattern at ** into a base +// path and a suffix, walks the base with a depth limit, and collects entries +// whose path ends with the suffix. +// +// If the base path does not yet exist, returns []string{basePath} so callers +// can still grant coverage to the parent directory (matters for fresh +// node_modules / pnpm caches that haven't been created yet). +func expandGlobstarPattern(pattern string, maxDepth, maxPaths int) ([]string, error) { + parts := strings.Split(pattern, "**") + if len(parts) != 2 { + return nil, fmt.Errorf("only one ** globstar supported per pattern") + } + + basePath := strings.TrimSuffix(parts[0], "/") + suffix := strings.TrimPrefix(parts[1], "/") + + if basePath == "" { + log.Debugf("Skipping globstar pattern '%s' with empty base path (would walk from root)", pattern) + return []string{}, nil + } + + expandedBase, err := util.ExpandVariables(basePath) + if err != nil { + return nil, fmt.Errorf("failed to expand base path: %w", err) + } + basePath = expandedBase + + if _, err := os.Stat(basePath); os.IsNotExist(err) { + return []string{basePath}, nil + } + + matches := []string{} + if err := walkGlobWithDepthLimit(basePath, suffix, maxDepth, maxPaths, &matches); err != nil { + return nil, fmt.Errorf("failed to walk directory tree: %w", err) + } + return matches, nil +} + +// walkGlobWithDepthLimit walks a directory tree from root, appending paths +// whose suffix matches `suffix`. Stops at maxDepth levels (when > 0) and +// after collecting maxPaths entries. +func walkGlobWithDepthLimit(root, suffix string, maxDepth, maxPaths int, matches *[]string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + relPath, err := filepath.Rel(root, path) + if err != nil { + return nil + } + depth := 0 + if relPath != "." { + depth = len(strings.Split(relPath, string(filepath.Separator))) + } + if maxDepth > 0 && depth > maxDepth { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if suffix == "" || strings.HasSuffix(path, suffix) { + *matches = append(*matches, path) + if len(*matches) >= maxPaths { + return filepath.SkipAll + } + } + return nil + }) +} + +// extractGlobParentDir extracts the parent directory of a glob pattern. Used +// for coarse-grained fallback when expansion yields too many paths or the +// target tree is unsuitable for fine-grained rules. +// +// Examples: +// - ${CWD}/node_modules/** → ${CWD}/node_modules +// - ${HOME}/.cache/pnpm/** → ${HOME}/.cache/pnpm +// - /tmp/*.txt → /tmp +// - /usr/lib/**/*.so → /usr/lib +func extractGlobParentDir(pattern string) string { + pattern = strings.TrimSuffix(pattern, "/**") + pattern = strings.TrimSuffix(pattern, "/*") + + idx := strings.IndexAny(pattern, "*?[") + if idx >= 0 { + pattern = pattern[:idx] + pattern = filepath.Dir(pattern) + } + pattern = strings.TrimSuffix(pattern, string(filepath.Separator)) + + if pattern == "" || pattern == string(filepath.Separator) { + return "." + } + return pattern +} diff --git a/sandbox/platform/landlock_abi_linux.go b/sandbox/platform/landlock_abi_linux.go new file mode 100644 index 0000000..c99b51c --- /dev/null +++ b/sandbox/platform/landlock_abi_linux.go @@ -0,0 +1,52 @@ +//go:build linux +// +build linux + +package platform + +import ( + "fmt" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" +) + +// landlockABI represents the detected Landlock ABI version and its feature capabilities. +// Each boolean flag indicates whether the corresponding Landlock feature is available +// at the detected ABI version. +type landlockABI struct { + Version int // 1-6, 0 if unsupported + HasRefer bool // V2+: rename across directories (atomic writes) + HasTruncate bool // V3+: file truncation + HasNetwork bool // V4+: TCP port filtering + HasIoctlDev bool // V5+: device ioctl (PTY terminal ops) + HasScoping bool // V6+: signal isolation +} + +// newLandlockABI constructs a landlockABI with feature flags derived from the version number. +// Version <= 0 means all flags are false (unsupported). Versions > 6 have all flags set +// to true since we assume forward compatibility for known features. +func newLandlockABI(version int) *landlockABI { + return &landlockABI{ + Version: version, + HasRefer: version >= 2, + HasTruncate: version >= 3, + HasNetwork: version >= 4, + HasIoctlDev: version >= 5, + HasScoping: version >= 6, + } +} + +// landlockDetectABI probes the running kernel for Landlock support and returns +// the detected ABI version with feature flags. Returns an error if Landlock is +// not supported by the kernel. +func landlockDetectABI() (*landlockABI, error) { + version, err := llsyscall.LandlockGetABIVersion() + if err != nil { + return nil, fmt.Errorf("landlock not supported: %w", err) + } + + if version <= 0 { + return nil, fmt.Errorf("landlock not supported: ABI version %d", version) + } + + return newLandlockABI(version), nil +} diff --git a/sandbox/platform/landlock_abi_linux_test.go b/sandbox/platform/landlock_abi_linux_test.go new file mode 100644 index 0000000..729906b --- /dev/null +++ b/sandbox/platform/landlock_abi_linux_test.go @@ -0,0 +1,102 @@ +//go:build linux +// +build linux + +package platform + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLandlockABI_FeatureFlags(t *testing.T) { + tests := []struct { + version int + hasRefer bool + hasTruncate bool + hasNetwork bool + hasIoctlDev bool + hasScoping bool + }{ + {version: 0, hasRefer: false, hasTruncate: false, hasNetwork: false, hasIoctlDev: false, hasScoping: false}, + {version: 1, hasRefer: false, hasTruncate: false, hasNetwork: false, hasIoctlDev: false, hasScoping: false}, + {version: 2, hasRefer: true, hasTruncate: false, hasNetwork: false, hasIoctlDev: false, hasScoping: false}, + {version: 3, hasRefer: true, hasTruncate: true, hasNetwork: false, hasIoctlDev: false, hasScoping: false}, + {version: 4, hasRefer: true, hasTruncate: true, hasNetwork: true, hasIoctlDev: false, hasScoping: false}, + {version: 5, hasRefer: true, hasTruncate: true, hasNetwork: true, hasIoctlDev: true, hasScoping: false}, + {version: 6, hasRefer: true, hasTruncate: true, hasNetwork: true, hasIoctlDev: true, hasScoping: true}, + } + + for _, tt := range tests { + t.Run( + func() string { + return "version_" + string(rune('0'+tt.version)) + }(), + func(t *testing.T) { + abi := newLandlockABI(tt.version) + assert.Equal(t, tt.version, abi.Version, "Version mismatch") + assert.Equal(t, tt.hasRefer, abi.HasRefer, "HasRefer mismatch for V%d", tt.version) + assert.Equal(t, tt.hasTruncate, abi.HasTruncate, "HasTruncate mismatch for V%d", tt.version) + assert.Equal(t, tt.hasNetwork, abi.HasNetwork, "HasNetwork mismatch for V%d", tt.version) + assert.Equal(t, tt.hasIoctlDev, abi.HasIoctlDev, "HasIoctlDev mismatch for V%d", tt.version) + assert.Equal(t, tt.hasScoping, abi.HasScoping, "HasScoping mismatch for V%d", tt.version) + }, + ) + } +} + +func TestNewLandlockABI_HighVersion(t *testing.T) { + for _, version := range []int{7, 8, 10, 100} { + abi := newLandlockABI(version) + // High versions should be capped at 6 but all flags should be true + assert.Equal(t, version, abi.Version, "Version should be preserved as-is") + assert.True(t, abi.HasRefer, "HasRefer should be true for V%d", version) + assert.True(t, abi.HasTruncate, "HasTruncate should be true for V%d", version) + assert.True(t, abi.HasNetwork, "HasNetwork should be true for V%d", version) + assert.True(t, abi.HasIoctlDev, "HasIoctlDev should be true for V%d", version) + assert.True(t, abi.HasScoping, "HasScoping should be true for V%d", version) + } +} + +func TestNewLandlockABI_NegativeVersion(t *testing.T) { + abi := newLandlockABI(-1) + assert.Equal(t, -1, abi.Version) + assert.False(t, abi.HasRefer) + assert.False(t, abi.HasTruncate) + assert.False(t, abi.HasNetwork) + assert.False(t, abi.HasIoctlDev) + assert.False(t, abi.HasScoping) +} + +func TestDetectABI_Integration(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Landlock is only available on Linux") + } + + abi, err := landlockDetectABI() + if err != nil { + // Landlock may not be available on this kernel (requires Linux 5.13+) + t.Skipf("Landlock not available on this system: %v", err) + } + + assert.NotNil(t, abi) + assert.Greater(t, abi.Version, 0, "Landlock ABI version should be > 0 when supported") + + // If we got here, basic feature flags should be consistent + if abi.Version >= 2 { + assert.True(t, abi.HasRefer) + } + if abi.Version >= 3 { + assert.True(t, abi.HasTruncate) + } + if abi.Version >= 4 { + assert.True(t, abi.HasNetwork) + } + if abi.Version >= 5 { + assert.True(t, abi.HasIoctlDev) + } + if abi.Version >= 6 { + assert.True(t, abi.HasScoping) + } +} diff --git a/sandbox/platform/landlock_e2e_linux_test.go b/sandbox/platform/landlock_e2e_linux_test.go new file mode 100644 index 0000000..6d22bee --- /dev/null +++ b/sandbox/platform/landlock_e2e_linux_test.go @@ -0,0 +1,309 @@ +//go:build linux + +package platform + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These end-to-end tests build the pmg binary and invoke the hidden +// `__landlock_sandbox_exec` entry point directly with a crafted policy file, +// bypassing the rest of pmg (config, proxy, etc.). They verify the helper +// flow on a real kernel + Landlock ABI. +// +// Opt-in: skipped unless PMG_LANDLOCK_E2E=1 is set in the environment. They +// require a kernel that allows installing seccomp without NNP from inside an +// unprivileged user namespace — Ubuntu 24.04 blocks this by default via +// `kernel.apparmor_restrict_unprivileged_userns=1`, so CI must disable +// AppArmor (or the sysctl) before setting the env var. Also skipped when +// kernel Landlock is unavailable or the pmg binary cannot be located/built. + +const landlockRuleReadExec = uint64(13) // READ_FILE | READ_DIR | EXECUTE +const landlockRuleReadDir = uint64(12) // READ_FILE | READ_DIR + +// landlockE2EEnabled reports whether the user has opted into running these +// e2e tests. Default: skip. The CI landlock job sets PMG_LANDLOCK_E2E=1 after +// disabling AppArmor. +func landlockE2EEnabled() bool { + v := os.Getenv("PMG_LANDLOCK_E2E") + return v == "1" || v == "true" || v == "yes" +} + +// buildPmgBinary locates or builds bin/pmg. Returns absolute path. +func buildPmgBinary(t *testing.T) string { + t.Helper() + // Walk upward from CWD to find the repo root (contains go.mod + main.go). + cwd, err := os.Getwd() + require.NoError(t, err) + dir := cwd + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "main.go")); err == nil { + break + } + parent := filepath.Dir(dir) + if parent == dir { + t.Skip("could not locate pmg repo root") + } + dir = parent + } + binPath := filepath.Join(dir, "bin", "pmg") + if _, err := os.Stat(binPath); err == nil { + return binPath + } + // Build fresh. + cmd := exec.Command("go", "build", "-o", binPath, "main.go") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "build failed: %s", out) + return binPath +} + +// writePolicyFile serializes a minimal landlockExecPolicy to a temp file. +func writePolicyFile(t *testing.T, p *landlockExecPolicy) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "policy-*.json") + require.NoError(t, err) + require.NoError(t, json.NewEncoder(f).Encode(p)) + require.NoError(t, f.Close()) + return f.Name() +} + +// runHelper invokes the hidden helper subcommand with the given policy and +// returns (stdout, stderr, exit-code). +func runHelper(t *testing.T, policyPath string) (string, string, int) { + t.Helper() + pmg := buildPmgBinary(t) + cmd := exec.Command(pmg, + "__landlock_sandbox_exec", + "--policy-file", policyPath, + "--audit-socket", "/tmp/pmg-test-audit.sock.nonexistent", + ) + // PMG_KEEP_POLICY ensures test state is visible on failure. + cmd.Env = append(os.Environ(), "PMG_KEEP_POLICY=1") + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + exit := 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + exit = ee.ExitCode() + } else { + exit = -1 + } + } + return outBuf.String(), errBuf.String(), exit +} + +// baseRules returns a minimal allow-list sufficient to run common binaries. +func baseRules() []landlockPathRule { + return []landlockPathRule{ + {Path: "/", Access: landlockRuleReadExec}, + {Path: "/usr", Access: landlockRuleReadExec}, + {Path: "/bin", Access: landlockRuleReadExec}, + {Path: "/lib", Access: landlockRuleReadExec}, + {Path: "/lib64", Access: landlockRuleReadExec}, + {Path: "/usr/lib", Access: landlockRuleReadExec}, + {Path: "/usr/lib64", Access: landlockRuleReadExec}, + {Path: "/proc", Access: landlockRuleReadDir}, + {Path: "/dev/null", Access: landlockRuleReadDir}, + {Path: "/dev/urandom", Access: landlockRuleReadDir}, + } +} + +// TestLandlockHelper_EchoRuns is the simplest smoke test: the helper should +// be able to install its filter, fork /bin/echo, collect its output, and +// exit cleanly. Regression for the supervisor.Stop hang and for the +// landlock-before-seccomp ordering deadlock. +func TestLandlockHelper_EchoRuns(t *testing.T) { + if !landlockE2EEnabled() { + t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)") + } + if _, err := landlockDetectABI(); err != nil { + t.Skipf("Landlock not available: %v", err) + } + + policy := &landlockExecPolicy{ + FilesystemRules: baseRules(), + SkipPIDNamespace: true, + SkipIPCNamespace: true, + Command: "/bin/echo", + Args: []string{"sandbox-ok"}, + } + policyPath := writePolicyFile(t, policy) + + stdout, stderr, exit := runHelper(t, policyPath) + assert.Equal(t, 0, exit, "helper exited non-zero: stderr=%s", stderr) + assert.Contains(t, stdout, "sandbox-ok") +} + +// TestLandlockHelper_DirectChildDenyBlocksRead is the security-critical +// assertion: when the policy says "allow /" but "deny ~/.ssh", a direct +// target that tries to read ~/.ssh must see EACCES. This works because the +// target is the helper's direct child, so /proc//mem is readable and +// seccomp-notify can resolve the openat path argument. +// +// Grandchild processes (e.g. node spawned by an npm shell wrapper) hit a +// dumpable=0 limitation and are covered by a separate TODO — see +// docs/sandbox.md for details. This test intentionally uses /usr/bin/cat +// as a direct target to keep enforcement in scope. +func TestLandlockHelper_DirectChildDenyBlocksRead(t *testing.T) { + if !landlockE2EEnabled() { + t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)") + } + if _, err := landlockDetectABI(); err != nil { + t.Skipf("Landlock not available: %v", err) + } + if _, err := os.Stat("/usr/bin/cat"); err != nil { + t.Skip("/usr/bin/cat not found") + } + + // Build a fake HOME with a decoy secret so we don't need real ~/.ssh. + home := t.TempDir() + secretPath := filepath.Join(home, ".ssh", "id_ed25519") + require.NoError(t, os.Mkdir(filepath.Join(home, ".ssh"), 0o700)) + const secret = "SECRET-PRIVATE-KEY-CONTENT" + require.NoError(t, os.WriteFile(secretPath, []byte(secret), 0o600)) + + policy := &landlockExecPolicy{ + FilesystemRules: append(baseRules(), + landlockPathRule{Path: home, Access: landlockRuleReadExec}, + ), + DenyPaths: []denyPathEntry{ + {Path: filepath.Join(home, ".ssh"), Mode: denyBoth}, + }, + SkipPIDNamespace: true, + SkipIPCNamespace: true, + Command: "/usr/bin/cat", + Args: []string{secretPath}, + } + policyPath := writePolicyFile(t, policy) + + stdout, stderr, exit := runHelper(t, policyPath) + assert.NotEqual(t, 0, exit, "cat should have failed; stdout=%q", stdout) + assert.NotContains(t, stdout, secret, "secret content must not leak") + combined := stdout + stderr + assert.True(t, + bytesContainsAny(combined, []string{"Permission denied", "EACCES"}), + "expected a permission-denied error; got: %q", combined) +} + +// TestLandlockHelper_DenyBothBlocksWrite extends the direct-child test to +// verify that denyBoth blocks write access as well. Uses /usr/bin/tee as +// the *direct* target (no shell wrapper) so seccomp-notify can actually +// read the openat path argument — see grandchild limitation note in +// TestLandlockHelper_DirectChildDenyBlocksRead. +func TestLandlockHelper_DenyBothBlocksWrite(t *testing.T) { + if !landlockE2EEnabled() { + t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)") + } + if _, err := landlockDetectABI(); err != nil { + t.Skipf("Landlock not available: %v", err) + } + if _, err := os.Stat("/usr/bin/tee"); err != nil { + t.Skip("/usr/bin/tee not found") + } + + home := t.TempDir() + denyDir := filepath.Join(home, "secrets") + require.NoError(t, os.Mkdir(denyDir, 0o700)) + writeTarget := filepath.Join(denyDir, "token") + + // AccessFSWriteFile (0x2) + MakeReg (0x100) so creation under $home is + // permitted by Landlock; the seccomp deny is what should block. + policy := &landlockExecPolicy{ + FilesystemRules: append(baseRules(), + landlockPathRule{Path: home, Access: landlockRuleReadExec | 0x2 | 0x100}, + ), + DenyPaths: []denyPathEntry{ + {Path: denyDir, Mode: denyBoth}, + }, + SkipPIDNamespace: true, + SkipIPCNamespace: true, + Command: "/usr/bin/tee", + Args: []string{writeTarget}, + } + policyPath := writePolicyFile(t, policy) + + stdout, stderr, exit := runHelper(t, policyPath) + combined := stdout + stderr + if _, err := os.Stat(writeTarget); err == nil { + t.Errorf("write target was created: %s (stdout=%q stderr=%q exit=%d)", writeTarget, stdout, stderr, exit) + } + assert.True(t, + bytesContainsAny(combined, []string{"Permission denied", "EACCES"}), + "expected permission denied; got: %q exit=%d", combined, exit) +} + +// TestLandlockHelper_GrandchildDenyBlocksRead is the big one: deny-rule +// enforcement must reach DESCENDANT processes, not just the direct target. +// This is the contract gap we historically had vs bubblewrap. We use a +// nested bash chain so the `cat` that actually opens the secret is a +// grandchild of the helper (bash -> bash -> cat), forcing enforcement to +// route through per-descendant /proc//mem reads. Works because the +// shim installs seccomp inside a user namespace WITHOUT NO_NEW_PRIVS, +// keeping dumpable=1 through every execve in the tree. +func TestLandlockHelper_GrandchildDenyBlocksRead(t *testing.T) { + if !landlockE2EEnabled() { + t.Skip("PMG_LANDLOCK_E2E not set; skipping landlock e2e (requires AppArmor disabled / unprivileged-userns sysctl)") + } + if _, err := landlockDetectABI(); err != nil { + t.Skipf("Landlock not available: %v", err) + } + if _, err := os.Stat("/bin/bash"); err != nil { + t.Skip("/bin/bash not found") + } + // Requires unprivileged user namespaces for the shim architecture. + if b, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { + if len(b) > 0 && b[0] == '0' { + t.Skip("unprivileged user namespaces disabled") + } + } + + home := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(home, ".ssh"), 0o700)) + secretPath := filepath.Join(home, ".ssh", "id_ed25519") + const secret = "GRANDCHILD-SECRET-CONTENT" + require.NoError(t, os.WriteFile(secretPath, []byte(secret), 0o600)) + + policy := &landlockExecPolicy{ + FilesystemRules: append(baseRules(), + landlockPathRule{Path: home, Access: landlockRuleReadExec}, + ), + DenyPaths: []denyPathEntry{ + {Path: filepath.Join(home, ".ssh"), Mode: denyBoth}, + }, + SkipPIDNamespace: true, + SkipIPCNamespace: true, + Command: "/bin/bash", + // Two layers of exec-via-bash before cat hits the secret. + Args: []string{"-c", + "exec /bin/bash -c 'exec /bin/cat " + secretPath + "'"}, + } + policyPath := writePolicyFile(t, policy) + + stdout, stderr, _ := runHelper(t, policyPath) + assert.NotContains(t, stdout, secret, "grandchild must not read the secret") + combined := stdout + stderr + assert.True(t, + bytesContainsAny(combined, []string{"Permission denied", "EACCES"}), + "expected permission-denied from grandchild; got: %q", combined) +} + +// bytesContainsAny reports whether s contains any of the given substrings. +func bytesContainsAny(s string, subs []string) bool { + for _, sub := range subs { + if bytes.Contains([]byte(s), []byte(sub)) { + return true + } + } + return false +} diff --git a/sandbox/platform/landlock_helper_linux.go b/sandbox/platform/landlock_helper_linux.go new file mode 100644 index 0000000..8a8c20d --- /dev/null +++ b/sandbox/platform/landlock_helper_linux.go @@ -0,0 +1,379 @@ +//go:build linux + +package platform + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "runtime" + "syscall" + "time" + "unsafe" + + "github.com/landlock-lsm/go-landlock/landlock" + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "github.com/safedep/dry/log" + "golang.org/x/sys/unix" +) + +// RunLandlockHelper is the entry point for the __landlock_sandbox_exec helper +// process. policyFile is the path to the policy JSON temp file. auditSocket is +// the path to the audit unix socket. cmdArgs are the target command args +// (everything after "--"). +// +// Architecture — why the helper spawns a shim in a user namespace: +// +// We want to install seccomp-notify AND keep /proc//mem readable for +// descendants, so the supervisor can resolve openat path arguments throughout +// the process tree. Unprivileged seccomp install requires PR_SET_NO_NEW_PRIVS +// — but NNP + execve resets the target's dumpable flag to 0, which blocks +// /proc//mem opens for anyone without CAP_SYS_PTRACE. This defeats +// deny-rule enforcement on grandchildren (bash -> npm -> node). +// +// Fix: fork the target through a thin shim with CLONE_NEWUSER + uid map +// 0->host. The shim boots as uid 0 inside the new user namespace (so +// CAP_SYS_ADMIN in that ns) and installs seccomp WITHOUT NNP, which means +// descendants keep dumpable=1 and the helper can read their memory. The +// shim then execve's the real target with the filter inherited. To the real +// target, this is indistinguishable from running directly — same uid, same +// filesystem, same environment. The user namespace is only a capability +// vehicle. +func RunLandlockHelper(policyFile, auditSocket string, cmdArgs []string) error { + // The shim will re-open this file from disk; we keep it alive until the + // shim has loaded it. + policy, err := readLandlockPolicyFromFile(policyFile) + if err != nil { + return fmt.Errorf("read policy from file: %w", err) + } + defer func() { + if os.Getenv("PMG_KEEP_POLICY") == "" { + _ = os.Remove(policyFile) + } + }() + + log.InitZapLogger("pmg", "landlock-helper") + + auditWriter := io.Writer(io.Discard) + conn, err := net.Dial("unix", auditSocket) + if err == nil { + defer func() { + if cerr := conn.Close(); cerr != nil { + log.Warnf("close audit socket: %v", cerr) + } + }() + auditWriter = conn + } else { + log.Debugf("Failed to connect to audit socket %s: %v", auditSocket, err) + } + + if len(cmdArgs) > 0 { + policy.Command = cmdArgs[0] + if len(cmdArgs) > 1 { + policy.Args = cmdArgs[1:] + } else { + policy.Args = nil + } + } + + // Die if parent exits. + if err := unix.Prctl(unix.PR_SET_PDEATHSIG, uintptr(unix.SIGKILL), 0, 0, 0); err != nil { + return fmt.Errorf("prctl PR_SET_PDEATHSIG: %w", err) + } + + // Socketpair: the shim sends its seccomp notify fd back to the helper + // over its end (passed via ExtraFiles). + sockPair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return fmt.Errorf("socketpair: %w", err) + } + helperSockFile := os.NewFile(uintptr(sockPair[0]), "shim-notify-helper") + shimSockFile := os.NewFile(uintptr(sockPair[1]), "shim-notify-shim") + defer func() { + if err := helperSockFile.Close(); err != nil { + log.Warnf("close helper socket: %v", err) + } + }() + + // ExtraFiles[0] becomes fd=3 inside the shim. Go's exec.Cmd writes + // uid_map/gid_map automatically when UidMappings/GidMappings are set. + selfExe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve self exe: %w", err) + } + shimArgs := []string{ + selfExe, "__landlock_shim", + "--policy-file", policyFile, + "--notify-socket-fd", "3", + "--", policy.Command, + } + shimArgs = append(shimArgs, policy.Args...) + + cmd := exec.Command(selfExe, shimArgs[1:]...) + cmd.Path = selfExe + cmd.Args = shimArgs + if len(policy.Env) > 0 { + cmd.Env = policy.Env + } else { + cmd.Env = os.Environ() + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.ExtraFiles = []*os.File{shimSockFile} + + // CLONE_NEWUSER is the whole point — see function-level comment. We map + // host uid/gid to 0 in the ns so the shim has CAP_SYS_ADMIN to install + // seccomp without NNP. Identity mapping would leave us as unprivileged + // uid inside the ns and we'd have to re-acquire caps via ambient, which + // is not trivial in a Go runtime. + uid := os.Getuid() + gid := os.Getgid() + cmd.SysProcAttr = &syscall.SysProcAttr{ + Cloneflags: syscall.CLONE_NEWUSER, + UidMappings: []syscall.SysProcIDMap{ + {ContainerID: 0, HostID: uid, Size: 1}, + }, + GidMappings: []syscall.SysProcIDMap{ + {ContainerID: 0, HostID: gid, Size: 1}, + }, + GidMappingsEnableSetgroups: false, + } + + extraCloneFlags := landlockBuildCloneflags(policy) + if extraCloneFlags != 0 { + cmd.SysProcAttr.Cloneflags |= extraCloneFlags + } + + // Retry without extra-ns flags if clone fails (kernel/seccomp policy + // may forbid PID/IPC namespaces in restricted environments). + if err := cmd.Start(); err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) && + (errors.Is(pathErr.Err, unix.EPERM) || errors.Is(pathErr.Err, unix.EINVAL)) && + extraCloneFlags != 0 { + _ = landlockWriteAuditEvent(auditWriter, auditEvent{ + Type: auditNamespaceUnavailable, + Message: fmt.Sprintf("namespace clone failed (%v), retrying without PID/IPC ns", err), + Ts: time.Now().UnixNano(), + }) + fmt.Fprintf(os.Stderr, "pmg: warning: PID/IPC namespace unavailable (%v), continuing without\n", err) + cmd.SysProcAttr.Cloneflags &^= extraCloneFlags + if err := cmd.Start(); err != nil { + _ = shimSockFile.Close() + return fmt.Errorf("start shim (retry): %w", err) + } + } else { + _ = shimSockFile.Close() + return fmt.Errorf("start shim: %w", err) + } + } + _ = shimSockFile.Close() // child has its own copy + childPID := cmd.Process.Pid + + notifyFd, err := receiveNotifyFd(int(helperSockFile.Fd())) + if err != nil { + _ = cmd.Process.Signal(unix.SIGKILL) + _ = cmd.Wait() + return fmt.Errorf("receive notify fd from shim: %w", err) + } + + supervisor, err := newLandlockSupervisorFromFd(notifyFd) + if err != nil { + _ = cmd.Process.Signal(unix.SIGKILL) + _ = cmd.Wait() + _ = unix.Close(notifyFd) + return fmt.Errorf("create supervisor: %w", err) + } + + // dumpable=1 is preserved across the shim tree (no NNP), so this open + // succeeds for grandchildren too via memFdFor. + memFd, err := openLandlockChildMemFd(childPID) + if err != nil { + _ = cmd.Process.Signal(unix.SIGKILL) + _ = cmd.Wait() + _ = supervisor.Stop() + _ = landlockWriteAuditEvent(auditWriter, auditEvent{ + Type: auditMemFdOpenFailed, + PID: childPID, + Error: err.Error(), + Message: "failed to open /proc//mem, killing child (fail-close)", + Ts: time.Now().UnixNano(), + }) + return fmt.Errorf("open /proc/%d/mem (fail-close): %w", childPID, err) + } + + if err := supervisor.Enforce(childPID, memFd, policy.DenyPaths, policy.DenyExecPaths, auditWriter); err != nil { + _ = cmd.Process.Signal(unix.SIGKILL) + _ = cmd.Wait() + if cerr := memFd.Close(); cerr != nil { + log.Warnf("close /proc/%d/mem: %v", childPID, cerr) + } + _ = supervisor.Stop() + return fmt.Errorf("enforce seccomp rules: %w", err) + } + + sigCh := make(chan os.Signal, 3) + signal.Notify(sigCh, unix.SIGINT, unix.SIGTERM, unix.SIGQUIT) + go func() { + for sig := range sigCh { + _ = cmd.Process.Signal(sig) + } + }() + + waitErr := cmd.Wait() + + _ = supervisor.Stop() + signal.Stop(sigCh) + close(sigCh) + if err := memFd.Close(); err != nil { + log.Warnf("close /proc/%d/mem: %v", childPID, err) + } + + exitCode := 0 + if waitErr != nil { + var exitErr *exec.ExitError + if errors.As(waitErr, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + os.Exit(exitCode) + return nil // unreachable +} + +// receiveNotifyFd reads a single SCM_RIGHTS-packed fd from the socketpair. +// The shim writes it right after installing the seccomp filter. Returns the +// fd as seen by the helper (kernel re-numbered at recvmsg time). +func receiveNotifyFd(sockFd int) (int, error) { + buf := make([]byte, 1) + oob := make([]byte, unix.CmsgSpace(4)) + iov := unix.Iovec{Base: &buf[0], Len: 1} + msg := unix.Msghdr{Iov: &iov, Iovlen: 1, Control: &oob[0]} + msg.SetControllen(len(oob)) + runtime.LockOSThread() + defer runtime.UnlockOSThread() + for { + _, _, errno := unix.Syscall( + unix.SYS_RECVMSG, + uintptr(sockFd), + uintptr(unsafe.Pointer(&msg)), + 0, + ) + runtime.KeepAlive(&buf) + runtime.KeepAlive(&oob) + runtime.KeepAlive(&iov) + runtime.KeepAlive(&msg) + if errno == unix.EINTR { + continue + } + if errno != 0 { + return -1, fmt.Errorf("recvmsg: %w", errno) + } + break + } + cmsgs, err := unix.ParseSocketControlMessage(oob[:msg.Controllen]) + if err != nil { + return -1, fmt.Errorf("parse cmsg: %w", err) + } + if len(cmsgs) == 0 { + return -1, fmt.Errorf("no SCM_RIGHTS cmsg received (shim likely failed before send)") + } + fds, err := unix.ParseUnixRights(&cmsgs[0]) + if err != nil { + return -1, fmt.Errorf("parse unix rights: %w", err) + } + if len(fds) == 0 { + return -1, fmt.Errorf("no fds in cmsg") + } + return fds[0], nil +} + +// readLandlockPolicyFromFile reads and deserializes a landlockExecPolicy from +// a file path. +func readLandlockPolicyFromFile(path string) (*landlockExecPolicy, error) { + if path == "" { + return nil, fmt.Errorf("policy file path is empty") + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open policy file: %w", err) + } + defer func() { + if err := f.Close(); err != nil { + log.Warnf("close policy file %s: %v", path, err) + } + }() + return readLandlockPolicyFromReader(f) +} + +// readLandlockPolicyFromReader reads and deserializes a landlockExecPolicy +// from an io.Reader. +func readLandlockPolicyFromReader(r io.Reader) (*landlockExecPolicy, error) { + var policy landlockExecPolicy + if err := json.NewDecoder(r).Decode(&policy); err != nil { + return nil, fmt.Errorf("decode policy JSON: %w", err) + } + if policy.Command == "" { + return nil, fmt.Errorf("policy has empty command") + } + return &policy, nil +} + +// landlockBuildCloneflags builds extra clone flags for the shim process based +// on the policy. The CLONE_NEWUSER flag itself is always added by the caller; +// this function returns ONLY the optional PID/IPC/MNT namespace flags. +func landlockBuildCloneflags(policy *landlockExecPolicy) uintptr { + var flags uintptr + if !policy.SkipPIDNamespace { + flags |= unix.CLONE_NEWPID | unix.CLONE_NEWNS + } + if !policy.SkipIPCNamespace { + flags |= unix.CLONE_NEWIPC + } + return flags +} + +// openLandlockChildMemFd opens /proc//mem for reading the child's memory. +func openLandlockChildMemFd(pid int) (*os.File, error) { + path := fmt.Sprintf("/proc/%d/mem", pid) + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + +// landlockSelectConfig picks the Landlock go library's Config (and thus +// ABI target) based on the highest access flag used in the policy. Kept +// in the helper file to avoid import cycles; consumed by the shim. +func landlockSelectConfig(policy *landlockExecPolicy) landlock.Config { + var hasRefer, hasTruncate, hasIoctlDev bool + for _, r := range policy.FilesystemRules { + if r.Access&uint64(llsyscall.AccessFSRefer) != 0 { + hasRefer = true + } + if r.Access&uint64(llsyscall.AccessFSTruncate) != 0 { + hasTruncate = true + } + if r.Access&uint64(llsyscall.AccessFSIoctlDev) != 0 { + hasIoctlDev = true + } + } + switch { + case hasIoctlDev: + return landlock.V5 + case hasTruncate: + return landlock.V3 + case hasRefer: + return landlock.V2 + default: + return landlock.V1 + } +} diff --git a/sandbox/platform/landlock_helper_linux_test.go b/sandbox/platform/landlock_helper_linux_test.go new file mode 100644 index 0000000..e98182a --- /dev/null +++ b/sandbox/platform/landlock_helper_linux_test.go @@ -0,0 +1,163 @@ +//go:build linux + +package platform + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestReadLandlockPolicyFromFile(t *testing.T) { + policy := &landlockExecPolicy{ + Command: "/usr/bin/node", + Args: []string{"index.js"}, + Env: []string{"HOME=/home/user", "PATH=/usr/bin"}, + FilesystemRules: []landlockPathRule{ + {Path: "/usr", Access: 0x0C}, + {Path: "/tmp", Access: 0xFF}, + }, + DenyPaths: []denyPathEntry{ + {Path: "/home/user/.ssh/", Mode: denyBoth}, + }, + DenyExecPaths: []string{"/usr/bin/curl"}, + AllowPTY: true, + SkipPIDNamespace: false, + SkipIPCNamespace: false, + } + + // Write policy to temp file + f, err := os.CreateTemp("", "pmg-test-policy-*.json") + require.NoError(t, err) + defer func() { + if err := os.Remove(f.Name()); err != nil { + t.Logf("remove temp policy: %v", err) + } + }() + + err = json.NewEncoder(f).Encode(policy) + require.NoError(t, err) + require.NoError(t, f.Close()) + + got, err := readLandlockPolicyFromFile(f.Name()) + require.NoError(t, err) + + assert.Equal(t, "/usr/bin/node", got.Command) + assert.Equal(t, []string{"index.js"}, got.Args) + assert.Equal(t, []string{"HOME=/home/user", "PATH=/usr/bin"}, got.Env) + assert.Len(t, got.FilesystemRules, 2) + assert.Equal(t, "/usr", got.FilesystemRules[0].Path) + assert.Equal(t, uint64(0x0C), got.FilesystemRules[0].Access) + assert.Len(t, got.DenyPaths, 1) + assert.Equal(t, "/home/user/.ssh/", got.DenyPaths[0].Path) + assert.Equal(t, denyBoth, got.DenyPaths[0].Mode) + assert.Equal(t, []string{"/usr/bin/curl"}, got.DenyExecPaths) + assert.True(t, got.AllowPTY) + assert.False(t, got.SkipPIDNamespace) + assert.False(t, got.SkipIPCNamespace) +} + +func TestReadLandlockPolicyFromFile_Invalid(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "invalid JSON", + input: `{this is not json}`, + }, + { + name: "empty command", + input: `{"command":"","args":[]}`, + }, + { + name: "empty input", + input: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.CreateTemp("", "pmg-test-policy-*.json") + require.NoError(t, err) + defer func() { + if err := os.Remove(f.Name()); err != nil { + t.Logf("remove temp policy: %v", err) + } + }() + + _, err = f.WriteString(tt.input) + require.NoError(t, err) + require.NoError(t, f.Close()) + + _, err = readLandlockPolicyFromFile(f.Name()) + assert.Error(t, err) + }) + } +} + +func TestReadLandlockPolicyFromFile_EmptyPath(t *testing.T) { + _, err := readLandlockPolicyFromFile("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "policy file path is empty") +} + +func TestReadLandlockPolicyFromFile_NonexistentFile(t *testing.T) { + _, err := readLandlockPolicyFromFile("/tmp/nonexistent-policy-file-12345.json") + assert.Error(t, err) +} + +func TestLandlockBuildCloneflags_Default(t *testing.T) { + policy := &landlockExecPolicy{ + Command: "/bin/sh", + SkipPIDNamespace: false, + SkipIPCNamespace: false, + } + + flags := landlockBuildCloneflags(policy) + + expected := uintptr(unix.CLONE_NEWPID | unix.CLONE_NEWIPC | unix.CLONE_NEWNS) + assert.Equal(t, expected, flags) +} + +func TestLandlockBuildCloneflags_SkipPID(t *testing.T) { + policy := &landlockExecPolicy{ + Command: "/bin/sh", + SkipPIDNamespace: true, + SkipIPCNamespace: false, + } + + flags := landlockBuildCloneflags(policy) + + expected := uintptr(unix.CLONE_NEWIPC) + assert.Equal(t, expected, flags) +} + +func TestLandlockBuildCloneflags_SkipIPC(t *testing.T) { + policy := &landlockExecPolicy{ + Command: "/bin/sh", + SkipPIDNamespace: false, + SkipIPCNamespace: true, + } + + flags := landlockBuildCloneflags(policy) + + expected := uintptr(unix.CLONE_NEWPID | unix.CLONE_NEWNS) + assert.Equal(t, expected, flags) +} + +func TestLandlockBuildCloneflags_SkipBoth(t *testing.T) { + policy := &landlockExecPolicy{ + Command: "/bin/sh", + SkipPIDNamespace: true, + SkipIPCNamespace: true, + } + + flags := landlockBuildCloneflags(policy) + + assert.Equal(t, uintptr(0), flags) +} diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go new file mode 100644 index 0000000..3996d95 --- /dev/null +++ b/sandbox/platform/landlock_linux.go @@ -0,0 +1,162 @@ +//go:build linux + +package platform + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/sandbox" +) + +// landlockSandbox implements the Sandbox interface using Landlock LSM on Linux. +// This implementation follows the CLI-wrapper pattern (like Bubblewrap): +// - Modifies the cmd in place by rewiring it to re-exec pmg with __landlock_sandbox_exec +// - Passes the translated policy via a temp file (--policy-file) +// - Passes an audit unix socket path (--audit-socket) for future audit event consumption +// - Returns ExecutionResult with executed=false +// - Caller must call cmd.Run() to execute the sandboxed command +type landlockSandbox struct { + abi *landlockABI + + // Cleanup state from last Execute() + policyFile string + socketPath string + listener net.Listener +} + +// newLandlockSandbox creates a new Landlock sandbox instance after verifying +// that both Landlock and seccomp user notification are available on the system. +func newLandlockSandbox() (sandbox.Sandbox, error) { + abi, err := landlockDetectABI() + if err != nil { + return nil, fmt.Errorf("landlock not available: %w", err) + } + + log.Debugf("Landlock ABI V%d detected (Refer=%v, Truncate=%v, Network=%v, IoctlDev=%v, Scoping=%v)", + abi.Version, abi.HasRefer, abi.HasTruncate, abi.HasNetwork, abi.HasIoctlDev, abi.HasScoping) + + return &landlockSandbox{abi: abi}, nil +} + +// Name returns the name of this sandbox implementation. +func (s *landlockSandbox) Name() string { + return "landlock" +} + +// IsAvailable returns true if Landlock is available and functional on this system. +func (s *landlockSandbox) IsAvailable() bool { + return s.abi != nil && s.abi.Version > 0 +} + +// Close cleans up any resources allocated by the sandbox. +// It closes the audit socket listener and removes temporary files. +// This method is idempotent and safe to call multiple times. +func (s *landlockSandbox) Close() error { + if s.listener != nil { + _ = s.listener.Close() + s.listener = nil + } + if s.socketPath != "" { + _ = os.Remove(s.socketPath) + s.socketPath = "" + } + if s.policyFile != "" { + _ = os.Remove(s.policyFile) + s.policyFile = "" + } + return nil +} + +// Execute prepares a command to run in the Landlock sandbox with the given policy. +// It translates the PMG policy to a landlockExecPolicy, serializes it to a pipe, +// and rewires the command to re-exec pmg with the __landlock_sandbox_exec subcommand. +// +// This implementation modifies the cmd in place and does NOT execute it. +// Returns ExecutionResult with executed=false, indicating the caller must run cmd.Run(). +func (s *landlockSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { + execPolicy, err := landlockTranslatePolicy(policy, s.abi) + if err != nil { + return nil, fmt.Errorf("failed to translate policy: %w", err) + } + + execPolicy.Command = cmd.Path + if len(cmd.Args) > 1 { + execPolicy.Args = cmd.Args[1:] + } + execPolicy.Env = cmd.Env + + policyFile, err := os.CreateTemp("", "pmg-landlock-policy-*.json") + if err != nil { + return nil, fmt.Errorf("failed to create policy temp file: %w", err) + } + + if err := json.NewEncoder(policyFile).Encode(execPolicy); err != nil { + _ = policyFile.Close() + _ = os.Remove(policyFile.Name()) + return nil, fmt.Errorf("failed to write policy to temp file: %w", err) + } + + policyFilePath := policyFile.Name() + _ = policyFile.Close() + s.policyFile = policyFilePath + + socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("pmg-landlock-audit-%d.sock", os.Getpid())) + listener, err := net.Listen("unix", socketPath) + if err != nil { + _ = os.Remove(policyFilePath) + return nil, fmt.Errorf("failed to create audit unix socket: %w", err) + } + + s.socketPath = socketPath + s.listener = listener + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + if _, err := io.Copy(io.Discard, conn); err != nil { + log.Warnf("audit socket drain: %v", err) + } + if err := conn.Close(); err != nil { + log.Warnf("close audit conn: %v", err) + } + }() + + selfExe, err := os.Executable() + if err != nil { + if cerr := s.Close(); cerr != nil { + log.Warnf("close landlock driver after self-exe lookup failure: %v", cerr) + } + return nil, fmt.Errorf("failed to get self executable path: %w", err) + } + + originalPath := cmd.Path + originalArgs := cmd.Args + + cmd.Path = selfExe + cmd.Args = []string{ + "pmg", "__landlock_sandbox_exec", + "--policy-file", policyFilePath, + "--audit-socket", socketPath, + "--", originalPath, + } + if len(originalArgs) > 1 { + cmd.Args = append(cmd.Args, originalArgs[1:]...) + } + + log.Debugf("Landlock sandboxed command: %s %v", cmd.Path, cmd.Args) + + return sandbox.NewExecutionResult( + sandbox.WithExecutionResultExecuted(false), + sandbox.WithExecutionResultSandbox(s), + ), nil +} diff --git a/sandbox/platform/landlock_linux_test.go b/sandbox/platform/landlock_linux_test.go new file mode 100644 index 0000000..70df2a0 --- /dev/null +++ b/sandbox/platform/landlock_linux_test.go @@ -0,0 +1,212 @@ +//go:build linux + +package platform + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "testing" + + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLandlockSandbox_Name(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + assert.Equal(t, "landlock", sb.Name()) +} + +func TestLandlockSandbox_IsAvailable_True(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + assert.True(t, sb.IsAvailable()) + + sb2 := &landlockSandbox{abi: newLandlockABI(1)} + assert.True(t, sb2.IsAvailable()) +} + +func TestLandlockSandbox_IsAvailable_False(t *testing.T) { + // nil ABI + sb := &landlockSandbox{abi: nil} + assert.False(t, sb.IsAvailable()) + + // Version 0 + sb2 := &landlockSandbox{abi: newLandlockABI(0)} + assert.False(t, sb2.IsAvailable()) +} + +func TestLandlockSandbox_Close(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + + // Close with no resources should return nil + err := sb.Close() + assert.NoError(t, err) + + // Close should be idempotent + err = sb.Close() + assert.NoError(t, err) +} + +func TestLandlockSandbox_Close_CleansUpResources(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test policy", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/usr"}, + }, + } + + cmd := exec.Command("/bin/echo", "hello") + ctx := context.Background() + result, err := sb.Execute(ctx, cmd, policy) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify resources exist before Close + assert.NotEmpty(t, sb.policyFile) + assert.NotEmpty(t, sb.socketPath) + assert.NotNil(t, sb.listener) + + _, err = os.Stat(sb.policyFile) + assert.NoError(t, err, "policy file should exist before Close") + _, err = os.Stat(sb.socketPath) + assert.NoError(t, err, "socket file should exist before Close") + + // Close should clean up + err = result.Close() + assert.NoError(t, err) + + assert.Empty(t, sb.policyFile) + assert.Empty(t, sb.socketPath) + assert.Nil(t, sb.listener) +} + +func TestLandlockSandbox_Execute_RewiresCmd(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test policy", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/usr"}, + }, + } + + cmd := exec.Command("/bin/echo", "hello", "world") + + ctx := context.Background() + result, err := sb.Execute(ctx, cmd, policy) + require.NoError(t, err) + require.NotNil(t, result) + + // cmd.Path should be the current executable (self re-exec) + assert.NotEmpty(t, cmd.Path) + + // cmd.Args should contain __landlock_sandbox_exec + assert.Equal(t, "__landlock_sandbox_exec", cmd.Args[1]) + + // cmd.Args should contain --policy-file and --audit-socket + var policyFileArg, auditSocketArg string + separatorIdx := -1 + for i, arg := range cmd.Args { + if arg == "--policy-file" && i+1 < len(cmd.Args) { + policyFileArg = cmd.Args[i+1] + } + if arg == "--audit-socket" && i+1 < len(cmd.Args) { + auditSocketArg = cmd.Args[i+1] + } + if arg == "--" { + separatorIdx = i + break + } + } + assert.NotEmpty(t, policyFileArg, "Should have --policy-file arg") + assert.NotEmpty(t, auditSocketArg, "Should have --audit-socket arg") + + // Policy file should exist on disk + _, err = os.Stat(policyFileArg) + assert.NoError(t, err, "Policy temp file should exist") + + // Socket path should exist on disk + _, err = os.Stat(auditSocketArg) + assert.NoError(t, err, "Audit socket file should exist") + + // After separator should be the original command and args + assert.True(t, separatorIdx >= 0, "Should have -- separator in args") + if separatorIdx >= 0 && separatorIdx+1 < len(cmd.Args) { + afterSeparator := cmd.Args[separatorIdx+1:] + assert.Equal(t, "/bin/echo", afterSeparator[0]) + assert.Equal(t, "hello", afterSeparator[1]) + assert.Equal(t, "world", afterSeparator[2]) + } + + // ExtraFiles should be empty (no longer using pipe-based communication) + assert.Empty(t, cmd.ExtraFiles) + + // result.ShouldRun() should return true (CLI-wrapper pattern, executed=false) + assert.True(t, result.ShouldRun()) + + err = result.Close() + assert.NoError(t, err) +} + +func TestLandlockSandbox_Execute_PolicySerialized(t *testing.T) { + sb := &landlockSandbox{abi: newLandlockABI(4)} + + policy := &sandbox.SandboxPolicy{ + Name: "test", + Description: "test policy for serialization", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/usr", "/lib"}, + AllowWrite: []string{"/tmp/test"}, + }, + Process: sandbox.ProcessPolicy{ + AllowExec: []string{"/usr/bin/node"}, + }, + } + + cmd := exec.Command("/bin/echo", "test") + + ctx := context.Background() + result, err := sb.Execute(ctx, cmd, policy) + require.NoError(t, err) + require.NotNil(t, result) + + // Policy file path should be stored on the sandbox struct + require.NotEmpty(t, sb.policyFile) + + // Read and decode the policy from the temp file + policyData, err := os.ReadFile(sb.policyFile) + require.NoError(t, err) + + var execPolicy landlockExecPolicy + err = json.Unmarshal(policyData, &execPolicy) + require.NoError(t, err) + + // Verify the exec policy contains the command info + assert.Equal(t, "/bin/echo", execPolicy.Command) + assert.Equal(t, []string{"test"}, execPolicy.Args) + + // Verify filesystem rules were translated (at least our AllowRead paths + implicit rules) + assert.NotEmpty(t, execPolicy.FilesystemRules) + + // Check that /usr is in the filesystem rules + foundUsr := false + for _, rule := range execPolicy.FilesystemRules { + if rule.Path == "/usr" { + foundUsr = true + break + } + } + assert.True(t, foundUsr, "Should have /usr in filesystem rules") + + err = result.Close() + assert.NoError(t, err) +} diff --git a/sandbox/platform/landlock_seccomp_linux.go b/sandbox/platform/landlock_seccomp_linux.go new file mode 100644 index 0000000..a7f5d95 --- /dev/null +++ b/sandbox/platform/landlock_seccomp_linux.go @@ -0,0 +1,693 @@ +//go:build linux + +package platform + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "unsafe" + + "github.com/safedep/dry/log" + "golang.org/x/sys/unix" +) + +// ioctl constants for seccomp-notify, from Linux kernel UAPI include/uapi/linux/seccomp.h. +// These are _IOWR('!', N, struct) values. +const ( + _SECCOMP_IOCTL_NOTIF_RECV = 0xc0502100 + _SECCOMP_IOCTL_NOTIF_SEND = 0xc0182101 +) + +// seccomp constants available in golang.org/x/sys/unix, aliased here for clarity. +// unix.SECCOMP_FILTER_FLAG_NEW_LISTENER = 0x8 +// unix.SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV = 0x20 +// unix.SECCOMP_USER_NOTIF_FLAG_CONTINUE = 0x1 +// unix.SECCOMP_RET_USER_NOTIF = 0x7fc00000 +// unix.SECCOMP_RET_ALLOW = 0x7fff0000 + +// C-layout structs matching kernel seccomp notification structures exactly. + +type seccompData struct { + Nr int32 + Arch uint32 + InstructionPointer uint64 + Args [6]uint64 +} + +type seccompNotification struct { + ID uint64 + PID uint32 + Flags uint32 + Data seccompData +} + +type seccompNotifResp struct { + ID uint64 + Val int64 + Error int32 + Flags uint32 +} + +// Compile-time size assertions to ensure struct layout matches kernel expectations. +var ( + _ [unsafe.Sizeof(seccompData{}) - 64]byte + _ [unsafe.Sizeof(seccompNotification{}) - 80]byte + _ [unsafe.Sizeof(seccompNotifResp{}) - 24]byte +) + +// denyMode specifies what kind of access should be denied for a path. +type denyMode int + +const ( + denyRead denyMode = iota + denyWrite + denyBoth +) + +// denyPathEntry pairs a filesystem path with the access mode to deny. +type denyPathEntry struct { + Path string + Mode denyMode +} + +// auditEventType categorizes security audit events. +type auditEventType string + +const ( + auditSeccompDeny auditEventType = "seccomp_deny" + auditNamespaceUnavailable auditEventType = "namespace_isolation_unavailable" + auditMemFdOpenFailed auditEventType = "memfd_open_failed" +) + +// auditEvent represents a single security audit log entry. +type auditEvent struct { + Type auditEventType `json:"type"` + Syscall string `json:"syscall,omitempty"` + Path string `json:"path,omitempty"` + PID int `json:"pid,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Ts int64 `json:"ts"` +} + +// writeAuditEvent JSON-encodes an audit event and writes it as a single line to w. +func landlockWriteAuditEvent(w io.Writer, evt auditEvent) error { + data, err := json.Marshal(evt) + if err != nil { + return fmt.Errorf("marshal audit event: %w", err) + } + + data = append(data, '\n') + + _, err = w.Write(data) + if err != nil { + return fmt.Errorf("write audit event: %w", err) + } + + return nil +} + +// buildSeccompBPFFilter builds a classic BPF program that intercepts openat, openat2, +// execve, and execveat syscalls, returning SECCOMP_RET_USER_NOTIF for these and +// SECCOMP_RET_ALLOW for everything else. +func landlockBuildBPFFilter() (*unix.SockFprog, error) { + filter := []unix.SockFilter{ + // [0] Load syscall number: BPF_LD | BPF_W | BPF_ABS, offset 0 (nr field in seccomp_data) + {Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: 0}, + // [1] JEQ SYS_OPENAT -> notify (jump to instruction 5) + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 4, Jf: 0, K: uint32(unix.SYS_OPENAT)}, + // [2] JEQ SYS_OPENAT2 -> notify (jump to instruction 5) + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 3, Jf: 0, K: uint32(unix.SYS_OPENAT2)}, + // [3] JEQ SYS_EXECVE -> notify (jump to instruction 5) + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 2, Jf: 0, K: uint32(unix.SYS_EXECVE)}, + // [4] JEQ SYS_EXECVEAT -> notify (jump to instruction 5) + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 1, Jf: 0, K: uint32(unix.SYS_EXECVEAT)}, + // [5] RET SECCOMP_RET_ALLOW + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ALLOW}, + // [6] notify: RET SECCOMP_RET_USER_NOTIF + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_USER_NOTIF}, + } + + return &unix.SockFprog{ + Len: uint16(len(filter)), + Filter: &filter[0], + }, nil +} + +// isPathDenied checks if a path should be denied based on the deny list and open flags. +// flags uses O_ACCMODE constants (O_RDONLY, O_WRONLY, O_RDWR). +// Matching rules: +// - Exact match: /home/user/.env matches deny /home/user/.env +// - Directory subtree: /home/user/.ssh/id_rsa matches deny /home/user/.ssh +// or deny /home/user/.ssh/ (either with or without trailing slash — a +// deny entry without slash is treated as "this path OR anything beneath it") +// - Must NOT match partial names: /home/.envrc does NOT match deny /home/.env +func isPathDenied(path string, flags int, denyPaths []denyPathEntry) bool { + accessMode := flags & unix.O_ACCMODE + + for _, entry := range denyPaths { + matched := false + if strings.HasSuffix(entry.Path, "/") { + // Directory prefix match: path must start with the deny prefix. + matched = strings.HasPrefix(path, entry.Path) + } else { + // Exact match OR any path under this entry as a directory. + matched = path == entry.Path || strings.HasPrefix(path, entry.Path+"/") + } + + if !matched { + continue + } + switch entry.Mode { + case denyRead: + if accessMode == unix.O_RDONLY || accessMode == unix.O_RDWR { + return true + } + case denyWrite: + if accessMode == unix.O_WRONLY || accessMode == unix.O_RDWR { + return true + } + case denyBoth: + return true + } + } + + return false +} + +// isExecDenied checks if a path matches the deny exec list. +// Same matching rules as isPathDenied but no flag check. +func isExecDenied(path string, denyExec []string) bool { + for _, entry := range denyExec { + if strings.HasSuffix(entry, "/") { + if strings.HasPrefix(path, entry) { + return true + } + } else { + if path == entry || strings.HasPrefix(path, entry+"/") { + return true + } + } + } + + return false +} + +// readPathFromMem reads a null-terminated path string from a process's memory +// via a pre-opened /proc//mem file descriptor. Uses ReadAt (pread syscall) +// which is NOT intercepted by the seccomp filter. Max 4096 bytes. +func readPathFromMem(memFd *os.File, addr uintptr) (string, error) { + buf := make([]byte, 4096) + + n, err := memFd.ReadAt(buf, int64(addr)) + if err != nil && n == 0 { + return "", fmt.Errorf("read process memory at 0x%x: %w", addr, err) + } + + // Find the null terminator. + idx := 0 + for idx < n { + if buf[idx] == 0 { + break + } + idx++ + } + + if idx == 0 { + return "", fmt.Errorf("empty path at 0x%x", addr) + } + + return string(buf[:idx]), nil +} + +// _AT_FDCWD is the Linux AT_FDCWD constant (-100). When stored as uint64 in +// seccomp args it may appear as 0xFFFFFF9C (32-bit sign-extended) or +// 0xFFFFFFFFFFFFFF9C (64-bit). +const ( + _AT_FDCWD_32 = 0xFFFFFF9C + _AT_FDCWD_64 = 0xFFFFFFFFFFFFFF9C +) + +// resolveNotifPath resolves a path from seccomp notification arguments. +// Handles AT_FDCWD and dirfd-relative paths via os.Readlink on /proc//cwd +// and /proc//fd/. readlinkat syscall is NOT intercepted. +func resolveNotifPath(pid uint32, dirfd int, rawPath string) (string, error) { + // Absolute path: return as-is. + if filepath.IsAbs(rawPath) { + return filepath.Clean(rawPath), nil + } + + var base string + + // Check for AT_FDCWD (which is -100, but may be sign-extended in uint64). + if dirfd == -100 { + cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) + if err != nil { + return "", fmt.Errorf("readlink /proc/%d/cwd: %w", pid, err) + } + base = cwd + } else { + fdPath, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%d", pid, dirfd)) + if err != nil { + return "", fmt.Errorf("readlink /proc/%d/fd/%d: %w", pid, dirfd, err) + } + base = fdPath + } + + return filepath.Clean(filepath.Join(base, rawPath)), nil +} + +// classifyOpenFlags extracts O_ACCMODE from openat flags. +// For openat(2): flags are in args[2] directly. +// For openat2(2): args[2] is a pointer to an open_how struct where the first +// uint64 field is the flags. We read those from process memory. +func classifyOpenFlags(nr int32, args [6]uint64, memFd *os.File) int { + if nr == int32(unix.SYS_OPENAT) { + return int(args[2]) & unix.O_ACCMODE + } + + // openat2: args[2] is a pointer to struct open_how { u64 flags; u64 mode; u64 resolve; } + if nr == int32(unix.SYS_OPENAT2) && memFd != nil { + buf := make([]byte, 8) + _, err := memFd.ReadAt(buf, int64(args[2])) + if err != nil { + // Cannot read open_how struct; default to read-only (conservative). + return unix.O_RDONLY + } + flags := binary.LittleEndian.Uint64(buf) + return int(flags) & unix.O_ACCMODE + } + + return unix.O_RDONLY +} + +// dirfdFromArgs extracts the dirfd from seccomp args, handling AT_FDCWD +// sign-extension from uint64. +func dirfdFromArgs(val uint64) int { + if val == _AT_FDCWD_32 || val == _AT_FDCWD_64 { + return -100 + } + return int(int32(val)) +} + +// seccompPhase holds the enforcement state for the seccomp supervisor. +type seccompPhase struct { + enforcing bool + childPID uint32 + // memFd is the pre-opened /proc//mem fd for the direct child. + // Descendants (grandchildren spawned via fork/exec) have their own PIDs; + // use memFdFor(pid) to resolve the right fd for any notification. + memFd *os.File + denyPaths []denyPathEntry + denyExec []string + auditWriter io.Writer + + // memFdCache maps descendant PID -> /proc//mem fd. Entries live for + // the duration of the enforce phase; fds are closed in (*seccompSupervisor).Stop. + memFdMu sync.Mutex + memFdCache map[uint32]*os.File +} + +// seccompSupervisor manages the seccomp notification loop. +type seccompSupervisor struct { + notifyFd int + // stopFd is an eventfd written to by Stop() to wake the recv loop. + // Closing notifyFd does NOT wake a goroutine blocked in ioctl(NOTIF_RECV), + // so we poll on both fds and use stopFd as an interrupt. + stopFd int + phase atomic.Pointer[seccompPhase] + loopDone chan struct{} +} + + +// newLandlockSupervisorFromFd wraps an already-created seccomp notify fd +// (obtained from the shim over a socketpair) in a supervisor. It does NOT +// install a filter — the shim did that inside its user namespace so the +// helper stays unfiltered. +func newLandlockSupervisorFromFd(notifyFd int) (*seccompSupervisor, error) { + stopFd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) + if err != nil { + _ = unix.Close(notifyFd) + return nil, fmt.Errorf("eventfd: %w", err) + } + s := &seccompSupervisor{ + notifyFd: notifyFd, + stopFd: stopFd, + loopDone: make(chan struct{}), + } + go s.loop() + return s, nil +} + +// Enforce transitions the supervisor to enforcement mode. From this point on, +// syscalls from childPID and its descendants are checked against the deny lists. +func (s *seccompSupervisor) Enforce(childPID int, memFd *os.File, denyPaths []denyPathEntry, denyExec []string, auditWriter io.Writer) error { + p := &seccompPhase{ + enforcing: true, + childPID: uint32(childPID), + memFd: memFd, + denyPaths: denyPaths, + denyExec: denyExec, + auditWriter: auditWriter, + memFdCache: map[uint32]*os.File{uint32(childPID): memFd}, + } + s.phase.Store(p) + return nil +} + +// memFdFor returns an open /proc//mem fd for the given PID, caching it. +// Returns nil if the fd cannot be opened (e.g., dumpable=0 from an execve +// inside the sandboxed process tree, or the process already exited). +func (p *seccompPhase) memFdFor(pid uint32) *os.File { + p.memFdMu.Lock() + defer p.memFdMu.Unlock() + if fd, ok := p.memFdCache[pid]; ok { + return fd + } + fd, err := os.Open(fmt.Sprintf("/proc/%d/mem", pid)) + if err != nil { + return nil + } + p.memFdCache[pid] = fd + return fd +} + +// invalidateMemFd drops the cached /proc//mem fd. Call this after an +// execve on `pid`: execve can change the process's address space layout and +// (crucially) its dumpable / PTRACE_MODE_ATTACH state, which invalidates +// reads through the existing mem fd with EIO/EOF. Callers will reopen on +// the next lookup. +func (p *seccompPhase) invalidateMemFd(pid uint32) { + p.memFdMu.Lock() + defer p.memFdMu.Unlock() + if fd, ok := p.memFdCache[pid]; ok { + _ = fd.Close() + delete(p.memFdCache, pid) + } +} + +// closeDescendantMemFds closes all cached memfd entries EXCEPT the direct +// child's. Called on Stop; the direct child's memfd is owned by the helper +// caller and closed separately. +func (p *seccompPhase) closeDescendantMemFds() { + p.memFdMu.Lock() + defer p.memFdMu.Unlock() + for pid, fd := range p.memFdCache { + if pid == p.childPID { + continue + } + _ = fd.Close() + delete(p.memFdCache, pid) + } +} + +// Stop signals the recv loop to exit via the eventfd, waits for it, then +// closes the notification fd. Closing notifyFd alone does NOT wake a +// goroutine blocked in ioctl(SECCOMP_IOCTL_NOTIF_RECV). +func (s *seccompSupervisor) Stop() error { + var one = [8]byte{1} + _, _ = unix.Write(s.stopFd, one[:]) + <-s.loopDone + if phase := s.phase.Load(); phase != nil { + phase.closeDescendantMemFds() + } + if err := unix.Close(s.notifyFd); err != nil { + log.Warnf("close seccomp notify fd: %v", err) + } + if err := unix.Close(s.stopFd); err != nil { + log.Warnf("close seccomp stop fd: %v", err) + } + return nil +} + +// loop is the main notification processing goroutine. +func (s *seccompSupervisor) loop() { + defer close(s.loopDone) + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + for { + ready, err := waitForNotif(s.notifyFd, s.stopFd) + if err != nil || !ready { + // stop signalled or fatal poll error — exit loop. + return + } + notif, err := recvNotification(s.notifyFd) + if err != nil { + // ENOENT: notif expired (process exited between poll and recv). + // Retry the loop rather than exit — the listener is still valid. + if errors.Is(err, unix.ENOENT) { + continue + } + return + } + + phase := s.phase.Load() + if phase == nil || !phase.enforcing { + _ = respondContinue(s.notifyFd, notif.ID) + continue + } + + // Enforce for the direct child AND all descendants. A memfd per + // notifying PID is resolved lazily in handleOpen/handleExec — we do + // NOT skip descendants here, because npm-style flows spawn real work + // (node, python, etc.) as grandchildren and the deny list must apply + // to them too. + + switch notif.Data.Nr { + case int32(unix.SYS_EXECVE), int32(unix.SYS_EXECVEAT): + s.handleExec(notif, phase) + // execve reshapes the process's memory layout and may drop + // PTRACE-read permission (if the new binary is setuid or + // changes dumpable). Drop the cached memfd so the next + // openat re-opens /proc//mem fresh. + phase.invalidateMemFd(notif.PID) + case int32(unix.SYS_OPENAT), int32(unix.SYS_OPENAT2): + s.handleOpen(notif, phase) + default: + _ = respondContinue(s.notifyFd, notif.ID) + } + } +} + +func (s *seccompSupervisor) handleExec(notif *seccompNotification, phase *seccompPhase) { + // For execve: args[0] is filename pointer. + // For execveat: args[0] is dirfd, args[1] is filename pointer. + var pathAddr uintptr + var dirfd int + + if notif.Data.Nr == int32(unix.SYS_EXECVE) { + pathAddr = uintptr(notif.Data.Args[0]) + dirfd = -100 // AT_FDCWD + } else { + dirfd = dirfdFromArgs(notif.Data.Args[0]) + pathAddr = uintptr(notif.Data.Args[1]) + } + + memFd := phase.memFdFor(notif.PID) + if memFd == nil { + // Process gone or /proc//mem unreadable — fail-closed would + // kill the process; fail-open to avoid breaking legit flows. + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + rawPath, err := readPathFromMem(memFd, pathAddr) + if err != nil { + // Cannot read memory (EIO, ESRCH) — process may have died. Continue. + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + resolved, err := resolveNotifPath(notif.PID, dirfd, rawPath) + if err != nil { + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + if isExecDenied(resolved, phase.denyExec) { + if phase.auditWriter != nil { + _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ + Type: auditSeccompDeny, + Syscall: syscallName(notif.Data.Nr), + Path: resolved, + PID: int(notif.PID), + }) + } + _ = respondDeny(s.notifyFd, notif.ID) + return + } + + _ = respondContinue(s.notifyFd, notif.ID) +} + +func (s *seccompSupervisor) handleOpen(notif *seccompNotification, phase *seccompPhase) { + dirfd := dirfdFromArgs(notif.Data.Args[0]) + pathAddr := uintptr(notif.Data.Args[1]) + + memFd := phase.memFdFor(notif.PID) + if memFd == nil { + // Can't read the target's memory — typically because an execve in the + // process chain with NO_NEW_PRIVS set makes /proc//mem owner-RW + // only via CAP_SYS_PTRACE (dumpable=0). Fail open rather than deny + // every openat from the process, but this is a real enforcement gap + // for grandchild processes. See docs/sandbox.md. + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + rawPath, err := readPathFromMem(memFd, pathAddr) + if err != nil { + // Same fail-open path as above; memfd exists but read returned EIO + // or similar (stale fd after execve). + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + resolved, err := resolveNotifPath(notif.PID, dirfd, rawPath) + if err != nil { + _ = respondContinue(s.notifyFd, notif.ID) + return + } + + flags := classifyOpenFlags(notif.Data.Nr, notif.Data.Args, memFd) + + if isPathDenied(resolved, flags, phase.denyPaths) { + if phase.auditWriter != nil { + _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ + Type: auditSeccompDeny, + Syscall: syscallName(notif.Data.Nr), + Path: resolved, + PID: int(notif.PID), + }) + } + _ = respondDeny(s.notifyFd, notif.ID) + return + } + + _ = respondContinue(s.notifyFd, notif.ID) +} + +// syscallName returns a human-readable name for known intercepted syscalls. +func syscallName(nr int32) string { + switch nr { + case int32(unix.SYS_OPENAT): + return "openat" + case int32(unix.SYS_OPENAT2): + return "openat2" + case int32(unix.SYS_EXECVE): + return "execve" + case int32(unix.SYS_EXECVEAT): + return "execveat" + default: + return fmt.Sprintf("syscall_%d", nr) + } +} + +// waitForNotif blocks until notifyFd has a notification to read or stopFd is +// signalled. Returns (true, nil) when a notification is ready, (false, nil) +// when stop was signalled, and (false, err) on fatal errors. +func waitForNotif(notifyFd, stopFd int) (bool, error) { + pfds := []unix.PollFd{ + {Fd: int32(notifyFd), Events: unix.POLLIN}, + {Fd: int32(stopFd), Events: unix.POLLIN}, + } + for { + _, err := unix.Ppoll(pfds, nil, nil) + if err == unix.EINTR { + continue + } + if err != nil { + return false, fmt.Errorf("ppoll: %w", err) + } + if pfds[1].Revents&unix.POLLIN != 0 { + return false, nil + } + if pfds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP) != 0 { + // POLLERR/POLLHUP on notifyFd means the child died and the + // listener is no longer useful — caller will see EINVAL on recv. + return true, nil + } + } +} + +// recvNotification receives a seccomp notification from the notification fd. +// Retries on EINTR which can happen due to Go runtime signals. +func recvNotification(fd int) (*seccompNotification, error) { + var notif seccompNotification + + for { + _, _, errno := unix.Syscall( + unix.SYS_IOCTL, + uintptr(fd), + _SECCOMP_IOCTL_NOTIF_RECV, + uintptr(unsafe.Pointer(¬if)), + ) + if errno == 0 { + return ¬if, nil + } + if errno == unix.EINTR { + continue + } + return nil, fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_RECV: %w", errno) + } +} + +// respondContinue tells the kernel to continue the syscall as if the filter +// was not installed (SECCOMP_USER_NOTIF_FLAG_CONTINUE). +func respondContinue(fd int, id uint64) error { + resp := seccompNotifResp{ + ID: id, + Flags: unix.SECCOMP_USER_NOTIF_FLAG_CONTINUE, + } + + for { + _, _, errno := unix.Syscall( + unix.SYS_IOCTL, + uintptr(fd), + _SECCOMP_IOCTL_NOTIF_SEND, + uintptr(unsafe.Pointer(&resp)), + ) + if errno == 0 { + return nil + } + if errno == unix.EINTR { + continue + } + return fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_SEND (continue): %w", errno) + } +} + +// respondDeny tells the kernel to fail the syscall with EACCES. +func respondDeny(fd int, id uint64) error { + resp := seccompNotifResp{ + ID: id, + Error: -int32(unix.EACCES), + } + + for { + _, _, errno := unix.Syscall( + unix.SYS_IOCTL, + uintptr(fd), + _SECCOMP_IOCTL_NOTIF_SEND, + uintptr(unsafe.Pointer(&resp)), + ) + if errno == 0 { + return nil + } + if errno == unix.EINTR { + continue + } + return fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_SEND (deny): %w", errno) + } +} diff --git a/sandbox/platform/landlock_seccomp_linux_test.go b/sandbox/platform/landlock_seccomp_linux_test.go new file mode 100644 index 0000000..1ffcc3d --- /dev/null +++ b/sandbox/platform/landlock_seccomp_linux_test.go @@ -0,0 +1,531 @@ +//go:build linux + +package platform + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "testing" + "unsafe" + + "golang.org/x/sys/unix" +) + +func TestSeccompStructSizes(t *testing.T) { + tests := []struct { + name string + got uintptr + expected uintptr + }{ + {"seccompData", unsafe.Sizeof(seccompData{}), 64}, + {"seccompNotification", unsafe.Sizeof(seccompNotification{}), 80}, + {"seccompNotifResp", unsafe.Sizeof(seccompNotifResp{}), 24}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.expected { + t.Errorf("sizeof(%s) = %d, want %d", tt.name, tt.got, tt.expected) + } + }) + } +} + +func TestBuildSeccompBPFFilter(t *testing.T) { + prog, err := landlockBuildBPFFilter() + if err != nil { + t.Fatalf("landlockBuildBPFFilter() returned error: %v", err) + } + + if prog == nil { + t.Fatal("landlockBuildBPFFilter() returned nil") + } + + if prog.Filter == nil { + t.Fatal("landlockBuildBPFFilter() returned nil Filter") + } + + // Expect 7 instructions: 1 load + 4 comparisons + 1 allow + 1 notify + if prog.Len != 7 { + t.Errorf("expected 7 instructions, got %d", prog.Len) + } +} + +func TestBuildSeccompBPFFilter_InstructionTypes(t *testing.T) { + prog, err := landlockBuildBPFFilter() + if err != nil { + t.Fatalf("landlockBuildBPFFilter() returned error: %v", err) + } + + // Access the filter instructions as a slice + instructions := unsafe.Slice(prog.Filter, prog.Len) + + // First instruction should be BPF_LD | BPF_W | BPF_ABS + firstCode := instructions[0].Code + expectedFirst := uint16(unix.BPF_LD | unix.BPF_W | unix.BPF_ABS) + if firstCode != expectedFirst { + t.Errorf("first instruction code = 0x%x, want 0x%x (BPF_LD|BPF_W|BPF_ABS)", firstCode, expectedFirst) + } + + // Second-to-last instruction should be BPF_RET (allow) + secondToLast := instructions[prog.Len-2] + expectedRet := uint16(unix.BPF_RET | unix.BPF_K) + if secondToLast.Code != expectedRet { + t.Errorf("second-to-last instruction code = 0x%x, want 0x%x (BPF_RET|BPF_K)", secondToLast.Code, expectedRet) + } + if secondToLast.K != unix.SECCOMP_RET_ALLOW { + t.Errorf("second-to-last instruction K = 0x%x, want 0x%x (SECCOMP_RET_ALLOW)", secondToLast.K, unix.SECCOMP_RET_ALLOW) + } + + // Last instruction should be BPF_RET (notify) + last := instructions[prog.Len-1] + if last.Code != expectedRet { + t.Errorf("last instruction code = 0x%x, want 0x%x (BPF_RET|BPF_K)", last.Code, expectedRet) + } + if last.K != unix.SECCOMP_RET_USER_NOTIF { + t.Errorf("last instruction K = 0x%x, want 0x%x (SECCOMP_RET_USER_NOTIF)", last.K, unix.SECCOMP_RET_USER_NOTIF) + } +} + +func TestDenyMode_Values(t *testing.T) { + if denyRead == denyWrite { + t.Error("denyRead and denyWrite should be distinct") + } + if denyRead == denyBoth { + t.Error("denyRead and denyBoth should be distinct") + } + if denyWrite == denyBoth { + t.Error("denyWrite and denyBoth should be distinct") + } + + // Verify iota ordering + if denyRead != 0 { + t.Errorf("denyRead = %d, want 0", denyRead) + } + if denyWrite != 1 { + t.Errorf("denyWrite = %d, want 1", denyWrite) + } + if denyBoth != 2 { + t.Errorf("denyBoth = %d, want 2", denyBoth) + } +} + +func TestWriteAuditEvent(t *testing.T) { + var buf bytes.Buffer + + evt := auditEvent{ + Type: auditSeccompDeny, + Syscall: "openat", + Path: "/etc/passwd", + PID: 1234, + Message: "blocked", + Ts: 1700000000, + } + + err := landlockWriteAuditEvent(&buf, evt) + if err != nil { + t.Fatalf("landlockWriteAuditEvent() returned error: %v", err) + } + + output := buf.String() + + // Should end with newline + if output[len(output)-1] != '\n' { + t.Error("output should end with newline") + } + + // Should be valid JSON + var decoded auditEvent + if err := json.Unmarshal([]byte(output), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + if decoded.Type != auditSeccompDeny { + t.Errorf("type = %q, want %q", decoded.Type, auditSeccompDeny) + } + if decoded.Syscall != "openat" { + t.Errorf("syscall = %q, want %q", decoded.Syscall, "openat") + } + if decoded.Path != "/etc/passwd" { + t.Errorf("path = %q, want %q", decoded.Path, "/etc/passwd") + } + if decoded.PID != 1234 { + t.Errorf("pid = %d, want %d", decoded.PID, 1234) + } + if decoded.Ts != 1700000000 { + t.Errorf("ts = %d, want %d", decoded.Ts, 1700000000) + } +} + +func TestWriteAuditEvent_Omitempty(t *testing.T) { + var buf bytes.Buffer + + evt := auditEvent{ + Type: auditNamespaceUnavailable, + Ts: 1700000000, + } + + err := landlockWriteAuditEvent(&buf, evt) + if err != nil { + t.Fatalf("landlockWriteAuditEvent() returned error: %v", err) + } + + output := buf.String() + + // Parse as raw JSON to check which fields are present + var raw map[string]interface{} + if err := json.Unmarshal([]byte(output), &raw); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + // These fields should be omitted due to omitempty + omittedFields := []string{"syscall", "path", "pid", "message", "error"} + for _, field := range omittedFields { + if _, ok := raw[field]; ok { + t.Errorf("field %q should be omitted when empty, but was present in output", field) + } + } + + // These fields should be present + requiredFields := []string{"type", "ts"} + for _, field := range requiredFields { + if _, ok := raw[field]; !ok { + t.Errorf("field %q should be present in output", field) + } + } +} + +func TestIsPathDenied_DenyRead(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyRead}, + } + + // DenyRead should block O_RDONLY + if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) { + t.Error("denyRead should block O_RDONLY") + } + + // DenyRead should block O_RDWR + if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) { + t.Error("denyRead should block O_RDWR") + } + + // DenyRead should allow O_WRONLY + if isPathDenied("/home/user/.env", unix.O_WRONLY, deny) { + t.Error("denyRead should allow O_WRONLY") + } +} + +func TestIsPathDenied_DenyWrite(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyWrite}, + } + + // DenyWrite should block O_WRONLY + if !isPathDenied("/home/user/.env", unix.O_WRONLY, deny) { + t.Error("denyWrite should block O_WRONLY") + } + + // DenyWrite should block O_RDWR + if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) { + t.Error("denyWrite should block O_RDWR") + } + + // DenyWrite should allow O_RDONLY + if isPathDenied("/home/user/.env", unix.O_RDONLY, deny) { + t.Error("denyWrite should allow O_RDONLY") + } +} + +func TestIsPathDenied_DenyBoth(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyBoth}, + } + + if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) { + t.Error("denyBoth should block O_RDONLY") + } + if !isPathDenied("/home/user/.env", unix.O_WRONLY, deny) { + t.Error("denyBoth should block O_WRONLY") + } + if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) { + t.Error("denyBoth should block O_RDWR") + } +} + +func TestIsPathDenied_ExactMatch(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyBoth}, + } + + if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) { + t.Error("exact match should be denied") + } +} + +func TestIsPathDenied_NoPartialMatch(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyBoth}, + } + + if isPathDenied("/home/user/.envrc", unix.O_RDONLY, deny) { + t.Error("/home/user/.envrc should NOT match deny /home/user/.env (no partial match)") + } +} + +func TestIsPathDenied_DirectoryPrefix(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.ssh/", Mode: denyBoth}, + } + + if !isPathDenied("/home/user/.ssh/id_rsa", unix.O_RDONLY, deny) { + t.Error("/home/user/.ssh/id_rsa should match deny /home/user/.ssh/") + } + + if !isPathDenied("/home/user/.ssh/config", unix.O_WRONLY, deny) { + t.Error("/home/user/.ssh/config should match deny /home/user/.ssh/") + } +} + +// Deny entries without a trailing slash are treated as "this path or anything +// beneath it" — matching how GetMandatoryDenyPatterns emits entries like +// "/home/user/.ssh" (no slash) that must cover "~/.ssh/id_rsa" too. +func TestIsPathDenied_DirectoryWithoutTrailingSlash(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.ssh", Mode: denyBoth}, + } + + if !isPathDenied("/home/user/.ssh", unix.O_RDONLY, deny) { + t.Error("exact match on /home/user/.ssh should be denied") + } + if !isPathDenied("/home/user/.ssh/id_rsa", unix.O_RDONLY, deny) { + t.Error("/home/user/.ssh/id_rsa should match deny /home/user/.ssh (no trailing slash)") + } + // Must not false-match on similarly-prefixed siblings. + if isPathDenied("/home/user/.ssh2/id_rsa", unix.O_RDONLY, deny) { + t.Error("/home/user/.ssh2/id_rsa must NOT match deny /home/user/.ssh (no trailing slash)") + } + if isPathDenied("/home/user/.sshfoo", unix.O_RDONLY, deny) { + t.Error("/home/user/.sshfoo must NOT match deny /home/user/.ssh") + } +} + +func TestIsPathDenied_NoMatch(t *testing.T) { + deny := []denyPathEntry{ + {Path: "/home/user/.env", Mode: denyBoth}, + {Path: "/home/user/.ssh/", Mode: denyBoth}, + } + + if isPathDenied("/home/user/safe.txt", unix.O_RDONLY, deny) { + t.Error("/home/user/safe.txt should not match any deny entry") + } +} + +func TestIsExecDenied_Match(t *testing.T) { + denyExec := []string{"/usr/bin/curl", "/usr/bin/wget"} + + if !isExecDenied("/usr/bin/curl", denyExec) { + t.Error("/usr/bin/curl should be denied") + } +} + +func TestIsExecDenied_NoMatch(t *testing.T) { + denyExec := []string{"/usr/bin/curl", "/usr/bin/wget"} + + if isExecDenied("/usr/bin/node", denyExec) { + t.Error("/usr/bin/node should not be denied") + } +} + +func TestIsExecDenied_DirectoryPrefix(t *testing.T) { + denyExec := []string{"/usr/bin/"} + + if !isExecDenied("/usr/bin/curl", denyExec) { + t.Error("/usr/bin/curl should match deny /usr/bin/") + } + if !isExecDenied("/usr/bin/node", denyExec) { + t.Error("/usr/bin/node should match deny /usr/bin/") + } + if isExecDenied("/usr/local/bin/node", denyExec) { + t.Error("/usr/local/bin/node should NOT match deny /usr/bin/") + } +} + +func TestReadPathFromMem(t *testing.T) { + // Create a temporary file with a null-terminated path string to simulate + // process memory. + tmpFile, err := os.CreateTemp("", "test-mem-*") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer func() { + if err := os.Remove(tmpFile.Name()); err != nil { + t.Logf("remove temp file: %v", err) + } + }() + defer func() { + if err := tmpFile.Close(); err != nil { + t.Logf("close temp file: %v", err) + } + }() + + testPath := "/home/user/.env" + data := append([]byte(testPath), 0) // null-terminated + // Add some extra bytes after the null to simulate memory contents. + data = append(data, []byte("garbage data after null")...) + + if _, err := tmpFile.Write(data); err != nil { + t.Fatalf("write temp file: %v", err) + } + + // Re-open for reading. + memFd, err := os.Open(tmpFile.Name()) + if err != nil { + t.Fatalf("open temp file: %v", err) + } + defer func() { + if err := memFd.Close(); err != nil { + t.Logf("close mem fd: %v", err) + } + }() + + result, err := readPathFromMem(memFd, 0) + if err != nil { + t.Fatalf("readPathFromMem() error: %v", err) + } + + if result != testPath { + t.Errorf("readPathFromMem() = %q, want %q", result, testPath) + } +} + +func TestReadPathFromMem_Offset(t *testing.T) { + tmpFile, err := os.CreateTemp("", "test-mem-offset-*") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer func() { + if err := os.Remove(tmpFile.Name()); err != nil { + t.Logf("remove temp file: %v", err) + } + }() + defer func() { + if err := tmpFile.Close(); err != nil { + t.Logf("close temp file: %v", err) + } + }() + + // Write padding, then a null-terminated path at a known offset. + padding := make([]byte, 100) + testPath := "/etc/passwd" + data := append(padding, append([]byte(testPath), 0)...) + if _, err := tmpFile.Write(data); err != nil { + t.Fatalf("write temp file: %v", err) + } + + memFd, err := os.Open(tmpFile.Name()) + if err != nil { + t.Fatalf("open temp file: %v", err) + } + defer func() { + if err := memFd.Close(); err != nil { + t.Logf("close mem fd: %v", err) + } + }() + + result, err := readPathFromMem(memFd, 100) + if err != nil { + t.Fatalf("readPathFromMem() error: %v", err) + } + + if result != testPath { + t.Errorf("readPathFromMem() = %q, want %q", result, testPath) + } +} + +func TestResolveNotifPath_Absolute(t *testing.T) { + // Absolute paths should be returned cleaned, regardless of dirfd/pid. + result, err := resolveNotifPath(1, -100, "/home/user/.env") + if err != nil { + t.Fatalf("resolveNotifPath() error: %v", err) + } + if result != "/home/user/.env" { + t.Errorf("resolveNotifPath() = %q, want %q", result, "/home/user/.env") + } + + // With .. components that should be cleaned. + result, err = resolveNotifPath(1, -100, "/home/user/../user/.env") + if err != nil { + t.Fatalf("resolveNotifPath() error: %v", err) + } + if result != "/home/user/.env" { + t.Errorf("resolveNotifPath() = %q, want %q", result, "/home/user/.env") + } +} + +func TestResolveNotifPath_AT_FDCWD(t *testing.T) { + // This test requires /proc//cwd to be readable. + pid := os.Getpid() + cwdLink := fmt.Sprintf("/proc/%d/cwd", pid) + if _, err := os.Readlink(cwdLink); err != nil { + t.Skipf("cannot read %s: %v (skipping /proc-dependent test)", cwdLink, err) + } + + cwd, _ := os.Getwd() + result, err := resolveNotifPath(uint32(pid), -100, "relative/path") + if err != nil { + t.Fatalf("resolveNotifPath() error: %v", err) + } + + expected := cwd + "/relative/path" + if result != expected { + t.Errorf("resolveNotifPath() = %q, want %q", result, expected) + } +} + +func TestDirfdFromArgs(t *testing.T) { + tests := []struct { + name string + val uint64 + want int + }{ + {"AT_FDCWD 32-bit", _AT_FDCWD_32, -100}, + {"AT_FDCWD 64-bit", _AT_FDCWD_64, -100}, + {"regular fd 3", 3, 3}, + {"regular fd 0", 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dirfdFromArgs(tt.val) + if got != tt.want { + t.Errorf("dirfdFromArgs(0x%x) = %d, want %d", tt.val, got, tt.want) + } + }) + } +} + +func TestClassifyOpenFlags_Openat(t *testing.T) { + tests := []struct { + name string + flags uint64 + expected int + }{ + {"O_RDONLY", uint64(unix.O_RDONLY), unix.O_RDONLY}, + {"O_WRONLY", uint64(unix.O_WRONLY), unix.O_WRONLY}, + {"O_RDWR", uint64(unix.O_RDWR), unix.O_RDWR}, + {"O_WRONLY|O_CREAT", uint64(unix.O_WRONLY | unix.O_CREAT), unix.O_WRONLY}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := [6]uint64{0, 0, tt.flags, 0, 0, 0} + got := classifyOpenFlags(int32(unix.SYS_OPENAT), args, nil) + if got != tt.expected { + t.Errorf("classifyOpenFlags() = %d, want %d", got, tt.expected) + } + }) + } +} diff --git a/sandbox/platform/landlock_shim_linux.go b/sandbox/platform/landlock_shim_linux.go new file mode 100644 index 0000000..72192c4 --- /dev/null +++ b/sandbox/platform/landlock_shim_linux.go @@ -0,0 +1,149 @@ +//go:build linux + +package platform + +import ( + "fmt" + "os" + "runtime" + "unsafe" + + "github.com/landlock-lsm/go-landlock/landlock" + "golang.org/x/sys/unix" +) + +// RunLandlockShim is the inside-user-namespace entry point. It is invoked by +// the helper as a direct child forked with CLONE_NEWUSER + uid map 0->host. +// As uid-0-in-ns with CAP_SYS_ADMIN, it: +// +// 1. Loads the serialised landlockExecPolicy from policyFile. +// 2. Installs the seccomp-notify filter WITHOUT PR_SET_NO_NEW_PRIVS. This is +// the whole point of the user-ns indirection: without NNP, subsequent +// execve(2)s in the target tree do NOT reset the dumpable flag to 0, so +// the helper can keep opening /proc//mem for descendants and resolve +// openat(2) path arguments. +// 3. Sends the notify fd back to the helper over a socketpair on +// notifySocketFd (fd number preserved via cmd.ExtraFiles). +// 4. Applies Landlock restrictions. +// 5. execve(2)s the target binary. The seccomp filter survives execve +// (filters are inherited) and applies to the target and all descendants. +// +// Returns an error only if the shim fails before execve. On success the +// shim process is replaced by the target and this function does not return. +func RunLandlockShim(policyFile string, notifySocketFd int, args []string) error { + if len(args) == 0 { + return fmt.Errorf("shim: no target command") + } + + // Without TSYNC the filter applies only to this thread; we must also + // execve from this same thread so the target inherits it. + runtime.LockOSThread() + + // The policy file is owned by the helper; shim doesn't delete it. + policy, err := readLandlockPolicyFromFile(policyFile) + if err != nil { + return fmt.Errorf("shim: read policy: %w", err) + } + + interceptOpen := len(policy.DenyPaths) > 0 + notifyFd, err := shimInstallSeccomp(interceptOpen) + if err != nil { + return fmt.Errorf("shim: install seccomp: %w", err) + } + + if err := sendFdToSocket(notifySocketFd, notifyFd); err != nil { + return fmt.Errorf("shim: send notify fd: %w", err) + } + // Helper owns the notify fd now; kernel routes notifications via the + // shared file description. + _ = unix.Close(notifyFd) + _ = unix.Close(notifySocketFd) + + var rules []landlock.Rule + for _, r := range policy.FilesystemRules { + access := landlockAdjustAccessForPath(r.Path, r.Access) + rules = append(rules, landlock.PathAccess( + landlock.AccessFSSet(access), r.Path, + ).IgnoreIfMissing()) + } + cfg := landlockSelectConfig(policy) + if err := cfg.BestEffort().RestrictPaths(rules...); err != nil { + return fmt.Errorf("shim: landlock restrict: %w", err) + } + + target := args[0] + env := os.Environ() + if len(policy.Env) > 0 { + env = policy.Env + } + if err := unix.Exec(target, args, env); err != nil { + return fmt.Errorf("shim: exec %s: %w", target, err) + } + return nil // unreachable +} + +// shimInstallSeccomp installs the seccomp-notify filter WITHOUT +// PR_SET_NO_NEW_PRIVS. The kernel accepts this only when the caller has +// CAP_SYS_ADMIN in its user namespace; the helper arranges that by cloning +// us with CLONE_NEWUSER + uid/gid mapping that makes us uid 0 in the new ns. +func shimInstallSeccomp(interceptOpen bool) (int, error) { + var filter []unix.SockFilter + if interceptOpen { + filter = []unix.SockFilter{ + {Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: 0}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 4, Jf: 0, K: uint32(unix.SYS_OPENAT)}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 3, Jf: 0, K: uint32(unix.SYS_OPENAT2)}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 2, Jf: 0, K: uint32(unix.SYS_EXECVE)}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 1, Jf: 0, K: uint32(unix.SYS_EXECVEAT)}, + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ALLOW}, + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_USER_NOTIF}, + } + } else { + filter = []unix.SockFilter{ + {Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: 0}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 2, Jf: 0, K: uint32(unix.SYS_EXECVE)}, + {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 1, Jf: 0, K: uint32(unix.SYS_EXECVEAT)}, + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ALLOW}, + {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_USER_NOTIF}, + } + } + prog := unix.SockFprog{Len: uint16(len(filter)), Filter: &filter[0]} + + flags := uintptr(unix.SECCOMP_FILTER_FLAG_NEW_LISTENER) + fd, _, errno := unix.Syscall( + unix.SYS_SECCOMP, + unix.SECCOMP_SET_MODE_FILTER, + flags, + uintptr(unsafe.Pointer(&prog)), + ) + runtime.KeepAlive(&filter) + runtime.KeepAlive(&prog) + if errno != 0 { + return -1, fmt.Errorf("SECCOMP_SET_MODE_FILTER without NNP (user-ns CAP_SYS_ADMIN required): %w", errno) + } + return int(fd), nil +} + +// sendFdToSocket sends `fd` over a connected unix-domain socket using +// SCM_RIGHTS. This transfers the fd to the peer process atomically. +func sendFdToSocket(sockFd, fd int) error { + rights := unix.UnixRights(fd) + buf := []byte{0} + iov := unix.Iovec{Base: &buf[0], Len: 1} + msg := unix.Msghdr{Iov: &iov, Iovlen: 1, Control: &rights[0]} + msg.SetControllen(len(rights)) + _, _, errno := unix.Syscall( + unix.SYS_SENDMSG, + uintptr(sockFd), + uintptr(unsafe.Pointer(&msg)), + 0, + ) + runtime.KeepAlive(&buf) + runtime.KeepAlive(&iov) + runtime.KeepAlive(&rights) + runtime.KeepAlive(&msg) + if errno != 0 { + return fmt.Errorf("sendmsg: %w", errno) + } + return nil +} diff --git a/sandbox/platform/landlock_translator_linux.go b/sandbox/platform/landlock_translator_linux.go new file mode 100644 index 0000000..8369bb5 --- /dev/null +++ b/sandbox/platform/landlock_translator_linux.go @@ -0,0 +1,506 @@ +//go:build linux + +package platform + +import ( + "os" + "path/filepath" + "strings" + + "github.com/safedep/dry/log" + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/util" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" +) + +// landlockExecPolicy is the internal representation of a translated sandbox policy +// ready for Landlock enforcement. It contains filesystem rules (allow-list), +// deny paths (for seccomp-notify enforcement), and execution configuration. +type landlockExecPolicy struct { + FilesystemRules []landlockPathRule `json:"filesystem_rules"` + DenyPaths []denyPathEntry `json:"deny_paths"` + DenyExecPaths []string `json:"deny_exec_paths"` + AllowPTY bool `json:"allow_pty"` + SkipPIDNamespace bool `json:"skip_pid_namespace"` + SkipIPCNamespace bool `json:"skip_ipc_namespace"` + Command string `json:"command"` + Args []string `json:"args"` + Env []string `json:"env,omitempty"` +} + +// landlockPathRule represents a single Landlock filesystem rule mapping a path +// to its allowed access bitmask. +type landlockPathRule struct { + Path string `json:"path"` + Access uint64 `json:"access"` // Raw kernel Landlock AccessFs bitmask +} + +// Landlock access flag groups composed from go-landlock syscall constants. +// +// Note on Execute: Landlock is stricter than bubblewrap's bind-mount model. +// With bubblewrap, a read-only bind automatically permits execve of anything +// inside. With Landlock, EXECUTE must be granted explicitly — otherwise +// execve returns EACCES even for script interpreters that the policy clearly +// intends to allow (e.g. /usr/bin/node reached via an allow_read of $HOME). +// +// We include AccessFSExecute in landlockReadAccess so `allow_read: /` matches +// user intent: "I can read and run stuff from under here." Deny-exec is still +// enforced via the seccomp supervisor, which wins over the Landlock allow. +var ( + landlockReadAccess = uint64(llsyscall.AccessFSReadFile | llsyscall.AccessFSReadDir | llsyscall.AccessFSExecute) + + landlockWriteAccessBase = uint64( + llsyscall.AccessFSWriteFile | + llsyscall.AccessFSMakeReg | + llsyscall.AccessFSMakeDir | + llsyscall.AccessFSMakeSock | + llsyscall.AccessFSMakeFifo | + llsyscall.AccessFSMakeBlock | + llsyscall.AccessFSMakeChar | + llsyscall.AccessFSMakeSym | + llsyscall.AccessFSRemoveFile | + llsyscall.AccessFSRemoveDir) + + // Execute access includes ReadFile because the kernel must read the + // shebang line of script files (e.g. #!/bin/bash) to determine the + // interpreter. Without ReadFile, execve on scripts fails with EACCES. + landlockExecuteAccess = uint64(llsyscall.AccessFSExecute | llsyscall.AccessFSReadFile) +) + +// landlockFileAccess is the set of Landlock access flags valid for regular files. +// Matches the go-landlock library's accessFile constant. All other access flags +// are directory-only and must be stripped when the rule targets a non-directory. +var landlockFileAccess = uint64( + llsyscall.AccessFSReadFile | + llsyscall.AccessFSWriteFile | + llsyscall.AccessFSExecute | + llsyscall.AccessFSTruncate | + llsyscall.AccessFSIoctlDev) + +// landlockAdjustAccessForPath stats the given path and strips directory-only +// access flags when the path refers to a regular file. If the path does not +// exist or cannot be stat'd, the original access mask is returned unchanged +// (go-landlock's IgnoreIfMissing handles missing paths). +func landlockAdjustAccessForPath(path string, access uint64) uint64 { + info, err := os.Stat(path) + if err != nil { + return access + } + if !info.IsDir() { + access &= landlockFileAccess + } + return access +} + +// landlockUsrBinAlternate returns the /usr/bin equivalent of a /bin path and +// vice versa, to handle merged-/usr systems where /bin is a symlink to /usr/bin. +// Returns empty string if the path is not in /bin or /usr/bin. +func landlockUsrBinAlternate(path string) string { + prefixes := [][2]string{ + {"/bin/", "/usr/bin/"}, + {"/sbin/", "/usr/sbin/"}, + {"/lib/", "/usr/lib/"}, + {"/lib64/", "/usr/lib64/"}, + } + for _, pair := range prefixes { + if strings.HasPrefix(path, pair[0]) { + return pair[1] + strings.TrimPrefix(path, pair[0]) + } + if strings.HasPrefix(path, pair[1]) { + return pair[0] + strings.TrimPrefix(path, pair[1]) + } + } + return "" +} + +// landlockIsProcPath returns true if the path is /proc or any path under /proc. +// Uses a path boundary check so /process, /procurement, etc. don't match. +func landlockIsProcPath(path string) bool { + return path == "/proc" || strings.HasPrefix(path, "/proc/") +} + +// landlockGlobMatches expands a glob pattern, transparently handling ** +// globstar (which filepath.Glob does not). Used for deny-path expansion. +func landlockGlobMatches(pattern string) ([]string, error) { + if strings.Contains(pattern, "**") { + return expandGlobstarPattern(pattern, landlockGlobstarMaxDepth, landlockGlobstarMaxPaths) + } + return filepath.Glob(pattern) +} + +// landlockIsWithinWritableArea checks if a path (or glob pattern) falls within +// any of the write-allowed prefixes. This is used to skip deny_write entries +// that Landlock already prevents (paths outside the write allow-list). +func landlockIsWithinWritableArea(path string, writePrefixes []string) bool { + // Strip glob suffix from both sides; we only compare the literal prefix. + base := path + if idx := strings.IndexAny(base, "*?["); idx >= 0 { + base = base[:idx] + } + + for _, prefix := range writePrefixes { + expandedPrefix, err := util.ExpandVariables(prefix) + if err != nil { + continue + } + cleanPrefix := expandedPrefix + if idx := strings.IndexAny(cleanPrefix, "*?["); idx >= 0 { + cleanPrefix = cleanPrefix[:idx] + } + if strings.HasPrefix(base, cleanPrefix) || strings.HasPrefix(cleanPrefix, base) { + return true + } + } + return false +} + +// landlockGlobFallbackThreshold is the maximum number of glob matches before +// falling back to the parent directory. Same threshold as Bubblewrap translator. +const landlockGlobFallbackThreshold = 100 + +// Globstar walk limits, matching the Bubblewrap translator defaults so the +// two drivers expand built-in profiles consistently. +const ( + landlockGlobstarMaxDepth = 5 + landlockGlobstarMaxPaths = 1000 +) + +// landlockTranslatePolicy converts a SandboxPolicy into a landlockExecPolicy +// that can be applied by the Landlock driver. It expands variables, resolves +// glob patterns, maps allow/deny rules, and adds implicit rules. +func landlockTranslatePolicy(policy *sandbox.SandboxPolicy, abi *landlockABI) (*landlockExecPolicy, error) { + ep := &landlockExecPolicy{} + + writeAccess := landlockWriteAccessBase + if abi.HasRefer { + writeAccess |= uint64(llsyscall.AccessFSRefer) + } + if abi.HasTruncate { + writeAccess |= uint64(llsyscall.AccessFSTruncate) + } + + for _, pattern := range policy.Filesystem.AllowRead { + paths, err := landlockExpandPattern(pattern) + if err != nil { + log.Warnf("Failed to expand allow_read pattern '%s': %v", pattern, err) + continue + } + for _, p := range paths { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: p, + Access: landlockReadAccess, + }) + } + } + + // allow_write grants read+write to mirror Bubblewrap's --bind (which + // exposes both directions). Package managers routinely reopen files in + // temp/cache trees with O_RDONLY/O_RDWR, so write-only access would + // surface as spurious EACCES on otherwise-allowed paths. + readWriteAccess := landlockReadAccess | writeAccess + for _, pattern := range policy.Filesystem.AllowWrite { + paths, err := landlockExpandPattern(pattern) + if err != nil { + log.Warnf("Failed to expand allow_write pattern '%s': %v", pattern, err) + continue + } + for _, p := range paths { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: p, + Access: readWriteAccess, + }) + } + } + + for _, pattern := range policy.Process.AllowExec { + paths, err := landlockExpandPattern(pattern) + if err != nil { + log.Warnf("Failed to expand allow_exec pattern '%s': %v", pattern, err) + continue + } + for _, p := range paths { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: p, + Access: landlockExecuteAccess, + }) + // On merged-/usr systems, /bin/X and /usr/bin/X refer to the + // same file. Add the alternate path so Landlock covers both + // access routes. + if alt := landlockUsrBinAlternate(p); alt != "" { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: alt, + Access: landlockExecuteAccess, + }) + } + } + } + + for _, pattern := range policy.Filesystem.DenyRead { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + log.Warnf("Failed to expand deny_read pattern '%s': %v", pattern, err) + continue + } + if landlockIsProcPath(expanded) { + log.Warnf("Dropping /proc deny entry '%s': Landlock cannot deny /proc sub-paths reliably", expanded) + continue + } + if util.ContainsGlob(expanded) { + matches, err := landlockGlobMatches(expanded) + if err != nil { + log.Warnf("Failed to expand deny_read glob '%s': %v", expanded, err) + continue + } + for _, m := range matches { + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: m, Mode: denyRead}) + } + } else { + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: expanded, Mode: denyRead}) + } + } + + // Landlock already prevents writes outside allow_write, so deny_write is + // only meaningful within writable areas. Skipping the rest avoids thousands + // of redundant seccomp deny entries (e.g. /etc/**, /usr/**). + writablePrefixes := policy.Filesystem.AllowWrite + for _, pattern := range policy.Filesystem.DenyWrite { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + log.Warnf("Failed to expand deny_write pattern '%s': %v", pattern, err) + continue + } + if landlockIsProcPath(expanded) { + log.Warnf("Dropping /proc deny entry '%s': Landlock cannot deny /proc sub-paths reliably", expanded) + continue + } + if !landlockIsWithinWritableArea(expanded, writablePrefixes) { + continue + } + if util.ContainsGlob(expanded) { + matches, err := landlockGlobMatches(expanded) + if err != nil { + log.Warnf("Failed to expand deny_write glob '%s': %v", expanded, err) + continue + } + for _, m := range matches { + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: m, Mode: denyWrite}) + } + } else { + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: expanded, Mode: denyWrite}) + } + } + + for _, pattern := range policy.Process.DenyExec { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + log.Warnf("Failed to expand deny_exec pattern '%s': %v", pattern, err) + continue + } + if util.ContainsGlob(expanded) { + matches, err := landlockGlobMatches(expanded) + if err != nil { + log.Warnf("Failed to expand deny_exec glob '%s': %v", expanded, err) + continue + } + ep.DenyExecPaths = append(ep.DenyExecPaths, matches...) + } else { + ep.DenyExecPaths = append(ep.DenyExecPaths, expanded) + } + } + + expandedAllowRead, err := expandAll(policy.Filesystem.AllowRead) + if err != nil { + log.Warnf("sandbox: failed to expand allow_read for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowRead = nil + } + expandedAllowWrite, err := expandAll(policy.Filesystem.AllowWrite) + if err != nil { + log.Warnf("sandbox: failed to expand allow_write for mandatory deny suppression, all mandatory denies preserved: %v", err) + expandedAllowWrite = nil + } + + mandatoryResult := util.GetMandatoryDenyPatterns(util.MandatoryDenyOptions{ + AllowGitConfig: utils.SafelyGetValue(policy.AllowGitConfig), + AllowRead: expandedAllowRead, + AllowWrite: expandedAllowWrite, + }) + + for _, p := range mandatoryResult.SuppressedRead { + log.Warnf("sandbox: mandatory deny %q suppressed for read by explicit allow rule in policy %q", p, policy.Name) + } + for _, p := range mandatoryResult.SuppressedWrite { + log.Warnf("sandbox: mandatory deny %q suppressed for write by explicit allow rule in policy %q", p, policy.Name) + } + + // Collapse paths that appear in both directions into a single denyBoth + // entry; emit per-direction entries for the rest. + denyWriteSet := make(map[string]bool, len(mandatoryResult.DenyWrite)) + for _, p := range mandatoryResult.DenyWrite { + denyWriteSet[p] = true + } + bothSet := make(map[string]bool) + for _, p := range mandatoryResult.DenyRead { + if denyWriteSet[p] { + bothSet[p] = true + } + } + appendDeny := func(pattern string, mode denyMode) { + if util.ContainsGlob(pattern) { + matches, err := landlockGlobMatches(pattern) + if err != nil { + log.Warnf("Failed to expand mandatory deny glob '%s': %v", pattern, err) + return + } + for _, m := range matches { + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: m, Mode: mode}) + } + return + } + ep.DenyPaths = append(ep.DenyPaths, denyPathEntry{Path: pattern, Mode: mode}) + } + + for _, p := range mandatoryResult.DenyRead { + if bothSet[p] { + appendDeny(p, denyBoth) + continue + } + appendDeny(p, denyRead) + } + for _, p := range mandatoryResult.DenyWrite { + if bothSet[p] { + continue // already emitted as denyBoth + } + appendDeny(p, denyWrite) + } + + // Unlike bubblewrap's read-only bind mounts, Landlock requires explicit + // execute permission on system binary directories. The deny_exec list + // (enforced via seccomp) blocks specific dangerous binaries within them. + sysExecDirs := []string{"/usr/bin", "/usr/sbin", "/usr/lib", "/usr/lib64", + "/bin", "/sbin", "/lib", "/lib64"} + for _, dir := range sysExecDirs { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: dir, + Access: landlockExecuteAccess, + }) + } + + // /proc read access — the supervisor reads /proc//{cwd,fd,mem}. + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: "/proc", + Access: landlockReadAccess, + }) + + devReadWrite := landlockReadAccess | writeAccess + for _, dev := range []string{"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"} { + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: dev, + Access: devReadWrite, + }) + } + + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: os.TempDir(), + Access: writeAccess, + }) + + allowPTY := utils.SafelyGetValue(policy.AllowPTY) + ep.AllowPTY = allowPTY + if allowPTY { + ptyAccess := landlockReadAccess | writeAccess + if abi.HasIoctlDev { + ptyAccess |= uint64(llsyscall.AccessFSIoctlDev) + } + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: "/dev/pts", + Access: ptyAccess, + }) + ep.FilesystemRules = append(ep.FilesystemRules, landlockPathRule{ + Path: "/dev/ptmx", + Access: ptyAccess, + }) + } + + if landlockPolicyExplicitlyAllowsProc(policy) { + ep.SkipPIDNamespace = true + log.Warnf("Policy explicitly allows /proc paths beyond /proc/self - skipping PID namespace isolation") + } + + return ep, nil +} + +// landlockExpandPattern expands variables and glob patterns in a path string, +// returning a list of concrete paths. Handles ** globstar (which +// filepath.Glob does not) so built-in profiles like ${CWD}/node_modules/** +// expand correctly even when the parent directory does not yet exist; in +// that case the parent path is returned so Landlock can still grant +// recursive coverage on the directory once it is created. When glob +// matches exceed the fallback threshold, uses the parent directory +// instead. +func landlockExpandPattern(pattern string) ([]string, error) { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + return nil, err + } + + if !util.ContainsGlob(expanded) { + return []string{expanded}, nil + } + + var matches []string + if strings.Contains(expanded, "**") { + matches, err = expandGlobstarPattern(expanded, landlockGlobstarMaxDepth, landlockGlobstarMaxPaths) + if err != nil { + return nil, err + } + } else { + matches, err = filepath.Glob(expanded) + if err != nil { + return nil, err + } + } + + if len(matches) > landlockGlobFallbackThreshold { + parentDir := extractGlobParentDir(expanded) + log.Warnf("Glob pattern '%s' matched %d paths (threshold: %d), using parent directory '%s'", + expanded, len(matches), landlockGlobFallbackThreshold, parentDir) + return []string{parentDir}, nil + } + + if len(matches) == 0 { + // Path may not exist yet. For globstar patterns, expandGlobstarPattern + // already returned the base path when it was missing. For non-globstar + // patterns, fall back to the literal expanded pattern so go-landlock's + // IgnoreIfMissing handles it. + return []string{expanded}, nil + } + + return matches, nil +} + +// landlockPolicyExplicitlyAllowsProc returns true if the policy's AllowRead or +// AllowWrite contains paths starting with /proc that are NOT /proc/self or +// /proc/self/*. +func landlockPolicyExplicitlyAllowsProc(policy *sandbox.SandboxPolicy) bool { + allPaths := append([]string{}, policy.Filesystem.AllowRead...) + allPaths = append(allPaths, policy.Filesystem.AllowWrite...) + + for _, p := range allPaths { + expanded, err := util.ExpandVariables(p) + if err != nil { + continue + } + if !landlockIsProcPath(expanded) { + continue + } + // Allow /proc/self and /proc/self/* without triggering + if expanded == "/proc/self" || strings.HasPrefix(expanded, "/proc/self/") { + continue + } + return true + } + + return false +} diff --git a/sandbox/platform/landlock_translator_linux_test.go b/sandbox/platform/landlock_translator_linux_test.go new file mode 100644 index 0000000..9599aca --- /dev/null +++ b/sandbox/platform/landlock_translator_linux_test.go @@ -0,0 +1,572 @@ +//go:build linux + +package platform + +import ( + "os" + "strings" + "testing" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" +) + +func newTestPolicy() *sandbox.SandboxPolicy { + return &sandbox.SandboxPolicy{ + Name: "test-policy", + PackageManagers: []string{"npm"}, + } +} + +func findRule(rules []landlockPathRule, path string) *landlockPathRule { + for i := range rules { + if rules[i].Path == path { + return &rules[i] + } + } + return nil +} + +func findDenyPath(entries []denyPathEntry, path string) *denyPathEntry { + for i := range entries { + if entries[i].Path == path { + return &entries[i] + } + } + return nil +} + +func TestLandlockTranslatePolicy_AllowRead(t *testing.T) { + tests := []struct { + name string + paths []string + wantPaths []string + }{ + { + name: "single path", + paths: []string{"/usr/lib"}, + wantPaths: []string{"/usr/lib"}, + }, + { + name: "multiple paths", + paths: []string{"/usr/lib", "/etc"}, + wantPaths: []string{"/usr/lib", "/etc"}, + }, + } + + abi := newLandlockABI(3) + expectedAccess := uint64(llsyscall.AccessFSReadFile | llsyscall.AccessFSReadDir | llsyscall.AccessFSExecute) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = tt.paths + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, wantPath := range tt.wantPaths { + rule := findRule(ep.FilesystemRules, wantPath) + if rule == nil { + t.Errorf("expected rule for path %s, not found", wantPath) + continue + } + if rule.Access != expectedAccess { + t.Errorf("path %s: access = %x, want %x", wantPath, rule.Access, expectedAccess) + } + } + }) + } +} + +func TestLandlockTranslatePolicy_AllowWrite(t *testing.T) { + tests := []struct { + name string + abiVersion int + wantRefer bool + wantTrunc bool + }{ + { + name: "V1 - no Refer, no Truncate", + abiVersion: 1, + wantRefer: false, + wantTrunc: false, + }, + { + name: "V2 - has Refer, no Truncate", + abiVersion: 2, + wantRefer: true, + wantTrunc: false, + }, + { + name: "V3 - has Refer and Truncate", + abiVersion: 3, + wantRefer: true, + wantTrunc: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + abi := newLandlockABI(tt.abiVersion) + policy := newTestPolicy() + policy.Filesystem.AllowWrite = []string{"/tmp/test"} + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rule := findRule(ep.FilesystemRules, "/tmp/test") + if rule == nil { + t.Fatal("expected rule for /tmp/test, not found") + } + + hasRefer := rule.Access&uint64(llsyscall.AccessFSRefer) != 0 + hasTrunc := rule.Access&uint64(llsyscall.AccessFSTruncate) != 0 + + if hasRefer != tt.wantRefer { + t.Errorf("Refer flag: got %v, want %v", hasRefer, tt.wantRefer) + } + if hasTrunc != tt.wantTrunc { + t.Errorf("Truncate flag: got %v, want %v", hasTrunc, tt.wantTrunc) + } + + // Verify base write flags are always present + if rule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 { + t.Error("WriteFile flag should always be present") + } + if rule.Access&uint64(llsyscall.AccessFSMakeReg) == 0 { + t.Error("MakeReg flag should always be present") + } + }) + } +} + +func TestLandlockTranslatePolicy_AllowExec(t *testing.T) { + policy := newTestPolicy() + policy.Process.AllowExec = []string{"/usr/bin/node", "/usr/bin/npm"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Execute access includes ReadFile because the kernel must read the + // shebang line of script files to determine the interpreter. + expectedAccess := uint64(llsyscall.AccessFSExecute | llsyscall.AccessFSReadFile) + + for _, path := range []string{"/usr/bin/node", "/usr/bin/npm"} { + rule := findRule(ep.FilesystemRules, path) + if rule == nil { + t.Errorf("expected rule for %s, not found", path) + continue + } + if rule.Access != expectedAccess { + t.Errorf("path %s: access = %x, want %x", path, rule.Access, expectedAccess) + } + } +} + +func TestLandlockTranslatePolicy_DenyRead(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.DenyRead = []string{"/etc/shadow", "/etc/passwd"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, path := range []string{"/etc/shadow", "/etc/passwd"} { + entry := findDenyPath(ep.DenyPaths, path) + if entry == nil { + t.Errorf("expected deny entry for %s, not found", path) + continue + } + if entry.Mode != denyRead { + t.Errorf("path %s: mode = %d, want denyRead (%d)", path, entry.Mode, denyRead) + } + } +} + +func TestLandlockTranslatePolicy_DenyWrite(t *testing.T) { + policy := newTestPolicy() + // DenyWrite is only effective within writable areas. Add /etc as writable + // so the deny rule for /etc/hosts is not pruned as redundant. + policy.Filesystem.AllowWrite = []string{"/etc/**"} + policy.Filesystem.DenyWrite = []string{"/etc/hosts"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + entry := findDenyPath(ep.DenyPaths, "/etc/hosts") + if entry == nil { + t.Fatal("expected deny entry for /etc/hosts, not found") + } + if entry.Mode != denyWrite { + t.Errorf("mode = %d, want denyWrite (%d)", entry.Mode, denyWrite) + } +} + +func TestLandlockTranslatePolicy_DenyWriteSkippedWhenNotWritable(t *testing.T) { + policy := newTestPolicy() + // No AllowWrite for /etc, so deny_write for /etc/hosts is redundant + // (Landlock already prevents writes). + policy.Filesystem.DenyWrite = []string{"/etc/hosts"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + entry := findDenyPath(ep.DenyPaths, "/etc/hosts") + if entry != nil { + t.Error("expected deny entry for /etc/hosts to be pruned (not in writable area)") + } +} + +func TestLandlockTranslatePolicy_DenyExec(t *testing.T) { + policy := newTestPolicy() + policy.Process.DenyExec = []string{"/usr/bin/curl", "/usr/bin/wget"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, path := range []string{"/usr/bin/curl", "/usr/bin/wget"} { + found := false + for _, p := range ep.DenyExecPaths { + if p == path { + found = true + break + } + } + if !found { + t.Errorf("expected %s in DenyExecPaths, not found", path) + } + } +} + +func TestLandlockTranslatePolicy_MandatoryDenies(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Mandatory denies should always be present with denyBoth mode + if len(ep.DenyPaths) == 0 { + t.Fatal("expected mandatory deny paths, got none") + } + + // Check that at least some mandatory denies have denyBoth mode + hasDenyBoth := false + for _, entry := range ep.DenyPaths { + if entry.Mode == denyBoth { + hasDenyBoth = true + break + } + } + if !hasDenyBoth { + t.Error("expected at least one mandatory deny with denyBoth mode") + } + + // Check that .env is in the mandatory denies (it should always be there) + hasEnvDeny := false + for _, entry := range ep.DenyPaths { + if strings.HasSuffix(entry.Path, "/.env") && entry.Mode == denyBoth { + hasEnvDeny = true + break + } + } + if !hasEnvDeny { + t.Error("expected .env in mandatory deny paths with denyBoth mode") + } +} + +func TestLandlockTranslatePolicy_ImplicitRules(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + readAccess := uint64(llsyscall.AccessFSReadFile | llsyscall.AccessFSReadDir | llsyscall.AccessFSExecute) + + // /proc must be present with read access + procRule := findRule(ep.FilesystemRules, "/proc") + if procRule == nil { + t.Error("expected implicit /proc rule") + } else if procRule.Access != readAccess { + t.Errorf("/proc access = %x, want read access %x", procRule.Access, readAccess) + } + + // /dev/null, /dev/zero, /dev/random, /dev/urandom must be present with read+write + for _, dev := range []string{"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"} { + rule := findRule(ep.FilesystemRules, dev) + if rule == nil { + t.Errorf("expected implicit rule for %s", dev) + continue + } + // Should have both read and write access + if rule.Access&readAccess != readAccess { + t.Errorf("%s: missing read access flags", dev) + } + if rule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 { + t.Errorf("%s: missing write access flags", dev) + } + } + + // os.TempDir() must be present with write access + tmpDir := os.TempDir() + tmpRule := findRule(ep.FilesystemRules, tmpDir) + if tmpRule == nil { + t.Errorf("expected implicit rule for %s", tmpDir) + } else if tmpRule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 { + t.Errorf("%s: missing write access flags", tmpDir) + } +} + +func TestLandlockTranslatePolicy_AllowPTY_True(t *testing.T) { + policy := newTestPolicy() + policy.AllowPTY = utils.PtrTo(true) + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(5) // V5 has IoctlDev + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !ep.AllowPTY { + t.Error("expected AllowPTY to be true") + } + + // Check /dev/pts + ptsRule := findRule(ep.FilesystemRules, "/dev/pts") + if ptsRule == nil { + t.Fatal("expected rule for /dev/pts") + } + if ptsRule.Access&uint64(llsyscall.AccessFSReadFile) == 0 { + t.Error("/dev/pts: missing read access") + } + if ptsRule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 { + t.Error("/dev/pts: missing write access") + } + // V5+ should have IoctlDev + if ptsRule.Access&uint64(llsyscall.AccessFSIoctlDev) == 0 { + t.Error("/dev/pts: missing IoctlDev access on V5+") + } + + // Check /dev/ptmx + ptmxRule := findRule(ep.FilesystemRules, "/dev/ptmx") + if ptmxRule == nil { + t.Fatal("expected rule for /dev/ptmx") + } + if ptmxRule.Access&uint64(llsyscall.AccessFSIoctlDev) == 0 { + t.Error("/dev/ptmx: missing IoctlDev access on V5+") + } +} + +func TestLandlockTranslatePolicy_AllowPTY_Nil(t *testing.T) { + policy := newTestPolicy() + policy.AllowPTY = nil // nil means false + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ep.AllowPTY { + t.Error("expected AllowPTY to be false when nil") + } + + // /dev/pts and /dev/ptmx should NOT be in rules + if findRule(ep.FilesystemRules, "/dev/pts") != nil { + t.Error("unexpected rule for /dev/pts when AllowPTY is nil") + } + if findRule(ep.FilesystemRules, "/dev/ptmx") != nil { + t.Error("unexpected rule for /dev/ptmx when AllowPTY is nil") + } +} + +func TestLandlockTranslatePolicy_ProcExplicitAllow(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = []string{"/proc/cpuinfo"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !ep.SkipPIDNamespace { + t.Error("expected SkipPIDNamespace=true when /proc/cpuinfo is allowed") + } +} + +func TestLandlockTranslatePolicy_ProcSelfOnly(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = []string{"/proc/self/status", "/proc/self/fd"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ep.SkipPIDNamespace { + t.Error("expected SkipPIDNamespace=false when only /proc/self paths are allowed") + } +} + +func TestLandlockTranslatePolicy_ProcDenyDropped(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.DenyRead = []string{"/proc/kcore", "/etc/shadow"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // /proc/kcore should be dropped + for _, entry := range ep.DenyPaths { + if strings.HasPrefix(entry.Path, "/proc") { + t.Errorf("expected /proc deny entries to be dropped, found: %s", entry.Path) + } + } + + // /etc/shadow should remain + found := false + for _, entry := range ep.DenyPaths { + if entry.Path == "/etc/shadow" { + found = true + break + } + } + if !found { + t.Error("expected /etc/shadow deny entry to remain") + } +} + +func TestLandlockTranslatePolicy_AllowGitConfig_Nil(t *testing.T) { + policy := newTestPolicy() + policy.AllowGitConfig = nil // nil means false -> .git/config should be in mandatory denies + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // .git/config should be in deny paths + hasGitConfigDeny := false + for _, entry := range ep.DenyPaths { + if strings.HasSuffix(entry.Path, ".git/config") { + hasGitConfigDeny = true + break + } + } + if !hasGitConfigDeny { + t.Error("expected .git/config in mandatory deny paths when AllowGitConfig is nil") + } +} + +func TestLandlockTranslatePolicy_AllowGitConfig_True(t *testing.T) { + policy := newTestPolicy() + policy.AllowGitConfig = utils.PtrTo(true) + policy.Filesystem.AllowRead = []string{"/usr"} + abi := newLandlockABI(3) + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // .git/config should NOT be in deny paths + for _, entry := range ep.DenyPaths { + if strings.HasSuffix(entry.Path, ".git/config") { + t.Error("expected .git/config to NOT be in mandatory deny paths when AllowGitConfig is true") + break + } + } +} + +func TestLandlockPolicyExplicitlyAllowsProc(t *testing.T) { + tests := []struct { + name string + readPaths []string + writePaths []string + want bool + }{ + { + name: "no proc paths", + readPaths: []string{"/usr/lib", "/etc"}, + want: false, + }, + { + name: "/proc/self only", + readPaths: []string{"/proc/self"}, + want: false, + }, + { + name: "/proc/self/status", + readPaths: []string{"/proc/self/status"}, + want: false, + }, + { + name: "/proc/cpuinfo triggers", + readPaths: []string{"/proc/cpuinfo"}, + want: true, + }, + { + name: "/proc/1/status triggers", + readPaths: []string{"/proc/1/status"}, + want: true, + }, + { + name: "/proc in write paths triggers", + writePaths: []string{"/proc/sys/kernel/randomize_va_space"}, + want: true, + }, + { + name: "/proc alone triggers", + readPaths: []string{"/proc"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := newTestPolicy() + policy.Filesystem.AllowRead = tt.readPaths + policy.Filesystem.AllowWrite = tt.writePaths + + got := landlockPolicyExplicitlyAllowsProc(policy) + if got != tt.want { + t.Errorf("landlockPolicyExplicitlyAllowsProc() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sandbox/platform/platform_linux.go b/sandbox/platform/platform_linux.go index 80b432f..9f13e3f 100644 --- a/sandbox/platform/platform_linux.go +++ b/sandbox/platform/platform_linux.go @@ -4,11 +4,34 @@ package platform import ( + "os" + + "github.com/safedep/dry/log" "github.com/safedep/pmg/sandbox" ) // NewSandbox creates a platform-specific sandbox instance for Linux. -// Uses Bubblewrap (bwrap) for filesystem, network, and process isolation. +// Prefers Landlock (kernel 5.13+) with seccomp-notify for deny enforcement. +// Falls back to Bubblewrap if Landlock or seccomp-notify is unavailable. +// Set PMG_SANDBOX_DRIVER=bubblewrap to force Bubblewrap, or +// PMG_SANDBOX_DRIVER=landlock to force Landlock (no fallback — fails if +// Landlock is unavailable). func NewSandbox() (sandbox.Sandbox, error) { + switch os.Getenv("PMG_SANDBOX_DRIVER") { + case "bubblewrap": + log.Debugf("PMG_SANDBOX_DRIVER=bubblewrap: forcing Bubblewrap sandbox") + return newBubblewrapSandbox() + case "landlock": + log.Debugf("PMG_SANDBOX_DRIVER=landlock: forcing Landlock sandbox") + return newLandlockSandbox() + } + + sb, err := newLandlockSandbox() + if err == nil { + log.Debugf("Using Landlock sandbox driver (ABI V%d)", sb.(*landlockSandbox).abi.Version) + return sb, nil + } + + log.Debugf("Landlock not available (%v), falling back to Bubblewrap", err) return newBubblewrapSandbox() } diff --git a/sandbox/registry_test.go b/sandbox/registry_test.go index 863b243..3a168ea 100644 --- a/sandbox/registry_test.go +++ b/sandbox/registry_test.go @@ -80,7 +80,7 @@ func TestLoadCustomProfile(t *testing.T) { tempFile, err := os.CreateTemp(t.TempDir(), "sandbox-policy-*.yml") assert.NoError(t, err) - defer tempFile.Close() + defer func() { _ = tempFile.Close() }() err = yaml.NewEncoder(tempFile).Encode(c.policy) assert.NoError(t, err)