mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
246 lines
7.4 KiB
Go
246 lines
7.4 KiB
Go
//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")
|
|
})
|
|
}
|