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)
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 " +
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"tools": [
|
||||
"sudo_rules",
|
||||
"sudo_check",
|
||||
"sudo_escalation",
|
||||
"sudo_drop_ins"
|
||||
],
|
||||
"resources": [
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user