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).
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
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, ""
|
||||
}
|
||||
Reference in New Issue
Block a user