atlas-iac/services/hermes/scripts/node_account_audit.py
2026-08-17 02:00:36 -03:00

139 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Fail-closed supplementary-group and host privilege-policy audits."""
from __future__ import annotations
import stat
import struct
from pathlib import Path
from node_account_io import HardeningError, read_regular
def _members(field: str, context: str) -> set[str]:
if not field:
return set()
values = field.split(",")
if any(not value or value.strip() != value for value in values):
raise HardeningError(f"{context} has a malformed member list")
if len(values) != len(set(values)):
raise HardeningError(f"{context} has duplicate members")
return set(values)
def audit_membership(
account: str, groups: list[list[str]], gshadow: list[list[str]]
) -> None:
"""Reject the dedicated account in any supplementary group/admin list."""
for record in groups:
if account in _members(record[3], f"group {record[0]}"):
raise HardeningError("dedicated Hermes account has supplementary group access")
for record in gshadow:
principals = _members(record[2], f"gshadow {record[0]} admins")
principals |= _members(record[3], f"gshadow {record[0]} members")
if account in principals:
raise HardeningError("dedicated Hermes account has gshadow group access")
def _policy_files(host_etc: Path, polkit_share: Path, root_uid: int) -> list[Path]:
files: list[Path] = []
sudoers = host_etc / "sudoers"
if sudoers.exists():
files.append(sudoers)
roots = (
host_etc / "sudoers.d",
host_etc / "polkit-1/rules.d",
host_etc / "polkit-1/localauthority",
polkit_share / "rules.d",
polkit_share / "localauthority",
)
for root in roots:
if not root.exists():
continue
metadata = root.lstat()
if (
not stat.S_ISDIR(metadata.st_mode)
or stat.S_ISLNK(metadata.st_mode)
or metadata.st_uid != root_uid
or stat.S_IMODE(metadata.st_mode) & 0o022
):
raise HardeningError("unsafe sudo/polkit policy directory")
files.extend(path for path in root.rglob("*") if path.is_file())
if len(files) > 256:
raise HardeningError("too many sudo/polkit policy files")
return sorted(files)
def _audit_policy_metadata(snapshot, root_uid: int, account_uid: int) -> None:
if snapshot.uid != root_uid or snapshot.mode & 0o022:
raise HardeningError("sudo/polkit policy metadata permits unsafe mutation")
acl = dict(snapshot.xattrs).get("system.posix_acl_access", b"")
if not acl:
return
header = struct.Struct("<I")
entry = struct.Struct("<HHI")
if len(acl) < header.size or (len(acl) - header.size) % entry.size:
raise HardeningError("sudo/polkit policy ACL is malformed")
for offset in range(header.size, len(acl), entry.size):
tag, permissions, identifier = entry.unpack_from(acl, offset)
if tag == 0x02 and identifier == account_uid and permissions & 0o2:
raise HardeningError("sudo/polkit policy ACL grants Hermes write access")
def audit_privilege_policies(
account: str,
account_uid: int,
root_uid: int,
host_etc: Path,
polkit_share: Path,
) -> None:
"""Reject direct, group, wildcard sudo, or root-equivalent polkit grants."""
dangerous_polkit = (
"org.freedesktop.policykit.exec",
"org.freedesktop.systemd1.manage-unit",
"org.freedesktop.udisks2.modify-device",
"org.freedesktop.packagekit",
)
nsswitch = host_etc / "nsswitch.conf"
if nsswitch.exists():
try:
nss_snapshot = read_regular(nsswitch, 64 * 1024)
_audit_policy_metadata(nss_snapshot, root_uid, account_uid)
text = nss_snapshot.value.decode("utf-8")
except UnicodeDecodeError as exc:
raise HardeningError("nsswitch policy is not UTF-8") from exc
for line in text.splitlines():
name, separator, sources = line.partition(":")
if name.strip() in {"group", "initgroups", "sudoers"} and separator:
active_sources = [item for item in sources.split() if not item.startswith("[")]
if not active_sources or any(
item not in {"files", "systemd"} for item in active_sources
):
raise HardeningError("external group/sudo authority source is unsafe")
total = 0
for path in _policy_files(host_etc, polkit_share, root_uid):
snapshot = read_regular(path, 256 * 1024)
_audit_policy_metadata(snapshot, root_uid, account_uid)
value = snapshot.value
total += len(value)
if total > 2 * 1024 * 1024:
raise HardeningError("sudo/polkit policy input exceeds safe limit")
try:
text = value.decode("utf-8")
except UnicodeDecodeError as exc:
raise HardeningError("sudo/polkit policy is not UTF-8") from exc
active = "\n".join(
line for line in text.splitlines() if not line.lstrip().startswith("#")
)
if account in active or f"%{account}" in active:
raise HardeningError("dedicated Hermes account has sudo/polkit authority")
if path.name == "sudoers" or "sudoers.d" in path.parts:
for line in active.splitlines():
fields = line.split()
if fields and fields[0] in {"ALL", "%ALL"} and "=" in line:
raise HardeningError("broad sudo authority includes Hermes")
broad = "unix-user:*" in active or "Identity=unix-user:*" in active
grants = "polkit.Result.YES" in active or "ResultActive=yes" in active
if broad and grants and any(item in active for item in dangerous_polkit):
raise HardeningError("root-equivalent broad polkit authority includes Hermes")