From 6e9e8c6ab5e5cc560e45a606c47ab83b8c261d64 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 17 Aug 2026 20:37:30 -0300 Subject: [PATCH] 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 --- services/hermes/scripts/node_polkit_audit.py | 7 +++ .../tests/test_hermes_node_polkit_identity.py | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/services/hermes/scripts/node_polkit_audit.py b/services/hermes/scripts/node_polkit_audit.py index 281f1484..d45a0134 100644 --- a/services/hermes/scripts/node_polkit_audit.py +++ b/services/hermes/scripts/node_polkit_audit.py @@ -85,6 +85,13 @@ def _split_strings(text: str) -> tuple[str, list[str]]: 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 diff --git a/testing/tests/test_hermes_node_polkit_identity.py b/testing/tests/test_hermes_node_polkit_identity.py index 059f05f2..3af8d095 100644 --- a/testing/tests/test_hermes_node_polkit_identity.py +++ b/testing/tests/test_hermes_node_polkit_identity.py @@ -124,6 +124,62 @@ def test_unprovable_rule_constructs_fail_closed(text: str, match: str): _rules(module, text) +def test_regex_literal_tokenizer_desync_grant_is_rejected(): + """A regex literal embedding a quote must not open a phantom string. + + Without regex awareness the two /'/ literals fool the string scanner into + swallowing the unconditional Result.YES between their quotes; the grant + then reads as unscoped and would slip past every later check. + """ + module = _load() + exploit = ( + "polkit.addRule(function(action, subject) { " + "var a = /'/; " + 'if (action.id == "org.freedesktop.policykit.exec") ' + "{ return polkit.Result.YES; } " + "var b = /'/; });\n" + ) + with pytest.raises(module.HardeningError, match="regex or division"): + _rules(module, exploit) + + +@pytest.mark.parametrize( + "snippet", + [ + "var a = /'/;", + 'var a = /"/;', + "var a = /abc/;", + "var a = /[/'\"]/;", + "var a = /x\\/y/;", + "var a = subject.user / 2;", + "// a comment / with a slash then\nvar a = /'/;", + '/* block */ var a = /"/;', + "return polkit.Result.YES / 1;", + ], +) +def test_every_slash_form_outside_a_comment_fails_closed(snippet: str): + module = _load() + text = f"polkit.addRule(function(action, subject) {{ {snippet} }});\n" + with pytest.raises(module.HardeningError, match="regex or division"): + _rules(module, text) + + +def test_slashes_confined_to_strings_and_comments_stay_legal(): + module = _load() + # Slashes inside string literals and // or /* */ comments are inert. + _rules( + module, + "// path-like /usr/bin comment\n" + "/* another /etc/ comment */\n" + "polkit.addRule(function(action, subject) {\n" + ' if (action.id == "org.freedesktop.systemd1.manage-units" &&\n' + ' subject.user == "systemd-network") {\n' + " return polkit.Result.YES;\n" + " }\n" + "});\n", + ) + + def test_string_and_comment_handling_keeps_literal_rules_auditable(): module = _load() code, strings = module._split_strings(