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>
162 lines
6.9 KiB
Python
162 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Literal-identity proof for polkit policies on hardened Atlas nodes.
|
|
|
|
Polkit rules are JavaScript, so a grant can name its subject indirectly:
|
|
concatenated strings, escape sequences, bracket lookups, or arithmetic can
|
|
compute an identity that plain text matching never sees. Rather than model
|
|
the language, every grant must scope itself through exact literal identity
|
|
comparisons. Anything the audit cannot prove literal fails closed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from node_account_io import HardeningError
|
|
|
|
_ALLOWED_ESCAPES = {'"', "'", "\\", "/", "n", "r", "t"}
|
|
_STRING_MARK = r"\x00[0-9]+\x00"
|
|
_GRANT_RE = re.compile(r"\bResult\s*\.\s*YES\b")
|
|
_PKLA_GRANT_RE = re.compile(r"(?im)^\s*Result(?:Any|Inactive|Active)\s*=\s*yes\s*$")
|
|
_PKLA_IDENTITY_RE = re.compile(r"unix-(?:user|group):[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z")
|
|
_IDENTITY_USE_RE = re.compile(r"subject\s*(\[|\.\s*(?:user|uid|isInGroup)\b)")
|
|
_COMPUTED_TOKEN_RE = re.compile(
|
|
r"\b(?:eval|Function|arguments|fromCharCode|fromCodePoint|atob|btoa|unescape"
|
|
r"|escape|decodeURI[A-Za-z]*|encodeURI[A-Za-z]*|charCodeAt|codePointAt"
|
|
r"|normalize|constructor|prototype|globalThis|Reflect|Proxy)\b"
|
|
)
|
|
# Logical && and || stay legal; every value-building operator next to a
|
|
# string or number literal is rejected so comparisons stay whole literals.
|
|
_OPERATOR_ADJACENT_RE = re.compile(
|
|
rf"(?:{_STRING_MARK}|[0-9])\s*[+\-*/%<>^~](?![&|=])"
|
|
rf"|(?<![&|=!<>])[+\-*/%<>^~]\s*(?:{_STRING_MARK}|[0-9])"
|
|
)
|
|
_USER_BEFORE_RE = re.compile(rf"{_STRING_MARK}\s*[=!]==?\s*\Z")
|
|
_USER_AFTER_RE = re.compile(rf"\s*[=!]==?\s*{_STRING_MARK}")
|
|
_UID_BEFORE_RE = re.compile(r"(?<![0-9A-Za-z_.$])[0-9]{1,10}\s*[=!]==?\s*\Z")
|
|
_UID_AFTER_RE = re.compile(r"\s*[=!]==?\s*[0-9]{1,10}(?![0-9A-Za-z_.$])")
|
|
_GROUP_AFTER_RE = re.compile(rf"\s*\(\s*{_STRING_MARK}\s*\)")
|
|
_BARE_SUBJECT_RE = re.compile(r"\bsubject\b(?!\s*[.\[])")
|
|
_PARAMETER_BEFORE_RE = re.compile(r"function\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*,\s*\Z")
|
|
_RESULT_USE_RE = re.compile(r"\bResult\b(?!\s*\.\s*[A-Z_]{2,32}\b)")
|
|
|
|
|
|
def _split_strings(text: str) -> tuple[str, list[str]]:
|
|
"""Replace string literals with markers and drop JavaScript comments."""
|
|
code: list[str] = []
|
|
strings: list[str] = []
|
|
index = 0
|
|
length = len(text)
|
|
while index < length:
|
|
character = text[index]
|
|
if character == "`":
|
|
raise HardeningError("polkit rule uses computed template strings")
|
|
if character in {'"', "'"}:
|
|
quote = character
|
|
index += 1
|
|
value: list[str] = []
|
|
while True:
|
|
if index >= length or text[index] in "\r\n":
|
|
raise HardeningError("polkit rule string is unterminated")
|
|
character = text[index]
|
|
if character == "\\":
|
|
if index + 1 >= length or text[index + 1] not in _ALLOWED_ESCAPES:
|
|
raise HardeningError("polkit rule string uses computed escapes")
|
|
escaped = text[index + 1]
|
|
value.append({"n": "\n", "r": "\r", "t": "\t"}.get(escaped, escaped))
|
|
index += 2
|
|
continue
|
|
index += 1
|
|
if character == quote:
|
|
break
|
|
value.append(character)
|
|
strings.append("".join(value))
|
|
code.append(f"\x00{len(strings) - 1}\x00")
|
|
continue
|
|
if text.startswith("//", index):
|
|
newline = text.find("\n", index)
|
|
index = length if newline < 0 else newline
|
|
continue
|
|
if text.startswith("/*", index):
|
|
closing = text.find("*/", index + 2)
|
|
if closing < 0:
|
|
raise HardeningError("polkit rule comment is unterminated")
|
|
code.append(" ")
|
|
index = closing + 2
|
|
continue
|
|
code.append(character)
|
|
index += 1
|
|
return "".join(code), strings
|
|
|
|
|
|
def _require_literal_identity_use(code: str, match: re.Match[str]) -> None:
|
|
"""Reject one identity reference unless it is an exact literal comparison."""
|
|
form = match.group(1)
|
|
if form.startswith("["):
|
|
raise HardeningError("polkit rule reads identity through a computed lookup")
|
|
member = form.removeprefix(".").strip()
|
|
before = code[max(match.start() - 48, 0) : match.start()]
|
|
after = code[match.end() : match.end() + 48]
|
|
if member == "user":
|
|
literal = _USER_BEFORE_RE.search(before) or _USER_AFTER_RE.match(after)
|
|
elif member == "uid":
|
|
literal = _UID_BEFORE_RE.search(before) or _UID_AFTER_RE.match(after)
|
|
else:
|
|
literal = _GROUP_AFTER_RE.match(after)
|
|
if not literal:
|
|
raise HardeningError("polkit rule identity test is not an exact literal")
|
|
|
|
|
|
def _audit_rules_javascript(text: str) -> None:
|
|
"""Require granting rules to gate on exact literal subject identities.
|
|
|
|
With computed tokens, bracket lookups, and subject aliasing rejected in
|
|
every rule file, a YES decision can only be written as the literal grant
|
|
token, so the grant test below cannot be evaded by construction.
|
|
"""
|
|
code, _strings = _split_strings(text)
|
|
if _COMPUTED_TOKEN_RE.search(code):
|
|
raise HardeningError("polkit rule computes values the audit cannot prove")
|
|
if _RESULT_USE_RE.search(code):
|
|
raise HardeningError("polkit rule reads results through a computed lookup")
|
|
for bare in _BARE_SUBJECT_RE.finditer(code):
|
|
if not _PARAMETER_BEFORE_RE.search(code[: bare.start()]):
|
|
raise HardeningError("polkit rule passes the subject to computed code")
|
|
if not _GRANT_RE.search(code):
|
|
return
|
|
if _OPERATOR_ADJACENT_RE.search(code):
|
|
raise HardeningError("polkit grant builds values with operators")
|
|
uses = 0
|
|
for match in _IDENTITY_USE_RE.finditer(code):
|
|
uses += 1
|
|
_require_literal_identity_use(code, match)
|
|
if not uses:
|
|
raise HardeningError("polkit grant is not scoped to an explicit identity")
|
|
|
|
|
|
def _audit_pkla(text: str) -> None:
|
|
"""Require granting localauthority entries to name exact literal identities."""
|
|
if not _PKLA_GRANT_RE.search(text):
|
|
return
|
|
identities: list[str] = []
|
|
for line in text.splitlines():
|
|
name, separator, value = line.strip().partition("=")
|
|
if name.strip() != "Identity" or not separator:
|
|
continue
|
|
entries = [entry.strip() for entry in value.split(";") if entry.strip()]
|
|
identities.extend(entries)
|
|
if not identities:
|
|
raise HardeningError("polkit authority grant lacks an identity scope")
|
|
for entry in identities:
|
|
if not _PKLA_IDENTITY_RE.fullmatch(entry):
|
|
raise HardeningError("polkit authority identity is not an exact literal")
|
|
|
|
|
|
def audit_polkit_policy(path: Path, text: str) -> None:
|
|
"""Prove one polkit policy file cannot grant through a computed identity."""
|
|
if "localauthority" in path.parts or path.suffix == ".pkla":
|
|
_audit_pkla(text)
|
|
else:
|
|
_audit_rules_javascript(text)
|