atlas-iac/testing/tests/test_hermes_node_sudo_alias_audit.py
jenkins b8f2163af0 hermes: prove sudo and polkit denial closed
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>
2026-08-17 15:15:47 -03:00

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())