A build module's sandbox needs one, and a module can require only a capability the node-engine detects (ADR 0448 section 5). The probe drops to an unprivileged uid first when run as root, since root always can.
156 lines
5.2 KiB
Go
156 lines
5.2 KiB
Go
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 "<name> exited <code>: <stderr>".
|
|
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")
|
|
}
|
|
}
|