Run the router as its own account, ask unlinking of the linked account, and take no approval where an agent can become root (hq ADR 0259 §7, §8)
mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate fail: builds messenger, sudo → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: modules/messenge…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
mesh/delivery-group group feat/asks-answered-on-any-channel checking: 3 of 4 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate fail: builds messenger, sudo → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: modules/messenge…
mesh/repo-check fail: its merge-check.sh failed: long-running resources without health: 70
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
// Who can become root here without a person (novox/hq ADR 0259 §8): the controller's self-check asks it of
|
||||
// every machine where the router or a channel proving its sender runs, for the accounts agents run as and
|
||||
// the account the machine's runtime runs commands as.
|
||||
//
|
||||
// Root without a person is any of: a sudo rule letting the account run every command as root with no
|
||||
// password; membership of a group that is root by another name (docker, which mounts the host; disk, which
|
||||
// reads it raw; wheel or sudo only where a rule grants them without a password, which the listing shows); or
|
||||
// the account being root itself.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Escalation is one account's answer.
|
||||
type Escalation struct {
|
||||
Account string `json:"account"`
|
||||
Root bool `json:"root_without_a_person"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// rootGroups are groups that are root by another name.
|
||||
var rootGroups = map[string]string{"docker": "the docker group, which can mount the machine's root file system",
|
||||
"disk": "the disk group, which reads and writes the drives raw", "root": "the root group"}
|
||||
|
||||
var accountShape = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// Escalations answers, for each account, whether it can become root here without a person.
|
||||
func (m *Machine) Escalations(accounts []string) ([]Escalation, error) {
|
||||
if len(accounts) == 0 {
|
||||
return nil, fmt.Errorf("name the accounts to judge")
|
||||
}
|
||||
var out []Escalation
|
||||
for _, a := range accounts {
|
||||
if !accountShape.MatchString(a) {
|
||||
return nil, fmt.Errorf("%q is not an account name", a)
|
||||
}
|
||||
out = append(out, m.escalation(a))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Machine) escalation(account string) Escalation {
|
||||
e := Escalation{Account: account}
|
||||
if account == "root" {
|
||||
e.Root, e.Why = true, "it is root"
|
||||
return e
|
||||
}
|
||||
id := m.Run(bg(), "id", "-nG", account)
|
||||
if id.Status != 0 || id.Err != "" {
|
||||
e.Why = "no such account here"
|
||||
return e
|
||||
}
|
||||
for _, g := range strings.Fields(id.Stdout) {
|
||||
if why, ok := rootGroups[g]; ok {
|
||||
e.Root, e.Why = true, "a member of "+why
|
||||
return e
|
||||
}
|
||||
}
|
||||
// sudo's own word, for that account: listing another account's rules needs root, which the runtime's
|
||||
// account escalates to as the mesh's acting tools do.
|
||||
r := m.Run(bg(), "sudo", "-n", "-l", "-U", account)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
if strings.Contains(r.Stdout+r.Stderr, "not allowed to run sudo") {
|
||||
e.Why = "no sudo rule names it"
|
||||
return e
|
||||
}
|
||||
e.Why = "sudo could not be asked: " + strings.TrimSpace(firstLine(r.Stderr+r.Err))
|
||||
return e
|
||||
}
|
||||
if strings.Contains(r.Stdout, "is not allowed to run sudo") {
|
||||
e.Why = "no sudo rule names it"
|
||||
return e
|
||||
}
|
||||
rules := ParseList(r.Stdout, account)
|
||||
if rules.PasswordlessAll {
|
||||
for _, rule := range rules.Rules {
|
||||
if hasTag(rule.Tags, "NOPASSWD") && contains(rule.Commands, "ALL") && runsAsRoot(rule.RunAs) {
|
||||
e.Root, e.Why = true, "sudo lets it run every command as root without a password ("+rule.Line+")"
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
e.Why = "sudo asks it for a password, or lets it run named commands only"
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §8: who can become root here without a person, as the self-check asks it.
|
||||
func TestWhoCanBecomeRootWithoutAPerson(t *testing.T) {
|
||||
var calls []call
|
||||
run := byLine(map[string]Ran{
|
||||
"id -nG ops": {Stdout: "ops wheel\n"},
|
||||
"sudo -n -l -U ops": {Stdout: "User ops may run the following commands on anchor:\n (ALL : ALL) NOPASSWD: ALL\n"},
|
||||
"id -nG agents": {Stdout: "agents\n"},
|
||||
"sudo -n -l -U agents": {Stdout: "User agents is not allowed to run sudo on anchor.\n"},
|
||||
"id -nG builder": {Stdout: "builder docker\n"},
|
||||
"id -nG careful": {Stdout: "careful wheel\n"},
|
||||
"sudo -n -l -U careful": {Stdout: "User careful may run the following commands on anchor:\n (ALL : ALL) ALL\n"},
|
||||
"id -nG ghost": {Status: 1, Stderr: "id: 'ghost': no such user"},
|
||||
}, &calls)
|
||||
got, err := machine(run, 1000).Escalations([]string{"ops", "agents", "builder", "careful", "ghost", "root"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{"ops": true, "agents": false, "builder": true, "careful": false, "ghost": false, "root": true}
|
||||
for _, e := range got {
|
||||
if e.Root != want[e.Account] || e.Why == "" {
|
||||
t.Errorf("%s: root %v (%s), want %v", e.Account, e.Root, e.Why, want[e.Account])
|
||||
}
|
||||
}
|
||||
if _, err := machine(run, 1000).Escalations([]string{"a b"}); err == nil || !strings.Contains(err.Error(), "not an account name") {
|
||||
t.Errorf("an account name that is not one was asked: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,26 @@ func tools(m *Machine) []stdio.Tool {
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.CheckEscalation() },
|
||||
},
|
||||
{
|
||||
Name: "sudo_escalation",
|
||||
Description: "Can these accounts become root on this machine without a person: a sudo rule running every " +
|
||||
"command as root with no password, or a group that is root by another name (docker, disk). Each " +
|
||||
"account with yes or no and why. Asked by the self-check where the router or a channel proving its " +
|
||||
"sender runs (novox/hq ADR 0259).",
|
||||
Input: schema(map[string]any{"accounts": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the accounts to judge"}}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var accounts []string
|
||||
if list, ok := a["accounts"].([]any); ok {
|
||||
for _, x := range list {
|
||||
if s, ok := x.(string); ok {
|
||||
accounts = append(accounts, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.Escalations(accounts)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 " +
|
||||
|
||||
Reference in New Issue
Block a user