Files
mesh-catalog/modules/sudo/cmd/sudo-tools/escalation.go
T
jschoubben f30089c288 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.
2026-10-09 17:08:17 +02:00

123 lines
4.9 KiB
Go

package main
// 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.
//
// **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"
"regexp"
"strings"
)
// Escalation is one account's answer.
type Escalation struct {
Account string `json:"account"`
Root bool `json:"root_without_a_person"`
Why string `json:"why"`
}
// 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",
"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. 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")
}
var out []Escalation
for _, a := range accounts {
if !accountShape.MatchString(a) {
return nil, fmt.Errorf("%q is not an account name", 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, error) {
e := Escalation{Account: account}
if account == "root" {
e.Root, e.Why = true, "it is root"
return e, nil
}
id := m.Run(bg(), "id", "-nG", account)
if id.Status != 0 || id.Err != "" {
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, 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)
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 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 !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.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 ""
}