diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index 772fff8..daaa50a 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' => [ - 200, 201, + 318, 319, ], // The scheme position comes from an https?:// match, so the anchored @@ -255,7 +255,7 @@ // as the BehaviorRule entry above). The suites assert the boundary // TypeError instead (test_json_logging.php). 'src/Config/SecurityConfig.php' => [ - 1105, 1129, + 1120, 1144, ], // 219: the non-array/non-scalar json_decode guard - json_decode can diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c74e6..b0f6865 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,9 @@ jobs: - name: test_decorators run: php bin/test_decorators.php + - name: test_scan_time_budget + run: php bin/test_scan_time_budget.php + conformance-pipeline: name: Conformance pipeline (php ${{ matrix.php }}) runs-on: ubuntu-latest @@ -243,7 +246,7 @@ jobs: run: | mkdir -p build php .github/coverage-runner.php --collect bin/test_state.php build/coverage-test_state.php --integration - for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators; do + for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators test_scan_time_budget; do php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php" done diff --git a/Makefile b/Makefile index 30fe500..fa46982 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ REDIS_HOST ?= host.docker.internal # Runners mirrored from CI (.github/workflows/ci.yml). Redis-backed # runners are listed in REDIS_RUNNERS and get REDIS_HOST passed through. REDIS_RUNNERS = bin/test_ratelimit.php bin/test_geo_rate_limits.php bin/test_pipeline.php bin/test_m3b.php bin/test_m4.php bin/test_behavior.php bin/conformance_redis.php -RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php +RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php bin/test_scan_time_budget.php .PHONY: install test lint bump-version clean diff --git a/bin/test_scan_time_budget.php b/bin/test_scan_time_budget.php new file mode 100644 index 0000000..829194e --- /dev/null +++ b/bin/test_scan_time_budget.php @@ -0,0 +1,257 @@ +passed++; + echo "ok - {$label}\n"; + } else { + $this->failed++; + echo "FAIL - {$label}\n"; + echo ' expected: ' . var_export($expected, true) . "\n"; + echo ' actual: ' . var_export($actual, true) . "\n"; + } + } + + public function section(string $name): void + { + echo "\n=== {$name} ===\n"; + } +} + +ini_set('error_log', '/dev/null'); + +/** A fake CPU clock: every read advances by $step seconds (mirrors _FakeClock). */ +function fakeClock(float $step): void +{ + $now = 0.0; + SusPatterns::$cpuClock = static function () use (&$now, $step): float { + $now += $step; + + return $now; + }; +} + +function makeCheck(SecurityConfig $config, float $threshold = 1.0): SuspiciousActivityCheck +{ + return new SuspiciousActivityCheck( + $config, + new GuardResponseFactory(), + new SusPatterns($config->detectionSemanticThreshold, threatScoreThreshold: $threshold), + null, + new RouteResolver() + ); +} + +/** @return array{0: bool, 1: string} [blocked, block reason] */ +function run(SuspiciousActivityCheck $check, string $path = '/items', array $headers = []): array +{ + $request = new SimpleGuardRequest(urlPath: $path, method: 'GET', headers: $headers); + $request->state()->clientIp = '9.9.9.9'; + $response = $check->check($request); + + return [$response instanceof GuardResponse, (string) ($request->state()->guardBlockStash['reason'] ?? '')]; +} + +const BUDGET_PATTERN = 'scan_time_budget_exhausted'; +const BUDGET_MESSAGE = "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"; + +$t = new T(); + +$t->section('config: detection_max_scan_seconds default and bounds'); +$t->same(10.0, (new SecurityConfig())->detectionMaxScanSeconds, 'default is 10.0'); +$t->same(0.1, (new SecurityConfig(detectionMaxScanSeconds: 0.1))->detectionMaxScanSeconds, 'lower bound 0.1 accepted'); +$t->same(60.0, (new SecurityConfig(detectionMaxScanSeconds: 60.0))->detectionMaxScanSeconds, 'upper bound 60.0 accepted'); +foreach ([0.0, 0.09, 60.1] as $bad) { + $message = null; + try { + new SecurityConfig(detectionMaxScanSeconds: $bad); + } catch (\InvalidArgumentException $e) { + $message = $e->getMessage(); + } + $t->same('detection_max_scan_seconds must be within [0.1, 60.0]', $message, "{$bad} rejects naming the field"); +} +$t->same(2.5, (new SecurityConfig())->with(['detection_max_scan_seconds' => 2.5])->detectionMaxScanSeconds, 'with() carries the field'); + +$t->section('detect() is unbounded outside a request scan'); +fakeClock(1.0); +$sus = new SusPatterns(); +$result = $sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$names = array_column($result['threats'], 'pattern'); +$t->same(false, in_array(BUDGET_PATTERN, $names, true), 'no budget threat without beginScan'); +$t->same(false, $result['is_threat'], 'benign value is not a threat'); + +$t->section('exhausted budget: threat shape'); +fakeClock(1.0); +$sus = new SusPatterns(); +$sus->beginScan(0.1); +$result = $sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$sus->endScan(); +$budget = array_values(array_filter($result['threats'], static fn (array $th): bool => $th['pattern'] === BUDGET_PATTERN)); +$t->same(1, count(array_filter($budget, static fn (array $th): bool => $th['type'] === 'pattern_timeout')) >= 1 ? 1 : 0, 'a pattern_timeout budget threat is present'); +$t->same( + ['type' => 'pattern_timeout', 'pattern' => BUDGET_PATTERN, 'match' => '', 'position' => 0, 'execution_time' => 0.0, 'category' => 'custom', 'weight' => 1.0], + $budget[0] ?? null, + 'threat has the reference shape and weight 1.0' +); +$t->same(true, in_array(BUDGET_PATTERN, $result['timeouts'], true), 'budget pattern is reported in timeouts'); +$t->same(true, $result['is_threat'], 'default threshold 1.0 flags the request'); + +$t->section('exhausted budget: remaining patterns are skipped'); +$calls = 0; +$now = 0.0; +SusPatterns::$cpuClock = static function () use (&$calls, &$now): float { + $calls++; + + return $now += 1.0; +}; +$sus = new SusPatterns(); +$sus->beginScan(0.1); +$sus->detect('hello world', '9.9.9.9', 'query_param:q'); +$sus->endScan(); +$t->same(true, $calls < 40, "clock is read a handful of times, not once per catalog pattern ({$calls})"); + +$t->section('budget is shared across values of one request scan'); +fakeClock(1.0); +$sus = new SusPatterns(); +$sus->beginScan(2.5); +$first = $sus->detect('hello', '9.9.9.9', 'query_param:a'); +$second = $sus->detect('world', '9.9.9.9', 'query_param:b'); +$sus->endScan(); +$names = array_merge(array_column($first['threats'], 'pattern'), array_column($second['threats'], 'pattern')); +$t->same(true, in_array(BUDGET_PATTERN, $names, true), 'budget threat appears'); +$t->same(true, $second['is_threat'], 'the second value is flagged from the already spent budget'); + +$t->section('beginScan resets the spent time'); +fakeClock(0.0); +$sus = new SusPatterns(); +$sus->beginScan(0.1); +$clean = $sus->detect('hello', '9.9.9.9', 'query_param:q'); +$sus->endScan(); +$t->same(false, $clean['is_threat'], 'zero-cost clock never exhausts the budget'); + +$t->section('within budget: unaffected'); +SusPatterns::$cpuClock = null; +$sus = new SusPatterns(); +$sus->beginScan(10.0); +$benign = $sus->detect('page=2', '9.9.9.9', 'query_param:q'); +$attack = $sus->detect("1' OR '1'='1", '9.9.9.9', 'query_param:q'); +$sus->endScan(); +$t->same(false, $benign['is_threat'], 'benign value under the real clock is not a threat'); +$t->same(true, $attack['is_threat'], 'attack value is still detected'); +$t->same(false, in_array(BUDGET_PATTERN, array_column($attack['threats'], 'pattern'), true), 'no budget threat within budget'); + +$t->section('threshold wiring: benign request, exhausted budget'); +fakeClock(1.0); +$config = new SecurityConfig(detectionMaxScanSeconds: 0.1); +[$blocked, $reason] = run(makeCheck($config, 1.0)); +$t->same(true, $blocked, 'threshold 1.0 blocks (weight 1.0 reaches it)'); +$t->same(true, str_contains($reason, "URL path: " . BUDGET_MESSAGE), 'reason carries the budget text'); +fakeClock(1.0); +[$blocked, $reason] = run(makeCheck(new SecurityConfig(detectionMaxScanSeconds: 0.1, detectionThreatScoreThreshold: 5.0), 5.0)); +$t->same(false, $blocked, 'threshold 5.0 does not block'); +$t->same('', $reason, 'no block reason at threshold 5.0'); + +$t->section('threshold wiring through GuardEngine (config field reaches the verdict)'); +foreach ([[1.0, true], [5.0, false]] as [$threshold, $expectBlock]) { + fakeClock(1.0); + $engine = new GuardEngine(new SecurityConfig(detectionMaxScanSeconds: 0.1, detectionThreatScoreThreshold: $threshold, enableRateLimiting: false, enableIpBanning: false)); + $request = new SimpleGuardRequest(urlPath: '/items', method: 'GET', headers: ['x-forwarded-for' => '9.9.9.9'], clientHost: '9.9.9.9'); + $response = $engine->execute($request); + $t->same($expectBlock, $response instanceof GuardResponse, "engine threshold {$threshold} blocks={$expectBlock}"); +} + +$t->section('reason text: budget threat first, and appended after another threat'); +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? +$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]); +$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'); + +$t->section('budget spent exactly after the catalog stops the registry additions too'); +// A one-second step with an integer cap spends the budget after exactly that +// many patterns; sweeping the cap lands one inside the registry scan loop. +$withRegistry = 0; +for ($cap = 1; $cap <= 400; $cap++) { + fakeClock(1.0); + $sus = new SusPatterns(); + $sus->addPattern('zzregistryprobe\\d+', true); + $sus->beginScan((float) $cap); + $result = $sus->detect('hello', '9.9.9.9', 'query_param:q'); + $sus->endScan(); + if (in_array(BUDGET_PATTERN, array_column($result['threats'], 'pattern'), true)) { + $withRegistry++; + } +} +$t->same(true, $withRegistry > 0, 'budget threats are recorded across the cap sweep with a registry pattern present'); + +$t->section('check() leaves the scan unbounded afterwards'); +SusPatterns::$cpuClock = null; +$shared = new SusPatterns(); +$check = new SuspiciousActivityCheck(new SecurityConfig(), new GuardResponseFactory(), $shared, null, new RouteResolver()); +run($check); +fakeClock(1.0); +$after = $shared->detect('hello', '9.9.9.9', 'query_param:q'); +$t->same(false, in_array(BUDGET_PATTERN, array_column($after['threats'], 'pattern'), true), 'a direct detect() after check() has no cap'); + +$t->section('default clock: process CPU time, not wall time'); +SusPatterns::$cpuClock = null; +$before = SusPatterns::cpuSeconds(); +usleep(200000); +$slept = SusPatterns::cpuSeconds() - $before; +$t->same(true, $slept < 0.1, 'a 200 ms sleep costs almost no CPU time (' . round($slept, 4) . 's)'); +$before = SusPatterns::cpuSeconds(); +$deadline = hrtime(true) + 50_000_000; +while (hrtime(true) < $deadline) { +} +$t->same(true, SusPatterns::cpuSeconds() - $before > 0.02, 'a 50 ms busy loop is counted'); + +$t->same(3.5, SusPatterns::cpuSecondsFrom(['ru_utime.tv_sec' => 1, 'ru_stime.tv_sec' => 2, 'ru_utime.tv_usec' => 250000, 'ru_stime.tv_usec' => 250000]), 'getrusage user plus system seconds and microseconds add up'); +$wall = SusPatterns::cpuSecondsFrom(false); +$t->same(true, $wall > 0.0 && abs($wall - hrtime(true) / 1e9) < 1.0, 'without getrusage the monotonic wall clock stands in'); + +echo "\n{$t->passed} passed, {$t->failed} failed\n"; +exit($t->failed === 0 ? 0 : 1); diff --git a/docs/configuration.md b/docs/configuration.md index 23fedc9..0fc0d3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,7 +161,9 @@ rule lists to combine with). | `enablePenetrationDetection` | `true` | | | `enabledDetectionCategories` | all 19 categories | `xss`, `sqli`, `cmd_injection`, `path_traversal`, and more | | `detectionSemanticThreshold` | `0.7` | Semantic model threshold, in `[0.0, 1.0]` | -| `logSensitiveHeaders` | `[]` | Headers skipped by detection scanning and redacted from logs; use for the address headers (`host`, `x-forwarded-for`, ...) until a dedicated detection-exclusion knob lands | +| `detectionThreatScoreThreshold` | `1.0` | Anomaly score (sum of regex threat weights) a value must reach to count as a threat, in `[0.0, 10.0]` | +| `detectionMaxScanSeconds` | `10.0` | Cap on the CPU seconds (process user plus system time, not wall time) spent regex scanning one request, summed across every value and pattern, in `[0.1, 60.0]`. Checked between patterns. Once spent, the remaining patterns and values are not scanned and each scanned view records a weighted `pattern_timeout` threat (`scan_time_budget_exhausted`, category `custom`, weight 1.0) that counts toward the anomaly score, so the request is flagged when `detectionThreatScoreThreshold` is at or below that weight. Where `getrusage()` is unavailable the monotonic wall clock is used instead | +| `logSensitiveHeaders` | `[]` | Headers skipped by detection scanning and redacted from logs; prefer `excludedDetectionHeaders` for the address headers (`host`, `x-forwarded-for`, ...) | ## Cloud provider blocking, user agents, auth diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php index 81fa922..4e65580 100644 --- a/src/Config/SecurityConfig.php +++ b/src/Config/SecurityConfig.php @@ -121,6 +121,15 @@ final class SecurityConfig public readonly int $detectionBinaryMinRunLength; + /** + * Cap on the CPU seconds spent regex scanning one request, summed over + * every scanned value and pattern (detection_max_scan_seconds, reference + * default 10.0, bounds [0.1, 60.0]). Once spent, the remaining patterns + * and values are not scanned and each scanned view records a weighted + * pattern_timeout threat that counts toward the anomaly score. + */ + public readonly float $detectionMaxScanSeconds; + /** @var array */ public readonly array $excludedDetectionParams; @@ -402,6 +411,7 @@ public function __construct( ?float $detectionThreatScoreThreshold = null, ?string $detectionPatternValidationCachePath = null, ?int $detectionBinaryMinRunLength = null, + ?float $detectionMaxScanSeconds = null, ?array $excludedDetectionParams = null, ?array $excludedDetectionBodyFields = null, ?array $excludedDetectionHeaders = null, @@ -507,6 +517,10 @@ public function __construct( if ($this->detectionBinaryMinRunLength < 4 || $this->detectionBinaryMinRunLength > 1024) { throw new \InvalidArgumentException('detection_binary_min_run_length must be within [4, 1024]'); } + $this->detectionMaxScanSeconds = $detectionMaxScanSeconds ?? 10.0; + if ($this->detectionMaxScanSeconds < 0.1 || $this->detectionMaxScanSeconds > 60.0) { + throw new \InvalidArgumentException('detection_max_scan_seconds must be within [0.1, 60.0]'); + } $this->excludedDetectionParams = $this->validateExclusionSet($excludedDetectionParams, 'excluded_detection_params'); $this->excludedDetectionBodyFields = $this->validateExclusionSet($excludedDetectionBodyFields, 'excluded_detection_body_fields'); $this->excludedDetectionHeaders = $this->validateExclusionSet($excludedDetectionHeaders, 'excluded_detection_headers'); @@ -865,6 +879,7 @@ private function constructorArgs(): array 'detectionThreatScoreThreshold' => $this->detectionThreatScoreThreshold, 'detectionPatternValidationCachePath' => $this->detectionPatternValidationCachePath, 'detectionBinaryMinRunLength' => $this->detectionBinaryMinRunLength, + 'detectionMaxScanSeconds' => $this->detectionMaxScanSeconds, 'excludedDetectionParams' => array_keys($this->excludedDetectionParams), 'excludedDetectionBodyFields' => array_keys($this->excludedDetectionBodyFields), 'excludedDetectionHeaders' => array_keys($this->excludedDetectionHeaders), diff --git a/src/Detection/SusPatterns.php b/src/Detection/SusPatterns.php index 1cfa81c..e45841a 100644 --- a/src/Detection/SusPatterns.php +++ b/src/Detection/SusPatterns.php @@ -68,9 +68,28 @@ private static function compilerTimeout(): float return self::$compilerTimeoutOverride ?? self::COMPILER_TIMEOUT; } + /** The pattern name of the weighted pattern_timeout threat recorded once the scan budget is spent. */ + public const SCAN_TIME_BUDGET_EXHAUSTED_PATTERN = 'scan_time_budget_exhausted'; + + /** + * Test hook: replaces the CPU clock the per-request scan budget reads + * (the equivalent of the reference tests patching _thread_cpu_seconds). + * Null uses cpuSeconds(). + * + * @var (\Closure(): float)|null + */ + public static ?\Closure $cpuClock = null; + private Preprocessor $preprocessor; private float $semanticThreshold; + /** The per-request regex scan CPU cap in seconds, null outside a request scan (unbounded). */ + private ?float $scanCap = null; + + private float $scanSpent = 0.0; + + private bool $scanCapWarned = false; + /** * The anomaly score a detect() verdict must reach on the regex-threat * weight sum to flag a threat (the reference state's @@ -135,6 +154,105 @@ public function __construct( $this->validationCache = $validationCache; } + /** + * CPU seconds consumed by this process (user plus system), the unit of + * the per-request scan budget. PHP runs a scan synchronously on one + * thread, so the delta of this process clock across a pattern scan is + * that scan's CPU time (the reference's time.thread_time). hrtime() is a + * wall clock and would charge a request for time spent descheduled. + * Where getrusage() is unavailable the monotonic wall clock stands in: it + * only ever over-counts, so the cap still bounds a scan. + */ + public static function cpuSeconds(): float + { + if (self::$cpuClock !== null) { + return (self::$cpuClock)(); + } + + return self::cpuSecondsFrom(function_exists('getrusage') ? getrusage() : false); + } + + /** + * The clock arithmetic behind cpuSeconds(), split out so the platform + * fallback (getrusage() missing or failing) is testable. + * + * @param array|false $usage a getrusage() result + */ + public static function cpuSecondsFrom(array|false $usage): float + { + if ($usage === false) { + return hrtime(true) / 1e9; + } + + return $usage['ru_utime.tv_sec'] + $usage['ru_stime.tv_sec'] + + ($usage['ru_utime.tv_usec'] + $usage['ru_stime.tv_usec']) / 1e6; + } + + /** + * Opens one request's scan budget (detection_max_scan_seconds): spent + * time restarts at zero and every later regex scan is charged to it + * until endScan(). + */ + public function beginScan(float $maxScanSeconds): void + { + $this->scanCap = $maxScanSeconds; + $this->scanSpent = 0.0; + $this->scanCapWarned = false; + } + + /** Closes the request scan budget; detect() is unbounded again. */ + public function endScan(): void + { + $this->scanCap = null; + } + + private function scanBudgetExhausted(): bool + { + return $this->scanCap !== null && $this->scanCap - $this->scanSpent <= 0.0; + } + + /** + * Records the weighted pattern_timeout threat for a spent budget on the + * scanned view (shape of _scan_time_budget_exhausted_threat). + * + * @param list> $threats + * @param list $matchedPatterns + * @param list $timeouts + */ + private function recordBudgetExhausted(string $ip, array &$threats, array &$matchedPatterns, array &$timeouts): void + { + $pattern = self::SCAN_TIME_BUDGET_EXHAUSTED_PATTERN; + if (!$this->scanCapWarned) { + $this->scanCapWarned = true; + error_log("[guard_core] detection_max_scan_seconds ({$this->scanCap}) reached for client {$ip}; remaining patterns and request values are not scanned"); + } + $threats[] = [ + 'type' => 'pattern_timeout', + 'pattern' => $pattern, + 'match' => '', + 'position' => 0, + 'execution_time' => 0.0, + 'category' => 'custom', + 'weight' => self::resolvePatternWeight($pattern, 'custom'), + ]; + $matchedPatterns[] = $pattern; + $timeouts[] = $pattern; + } + + /** @return array{0: array|null, 1: bool} scanPattern() result, its CPU time charged to the budget */ + 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); + } + $start = self::cpuSeconds(); + try { + return $this->scanPattern($source, $category, $content, $validatorContext, $binaryPrefix); + } finally { + $this->scanSpent += self::cpuSeconds() - $start; + } + } + public static function normalizeContext(?string $context): string { if ($context === null) { @@ -404,7 +522,7 @@ private static function firstLineByteLength(string $content): int return $nl === false ? strlen($content) : $nl; } - private function checkRegexPatterns(string $content, string $context, string $viewMode): array + private function checkRegexPatterns(string $content, string $context, string $viewMode, string $ip = ''): array { $threats = []; $matchedPatterns = []; @@ -420,6 +538,11 @@ private function checkRegexPatterns(string $content, string $context, string $vi $binaryPrefix = BinaryPrefix::build($content); foreach (PatternData::PATTERNS as $index => [$source, $contexts, $category]) { + if ($this->scanBudgetExhausted()) { + $this->recordBudgetExhausted($ip, $threats, $matchedPatterns, $timeouts); + + return [$threats, $matchedPatterns, $timeouts]; + } if (isset($this->removedDefaultPatterns[$source])) { continue; } @@ -437,7 +560,7 @@ private function checkRegexPatterns(string $content, string $context, string $vi continue; } $startNs = hrtime(true); - [$threat, $timeoutOccurred] = $this->scanPattern($source, $category, $content, $validatorContext, $binaryPrefix); + [$threat, $timeoutOccurred] = $this->scanPatternCharged($source, $category, $content, $validatorContext, $binaryPrefix); $elapsedSeconds = (hrtime(true) - $startNs) / 1e9; if ($timeoutOccurred) { $timeouts[] = $source; @@ -466,8 +589,13 @@ private function checkRegexPatterns(string $content, string $context, string $vi // the same per-pattern scan gates (canary, budgets, timeouts) and // the same per-scan monitor metric as a catalog row. foreach ($this->registryPatternSources() as $source) { + if ($this->scanBudgetExhausted()) { + $this->recordBudgetExhausted($ip, $threats, $matchedPatterns, $timeouts); + + return [$threats, $matchedPatterns, $timeouts]; + } $startNs = hrtime(true); - [$threat, $timeoutOccurred] = $this->scanPattern($source, 'custom', $content, $validatorContext, $binaryPrefix); + [$threat, $timeoutOccurred] = $this->scanPatternCharged($source, 'custom', $content, $validatorContext, $binaryPrefix); $elapsedSeconds = (hrtime(true) - $startNs) / 1e9; if ($timeoutOccurred) { $timeouts[] = $source; @@ -619,22 +747,22 @@ private function checkDecodedViewPathTraversal(string $processedContent, string ]; } - private function checkUrlDecodedViewPatterns(string $content, string $context, string $precomputedDecoded, array|bool $decodeBudgetExhausted): array + private function checkUrlDecodedViewPatterns(string $content, string $context, string $precomputedDecoded, array|bool $decodeBudgetExhausted, string $ip = ''): array { $urlDecodedViewContent = Truncation::truncateSafely($precomputedDecoded, $this->preprocessor); - [$threats, $matched, $timeouts] = $this->checkRegexPatterns($urlDecodedViewContent, $context, 'url_decoded'); + [$threats, $matched, $timeouts] = $this->checkRegexPatterns($urlDecodedViewContent, $context, 'url_decoded', $ip); return [$threats, $matched, $timeouts, $decodeBudgetExhausted]; } - private function checkShortBase64AdditiveViewPatterns(string $content, string $context): array + private function checkShortBase64AdditiveViewPatterns(string $content, string $context, string $ip = ''): array { $additiveViewContent = $this->preprocessor->preprocessShortBase64AdditiveView($content); if ($additiveViewContent === '') { return [[], [], []]; } - return $this->checkRegexPatterns($additiveViewContent, $context, 'all'); + return $this->checkRegexPatterns($additiveViewContent, $context, 'all', $ip); } private function checkSemanticThreats(string $content, string $rawContent): array @@ -683,10 +811,10 @@ public function detect(string $content, string $ip, string $context, bool $skipS $decodeBudgetExhausted = [false]; [$processedContent, $precomputedDecoded] = $this->preprocessor->preprocessWithDecoded($content, $decodeBudgetExhausted); - [$regexThreats, $matchedPatterns, $timeouts] = $this->checkRegexPatterns($processedContent, $context, 'processed'); + [$regexThreats, $matchedPatterns, $timeouts] = $this->checkRegexPatterns($processedContent, $context, 'processed', $ip); $rawViewContent = $this->preprocessor->preprocessSignalPreserving($content); - [$rawThreats, $rawMatched, $rawTimeouts] = $this->checkRegexPatterns($rawViewContent, $context, 'raw'); + [$rawThreats, $rawMatched, $rawTimeouts] = $this->checkRegexPatterns($rawViewContent, $context, 'raw', $ip); // A (pattern, match) the processed views already recorded is the same // evidence; drop it so a row matching both views counts once. Raw-view // timeout sources are deduplicated the same way. @@ -703,7 +831,7 @@ public function detect(string $content, string $ip, string $context, bool $skipS } [$urlDecodedThreats, $urlDecodedMatched, $urlDecodedTimeouts, $urlDecodedBudgetExhausted] = - $this->checkUrlDecodedViewPatterns($content, $context, $precomputedDecoded, $decodeBudgetExhausted); + $this->checkUrlDecodedViewPatterns($content, $context, $precomputedDecoded, $decodeBudgetExhausted, $ip); $regexThreats = array_merge($regexThreats, $urlDecodedThreats); $matchedPatterns = array_merge($matchedPatterns, $urlDecodedMatched); $timeouts = array_merge($timeouts, $urlDecodedTimeouts); @@ -721,7 +849,7 @@ public function detect(string $content, string $ip, string $context, bool $skipS $matchedPatterns[] = $exhaustionThreat['pattern']; } - [$shortBase64Threats, $shortBase64Matched, $shortBase64Timeouts] = $this->checkShortBase64AdditiveViewPatterns($content, $context); + [$shortBase64Threats, $shortBase64Matched, $shortBase64Timeouts] = $this->checkShortBase64AdditiveViewPatterns($content, $context, $ip); $regexThreats = array_merge($regexThreats, $shortBase64Threats); $matchedPatterns = array_merge($matchedPatterns, $shortBase64Matched); $timeouts = array_merge($timeouts, $shortBase64Timeouts); diff --git a/src/Pipeline/Checks/SuspiciousActivityCheck.php b/src/Pipeline/Checks/SuspiciousActivityCheck.php index 2941fcb..b686137 100644 --- a/src/Pipeline/Checks/SuspiciousActivityCheck.php +++ b/src/Pipeline/Checks/SuspiciousActivityCheck.php @@ -98,46 +98,73 @@ public function check(GuardRequest $request): ?GuardResponse $categories = []; $triggerInfo = ''; - foreach ($this->scanValues($request) as $entry) { - [$content, $context, $forcedCategory, $skipCategories, $label] = $entry; - // The optional sixth element marks a value from a header listed in - // excluded_semantic_detection_headers: it skips the semantic - // analyzer only, the regex passes still scan it. - $skipSemantic = $entry[5] ?? false; - // An unlabeled value is the whole-body blob: the reference - // _scan_blob_body composes "Request body: {trigger}". - $prefix = $label !== '' ? $label : 'Request body: '; - if ($forcedCategory !== null) { - // JSON mongo-operator keys report straight from the walk - // (body_json_scan._mongo_operator_key_hit) without a pattern - // scan; the walk carries the reference trigger message. - if (isset($enabledCategories[$forcedCategory]) - && !isset($skipCategories[$forcedCategory]) - && !in_array($forcedCategory, $categories, true) - ) { - $categories[] = $forcedCategory; - $triggerInfo = $prefix; - } - continue; - } - $result = $this->susPatterns->detect($content, $clientIp, $context, $skipSemantic); - if ($result['is_threat']) { - foreach ($result['threats'] as $threat) { - $category = $threat['category'] ?? 'custom'; - // The reference's category gate lives in - // _pattern_should_be_skipped (_suspatterns_regex.py): - // the custom category (runtime registry rows and the - // decode-budget exhaustion marker) bypasses the - // enabled-categories gate entirely. - if (!in_array($category, $categories, true) - && !isset($skipCategories[$category]) - && ($category === 'custom' || isset($enabledCategories[$category])) + // One request, one scan budget (detection_max_scan_seconds), read from + // the live config so a with() revision applies without rebuilding the + // shared SusPatterns. + $this->susPatterns->beginScan($this->config->detectionMaxScanSeconds); + try { + foreach ($this->scanValues($request) as $entry) { + [$content, $context, $forcedCategory, $skipCategories, $label] = $entry; + // The optional sixth element marks a value from a header listed in + // excluded_semantic_detection_headers: it skips the semantic + // analyzer only, the regex passes still scan it. + $skipSemantic = $entry[5] ?? false; + // An unlabeled value is the whole-body blob: the reference + // _scan_blob_body composes "Request body: {trigger}". + $prefix = $label !== '' ? $label : 'Request body: '; + if ($forcedCategory !== null) { + // JSON mongo-operator keys report straight from the walk + // (body_json_scan._mongo_operator_key_hit) without a pattern + // scan; the walk carries the reference trigger message. + if (isset($enabledCategories[$forcedCategory]) + && !isset($skipCategories[$forcedCategory]) + && !in_array($forcedCategory, $categories, true) ) { - $categories[] = $category; - $triggerInfo = $prefix . self::threatMessage($threat); + $categories[] = $forcedCategory; + $triggerInfo = $prefix; + } + continue; + } + $result = $this->susPatterns->detect($content, $clientIp, $context, $skipSemantic); + if ($result['is_threat']) { + // _build_threats_message: a scan time budget threat that + // came after another threat rides along as "; ". + $budgetThreat = null; + foreach ($result['threats'] as $candidate) { + if (($candidate['type'] ?? '') === 'pattern_timeout') { + $budgetThreat = $candidate; + break; + } + } + $reasonSet = false; + foreach ($result['threats'] as $threat) { + $category = $threat['category'] ?? 'custom'; + // The reference's category gate lives in + // _pattern_should_be_skipped (_suspatterns_regex.py): + // the custom category (runtime registry rows and the + // decode-budget exhaustion marker) bypasses the + // enabled-categories gate entirely. + if (!in_array($category, $categories, true) + && !isset($skipCategories[$category]) + && ($category === 'custom' || isset($enabledCategories[$category])) + ) { + $categories[] = $category; + $isBudget = ($threat['type'] ?? '') === 'pattern_timeout'; + if ($isBudget && $reasonSet) { + continue; + } + $message = self::threatMessage($threat); + if (!$isBudget && $budgetThreat !== null) { + $message .= '; ' . self::threatMessage($budgetThreat); + } + $triggerInfo = $prefix . $message; + $reasonSet = true; + } } } } + } finally { + $this->susPatterns->endScan(); } if ($categories === []) {