diff --git a/sandbox/platform/seatbelt_darwin.go b/sandbox/platform/seatbelt_darwin.go index b18cfc0..cae245d 100644 --- a/sandbox/platform/seatbelt_darwin.go +++ b/sandbox/platform/seatbelt_darwin.go @@ -11,9 +11,6 @@ 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" ) @@ -43,18 +40,7 @@ func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa 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) + sbProfile, err := s.translator.translate(policy, rt) 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 5574f31..58b2343 100644 --- a/sandbox/platform/seatbelt_darwin_test.go +++ b/sandbox/platform/seatbelt_darwin_test.go @@ -5,6 +5,7 @@ package platform import ( "context" + "os" "os/exec" "testing" @@ -73,7 +74,6 @@ func TestSeatbeltExecuteLockdownValidation(t *testing.T) { {"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 { @@ -90,3 +90,31 @@ func TestSeatbeltExecuteLockdownValidation(t *testing.T) { }) } } + +func TestSeatbeltExecuteLockdownWrapsCommand(t *testing.T) { + policy := &sandbox.SandboxPolicy{ + Name: "lockdown", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + NetworkViaProxyOnly: utils.PtrTo(true), + } + + sb, err := newSeatbeltSandbox() + require.NoError(t, err) + + cmd := exec.Command("/usr/bin/true") + result, err := sb.Execute(context.Background(), cmd, policy, + &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"}) + require.NoError(t, err) + assert.True(t, result.ShouldRun()) + assert.Equal(t, "/usr/bin/sandbox-exec", cmd.Path) + + profile, err := os.ReadFile(sb.tempProfilePath) + require.NoError(t, err) + assert.Contains(t, string(profile), `(allow network-outbound (remote ip "localhost:54321"))`) + + require.NoError(t, result.Close()) +} diff --git a/sandbox/platform/seatbelt_render_darwin.go b/sandbox/platform/seatbelt_render_darwin.go index 8a9d774..1eb6291 100644 --- a/sandbox/platform/seatbelt_render_darwin.go +++ b/sandbox/platform/seatbelt_render_darwin.go @@ -23,7 +23,7 @@ func RenderSeatbelt(policy *sandbox.SandboxPolicy) ([]byte, error) { } t := newSeatbeltPolicyTranslator() - out, err := t.translate(policy) + out, err := t.translate(policy, nil) if err != nil { return nil, err } diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index 3d4761e..628664f 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -209,7 +209,7 @@ func (t *seatbeltPolicyTranslator) LogTag() string { return t.logTag } -func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (string, error) { +func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext) (string, error) { var sb strings.Builder // Header @@ -392,7 +392,7 @@ func (t *seatbeltPolicyTranslator) translate(policy *sandbox.SandboxPolicy) (str } // Network rules - if err := t.translateNetwork(policy, &sb); err != nil { + if err := t.translateNetwork(policy, rt, &sb); err != nil { return "", fmt.Errorf("failed to translate network rules: %w", err) } @@ -605,41 +605,79 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo return nil } -// translateNetwork translates network access rules. -func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolicy, sb *strings.Builder) error { +// translateNetwork translates network access rules. SBPL is last-match-wins, +// so rule ordering is normative: the lockdown broad deny first, specific +// allows after, and the AllowNetworkBind rules last so loopback traffic +// keeps working under lockdown. +func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolicy, rt *sandbox.ExecutionContext, sb *strings.Builder) error { sb.WriteString(";; Network access\n") - // Check if deny all is present - denyAll := false - for _, pattern := range policy.Network.DenyOutbound { - if pattern == "*:*" { - denyAll = true - break + if utils.SafelyGetValue(policy.NetworkViaProxyOnly) { + sb.WriteString(";; network_via_proxy_only: all outbound confined to the PMG proxy\n") + sb.WriteString("(deny network-outbound (with message \"") + sb.WriteString(seatbeltLogMessage(t.logTag, "network-outbound", "direct")) + sb.WriteString("\"))\n") + + // Without a running proxy (render/inspection, e.g. `pmg sandbox + // profile show`) the profile stays fail-closed: the deny stands and + // the runtime-only allow is documented instead of emitted with a + // fabricated port. + if rt == nil || rt.ProxyAddr == "" { + sb.WriteString(";; Rendered without a running PMG proxy. At runtime an\n") + sb.WriteString(";; (allow network-outbound (remote ip \"localhost:\"))\n") + sb.WriteString(";; rule permits traffic to the PMG proxy only.\n") + } else { + port, err := sandbox.ValidateNetworkLockdown(policy, rt) + if err != nil { + return err + } + + sb.WriteString("(allow network-outbound (remote ip \"localhost:") + sb.WriteString(port) + sb.WriteString("\"))\n") } + + // macOS resolves names via the /var/run/mDNSResponder unix socket, + // which the deny above covers; the proxy resolves names, so direct + // DNS stays closed unless explicitly re-opened. + if utils.SafelyGetValue(policy.AllowDirectDNS) { + sb.WriteString("(allow network-outbound (remote unix-socket (path-literal \"/var/run/mDNSResponder\")))\n") + } + + sb.WriteString("\n") + } else { + // Check if deny all is present + denyAll := false + for _, pattern := range policy.Network.DenyOutbound { + if pattern == "*:*" { + denyAll = true + break + } + } + + // If there are allow outbound rules, allow network-outbound generally + // (Seatbelt doesn't support fine-grained host:port filtering in all cases) + // Note: This is a limitation of Seatbelt - for more fine-grained control, + // consider using a network filtering solution or firewall rules + if len(policy.Network.AllowOutbound) > 0 { + sb.WriteString(";; Network outbound allowed to specific hosts\n") + sb.WriteString(";; Note: Seatbelt has limited host-based filtering, consider using firewall rules for strict control\n") + sb.WriteString("(allow network-outbound)\n") + } else if denyAll { + // If there are no allow rules but deny all is set, explicitly deny network + // This handles the case where user wants to completely block network access + sb.WriteString(";; Network outbound denied (no allowed hosts specified)\n") + sb.WriteString("(deny network-outbound)\n") + } + + // Note: We don't add an explicit deny rule when both allow and deny_all are present + // because the default (deny default) at the top of the profile handles blocking + // everything that isn't explicitly allowed. Adding an explicit deny here would + // override the allow rule above, breaking network access entirely. + + sb.WriteString("\n") } - // If there are allow outbound rules, allow network-outbound generally - // (Seatbelt doesn't support fine-grained host:port filtering in all cases) - // Note: This is a limitation of Seatbelt - for more fine-grained control, - // consider using a network filtering solution or firewall rules - if len(policy.Network.AllowOutbound) > 0 { - sb.WriteString(";; Network outbound allowed to specific hosts\n") - sb.WriteString(";; Note: Seatbelt has limited host-based filtering, consider using firewall rules for strict control\n") - sb.WriteString("(allow network-outbound)\n") - } else if denyAll { - // If there are no allow rules but deny all is set, explicitly deny network - // This handles the case where user wants to completely block network access - sb.WriteString(";; Network outbound denied (no allowed hosts specified)\n") - sb.WriteString("(deny network-outbound)\n") - } - - // Note: We don't add an explicit deny rule when both allow and deny_all are present - // because the default (deny default) at the top of the profile handles blocking - // everything that isn't explicitly allowed. Adding an explicit deny here would - // override the allow rule above, breaking network access entirely. - - sb.WriteString("\n") - // Network bind rules for local listening if utils.SafelyGetValue(policy.AllowNetworkBind) { sb.WriteString(";; Local network bind (localhost only)\n") diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index 1c56e4c..77fcd58 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -37,7 +37,7 @@ func TestSeatbeltTranslatorDarwinCommonTranslation(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Test common translation @@ -61,7 +61,7 @@ func TestSeatbeltTranslatorDarwinAlwaysAllowsFSEventsMachLookup(t *testing.T) { policy := &sandbox.SandboxPolicy{} translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) require.NoError(t, err) assert.Contains(t, actual, `(global-name "com.apple.FSEvents")`) @@ -282,7 +282,7 @@ func TestSeatbeltTranslatorDarwinFilesystemTranslation(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(tt.policy) + actual, err := translator.translate(tt.policy, nil) tt.assert(t, actual, err) }) } @@ -341,7 +341,7 @@ func TestSeatbeltTranslatorDarwinProcessTranslation(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(tt.policy) + actual, err := translator.translate(tt.policy, nil) tt.assert(t, actual, err) }) } @@ -540,7 +540,7 @@ func TestFilesystemTranslationWithMoveProtection(t *testing.T) { translator := newSeatbeltPolicyTranslator() translator.enableMoveBlockingMitigation = true - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Should contain deny read rule @@ -571,7 +571,7 @@ func TestPTYSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Should NOT contain PTY rules @@ -588,7 +588,7 @@ func TestPTYSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Should contain PTY rules @@ -611,7 +611,7 @@ func TestNetworkBindSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) assert.Contains(t, actual, ";; Local network bind (localhost only)") @@ -633,7 +633,7 @@ func TestNetworkBindSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Localhost bind from AllowNetworkBind @@ -654,7 +654,7 @@ func TestNetworkBindSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) // Should NOT contain localhost bind @@ -672,7 +672,7 @@ func TestNetworkBindSupport(t *testing.T) { } translator := newSeatbeltPolicyTranslator() - actual, err := translator.translate(policy) + actual, err := translator.translate(policy, nil) assert.NoError(t, err) assert.NotContains(t, actual, "network-bind") @@ -743,3 +743,101 @@ func translateFilesystemForTest(t *testing.T, policy *sandbox.SandboxPolicy) str require.NoError(t, tr.translateFilesystem(policy, &sb)) return sb.String() } + +func TestTranslateNetworkLockdown(t *testing.T) { + rt := &sandbox.ExecutionContext{ProxyAddr: "127.0.0.1:54321"} + basePolicy := func() *sandbox.SandboxPolicy { + return &sandbox.SandboxPolicy{ + Name: "lockdown-translate", + PackageManagers: []string{"golang"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + Network: sandbox.NetworkPolicy{ + AllowOutbound: []string{"proxy.golang.org:443"}, + }, + NetworkViaProxyOnly: utils.PtrTo(true), + } + } + + denyMarker := `|kind=network-outbound|target=direct"))` + proxyAllow := `(allow network-outbound (remote ip "localhost:54321"))` + blanketAllow := "(allow network-outbound)\n" + dnsAllow := `(allow network-outbound (remote unix-socket (path-literal "/var/run/mDNSResponder")))` + bindRule := `(allow network* (local ip "localhost:*"))` + + tests := []struct { + name string + rt *sandbox.ExecutionContext + mutate func(*sandbox.SandboxPolicy) + assert func(t *testing.T, out string) + }{ + { + name: "lockdown base", + rt: rt, + mutate: func(p *sandbox.SandboxPolicy) {}, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, denyMarker) + assert.Contains(t, out, proxyAllow) + assert.NotContains(t, out, "mDNSResponder") + assert.NotContains(t, out, blanketAllow) + }, + }, + { + name: "allow_direct_dns reopens mDNSResponder", + rt: rt, + mutate: func(p *sandbox.SandboxPolicy) { p.AllowDirectDNS = utils.PtrTo(true) }, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, dnsAllow) + }, + }, + { + name: "allow_network_bind rules come after the lockdown deny", + rt: rt, + mutate: func(p *sandbox.SandboxPolicy) { p.AllowNetworkBind = utils.PtrTo(true) }, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, denyMarker) + assert.Contains(t, out, bindRule) + denyIdx := strings.Index(out, denyMarker) + bindIdx := strings.Index(out, bindRule) + require.GreaterOrEqual(t, denyIdx, 0) + assert.Greater(t, bindIdx, denyIdx, "bind rules must come after the lockdown deny (SBPL last-match-wins)") + }, + }, + { + name: "render without proxy context stays deny-only and documents the runtime allow", + rt: nil, + mutate: func(p *sandbox.SandboxPolicy) {}, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, denyMarker) + assert.Contains(t, out, "Rendered without a running PMG proxy") + // The runtime allow is documented in an SBPL comment; assert no + // actual (non-comment) allow rule is emitted. + assert.NotContains(t, out, "\n(allow network-outbound (remote ip") + }, + }, + { + name: "lockdown off keeps blanket allow", + rt: rt, + mutate: func(p *sandbox.SandboxPolicy) { p.NetworkViaProxyOnly = nil }, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, blanketAllow) + assert.NotContains(t, out, denyMarker) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := basePolicy() + tt.mutate(policy) + + translator := newSeatbeltPolicyTranslator() + out, err := translator.translate(policy, tt.rt) + require.NoError(t, err) + + tt.assert(t, out) + }) + } +}