Files
pmg/sandbox/platform/landlock_linux_test.go
T
5131c3f641 feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract (#371)
* feat(sandbox): ExecutionContext plumbing and fail-closed lockdown contract

Network lockdown needs the PMG proxy's address, which is only known at
spawn time. Thread an ExecutionContext from the proxy flow through the
runner and executor into every sandbox driver, and enforce the
network_via_proxy_only fail-closed contract: lockdown without a running
loopback proxy, or on a driver that cannot enforce it, is a hard error —
never a silent fallback to unrestricted network.

- sandbox.ExecutionContext{ProxyAddr} + 4-arg Sandbox.Execute
- sandbox.ValidateLockdown validates the proxy address (loopback only)
  with usefulerror code SandboxRequiresProxy
- Seatbelt validates lockdown before translation (translation itself
  lands next); bubblewrap and landlock reject lockdown as unsupported
  until Linux enforcement is implemented
- executor.WithExecutionContext, runner.ExecuteOptions.SandboxProxyAddr,
  proxy flow passes the live proxy address
- ApplySandbox also validates centrally before invoking the driver

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* fix(sandbox): require numeric in-range proxy port in ValidateLockdown

The validated port string is embedded into generated sandbox profiles,
so service names, zero, and out-of-range ports are refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* fix(sandbox): fail closed on Seatbelt lockdown until translation lands

A lockdown policy that passed proxy validation would silently receive
the pre-lockdown network rules from the translator. Reject it until the
lockdown profile translation is implemented, keeping the window between
plumbing and enforcement fail-closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

* chore: review feedback — drop redundant comment, simplify stub help text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 20:12:33 +05:30

213 lines
5.7 KiB
Go

//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, sandbox.DriverLandlock, 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, nil)
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, nil)
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, nil)
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)
}