The two finds on the control-node sit in the operator's home of mode 0700, which the agent account cannot search. The search keeps every find; each judgement reads the modes on the way again, since an account can open its own home without root. A find in a home is said by count, its path in the journal alone. Also from review: a checkpoint racing SearchNow forgets the walk it just kept, and a walk kept by an older engine says its counts as lower bounds.
306 lines
14 KiB
Go
306 lines
14 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, which agent can execute: /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, which agent can execute: /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, Lstat: m.lstat})
|
|
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() {}, nil)
|
|
}
|
|
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")
|
|
}
|
|
}
|