mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between updates, so a search that starts over at every restart never completes. The walk is the engine's own, in lexical order, so its progress is one cursor per filesystem, kept every minute beside the engine's state and carried on after a restart while younger than rootsearch.FreshFor. It runs on a thread of its own at nice 19 and the idle I/O class.
271 lines
9.5 KiB
Go
271 lines
9.5 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner).
|
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
|
|
|
// Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`,
|
|
// the machine's own manager's `systemctl show`, `sudo -l -U` (which lists, as root, what sudo would let an
|
|
// account run, and runs nothing), a status file under /proc and a secret's owner and mode (a test holds
|
|
// both).
|
|
type Exec struct {
|
|
Run Runner
|
|
// Proc is where the process table is; empty is /proc. A test points it at a directory of its own.
|
|
Proc string
|
|
// Stat is a file's owner and mode; nil is the machine's own (os.Stat). A test gives files of its own.
|
|
Stat func(path string) (FileMode, error)
|
|
// ReadFile, ReadDir and ACL read doas's and polkit's rules and a file's POSIX ACL; nil is the machine's.
|
|
ReadFile func(path string) ([]byte, error)
|
|
ReadDir func(path string) ([]fs.DirEntry, error)
|
|
ACL func(path string) ([]ACLEntry, error)
|
|
// Cache keeps the search for setuid programs between looks; nil searches on every look.
|
|
Cache *SetuidCache
|
|
// SearchRun runs the setuid search's commands (the package manager it asks); nil is Run. A
|
|
// serving engine gives Idle, so they yield to everything else on the machine, as the walk does.
|
|
SearchRun Runner
|
|
// Walk carries a walk for setuid programs on; nil is the engine's own (walker). A test gives its own.
|
|
Walk func(ctx context.Context, p *SetuidProgress, checkpoint func()) error
|
|
// Now is the clock the cache is kept by; nil is the machine's.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// FileMode is what decides whether an account reads a file: its owner, its group and its permission bits.
|
|
type FileMode struct {
|
|
UID, GID int
|
|
Perm fs.FileMode
|
|
}
|
|
|
|
// Escalation is every way account can become root without a person (novox/hq ADR 0266) that the judge
|
|
// looks for — Judged lists them, NotJudged what it does not: its uid, a group of RootGroups the user database
|
|
// lists it in, any sudo rule naming it or a group of it, any of secrets it can read by owner, group or other
|
|
// bits, and the ways of ways.go: doas, polkit, a runtime's socket, an ACL, a setuid program no package owns.
|
|
// sudo absent is no sudo rule; doas and polkit (pkexec's grants) are judged from their own rules. A secret not there yet is skipped: there is nothing to read.
|
|
// The parent directories are not walked, so a file the bits allow and a directory hides is still said:
|
|
// the judge errs toward saying a way that is not, never toward missing one that is.
|
|
func (e Exec) Escalation(ctx context.Context, account string, secrets []string) ([]string, error) {
|
|
var ways []string
|
|
uidOut, err := e.Run(ctx, "id", "-u", account)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the user database did not answer about %q: %w", account, err)
|
|
}
|
|
uid, err := strconv.Atoi(strings.TrimSpace(uidOut))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the user database gave %q as %q's number", strings.TrimSpace(uidOut), account)
|
|
}
|
|
if uid == 0 {
|
|
ways = append(ways, "its uid is 0")
|
|
}
|
|
names, err := e.InDatabase(ctx, account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, g := range names {
|
|
if slices.Contains(RootGroups, g) {
|
|
ways = append(ways, "in the group "+g+", which grants root")
|
|
}
|
|
}
|
|
listed, err := e.Run(ctx, "sudo", "-l", "-U", account)
|
|
rules, err := SudoRules(listed, err)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rules) > 0 {
|
|
ways = append(ways, "sudo grants it: "+strings.Join(rules, ", "))
|
|
}
|
|
gidsOut, err := e.Run(ctx, "id", "-G", account)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the user database did not answer about %q's groups: %w", account, err)
|
|
}
|
|
gids := map[int]bool{}
|
|
for _, f := range strings.Fields(gidsOut) {
|
|
if n, err := strconv.Atoi(f); err == nil {
|
|
gids[n] = true
|
|
}
|
|
}
|
|
if len(secrets) > 0 {
|
|
stat := e.Stat
|
|
if stat == nil {
|
|
stat = statOf
|
|
}
|
|
for _, path := range secrets {
|
|
m, err := stat(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the secret %s could not be read for its owner and mode: %w", path, err)
|
|
}
|
|
if Readable(m, uid, gids) {
|
|
ways = append(ways, "it can read the secret "+path)
|
|
}
|
|
}
|
|
}
|
|
more, err := e.moreWays(ctx, account, uid, names, gids, secrets)
|
|
var notJudged *SetuidNotJudged
|
|
if err != nil && !errors.As(err, ¬Judged) {
|
|
return nil, err
|
|
}
|
|
// Every way found, and — when the setuid search has no result yet — that it is not judged.
|
|
return append(ways, more...), err
|
|
}
|
|
|
|
// Readable is whether an account of uid, in the groups gids, reads a file of m by its permission bits, as
|
|
// the kernel decides it: the owner's bits for the owner (root reads everything), the group's for a member,
|
|
// the others' for anybody else.
|
|
func Readable(m FileMode, uid int, gids map[int]bool) bool {
|
|
switch {
|
|
case uid == 0:
|
|
return true
|
|
case m.UID == uid:
|
|
return m.Perm&0o400 != 0
|
|
case gids[m.GID]:
|
|
return m.Perm&0o040 != 0
|
|
default:
|
|
return m.Perm&0o004 != 0
|
|
}
|
|
}
|
|
|
|
// SudoRules is the rules `sudo -l -U <account>` lists, from what it printed and how it ended: none when
|
|
// the account "is not allowed to run sudo" or sudo is not on the machine; every indented line after "may
|
|
// run the following commands" otherwise. Any rule counts — the decision is no sudo for the account at
|
|
// all, so a rule that asks for a password the account was never given is still a rule somebody can give
|
|
// it one for. Output that says neither is an error: unread is never none.
|
|
func SudoRules(out string, err error) ([]string, error) {
|
|
if err != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist)) {
|
|
return nil, nil
|
|
}
|
|
text := out
|
|
if err != nil {
|
|
text += "\n" + err.Error()
|
|
}
|
|
if strings.Contains(text, "is not allowed to run sudo") {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sudo did not list the account's rules: %w", err)
|
|
}
|
|
var rules []string
|
|
listing := false
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.Contains(line, "may run the following commands") {
|
|
listing = true
|
|
continue
|
|
}
|
|
if listing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && strings.TrimSpace(line) != "" {
|
|
rules = append(rules, strings.TrimSpace(line))
|
|
}
|
|
}
|
|
if !listing {
|
|
return nil, fmt.Errorf("sudo listed neither rules nor a refusal: %q", firstLine(out))
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func statOf(path string) (FileMode, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return FileMode{}, err
|
|
}
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return FileMode{}, fmt.Errorf("%s has no owner this platform reports", path)
|
|
}
|
|
return FileMode{UID: int(st.Uid), GID: int(st.Gid), Perm: info.Mode().Perm()}, nil
|
|
}
|
|
|
|
// InDatabase is `id -nG`: every group the user database lists the account in.
|
|
func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) {
|
|
out, err := e.Run(ctx, "id", "-nG", account)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return strings.Fields(out), nil
|
|
}
|
|
|
|
// Session reads the account's own manager, user@<uid>.service, from the machine's manager — never from
|
|
// the account's, which asking would start — and the groups its process holds, from its status file.
|
|
func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) {
|
|
passwd, err := e.Run(ctx, "getent", "passwd", account)
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err)
|
|
}
|
|
fields := strings.Split(strings.TrimSpace(passwd), ":")
|
|
if len(fields) < 7 || fields[2] == "" {
|
|
return Session{}, fmt.Errorf("the user database gave no number for %q", account)
|
|
}
|
|
shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service")
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err)
|
|
}
|
|
pid := strings.TrimSpace(shown)
|
|
if pid == "" || pid == "0" {
|
|
return Session{}, nil
|
|
}
|
|
held, err := e.heldBy(pid)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
s := Session{Running: true, Has: map[string]bool{}}
|
|
for _, g := range groups {
|
|
entry, err := e.Run(ctx, "getent", "group", g)
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err)
|
|
}
|
|
parts := strings.Split(strings.TrimSpace(entry), ":")
|
|
if len(parts) < 3 {
|
|
return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g)
|
|
}
|
|
s.Has[g] = held[parts[2]]
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// heldBy is every group id a process holds, from the Groups line of its status file.
|
|
func (e Exec) heldBy(pid string) (map[string]bool, error) {
|
|
proc := e.Proc
|
|
if proc == "" {
|
|
proc = "/proc"
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(proc, pid, "status"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Its supplementary groups, and its own group, which the supplementary list need not repeat.
|
|
held := map[string]bool{}
|
|
named := false
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if rest, ok := strings.CutPrefix(line, "Groups:"); ok {
|
|
named = true
|
|
for _, gid := range strings.Fields(rest) {
|
|
held[gid] = true
|
|
}
|
|
}
|
|
if rest, ok := strings.CutPrefix(line, "Gid:"); ok {
|
|
if f := strings.Fields(rest); len(f) > 0 {
|
|
held[f[0]] = true
|
|
}
|
|
}
|
|
}
|
|
if !named {
|
|
return nil, fmt.Errorf("process %s's status names no groups", pid)
|
|
}
|
|
return held, nil
|
|
}
|