mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
22d6eabb6b
commit
5131c3f641
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
)
|
||||
@@ -43,7 +44,15 @@ func newBubblewrapSandbox() (*bubblewrapSandbox, error) {
|
||||
//
|
||||
// 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 (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
|
||||
func (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
||||
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
|
||||
return nil, usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.UnsupportedPlatform).
|
||||
WithHumanError("network_via_proxy_only is not yet supported on this platform").
|
||||
WithHelp("Disable network_via_proxy_only for this profile.").
|
||||
Wrap(fmt.Errorf("network_via_proxy_only is not yet supported on this platform (%s sandbox)", b.Name()))
|
||||
}
|
||||
|
||||
bwrapPath, err := exec.LookPath("bwrap")
|
||||
if err != nil {
|
||||
return nil, usefulerror.NewUsefulError().
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestBubblewrapSandboxExecute(t *testing.T) {
|
||||
cmd := exec.Command("/bin/echo", "hello")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestBubblewrapSandboxExecuteCommandWrapping(t *testing.T) {
|
||||
cmd := exec.Command(originalCmd, originalArgs[1:]...)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify command structure
|
||||
@@ -148,7 +148,7 @@ func TestBubblewrapSandboxExecuteWithPTY(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("/bin/echo", "test")
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have PTY-related arguments
|
||||
@@ -186,7 +186,7 @@ func TestBubblewrapSandboxExecuteWithNetworkIsolation(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("/bin/echo", "test")
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have network isolation
|
||||
@@ -231,7 +231,7 @@ func TestBubblewrapSandboxExecutionResult(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("/bin/echo", "test")
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ExecutionResult properties
|
||||
@@ -267,7 +267,7 @@ func TestBubblewrapSandboxTranslationError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Should succeed even with complex patterns
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
@@ -294,7 +294,7 @@ func TestBubblewrapSandboxEssentialBindMounts(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("/bin/echo", "test")
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
argsStr := ""
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
)
|
||||
|
||||
@@ -81,7 +84,15 @@ func (s *landlockSandbox) Close() error {
|
||||
//
|
||||
// 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) {
|
||||
func (s *landlockSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
||||
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
|
||||
return nil, usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.UnsupportedPlatform).
|
||||
WithHumanError("network_via_proxy_only is not yet supported on this platform").
|
||||
WithHelp("Disable network_via_proxy_only for this profile.").
|
||||
Wrap(fmt.Errorf("network_via_proxy_only is not yet supported on this platform (%s sandbox)", s.Name()))
|
||||
}
|
||||
|
||||
execPolicy, err := landlockTranslatePolicy(policy, s.abi)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to translate policy: %w", err)
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestLandlockSandbox_Close_CleansUpResources(t *testing.T) {
|
||||
|
||||
cmd := exec.Command("/bin/echo", "hello")
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestLandlockSandbox_Execute_RewiresCmd(t *testing.T) {
|
||||
cmd := exec.Command("/bin/echo", "hello", "world")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestLandlockSandbox_Execute_PolicySerialized(t *testing.T) {
|
||||
cmd := exec.Command("/bin/echo", "test")
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build linux
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLinuxDriversRejectNetworkViaProxyOnly(t *testing.T) {
|
||||
bwrap, err := newBubblewrapSandbox()
|
||||
require.NoError(t, err)
|
||||
|
||||
drivers := []struct {
|
||||
name string
|
||||
sb sandbox.Sandbox
|
||||
}{
|
||||
{"bubblewrap", bwrap},
|
||||
{"landlock", &landlockSandbox{abi: newLandlockABI(4)}},
|
||||
}
|
||||
|
||||
policy := &sandbox.SandboxPolicy{
|
||||
Name: "lockdown",
|
||||
PackageManagers: []string{"npm"},
|
||||
Filesystem: sandbox.FilesystemPolicy{AllowRead: []string{"/tmp"}},
|
||||
NetworkViaProxyOnly: utils.PtrTo(true),
|
||||
}
|
||||
|
||||
for _, d := range drivers {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
cmd := exec.Command("/bin/true")
|
||||
result, err := d.sb.Execute(context.Background(), cmd, policy,
|
||||
&sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "not yet supported on this platform")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func runCanary(ctx context.Context, name string, driver sandbox.DriverName, fact
|
||||
}
|
||||
policy := denyAllCanaryPolicy()
|
||||
|
||||
result, err := sb.Execute(ctx, cmd, policy)
|
||||
result, err := sb.Execute(ctx, cmd, policy, nil)
|
||||
if err != nil {
|
||||
return sandbox.ProbeResult{
|
||||
Name: name,
|
||||
|
||||
@@ -20,7 +20,7 @@ type fakeSandbox struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
|
||||
func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
||||
if f.executeErr != nil {
|
||||
return nil, f.executeErr
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/safedep/dry/usefulerror"
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/errcodes"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
)
|
||||
|
||||
@@ -35,7 +38,22 @@ func newSeatbeltSandbox() (*seatbeltSandbox, error) {
|
||||
//
|
||||
// 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 *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) {
|
||||
func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) {
|
||||
if _, err := sandbox.ValidateNetworkLockdown(policy, rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Temporary fail-closed stub until the Seatbelt translator emits the
|
||||
// lockdown profile: without it, a lockdown policy would silently get the
|
||||
// pre-lockdown network rules. Removed when lockdown translation lands.
|
||||
if utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
|
||||
return nil, usefulerror.NewUsefulError().
|
||||
WithCode(errcodes.UnsupportedPlatform).
|
||||
WithHumanError("network_via_proxy_only is not yet enforced by this pmg build").
|
||||
WithHelp("Disable network_via_proxy_only for this profile until lockdown enforcement ships.").
|
||||
Wrap(fmt.Errorf("network_via_proxy_only translation is not yet implemented (%s sandbox)", s.Name()))
|
||||
}
|
||||
|
||||
sbProfile, err := s.translator.translate(policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to translate sandbox policy: %w", err)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/safedep/dry/utils"
|
||||
"github.com/safedep/pmg/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSeatbeltDarwin(t *testing.T) {
|
||||
@@ -38,7 +40,7 @@ func TestSeatbeltDarwin(t *testing.T) {
|
||||
cmd := exec.Command("npm", "install", "lodash")
|
||||
npmResolvedPath := cmd.Path
|
||||
|
||||
result, err := sb.Execute(context.Background(), cmd, policy)
|
||||
result, err := sb.Execute(context.Background(), cmd, policy, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, result.ShouldRun(), "command should be runnable because seatbelt only patches the command")
|
||||
|
||||
@@ -50,3 +52,41 @@ func TestSeatbeltDarwin(t *testing.T) {
|
||||
|
||||
assert.NoFileExists(t, sb.tempProfilePath)
|
||||
}
|
||||
|
||||
func TestSeatbeltExecuteLockdownValidation(t *testing.T) {
|
||||
lockdownPolicy := &sandbox.SandboxPolicy{
|
||||
Name: "lockdown",
|
||||
PackageManagers: []string{"npm"},
|
||||
Filesystem: sandbox.FilesystemPolicy{
|
||||
AllowRead: []string{"/tmp"},
|
||||
AllowWrite: []string{"/tmp"},
|
||||
},
|
||||
NetworkViaProxyOnly: utils.PtrTo(true),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rt *sandbox.ExecutionContext
|
||||
wantErr string
|
||||
}{
|
||||
{"nil execution context", nil, "requires the PMG proxy"},
|
||||
{"empty proxy address", &sandbox.ExecutionContext{}, "requires the PMG proxy"},
|
||||
{"non-loopback proxy address", &sandbox.ExecutionContext{ProxyAddr: "192.168.1.5:9999"}, "loopback"},
|
||||
{"unparseable proxy address", &sandbox.ExecutionContext{ProxyAddr: "not-an-address"}, "loopback"},
|
||||
{"valid proxy address rejected until translation lands", &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"}, "not yet implemented"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sb, err := newSeatbeltSandbox()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := exec.Command("/usr/bin/true")
|
||||
result, err := sb.Execute(context.Background(), cmd, lockdownPolicy, tt.rt)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user