A hard link swapped in for ~/.claude would have had root chown another account's file; fstat on the descriptor now refuses a second link, a fifo or an unexpected kind before anything is changed (hq ADR 0266, the re-review). The judge also finds polkit rules for every account, a runtime's API on TCP, setgid-to-root programs whoever owns them, setuid programs on every suid filesystem, and unprotected links; the rest is listed as not judged.
228 lines
9.6 KiB
Go
228 lines
9.6 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 args[0] != "/" || args[1] != "/srv" || args[2] != "-xdev" {
|
|
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...)
|
|
}, 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 TestTheSetuidSearchIsKeptForItsInterval(t *testing.T) {
|
|
c := &SetuidCache{Every: time.Hour}
|
|
searched := 0
|
|
search := func() ([]string, error) { searched++; return nil, nil }
|
|
at := time.Date(2026, 10, 8, 19, 0, 0, 0, time.UTC)
|
|
c.get(at, search)
|
|
c.get(at.Add(30*time.Minute), search)
|
|
c.get(at.Add(61*time.Minute), search)
|
|
if searched != 2 {
|
|
t.Fatalf("searched %d times in 61 minutes, want 2", searched)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|