mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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/<pid>/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/<pid>/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/<pid>/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.
|
||||
+57
-3
@@ -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
|
||||
|
||||
<details>
|
||||
@@ -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
|
||||
|
||||
<details>
|
||||
<summary>Linux (Bubblewrap)</summary>
|
||||
<summary>Linux (Landlock, default)</summary>
|
||||
|
||||
**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/<pid>/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/<pid>/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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Linux (Bubblewrap, fallback)</summary>
|
||||
|
||||
**Filesystem permissions are coarse-grained**: [Bubblewrap](https://github.com/containers/bubblewrap) uses bind mounts for filesystem isolation.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")).
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<pid>/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/<pid>/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
|
||||
}
|
||||
@@ -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/<pid>/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/<pid>/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/<pid>/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/<pid>/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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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/<pid>/cwd
|
||||
// and /proc/<pid>/fd/<dirfd>. 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/<childPID>/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/<pid>/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/<pid>/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/<pid>/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/<pid>/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/<pid>/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/<pid>/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)
|
||||
}
|
||||
}
|
||||
@@ -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/<self>/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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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/<pid>/{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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user