Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions cmd/landlock/landlock_probe_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build linux

package landlock

import (
"github.com/safedep/pmg/sandbox/platform"
"github.com/spf13/cobra"
)

// NewLandlockProbeCommand returns the hidden Cobra command used to detect
// whether the Landlock shim can install seccomp without NO_NEW_PRIVS.
func NewLandlockProbeCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "__landlock_probe",
Hidden: true,
SilenceUsage: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
},
Comment on lines +17 to +18
RunE: func(cmd *cobra.Command, args []string) error {
return platform.RunLandlockProbe()
},
}
return cmd
}
8 changes: 8 additions & 0 deletions cmd/landlock/landlock_probe_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build !linux

package landlock

import "github.com/spf13/cobra"

// NewLandlockProbeCommand returns nil on non-Linux platforms.
func NewLandlockProbeCommand() *cobra.Command { return nil }
6 changes: 5 additions & 1 deletion docs/sandbox-landlock.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ constant tax that maps to most of the decisions above:
## Limitations

- **Unprivileged user namespaces required.** On distros that disable them, `clone()`
returns EPERM. We don't yet probe and fall back to bubblewrap (TODO).
returns EPERM. PMG probes the shim path at driver-selection time and falls back
to Bubblewrap when it cannot work.
- **Host LSM policy must allow the user-ns capability path.** Ubuntu AppArmor can allow
user namespace creation while denying `CAP_SYS_ADMIN` inside the namespace through the
`unprivileged_userns` profile. The shim probe catches this before PMG selects Landlock.
- **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.
Expand Down
15 changes: 10 additions & 5 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,9 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in
<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.
without requiring external binaries. PMG's deny-rule enforcement also requires unprivileged
user namespaces because the shim needs `CAP_SYS_ADMIN` inside a fresh user namespace to install
seccomp-notify without `NO_NEW_PRIVS`.

For the architecture, design tradeoffs, and known limitations see
[sandbox-landlock.md](./sandbox-landlock.md).
Expand Down Expand Up @@ -243,8 +245,11 @@ refuse to run as root (npm's root-in-container warning) are unaffected because t
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.
on Debian/Ubuntu; default on most modern distros), and any host LSM policy must allow the shim
to use `CAP_SYS_ADMIN` inside that namespace. PMG probes this at driver-selection time. If the
probe fails, for example on Ubuntu AppArmor setups that deny capabilities inside
`unprivileged_userns`, the sandbox falls back to Bubblewrap unless
`PMG_SANDBOX_DRIVER=landlock` is set.

**Network filtering**: Not enforced. Landlock supports TCP port filtering only (V4+, no hostname).
Use `--proxy-mode` for network control.
Expand All @@ -257,8 +262,8 @@ to force Bubblewrap if namespace isolation is required.
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.
**Fallback**: If Landlock is unavailable (kernel < 5.13) or the no-NNP shim probe fails,
Bubblewrap is used automatically. Set `PMG_SANDBOX_DRIVER=bubblewrap` to force Bubblewrap.

</details>

Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ func main() {
if subcmd := landlockCmd.NewLandlockShimCommand(); subcmd != nil {
cmd.AddCommand(subcmd)
}
if subcmd := landlockCmd.NewLandlockProbeCommand(); subcmd != nil {
cmd.AddCommand(subcmd)
}
Comment on lines 144 to +148

// Print Banner on --help / -h
cmd.SetHelpFunc(func(command *cobra.Command, args []string) {
Expand Down
6 changes: 5 additions & 1 deletion sandbox/platform/landlock_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ type landlockSandbox struct {
}

// newLandlockSandbox creates a new Landlock sandbox instance after verifying
// that both Landlock and seccomp user notification are available on the system.
// that Landlock and the no-NNP seccomp shim path 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)
}

if err := landlockShimProbe(); err != nil {
return nil, fmt.Errorf("landlock shim not available: %w", err)
}
Comment on lines 35 to +45

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)

Expand Down
67 changes: 67 additions & 0 deletions sandbox/platform/landlock_probe_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//go:build linux

package platform

import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"

"golang.org/x/sys/unix"
)

var landlockShimProbe = runLandlockShimProbe

// runLandlockShimProbe verifies that the installed pmg binary can perform the
// same critical operation as the real Landlock shim: create a user namespace
// and install a seccomp-notify filter without setting NO_NEW_PRIVS.
func runLandlockShimProbe() error {
selfExe, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve self exe: %w", err)
}

cmd := exec.Command(selfExe, "__landlock_probe")
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,
}

output, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(output))
if msg == "" {
return fmt.Errorf("run shim probe: %w", err)
}
return fmt.Errorf("run shim probe: %w: %s", err, msg)
}
return nil
}

// RunLandlockProbe is the hidden self-reexec entry point used by
// runLandlockShimProbe. It intentionally reuses shimInstallSeccomp so the probe
// stays coupled to the exact no-NNP seccomp path the real shim needs.
func RunLandlockProbe() error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()

notifyFd, err := shimInstallSeccomp(false)
if err != nil {
return fmt.Errorf("probe: install seccomp: %w", err)
}
if err := unix.Close(notifyFd); err != nil {
return fmt.Errorf("probe: close notify fd: %w", err)
}
return nil
}
21 changes: 16 additions & 5 deletions sandbox/platform/platform_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import (
"github.com/safedep/pmg/sandbox"
)

var (
landlockSandboxFactory = newLandlockSandbox
bubblewrapSandboxFactory = func() (sandbox.Sandbox, error) {
return newBubblewrapSandbox()
}
)

// NewSandbox creates a platform-specific sandbox instance for Linux.
// Prefers Landlock (kernel 5.13+) with seccomp-notify for deny enforcement.
// Falls back to Bubblewrap if Landlock or seccomp-notify is unavailable.
Expand All @@ -20,18 +27,22 @@ func NewSandbox() (sandbox.Sandbox, error) {
switch os.Getenv("PMG_SANDBOX_DRIVER") {
case "bubblewrap":
log.Debugf("PMG_SANDBOX_DRIVER=bubblewrap: forcing Bubblewrap sandbox")
return newBubblewrapSandbox()
return bubblewrapSandboxFactory()
case "landlock":
log.Debugf("PMG_SANDBOX_DRIVER=landlock: forcing Landlock sandbox")
return newLandlockSandbox()
return landlockSandboxFactory()
}

sb, err := newLandlockSandbox()
sb, err := landlockSandboxFactory()
if err == nil {
log.Debugf("Using Landlock sandbox driver (ABI V%d)", sb.(*landlockSandbox).abi.Version)
if ll, ok := sb.(*landlockSandbox); ok {
log.Debugf("Using Landlock sandbox driver (ABI V%d)", ll.abi.Version)
} else {
log.Debugf("Using Landlock sandbox driver")
}
return sb, nil
}

log.Debugf("Landlock not available (%v), falling back to Bubblewrap", err)
return newBubblewrapSandbox()
return bubblewrapSandboxFactory()
}
127 changes: 127 additions & 0 deletions sandbox/platform/platform_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//go:build linux

package platform

import (
"context"
"errors"
"os/exec"
"testing"

"github.com/safedep/pmg/sandbox"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type testSandbox struct {
name string
available bool
}

func (s *testSandbox) Name() string {
return s.name
}

func (s *testSandbox) IsAvailable() bool {
return s.available
}

func (s *testSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
return sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(s)), nil
}

func (s *testSandbox) Close() error {
return nil
}

func TestNewSandbox_DefaultFallsBackToBubblewrapWhenLandlockUnavailable(t *testing.T) {
restoreFactories := replaceSandboxFactories(t,
func() (sandbox.Sandbox, error) {
return nil, errors.New("landlock shim not available")
},
func() (sandbox.Sandbox, error) {
return &testSandbox{name: "bubblewrap", available: true}, nil
},
)
defer restoreFactories()
t.Setenv("PMG_SANDBOX_DRIVER", "")

sb, err := NewSandbox()
require.NoError(t, err)
assert.Equal(t, "bubblewrap", sb.Name())
}

func TestNewSandbox_DefaultUsesLandlockWhenAvailable(t *testing.T) {
restoreFactories := replaceSandboxFactories(t,
func() (sandbox.Sandbox, error) {
return &testSandbox{name: "landlock", available: true}, nil
},
func() (sandbox.Sandbox, error) {
return nil, errors.New("bubblewrap should not be used")
},
)
defer restoreFactories()
t.Setenv("PMG_SANDBOX_DRIVER", "")

sb, err := NewSandbox()
require.NoError(t, err)
assert.Equal(t, "landlock", sb.Name())
}

func TestNewSandbox_ForcedLandlockDoesNotFallback(t *testing.T) {
bubblewrapCalled := false
restoreFactories := replaceSandboxFactories(t,
func() (sandbox.Sandbox, error) {
return nil, errors.New("landlock shim not available")
},
func() (sandbox.Sandbox, error) {
bubblewrapCalled = true
return &testSandbox{name: "bubblewrap", available: true}, nil
},
)
defer restoreFactories()
t.Setenv("PMG_SANDBOX_DRIVER", "landlock")

sb, err := NewSandbox()
require.Error(t, err)
assert.Nil(t, sb)
assert.False(t, bubblewrapCalled)
}

func TestNewSandbox_ForcedBubblewrapSkipsLandlock(t *testing.T) {
landlockCalled := false
restoreFactories := replaceSandboxFactories(t,
func() (sandbox.Sandbox, error) {
landlockCalled = true
return &testSandbox{name: "landlock", available: true}, nil
},
func() (sandbox.Sandbox, error) {
return &testSandbox{name: "bubblewrap", available: true}, nil
},
)
defer restoreFactories()
t.Setenv("PMG_SANDBOX_DRIVER", "bubblewrap")

sb, err := NewSandbox()
require.NoError(t, err)
assert.Equal(t, "bubblewrap", sb.Name())
assert.False(t, landlockCalled)
}

func replaceSandboxFactories(
t *testing.T,
landlockFactory func() (sandbox.Sandbox, error),
bubblewrapFactory func() (sandbox.Sandbox, error),
) func() {
t.Helper()

origLandlock := landlockSandboxFactory
origBubblewrap := bubblewrapSandboxFactory
landlockSandboxFactory = landlockFactory
bubblewrapSandboxFactory = bubblewrapFactory

return func() {
landlockSandboxFactory = origLandlock
bubblewrapSandboxFactory = origBubblewrap
}
}
Loading