diff --git a/services/hermes/scripts/node_account_audit.py b/services/hermes/scripts/node_account_audit.py index 31b54302..fa6245ba 100644 --- a/services/hermes/scripts/node_account_audit.py +++ b/services/hermes/scripts/node_account_audit.py @@ -89,13 +89,22 @@ def _active_sudo_policy(text: str) -> str: stripped = line.lstrip() if not stripped: continue - include = re.fullmatch( - r"(?i)([#@]include|[#@]includedir)\s+([^\s]+)", stripped - ) - if include: - directive, target = include.groups() - if directive.lower().endswith("includedir") and target == "/etc/sudoers.d": - continue + # Detect any include spelling before the comment rule below. sudo + # honours both #include and @include; a quoted or space-bearing path + # must never fall through the "#" comment branch and leave a second + # authority file unaudited. Only the exact bare includedir into the + # audited /etc/sudoers.d passes; everything else fails closed. + if re.match(r"(?i)[#@]include(?:dir)?(?=\s|$)", stripped): + include = re.fullmatch( + r"(?i)([#@]include(?:dir)?)\s+([^\s\"'\\]+)", stripped + ) + if include: + directive, target = include.groups() + if ( + directive.lower().endswith("includedir") + and target == "/etc/sudoers.d" + ): + continue raise HardeningError("sudo policy includes an unaudited authority source") if stripped.startswith("#") and not re.match(r"#\d+(?:\s|$)", stripped): continue diff --git a/testing/tests/test_hermes_node_audit_coverage.py b/testing/tests/test_hermes_node_audit_coverage.py index 8c011b5b..20114ada 100644 --- a/testing/tests/test_hermes_node_audit_coverage.py +++ b/testing/tests/test_hermes_node_audit_coverage.py @@ -105,6 +105,50 @@ def test_active_sudo_policy_handles_blank_comments_numeric_and_includes(): module._active_sudo_policy("@include /external/policy\n") +@pytest.mark.parametrize( + "directive", + [ + '#include "/etc/evil path"', + "#include '/etc/evil path'", + "#include /etc/evil\\ path", + "#include\t/etc/evil", + "#include /etc/evil", + "#includedir /opt/other", + '#includedir "/etc/sudoers.d"', + "@include /external/policy", + '@include "/etc/evil path"', + "@includedir /opt/other", + "#include", + "@includedir", + ], +) +def test_every_include_spelling_outside_the_standard_dir_fails_closed(directive: str): + """No include form may reach the comment branch and stay unaudited.""" + module = _audit_module() + with pytest.raises(module.HardeningError, match="unaudited"): + module._active_sudo_policy(directive + "\n") + + +@pytest.mark.parametrize( + "directive", + ["#includedir /etc/sudoers.d", "@includedir /etc/sudoers.d"], +) +def test_standard_includedir_is_the_only_allowed_include(directive: str): + module = _audit_module() + active = module._active_sudo_policy(directive + "\nroot ALL=(ALL) ALL\n") + assert active == "root ALL=(ALL) ALL" + + +@pytest.mark.parametrize( + "line", + ["#included by an operator note", "#include-guard notes", "#includes history"], +) +def test_comment_lines_that_only_resemble_includes_stay_comments(line: str): + module = _audit_module() + active = module._active_sudo_policy(line + "\nroot ALL=(ALL) ALL\n") + assert active == "root ALL=(ALL) ALL" + + def test_identity_matcher_covers_account_numeric_and_unrelated_values(): module = _audit_module() assert module._mentions_dedicated_identity("hermes-agent ALL", "hermes-agent", 1200)