mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
test(sandbox): darwin end-to-end lockdown enforcement test (#374)
This commit is contained in:
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user