Compare commits
3 Commits
5020e49057
...
31439f096a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31439f096a | ||
|
|
6c1123201e | ||
|
|
6e9e8c6ab5 |
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user