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).
438 lines
13 KiB
Go
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, ""
|
|
}
|