diff --git a/modules/sudo/.gitignore b/modules/sudo/.gitignore new file mode 100644 index 00000000..3b1a6054 --- /dev/null +++ b/modules/sudo/.gitignore @@ -0,0 +1,2 @@ +# The bundle built here (manifest build.artifacts[0].binary): never committed. +/sudo-tools diff --git a/modules/sudo/cmd/sudo-tools/escalation.go b/modules/sudo/cmd/sudo-tools/escalation.go index 6bbd01be..dc8a1734 100644 --- a/modules/sudo/cmd/sudo-tools/escalation.go +++ b/modules/sudo/cmd/sudo-tools/escalation.go @@ -1,13 +1,23 @@ package main -// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check asks it of -// every machine where the router or a channel proving its sender runs, for the accounts agents run as and -// the account the machine's runtime runs commands as. +// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check may ask it for +// the accounts agents run as and the account the machine's runtime runs commands as. // -// Root without a person is any of: a sudo rule letting the account run every command as root with no -// password; membership of a group that is root by another name (docker, which mounts the host; disk, which -// reads it raw; wheel or sudo only where a rule grants them without a password, which the listing shows); or -// the account being root itself. +// **Conservative, as the review of 2026-10-09 asked.** An account can become root without a person when: +// - it is root; +// - it is a member of a group that is root by another name (docker, disk, root, lxd, incus-admin, libvirt); +// - **sudo lists any rule for it at all** — with NOPASSWD or without, for every command or for one. A named +// command is root when it can start a shell or write a file (systemctl, tee, bash, pacman, an editor, …), +// and no list of safe commands is kept: none is proven safe here. A rule that asks a password is root too, +// because sudo keeps a password typed by the person for minutes, during which a program of the same +// account needs none. This is the node-engine's own rule for an agent account (ADR 0266: "any sudo rule"). +// +// **What cannot be judged is an error, never a no**: an account this machine does not know, sudo that could not +// be asked, a listing that cannot be read. The caller says it was not measured. +// +// **Measured as the runtime's account**, which these tools run as: an agent that could act as that account +// could change this answer, so the controller takes the agent account's verdict from the node-engine, which +// runs as root (ADR 0266), and this tool is a reading for a person, not that verdict. import ( "fmt" @@ -24,11 +34,14 @@ type Escalation struct { // rootGroups are groups that are root by another name. var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system", - "disk": "the disk group, which reads and writes the drives raw", "root": "the root group"} + "disk": "the disk group, which reads and writes the drives raw", "root": "the root group", + "lxd": "the lxd group, which runs privileged containers", "incus-admin": "the incus-admin group, which runs privileged containers", + "libvirt": "the libvirt group, which runs machines with the host's disks"} var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) -// Escalations answers, for each account, whether it can become root here without a person. +// Escalations answers, for each account, whether it can become root here without a person. An account that +// cannot be judged fails the whole answer: never a no. func (m *Machine) Escalations(accounts []string) ([]Escalation, error) { if len(accounts) == 0 { return nil, fmt.Errorf("name the accounts to judge") @@ -38,52 +51,72 @@ func (m *Machine) Escalations(accounts []string) ([]Escalation, error) { if !accountShape.MatchString(a) { return nil, fmt.Errorf("%q is not an account name", a) } - out = append(out, m.escalation(a)) + e, err := m.escalation(a) + if err != nil { + return nil, fmt.Errorf("%s cannot be judged, so nothing is said of it: %v", a, err) + } + out = append(out, e) } return out, nil } -func (m *Machine) escalation(account string) Escalation { +func (m *Machine) escalation(account string) (Escalation, error) { e := Escalation{Account: account} if account == "root" { e.Root, e.Why = true, "it is root" - return e + return e, nil } id := m.Run(bg(), "id", "-nG", account) if id.Status != 0 || id.Err != "" { - e.Why = "no such account here" - return e + return e, fmt.Errorf("this machine does not know the account: %s", strings.TrimSpace(firstLine(id.Stderr+id.Err))) } for _, g := range strings.Fields(id.Stdout) { if why, ok := rootGroups[g]; ok { e.Root, e.Why = true, "a member of "+why - return e + return e, nil } } // sudo's own word, for that account: listing another account's rules needs root, which the runtime's // account escalates to as the mesh's acting tools do. r := m.Run(bg(), "sudo", "-n", "-l", "-U", account) - if r.Status != 0 || r.Err != "" { - if strings.Contains(r.Stdout+r.Stderr, "not allowed to run sudo") { - e.Why = "no sudo rule names it" - return e - } - e.Why = "sudo could not be asked: " + strings.TrimSpace(firstLine(r.Stderr+r.Err)) - return e + listing := r.Stdout + r.Stderr + if strings.Contains(listing, "is not allowed to run sudo") { + e.Why = "no sudo rule names it, and no group it is in is root by another name" + return e, nil } - if strings.Contains(r.Stdout, "is not allowed to run sudo") { - e.Why = "no sudo rule names it" - return e + if r.Status != 0 || r.Err != "" { + return e, fmt.Errorf("sudo could not be asked: %s", strings.TrimSpace(firstLine(r.Stderr+r.Err))) } rules := ParseList(r.Stdout, account) - if rules.PasswordlessAll { - for _, rule := range rules.Rules { - if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) { - e.Root, e.Why = true, "sudo lets it run every command as root without a password ("+rule.Line+")" - return e - } + if !mayRun.MatchString(firstMayRun(r.Stdout)) { + return e, fmt.Errorf("sudo's listing could not be read") + } + if len(rules.Rules) == 0 { + e.Why = "sudo lists no rule for it, and no group it is in is root by another name" + return e, nil + } + var lines []string + for _, rule := range rules.Rules { + lines = append(lines, rule.Line) + } + how := "a password the person typed is kept by sudo for minutes" + for _, rule := range rules.Rules { + if hasTag(rule.Tags, "NOPASSWD") { + how = "with no password" + break } } - e.Why = "sudo asks it for a password, or lets it run named commands only" - return e + e.Root, e.Why = true, "sudo has a rule for it ("+strings.Join(lines, "; ")+"), "+how+ + ": any rule counts, since no command is proven unable to reach root" + return e, nil +} + +// firstMayRun is the listing's "User … may run the following commands on …:" line, or "". +func firstMayRun(out string) string { + for _, l := range strings.Split(out, "\n") { + if l = strings.TrimSpace(l); mayRun.MatchString(l) { + return l + } + } + return "" } diff --git a/modules/sudo/cmd/sudo-tools/escalation_test.go b/modules/sudo/cmd/sudo-tools/escalation_test.go index d16434ee..69b89726 100644 --- a/modules/sudo/cmd/sudo-tools/escalation_test.go +++ b/modules/sudo/cmd/sudo-tools/escalation_test.go @@ -5,24 +5,39 @@ import ( "testing" ) -// novox/hq ADR 0259 §8: who can become root here without a person, as the self-check asks it. +// novox/hq ADR 0259 §8, as the review of 2026-10-09 asked: any sudo rule is root without a person — NOPASSWD on +// a named command (systemctl, tee, bash, pacman) as much as on all of them, and a rule asking a password too — +// and what cannot be judged is an error, never a no. func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) { var calls []call + listing := func(account, rule string) Ran { + return Ran{Stdout: "User " + account + " may run the following commands on anchor:\n " + rule + "\n"} + } run := byLine(map[string]Ran{ - "id -nG ops": {Stdout: "ops wheel\n"}, - "sudo -n -l -U ops": {Stdout: "User ops may run the following commands on anchor:\n (ALL : ALL) NOPASSWD: ALL\n"}, - "id -nG agents": {Stdout: "agents\n"}, - "sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n"}, - "id -nG builder": {Stdout: "builder docker\n"}, - "id -nG careful": {Stdout: "careful wheel\n"}, - "sudo -n -l -U careful": {Stdout: "User careful may run the following commands on anchor:\n (ALL : ALL) ALL\n"}, - "id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"}, + "id -nG ops": {Stdout: "ops wheel\n"}, + "sudo -n -l -U ops": listing("ops", "(ALL : ALL) NOPASSWD: ALL"), + "id -nG agents": {Stdout: "agents\n"}, + "sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n", Status: 1}, + "id -nG builder": {Stdout: "builder docker\n"}, + "id -nG careful": {Stdout: "careful wheel\n"}, + "sudo -n -l -U careful": listing("careful", "(ALL : ALL) ALL"), + "id -nG units": {Stdout: "units\n"}, + "sudo -n -l -U units": listing("units", "(root) NOPASSWD: /usr/bin/systemctl"), + "id -nG writer": {Stdout: "writer\n"}, + "sudo -n -l -U writer": listing("writer", "(root) NOPASSWD: /usr/bin/tee /etc/hosts"), + "id -nG packager": {Stdout: "packager\n"}, + "sudo -n -l -U packager": listing("packager", "(root) NOPASSWD: /usr/bin/pacman -Syu"), + "id -nG asother": {Stdout: "asother\n"}, + "sudo -n -l -U asother": listing("asother", "(ops) NOPASSWD: /bin/bash"), + "id -nG containers": {Stdout: "containers lxd\n"}, }, &calls) - got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "ghost", "root"}) + got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "units", "writer", + "packager", "asother", "containers", "root"}) if err != nil { t.Fatal(err) } - want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": false, "ghost": false, "root": true} + want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": true, "units": true, "writer": true, + "packager": true, "asother": true, "containers": true, "root": true} for _, e := range got { if e.Root != want[e.Account] || e.Why == "" { t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account]) @@ -32,3 +47,24 @@ func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) { t.Errorf("an account name that is not one was asked: %v", err) } } + +// What cannot be judged is never a no: an unknown account, sudo that cannot be asked, a listing that cannot be +// read — each fails the whole answer. +func TestWhatCannotBeJudgedIsAnError(t *testing.T) { + var calls []call + run := byLine(map[string]Ran{ + "id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"}, + "id -nG agents": {Stdout: "agents\n"}, + "sudo -n -l -U agents": {Status: 1, Stderr: "sudo: a password is required"}, + "id -nG garbled": {Stdout: "garbled\n"}, + "sudo -n -l -U garbled": {Stdout: "something else entirely\n"}, + "id -nG fine": {Stdout: "fine\n"}, + "sudo -n -l -U fine": {Stdout: "User fine is not allowed to run sudo on anchor.\n", Status: 1}, + }, &calls) + for _, a := range []string{"ghost", "agents", "garbled"} { + got, err := machine(run, 1000).Escalations([]string{"fine", a}) + if err == nil || !strings.Contains(err.Error(), a+" cannot be judged") { + t.Errorf("%s: answered %+v (%v), want an error", a, got, err) + } + } +} diff --git a/modules/sudo/cmd/sudo-tools/main.go b/modules/sudo/cmd/sudo-tools/main.go index 42b79a6a..0c135774 100644 --- a/modules/sudo/cmd/sudo-tools/main.go +++ b/modules/sudo/cmd/sudo-tools/main.go @@ -46,10 +46,11 @@ func tools(m *Machine) []stdio.Tool { }, { Name: "sudo_escalation", - Description: "Can these accounts become root on this machine without a person: a sudo rule running every " + - "command as root with no password, or a group that is root by another name (docker, disk). Each " + - "account with yes or no and why. Asked by the self-check where the router or a channel proving its " + - "sender runs (novox/hq ADR 0259).", + Description: "Can these accounts become root on this machine without a person: any sudo rule for it " + + "(with or without a password, for any command), or a group that is root by another name (docker, disk, " + + "root, lxd, incus-admin, libvirt). Each account with yes or no and why; an account that cannot be " + + "judged fails the answer, never a no. Read as the runtime's account: a reading for a person — the " + + "agent account's verdict is the node-engine's (novox/hq ADR 0259 §8, ADR 0266).", Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the accounts to judge"}}), Run: func(a map[string]any) (any, error) { diff --git a/modules/sudo/sudo-tools b/modules/sudo/sudo-tools deleted file mode 100755 index 60c4fb4f..00000000 Binary files a/modules/sudo/sudo-tools and /dev/null differ