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