//go:build linux package instanceexec import ( "errors" "net" "os" "path/filepath" "runtime" "syscall" "testing" "time" ) // The probe refuses readiness for each property the hardening adds (#214 // items 1-3, #178), naming the line. func TestProbeFailsOnEachMissingHardening(t *testing.T) { cases := map[string]func(t *testing.T, e *probeEnv){ "opt-own-build": func(t *testing.T, e *probeEnv) { if err := os.MkdirAll(filepath.Join(e.optRoot, "versions"), 0o755); err != nil { t.Fatal(err) } }, "dns": func(t *testing.T, e *probeEnv) { e.sendUDP = func(string, string) error { return syscall.EPERM } }, "loopback-v4": func(t *testing.T, e *probeEnv) { e.sendUDP = func(_, addr string) error { if addr == "127.0.0.1:9" || addr == "10.0.0.2:53" { return nil } return syscall.EPERM } }, "private-v4": func(t *testing.T, e *probeEnv) { e.sendUDP = func(_, addr string) error { if addr == "172.31.255.254:9" || addr == "10.0.0.2:53" { return nil } return syscall.EPERM } }, "private-v6": func(t *testing.T, e *probeEnv) { e.sendUDP = func(_, addr string) error { if addr == "[fd00::1]:9" || addr == "10.0.0.2:53" { return nil } return syscall.EPERM } }, "ipc-private": func(t *testing.T, e *probeEnv) { e.ipcInode = func() (uint64, error) { return initIPCInode, nil } }, "abstract-unix": func(t *testing.T, e *probeEnv) { e.abstractScoped = func() error { return errAbstractReached } }, "cpu-limits": func(t *testing.T, e *probeEnv) { cg, _ := e.selfCgroup() if err := os.WriteFile(filepath.Join(cg, "cpu.max"), []byte("max 100000\n"), 0o644); err != nil { t.Fatal(err) } }, "io-weight": func(t *testing.T, e *probeEnv) { cg, _ := e.selfCgroup() if err := os.WriteFile(filepath.Join(cg, "io.weight"), []byte("default 100\n"), 0o644); err != nil { t.Fatal(err) } }, } for check, breakIt := range cases { t.Run(check, func(t *testing.T) { e := goodEnv(t) breakIt(t, &e) r := runChecks(e) if r.Ready || verdict(r, check) != "fail" { t.Errorf("%s broken yet ready=%v verdict=%s", check, r.Ready, verdict(r, check)) } }) } } // A host mqueue visible in the namespace fails ipc-private even with a // private namespace inode. func TestProbeFailsOnAVisibleHostMqueue(t *testing.T) { e := goodEnv(t) if err := os.WriteFile(filepath.Join(e.devMqueue, "q"), nil, 0o600); err != nil { t.Fatal(err) } if r := runChecks(e); r.Ready || verdict(r, "ipc-private") != "fail" { t.Errorf("host mqueue visible yet ready=%v", r.Ready) } } // A loopback resolver other than resolved's stub is refused by policy, // and the probe names it rather than passing on a reachable address. func TestProbeRefusesALoopbackResolver(t *testing.T) { e := goodEnv(t) if err := os.WriteFile(e.resolvConf, []byte("nameserver 127.0.0.1\n"), 0o644); err != nil { t.Fatal(err) } e.sendUDP = func(string, string) error { return syscall.EPERM } if r := runChecks(e); r.Ready || verdict(r, "dns") != "fail" { t.Errorf("loopback resolver yet ready=%v", r.Ready) } } // Unverified, not failed: no IPv6 on the box, and IO weights with no // controller to enforce them. func TestProbeUnverifiedLinesDoNotBlock(t *testing.T) { e := goodEnv(t) e.sendUDP = func(network, addr string) error { if addr == "10.0.0.2:53" { return nil } if network == "udp6" { return syscall.ENETUNREACH } return syscall.EPERM } e.ioEnforced = func(string) bool { return false } r := runChecks(e) if !r.Ready { t.Fatalf("not ready: %+v", r.Lines) } for _, c := range []string{"loopback-v6", "private-v6", "io-weight"} { if v := verdict(r, c); v != "unverified" { t.Errorf("%s = %s, want unverified", c, v) } } } // The real Landlock scope on this kernel (#178): a scoped thread cannot // connect to an abstract name bound outside its domain, while the // unscoped process still can. Skipped where the kernel lacks ABI 6. func TestScopeAbstractUnixRefusesAnOutsideName(t *testing.T) { name := "@carlos-scope-test-" + randomName() ln, err := net.Listen("unix", name) if err != nil { t.Skipf("abstract listen: %v", err) } defer ln.Close() go func() { for { c, err := ln.Accept() if err != nil { return } c.Close() } }() res := make(chan error, 1) go func() { runtime.LockOSThread() // never unlocked: the thread dies scoped if err := ScopeAbstractUnix(); err != nil { res <- err return } c, err := net.Dial("unix", name) if err == nil { c.Close() res <- errAbstractReached return } res <- unwrapErrno(err) }() err = <-res if err != nil && !errors.Is(err, syscall.EPERM) && !errors.Is(err, errAbstractReached) { t.Skipf("no Landlock scope here: %v", err) } if !errors.Is(err, syscall.EPERM) { t.Fatalf("scoped connect = %v, want EPERM", err) } c, err := net.Dial("unix", name) if err != nil { t.Fatalf("unscoped connect after the scoped thread: %v", err) } c.Close() if err := realAbstractCheck(); err != nil { t.Errorf("realAbstractCheck = %v", err) } } // An allowed resolver must open only a resolver (#214 item 3, review F1): // one that answers on a non-DNS port fails resolver-ports. One that is an // address of this box is the control service's refusal (resolver-local). func TestProbeResolverPortsLine(t *testing.T) { e := goodEnv(t) if r := runChecks(e); verdict(r, "resolver-ports") != "ok" { t.Fatalf("good fixture: resolver-ports = %s", verdict(r, "resolver-ports")) } e.connectTCP = func(addr string, _ time.Duration) error { if addr == "10.0.0.2:22" { return nil } return syscall.ETIMEDOUT } if r := runChecks(e); r.Ready || verdict(r, "resolver-ports") != "fail" { t.Errorf("resolver answering on 22 yet ready=%v", r.Ready) } } // resolved's stub is never a resolver an instance may use: the namespace // should have replaced it with the upstream list, and if it did not, dns // fails with the reason. func TestProbeRefusesTheResolvedStub(t *testing.T) { e := goodEnv(t) if err := os.WriteFile(e.resolvConf, []byte("nameserver 127.0.0.53\noptions edns0 trust-ad\n"), 0o644); err != nil { t.Fatal(err) } e.sendUDP = func(string, string) error { return nil } if r := runChecks(e); r.Ready || verdict(r, "dns") != "fail" { t.Errorf("stub resolver yet ready=%v", r.Ready) } }