mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat(sandbox): Seatbelt lockdown translation — deny-all outbound, allow loopback proxy port (#372)
* feat(sandbox): Seatbelt lockdown translation confines outbound to the PMG proxy Under network_via_proxy_only the Seatbelt profile now denies all network-outbound (with a target=direct violation marker) and allows only the loopback proxy port. SBPL is last-match-wins, so the broad deny is emitted first, specific allows after, and the allow_network_bind rules last — keeping loopback-to-loopback dev traffic working under lockdown. allow_direct_dns re-opens the /var/run/mDNSResponder unix socket that the deny otherwise covers. Replaces the temporary fail-closed rejection in the Seatbelt driver with the real translation; non-lockdown profiles translate byte-identically to before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS * test(sandbox): assert deny marker presence before ordering comparison Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqMU5GNBbQvQct9nxek1VS * feat(sandbox): render lockdown profiles without a proxy as deny-only with runtime note pmg sandbox profile show renders profiles for debugging and must not fail on lockdown profiles. Without a running proxy the translator keeps the broad deny (rendered profile stays fail-closed, never looser than runtime) and documents the runtime-only proxy-port allow in an SBPL comment instead of fabricating a port. Execution is unaffected: the driver validates the proxy address before translating. 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
5131c3f641
commit
3ac83a436d
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:<pmg-proxy-port>\"))\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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user