diff --git a/cmd/landlock/landlock_probe_linux.go b/cmd/landlock/landlock_probe_linux.go new file mode 100644 index 00000000..b56c8503 --- /dev/null +++ b/cmd/landlock/landlock_probe_linux.go @@ -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) { + }, + RunE: func(cmd *cobra.Command, args []string) error { + return platform.RunLandlockProbe() + }, + } + return cmd +} diff --git a/cmd/landlock/landlock_probe_other.go b/cmd/landlock/landlock_probe_other.go new file mode 100644 index 00000000..b823dc4e --- /dev/null +++ b/cmd/landlock/landlock_probe_other.go @@ -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 } diff --git a/docs/sandbox-landlock.md b/docs/sandbox-landlock.md index dcc15ec1..8228234a 100644 --- a/docs/sandbox-landlock.md +++ b/docs/sandbox-landlock.md @@ -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. diff --git a/docs/sandbox.md b/docs/sandbox.md index 1602a9cf..0b2230a8 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -213,7 +213,9 @@ Next time you run `pmg pnpm install`, the custom policy template will be used in Linux (Landlock, default) **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). @@ -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. @@ -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. diff --git a/main.go b/main.go index 344aa48e..16ac39c0 100644 --- a/main.go +++ b/main.go @@ -143,6 +143,9 @@ func main() { if subcmd := landlockCmd.NewLandlockShimCommand(); subcmd != nil { cmd.AddCommand(subcmd) } + if subcmd := landlockCmd.NewLandlockProbeCommand(); subcmd != nil { + cmd.AddCommand(subcmd) + } // Print Banner on --help / -h cmd.SetHelpFunc(func(command *cobra.Command, args []string) { diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go index 3996d95b..c70ef250 100644 --- a/sandbox/platform/landlock_linux.go +++ b/sandbox/platform/landlock_linux.go @@ -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) + } + 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) diff --git a/sandbox/platform/landlock_probe_linux.go b/sandbox/platform/landlock_probe_linux.go new file mode 100644 index 00000000..1d2ab4ee --- /dev/null +++ b/sandbox/platform/landlock_probe_linux.go @@ -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 +} diff --git a/sandbox/platform/platform_linux.go b/sandbox/platform/platform_linux.go index 9f13e3f2..a489d81c 100644 --- a/sandbox/platform/platform_linux.go +++ b/sandbox/platform/platform_linux.go @@ -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. @@ -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() } diff --git a/sandbox/platform/platform_linux_test.go b/sandbox/platform/platform_linux_test.go new file mode 100644 index 00000000..22fce6d1 --- /dev/null +++ b/sandbox/platform/platform_linux_test.go @@ -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 + } +}