139 lines
5.7 KiB
Python
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")
|