Files
mesh-catalog/modules/sudo/cmd/sudo-tools/sudo.go
T
jochen f015aba34a sudo: declare the operator account's passwordless escalation as a module
Three modules' tools act through `sudo -n` and nothing declared that the
account may; each machine said so in a hand-set line in /etc/sudoers. The
module owns the package and /etc/sudoers.d/10-mesh-operator (0440), checked
by visudo in its manifest test, and serves sudo_rules, sudo_check and
sudo_drop_ins from a Go bundle. lab stops declaring the sudo package, which
would collide with this module on the node that runs both (hq ADR 0207,
to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00

438 lines
13 KiB
Go

package main
// What sudo grants the operator account, and whether the escalation the mesh's tools rely on works
// (novox/hq to-be 42 Phase 1, research 027/01 "Privilege"). Before this module the grant was a line
// set by hand in /etc/sudoers on every machine — a group rule on two, the account named on two — and
// nothing declared it; the module's drop-in is the declaration, and these tools read what is in
// force, including the grants it did not write.
import (
"fmt"
"path"
"regexp"
"sort"
"strconv"
"strings"
)
// Where sudo reads its rules, and the drop-in the module writes (its manifest's `operator` file).
const (
SudoersFile = "/etc/sudoers"
DropInDir = "/etc/sudoers.d"
MeshDropIn = DropInDir + "/10-mesh-operator"
)
// Rule is one line of `sudo -l`: as whom, with which tags, which commands.
type Rule struct {
RunAs string `json:"run_as"`
Tags []string `json:"tags"`
Commands []string `json:"commands"`
Line string `json:"line"`
}
// Rules is what the account may run here, as sudo itself says.
type Rules struct {
Account string `json:"account"`
Host string `json:"host,omitempty"`
Defaults []string `json:"defaults"`
Rules []Rule `json:"rules"`
// PasswordlessAll is whether a rule lets the account run every command as root with no prompt.
PasswordlessAll bool `json:"passwordless_all"`
}
var (
mayRun = regexp.MustCompile(`^User (\S+) may run the following commands on (\S+):$`)
runAsLine = regexp.MustCompile(`^\(([^)]*)\)\s*(.*)$`)
tag = regexp.MustCompile(`^([A-Z_]+):\s*`)
allLast = regexp.MustCompile(`(^|[:\s,])ALL\s*$`)
)
// ParseList reads `sudo -n -l`.
func ParseList(out, account string) Rules {
r := Rules{Account: account, Defaults: []string{}, Rules: []Rule{}}
section := ""
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimSpace(raw)
switch {
case line == "":
continue
case strings.HasPrefix(line, "Matching Defaults entries"):
section = "defaults"
continue
case strings.HasPrefix(line, "Runas and Command-specific defaults"):
section = "other"
continue
case mayRun.MatchString(line):
m := mayRun.FindStringSubmatch(line)
r.Account, r.Host = m[1], m[2]
section = "rules"
continue
}
switch section {
case "defaults":
for _, d := range strings.Split(line, ", ") {
if d = strings.TrimSpace(d); d != "" {
r.Defaults = append(r.Defaults, d)
}
}
case "rules":
m := runAsLine.FindStringSubmatch(line)
if m == nil {
continue
}
rule := Rule{RunAs: m[1], Tags: []string{}, Line: line}
rest := m[2]
for {
t := tag.FindStringSubmatch(rest)
if t == nil {
break
}
rule.Tags = append(rule.Tags, t[1])
rest = rest[len(t[0]):]
}
for _, c := range strings.Split(rest, ",") {
if c = strings.TrimSpace(c); c != "" {
rule.Commands = append(rule.Commands, c)
}
}
r.Rules = append(r.Rules, rule)
if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) {
r.PasswordlessAll = true
}
}
}
return r
}
func runsAsRoot(runAs string) bool {
user, _, _ := strings.Cut(runAs, ":")
user = strings.TrimSpace(user)
return user == "ALL" || user == "root"
}
func hasTag(tags []string, want string) bool { return contains(tags, want) }
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
// ListRules is `sudo -n -l` for the runtime's account, parsed. sudo asking for a password to list is
// itself the answer that escalation does not work without one, and is said as an error.
func (m *Machine) ListRules() (Rules, error) {
r := m.Run(bg(), "sudo", "-n", "-l")
if r.Status != 0 || r.Err != "" {
return Rules{}, failure("sudo -l", "sudo", r)
}
return ParseList(r.Stdout, m.User), nil
}
// Grant is a line in sudo's rules that lets the account escalate.
type Grant struct {
File string `json:"file"`
Line int `json:"line"`
Text string `json:"text"`
Who string `json:"who"`
NoPasswd bool `json:"nopasswd"`
All bool `json:"all_commands"`
}
// Check is whether passwordless escalation works, and which line grants it.
type Check struct {
Account string `json:"account"`
RunsAs string `json:"runtime_user"`
Groups []string `json:"groups"`
Passwordless bool `json:"passwordless"`
Refusal string `json:"refusal,omitempty"`
// Grants are the lines naming the account, one of its groups or ALL, in the order sudo reads
// them; the last that matches a command is the one sudo applies.
Grants []Grant `json:"grants"`
DecidedBy *Grant `json:"decided_by,omitempty"`
MeshDropIn struct {
Path string `json:"path"`
Present bool `json:"present"`
Grants bool `json:"grants_the_account"`
} `json:"mesh_drop_in"`
Note string `json:"note,omitempty"`
}
// CheckEscalation answers whether `sudo -n` works for the account and which rule makes it so.
func (m *Machine) CheckEscalation() (Check, error) {
c := Check{Account: m.Account, RunsAs: m.User, Groups: []string{}, Grants: []Grant{}}
c.MeshDropIn.Path = MeshDropIn
if m.UID == 0 {
c.Passwordless = true
c.Note = "this runtime runs as root, which escalates without sudo; the grants below are the operator account's"
} else {
r := m.Run(bg(), "sudo", "-n", "true")
switch {
case r.Err == "ENOENT":
c.Refusal = "sudo is not installed on this machine"
case r.Status == 0 && r.Err == "":
c.Passwordless = true
default:
c.Refusal = firstLine(r.Stderr + "\n" + r.Stdout)
if c.Refusal == "" {
c.Refusal = fmt.Sprintf("sudo -n true failed with status %d", r.Status)
}
}
}
if out, err := m.Out("id", "-nG", m.Account); err == nil {
c.Groups = strings.Fields(out)
}
if !c.Passwordless {
// Reading the rules needs root, which is what was just refused: say so rather than read
// nothing and call it no grant.
c.Note = "sudo's rules are readable only by root, and escalation was refused; the grants are not read"
return c, nil
}
files, err := m.sudoersInOrder()
if err != nil {
return c, err
}
for _, f := range files {
for _, g := range grantsIn(f.path, f.lines, c.Account, c.Groups) {
c.Grants = append(c.Grants, g)
if f.path == MeshDropIn {
c.MeshDropIn.Grants = true
}
}
if f.path == MeshDropIn {
c.MeshDropIn.Present = true
}
}
for i := len(c.Grants) - 1; i >= 0; i-- {
if c.Grants[i].All {
g := c.Grants[i]
c.DecidedBy = &g
break
}
}
return c, nil
}
type sudoersFile struct {
path string
lines []numbered
}
type numbered struct {
n int
text string
}
// sudoersInOrder is every file sudo reads, in the order it reads them: the main file up to its
// include directive, the drop-ins in name order (skipping what sudo skips), then the rest of the
// main file.
func (m *Machine) sudoersInOrder() ([]sudoersFile, error) {
mainText, err := m.Root("cat", SudoersFile)
if err != nil {
return nil, err
}
names, err := m.dropInNames()
if err != nil {
return nil, err
}
var before, after []numbered
included := false
for _, l := range logical(mainText) {
f := strings.Fields(l.text)
if len(f) == 2 && (f[0] == "@includedir" || f[0] == "#includedir") && strings.TrimRight(f[1], "/") == DropInDir {
included = true
continue
}
if included {
after = append(after, l)
} else {
before = append(before, l)
}
}
files := []sudoersFile{{SudoersFile, before}}
if included {
for _, n := range names {
if !ReadBySudo(n) {
continue
}
p := path.Join(DropInDir, n)
body, err := m.Root("cat", p)
if err != nil {
return nil, err
}
files = append(files, sudoersFile{p, logical(body)})
}
}
if len(after) > 0 {
files = append(files, sudoersFile{SudoersFile, after})
}
return files, nil
}
func (m *Machine) dropInNames() ([]string, error) {
out, err := m.Root("find", DropInDir, "-mindepth", "1", "-maxdepth", "1", "-type", "f", "-printf", "%f\n")
if err != nil {
return nil, err
}
names := lines(out)
sort.Strings(names)
return names, nil
}
// ReadBySudo is whether sudo reads a file of its drop-in directory by its name: one holding a dot
// or ending in ~ is skipped, so that an editor's backup or a package's .pacnew is never a rule.
func ReadBySudo(name string) bool {
return !strings.Contains(name, ".") && !strings.HasSuffix(name, "~")
}
// logical is a sudoers file's lines with continuations joined and comments dropped; a `#include`
// is a directive, not a comment, and is kept.
func logical(text string) []numbered {
var out []numbered
var pending strings.Builder
start := 0
for i, raw := range strings.Split(text, "\n") {
line := strings.TrimRight(raw, "\r")
if pending.Len() == 0 {
start = i + 1
}
if strings.HasSuffix(line, "\\") {
pending.WriteString(strings.TrimSuffix(line, "\\"))
pending.WriteString(" ")
continue
}
pending.WriteString(line)
l := strings.TrimSpace(pending.String())
pending.Reset()
if l == "" || (strings.HasPrefix(l, "#") && !strings.HasPrefix(l, "#include")) {
continue
}
out = append(out, numbered{start, l})
}
return out
}
// grantsIn is each user rule naming the account, one of its groups, or ALL.
func grantsIn(file string, ls []numbered, account string, groups []string) []Grant {
var out []Grant
for _, l := range ls {
f := strings.Fields(l.text)
if len(f) < 2 || strings.HasPrefix(f[0], "Defaults") || strings.HasSuffix(f[0], "_Alias") || strings.HasPrefix(f[0], "@") || strings.HasPrefix(f[0], "#") {
continue
}
who := f[0]
match := who == account || who == "ALL"
if strings.HasPrefix(who, "%") {
match = contains(groups, strings.TrimPrefix(who, "%"))
}
if !match {
continue
}
rest := strings.Join(f[1:], " ")
out = append(out, Grant{
File: file, Line: l.n, Text: l.text, Who: who,
NoPasswd: strings.Contains(rest, "NOPASSWD:"),
All: allLast.MatchString(rest),
})
}
return out
}
// DropIn is one entry of sudo's drop-in directory.
type DropIn struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Owner string `json:"owner"`
Group string `json:"group"`
Mode string `json:"mode"`
Size int64 `json:"size"`
ReadBySudo bool `json:"read_by_sudo"`
Why string `json:"why_not_read,omitempty"`
Parses *bool `json:"parses,omitempty"`
Error string `json:"error,omitempty"`
Mesh bool `json:"mesh_owned"`
}
// DropIns is the drop-in directory, each file checked as sudo would read it.
type DropIns struct {
Directory string `json:"directory"`
Entries []DropIn `json:"entries"`
SudoersParses bool `json:"sudoers_parses"`
SudoersSaid []string `json:"sudoers_said"`
}
// ListDropIns lists /etc/sudoers.d with owner and mode, and runs visudo's check on each file and on
// the whole of sudo's rules. A file that does not parse is a sudo that refuses everyone.
func (m *Machine) ListDropIns() (DropIns, error) {
d := DropIns{Directory: DropInDir, Entries: []DropIn{}, SudoersSaid: []string{}}
out, err := m.Root("find", DropInDir, "-mindepth", "1", "-maxdepth", "1", "-printf", "%f\t%y\t%u\t%g\t%m\t%s\n")
if err != nil {
return d, err
}
for _, l := range lines(out) {
f := strings.Split(l, "\t")
if len(f) != 6 {
continue
}
size, _ := strconv.ParseInt(f[5], 10, 64)
e := DropIn{Name: f[0], Path: path.Join(DropInDir, f[0]), Type: kindOf(f[1]), Owner: f[2], Group: f[3], Mode: "0" + strings.TrimLeft(f[4], "0"), Size: size}
if len(f[4]) == 4 {
e.Mode = f[4]
}
e.Mesh = e.Path == MeshDropIn
e.ReadBySudo, e.Why = readable(e)
if e.Type == "file" {
r, err := m.RootRan("visudo", "-c", "-f", e.Path)
if err != nil {
return d, err
}
ok := r.Status == 0
e.Parses = &ok
if !ok {
e.Error = firstLine(r.Stderr + "\n" + r.Stdout)
}
}
d.Entries = append(d.Entries, e)
}
sort.Slice(d.Entries, func(i, j int) bool { return d.Entries[i].Name < d.Entries[j].Name })
r, err := m.RootRan("visudo", "-c")
if err != nil {
return d, err
}
d.SudoersParses = r.Status == 0
d.SudoersSaid = lines(r.Stdout + r.Stderr)
return d, nil
}
func kindOf(y string) string {
switch y {
case "f":
return "file"
case "d":
return "directory"
case "l":
return "link"
}
return y
}
// readable is whether sudo reads an entry, and why not: its name, its type, its owner, or a mode
// that lets anyone but root write it.
func readable(e DropIn) (bool, string) {
switch {
case e.Type != "file":
return false, "not a regular file"
case !ReadBySudo(e.Name):
return false, "its name holds a dot or ends in ~, which sudo skips"
case e.Owner != "root":
return false, "not owned by root, which sudo refuses"
}
if mode, err := strconv.ParseUint(e.Mode, 8, 32); err == nil && mode&0o022 != 0 {
return false, "writable by others than root, which sudo refuses"
}
return true, ""
}