Expand sudoers User_Alias chains so aliases, wildcards, netgroups, and undefined names cannot smuggle authority to the Hermes account, and require polkit grants to scope through exact literal identity comparisons: computed strings, bracket lookups, subject aliasing, operator-built values, and unconditional or wildcard grants fail closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
134 lines
4.6 KiB
Python
134 lines
4.6 KiB
Python
"""Sudo alias and computed-identity denial proofs for the Hermes account."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).parents[2]
|
|
SCRIPTS = ROOT / "services/hermes/scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
|
|
def _load():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"node_account_audit_sudo_test", SCRIPTS / "node_account_audit.py"
|
|
)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _audit(tmp_path: Path, sudoers: str):
|
|
module = _load()
|
|
host_etc = tmp_path / "etc"
|
|
(host_etc / "sudoers.d").mkdir(parents=True, exist_ok=True)
|
|
(host_etc / "sudoers.d/atlas").write_text(sudoers, encoding="utf-8")
|
|
module.audit_privilege_policies(
|
|
"hermes-agent", 1200, os.getuid(), host_etc, tmp_path / "missing-share"
|
|
)
|
|
|
|
|
|
def test_benign_distribution_policy_passes(tmp_path: Path):
|
|
_audit(
|
|
tmp_path,
|
|
"Defaults secure_path=/usr/bin\n"
|
|
"root ALL=(ALL:ALL) ALL\n"
|
|
"%sudo ALL=(ALL:ALL) ALL\n"
|
|
"#999 ALL=(root) /usr/bin/uptime\n"
|
|
"alice, bob ALL=(root) NOPASSWD: /usr/bin/systemctl status\n",
|
|
)
|
|
|
|
|
|
def test_alias_reaching_hermes_directly_is_denied(tmp_path: Path):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="sudo authority"):
|
|
_audit(
|
|
tmp_path,
|
|
"User_Alias OPERATORS = atlas, hermes-agent\n"
|
|
"OPERATORS ALL=(ALL) ALL\n",
|
|
)
|
|
|
|
|
|
def test_alias_reaching_hermes_by_numeric_uid_is_denied(tmp_path: Path):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="sudo authority"):
|
|
_audit(tmp_path, "User_Alias OPERATORS = #1200\nOPERATORS ALL=(ALL) ALL\n")
|
|
|
|
|
|
def test_alias_expanding_to_all_is_denied(tmp_path: Path):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="broad sudo authority"):
|
|
_audit(tmp_path, "User_Alias ADMINS = ALL\nADMINS ALL=(ALL) ALL\n")
|
|
|
|
|
|
def test_nested_alias_expanding_to_all_via_continuation_is_denied(tmp_path: Path):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="broad sudo authority"):
|
|
_audit(
|
|
tmp_path,
|
|
"User_Alias INNER = atlas, \\\n ALL\n"
|
|
"User_Alias OUTER = INNER\n"
|
|
"OUTER ALL=(ALL) ALL\n",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("sudoers", "match"),
|
|
[
|
|
("+operators ALL=(ALL) ALL\n", "netgroup"),
|
|
("%:S-1-5-32 ALL=(ALL) ALL\n", "netgroup"),
|
|
("GHOSTS ALL=(ALL) ALL\n", "alias is undefined"),
|
|
("User_Alias A = B\nUser_Alias B = A\nA ALL=(ALL) ALL\n", "cyclic"),
|
|
("User_Alias broken\nroot ALL=(ALL) ALL\n", "not auditable"),
|
|
("User_Alias lower = atlas\n", "not auditable"),
|
|
("User_Alias A = atlas\nUser_Alias A = bob\nA ALL=(ALL) ALL\n", "not auditable"),
|
|
("User_Alias A = atlas,,bob\nA ALL=(ALL) ALL\n", "not auditable"),
|
|
("stray-line-without-equals\n", "not auditable"),
|
|
("=orphan (ALL) ALL\n", "not auditable"),
|
|
("! ALL=(ALL) ALL\n", "malformed"),
|
|
("User_Alias A = atlas : B = ALL\nB ALL=(ALL) ALL\n", "broad sudo"),
|
|
],
|
|
)
|
|
def test_unauditable_or_broad_sudo_policies_fail_closed(
|
|
tmp_path: Path, sudoers: str, match: str
|
|
):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match=match):
|
|
_audit(tmp_path, sudoers)
|
|
|
|
|
|
def test_negated_and_quoted_principals_are_expanded_before_judging(tmp_path: Path):
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="broad sudo authority"):
|
|
_audit(tmp_path, '!"ALL" ALL=(ALL) ALL\n')
|
|
_audit(tmp_path, "!alice ALL=(ALL) ALL\n")
|
|
|
|
|
|
def test_continuation_join_requires_a_complete_final_line():
|
|
module = _load()
|
|
with pytest.raises(module.HardeningError, match="line continuation"):
|
|
module._joined_sudo_lines("root ALL=(ALL) ALL \\")
|
|
assert module._joined_sudo_lines("a \\\nb\nc") == ["a b", "c"]
|
|
|
|
|
|
def test_alias_definitions_parse_multiple_groups_per_line():
|
|
module = _load()
|
|
aliases = module._sudo_user_aliases(
|
|
["User_Alias A = atlas, bob : B = carol", "Runas_Alias R = root"]
|
|
)
|
|
assert aliases == {"A": ["atlas", "bob"], "B": ["carol"]}
|
|
|
|
|
|
def test_expansion_accepts_nested_literal_users():
|
|
module = _load()
|
|
aliases = {"A": ["atlas", "B"], "B": ["carol"]}
|
|
module._expand_sudo_principal("A", aliases, frozenset())
|
|
module._expand_sudo_principal("%wheel", aliases, frozenset())
|
|
module._expand_sudo_principal("#999", aliases, frozenset()) |