From 5e0670266e1495e3b6313ae4b749b601c8b93c22 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Fri, 10 Jul 2026 23:01:36 +0530 Subject: [PATCH] test(sandbox): darwin end-to-end lockdown enforcement test (#374) --- .../lockdown_integration_darwin_test.go | 245 ++++++++++++++++++ .../platform/seatbelt_translator_darwin.go | 17 +- .../seatbelt_translator_darwin_test.go | 14 +- 3 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 sandbox/platform/lockdown_integration_darwin_test.go diff --git a/sandbox/platform/lockdown_integration_darwin_test.go b/sandbox/platform/lockdown_integration_darwin_test.go new file mode 100644 index 0000000..e79a6d6 --- /dev/null +++ b/sandbox/platform/lockdown_integration_darwin_test.go @@ -0,0 +1,245 @@ +//go:build darwin + +package platform + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + lockdownHelperEnv = "PMG_LOCKDOWN_HELPER" + lockdownSandboxExecPath = "/usr/bin/sandbox-exec" +) + +// TestLockdownHelperProcess is not a test: it is the child process re-executed +// under sandbox-exec by the lockdown integration test below. It performs the +// network checks described by its environment and exits 0 only when every +// check agrees with its expectation. +func TestLockdownHelperProcess(t *testing.T) { + if os.Getenv(lockdownHelperEnv) != "1" { + t.Skip("helper process only") + } + + failed := false + report := func(name string, ok bool, err error) { + fmt.Printf("check %s: ok=%v err=%v\n", name, ok, err) + if !ok { + failed = true + } + } + + if addr := os.Getenv("PMG_DIAL_MUST_PASS"); addr != "" { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + report("dial-must-pass "+addr, err == nil, err) + if conn != nil { + _ = conn.Close() + } + } + + if addr := os.Getenv("PMG_DIAL_MUST_FAIL"); addr != "" { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + report("dial-must-fail "+addr, err != nil, err) + if conn != nil { + _ = conn.Close() + } + } + + if host := os.Getenv("PMG_RESOLVE"); host != "" { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := net.DefaultResolver.LookupHost(ctx, host) + wantPass := os.Getenv("PMG_RESOLVE_MUST_PASS") == "1" + report(fmt.Sprintf("resolve %s (wantPass=%v)", host, wantPass), (err == nil) == wantPass, err) + + // Informational probes to localize where resolution is blocked; they + // do not affect the pass/fail outcome. + goResolver := &net.Resolver{PreferGo: true} + _, goErr := goResolver.LookupHost(ctx, host) + fmt.Printf("probe prefergo-resolve %s: err=%v\n", host, goErr) + + conn, dialErr := net.DialTimeout("udp", "1.1.1.1:53", 2*time.Second) + if dialErr != nil { + fmt.Printf("probe udp53-dial: err=%v\n", dialErr) + } else { + _, writeErr := conn.Write([]byte{0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01}) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 512) + _, readErr := conn.Read(buf) + fmt.Printf("probe udp53 write err=%v read err=%v\n", writeErr, readErr) + _ = conn.Close() + } + } + + if os.Getenv("PMG_SELF_DIAL") == "1" { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + report("self-dial listen", false, err) + } else { + go func() { + conn, aerr := ln.Accept() + if aerr == nil { + _ = conn.Close() + } + }() + conn, derr := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + report("self-dial "+ln.Addr().String(), derr == nil, derr) + if conn != nil { + _ = conn.Close() + } + _ = ln.Close() + } + } + + if failed { + t.Fatal("one or more sandboxed checks disagreed with expectations") + } +} + +func requireSandboxExec(t *testing.T) { + t.Helper() + if _, err := os.Stat(lockdownSandboxExecPath); err != nil { + // Skips must not hide security tests in CI (see ci.yml). + if os.Getenv("CI") != "" { + t.Fatal("sandbox-exec required in CI") + } + t.Skip("sandbox-exec not available") + } +} + +func mustListenLoopback(t *testing.T) net.Listener { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + return ln +} + +// lockdownTestPolicy mirrors the shipped profiles' filesystem posture +// (broad read like go.yml, temp-only writes): filesystem breadth is not +// under test here, network confinement is. +func lockdownTestPolicy(t *testing.T) *sandbox.SandboxPolicy { + t.Helper() + + return &sandbox.SandboxPolicy{ + Name: "lockdown-e2e", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/"}, + AllowWrite: []string{ + os.TempDir() + "/**", + "/tmp/**", + "/private/tmp/**", + "/dev/null", + }, + }, + NetworkViaProxyOnly: utils.PtrTo(true), + } +} + +func writeTempProfile(t *testing.T, policy *sandbox.SandboxPolicy, proxyAddr string) string { + t.Helper() + profile, err := newSeatbeltPolicyTranslator().translate(policy, + &sandbox.ExecutionContext{ProxyAddr: proxyAddr}) + require.NoError(t, err) + + f, err := os.CreateTemp("", "pmg-lockdown-e2e-*.sb") + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(f.Name()) }) + _, err = f.WriteString(profile) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name() +} + +func runSandboxedHelper(t *testing.T, profilePath string, env map[string]string) error { + t.Helper() + cmd := exec.Command(lockdownSandboxExecPath, "-f", profilePath, + os.Args[0], "-test.run", "^TestLockdownHelperProcess$", "-test.v") + cmd.Env = append(os.Environ(), lockdownHelperEnv+"=1") + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + out, err := cmd.CombinedOutput() + t.Logf("sandboxed helper output:\n%s", out) + return err +} + +func TestLockdownEnforcementDarwin(t *testing.T) { + requireSandboxExec(t) + + proxyLn := mustListenLoopback(t) + otherLn := mustListenLoopback(t) + + t.Run("base lockdown", func(t *testing.T) { + profile := writeTempProfile(t, lockdownTestPolicy(t), proxyLn.Addr().String()) + err := runSandboxedHelper(t, profile, map[string]string{ + "PMG_DIAL_MUST_PASS": proxyLn.Addr().String(), + "PMG_DIAL_MUST_FAIL": otherLn.Addr().String(), + "PMG_RESOLVE": "example.com", + }) + assert.NoError(t, err, "proxy dial must pass, direct dial and DNS must fail") + }) + + t.Run("allow_direct_dns re-opens DNS", func(t *testing.T) { + policy := lockdownTestPolicy(t) + policy.AllowDirectDNS = utils.PtrTo(true) + profile := writeTempProfile(t, policy, proxyLn.Addr().String()) + err := runSandboxedHelper(t, profile, map[string]string{ + "PMG_DIAL_MUST_PASS": proxyLn.Addr().String(), + "PMG_RESOLVE": "example.com", + "PMG_RESOLVE_MUST_PASS": "1", + }) + assert.NoError(t, err, "DNS must pass with allow_direct_dns") + }) + + t.Run("allow_direct_dns with network bind", func(t *testing.T) { + policy := lockdownTestPolicy(t) + policy.AllowDirectDNS = utils.PtrTo(true) + policy.AllowNetworkBind = utils.PtrTo(true) + profile := writeTempProfile(t, policy, proxyLn.Addr().String()) + err := runSandboxedHelper(t, profile, map[string]string{ + "PMG_RESOLVE": "example.com", + "PMG_RESOLVE_MUST_PASS": "1", + }) + assert.NoError(t, err, "DNS with allow_direct_dns and allow_network_bind") + }) + + // Gating check for the milestone: allow_network_bind's + // (allow network* (local ip "localhost:*")) rules are emitted after the + // lockdown deny and are expected to keep loopback->loopback connects + // working. If this sub-test fails, the committed fallback is to emit + // (allow network-outbound (remote ip "localhost:*")) under lockdown when + // AllowNetworkBind is set. + t.Run("allow_network_bind keeps loopback self-dial working", func(t *testing.T) { + policy := lockdownTestPolicy(t) + policy.AllowNetworkBind = utils.PtrTo(true) + profile := writeTempProfile(t, policy, proxyLn.Addr().String()) + err := runSandboxedHelper(t, profile, map[string]string{ + "PMG_DIAL_MUST_PASS": proxyLn.Addr().String(), + "PMG_SELF_DIAL": "1", + }) + assert.NoError(t, err, "loopback self-dial must pass under lockdown with allow_network_bind") + }) +} diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index be4bf03..abfe3dd 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -644,11 +644,22 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic 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. + // Bind rules cannot cover outbound connects (the local address is + // unbound at connect() time), so loopback outbound is re-opened + // explicitly for bind-enabled profiles. + if utils.SafelyGetValue(policy.AllowNetworkBind) { + sb.WriteString("(allow network-outbound (remote ip \"localhost:*\"))\n") + } + + // getaddrinfo resolves via the com.apple.dnssd.service XPC endpoint, + // legacy clients use the mDNSResponder socket (both /var and resolved + // /private/var paths — Seatbelt matches resolved paths), and + // self-resolving clients need direct port 53. if utils.SafelyGetValue(policy.AllowDirectDNS) { + sb.WriteString("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n") sb.WriteString("(allow network-outbound (remote unix-socket (path-literal \"/var/run/mDNSResponder\")))\n") + sb.WriteString("(allow network-outbound (remote unix-socket (path-literal \"/private/var/run/mDNSResponder\")))\n") + sb.WriteString("(allow network-outbound (remote ip \"*:53\"))\n") } sb.WriteString("\n") diff --git a/sandbox/platform/seatbelt_translator_darwin_test.go b/sandbox/platform/seatbelt_translator_darwin_test.go index 77fcd58..6ca4ef4 100644 --- a/sandbox/platform/seatbelt_translator_darwin_test.go +++ b/sandbox/platform/seatbelt_translator_darwin_test.go @@ -765,7 +765,11 @@ func TestTranslateNetworkLockdown(t *testing.T) { 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")))` + dnsAllowPrivate := `(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))` + dnsMachAllow := `(allow mach-lookup (global-name "com.apple.dnssd.service"))` + dnsPortAllow := `(allow network-outbound (remote ip "*:53"))` bindRule := `(allow network* (local ip "localhost:*"))` + loopbackOutboundAllow := `(allow network-outbound (remote ip "localhost:*"))` tests := []struct { name string @@ -785,11 +789,14 @@ func TestTranslateNetworkLockdown(t *testing.T) { }, }, { - name: "allow_direct_dns reopens mDNSResponder", + name: "allow_direct_dns reopens mDNSResponder and port 53", rt: rt, mutate: func(p *sandbox.SandboxPolicy) { p.AllowDirectDNS = utils.PtrTo(true) }, assert: func(t *testing.T, out string) { assert.Contains(t, out, dnsAllow) + assert.Contains(t, out, dnsAllowPrivate) + assert.Contains(t, out, dnsMachAllow) + assert.Contains(t, out, dnsPortAllow) }, }, { @@ -799,10 +806,11 @@ func TestTranslateNetworkLockdown(t *testing.T) { assert: func(t *testing.T, out string) { assert.Contains(t, out, denyMarker) assert.Contains(t, out, bindRule) + assert.Contains(t, out, loopbackOutboundAllow) 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)") + assert.Greater(t, strings.Index(out, bindRule), denyIdx, "bind rules must come after the lockdown deny (SBPL last-match-wins)") + assert.Greater(t, strings.Index(out, loopbackOutboundAllow), denyIdx, "loopback outbound allow must come after the lockdown deny") }, }, {