Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions tests/test_detection/test_builtin_pattern_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def test_windowed_registries_share_no_pattern() -> None:


_SCAN_WINDOW_TIMING_RUNS = 5
_HANG_GUARD_SECONDS = 60.0


def _scan_window_child(
Expand Down Expand Up @@ -1368,9 +1369,12 @@ def test_hostname_fragment_not_catastrophic_with_failing_tail() -> None:
f"hostname group still has outer * (nested unbounded quantifier): {fragment}"
)
probe = fragment.rstrip("?") + r"$"
results = _timed_batch(probe, ["a" * 25 + "!"], timeout=1.0)
assert results is not None, (
f"hostname fragment timed out with failing tail: {probe}"
# The 1s budget is the child's CPU time; the wall-clock join is only a
# hang guard, since forkserver start-up alone outruns 1s on a loaded runner.
results = _timed_batch(probe, ["a" * 25 + "!"], timeout=_HANG_GUARD_SECONDS)
assert results is not None, f"hostname fragment hung with failing tail: {probe}"
assert results[0] < 1.0, (
f"hostname fragment used {results[0]:.3f}s of CPU with failing tail: {probe}"
)


Expand Down
34 changes: 17 additions & 17 deletions tests/test_detection/test_builtin_redos_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

_REDOS_PAYLOAD = "{{" * 10000
_DETECT_DEADLINE_SECONDS = 2.0
_HANG_GUARD_SECONDS = 60.0
_COMPILER_TIMEOUT_SECONDS = 0.3
_MAX_CONTENT_LENGTH = 20000

Expand All @@ -26,12 +27,12 @@ def _child_detect(
)
manager = SusPatternsManager()
manager.configure(config)
t0 = time.monotonic()
t0 = time.process_time()
result = asyncio.run(manager.detect(payload, "127.0.0.1", context="request_body"))
elapsed = time.monotonic() - t0
cpu = time.process_time() - t0
q.put(
{
"elapsed": elapsed,
"cpu": cpu,
"is_threat": bool(result["is_threat"]),
"timeout_count": len(result["timeouts"]),
"detection_method": result["detection_method"],
Expand All @@ -40,7 +41,7 @@ def _child_detect(


def _run_detect_under_deadline(
config: SecurityConfig, payload: str, deadline: float
config: SecurityConfig, payload: str
) -> dict[str, Any] | None:
ctx = mp.get_context("forkserver")
q: mp.Queue[dict[str, Any]] = ctx.Queue()
Expand All @@ -54,7 +55,10 @@ def _run_detect_under_deadline(
),
)
proc.start()
proc.join(deadline)
# Wall clock only guards against a hang: forkserver start-up and imports
# alone outrun a 2s wall budget on a loaded runner. The deadline itself is
# the child's CPU time around detect(), which load does not inflate.
proc.join(_HANG_GUARD_SECONDS)
if proc.is_alive():
proc.terminate()
proc.join()
Expand All @@ -70,15 +74,13 @@ def _enhanced_config() -> SecurityConfig:


def test_builtin_template_redos_protected_in_enhanced_mode() -> None:
result = _run_detect_under_deadline(
_enhanced_config(), _REDOS_PAYLOAD, _DETECT_DEADLINE_SECONDS
)
result = _run_detect_under_deadline(_enhanced_config(), _REDOS_PAYLOAD)
assert result is not None, (
"detect did not return within the deadline; the built-in template "
"detect did not return within the hang guard; the built-in template "
"pattern ran an unguarded re.search and ReDoSed the worker"
)
assert result["elapsed"] < _DETECT_DEADLINE_SECONDS, (
f"detect took {result['elapsed']:.3f}s, exceeding the "
assert result["cpu"] < _DETECT_DEADLINE_SECONDS, (
f"detect used {result['cpu']:.3f}s of CPU, exceeding the "
f"{_DETECT_DEADLINE_SECONDS}s deadline"
)
assert result["detection_method"] == "enhanced"
Expand All @@ -87,16 +89,14 @@ def test_builtin_template_redos_protected_in_enhanced_mode() -> None:

def test_builtin_ldap_extensible_match_redos_protected_in_enhanced_mode() -> None:
result = _run_detect_under_deadline(
_enhanced_config(),
_LDAP_EXTENSIBLE_MATCH_REDOS_PAYLOAD,
_DETECT_DEADLINE_SECONDS,
_enhanced_config(), _LDAP_EXTENSIBLE_MATCH_REDOS_PAYLOAD
)
assert result is not None, (
"detect did not return within the deadline; the ldap extensible-match "
"detect did not return within the hang guard; the ldap extensible-match "
"attribute pattern's ambiguous ':dn' alternation ReDoSed the worker"
)
assert result["elapsed"] < _DETECT_DEADLINE_SECONDS, (
f"detect took {result['elapsed']:.3f}s, exceeding the "
assert result["cpu"] < _DETECT_DEADLINE_SECONDS, (
f"detect used {result['cpu']:.3f}s of CPU, exceeding the "
f"{_DETECT_DEADLINE_SECONDS}s deadline"
)
assert result["detection_method"] == "enhanced"
Expand Down
18 changes: 13 additions & 5 deletions tests/test_detection_engine/test_scan_window_timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,20 @@ def test_bounded_scan_stays_under_five_milliseconds_on_all_three_fill_shapes() -
measured = _run_probe()["shapes"]

for shape_name, shape_data in measured.items():
sizes = shape_data["sizes"]
bounded_min = [entry["min"] for entry in shape_data["bounded"]]
for size, cpu in zip(shape_data["sizes"], bounded_min, strict=True):
assert cpu < _BOUNDED_CPU_CEILING_SECONDS, (
f"{shape_name} at size={size}: bounded CPU {cpu:.6f}s exceeded "
f"the {_BOUNDED_CPU_CEILING_SECONDS}s ceiling"
)
# The ceiling is _BOUNDED_CPU_CEILING_SECONDS at the largest size and
# scales linearly down. A breach has to repeat at every size: a
# contention spike inflates one size's min-of-N (5.97ms against 5ms on
# a loaded runner, 2.3ms measured idle), while a real regression, a
# quadratic scan or a constant-factor slowdown, breaches all of them.
ceilings = [_BOUNDED_CPU_CEILING_SECONDS * size / max(sizes) for size in sizes]
assert any(
cpu < ceiling for cpu, ceiling in zip(bounded_min, ceilings, strict=True)
), (
f"{shape_name}: bounded CPU {bounded_min} exceeded the "
f"{ceilings} ceilings at sizes={sizes}"
)


def _assert_bounded_scan_grows_linearly() -> None:
Expand Down
24 changes: 16 additions & 8 deletions tests/test_handler_initializer_lazy_init.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import asyncio
import threading
import time
from contextlib import contextmanager
from typing import Any
Expand All @@ -8,6 +7,8 @@
from guard_core.core.initialization.handler_initializer import HandlerInitializer
from guard_core.models import SecurityConfig

_BACKGROUND_BLOCK_SECONDS = 2.0


def _make_initializer(
lazy_init: bool, block_cloud: set[str] | None = None
Expand Down Expand Up @@ -103,22 +104,29 @@ async def slow_geo_init(*args: Any, **kwargs: Any) -> None:
async def test_lazy_init_returns_quickly_with_blocking_background_init() -> None:
initializer, geo_ip, _ = _make_initializer(lazy_init=True, block_cloud={"AWS"})

release = threading.Event()
finished: list[str] = []

async def blocking_cloud_init(*args: Any, **kwargs: Any) -> None:
release.set()
await asyncio.sleep(_BACKGROUND_BLOCK_SECONDS)
finished.append("cloud")

geo_ip.initialize_redis = AsyncMock(side_effect=blocking_cloud_init)
geo_ip.initialize_redis = AsyncMock()

with _patch_handlers() as patches:
patches["cloud"].initialize_redis = AsyncMock(side_effect=blocking_cloud_init)
start = time.perf_counter()
await initializer.initialize_redis_handlers()
elapsed = time.perf_counter() - start

assert elapsed < 0.5
# The contract is "returns without waiting for the background init",
# asserted as ordering rather than a wall-clock budget: the background
# init is still inside its block when the call returns. A lazy init
# that awaited it would have finished it first, however fast the
# runner is (the old 0.5s bound flaked at 0.56-0.69s under load, and
# could not fail on a real regression because the stub never blocked).
assert initializer._lazy_init_task is not None
await asyncio.wait_for(initializer._lazy_init_task, timeout=1.0)
assert finished == []
await asyncio.wait_for(initializer._lazy_init_task, timeout=30.0)
assert finished == ["cloud"]
geo_ip.initialize_redis.assert_awaited_once()


async def test_lazy_init_cloud_failure_still_runs_geo_init() -> None:
Expand Down
34 changes: 17 additions & 17 deletions tests/test_sync/test_detection/test_builtin_redos_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

_REDOS_PAYLOAD = "{{" * 10000
_DETECT_DEADLINE_SECONDS = 2.0
_HANG_GUARD_SECONDS = 60.0
_COMPILER_TIMEOUT_SECONDS = 0.3
_MAX_CONTENT_LENGTH = 20000

Expand All @@ -25,12 +26,12 @@ def _child_detect(
)
manager = SusPatternsManager()
manager.configure(config)
t0 = time.monotonic()
t0 = time.process_time()
result = manager.detect(payload, "127.0.0.1", context="request_body")
elapsed = time.monotonic() - t0
cpu = time.process_time() - t0
q.put(
{
"elapsed": elapsed,
"cpu": cpu,
"is_threat": bool(result["is_threat"]),
"timeout_count": len(result["timeouts"]),
"detection_method": result["detection_method"],
Expand All @@ -39,7 +40,7 @@ def _child_detect(


def _run_detect_under_deadline(
config: SecurityConfig, payload: str, deadline: float
config: SecurityConfig, payload: str
) -> dict[str, Any] | None:
ctx = mp.get_context("forkserver")
q: mp.Queue[dict[str, Any]] = ctx.Queue()
Expand All @@ -53,7 +54,10 @@ def _run_detect_under_deadline(
),
)
proc.start()
proc.join(deadline)
# Wall clock only guards against a hang: forkserver start-up and imports
# alone outrun a 2s wall budget on a loaded runner. The deadline itself is
# the child's CPU time around detect(), which load does not inflate.
proc.join(_HANG_GUARD_SECONDS)
if proc.is_alive():
proc.terminate()
proc.join()
Expand All @@ -69,15 +73,13 @@ def _enhanced_config() -> SecurityConfig:


def test_builtin_template_redos_protected_in_enhanced_mode() -> None:
result = _run_detect_under_deadline(
_enhanced_config(), _REDOS_PAYLOAD, _DETECT_DEADLINE_SECONDS
)
result = _run_detect_under_deadline(_enhanced_config(), _REDOS_PAYLOAD)
assert result is not None, (
"detect did not return within the deadline; the built-in template "
"detect did not return within the hang guard; the built-in template "
"pattern ran an unguarded re.search and ReDoSed the worker"
)
assert result["elapsed"] < _DETECT_DEADLINE_SECONDS, (
f"detect took {result['elapsed']:.3f}s, exceeding the "
assert result["cpu"] < _DETECT_DEADLINE_SECONDS, (
f"detect used {result['cpu']:.3f}s of CPU, exceeding the "
f"{_DETECT_DEADLINE_SECONDS}s deadline"
)
assert result["detection_method"] == "enhanced"
Expand All @@ -86,16 +88,14 @@ def test_builtin_template_redos_protected_in_enhanced_mode() -> None:

def test_builtin_ldap_extensible_match_redos_protected_in_enhanced_mode() -> None:
result = _run_detect_under_deadline(
_enhanced_config(),
_LDAP_EXTENSIBLE_MATCH_REDOS_PAYLOAD,
_DETECT_DEADLINE_SECONDS,
_enhanced_config(), _LDAP_EXTENSIBLE_MATCH_REDOS_PAYLOAD
)
assert result is not None, (
"detect did not return within the deadline; the ldap extensible-match "
"detect did not return within the hang guard; the ldap extensible-match "
"attribute pattern's ambiguous ':dn' alternation ReDoSed the worker"
)
assert result["elapsed"] < _DETECT_DEADLINE_SECONDS, (
f"detect took {result['elapsed']:.3f}s, exceeding the "
assert result["cpu"] < _DETECT_DEADLINE_SECONDS, (
f"detect used {result['cpu']:.3f}s of CPU, exceeding the "
f"{_DETECT_DEADLINE_SECONDS}s deadline"
)
assert result["detection_method"] == "enhanced"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,20 @@ def test_bounded_scan_stays_under_five_milliseconds_on_all_three_fill_shapes() -
measured = _run_probe()["shapes"]

for shape_name, shape_data in measured.items():
sizes = shape_data["sizes"]
bounded_min = [entry["min"] for entry in shape_data["bounded"]]
for size, cpu in zip(shape_data["sizes"], bounded_min, strict=True):
assert cpu < _BOUNDED_CPU_CEILING_SECONDS, (
f"{shape_name} at size={size}: bounded CPU {cpu:.6f}s exceeded "
f"the {_BOUNDED_CPU_CEILING_SECONDS}s ceiling"
)
# The ceiling is _BOUNDED_CPU_CEILING_SECONDS at the largest size and
# scales linearly down. A breach has to repeat at every size: a
# contention spike inflates one size's min-of-N (5.97ms against 5ms on
# a loaded runner, 2.3ms measured idle), while a real regression, a
# quadratic scan or a constant-factor slowdown, breaches all of them.
ceilings = [_BOUNDED_CPU_CEILING_SECONDS * size / max(sizes) for size in sizes]
assert any(
cpu < ceiling for cpu, ceiling in zip(bounded_min, ceilings, strict=True)
), (
f"{shape_name}: bounded CPU {bounded_min} exceeded the "
f"{ceilings} ceilings at sizes={sizes}"
)


def _assert_bounded_scan_grows_linearly() -> None:
Expand Down
24 changes: 16 additions & 8 deletions tests/test_sync/test_handler_initializer_lazy_init.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import threading
import time
from contextlib import contextmanager
from typing import Any
Expand All @@ -7,6 +6,8 @@
from guard_core.models import SecurityConfig
from guard_core.sync.core.initialization.handler_initializer import HandlerInitializer

_BACKGROUND_BLOCK_SECONDS = 2.0


def _make_initializer(
lazy_init: bool, block_cloud: set[str] | None = None
Expand Down Expand Up @@ -73,22 +74,29 @@ def test_lazy_init_schedules_background_task_for_cloud_and_geo() -> None:
def test_lazy_init_returns_quickly_with_blocking_background_init() -> None:
initializer, geo_ip, _ = _make_initializer(lazy_init=True, block_cloud={"AWS"})

release = threading.Event()
finished: list[str] = []

def blocking_cloud_init(*args: Any, **kwargs: Any) -> None:
release.set()
time.sleep(_BACKGROUND_BLOCK_SECONDS)
finished.append("cloud")

geo_ip.initialize_redis = MagicMock(side_effect=blocking_cloud_init)
geo_ip.initialize_redis = MagicMock()

with _patch_handlers() as patches:
patches["cloud"].initialize_redis = MagicMock(side_effect=blocking_cloud_init)
start = time.perf_counter()
initializer.initialize_redis_handlers()
elapsed = time.perf_counter() - start

assert elapsed < 0.5
# The contract is "returns without waiting for the background init",
# asserted as ordering rather than a wall-clock budget: the background
# init is still inside its block when the call returns. A lazy init
# that awaited it would have finished it first, however fast the
# runner is (the old 0.5s bound flaked at 0.56-0.69s under load, and
# could not fail on a real regression because the stub never blocked).
assert initializer._lazy_init_task is not None
initializer._lazy_init_task.join(timeout=1.0)
assert finished == []
initializer._lazy_init_task.join(timeout=30.0)
assert finished == ["cloud"]
geo_ip.initialize_redis.assert_called_once()


def test_lazy_init_cloud_failure_still_runs_geo_init() -> None:
Expand Down
Loading