Compare commits

...

3 Commits

Author SHA1 Message Date
jenkins
31439f096a hermes: bound streaming git DNS resolution
The watchdog can only close a live socket, but getaddrinfo runs before
any socket exists and ignores socket timeouts, so a slow resolver
outlived the stream deadline. Resolve under the same absolute deadline
in a joinable worker before the real connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:37:30 -03:00
jenkins
6c1123201e hermes: audit every sudo include spelling
A quoted, space-bearing #include path failed the include regex and fell
through to the comment branch, leaving a second authority file
unenumerated. Detect any include directive before the comment rule and
fail closed on every form except the exact bare includedir into the
audited /etc/sudoers.d.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:37:30 -03:00
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
6 changed files with 254 additions and 9 deletions

View File

@ -227,6 +227,31 @@ def open_bounded(
)
def _resolve_within(host: str, port: int, timeout: float) -> None:
"""Resolve one host under a hard timeout the socket layer never covers.
``getaddrinfo`` ignores socket timeouts and runs before any socket
exists, so a slow or hostile resolver would otherwise outlive the whole
stream deadline. Running it in a joinable worker bounds resolution by the
same wall clock; a stuck lookup leaks only one daemon thread.
"""
outcome: dict[str, object] = {}
def run() -> None:
try:
outcome["value"] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
outcome["error"] = exc
worker = threading.Thread(target=run, daemon=True)
worker.start()
worker.join(timeout)
if worker.is_alive():
raise PolicyError("HTTP stream DNS resolution deadline exceeded")
if "error" in outcome:
raise outcome["error"] # type: ignore[misc]
class StreamDeadline:
"""Force-close tracked connections once an absolute deadline passes."""
@ -270,6 +295,23 @@ class StreamDeadline:
raise PolicyError("HTTP stream deadline exceeded")
return connection
def _bounded_connection(self, connection: http.client.HTTPConnection):
"""Register one connection and bound its DNS phase by the deadline.
The watchdog can only close a live socket, but ``connect`` resolves
the host before any socket exists. Wrapping ``connect`` runs that
resolution under the same absolute deadline first, then hands off to
the real connect for the socket phases the watchdog already covers.
"""
original_connect = connection.connect
def connect() -> None:
_resolve_within(connection.host, connection.port, self.remaining())
original_connect()
connection.connect = connect # type: ignore[method-assign]
return self._track(connection)
def cancel(self) -> None:
self._timer.cancel()
@ -280,7 +322,7 @@ class StreamDeadline:
class GuardedHTTPHandler(urllib.request.HTTPHandler):
def http_open(self, req):
return self.do_open(
lambda host, **kwargs: deadline._track(
lambda host, **kwargs: deadline._bounded_connection(
http.client.HTTPConnection(host, **kwargs)
),
req,
@ -289,7 +331,7 @@ class StreamDeadline:
class GuardedHTTPSHandler(urllib.request.HTTPSHandler):
def https_open(self, req):
return self.do_open(
lambda host, **kwargs: deadline._track(
lambda host, **kwargs: deadline._bounded_connection(
http.client.HTTPSConnection(host, **kwargs)
),
req,

View File

@ -89,13 +89,22 @@ def _active_sudo_policy(text: str) -> str:
stripped = line.lstrip()
if not stripped:
continue
include = re.fullmatch(
r"(?i)([#@]include|[#@]includedir)\s+([^\s]+)", stripped
)
if include:
directive, target = include.groups()
if directive.lower().endswith("includedir") and target == "/etc/sudoers.d":
continue
# Detect any include spelling before the comment rule below. sudo
# honours both #include and @include; a quoted or space-bearing path
# must never fall through the "#" comment branch and leave a second
# authority file unaudited. Only the exact bare includedir into the
# audited /etc/sudoers.d passes; everything else fails closed.
if re.match(r"(?i)[#@]include(?:dir)?(?=\s|$)", stripped):
include = re.fullmatch(
r"(?i)([#@]include(?:dir)?)\s+([^\s\"'\\]+)", stripped
)
if include:
directive, target = include.groups()
if (
directive.lower().endswith("includedir")
and target == "/etc/sudoers.d"
):
continue
raise HardeningError("sudo policy includes an unaudited authority source")
if stripped.startswith("#") and not re.match(r"#\d+(?:\s|$)", stripped):
continue

View File

@ -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

View File

@ -107,6 +107,93 @@ def test_handlers_build_guarded_connections_without_network(monkeypatch):
guard.cancel()
def test_dns_resolution_is_bounded_by_the_stream_deadline(monkeypatch):
module = _load("deadline_http")
def hang(*_args, **_kwargs):
time.sleep(30)
monkeypatch.setattr(module.socket, "getaddrinfo", hang)
started = time.monotonic()
with pytest.raises(module.PolicyError, match="DNS resolution deadline"):
module._resolve_within("example.invalid", 443, 0.4)
assert time.monotonic() - started < 3
def test_resolve_within_returns_on_success_and_propagates_errors(monkeypatch):
module = _load("deadline_http")
calls = []
monkeypatch.setattr(
module.socket, "getaddrinfo", lambda *a, **k: calls.append((a, k))
)
module._resolve_within("127.0.0.1", 80, 5)
assert calls
def boom(*_a, **_k):
raise OSError("name resolution failed")
monkeypatch.setattr(module.socket, "getaddrinfo", boom)
with pytest.raises(OSError, match="name resolution failed"):
module._resolve_within("127.0.0.1", 80, 5)
def test_bounded_connection_wraps_connect_to_resolve_first(monkeypatch):
module = _load("deadline_http")
guard = module.StreamDeadline(30, timer=_FakeTimer)
order = []
monkeypatch.setattr(
module,
"_resolve_within",
lambda host, port, timeout: order.append(("resolve", host, port)),
)
class FakeConnection:
host = "scm.bstein.dev"
port = 443
def __init__(self):
self.sock = None
def connect(self):
order.append(("connect", self.host))
def close(self):
order.append(("close",))
connection = guard._bounded_connection(FakeConnection())
assert connection in guard._connections
connection.connect()
assert order == [("resolve", "scm.bstein.dev", 443), ("connect", "scm.bstein.dev")]
guard.cancel()
def test_bounded_connection_connect_fails_closed_once_expired(monkeypatch):
module = _load("deadline_http")
guard = module.StreamDeadline(30, timer=_FakeTimer)
resolved = []
monkeypatch.setattr(
module, "_resolve_within", lambda *a, **k: resolved.append(a)
)
class FakeConnection:
host = "scm.bstein.dev"
port = 443
sock = None
def connect(self):
resolved.append("connected")
def close(self):
pass
connection = guard._bounded_connection(FakeConnection())
guard._expire()
with pytest.raises(module.PolicyError, match="deadline exceeded"):
connection.connect()
assert resolved == []
guard.cancel()
class _TrickleHandler(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(10)

View File

@ -105,6 +105,50 @@ def test_active_sudo_policy_handles_blank_comments_numeric_and_includes():
module._active_sudo_policy("@include /external/policy\n")
@pytest.mark.parametrize(
"directive",
[
'#include "/etc/evil path"',
"#include '/etc/evil path'",
"#include /etc/evil\\ path",
"#include\t/etc/evil",
"#include /etc/evil",
"#includedir /opt/other",
'#includedir "/etc/sudoers.d"',
"@include /external/policy",
'@include "/etc/evil path"',
"@includedir /opt/other",
"#include",
"@includedir",
],
)
def test_every_include_spelling_outside_the_standard_dir_fails_closed(directive: str):
"""No include form may reach the comment branch and stay unaudited."""
module = _audit_module()
with pytest.raises(module.HardeningError, match="unaudited"):
module._active_sudo_policy(directive + "\n")
@pytest.mark.parametrize(
"directive",
["#includedir /etc/sudoers.d", "@includedir /etc/sudoers.d"],
)
def test_standard_includedir_is_the_only_allowed_include(directive: str):
module = _audit_module()
active = module._active_sudo_policy(directive + "\nroot ALL=(ALL) ALL\n")
assert active == "root ALL=(ALL) ALL"
@pytest.mark.parametrize(
"line",
["#included by an operator note", "#include-guard notes", "#includes history"],
)
def test_comment_lines_that_only_resemble_includes_stay_comments(line: str):
module = _audit_module()
active = module._active_sudo_policy(line + "\nroot ALL=(ALL) ALL\n")
assert active == "root ALL=(ALL) ALL"
def test_identity_matcher_covers_account_numeric_and_unrelated_values():
module = _audit_module()
assert module._mentions_dedicated_identity("hermes-agent ALL", "hermes-agent", 1200)

View File

@ -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(