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:
@@ -50,11 +50,6 @@
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "sudo",
|
||||
"type": "package",
|
||||
"package": "sudo"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
|
||||
@@ -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:
|
||||
`<operator account> 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.
|
||||
@@ -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 `key<sep>value` 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module sudo
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user