diff --git a/errcodes/codes.go b/errcodes/codes.go index a371f2c..76169f0 100644 --- a/errcodes/codes.go +++ b/errcodes/codes.go @@ -25,6 +25,11 @@ const ( CertTrustStore = "CertTrustStore" UnsupportedPlatform = "UnsupportedPlatform" + // Sandbox error codes. SandboxRequiresProxy is returned when a sandbox + // policy enables network_via_proxy_only but no PMG proxy is running to + // confine traffic to. + SandboxRequiresProxy = "SandboxRequiresProxy" + // Proxy error codes. ProxyPolicyViolation is returned when the proxy blocked // one or more packages by policy (malware, dependency cooldown, or a denied // suspicious package) and the run was gated with --fail-on-violation. diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 3b21594..92154e7 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -222,6 +222,7 @@ func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagema executionError := runner.ExecuteWithOptions(ctx, parsedCmd, runner.ExecuteOptions{ PackageManagerName: f.pm.Name(), DryRun: cfg.DryRun, + SandboxProxyAddr: proxyAddr, Mode: runner.ExecutionModeAuto, EnvOverrides: append(packagemanager.EnvVarForProxy(proxyAddr, caCertPath), routing.ExtraEnv...), DirectEnvOverrides: ciEnvOverride(), diff --git a/internal/runner/execute.go b/internal/runner/execute.go index 93e02d5..45335a2 100644 --- a/internal/runner/execute.go +++ b/internal/runner/execute.go @@ -35,6 +35,7 @@ const outputDrainGrace = 2 * time.Second type ExecuteOptions struct { PackageManagerName string DryRun bool + SandboxProxyAddr string EnvOverrides []string DirectEnvOverrides []string PTYEnvOverrides []string @@ -107,7 +108,8 @@ func ExecuteWithOptions(ctx context.Context, pc *packagemanager.ParsedCommand, o } } - result, err := executor.ApplySandbox(ctx, cmd, opts.PackageManagerName) + result, err := executor.ApplySandbox(ctx, cmd, opts.PackageManagerName, + executor.WithExecutionContext(&sandbox.ExecutionContext{ProxyAddr: opts.SandboxProxyAddr})) if err != nil { return fmt.Errorf("failed to apply sandbox: %w", err) } diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index 4c24b44..cf56682 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -21,6 +21,7 @@ import ( type applySandboxConfig struct { sb sandbox.Sandbox + rt *sandbox.ExecutionContext } type applySandboxOpt func(*applySandboxConfig) @@ -33,6 +34,14 @@ func WithSandbox(sb sandbox.Sandbox) applySandboxOpt { } } +// WithExecutionContext provides runtime data known only at spawn time +// (e.g. the PMG proxy address) to the sandbox driver. +func WithExecutionContext(rt *sandbox.ExecutionContext) applySandboxOpt { + return func(c *applySandboxConfig) { + c.rt = rt + } +} + // ApplySandbox applies sandbox isolation to the command if sandbox mode is enabled. // This is a helper function used by both guard and proxy flows to avoid code duplication. // @@ -172,7 +181,11 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app // env slice regardless of the OS sandbox driver). scrubbed := scrubEnv(cmd, policy) - result, err := sb.Execute(ctx, cmd, policy) + if _, err := sandbox.ValidateNetworkLockdown(policy, applyConfig.rt); err != nil { + return nil, err + } + + result, err := sb.Execute(ctx, cmd, policy, applyConfig.rt) if err != nil { return nil, fmt.Errorf("failed to setup sandbox: %w", err) } diff --git a/sandbox/executor/apply_test.go b/sandbox/executor/apply_test.go index 3be13cf..1f5ab1c 100644 --- a/sandbox/executor/apply_test.go +++ b/sandbox/executor/apply_test.go @@ -1,6 +1,7 @@ package executor import ( + "context" "os" "os/exec" "path/filepath" @@ -372,3 +373,51 @@ func TestApplyProjectOverlayEmptyArgsNoop(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, applied) } + +type fakeApplySandbox struct { + rt *sandbox.ExecutionContext +} + +func (f *fakeApplySandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) { + f.rt = rt + return sandbox.NewExecutionResult(), nil +} + +func (f *fakeApplySandbox) Name() sandbox.DriverName { return "fake" } +func (f *fakeApplySandbox) IsAvailable() bool { return true } +func (f *fakeApplySandbox) Close() error { return nil } + +func TestApplySandboxLockdownRequiresExecutionContext(t *testing.T) { + profile := filepath.Join(t.TempDir(), "lockdown.yml") + require.NoError(t, os.WriteFile(profile, []byte(` +name: lockdown-apply-test +package_managers: ["npm"] +filesystem: + allow_read: ["/tmp"] +network_via_proxy_only: true +`), 0o600)) + + cfg := config.Get() + oldEnabled := cfg.Config.Sandbox.Enabled + oldOverride := cfg.SandboxProfileOverride + t.Cleanup(func() { + cfg.Config.Sandbox.Enabled = oldEnabled + cfg.SandboxProfileOverride = oldOverride + }) + cfg.Config.Sandbox.Enabled = true + cfg.SandboxProfileOverride = profile + + fake := &fakeApplySandbox{} + + _, err := ApplySandbox(context.Background(), exec.Command("npm"), "npm", WithSandbox(fake)) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires the PMG proxy") + assert.Nil(t, fake.rt) + + rt := &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"} + result, err := ApplySandbox(context.Background(), exec.Command("npm"), "npm", + WithSandbox(fake), WithExecutionContext(rt)) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, rt, fake.rt) +} diff --git a/sandbox/executor/diagnostics_test.go b/sandbox/executor/diagnostics_test.go index a51dc91..6db6056 100644 --- a/sandbox/executor/diagnostics_test.go +++ b/sandbox/executor/diagnostics_test.go @@ -14,7 +14,7 @@ type fakeViolationSandbox struct { report *sandbox.ViolationReport } -func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { +func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy, *sandbox.ExecutionContext) (*sandbox.ExecutionResult, error) { return sandbox.NewExecutionResult(), nil } diff --git a/sandbox/lockdown_test.go b/sandbox/lockdown_test.go new file mode 100644 index 0000000..7ba692d --- /dev/null +++ b/sandbox/lockdown_test.go @@ -0,0 +1,99 @@ +package sandbox + +import ( + "testing" + + "github.com/safedep/dry/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateNetworkLockdown(t *testing.T) { + lockdownPolicy := &SandboxPolicy{ + Name: "lockdown", + NetworkViaProxyOnly: utils.PtrTo(true), + } + + tests := []struct { + name string + policy *SandboxPolicy + rt *ExecutionContext + wantPort string + wantErr string + }{ + { + name: "lockdown off returns empty port and no error", + policy: &SandboxPolicy{Name: "plain"}, + rt: nil, + }, + { + name: "nil execution context", + policy: lockdownPolicy, + rt: nil, + wantErr: "requires the PMG proxy", + }, + { + name: "empty proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{}, + wantErr: "requires the PMG proxy", + }, + { + name: "non-loopback proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "192.168.1.5:9999"}, + wantErr: "loopback", + }, + { + name: "unparseable proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "not-an-address"}, + wantErr: "loopback", + }, + { + name: "service-name proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:http"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "zero proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:0"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "out-of-range proxy port", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:70000"}, + wantErr: "non-numeric or out-of-range proxy port", + }, + { + name: "loopback ipv4 proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "127.0.0.1:54321"}, + wantPort: "54321", + }, + { + name: "loopback ipv6 proxy address", + policy: lockdownPolicy, + rt: &ExecutionContext{ProxyAddr: "[::1]:54321"}, + wantPort: "54321", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + port, err := ValidateNetworkLockdown(tt.policy, tt.rt) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPort, port) + }) + } +} diff --git a/sandbox/platform/bubblewrap_linux.go b/sandbox/platform/bubblewrap_linux.go index af63797..892c759 100644 --- a/sandbox/platform/bubblewrap_linux.go +++ b/sandbox/platform/bubblewrap_linux.go @@ -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(). diff --git a/sandbox/platform/bubblewrap_linux_test.go b/sandbox/platform/bubblewrap_linux_test.go index e9b56e6..63850ca 100644 --- a/sandbox/platform/bubblewrap_linux_test.go +++ b/sandbox/platform/bubblewrap_linux_test.go @@ -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 := "" diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go index d70c74d..a72e670 100644 --- a/sandbox/platform/landlock_linux.go +++ b/sandbox/platform/landlock_linux.go @@ -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) diff --git a/sandbox/platform/landlock_linux_test.go b/sandbox/platform/landlock_linux_test.go index 29c957c..87be43e 100644 --- a/sandbox/platform/landlock_linux_test.go +++ b/sandbox/platform/landlock_linux_test.go @@ -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) diff --git a/sandbox/platform/lockdown_stub_linux_test.go b/sandbox/platform/lockdown_stub_linux_test.go new file mode 100644 index 0000000..694979c --- /dev/null +++ b/sandbox/platform/lockdown_stub_linux_test.go @@ -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") + }) + } +} diff --git a/sandbox/platform/probe_canary.go b/sandbox/platform/probe_canary.go index ca784ae..459b317 100644 --- a/sandbox/platform/probe_canary.go +++ b/sandbox/platform/probe_canary.go @@ -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, diff --git a/sandbox/platform/probe_canary_test.go b/sandbox/platform/probe_canary_test.go index 4e37c9e..d820933 100644 --- a/sandbox/platform/probe_canary_test.go +++ b/sandbox/platform/probe_canary_test.go @@ -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 } diff --git a/sandbox/platform/seatbelt_darwin.go b/sandbox/platform/seatbelt_darwin.go index 313ff48..b18cfc0 100644 --- a/sandbox/platform/seatbelt_darwin.go +++ b/sandbox/platform/seatbelt_darwin.go @@ -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) diff --git a/sandbox/platform/seatbelt_darwin_test.go b/sandbox/platform/seatbelt_darwin_test.go index ef91cbb..5574f31 100644 --- a/sandbox/platform/seatbelt_darwin_test.go +++ b/sandbox/platform/seatbelt_darwin_test.go @@ -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) + }) + } +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go index 8674cd7..b92be23 100644 --- a/sandbox/sandbox.go +++ b/sandbox/sandbox.go @@ -2,9 +2,57 @@ package sandbox import ( "context" + "fmt" + "net" "os/exec" + "strconv" + + "github.com/safedep/dry/usefulerror" + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/errcodes" ) +// ExecutionContext carries runtime data known only at spawn time. +type ExecutionContext struct { + // ProxyAddr is the loopback TCP address of the running PMG proxy + // (e.g. "127.0.0.1:54321"). Empty when no proxy flow is active. + ProxyAddr string +} + +// ValidateNetworkLockdown enforces the network_via_proxy_only fail-closed contract +// for drivers that support it. Returns the validated proxy port, or "" when +// lockdown is off. +func ValidateNetworkLockdown(policy *SandboxPolicy, rt *ExecutionContext) (string, error) { + if !utils.SafelyGetValue(policy.NetworkViaProxyOnly) { + return "", nil + } + + if rt == nil || rt.ProxyAddr == "" { + return "", usefulerror.NewUsefulError(). + WithCode(errcodes.SandboxRequiresProxy). + WithHumanError("network_via_proxy_only requires the PMG proxy flow"). + WithHelp("This sandbox profile confines all network access to the PMG proxy, but no proxy is running."). + Wrap(fmt.Errorf("policy %s requires the PMG proxy flow: network_via_proxy_only is set but no proxy address was provided", policy.Name)) + } + + host, port, err := net.SplitHostPort(rt.ProxyAddr) + if err != nil { + return "", fmt.Errorf("network_via_proxy_only: proxy address %q is not host:port and not loopback: %w", rt.ProxyAddr, err) + } + + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return "", fmt.Errorf("network_via_proxy_only: refusing non-loopback proxy address %q", rt.ProxyAddr) + } + + portNum, err := strconv.Atoi(port) + if err != nil || portNum < 1 || portNum > 65535 { + return "", fmt.Errorf("network_via_proxy_only: refusing non-numeric or out-of-range proxy port in %q", rt.ProxyAddr) + } + + return port, nil +} + // DriverName identifies a sandbox driver implementation. Returned by // Sandbox.Name() and used wherever code needs to refer to a specific driver. type DriverName string @@ -153,8 +201,11 @@ type Sandbox interface { // - ExecutionResult: Contains execution state and metadata // - error: Non-nil if sandbox setup or execution failed // + // rt carries runtime data known only at spawn time; drivers treat a nil + // rt as &ExecutionContext{}. + // // Callers must check result.ShouldRun() and only call cmd.Run() if true. - Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) (*ExecutionResult, error) + Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy, rt *ExecutionContext) (*ExecutionResult, error) // Name returns the sandbox driver identifier. Name() DriverName