An account could replace ~/.claude with a link to /etc and have the node-engine chown, chmod or write through it on the next apply. Below a person's or an agent's home every component is now opened without following a link, and a link refuses the resource in words. The root judge also reads doas and polkit rules, the container runtimes' sockets with their ACLs, ACLs on the secrets, and setuid-root programs no package owns, in the C locale; Judged and NotJudged write down exactly what it covers (hq ADR 0266 review).
247 lines
8.1 KiB
Go
247 lines
8.1 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 find prints, 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
|
|
}
|
|
|
|
func (m *agentMachine) readFile(path string) ([]byte, error) {
|
|
if t, ok := m.texts[path]; ok {
|
|
return []byte(t), nil
|
|
}
|
|
return nil, fs.ErrNotExist
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 == "find":
|
|
return m.setuid, m.findErr
|
|
case name == "pacman" && len(args) == 2 && args[0] == "-Qqo":
|
|
if m.packaged[args[1]] {
|
|
return "somepackage\n", nil
|
|
}
|
|
return "", errors.New("pacman exited 1: error: No package owns " + args[1])
|
|
}
|
|
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}}}
|
|
}
|
|
|
|
func lookAgent(t *testing.T, m *agentMachine) Verdict {
|
|
t.Helper()
|
|
j := New(Exec{Run: m.run, 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, "find / -xdev") &&
|
|
!strings.HasPrefix(a, "pacman -Qqo ") {
|
|
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)
|
|
}
|
|
}
|