The two finds on the control-node sit in the operator's home of mode 0700, which the agent account cannot search. The search keeps every find; each judgement reads the modes on the way again, since an account can open its own home without root. A find in a home is said by count, its path in the journal alone. Also from review: a checkpoint racing SearchNow forgets the walk it just kept, and a walk kept by an older engine says its counts as lower bounds.
304 lines
10 KiB
Go
304 lines
10 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// The walk for setuid programs, resumable (novox/hq issue 361).
|
|
//
|
|
// The walk is the engine's own, in Go rather than find, so it can say how far it got: filepath.WalkDir visits
|
|
// every filesystem's tree depth first, each directory's entries in lexical order, so the walk's progress is one
|
|
// cursor per filesystem — the last path it visited — and everything before the cursor in that order is done.
|
|
// The progress is kept every CheckpointEvery beside the engine's state (SetuidCache), so an engine restarted,
|
|
// updated or rebooted midway carries on from its cursor rather than starting over: on the control-node the
|
|
// whole walk takes far longer than an engine runs between updates.
|
|
//
|
|
// **Why a resumed walk is sound.** Only root can make a setuid- or setgid-root file: the agent account can
|
|
// neither set the bit on root's file nor give its own file to root. A file that appears behind the cursor while
|
|
// the walk runs, or while it waits to be resumed, was made by root — and root's acts through the mesh start a
|
|
// new search (apply.MovesRoot), whose walk starts from nothing. A walk is dated by its start, and one resumed
|
|
// more than FreshFor after its start is thrown away and begun again.
|
|
|
|
// SetuidProgress is one walk, as far as it got.
|
|
type SetuidProgress struct {
|
|
// ID names the walk; Started is when it began, on the cache's clock: the search is dated by it.
|
|
ID string `json:"id"`
|
|
Started time.Time `json:"started"`
|
|
// Mounts are the filesystems it walks, root first; Done those walked to their end.
|
|
Mounts Paths `json:"mounts"`
|
|
Done Paths `json:"done"`
|
|
// Cursor is, for a filesystem being walked, the last path visited.
|
|
Cursor Cursors `json:"cursor"`
|
|
// Found is every setuid- or setgid-root regular file found so far, before the package manager is asked.
|
|
Found Paths `json:"found"`
|
|
// Visited is how many entries the walk has gone past: each once, across a resume, as it is kept with the
|
|
// cursor it matches.
|
|
Visited int64 `json:"visited"`
|
|
// Took is how long the walk spent in each of Done, in its order (negative: not known, a walk kept by an
|
|
// engine before it counted); Walking how long it has spent in the filesystem it walks now. Time the
|
|
// engine did not run is in neither.
|
|
Took []time.Duration `json:"took"`
|
|
Walking time.Duration `json:"walking"`
|
|
// Kept is when this progress was kept, as the cache wrote it; zero before the first time.
|
|
Kept time.Time `json:"kept"`
|
|
// Counted is a walk counted from its start: false for one kept by an engine before it counted, whose
|
|
// Visited is then a lower bound and whose filesystem under way took a time not known (the review of
|
|
// 2026-10-10).
|
|
Counted bool `json:"counted"`
|
|
}
|
|
|
|
// Paths are paths kept as their bytes. **Never as JSON strings**: encoding/json replaces bytes that are not
|
|
// UTF-8 with U+FFFD, and a cursor so changed sorts after the names it stood before — a resumed walk would skip
|
|
// them unread (the review of 2026-10-10). A name of any bytes is any account's to make.
|
|
type Paths []string
|
|
|
|
func (ps Paths) MarshalJSON() ([]byte, error) {
|
|
raw := make([][]byte, len(ps))
|
|
for i, p := range ps {
|
|
raw[i] = []byte(p)
|
|
}
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
func (ps *Paths) UnmarshalJSON(b []byte) error {
|
|
var raw [][]byte
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return err
|
|
}
|
|
*ps = make(Paths, len(raw))
|
|
for i, r := range raw {
|
|
(*ps)[i] = string(r)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Cursors are each filesystem's cursor, kept as bytes as Paths are.
|
|
type Cursors map[string]string
|
|
|
|
type keptCursor struct {
|
|
Mount []byte `json:"mount"`
|
|
Path []byte `json:"path"`
|
|
}
|
|
|
|
func (cs Cursors) MarshalJSON() ([]byte, error) {
|
|
raw := []keptCursor{}
|
|
for m, p := range cs {
|
|
raw = append(raw, keptCursor{Mount: []byte(m), Path: []byte(p)})
|
|
}
|
|
slices.SortFunc(raw, func(a, b keptCursor) int { return strings.Compare(string(a.Mount), string(b.Mount)) })
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
func (cs *Cursors) UnmarshalJSON(b []byte) error {
|
|
var raw []keptCursor
|
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
return err
|
|
}
|
|
*cs = Cursors{}
|
|
for _, r := range raw {
|
|
(*cs)[string(r.Mount)] = string(r.Path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CheckpointEvery is how often a walk's progress is kept.
|
|
const CheckpointEvery = time.Minute
|
|
|
|
// prunedPaths are never walked: the kernel's own trees, and the container runtimes' layers.
|
|
var prunedPaths = []string{"/proc", "/sys", "/var/lib/docker", "/var/lib/containers"}
|
|
|
|
// walker walks for setuid programs. rootUID and rootGID are the owner and group a match has: root's (0), but
|
|
// for a test. mountPoints are every filesystem mounted on the machine (MountPoints): a directory that is one is
|
|
// another filesystem, left without being read — a FUSE mount root cannot read, or a network mount whose
|
|
// server is away, would fail or hang the walk (the review of 2026-10-10).
|
|
type walker struct {
|
|
rootUID, rootGID uint32
|
|
every time.Duration
|
|
mountPoints map[string]bool
|
|
}
|
|
|
|
// walk carries p on to its end, calling checkpoint every w.every and after each filesystem. It ends early only
|
|
// with an error — a directory it could not read, or ctx ended — which is an incomplete walk, never "none".
|
|
// A file or directory that vanished while it walked is not an error: what still exists was walked.
|
|
func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func()) error {
|
|
if p.Cursor == nil {
|
|
p.Cursor = Cursors{}
|
|
}
|
|
// One Took for each of Done: a walk kept before they were counted has them not known.
|
|
for len(p.Took) < len(p.Done) {
|
|
p.Took = append(p.Took, -1)
|
|
}
|
|
p.Took = p.Took[:len(p.Done)]
|
|
if !p.Counted && len(p.Cursor) > 0 {
|
|
p.Walking = -1 // walked partly by an engine that did not count its time
|
|
}
|
|
last := time.Now()
|
|
for _, m := range p.Mounts {
|
|
if slices.Contains(p.Done, m) {
|
|
continue
|
|
}
|
|
if err := w.walkMount(ctx, p, m, checkpoint, &last); err != nil {
|
|
return err
|
|
}
|
|
p.Done = append(p.Done, m)
|
|
p.Took = append(p.Took, p.Walking)
|
|
p.Walking = 0
|
|
delete(p.Cursor, m)
|
|
checkpoint()
|
|
last = time.Now()
|
|
}
|
|
// Looked at again: a file found that is gone, or no longer setuid-root, is no answer the package manager
|
|
// can give, and would fail every search that resumes this walk (the review of 2026-10-10).
|
|
var still Paths
|
|
for _, f := range p.Found {
|
|
info, err := os.Lstat(f)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("the walk for setuid programs could not read %s again: %w", f, err)
|
|
}
|
|
if w.setuidRoot(info) {
|
|
still = append(still, f)
|
|
}
|
|
}
|
|
slices.Sort(still)
|
|
p.Found = slices.Compact(still)
|
|
return nil
|
|
}
|
|
|
|
func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string, checkpoint func(), last *time.Time) error {
|
|
top, err := os.Lstat(mount)
|
|
if err != nil {
|
|
return fmt.Errorf("the filesystem at %s could not be read: %w", mount, err)
|
|
}
|
|
dev, ok := deviceOf(top)
|
|
if !ok || !top.IsDir() {
|
|
return fmt.Errorf("the filesystem at %s is not a directory whose device could be read", mount)
|
|
}
|
|
var cursor []string
|
|
if c := p.Cursor[mount]; c != "" {
|
|
cursor = components(c)
|
|
}
|
|
// How long it walks this filesystem, carried on from what a resumed walk had spent in it.
|
|
began, before := time.Now(), p.Walking
|
|
spent := func() time.Duration {
|
|
if before < 0 {
|
|
return -1
|
|
}
|
|
return before + time.Since(began)
|
|
}
|
|
defer func() { p.Walking = spent() }()
|
|
return filepath.WalkDir(mount, func(path string, d fs.DirEntry, err error) error {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil // vanished while it walked
|
|
}
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
at := components(path)
|
|
if cursor != nil && walkOrder(at, cursor) < 0 && !isPrefix(at, cursor) {
|
|
// Before the cursor and not on its way: visited by the walk this resumes.
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
switch {
|
|
case d.IsDir():
|
|
if path != mount && (slices.Contains(prunedPaths, path) || w.mountPoints[path]) {
|
|
return filepath.SkipDir
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return filepath.SkipDir
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
if d, ok := deviceOf(info); !ok || d != dev {
|
|
return filepath.SkipDir // another filesystem, walked as its own or not at all (find's -xdev)
|
|
}
|
|
case d.Type().IsRegular():
|
|
info, err := d.Info() // the entry's own, never through a link
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
if w.setuidRoot(info) {
|
|
p.Found = append(p.Found, path)
|
|
}
|
|
}
|
|
// The cursor only moves on: the directories on the way back to it are visited again, and are before it.
|
|
// Counted here alone, so an entry visited again on the way back to the cursor is counted once.
|
|
if cursor == nil || walkOrder(at, cursor) > 0 {
|
|
p.Cursor[mount], cursor = path, at
|
|
p.Visited++
|
|
}
|
|
if time.Since(*last) >= w.every {
|
|
p.Walking = spent()
|
|
checkpoint()
|
|
*last = time.Now()
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// setuidRoot is a regular file setuid and owned by root, or setgid to root's group.
|
|
func (w walker) setuidRoot(info fs.FileInfo) bool {
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok || !info.Mode().IsRegular() {
|
|
return false
|
|
}
|
|
return uint32(st.Uid) == w.rootUID && info.Mode()&fs.ModeSetuid != 0 ||
|
|
uint32(st.Gid) == w.rootGID && info.Mode()&fs.ModeSetgid != 0
|
|
}
|
|
|
|
func deviceOf(info fs.FileInfo) (uint64, bool) {
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return uint64(st.Dev), true
|
|
}
|
|
|
|
// components are a path's names, in order; "/" has none.
|
|
func components(path string) []string {
|
|
path = strings.Trim(filepath.Clean(path), "/")
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(path, "/")
|
|
}
|
|
|
|
// walkOrder compares two paths in the order WalkDir visits them: name by name, each in lexical order, a
|
|
// directory before what is in it.
|
|
func walkOrder(a, b []string) int {
|
|
for i := 0; i < len(a) && i < len(b); i++ {
|
|
if c := strings.Compare(a[i], b[i]); c != 0 {
|
|
return c
|
|
}
|
|
}
|
|
return len(a) - len(b)
|
|
}
|
|
|
|
// isPrefix says a is b or a directory b is in.
|
|
func isPrefix(a, b []string) bool {
|
|
return len(a) <= len(b) && slices.Equal(a, b[:len(a)])
|
|
}
|