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, "" }