atlas-iac/services/hermes/scripts/node_polkit_audit.py
jenkins 6e9e8c6ab5 hermes: reject regex literals in polkit grants
A regex literal embedding a quote desynced the string tokenizer and
swallowed an unconditional Result.YES, so the grant read as unscoped and
slipped past the literal-identity check. Fail closed on any slash outside
a string or comment; neither regex nor division belongs in an
identity-scoped grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:37:30 -03:00

169 lines
7.4 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
if character == "/":
# Any remaining slash is a regex literal or a division operator.
# A regex literal may embed a quote character, which would desync
# the string scanner and swallow an unconditional grant. Neither
# regex nor division has a place in an identity-scoped grant, so
# both fail closed before they can hide a Result.YES.
raise HardeningError("polkit rule uses a regex or division operator")
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)