From 21e1ee7460cac9c5b4a20ec573d859b04e1017a4 Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 16 Oct 2026 11:48:08 +0200 Subject: [PATCH 1/3] perf(monitor): skip anomaly sanitizing when no callbacks are registered --- src/Detection/PerformanceMonitor.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Detection/PerformanceMonitor.php b/src/Detection/PerformanceMonitor.php index fa1ec71..58346a7 100644 --- a/src/Detection/PerformanceMonitor.php +++ b/src/Detection/PerformanceMonitor.php @@ -278,6 +278,9 @@ private function sendAnomalyEvent(array $anomaly, object $agentHandler, ?string */ private function notifyCallbacks(array $anomaly, ?object $agentHandler, ?string $correlationId): void { + if ($this->anomalyCallbacks === []) { + return; + } $safeAnomaly = MonitorAnomalies::sanitizeAnomalyData($anomaly); foreach ($this->anomalyCallbacks as $callback) { try { From d917d8e04f65010f0493e5f8019c759750142921 Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 16 Oct 2026 13:25:12 +0200 Subject: [PATCH 2/3] perf(detection): charge the scan budget CPU per pass instead of per pattern The per-request scan budget read getrusage() twice per pattern. It now reads the CPU clock when a pass opens, at each 2 ms wall-clock slice boundary and when the pass closes, matching the reference engine. The exhausted check stays arithmetic-only, so the budget lags by at most one slice plus one pattern. --- bin/test_scan_time_budget.php | 68 +++++++++++++++++++++++++---------- src/Detection/SusPatterns.php | 61 ++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/bin/test_scan_time_budget.php b/bin/test_scan_time_budget.php index 829194e..ccc1fe3 100644 --- a/bin/test_scan_time_budget.php +++ b/bin/test_scan_time_budget.php @@ -134,6 +134,43 @@ function run(SuspiciousActivityCheck $check, string $path = '/items', array $hea $sus->endScan(); $t->same(true, $calls < 40, "clock is read a handful of times, not once per catalog pattern ({$calls})"); +$t->section('CPU clock is read a small constant number of times per pass'); +$reads = 0; +SusPatterns::$cpuClock = static function () use (&$reads): float { + $reads++; + + return 0.0; +}; +$sus = new SusPatterns(); +$sus->beginScan(60.0); +$sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$sus->endScan(); +$t->same(true, $reads >= 2 && $reads <= 12, "one open read and one close read per pass, plus slice charges ({$reads} reads over several hundred patterns)"); +$reads = 0; +$sus = new SusPatterns(); +$sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$t->same(0, $reads, 'no CPU clock read outside a request scan'); + +$t->section('a long pass stops at the budget within about one slice'); +// One fake clock shared by wall and CPU: every wall read (one per pattern) +// advances 1 ms, so a 50 ms cap is spent after about 50 patterns, and the +// scan may overshoot it by at most one 2 ms slice plus one pattern. +$fakeNs = 0; +SusPatterns::$wallClockNs = static function () use (&$fakeNs): int { + return $fakeNs += 1_000_000; +}; +SusPatterns::$cpuClock = static function () use (&$fakeNs): float { + return $fakeNs / 1e9; +}; +$sus = new SusPatterns(); +$sus->beginScan(0.05); +$result = $sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$spent = (new ReflectionProperty($sus, 'scanSpent'))->getValue($sus); +$sus->endScan(); +SusPatterns::$wallClockNs = null; +$t->same(true, in_array(BUDGET_PATTERN, array_column($result['threats'], 'pattern'), true), 'budget threat is recorded'); +$t->same(true, $spent >= 0.05 && $spent <= 0.05 + 0.002 + 0.001, "stopped within about one slice plus one pattern of the cap (charged {$spent} s for a 0.05 s cap)"); + $t->section('budget is shared across values of one request scan'); fakeClock(1.0); $sus = new SusPatterns(); @@ -188,25 +225,20 @@ function run(SuspiciousActivityCheck $check, string $path = '/items', array $hea fakeClock(1.0); [, $reason] = run(makeCheck($config), '/items', ['x-pad' => 'hello']); $t->same(false, str_contains($reason, '; ' . BUDGET_MESSAGE), 'budget threat alone has no appended form'); -// Calibrate: how many patterns does an attack header scan across every view? +// CPU is charged per pass (a one-second clock step costs two seconds per +// pass), so sweeping an integer cap lands the exhaustion between the passes +// of the attack header's detect(): its threat is recorded, then the budget. $attackHeader = "1' OR '1'='1 UNION SELECT password FROM users--"; -$reads = 0; -SusPatterns::$cpuClock = static function () use (&$reads): float { - $reads++; - - return 0.0; -}; -run(makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0)), '/items', ['x-attack' => $attackHeader]); -$scanned = intdiv($reads, 2); -// Each pattern costs $step; the 60 s cap is spent late in the request scan, -// after the attack header has already been scanned. -$step = 60.0 / ($scanned * 0.85); -$now = 0.0; -SusPatterns::$cpuClock = static function () use (&$now, $step): float { - return $now += $step; -}; -$check = makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0)); -[$blocked, $reason] = run($check, '/items', ['x-attack' => $attackHeader]); +$blocked = false; +$reason = ''; +for ($cap = 1; $cap <= 40; $cap++) { + $check = makeCheck(new SecurityConfig(detectionMaxScanSeconds: (float) $cap)); + fakeClock(1.0); + [$blocked, $reason] = run($check, '/items', ['x-attack' => $attackHeader]); + if (str_ends_with($reason, '; ' . BUDGET_MESSAGE)) { + break; + } +} $t->same(true, $blocked, 'attack header with a mid-scan exhausted budget blocks'); $t->same(true, str_ends_with($reason, '; ' . BUDGET_MESSAGE), 'reason is "; "'); $t->same(true, str_contains($reason, "Value matched pattern"), 'the first threat message leads the reason'); diff --git a/src/Detection/SusPatterns.php b/src/Detection/SusPatterns.php index e45841a..f004a93 100644 --- a/src/Detection/SusPatterns.php +++ b/src/Detection/SusPatterns.php @@ -80,6 +80,20 @@ private static function compilerTimeout(): float */ public static ?\Closure $cpuClock = null; + /** + * Test hook: replaces the wall clock (nanoseconds) that decides the 2 ms + * slice boundaries, so a fake clock can drive the slice deterministically. + * Null uses hrtime(true). + * + * @var (\Closure(): int)|null + */ + public static ?\Closure $wallClockNs = null; + + private static function wallNs(): int + { + return self::$wallClockNs !== null ? (self::$wallClockNs)() : hrtime(true); + } + private Preprocessor $preprocessor; private float $semanticThreshold; @@ -88,6 +102,15 @@ private static function compilerTimeout(): float private float $scanSpent = 0.0; + /** Wall-clock (hrtime ns) start of the current 2 ms slice; request-scoped, spans values. */ + private int $sliceStartNs = 0; + + /** CPU clock reading at the last charge of the open pass, null when no pass is charging. */ + private ?float $cpuMark = null; + + /** Wall-clock span after which a pass charges its CPU (the reference _EVENT_LOOP_SLICE_SECONDS). */ + private const SLICE_NS = 2_000_000; + private bool $scanCapWarned = false; /** @@ -198,6 +221,7 @@ public function beginScan(float $maxScanSeconds): void $this->scanCap = $maxScanSeconds; $this->scanSpent = 0.0; $this->scanCapWarned = false; + $this->sliceStartNs = self::wallNs(); } /** Closes the request scan budget; detect() is unbounded again. */ @@ -239,17 +263,27 @@ private function recordBudgetExhausted(string $ip, array &$threats, array &$matc $timeouts[] = $pattern; } - /** @return array{0: array|null, 1: bool} scanPattern() result, its CPU time charged to the budget */ + /** @return array{0: array|null, 1: bool} scanPattern() result; CPU is charged per pass at slice boundaries */ private function scanPatternCharged(string $source, string $category, string $content, string $validatorContext, ?array $binaryPrefix): array { - if ($this->scanCap === null) { - return $this->scanPattern($source, $category, $content, $validatorContext, $binaryPrefix); + $result = $this->scanPattern($source, $category, $content, $validatorContext, $binaryPrefix); + if ($this->cpuMark !== null) { + $nowNs = self::wallNs(); + if ($nowNs - $this->sliceStartNs >= self::SLICE_NS) { + $this->chargeInlineCpu(); + $this->sliceStartNs = $nowNs; + } } - $start = self::cpuSeconds(); - try { - return $this->scanPattern($source, $category, $content, $validatorContext, $binaryPrefix); - } finally { - $this->scanSpent += self::cpuSeconds() - $start; + + return $result; + } + + private function chargeInlineCpu(): void + { + if ($this->cpuMark !== null) { + $now = self::cpuSeconds(); + $this->scanSpent += $now - $this->cpuMark; + $this->cpuMark = $now; } } @@ -523,6 +557,17 @@ private static function firstLineByteLength(string $content): int } private function checkRegexPatterns(string $content, string $context, string $viewMode, string $ip = ''): array + { + $this->cpuMark = $this->scanCap === null ? null : self::cpuSeconds(); + try { + return $this->checkRegexPatternsPass($content, $context, $viewMode, $ip); + } finally { + $this->chargeInlineCpu(); + $this->cpuMark = null; + } + } + + private function checkRegexPatternsPass(string $content, string $context, string $viewMode, string $ip = ''): array { $threats = []; $matchedPatterns = []; From ae5d5e74e779bdab7964cd05931a7047345b66f4 Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 16 Oct 2026 14:40:27 +0200 Subject: [PATCH 3/3] test(budget): cover the registry-loop budget stop and move the surrogate waiver to its new lines --- .github/coverage-unreachable.php | 2 +- bin/test_scan_time_budget.php | 66 +++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index 7ed1be7..ada808c 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -152,7 +152,7 @@ // there (it emits the \xNN reporting escape); the 8.2 matrix jobs run // without pcov. 'src/Detection/SusPatterns.php' => [ - 318, 319, + 352, 353, ], // The scheme position comes from an https?:// match, so the anchored diff --git a/bin/test_scan_time_budget.php b/bin/test_scan_time_budget.php index ccc1fe3..a404a70 100644 --- a/bin/test_scan_time_budget.php +++ b/bin/test_scan_time_budget.php @@ -171,6 +171,38 @@ function run(SuspiciousActivityCheck $check, string $path = '/items', array $hea $t->same(true, in_array(BUDGET_PATTERN, array_column($result['threats'], 'pattern'), true), 'budget threat is recorded'); $t->same(true, $spent >= 0.05 && $spent <= 0.05 + 0.002 + 0.001, "stopped within about one slice plus one pattern of the cap (charged {$spent} s for a 0.05 s cap)"); +$t->section('a budget spent by the last catalog pattern stops before the registry patterns'); +// Every wall read crosses a slice and every CPU read adds one second, so the +// first pass spends one second per scanned catalog pattern. The smallest cap +// that still lets the registry pattern run marks the pass's catalog size. +$wallNs = 0; +SusPatterns::$wallClockNs = static function () use (&$wallNs): int { + return $wallNs += 2_000_000; +}; +$probe = 'zzregistryprobe'; +$scanWithCap = static function (float $cap) use ($probe): array { + fakeClock(1.0); + $sus = new SusPatterns(); + $sus->addPattern($probe, true); + $sus->beginScan($cap); + $result = $sus->detect($probe, '9.9.9.9', 'unknown'); + $sus->endScan(); + + return array_column($result['threats'], 'pattern'); +}; +$catalogScanned = 0; +for ($k = 1; $k <= 1000; $k++) { + if (in_array($probe, $scanWithCap($k + 0.5), true)) { + $catalogScanned = $k; + break; + } +} +$patterns = $scanWithCap($catalogScanned - 0.5); +SusPatterns::$wallClockNs = null; +$t->same(true, $catalogScanned > 1, "the first pass scans the catalog before the registry ({$catalogScanned} patterns)"); +$t->same(true, in_array(BUDGET_PATTERN, $patterns, true), 'budget threat is recorded when the last catalog pattern spends it'); +$t->same(false, in_array($probe, $patterns, true), 'the registry pattern is not scanned once the budget is spent'); + $t->section('budget is shared across values of one request scan'); fakeClock(1.0); $sus = new SusPatterns(); @@ -225,20 +257,28 @@ function run(SuspiciousActivityCheck $check, string $path = '/items', array $hea fakeClock(1.0); [, $reason] = run(makeCheck($config), '/items', ['x-pad' => 'hello']); $t->same(false, str_contains($reason, '; ' . BUDGET_MESSAGE), 'budget threat alone has no appended form'); -// CPU is charged per pass (a one-second clock step costs two seconds per -// pass), so sweeping an integer cap lands the exhaustion between the passes -// of the attack header's detect(): its threat is recorded, then the budget. +// Calibrate: how many patterns does an attack header scan across every view? +// Every wall read crosses a 2 ms slice, so the CPU is charged once per +// pattern and each pattern costs one clock step, as before per-pass charging. $attackHeader = "1' OR '1'='1 UNION SELECT password FROM users--"; -$blocked = false; -$reason = ''; -for ($cap = 1; $cap <= 40; $cap++) { - $check = makeCheck(new SecurityConfig(detectionMaxScanSeconds: (float) $cap)); - fakeClock(1.0); - [$blocked, $reason] = run($check, '/items', ['x-attack' => $attackHeader]); - if (str_ends_with($reason, '; ' . BUDGET_MESSAGE)) { - break; - } -} +$wallNs = 0; +SusPatterns::$wallClockNs = static function () use (&$wallNs): int { + return $wallNs += 2_000_000; +}; +$wallNs = 0; +SusPatterns::$cpuClock = static fn (): float => 0.0; +run(makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0)), '/items', ['x-attack' => $attackHeader]); +$scanned = intdiv($wallNs, 2_000_000); +// Each pattern costs $step; the 60 s cap is spent late in the request scan, +// after the attack header has already been scanned. +$step = 60.0 / ($scanned * 0.85); +$now = 0.0; +SusPatterns::$cpuClock = static function () use (&$now, $step): float { + return $now += $step; +}; +$check = makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0)); +[$blocked, $reason] = run($check, '/items', ['x-attack' => $attackHeader]); +SusPatterns::$wallClockNs = null; $t->same(true, $blocked, 'attack header with a mid-scan exhausted budget blocks'); $t->same(true, str_ends_with($reason, '; ' . BUDGET_MESSAGE), 'reason is "; "'); $t->same(true, str_contains($reason, "Value matched pattern"), 'the first threat message leads the reason');