mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between updates, so a search that starts over at every restart never completes. The walk is the engine's own, in lexical order, so its progress is one cursor per filesystem, kept every minute beside the engine's state and carried on after a restart while younger than rootsearch.FreshFor. It runs on a thread of its own at nice 19 and the idle I/O class.
306 lines
13 KiB
Go
306 lines
13 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The ways beyond uid, groups and sudo (novox/hq ADR 0266, the review of 2026-10-08): doas, polkit, a
|
|
// runtime's socket, an ACL on a secret, a setuid program no package owns — each said; each unread question
|
|
// unknown; and the judge's commands in the C locale.
|
|
|
|
func TestEachFurtherWayToRootIsSaid(t *testing.T) {
|
|
const broker = "/var/lib/mesh/node-tools/broker"
|
|
for _, c := range []struct {
|
|
name string
|
|
set func(*agentMachine)
|
|
said string
|
|
}{
|
|
{"doas by name", func(m *agentMachine) {
|
|
m.texts = map[string]string{"/etc/doas.conf": "permit persist operator\npermit nopass agent as root\n"}
|
|
}, "doas permits it: permit nopass agent as root"},
|
|
{"doas by group", func(m *agentMachine) {
|
|
m.groups = "agent builders"
|
|
m.texts = map[string]string{"/etc/opendoas.conf": "permit :builders # the builders\n"}
|
|
}, "doas permits it: permit :builders"},
|
|
{"polkit by name", func(m *agentMachine) {
|
|
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"10-agent.rules"}}
|
|
m.texts = map[string]string{"/etc/polkit-1/rules.d/10-agent.rules": `polkit.addRule(function(a, s) { if (s.user == "agent") return polkit.Result.YES; });`}
|
|
}, "a polkit rule names it or a group of it: /etc/polkit-1/rules.d/10-agent.rules"},
|
|
{"polkit by group", func(m *agentMachine) {
|
|
m.groups = "agent network"
|
|
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-nm.rules"}}
|
|
m.texts = map[string]string{"/usr/share/polkit-1/rules.d/50-nm.rules": `if (subject.isInGroup("network")) return polkit.Result.YES;`}
|
|
}, "a polkit rule names it or a group of it: /usr/share/polkit-1/rules.d/50-nm.rules"},
|
|
{"docker socket by group bits", func(m *agentMachine) {
|
|
m.gids = "1600 970"
|
|
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
|
}, "it can write the container runtime's socket /run/docker.sock"},
|
|
{"docker socket by ACL", func(m *agentMachine) {
|
|
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
|
m.acls = map[string][]ACLEntry{"/run/docker.sock": {{User: true, ID: 1600, Read: true, Write: true}}}
|
|
}, "it can write the container runtime's socket /run/docker.sock"},
|
|
{"secret by ACL", func(m *agentMachine) {
|
|
m.acls = map[string][]ACLEntry{broker: {{User: false, ID: 1600, Read: true}}}
|
|
}, "an ACL lets it read the secret " + broker},
|
|
{"polkit for every account", func(m *agentMachine) {
|
|
m.dirs = map[string][]string{"/etc/polkit-1/rules.d": {"00-all.rules"}}
|
|
m.texts = map[string]string{"/etc/polkit-1/rules.d/00-all.rules": `polkit.addRule(function(action, subject) { return polkit.Result.YES; });`}
|
|
}, "a polkit rule grants every account: /etc/polkit-1/rules.d/00-all.rules"},
|
|
{"pkla for every account", func(m *agentMachine) {
|
|
m.dirs = map[string][]string{"/etc/polkit-1/localauthority": {"all.pkla"}}
|
|
m.texts = map[string]string{"/etc/polkit-1/localauthority/all.pkla": "[all]\nIdentity=unix-user:*\nAction=*\nResultAny=yes\n"}
|
|
}, "a polkit rule grants every account: /etc/polkit-1/localauthority/all.pkla"},
|
|
{"docker on TCP", func(m *agentMachine) {
|
|
m.proc["/proc/net/tcp6"] = " sl local_address rem_address st\n 0: 00000000000000000000000000000000:0947 00000000000000000000000000000000:0000 0A 0\n"
|
|
}, "a container runtime's API listens on TCP port 2375"},
|
|
{"hard links unprotected", func(m *agentMachine) {
|
|
m.proc["/proc/sys/fs/protected_hardlinks"] = "0\n"
|
|
}, "hard links to other accounts' files are not protected (fs.protected_hardlinks=0)"},
|
|
{"setuid on a second filesystem", func(m *agentMachine) {
|
|
m.proc["/proc/mounts"] += "/dev/sdb1 /srv xfs rw 0 0\n/dev/sdc1 /data ext4 rw,nosuid 0 0\n"
|
|
m.setuid = "/srv/bin/rootshell\n"
|
|
m.findArgs = func(args []string) {
|
|
if len(args) != 2 || args[0] != "/" || args[1] != "/srv" {
|
|
panic(fmt.Sprintf("searched %v", args))
|
|
}
|
|
}
|
|
}, "a setuid-root program no package owns: /srv/bin/rootshell"},
|
|
{"setuid no package owns", func(m *agentMachine) {
|
|
m.setuid = "/usr/bin/sudo\n/usr/local/bin/rootshell\n"
|
|
m.packaged = map[string]bool{"/usr/bin/sudo": true}
|
|
}, "a setuid-root program no package owns: /usr/local/bin/rootshell"},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
m := clean()
|
|
c.set(m)
|
|
v := lookAgent(t, m)
|
|
if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRoot+": ") || !strings.Contains(v.Reason, c.said) {
|
|
t.Fatalf("want %q said: %+v", c.said, v)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWhatGrantsSomebodyElseIsNoWay(t *testing.T) {
|
|
m := clean()
|
|
m.texts = map[string]string{"/etc/doas.conf": "permit operator\npermit :wheel\n# permit agent\n",
|
|
"/usr/share/polkit-1/rules.d/50-default.rules": `polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`}
|
|
m.dirs = map[string][]string{"/usr/share/polkit-1/rules.d": {"50-default.rules"}}
|
|
m.files["/run/docker.sock"] = FileMode{UID: 0, GID: 970, Perm: 0o660}
|
|
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n"
|
|
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
|
|
if v := lookAgent(t, m); v.State != Healthy {
|
|
t.Fatalf("the operator's and wheel's grants, a socket of another group, packaged setuid programs: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestDoasWithoutSudoIsJudgedFromItsRules(t *testing.T) {
|
|
m := clean()
|
|
m.sudo, m.sudoErr = "", fmt.Errorf("sudo: %w", exec.ErrNotFound)
|
|
m.texts = map[string]string{"/etc/doas.conf": "permit nopass agent\n"}
|
|
v := lookAgent(t, m)
|
|
if v.State != Unhealthy || !strings.Contains(v.Reason, "doas permits it") {
|
|
t.Fatalf("no sudo is no sudo rule, and doas is read for itself: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestAFurtherQuestionUnansweredIsUnknown(t *testing.T) {
|
|
m := clean()
|
|
m.findErr = errors.New("find: interrupted")
|
|
if v := lookAgent(t, m); v.State != Unknown {
|
|
t.Fatalf("a search that did not finish: %+v", v)
|
|
}
|
|
m = clean()
|
|
m.setuid = "/usr/local/bin/x\n"
|
|
j := New(Exec{Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
|
if name == "pacman" || name == "apk" {
|
|
return "", fmt.Errorf("%s: %w", name, exec.ErrNotFound)
|
|
}
|
|
return m.run(ctx, name, args...)
|
|
}, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
|
|
j.Set([]Account{agent})
|
|
if st, _ := j.Look(t.Context()); st.Accounts[0].State != Unknown {
|
|
t.Fatalf("no package manager to ask: %+v", st.Accounts[0])
|
|
}
|
|
}
|
|
|
|
func TestANotJudgedSearchStillSaysAWayFound(t *testing.T) {
|
|
j := New(fakeReader{ways: []string{"in the group docker, which grants root"}, err: &SetuidNotJudged{Pending: true, Since: time.Now()}})
|
|
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
|
|
st, _ := j.Look(context.Background())
|
|
if v := st.Accounts[0]; v.State != Unhealthy || !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, ReasonPending) {
|
|
t.Fatalf("a way found is unhealthy, with the search's state beside it: %+v", v)
|
|
}
|
|
j = New(fakeReader{err: &SetuidNotJudged{Since: time.Now(), Err: errors.New("timeout")}})
|
|
j.Set([]Account{{Module: "m", ID: "m.a", Name: "agent", Root: true}})
|
|
st, _ = j.Look(context.Background())
|
|
if v := st.Accounts[0]; v.State != Unknown || !strings.HasPrefix(v.Reason, ReasonIncomplete) {
|
|
t.Fatalf("nothing found and the search incomplete: unknown, said so: %+v", v)
|
|
}
|
|
}
|
|
|
|
type fakeReader struct {
|
|
ways []string
|
|
err error
|
|
}
|
|
|
|
func (f fakeReader) InDatabase(context.Context, string) ([]string, error) { return nil, nil }
|
|
func (f fakeReader) Session(context.Context, string, []string) (Session, error) {
|
|
return Session{}, nil
|
|
}
|
|
func (f fakeReader) Escalation(context.Context, string, []string) ([]string, error) {
|
|
return f.ways, f.err
|
|
}
|
|
|
|
func TestParseACL(t *testing.T) {
|
|
entry := func(tag, perm uint16, id uint32) []byte {
|
|
b := make([]byte, 8)
|
|
binary.LittleEndian.PutUint16(b, tag)
|
|
binary.LittleEndian.PutUint16(b[2:], perm)
|
|
binary.LittleEndian.PutUint32(b[4:], id)
|
|
return b
|
|
}
|
|
raw := []byte{2, 0, 0, 0}
|
|
raw = append(raw, entry(0x01, 6, 0xffffffff)...)
|
|
raw = append(raw, entry(0x02, 6, 1600)...) // user agent rw
|
|
raw = append(raw, entry(0x04, 4, 0xffffffff)...)
|
|
raw = append(raw, entry(0x08, 6, 970)...) // group 970 rw
|
|
raw = append(raw, entry(0x10, 4, 0xffffffff)...) // mask r--
|
|
raw = append(raw, entry(0x20, 0, 0xffffffff)...)
|
|
acl, err := ParseACL(raw)
|
|
if err != nil || len(acl) != 2 {
|
|
t.Fatalf("%v %v", acl, err)
|
|
}
|
|
if !acl[0].User || acl[0].ID != 1600 || !acl[0].Read || acl[0].Write {
|
|
t.Fatalf("the mask takes write away: %+v", acl[0])
|
|
}
|
|
if grantsByACL(acl, 1600, nil, true) || !grantsByACL(acl, 1601, map[int]bool{970: true}, false) {
|
|
t.Fatal("grants")
|
|
}
|
|
if _, err := ParseACL([]byte{2, 0, 0}); err == nil {
|
|
t.Fatal("a short ACL")
|
|
}
|
|
}
|
|
|
|
func TestTheJudgesCommandsRunInTheCLocale(t *testing.T) {
|
|
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
|
t.Setenv("LANG", "de_DE.UTF-8")
|
|
out, err := CLocale(t.Context(), "sh", "-c", `echo "$LC_ALL $LANG"`)
|
|
if err != nil || strings.TrimSpace(out) != "C C" {
|
|
t.Fatalf("%q %v", out, err)
|
|
}
|
|
if _, err := CLocale(t.Context(), "sh", "-c", "echo nope >&2; exit 3"); err == nil ||
|
|
!strings.Contains(err.Error(), "exited 3: nope") {
|
|
t.Fatalf("an exit is said with its words: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheJudgedListIsWrittenDown(t *testing.T) {
|
|
if len(Judged) < 8 || len(NotJudged) == 0 {
|
|
t.Fatal("what is judged and what is not are both written down")
|
|
}
|
|
}
|
|
|
|
func TestDistributionRulesForASeatOrForSomebodyAreNotEveryone(t *testing.T) {
|
|
for _, rule := range []string{
|
|
`polkit.addRule(function(a, s) { if (s.local && s.active) return polkit.Result.YES; });`,
|
|
`polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) return polkit.Result.YES; });`,
|
|
`polkit.addAdminRule(function(a, s) { return ["unix-group:wheel"]; });`,
|
|
} {
|
|
if PolkitGrantsEveryone("/x.rules", rule) {
|
|
t.Errorf("not every account: %s", rule)
|
|
}
|
|
}
|
|
if PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n") {
|
|
t.Error("a pkla for wheel is not every account")
|
|
}
|
|
}
|
|
|
|
func TestAMachineMissingWhatTheJudgeReadsIsUnknown(t *testing.T) {
|
|
for _, file := range []string{"/proc/sys/fs/protected_hardlinks", "/proc/net/tcp", "/proc/mounts"} {
|
|
m := clean()
|
|
delete(m.proc, file)
|
|
if v := lookAgent(t, m); v.State != Unknown {
|
|
t.Errorf("%s unread: %+v", file, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSuidMountsLeaveOutNosuidPseudoAndContainerLayers(t *testing.T) {
|
|
got := SuidMounts("/dev/sdb1 /srv xfs rw 0 0\n/dev/sda2 / ext4 rw 0 0\noverlay /var/lib/docker/overlay2/x/merged overlay rw 0 0\n" +
|
|
"/dev/sdc1 /data ext4 rw,nosuid 0 0\nproc /proc proc rw 0 0\nhost:/x /mnt/nfs nfs4 rw 0 0\n/dev/sdd1 /my\\040disk ext4 rw 0 0\n")
|
|
if strings.Join(got, "|") != "/|/my disk|/srv" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
}
|
|
|
|
// Each polkit rule is judged on its own, comments taken out (the third review of 2026-10-08).
|
|
func TestAnUnconditionalPolkitYesIsNotMaskedByAnotherRuleOrAComment(t *testing.T) {
|
|
cases := []struct {
|
|
name, text string
|
|
every bool
|
|
}{
|
|
{"another rule names a user", `polkit.addRule(function(a, s) { if (s.user == "operator") return polkit.Result.YES; });
|
|
polkit.addRule(function(a, s) { return polkit.Result.YES; });`, true},
|
|
{"a user named only in a comment", `// subject.user == "x"
|
|
polkit.addRule(function(a, s) { /* isInGroup("wheel") */ return polkit.Result.YES; });`, true},
|
|
{"the yes only in a comment", `polkit.addRule(function(a, s) { // return polkit.Result.YES;
|
|
return polkit.Result.NO; });`, false},
|
|
{"a condition in the same rule", `polkit.addRule(function(a, s) { if (s.isInGroup("wheel")) { return polkit.Result.YES; } });`, false},
|
|
{"a comment marker inside a string", `polkit.addRule(function(a, s) { var u = "http://x"; return polkit.Result.YES; });`, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := PolkitGrantsEveryone("/etc/polkit-1/rules.d/x.rules", c.text); got != c.every {
|
|
t.Errorf("%s: grants every account %v, want %v", c.name, got, c.every)
|
|
}
|
|
}
|
|
if PolkitGrantsEveryone("/x.pkla", "# [a]\n# Identity=unix-user:*\n# ResultAny=yes\n") {
|
|
t.Error("a .pkla section only in comments grants nobody")
|
|
}
|
|
if !PolkitGrantsEveryone("/x.pkla", "[a]\nIdentity=unix-group:wheel\nResultAny=yes\n[b]\nIdentity=unix-user:*\nResultActive=yes\n") {
|
|
t.Error("each .pkla section on its own")
|
|
}
|
|
}
|
|
|
|
// The package manager is asked about every path found in one call, and answers each: owned, or owned by nothing.
|
|
func TestThePackageManagerIsAskedOnceForEveryPathFound(t *testing.T) {
|
|
m := clean()
|
|
m.setuid = "/usr/bin/sudo\n/usr/bin/passwd\n/usr/local/bin/x\n/opt/y\n"
|
|
m.packaged = map[string]bool{"/usr/bin/sudo": true, "/usr/bin/passwd": true}
|
|
search := func(e Exec) ([]string, error) {
|
|
return e.setuidSearch(t.Context(), &SetuidProgress{Mounts: []string{"/"}}, func() {})
|
|
}
|
|
found, err := search(Exec{Run: m.run, Walk: m.walk})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.pacmanCalls != 1 {
|
|
t.Errorf("pacman was asked %d times for four paths", m.pacmanCalls)
|
|
}
|
|
if strings.Join(found, ",") != "/opt/y,/usr/local/bin/x" {
|
|
t.Errorf("unowned: %v", found)
|
|
}
|
|
// An error pacman says that is not "owned by nothing" is no answer.
|
|
odd := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
if name == "pacman" {
|
|
return "", errors.New("pacman exited 1: error: could not open database")
|
|
}
|
|
return m.run(ctx, name, args...)
|
|
}
|
|
if _, err := search(Exec{Run: odd, Walk: m.walk}); err == nil {
|
|
t.Error("a package manager that could not answer was read as an answer")
|
|
}
|
|
// A walk that did not complete is no answer either.
|
|
m.findErr = errors.New("could not read /mnt/data/private: permission denied")
|
|
if _, err := search(Exec{Run: m.run, Walk: m.walk}); err == nil {
|
|
t.Error("a walk that did not complete was read as an answer")
|
|
}
|
|
}
|