#!/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"(? 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)