diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index 604f49d..8e245d0 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -24,6 +24,11 @@ const ( CapSeat = "seat" CapPrivileged = "privileged" + // CapUserNamespaces is whether an unprivileged user can make a user namespace: what a build + // module's sandbox (bubblewrap) needs (novox/hq ADR 0448 section 5). Proven by making one, as + // an unprivileged user, never by a version string or a sysctl alone. + CapUserNamespaces = "user-namespaces" + // The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the // uplink seat's holder declares its own, so the holder for a manager the machine does not run // is refused the way any missing capability is, naming it. Active, not installed — a machine @@ -189,6 +194,7 @@ func Default(runner Runner) []Detector { seat{}, battery{}, powerMeter{}, + userNamespaces(runner), commandCapability{ name: CapContainerRuntime, command: "docker", args: []string{"info", "--format", "{{.ServerVersion}}"}, why: "asks the daemon for its version — a running daemon, not an installed client", diff --git a/internal/profile/userns.go b/internal/profile/userns.go new file mode 100644 index 0000000..b648137 --- /dev/null +++ b/internal/profile/userns.go @@ -0,0 +1,61 @@ +package profile + +import ( + "fmt" + "os" + "strings" +) + +// effectiveUID and readProcFile are replaceable so the unit tests stay off the machine. +var ( + effectiveUID = os.Geteuid + readProcFile = func(path string) (string, error) { + b, err := os.ReadFile(path) + return string(b), err + } +) + +// unprivilegedID is the uid and gid the probe drops to (nobody on every mesh node). +const unprivilegedID = "65534" + +// userNamespaces builds the detector for CapUserNamespaces: it makes a user namespace as an +// unprivileged user. The node-engine runs as root, and root can always make one, so as root the +// probe drops first with setpriv (util-linux). Go's SysProcAttr Cloneflags with a Credential +// would clone before the uid drops and prove nothing, so it is not used. +func userNamespaces(runner Runner) Detector { + command, args := "unshare", []string{"--user", "true"} + if effectiveUID() == 0 { + command = "setpriv" + args = []string{"--reuid=" + unprivilegedID, "--regid=" + unprivilegedID, "--clear-groups", "unshare", "--user", "true"} + } + read := readProcFile // bound now, so a probe cut by the timeout never reads a later replacement + return commandCapability{ + name: CapUserNamespaces, command: command, args: args, + why: "makes a user namespace as an unprivileged user — what bubblewrap needs, not a kernel setting read", + interpret: func(_ string, err error) (bool, string) { + if err == nil { + return true, "an unprivileged user can make a user namespace" + } + return false, err.Error() + userNamespaceSettings(read) + }, + runner: runner, + } +} + +// userNamespaceSettings names the two kernel settings that switch user namespaces off, when +// they can be read. It is added to the reason of an absent verdict only. +func userNamespaceSettings(read func(string) (string, error)) string { + var parts []string + for _, s := range []struct{ path, name string }{ + {"/proc/sys/kernel/unprivileged_userns_clone", "unprivileged_userns_clone"}, + {"/proc/sys/user/max_user_namespaces", "max_user_namespaces"}, + } { + if v, err := read(s.path); err == nil { + parts = append(parts, fmt.Sprintf("%s=%s", s.name, strings.TrimSpace(v))) + } + } + if len(parts) == 0 { + return "" + } + return " (" + strings.Join(parts, ", ") + ")" +} diff --git a/internal/profile/userns_test.go b/internal/profile/userns_test.go new file mode 100644 index 0000000..67a27d2 --- /dev/null +++ b/internal/profile/userns_test.go @@ -0,0 +1,155 @@ +package profile + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" +) + +// recorded is a runner that remembers every command it was given and answers the user-namespace +// probe with the given output and error. Other detectors get "not here". +type recorded struct { + mu sync.Mutex + calls [][]string + out string + err error +} + +func (r *recorded) run(_ context.Context, name string, args ...string) (string, error) { + if name != "setpriv" && name != "unshare" { + return "", errors.New("not here") + } + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, append([]string{name}, args...)) + return r.out, r.err +} + +// asUID makes the detector believe it runs as uid, and reads no files from the machine. +func asUID(t *testing.T, uid int, procs map[string]string) { + t.Helper() + oldUID, oldRead := effectiveUID, readProcFile + effectiveUID = func() int { return uid } + readProcFile = func(path string) (string, error) { + if v, ok := procs[path]; ok { + return v, nil + } + return "", errors.New("no such file") + } + t.Cleanup(func() { effectiveUID, readProcFile = oldUID, oldRead }) +} + +func verdictOf(t *testing.T, p Profile, name string) Verdict { + t.Helper() + for _, v := range p.Capabilities { + if v.Name == name { + return v + } + } + t.Fatalf("no verdict named %s in the profile", name) + return Verdict{} +} + +// unshare's own words when user namespaces are off, from util-linux's unshare.c: +// err(EXIT_FAILURE, _("unshare failed")), which prints "unshare: unshare failed: " and strerror(EPERM). +// ExecRunner wraps stderr as " exited : ". +const unshareRefused = "unshare exited 1: unshare: unshare failed: Operation not permitted" + +func TestUserNamespacesAsRootDropsToAnUnprivilegedUIDFirst(t *testing.T) { + // 1308/AC1. Root can always make a user namespace, so a probe run as root proves nothing: the + // command must drop to an unprivileged uid before unshare runs. + asUID(t, 0, nil) + r := &recorded{} + got := Detect(context.Background(), Default(r.run), time.Second) + + v := verdictOf(t, got, CapUserNamespaces) + if !v.Present { + t.Fatalf("a successful probe was reported absent: %+v", v) + } + want := "setpriv --reuid=65534 --regid=65534 --clear-groups unshare --user true" + if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != want { + t.Fatalf("the probe ran %v, want one call: %s", r.calls, want) + } +} + +func TestUserNamespacesAbsentCarriesTheCommandsWords(t *testing.T) { + // 1308/AC2. The failing probe gives absent, and the detail is what unshare said. + asUID(t, 0, nil) + r := &recorded{err: errors.New(unshareRefused)} + got := Detect(context.Background(), Default(r.run), time.Second) + + v := verdictOf(t, got, CapUserNamespaces) + if v.Present { + t.Fatal("a probe that failed was reported present") + } + if !strings.Contains(v.Detail, "unshare failed: Operation not permitted") { + t.Fatalf("the command's words were lost; detail was %q", v.Detail) + } + if strings.TrimSpace(v.How) == "" { + t.Fatal("an absent verdict with no stated method") + } +} + +func TestUserNamespacesAbsentSaysWhyFromTheKernelSettings(t *testing.T) { + // 1308/AC2, the reasons: the two kernel settings that switch user namespaces off are read + // through a replaceable function and named in the detail. + asUID(t, 0, map[string]string{ + "/proc/sys/user/max_user_namespaces": "0\n", + "/proc/sys/kernel/unprivileged_userns_clone": "0\n", + }) + r := &recorded{err: errors.New(unshareRefused)} + v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces) + + if v.Present { + t.Fatal("reported present") + } + // The detail is one line (firstLine), so the settings must be on that line. + if !strings.Contains(v.Detail, "max_user_namespaces=0") || !strings.Contains(v.Detail, "unprivileged_userns_clone=0") { + t.Fatalf("the kernel settings are not in the detail: %q", v.Detail) + } +} + +func TestUserNamespacesNotAsRootUnsharesDirectly(t *testing.T) { + // 1308/AC3. An unprivileged node-engine is already the unprivileged user: no setpriv. + asUID(t, 1000, nil) + r := &recorded{} + v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces) + + if !v.Present { + t.Fatalf("a successful probe was reported absent: %+v", v) + } + if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != "unshare --user true" { + t.Fatalf("the probe ran %v, want: unshare --user true", r.calls) + } + if strings.TrimSpace(v.Detail) == "" || strings.TrimSpace(v.How) == "" { + t.Fatalf("present verdict lacks detail or how: %+v", v) + } +} + +func TestUserNamespacesProbeThatHangsIsCutAndTheRestStillReport(t *testing.T) { + // 1308/AC4. A wedged unshare is cut by Detect's timeout; the other detectors still answer. + asUID(t, 0, nil) + blocking := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "setpriv" { + <-ctx.Done() + return "", ctx.Err() + } + return "ok", nil + } + done := make(chan Profile, 1) + go func() { done <- Detect(context.Background(), Default(blocking), 50*time.Millisecond) }() + select { + case got := <-done: + if verdictOf(t, got, CapUserNamespaces).Present { + t.Error("a probe that never answered was reported present") + } + if !got.Has(CapPackageManager) { + t.Error("the other capabilities were lost behind the wedged probe") + } + case <-time.After(5 * time.Second): + t.Fatal("detection did not return") + } +}