diff --git a/modules/lab/module.json b/modules/lab/module.json index 5cdc40b..78c92e9 100644 --- a/modules/lab/module.json +++ b/modules/lab/module.json @@ -50,11 +50,6 @@ "type": "package", "package": "iproute2" }, - { - "id": "sudo", - "type": "package", - "package": "sudo" - }, { "id": "npm", "type": "package", diff --git a/modules/sudo/README.md b/modules/sudo/README.md new file mode 100644 index 0000000..2481f42 --- /dev/null +++ b/modules/sudo/README.md @@ -0,0 +1,42 @@ +# sudo + +Privilege escalation as a module (novox/hq to-be 42 Phase 1, research 027). + +## What it owns + +- The `sudo` package. +- `/etc/sudoers.d/10-mesh-operator`, root's, mode 0440, written whole: + ` ALL=(ALL:ALL) NOPASSWD: ALL`. + +That one line is what the mesh's acting tools assume: the packet filter, the service manager and the +intrusion prevention tools act through `sudo -n` as the operator account (to-be 38 WP4). Before this +module, nothing declared it. Each machine said it in its own line in `/etc/sudoers`, set by hand: a +`wheel` group rule on two machines, the account by name on the other two. + +A sudoers file that does not parse locks sudo for every account. The manifest test renders the drop-in +for several account names and runs `visudo -cf` on each. It skips that check where visudo is not +installed. + +## What it improves + +- The escalation is declared once, the same on every machine, and readable through its tools. +- `lab` no longer declares the `sudo` package (novox/hq ADR 0207: a component's package belongs to one + module). Lab's tools still rely on sudo, and this module provides it on every machine. + +## What it leaves found + +- `/etc/sudoers` itself: its `root` line, the hand-set grants (`%wheel`, the account by name, + `%sudo`), and its `@includedir`. They are redundant beside the drop-in, and removing them is a + person's act on each machine (ADR 0182). `sudo_check` shows each grant and the one that decides. +- Every other file in `/etc/sudoers.d`. + +## Tools + +| tool | | answers | +|---|---|---| +| `sudo_rules` | r | `sudo -n -l` parsed: the defaults, and each rule with its run-as, tags and commands; `passwordless_all` | +| `sudo_check` | r | whether `sudo -n` works, every grant naming the account, its groups or `ALL` in the order sudo reads them, the one that decides, and whether the module's drop-in is present | +| `sudo_drop_ins` | r | `/etc/sudoers.d` with owner, mode, size, whether sudo reads each file (name, owner, mode), whether each parses, and whether the whole parses | + +The tools change nothing. They read root-only files through `sudo -n`, and a refusal is an answer, not +an empty list. diff --git a/modules/sudo/cmd/sudo-tools/machine.go b/modules/sudo/cmd/sudo-tools/machine.go new file mode 100644 index 0000000..5e41de7 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/machine.go @@ -0,0 +1,288 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// Escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func Escalated(uid int, name string, args ...string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/sudo/cmd/sudo-tools/machine_test.go b/modules/sudo/cmd/sudo-tools/machine_test.go new file mode 100644 index 0000000..b400f46 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/machine_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/sudo/cmd/sudo-tools/main.go b/modules/sudo/cmd/sudo-tools/main.go new file mode 100644 index 0000000..34217b9 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/main.go @@ -0,0 +1,56 @@ +// sudo's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It answers what +// sudo grants the operator account and whether the passwordless escalation every module's acting +// tools rely on works here. It changes nothing: the grant itself is the module's drop-in, which the +// host writes. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "sudo-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): sudo. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "sudo_rules", + Description: "What the runtime's account may run through sudo on this machine, as `sudo -n -l` says it: " + + "the defaults in force and each rule with its run-as, tags (NOPASSWD …) and commands, and whether one " + + "lets it run everything as root without a prompt. An error when sudo itself asks for a password.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.ListRules() }, + }, + { + Name: "sudo_check", + Description: "Does the passwordless escalation the mesh's acting tools rely on work here, and which file grants it: " + + "every rule in /etc/sudoers and its drop-ins naming the operator account, one of its groups or ALL, in " + + "the order sudo reads them, the one that decides, and whether the module's own drop-in is present.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.CheckEscalation() }, + }, + { + Name: "sudo_drop_ins", + Description: "The files of /etc/sudoers.d with owner, mode and size, whether sudo reads each (a name with a dot " + + "or ending in ~, another owner or a group- or world-writable mode is skipped), whether each parses " + + "(visudo -cf), and whether sudo's rules as a whole parse. A file that does not parse locks sudo for everyone.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.ListDropIns() }, + }, + } +} diff --git a/modules/sudo/cmd/sudo-tools/manifest_test.go b/modules/sudo/cmd/sudo-tools/manifest_test.go new file mode 100644 index 0000000..60ed77d --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/manifest_test.go @@ -0,0 +1,65 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): it declares the sudo package and one +// drop-in, mode 0440, granting the operator account passwordless escalation — and that drop-in is +// rendered and checked by visudo here, because a sudoers file that does not parse locks sudo for +// every account on the machine, the operator's included. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestItDeclaresThePackageAndTheDropInSudoReads(t *testing.T) { + m := manifest(t) + if m.Module != "sudo" || m.Version != "1" { + t.Fatalf("%s %s", m.Module, m.Version) + } + if p := m.resource(t, "package"); p["type"] != "package" || p["package"] != "sudo" { + t.Fatalf("package: %v", p) + } + f := m.resource(t, "operator") + if f["path"] != MeshDropIn || f["mode"] != "0440" || f["into"] != nil || f["owner"] != nil { + t.Fatalf("the drop-in is root's, whole, 0440: %v", f) + } + if !ReadBySudo(filepath.Base(MeshDropIn)) { + t.Fatal("sudo would skip the drop-in by its name") + } + if len(m.Resources) != 2 { + t.Fatalf("the module declares the package and the drop-in, nothing else: %v", m.Resources) + } +} + +func TestTheDropInGrantsExactlyTheOperatorAccountAndParses(t *testing.T) { + content := manifest(t).resource(t, "operator")["content"].(string) + var rules []string + for _, l := range strings.Split(content, "\n") { + if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") { + rules = append(rules, l) + } + } + if len(rules) != 1 || rules[0] != "${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL" { + t.Fatalf("rules: %q", rules) + } + if !strings.HasSuffix(content, "\n") { + t.Fatal("sudo requires the last line to end in a newline") + } + visudo, err := exec.LookPath("visudo") + if err != nil { + t.Skip("visudo is not installed here; the rendered drop-in is not checked") + } + for _, account := range []string{"operator", "ace", "jochen-s"} { + file := filepath.Join(t.TempDir(), "10-mesh-operator") + rendered := strings.ReplaceAll(content, "${machine:account}", account) + if err := os.WriteFile(file, []byte(rendered), 0o440); err != nil { + t.Fatal(err) + } + out, err := exec.Command(visudo, "-c", "-f", file).CombinedOutput() + if err != nil || !strings.Contains(string(out), "parsed OK") { + t.Fatalf("visudo refuses the drop-in rendered for %s: %v\n%s", account, err, out) + } + } +} diff --git a/modules/sudo/cmd/sudo-tools/shape_test.go b/modules/sudo/cmd/sudo-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/sudo/cmd/sudo-tools/sudo.go b/modules/sudo/cmd/sudo-tools/sudo.go new file mode 100644 index 0000000..e4f73bc --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/sudo.go @@ -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, "" +} diff --git a/modules/sudo/cmd/sudo-tools/sudo_test.go b/modules/sudo/cmd/sudo-tools/sudo_test.go new file mode 100644 index 0000000..73fb178 --- /dev/null +++ b/modules/sudo/cmd/sudo-tools/sudo_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "strings" + "testing" +) + +const listNovox = `Matching Defaults entries for operator on anchor: + env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/bin + +User operator may run the following commands on anchor: + (ALL) NOPASSWD: ALL + (root) SETENV: NOPASSWD: /usr/bin/pacman, /usr/bin/systemctl +` + +func TestSudoListIsParsedIntoDefaultsAndRules(t *testing.T) { + r := ParseList(listNovox, "x") + if r.Account != "operator" || r.Host != "anchor" { + t.Fatalf("who: %+v", r) + } + if len(r.Defaults) != 3 || r.Defaults[0] != "env_reset" { + t.Fatalf("defaults: %v", r.Defaults) + } + if len(r.Rules) != 2 || r.Rules[0].RunAs != "ALL" || strings.Join(r.Rules[0].Tags, ",") != "NOPASSWD" || r.Rules[0].Commands[0] != "ALL" { + t.Fatalf("first rule: %+v", r.Rules) + } + if strings.Join(r.Rules[1].Tags, ",") != "SETENV,NOPASSWD" || len(r.Rules[1].Commands) != 2 { + t.Fatalf("second rule: %+v", r.Rules[1]) + } + if !r.PasswordlessAll { + t.Fatal("(ALL) NOPASSWD: ALL is passwordless escalation") + } + only := ParseList("User operator may run the following commands on h:\n (ALL : ALL) ALL\n", "x") + if only.PasswordlessAll { + t.Fatal("a rule that asks for a password is not passwordless") + } +} + +func TestListingThatNeedsAPasswordIsAnError(t *testing.T) { + m := machine(fake(func(call) Ran { return Ran{Status: 1, Stderr: "sudo: a password is required\n"} }, nil), 1000) + if _, err := m.ListRules(); err == nil || !strings.Contains(err.Error(), "a password is required") { + t.Fatalf("got %v", err) + } +} + +const mainSudoers = `## sudoers file. +root ALL=(ALL:ALL) ALL +%wheel ALL=(ALL:ALL) NOPASSWD: ALL +#includedir is spelled with @ these days +@includedir /etc/sudoers.d +operator ALL=(ALL) \ + ALL +` + +func sudoersMachine(uid int, calls *[]call) *Machine { + return machine(byLine(map[string]Ran{ + "sudo -n true": {}, + "id -nG operator": {Stdout: "users wheel docker\n"}, + "sudo -n cat /etc/sudoers": {Stdout: mainSudoers}, + "sudo -n find /etc/sudoers.d -mindepth 1 -maxdepth 1 -type f -printf %f\n": {Stdout: "10-mesh-operator\nold.pacsave\n"}, + "sudo -n cat /etc/sudoers.d/10-mesh-operator": {Stdout: "# The mesh's\noperator ALL=(ALL:ALL) NOPASSWD: ALL\n"}, + }, calls), uid) +} + +func TestCheckFindsEveryGrantInReadingOrderAndTheOneThatDecides(t *testing.T) { + var calls []call + c, err := sudoersMachine(1000, &calls).CheckEscalation() + if err != nil { + t.Fatal(err) + } + if !c.Passwordless || c.Refusal != "" { + t.Fatalf("escalation: %+v", c) + } + got := []string{} + for _, g := range c.Grants { + got = append(got, g.File+":"+g.Who) + } + want := "/etc/sudoers:%wheel /etc/sudoers.d/10-mesh-operator:operator /etc/sudoers:operator" + if strings.Join(got, " ") != want { + t.Fatalf("grants in order: %v", got) + } + if c.DecidedBy == nil || c.DecidedBy.File != "/etc/sudoers" || c.DecidedBy.NoPasswd || c.DecidedBy.Line != 6 { + t.Fatalf("the last rule sudo reads decides, joined across its continuation: %+v", c.DecidedBy) + } + if !c.MeshDropIn.Present || !c.MeshDropIn.Grants { + t.Fatalf("the module's drop-in: %+v", c.MeshDropIn) + } + for _, cl := range calls { + if strings.Contains(cl.String(), "old.pacsave") { + t.Fatal("a file sudo skips was read as a rule") + } + } +} + +func TestARefusedEscalationIsSaidAndNothingIsReadAsNoGrant(t *testing.T) { + m := machine(fake(func(c call) Ran { + if c.String() == "sudo -n true" { + return Ran{Status: 1, Stderr: "sudo: a password is required\n"} + } + if c.name == "id" { + return Ran{Stdout: "users\n"} + } + t.Fatalf("read %s after a refusal", c) + return Ran{} + }, nil), 1000) + c, err := m.CheckEscalation() + if err != nil { + t.Fatal(err) + } + if c.Passwordless || c.Refusal != "sudo: a password is required" || !strings.Contains(c.Note, "not read") { + t.Fatalf("%+v", c) + } +} + +func TestSudoSkipsDottedAndBackupNames(t *testing.T) { + for name, want := range map[string]bool{"10-mesh-operator": true, "old.pacsave": false, "rule~": false, "README": true} { + if ReadBySudo(name) != want { + t.Errorf("%s: %v", name, !want) + } + } +} + +func TestDropInsAreListedWithWhetherSudoReadsAndParsesEach(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "sudo -n find /etc/sudoers.d -mindepth 1 -maxdepth 1 -printf %f\t%y\t%u\t%g\t%m\t%s\n": {Stdout: "10-mesh-operator\tf\troot\troot\t440\t120\nbroken\tf\troot\troot\t440\t9\nloose\tf\toperator\troot\t644\t3\nx.bak\tf\troot\troot\t640\t3\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/10-mesh-operator": {Stdout: "/etc/sudoers.d/10-mesh-operator: parsed OK\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/broken": {Status: 1, Stderr: "/etc/sudoers.d/broken:1:5: syntax error\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/loose": {Stdout: "parsed OK\n"}, + "sudo -n visudo -c -f /etc/sudoers.d/x.bak": {Stdout: "parsed OK\n"}, + "sudo -n visudo -c": {Status: 1, Stdout: "/etc/sudoers: parsed OK\n", Stderr: "/etc/sudoers.d/broken:1:5: syntax error\n"}, + }, nil), 1000) + d, err := m.ListDropIns() + if err != nil { + t.Fatal(err) + } + by := map[string]DropIn{} + for _, e := range d.Entries { + by[e.Name] = e + } + if e := by["10-mesh-operator"]; !e.Mesh || !e.ReadBySudo || e.Parses == nil || !*e.Parses || e.Mode != "0440" { + t.Fatalf("the mesh's: %+v", e) + } + if e := by["broken"]; e.Parses == nil || *e.Parses || !strings.Contains(e.Error, "syntax error") { + t.Fatalf("broken: %+v", e) + } + if e := by["loose"]; e.ReadBySudo || !strings.Contains(e.Why, "owned by root") { + t.Fatalf("loose: %+v", e) + } + if e := by["x.bak"]; e.ReadBySudo || !strings.Contains(e.Why, "dot") { + t.Fatalf("x.bak: %+v", e) + } + if d.SudoersParses || len(d.SudoersSaid) != 2 { + t.Fatalf("the whole: %+v", d) + } +} diff --git a/modules/sudo/go.mod b/modules/sudo/go.mod new file mode 100644 index 0000000..5119383 --- /dev/null +++ b/modules/sudo/go.mod @@ -0,0 +1,5 @@ +module sudo + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/sudo/go.sum b/modules/sudo/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/sudo/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/sudo/module.json b/modules/sudo/module.json new file mode 100644 index 0000000..a9a5fc9 --- /dev/null +++ b/modules/sudo/module.json @@ -0,0 +1,41 @@ +{ + "module": "sudo", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "sudo_rules", + "sudo_check", + "sudo_drop_ins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "sudo" + }, + { + "id": "operator", + "type": "file", + "path": "/etc/sudoers.d/10-mesh-operator", + "mode": "0440", + "content": "# The mesh's (module sudo, novox/hq to-be 42, research 027): the operator account escalates\n# without a prompt. The mesh's tools that act as root run `sudo -n` as this account and rely on it;\n# until this file, every machine said so only in a line set by hand in /etc/sudoers.\n# Written whole at every push: an edit here is overwritten. A file of this directory whose name\n# holds a dot or ends in ~ is not read by sudo; this name holds neither.\n${machine:account} ALL=(ALL:ALL) NOPASSWD: ALL\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/sudo-tools", + "binary": "sudo-tools", + "loads": [ + "sudo-tools" + ] + } + ] + } +}