sudo: count any sudo rule as root, fail on what cannot be judged, and commit no binary
The review of 2026-10-09: an unknown account and sudo that could not be asked were answered not root, and only NOPASSWD on ALL counted, though NOPASSWD on systemctl, tee, bash or pacman reaches root as surely, and a password the person typed is kept by sudo for minutes. Any rule now counts, as the node-engine's own judge of an agent account does (ADR 0266); what cannot be judged fails the answer. The bundle built in the module's directory is removed and ignored.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# The bundle built here (manifest build.artifacts[0].binary): never committed.
|
||||
/sudo-tools
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user