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.
290 lines
9.7 KiB
Go
290 lines
9.7 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// An account declared never to become root without a person (novox/hq ADR 0266, "how it is checked"): each
|
|
// way to root is found and said, none is healthy whether or not anybody is logged in, an unanswered question
|
|
// is unknown, and the judge only reads.
|
|
|
|
// agentMachine is a fake machine for the escalation question: the account's uid, its groups by name and
|
|
// number, what sudo lists, and the secrets' owners and modes.
|
|
type agentMachine struct {
|
|
uid string
|
|
groups string
|
|
gids string
|
|
sudo string
|
|
sudoErr error
|
|
files map[string]FileMode
|
|
failsID bool
|
|
asked []string
|
|
// texts are files' contents (doas's, polkit's); dirs a directory's files by name; acls a file's ACL;
|
|
// setuid what the walk finds, one per line, and packaged the paths a package owns.
|
|
texts map[string]string
|
|
dirs map[string][]string
|
|
acls map[string][]ACLEntry
|
|
setuid string
|
|
findErr error
|
|
packaged map[string]bool
|
|
// proc is /proc's files, aSafeProc unless a test changes them; findArgs sees the filesystems walked.
|
|
proc map[string]string
|
|
findArgs func([]string)
|
|
// pacmanCalls counts the package manager's calls: one for every path found.
|
|
pacmanCalls int
|
|
}
|
|
|
|
func (m *agentMachine) readFile(path string) ([]byte, error) {
|
|
if t, ok := m.texts[path]; ok {
|
|
return []byte(t), nil
|
|
}
|
|
if t, ok := m.proc[path]; ok {
|
|
return []byte(t), nil
|
|
}
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
// aSafeProc is what a machine with nothing to say has in /proc: links protected, nothing on the runtimes'
|
|
// ports, one root filesystem.
|
|
func aSafeProc() map[string]string {
|
|
return map[string]string{
|
|
"/proc/sys/fs/protected_hardlinks": "1\n",
|
|
"/proc/sys/fs/protected_symlinks": "1\n",
|
|
"/proc/net/tcp": " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" +
|
|
" 0: 0100007F:1092 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1\n",
|
|
"/proc/mounts": "/dev/sda2 / ext4 rw,relatime 0 0\nproc /proc proc rw,nosuid 0 0\ntmpfs /tmp tmpfs rw,nosuid 0 0\n",
|
|
}
|
|
}
|
|
|
|
func (m *agentMachine) readDir(path string) ([]fs.DirEntry, error) {
|
|
names, ok := m.dirs[path]
|
|
if !ok {
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
var out []fs.DirEntry
|
|
for _, n := range names {
|
|
out = append(out, fakeEntry(n))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type fakeEntry string
|
|
|
|
func (f fakeEntry) Name() string { return string(f) }
|
|
func (f fakeEntry) IsDir() bool { return false }
|
|
func (f fakeEntry) Type() fs.FileMode { return 0 }
|
|
func (f fakeEntry) Info() (fs.FileInfo, error) { return nil, fs.ErrNotExist }
|
|
|
|
func (m *agentMachine) acl(path string) ([]ACLEntry, error) {
|
|
if _, ok := m.files[path]; !ok {
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
return m.acls[path], nil
|
|
}
|
|
|
|
// walk is the walk for setuid programs on this machine: m.setuid found, then m.findErr.
|
|
func (m *agentMachine) walk(_ context.Context, p *SetuidProgress, _ func()) error {
|
|
m.asked = append(m.asked, "walk "+strings.Join(p.Mounts, " "))
|
|
if m.findArgs != nil {
|
|
m.findArgs(p.Mounts)
|
|
}
|
|
for _, l := range strings.Split(m.setuid, "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
p.Found = append(p.Found, l)
|
|
}
|
|
}
|
|
return m.findErr
|
|
}
|
|
|
|
func (m *agentMachine) run(_ context.Context, name string, args ...string) (string, error) {
|
|
line := name + " " + strings.Join(args, " ")
|
|
m.asked = append(m.asked, line)
|
|
switch {
|
|
case m.failsID && name == "id":
|
|
return "", errors.New("id exited 1: no such user")
|
|
case line == "id -u agent":
|
|
return m.uid + "\n", nil
|
|
case line == "id -nG agent":
|
|
return m.groups + "\n", nil
|
|
case line == "id -G agent":
|
|
return m.gids + "\n", nil
|
|
case line == "sudo -l -U agent":
|
|
return m.sudo, m.sudoErr
|
|
case name == "pacman" && len(args) >= 2 && args[0] == "-Qo":
|
|
m.pacmanCalls++
|
|
var out, errs []string
|
|
for _, p := range args[1:] {
|
|
if m.packaged[p] {
|
|
out = append(out, p+" is owned by somepackage 1.0-1")
|
|
} else {
|
|
errs = append(errs, "error: No package owns "+p)
|
|
}
|
|
}
|
|
said := strings.Join(out, "\n")
|
|
if len(errs) > 0 {
|
|
return said, errors.New("pacman exited 1: " + strings.Join(errs, "\n"))
|
|
}
|
|
return said, nil
|
|
}
|
|
return "", errors.New("not a command the judge may run: " + line)
|
|
}
|
|
|
|
func (m *agentMachine) stat(path string) (FileMode, error) {
|
|
if f, ok := m.files[path]; ok {
|
|
return f, nil
|
|
}
|
|
return FileMode{}, fs.ErrNotExist
|
|
}
|
|
|
|
const notAllowed = "User agent is not allowed to run sudo on box.\n"
|
|
|
|
var agent = Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true,
|
|
Secrets: []string{"/var/lib/mesh/node-tools/broker"}}
|
|
|
|
func clean() *agentMachine {
|
|
return &agentMachine{uid: "1600", groups: "agent", gids: "1600", sudo: notAllowed,
|
|
files: map[string]FileMode{"/var/lib/mesh/node-tools/broker": {UID: 1500, GID: 1500, Perm: 0o600}},
|
|
proc: aSafeProc()}
|
|
}
|
|
|
|
func lookAgent(t *testing.T, m *agentMachine) Verdict {
|
|
t.Helper()
|
|
j := New(Exec{Run: m.run, Walk: m.walk, Stat: m.stat, ReadFile: m.readFile, ReadDir: m.readDir, ACL: m.acl})
|
|
j.Set([]Account{agent})
|
|
st, _ := j.Look(t.Context())
|
|
if len(st.Accounts) != 1 {
|
|
t.Fatalf("the statement: %+v", st)
|
|
}
|
|
for _, a := range m.asked {
|
|
if !strings.HasPrefix(a, "id ") && a != "sudo -l -U agent" && !strings.HasPrefix(a, "walk /") &&
|
|
!strings.HasPrefix(a, "pacman -Qo ") {
|
|
t.Errorf("the judge asked something that is not a read: %q", a)
|
|
}
|
|
}
|
|
return st.Accounts[0]
|
|
}
|
|
|
|
func TestAnAgentAccountWithNoWayToRootIsHealthyWithNobodyLoggedIn(t *testing.T) {
|
|
if v := lookAgent(t, clean()); v.State != Healthy || v.Reason != "" || !v.Root {
|
|
t.Fatalf("no way to root: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestEachWayToRootIsSaid(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
set func(*agentMachine)
|
|
said string
|
|
}{
|
|
{"uid 0", func(m *agentMachine) { m.uid = "0" }, "its uid is 0"},
|
|
{"docker", func(m *agentMachine) { m.groups = "agent docker" }, "in the group docker, which grants root"},
|
|
{"wheel", func(m *agentMachine) { m.groups = "agent wheel" }, "in the group wheel, which grants root"},
|
|
{"sudo", func(m *agentMachine) {
|
|
m.sudo = "Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) NOPASSWD: ALL\n"
|
|
}, "sudo grants it: (ALL) NOPASSWD: ALL"},
|
|
{"secret by others", func(m *agentMachine) {
|
|
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o644}
|
|
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
|
|
{"secret by group", func(m *agentMachine) {
|
|
m.gids = "1600 1500"
|
|
m.files["/var/lib/mesh/node-tools/broker"] = FileMode{UID: 1500, GID: 1500, Perm: 0o640}
|
|
}, "it can read the secret /var/lib/mesh/node-tools/broker"},
|
|
} {
|
|
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 TestEveryWayIsSaidAtOnce(t *testing.T) {
|
|
m := clean()
|
|
m.groups = "agent docker"
|
|
m.sudo = "User agent may run the following commands on box:\n (ALL) ALL\n"
|
|
v := lookAgent(t, m)
|
|
if !strings.Contains(v.Reason, "docker") || !strings.Contains(v.Reason, "(ALL) ALL") {
|
|
t.Fatalf("both ways: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestAnUnansweredQuestionIsUnknownNeverHealthy(t *testing.T) {
|
|
m := clean()
|
|
m.failsID = true
|
|
if v := lookAgent(t, m); v.State != Unknown {
|
|
t.Fatalf("an unread database: %+v", v)
|
|
}
|
|
m = clean()
|
|
m.sudo = "something sudo never says\n"
|
|
if v := lookAgent(t, m); v.State != Unknown {
|
|
t.Fatalf("an unread sudo: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestASecretNotThereYetIsNoWay(t *testing.T) {
|
|
m := clean()
|
|
delete(m.files, "/var/lib/mesh/node-tools/broker")
|
|
if v := lookAgent(t, m); v.State != Healthy {
|
|
t.Fatalf("no secret placed: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestSudoRules(t *testing.T) {
|
|
none := []struct {
|
|
out string
|
|
err error
|
|
}{
|
|
{notAllowed, nil},
|
|
{notAllowed, errors.New("sudo exited 1: ")},
|
|
{"", fmt.Errorf("sudo: %w", exec.ErrNotFound)},
|
|
}
|
|
for _, c := range none {
|
|
if rules, err := SudoRules(c.out, c.err); err != nil || len(rules) != 0 {
|
|
t.Errorf("%q, %v: rules %v, err %v", c.out, c.err, rules, err)
|
|
}
|
|
}
|
|
rules, err := SudoRules("Matching Defaults entries for agent on box:\n env_reset\n\nUser agent may run the following commands on box:\n (ALL) /usr/bin/systemctl\n (root) NOPASSWD: /usr/bin/true\n", nil)
|
|
if err != nil || len(rules) != 2 || rules[0] != "(ALL) /usr/bin/systemctl" {
|
|
t.Fatalf("two rules: %v, %v", rules, err)
|
|
}
|
|
if _, err := SudoRules("", errors.New("sudo exited 1: sudo: unable to resolve host")); err == nil {
|
|
t.Error("a failing sudo that said neither was read as no rules")
|
|
}
|
|
}
|
|
|
|
func TestReadable(t *testing.T) {
|
|
m := FileMode{UID: 1500, GID: 1500, Perm: 0o600}
|
|
if Readable(m, 1600, map[int]bool{1600: true}) || !Readable(m, 1500, nil) || !Readable(m, 0, nil) {
|
|
t.Fatal("owner bits")
|
|
}
|
|
}
|
|
|
|
func TestOfJudgesARootNeverAccountWithNoGroupsAndItsSecrets(t *testing.T) {
|
|
d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
|
|
{"id":"claude-code.agent","type":"user","name":"agent","root":"never"},
|
|
{"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"},
|
|
{"id":"node-tools.need-broker","type":"file","path":"/var/lib/mesh/broker","sealed":"x","owner":"operator"},
|
|
{"id":"claude-code.own","type":"file","path":"/home/agent/own","sealed":"x","owner":"agent"},
|
|
{"id":"claude-code.plain","type":"file","path":"/etc/plain","content":"x"}
|
|
]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := Of(d, nil)
|
|
if len(got) != 1 || got[0].ID != "claude-code.agent" || !got[0].Root ||
|
|
len(got[0].Secrets) != 1 || got[0].Secrets[0] != "/var/lib/mesh/broker" {
|
|
t.Fatalf("judged: %+v", got)
|
|
}
|
|
}
|