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:
Abhisek Datta
2026-05-07 12:42:28 +05:30
committed by GitHub
parent 5122a1594c
commit 4c42ceca0e
27 changed files with 4356 additions and 122 deletions
+81
View File
@@ -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 }
+38
View File
@@ -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(&notifySocketFd, "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
}
+8
View File
@@ -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 }
+141
View File
@@ -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
View File
@@ -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.
+3 -1
View File
@@ -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
+4
View File
@@ -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=
+6
View File
@@ -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")).
+8
View File
@@ -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))
+3 -116
View File
@@ -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.
+110
View File
@@ -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
}
+52
View File
@@ -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
}
+102
View File
@@ -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)
}
}
+309
View File
@@ -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
}
+379
View File
@@ -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)
}
+162
View File
@@ -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
}
+212
View File
@@ -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)
}
+693
View File
@@ -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(&notif)),
)
if errno == 0 {
return &notif, 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)
}
})
}
}
+149
View File
@@ -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)
}
})
}
}
+24 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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)