From b729e39119384e4eb1f53986fb00ded2f48f885c Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:17:35 +0200 Subject: [PATCH 1/6] fix(logging): keep the reason on passive-mode log lines --- bin/test_m3c.php | 9 +++++++++ src/Logging/LogActivity.php | 15 ++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/bin/test_m3c.php b/bin/test_m3c.php index 75111ca..5578cde 100644 --- a/bin/test_m3c.php +++ b/bin/test_m3c.php @@ -184,6 +184,15 @@ function m3cPipeline(SecurityConfig $config, array $routeConfigs = []): Security $t->truthy(str_contains($logger->records()[0]['message'], '[PASSIVE MODE] Penetration attempt detected from'), 'passive line lead'); $t->truthy(str_contains($logger->records()[0]['message'], 'Trigger: trig - Headers:'), 'passive trigger segment'); +$logger = new SimpleRequestLogger(); +$req = m3cRequest('/login'); +LogActivity::log($req, $logger, $passiveConfig, logType: 'suspicious', level: 'WARNING', reason: 'Rate limit exceeded for IP: 9.9.9.9', passiveMode: true, checkName: 'rate_limit'); +$passiveNoTrigger = $logger->records()[0]['message']; +$t->truthy(str_contains($passiveNoTrigger, '[PASSIVE MODE] Suspicious activity detected from'), 'passive line without trigger names itself suspicious activity'); +$t->truthy(!str_contains($passiveNoTrigger, 'Penetration attempt'), 'passive line without trigger is not a penetration attempt'); +$t->truthy(str_contains($passiveNoTrigger, 'Reason: Rate limit exceeded for IP: 9.9.9.9 - Headers:'), 'passive line without trigger keeps the reason'); +$t->truthy(!str_contains($passiveNoTrigger, 'Trigger:'), 'passive line without trigger has no trigger segment'); + $payloads = []; $hook = function ($request, $payload) use (&$payloads) { $payloads[] = $payload; diff --git a/src/Logging/LogActivity.php b/src/Logging/LogActivity.php index 48b48d6..5e08e63 100644 --- a/src/Logging/LogActivity.php +++ b/src/Logging/LogActivity.php @@ -79,14 +79,15 @@ private static function buildMessage( $details = 'Request from ' . $context['client_ip'] . ': ' . $context['method'] . ' ' . $context['url']; $reasonMessage = 'Headers: ' . self::headersSegment($context['headers']); } elseif ($logType === 'suspicious') { - $lead = $passiveMode ? '[PASSIVE MODE] Penetration attempt detected from' : 'Suspicious activity detected from'; + // Without a trigger (for example a passive rate-limit hit) the line + // is not a penetration attempt: it names itself suspicious activity + // and keeps the reason (guard-core #165). + $lead = $passiveMode && $triggerInfo !== '' + ? '[PASSIVE MODE] Penetration attempt detected from' + : ($passiveMode ? '[PASSIVE MODE] Suspicious activity detected from' : 'Suspicious activity detected from'); $details = $lead . ' ' . $context['client_ip'] . ': ' . $context['method'] . ' ' . $context['url']; - if ($passiveMode) { - $reasonMessage = 'Headers: ' . self::headersSegment($context['headers']); - $triggerMessage = $triggerInfo !== '' ? 'Trigger: ' . $triggerInfo : ''; - if ($triggerMessage !== '') { - $reasonMessage = $triggerMessage . ' - ' . $reasonMessage; - } + if ($passiveMode && $triggerInfo !== '') { + $reasonMessage = 'Trigger: ' . $triggerInfo . ' - Headers: ' . self::headersSegment($context['headers']); } else { $reasonMessage = 'Reason: ' . $reason . ' - Headers: ' . self::headersSegment($context['headers']); } From b3a23f0d12f446f5eb308ec31b5d7dc249ddfea5 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:18:25 +0200 Subject: [PATCH 2/6] docs(config): list what a dynamic rule can change --- bin/test_config_fields.php | 12 ++++++++++++ docs/configuration.md | 9 +++++++-- src/Config/SecurityConfig.php | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/test_config_fields.php b/bin/test_config_fields.php index 57ae2be..33af689 100644 --- a/bin/test_config_fields.php +++ b/bin/test_config_fields.php @@ -473,5 +473,17 @@ public function getDynamicRules(): ?array $standaloneChecks = $standaloneFactory->buildChecks(new SecurityConfig(detectionPatternValidationCachePath: $tmpPath, blockCloudProviders: ['AWS'])); $t->truthy($standaloneChecks !== [], 'a standalone CheckFactory builds the cache for its fallback detection engine'); +// enable_dynamic_rules documents exactly what a rule changes (guard-core #165). +$ctorDoc = (string) (new ReflectionMethod(SecurityConfig::class, '__construct'))->getDocComment(); +$dynamicDoc = ''; +if (preg_match('/@param bool\|null \$enableDynamicRules(.*?)(?=\n\s*\* @param|\n\s*\*\/)/s', $ctorDoc, $m)) { + $dynamicDoc = $m[1]; +} +$t->truthy($dynamicDoc !== '', 'enableDynamicRules has a @param doc line'); +foreach (['ip_blacklist', 'ip_whitelist', 'suspicious_patterns', 'blocked_countries', 'whitelist_countries', 'rate_limit', 'rate_limit_window', 'endpoint_rate_limits', 'block_cloud_providers', 'blocked_user_agents', 'enable_penetration_detection', 'enable_ip_banning', 'enable_rate_limiting', 'enable_rate_limit_auto_ban', 'auto_ban_threshold', 'auto_ban_duration', 'emergency_mode', 'emergency_whitelist'] as $ruleField) { + $t->truthy(str_contains($dynamicDoc, $ruleField), "enableDynamicRules doc lists {$ruleField}"); +} +$t->truthy(str_contains($dynamicDoc, 'cannot change any other setting'), 'enableDynamicRules doc states the closed set'); + $t->same(0, $t->failed, 'no failures above'); exit($t->finish('config fields')); diff --git a/docs/configuration.md b/docs/configuration.md index f958f1f..2d3f303 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,8 +3,13 @@ `SecurityConfig` is built through a single constructor of named arguments (every field nullable with an engine default). Arguments belonging to features this port does not implement, when set to an enabling value, throw -`UnsupportedFeatureError` (fail closed): guard agent telemetry, and dynamic -rules. +`UnsupportedFeatureError` (fail closed). + +## Dynamic rules + +| Argument | Default | Notes | +|---|---|---| +| `enableDynamicRules` | `false` | Lets the dashboard change protection at runtime without a code change. Needs an agent handler to deliver the rules. A rule adds the IPs in its `ip_blacklist` to the blacklist and the IPs in its `ip_whitelist` to the whitelist (`ip_ban_duration` is parsed but not applied, these are not timed bans), adds its `suspicious_patterns` to detection, appends to `block_cloud_providers` and `blocked_user_agents`, merges `endpoint_rate_limits`, and replaces `blocked_countries`, `whitelist_countries`, `rate_limit`, `rate_limit_window`, `enable_penetration_detection`, `enable_ip_banning`, `enable_rate_limiting`, `enable_rate_limit_auto_ban`, `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode` and `emergency_whitelist`. `emergency_mode` also halves the auto-ban threshold. A rule cannot change any other setting. | ## CORS diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php index 54b8648..4b26bd8 100644 --- a/src/Config/SecurityConfig.php +++ b/src/Config/SecurityConfig.php @@ -336,6 +336,7 @@ final class SecurityConfig * @param list $logSensitiveParams * @param list $logSensitiveBodyFields * @param (\Closure(object, array): void)|null $onBlock + * @param bool|null $enableDynamicRules default false; lets the dashboard change protection at runtime without a code change, and needs an agent handler to deliver the rules: a rule adds the IPs in its ip_blacklist to the blacklist and the IPs in its ip_whitelist to the whitelist (ip_ban_duration is parsed but not applied, these are not timed bans), adds its suspicious_patterns to detection, appends to block_cloud_providers and blocked_user_agents, merges endpoint_rate_limits, and replaces blocked_countries, whitelist_countries, rate_limit, rate_limit_window, enable_penetration_detection, enable_ip_banning, enable_rate_limiting, enable_rate_limit_auto_ban, auto_ban_threshold, auto_ban_duration, emergency_mode and emergency_whitelist (emergency_mode also halves the auto-ban threshold); a rule cannot change any other setting * @param list $emergencyWhitelist * @param array $customErrorResponses per-status-code body overrides * @param list $blockedUserAgents regex patterns, subject truncated to 512 chars From 43f251118fbaaabd8b8f3bfe8ea316cc5c4818b5 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:21:43 +0200 Subject: [PATCH 3/6] feat(detection): add an opt-in semantic-only header exclusion --- bin/test_decorators.php | 3 ++ bin/test_excluded_headers.php | 41 +++++++++++++++++ docs/configuration.md | 2 + src/Config/SecurityConfig.php | 13 ++++++ src/Decorators/RouteDecoration.php | 9 +++- src/Decorators/SecurityDecorator.php | 6 ++- src/Detection/SusPatterns.php | 10 +++- .../Checks/SuspiciousActivityCheck.php | 46 +++++++++++++++---- src/Routing/RouteConfig.php | 13 +++++- 9 files changed, 127 insertions(+), 16 deletions(-) diff --git a/bin/test_decorators.php b/bin/test_decorators.php index 5e532d1..578e77f 100644 --- a/bin/test_decorators.php +++ b/bin/test_decorators.php @@ -413,6 +413,9 @@ public function __invoke(): string $t->same(['sqli'], $rc->enabledDetectionCategories, 'detection_exclusion categories'); $t->same(false, $rc->detectionScanBody, 'detection_exclusion scan body'); $g = dtGuard(); +$g->detectionExclusion(semanticHeaders: ['X-Session'])->decorate('/dex-sem'); +$t->same(['X-Session'], $g->getRouteConfig('/dex-sem')->excludedSemanticDetectionHeaders, 'detection_exclusion semantic headers'); +$g = dtGuard(); $g->detectionExclusion(params: ['only-one'])->decorate('/dex-partial'); $rc = $g->getRouteConfig('/dex-partial'); $t->same(['only-one'], $rc->excludedDetectionParams, 'detection_exclusion partial application'); diff --git a/bin/test_excluded_headers.php b/bin/test_excluded_headers.php index 6f2b15f..c7e0666 100644 --- a/bin/test_excluded_headers.php +++ b/bin/test_excluded_headers.php @@ -9,6 +9,7 @@ use RenzoFranceschini\GuardCore\Request\GuardResponse; use RenzoFranceschini\GuardCore\Request\GuardResponseFactory; use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest; +use RenzoFranceschini\GuardCore\Routing\RouteConfig; use RenzoFranceschini\GuardCore\Routing\RouteResolver; require __DIR__ . '/../vendor/autoload.php'; @@ -169,5 +170,45 @@ function blockedHeaders(SuspiciousActivityCheck $check, array $headers): bool $t->same(true, blockedHeaders(makeCheck(), ['x-plain-header' => SCRIPT]), 'header attack blocks with no exclusions'); $t->same(false, blockedHeaders(makeCheck(), ['user-agent' => 'Mozilla/5.0', 'accept' => 'application/json']), 'benign proxy identity values do not block'); +$t->section('opt-in semantic-only header exclusion (excluded_semantic_detection_headers)'); +// A detection threshold of 0.1 makes the benign value below a semantic-only +// hit (no regex pattern matches it), the shape of a session cookie scoring +// near the production threshold. +const SEMANTIC_ONLY = 'theme=system'; + +/** @param array $headers */ +function blockedHeadersWithRoute(SuspiciousActivityCheck $check, array $headers, ?RouteConfig $route): bool +{ + $request = new SimpleGuardRequest(urlPath: '/items', method: 'POST', headers: $headers); + $request->state()->clientIp = '9.9.9.9'; + $request->state()->routeConfig = $route; + + return $check->check($request) instanceof GuardResponse; +} + +$lowThreshold = new SecurityConfig(detectionSemanticThreshold: 0.1); +$t->same([], array_keys($lowThreshold->excludedSemanticDetectionHeaders), 'excludedSemanticDetectionHeaders defaults empty'); +$t->same(true, blockedHeaders(makeCheck($lowThreshold), ['x-session' => SEMANTIC_ONLY]), 'with no opt-in a semantic-only header value blocks'); + +$semanticExcluded = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: [' X-Session ']); +$t->same(false, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => SEMANTIC_ONLY]), 'a semantic-excluded header (any case, trimmed) skips the semantic analyzer'); +$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-other' => SEMANTIC_ONLY]), 'headers outside the set keep the semantic analyzer'); +$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => SCRIPT]), 'regex scanning of a semantic-excluded header is unchanged (xss)'); +$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => JNDI]), 'regex scanning of a semantic-excluded header is unchanged (jndi)'); + +$routeSet = new RouteConfig(excludedSemanticDetectionHeaders: ['X-Route']); +$t->same(false, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SEMANTIC_ONLY], $routeSet), 'a route-level semantic exclusion skips the semantic analyzer'); +$t->same(true, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SEMANTIC_ONLY], null), 'without the route set the same header blocks'); +$t->same(true, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SCRIPT], $routeSet), 'a route-level semantic exclusion keeps the regex scan'); +$mergedGlobal = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: ['x-global']); +$t->same(false, blockedHeadersWithRoute(makeCheck($mergedGlobal), ['x-global' => SEMANTIC_ONLY], $routeSet), 'the route set merges with the global set (global entry)'); +$t->same(false, blockedHeadersWithRoute(makeCheck($mergedGlobal), ['x-route' => SEMANTIC_ONLY], $routeSet), 'the route set merges with the global set (route entry)'); +$t->same(null, (new RouteConfig())->excludedSemanticDetectionHeaders, 'RouteConfig semantic exclusion defaults null'); + +$base = new SecurityConfig(); +$mutated = $base->with(['excluded_semantic_detection_headers' => ['x-late']]); +$t->same([], array_keys($base->excludedSemanticDetectionHeaders), 'with() leaves the original semantic set untouched'); +$t->same(['x-late'], array_keys($mutated->excludedSemanticDetectionHeaders), 'with() carries the new semantic exclusion'); + echo "\npassed={$t->passed} failed={$t->failed}\n"; exit($t->failed === 0 ? 0 : 1); diff --git a/docs/configuration.md b/docs/configuration.md index 2d3f303..5a02e5b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,8 @@ statically-built pipeline schedules the check from the global flag; the route-aware scheduling gate lives at the `CheckFactory` level (see KNOWN_GAPS). +`excludedSemanticDetectionHeaders` (global `SecurityConfig` field, default empty, and a `RouteConfig` field that merges with it) is an opt-in list of header names whose values skip the semantic analyzer only: the regex passes still scan them. Names match case-insensitively and trimmed. Use it for values such as JWT session cookies that score near the semantic threshold. The decorator form is `detectionExclusion(semanticHeaders: [...])`. + ## Behavior rules | Argument | Default | Notes | diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php index 4b26bd8..81fa922 100644 --- a/src/Config/SecurityConfig.php +++ b/src/Config/SecurityConfig.php @@ -130,6 +130,15 @@ final class SecurityConfig /** @var array */ public readonly array $excludedDetectionHeaders; + /** + * Opt-in header names (default empty) whose values skip the semantic + * analyzer only; regex scanning of them is unchanged + * (excluded_semantic_detection_headers). + * + * @var array + */ + public readonly array $excludedSemanticDetectionHeaders; + /** @var list */ public readonly array $excludePaths; @@ -330,6 +339,7 @@ final class SecurityConfig * @param list $excludedDetectionParams query parameter names excluded from penetration detection scanning * @param list $excludedDetectionBodyFields body field names excluded from penetration detection scanning (JSON keys at any nesting depth, urlencoded and multipart field names) * @param list $excludedDetectionHeaders header names merged into the excluded-header scan: excluded headers skip the ssrf category only when the header is address-carrying or its value parses as an address chain, every other category still scans them + * @param list $excludedSemanticDetectionHeaders opt-in header names (default empty) whose values skip the semantic analyzer only, matched case-insensitively and trimmed; regex scanning of them is unchanged. Useful for JWT session cookies that score near the semantic threshold; a Cookie header is scanned one name=value pair at a time and the opt-out applies to each pair * @param list $excludePaths * @param list $mutedCheckLogs * @param list $logSensitiveHeaders @@ -395,6 +405,7 @@ public function __construct( ?array $excludedDetectionParams = null, ?array $excludedDetectionBodyFields = null, ?array $excludedDetectionHeaders = null, + ?array $excludedSemanticDetectionHeaders = null, ?array $excludePaths = null, ?array $mutedCheckLogs = null, ?array $logSensitiveHeaders = null, @@ -499,6 +510,7 @@ public function __construct( $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'); + $this->excludedSemanticDetectionHeaders = $this->validateExclusionSet($excludedSemanticDetectionHeaders, 'excluded_semantic_detection_headers'); $this->excludePaths = $this->validateExcludePaths($excludePaths ?? self::DEFAULT_EXCLUDE_PATHS); $this->mutedCheckLogs = $this->validateNameSet($mutedCheckLogs, self::CHECK_NAME_VALUES, 'muted_check_logs'); $this->logSensitiveHeaders = $this->validateSensitiveSet($logSensitiveHeaders, 'log_sensitive_headers'); @@ -856,6 +868,7 @@ private function constructorArgs(): array 'excludedDetectionParams' => array_keys($this->excludedDetectionParams), 'excludedDetectionBodyFields' => array_keys($this->excludedDetectionBodyFields), 'excludedDetectionHeaders' => array_keys($this->excludedDetectionHeaders), + 'excludedSemanticDetectionHeaders' => array_keys($this->excludedSemanticDetectionHeaders), 'excludePaths' => $this->excludePaths, 'mutedCheckLogs' => array_keys($this->mutedCheckLogs), 'logSensitiveHeaders' => array_keys($this->logSensitiveHeaders), diff --git a/src/Decorators/RouteDecoration.php b/src/Decorators/RouteDecoration.php index fcf954d..b59928a 100644 --- a/src/Decorators/RouteDecoration.php +++ b/src/Decorators/RouteDecoration.php @@ -311,15 +311,17 @@ public function customValidation(\Closure $validator): self * @param list|null $params * @param list|null $bodyFields * @param list|null $categories + * @param list|null $semanticHeaders */ public function detectionExclusion( ?array $headers = null, ?array $params = null, ?array $bodyFields = null, ?array $categories = null, - ?bool $scanBody = null + ?bool $scanBody = null, + ?array $semanticHeaders = null ): self { - return $this->with(static function (RouteConfig $rc) use ($headers, $params, $bodyFields, $categories, $scanBody): RouteConfig { + return $this->with(static function (RouteConfig $rc) use ($headers, $params, $bodyFields, $categories, $scanBody, $semanticHeaders): RouteConfig { $values = []; if ($headers !== null) { $values['excludedDetectionHeaders'] = $headers; @@ -336,6 +338,9 @@ public function detectionExclusion( if ($scanBody !== null) { $values['detectionScanBody'] = $scanBody; } + if ($semanticHeaders !== null) { + $values['excludedSemanticDetectionHeaders'] = $semanticHeaders; + } return $values === [] ? $rc : $rc->with($values); }); diff --git a/src/Decorators/SecurityDecorator.php b/src/Decorators/SecurityDecorator.php index 12f7ea5..e9d089f 100644 --- a/src/Decorators/SecurityDecorator.php +++ b/src/Decorators/SecurityDecorator.php @@ -296,15 +296,17 @@ public function customValidation(\Closure $validator): RouteDecoration * @param list|null $params * @param list|null $bodyFields * @param list|null $categories + * @param list|null $semanticHeaders */ public function detectionExclusion( ?array $headers = null, ?array $params = null, ?array $bodyFields = null, ?array $categories = null, - ?bool $scanBody = null + ?bool $scanBody = null, + ?array $semanticHeaders = null ): RouteDecoration { - return (new RouteDecoration($this))->detectionExclusion($headers, $params, $bodyFields, $categories, $scanBody); + return (new RouteDecoration($this))->detectionExclusion($headers, $params, $bodyFields, $categories, $scanBody, $semanticHeaders); } // ------------------------------------------------------------------ diff --git a/src/Detection/SusPatterns.php b/src/Detection/SusPatterns.php index 258e92c..1cfa81c 100644 --- a/src/Detection/SusPatterns.php +++ b/src/Detection/SusPatterns.php @@ -670,7 +670,13 @@ private function checkSemanticThreats(string $content, string $rawContent): arra return [$threats, $semanticScore]; } - public function detect(string $content, string $ip, string $context): array + /** + * @param bool $skipSemantic leave the semantic analyzer out for this value + * (a header listed in excluded_semantic_detection_headers): + * the regex passes still run, only the semantic threats + * are dropped + */ + public function detect(string $content, string $ip, string $context, bool $skipSemantic = false): array { $executionStartNs = hrtime(true); $originalContent = $content; @@ -720,7 +726,7 @@ public function detect(string $content, string $ip, string $context): array $matchedPatterns = array_merge($matchedPatterns, $shortBase64Matched); $timeouts = array_merge($timeouts, $shortBase64Timeouts); - [$semanticThreats] = $this->checkSemanticThreats($processedContent, $originalContent); + $semanticThreats = $skipSemantic ? [] : $this->checkSemanticThreats($processedContent, $originalContent)[0]; $threats = array_merge($regexThreats, $semanticThreats); diff --git a/src/Pipeline/Checks/SuspiciousActivityCheck.php b/src/Pipeline/Checks/SuspiciousActivityCheck.php index 5d061cd..53eca56 100644 --- a/src/Pipeline/Checks/SuspiciousActivityCheck.php +++ b/src/Pipeline/Checks/SuspiciousActivityCheck.php @@ -98,7 +98,12 @@ public function check(GuardRequest $request): ?GuardResponse $categories = []; $triggerInfo = ''; - foreach ($this->scanValues($request) as [$content, $context, $forcedCategory, $skipCategories, $label]) { + 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: '; @@ -115,7 +120,7 @@ public function check(GuardRequest $request): ?GuardResponse } continue; } - $result = $this->susPatterns->detect($content, $clientIp, $context); + $result = $this->susPatterns->detect($content, $clientIp, $context, $skipSemantic); if ($result['is_threat']) { foreach ($result['threats'] as $threat) { $category = $threat['category'] ?? 'custom'; @@ -225,7 +230,9 @@ public function check(GuardRequest $request): ?GuardResponse * label], the label being the reference trigger prefix ("Request body: * ", "Header 'x': ", ...) the DetectionResult.trigger_info reports. * - * @return list, string}> + * An entry from a semantic-excluded header carries a sixth element, true. + * + * @return list, 4: string, 5?: bool}> */ /** * Mirrors _resolve_enabled_categories: a non-null route category set @@ -260,6 +267,12 @@ private function scanValues(GuardRequest $request): array $this->config->excludedDetectionHeaders, array_fill_keys($routeExcludedHeaders, true) )); + // The semantic-only header opt-out merges global and route sets, each + // entry trimmed and lowercased (_resolve_semantic_excluded_headers). + $semanticHeaders = []; + foreach (array_merge(array_keys($this->config->excludedSemanticDetectionHeaders), $routeConfig?->excludedSemanticDetectionHeaders ?? []) as $semanticName) { + $semanticHeaders[strtolower(trim((string) $semanticName))] = true; + } $enabledCategories = $this->resolveEnabledCategories($routeConfig); $scanBody = $routeConfig?->detectionScanBody ?? $this->config->detectionScanBody; foreach ($request->queryParams() as $name => $value) { @@ -280,7 +293,8 @@ private function scanValues(GuardRequest $request): array $skipCategories = isset($excludedHeaders[strtolower((string) $name)]) ? HeaderExclusions::skipCategories((string) $name, (string) $value) : []; - foreach ($this->scannedValue((string) $value, 'header', "Header '" . strtolower((string) $name) . "': ", $excludedBodyFields, $skipCategories) as $entry) { + $skipSemantic = isset($semanticHeaders[strtolower(trim((string) $name))]); + foreach ($this->scannedValue((string) $value, 'header', "Header '" . strtolower((string) $name) . "': ", $excludedBodyFields, $skipCategories, $skipSemantic) as $entry) { $values[] = $entry; } } @@ -306,23 +320,37 @@ private function scanValues(GuardRequest $request): array * * @param array $excludedBodyFields * @param array $skipCategories - * @return list}> + * @return list, 4: string, 5?: bool}> */ - private function scannedValue(string $value, string $context, string $label, array $excludedBodyFields = [], array $skipCategories = []): array + private function scannedValue(string $value, string $context, string $label, array $excludedBodyFields = [], array $skipCategories = [], bool $skipSemantic = false): array { $root = JsonWalk::parse($value); if ($root === null) { - return [[$value, $context, null, $skipCategories, $label]]; + return [self::scanEntry($value, $context, null, $skipCategories, $label, $skipSemantic)]; } $entries = JsonWalk::walkEntries($root, $context . JsonWalk::EMBEDDED_JSON_LEAF_CONTEXT_SUFFIX, $excludedBodyFields); foreach ($entries as $i => [$leaf, $leafContext, $leafForced, $leafLabel]) { - $entries[$i] = [$leaf, $leafContext, $leafForced, $skipCategories, $leafLabel]; + $entries[$i] = self::scanEntry($leaf, $leafContext, $leafForced, $skipCategories, $leafLabel, $skipSemantic); } - $entries[] = [$value, $context, null, $skipCategories, $label]; + $entries[] = self::scanEntry($value, $context, null, $skipCategories, $label, $skipSemantic); return $entries; } + /** + * @param array $skipCategories + * @return array{0: string, 1: string, 2: ?string, 3: array, 4: string, 5?: bool} + */ + private static function scanEntry(string $content, string $context, ?string $forcedCategory, array $skipCategories, string $label, bool $skipSemantic): array + { + $entry = [$content, $context, $forcedCategory, $skipCategories, $label]; + if ($skipSemantic) { + $entry[] = true; + } + + return $entry; + } + /** * _build_threat_message (guard_core/_utils/detection_scan.py). * diff --git a/src/Routing/RouteConfig.php b/src/Routing/RouteConfig.php index bc438a3..2f9f484 100644 --- a/src/Routing/RouteConfig.php +++ b/src/Routing/RouteConfig.php @@ -48,6 +48,14 @@ final class RouteConfig /** @var list|null */ public readonly ?array $excludedDetectionHeaders; + /** + * Header names whose values skip the semantic analyzer only (regex + * scanning is unchanged); merges with the global set, never replaces it. + * + * @var list|null + */ + public readonly ?array $excludedSemanticDetectionHeaders; + /** @var list|null */ public readonly ?array $excludedDetectionParams; @@ -136,7 +144,8 @@ public function __construct( array $ipWhitelist = [], array $ipBlacklist = [], array $blockedCountries = [], - array $whitelistCountries = [] + array $whitelistCountries = [], + ?array $excludedSemanticDetectionHeaders = null ) { $this->bypassedChecks = array_values(array_filter( $bypassedChecks, @@ -155,6 +164,7 @@ public function __construct( $this->behaviorRules = $rules; $this->enableSuspiciousDetection = $enableSuspiciousDetection; $this->excludedDetectionHeaders = self::stringListOrNull($excludedDetectionHeaders); + $this->excludedSemanticDetectionHeaders = self::stringListOrNull($excludedSemanticDetectionHeaders); $this->excludedDetectionParams = self::stringListOrNull($excludedDetectionParams); $this->excludedDetectionBodyFields = self::stringListOrNull($excludedDetectionBodyFields); $this->enabledDetectionCategories = self::stringListOrNull($enabledDetectionCategories); @@ -293,6 +303,7 @@ public function with(array $values): self 'ipBlacklist' => $this->ipBlacklist, 'blockedCountries' => $this->blockedCountries, 'whitelistCountries' => $this->whitelistCountries, + 'excludedSemanticDetectionHeaders' => $this->excludedSemanticDetectionHeaders, ]; foreach ($values as $name => $value) { if (!array_key_exists($name, $known)) { From 9fffafb24dd294d734dc233353a5249f961de636 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:22:12 +0200 Subject: [PATCH 4/6] fix(detection): scan the Cookie header one name=value pair at a time --- bin/test_excluded_headers.php | 22 ++++++++++++ docs/configuration.md | 2 ++ .../Checks/SuspiciousActivityCheck.php | 36 ++++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/bin/test_excluded_headers.php b/bin/test_excluded_headers.php index c7e0666..5ac9c3b 100644 --- a/bin/test_excluded_headers.php +++ b/bin/test_excluded_headers.php @@ -210,5 +210,27 @@ function blockedHeadersWithRoute(SuspiciousActivityCheck $check, array $headers, $t->same([], array_keys($base->excludedSemanticDetectionHeaders), 'with() leaves the original semantic set untouched'); $t->same(['x-late'], array_keys($mutated->excludedSemanticDetectionHeaders), 'with() carries the new semantic exclusion'); +$t->section('the Cookie header scans one name=value pair at a time'); +$enc = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); +$sig = ''; +for ($i = 0; $i < 64; $i++) { + $sig .= chr($i); +} +$jwt = $enc('{"alg": "ES256", "typ": "JWT"}') . '.' + . $enc('{"sub": "3f2c1b9e-8a47-4d2e-9c1f-5b6a7d8e9f01", "name": "Jane Doe (Acme)", "email": "jane.doe@example.com", "roles": ["admin"], "iat": 1792000000, "exp": 1792000900}') . '.' + . $enc($sig); +$sessionCookie = 'session=' . $jwt . '; theme=system; _ga=GA1.1.123456789.1792000000'; +$t->same(false, blockedHeaders(makeCheck(), ['cookie' => $sessionCookie]), 'a benign session cookie does not score as one semantic blob (each pair scans alone)'); +$t->same(false, blockedHeaders(makeCheck(), ['Cookie' => $sessionCookie]), 'the Cookie header name matches case-insensitively'); +$t->same(true, blockedHeaders(makeCheck(), ['x-session' => $sessionCookie]), 'the same value in another header keeps the whole-value scan'); +$t->same(true, blockedHeaders(makeCheck(), ['cookie' => 'a=1; b=' . SCRIPT . '; c=3']), 'an attack pair in a cookie still blocks'); +$t->same(true, blockedHeaders(makeCheck(), ['cookie' => 'a=1;' . JNDI]), 'a pair with no space after the separator still blocks'); +$t->same(['a=b', 'c=d', "e=\r\nf", "\t"], SuspiciousActivityCheck::cookiePairs("a=b; c=d ;;e=\r\nf; ;\t"), 'pairs trim spaces only (CR/LF and tab stay) and empty pairs are skipped'); +$t->same([], SuspiciousActivityCheck::cookiePairs(' ; ;'), 'a cookie of empty pairs yields nothing'); +$t->same(true, blockedHeaders(makeCheck($lowThreshold), ['cookie' => SEMANTIC_ONLY]), 'a semantic-only cookie pair blocks with no opt-in'); +$cookieSemanticExcluded = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: ['Cookie']); +$t->same(false, blockedHeaders(makeCheck($cookieSemanticExcluded), ['cookie' => 'theme=system; _ga=GA1.1.123456789.1792000000']), 'the semantic opt-out applies to each cookie pair'); +$t->same(true, blockedHeaders(makeCheck($cookieSemanticExcluded), ['cookie' => 'theme=system; b=' . SCRIPT]), 'regex still scans every pair of a semantic-excluded cookie'); + echo "\npassed={$t->passed} failed={$t->failed}\n"; exit($t->failed === 0 ? 0 : 1); diff --git a/docs/configuration.md b/docs/configuration.md index 5a02e5b..23fedc9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,6 +70,8 @@ KNOWN_GAPS). `excludedSemanticDetectionHeaders` (global `SecurityConfig` field, default empty, and a `RouteConfig` field that merges with it) is an opt-in list of header names whose values skip the semantic analyzer only: the regex passes still scan them. Names match case-insensitively and trimmed. Use it for values such as JWT session cookies that score near the semantic threshold. The decorator form is `detectionExclusion(semanticHeaders: [...])`. +The `Cookie` header is scanned one `name=value` pair at a time (split on `;`, only spaces trimmed so CR and LF stay, empty pairs skipped), each pair taking the same header path, so a session cookie no longer scores as one blob and the semantic opt-out applies to each pair. + ## Behavior rules | Argument | Default | Notes | diff --git a/src/Pipeline/Checks/SuspiciousActivityCheck.php b/src/Pipeline/Checks/SuspiciousActivityCheck.php index 53eca56..2941fcb 100644 --- a/src/Pipeline/Checks/SuspiciousActivityCheck.php +++ b/src/Pipeline/Checks/SuspiciousActivityCheck.php @@ -290,12 +290,18 @@ private function scanValues(GuardRequest $request): array if (isset($this->config->logSensitiveHeaders[strtolower($name)])) { continue; } - $skipCategories = isset($excludedHeaders[strtolower((string) $name)]) - ? HeaderExclusions::skipCategories((string) $name, (string) $value) - : []; + $loweredName = strtolower((string) $name); $skipSemantic = isset($semanticHeaders[strtolower(trim((string) $name))]); - foreach ($this->scannedValue((string) $value, 'header', "Header '" . strtolower((string) $name) . "': ", $excludedBodyFields, $skipCategories, $skipSemantic) as $entry) { - $values[] = $entry; + // The Cookie header scans one name=value pair at a time, each pair + // through the same excluded/normal header path (_header_items_to_scan). + $pairs = strtolower(trim((string) $name)) === 'cookie' ? self::cookiePairs((string) $value) : [(string) $value]; + foreach ($pairs as $pair) { + $skipCategories = isset($excludedHeaders[$loweredName]) + ? HeaderExclusions::skipCategories((string) $name, $pair) + : []; + foreach ($this->scannedValue($pair, 'header', "Header '" . $loweredName . "': ", $excludedBodyFields, $skipCategories, $skipSemantic) as $entry) { + $values[] = $entry; + } } } $body = $request->body(); @@ -309,6 +315,26 @@ private function scanValues(GuardRequest $request): array return $values; } + /** + * A Cookie header split on ';' into its name=value pairs: only spaces + * around a pair are trimmed (so a CR or LF stays in the scanned value) + * and empty pairs are skipped (guard-core #162). + * + * @return list + */ + public static function cookiePairs(string $value): array + { + $pairs = []; + foreach (explode(';', $value) as $pair) { + $pair = trim($pair, ' '); + if ($pair !== '') { + $pairs[] = $pair; + } + } + + return $pairs; + } + /** * One query or header value: an embedded JSON value walks leaf-first * with the context plus the :embedded_json suffix (the reference's From 66297c1853066231cacd4388f575c6acc439ca0a Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:23:46 +0200 Subject: [PATCH 5/6] feat(metrics): emit the per-request metrics from processResponse --- bin/test_event_bus.php | 62 +++++++++++++++++++++++++++++++++ src/Engine/GuardEngine.php | 52 +++++++++++++++++++++++---- src/Events/MetricsCollector.php | 5 +++ src/Request/RequestState.php | 6 ++++ 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/bin/test_event_bus.php b/bin/test_event_bus.php index 615e1b0..9a1f52a 100644 --- a/bin/test_event_bus.php +++ b/bin/test_event_bus.php @@ -12,6 +12,7 @@ use RenzoFranceschini\GuardCore\Events\SecurityMetric; use RenzoFranceschini\GuardCore\Pipeline\SecurityCheckPipeline; use RenzoFranceschini\GuardCore\RateLimit\RateLimitHandler; +use RenzoFranceschini\GuardCore\Request\GuardResponse; use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest; use RenzoFranceschini\GuardCore\Rules\DynamicRuleManager; use RenzoFranceschini\GuardCore\Rules\DynamicRules; @@ -1122,4 +1123,65 @@ public function state(): object $persistApply->invoke($persistFailManager, new DynamicRules("\xB1\xB1", 1, new DateTimeImmutable())); $t->truthy((bool) array_filter($persistFailLogs, static fn (string $l): bool => str_contains($l, 'Failed to build last-known dynamic rules snapshot')), 'the snapshot build failure is logged'); +// --------------------------------------------------------------------- +// 9. Engine per-request metrics on the response path +// --------------------------------------------------------------------- + +$t->section('engine processResponse emits the request metrics'); +$metricsAgent = new BusAgent(); +$metricsEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$metricsEngine->setAgentHandler($metricsAgent); +$metricsRequest = busRequest('/orders?token=1', 'GET'); +$t->same(null, $metricsEngine->execute($metricsRequest), 'the request passes the pipeline'); +usleep(60000); +$metricsEngine->processResponse($metricsRequest, new GuardResponse(200)); +$t->same([EventTypes::METRIC_RESPONSE_TIME, EventTypes::METRIC_REQUEST_COUNT], array_map(static fn (SecurityMetric $m): string => $m->metricType, $metricsAgent->metrics), 'a 2xx response emits response_time and request_count'); +$t->truthy($metricsAgent->metrics[0]->value >= 0.05 && $metricsAgent->metrics[0]->value < 5.0, 'response_time is measured from the pipeline start'); +$t->same('200', $metricsAgent->metrics[0]->tags['status'], 'response_time carries the status tag'); +$t->same('GET', $metricsAgent->metrics[1]->tags['method'], 'request_count carries the method tag'); +$metricsAgent->metrics = []; +$metricsEngine->processResponse($metricsRequest, new GuardResponse(503)); +$t->same([EventTypes::METRIC_RESPONSE_TIME, EventTypes::METRIC_REQUEST_COUNT, EventTypes::METRIC_ERROR_RATE], array_map(static fn (SecurityMetric $m): string => $m->metricType, $metricsAgent->metrics), 'a 5xx response adds error_rate'); +$metricsAgent->metrics = []; +$metricsEngine->processResponse($metricsRequest, null); +$t->same([], $metricsAgent->metrics, 'no response, no metrics'); + +$offAgent = new BusAgent(); +$offEngine = new GuardEngine(new SecurityConfig(enableRedis: false, agentEnableMetrics: false)); +$offEngine->setAgentHandler($offAgent); +$offRequest = busRequest('/orders', 'GET'); +$offEngine->execute($offRequest); +$offEngine->processResponse($offRequest, new GuardResponse(200)); +$t->same([], $offAgent->metrics, 'agentEnableMetrics false emits nothing'); + +$mutedAgent = new BusAgent(); +$mutedEngine = new GuardEngine(new SecurityConfig(enableRedis: false, mutedMetricTypes: [EventTypes::METRIC_REQUEST_COUNT])); +$mutedEngine->setAgentHandler($mutedAgent); +$mutedRequest = busRequest('/orders', 'GET'); +$mutedEngine->execute($mutedRequest); +$mutedEngine->processResponse($mutedRequest, new GuardResponse(200)); +$t->same([EventTypes::METRIC_RESPONSE_TIME], array_map(static fn (SecurityMetric $m): string => $m->metricType, $mutedAgent->metrics), 'muted metric types are not emitted'); + +$bareEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$bareRequest = busRequest('/orders', 'GET'); +$bareEngine->execute($bareRequest); +$bareEngine->processResponse($bareRequest, new GuardResponse(200)); +$t->same([], $bareEngine->metricsCollector()->drain(), 'with no agent handler nothing queues'); + +$legacyAgent = new class { + /** @var list */ + public array $received = []; + + public function sendEvent(object $event): void + { + $this->received[] = $event; + } +}; +$legacyEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$legacyEngine->setAgentHandler($legacyAgent); +$legacyRequest = busRequest('/orders', 'GET'); +$legacyEngine->execute($legacyRequest); +$legacyEngine->processResponse($legacyRequest, new GuardResponse(200)); +$t->same([], $legacyEngine->metricsCollector()->drain(), 'a handler without sendMetric gets no metrics and nothing queues'); + exit($t->finish('EVENT BUS')); diff --git a/src/Engine/GuardEngine.php b/src/Engine/GuardEngine.php index a7304e9..b2c1ff6 100644 --- a/src/Engine/GuardEngine.php +++ b/src/Engine/GuardEngine.php @@ -16,7 +16,9 @@ use RenzoFranceschini\GuardCore\Detection\Redos\ValidationCache; use RenzoFranceschini\GuardCore\Decorators\SecurityDecorator; use RenzoFranceschini\GuardCore\Events\EventBus; +use RenzoFranceschini\GuardCore\Events\EventFilter; use RenzoFranceschini\GuardCore\Events\EventTypes; +use RenzoFranceschini\GuardCore\Events\MetricsCollector; use RenzoFranceschini\GuardCore\Logging\LogRedactor; use RenzoFranceschini\GuardCore\Pipeline\BlockEvents; use RenzoFranceschini\GuardCore\Pipeline\CheckFactory; @@ -51,6 +53,8 @@ final class GuardEngine private readonly EventBus $eventBus; + private readonly MetricsCollector $metricsCollector; + private readonly RedisHandler $redis; private readonly IpBanManager $banManager; @@ -109,6 +113,14 @@ public function __construct( $this->eventBus = new EventBus(null, $config, countryResolver: static function (string $ip) use ($config): ?string { return $config->geoIpHandler?->getCountry($ip); }); + // No RedisHandler: the per-request metrics only emit once an agent + // handler is attached (the reference gate), so nothing queues or + // persists before that. + $this->metricsCollector = new MetricsCollector( + null, + $config, + new EventFilter($config->mutedEventTypes, $config->mutedMetricTypes) + ); $this->banManager = new IpBanManager($config->trustedProxies, $warn); $this->rateLimitHandler = new RateLimitHandler(self::rateLimitConfig($config), warn: $warn); $this->cloudManager = $cloudManager ?? ($config->cloudBlockingEnabled() ? new CloudManager() : null); @@ -200,6 +212,16 @@ public function eventBus(): EventBus return $this->eventBus; } + /** + * The per-request metrics collector (response_time, request_count, + * error_rate), fed by processResponse once an agent handler that exposes + * sendMetric is attached. + */ + public function metricsCollector(): MetricsCollector + { + return $this->metricsCollector; + } + /** * Attach a duck-typed agent handler (sendEvent(object $event): void, * e.g. GuardAgent) to the event bus. Events emitted before this call @@ -223,6 +245,11 @@ public function setAgentHandler(object $agentHandler): void return; } $this->eventBus->setAgentHandler($agentHandler); + // A handler without sendMetric keeps events only: the collector would + // log a failure for every metric otherwise. + if (is_callable([$agentHandler, 'sendMetric'])) { + $this->metricsCollector->setAgentHandler($agentHandler); + } } /** @@ -340,17 +367,27 @@ public function failClosedResponse(): GuardResponse * process_response): the route's return_pattern rules run first, then * the global ones. Return rules never modify the response; a matched * rule dispatches its configured action (ban/log/throttle/alert). The - * adapter calls this on every pass-through response it sends. + * adapter calls this on every pass-through response it sends. It also + * emits the per-request metrics (response_time, request_count and, from + * 400 up, error_rate) when an agent handler with sendMetric is attached. */ public function processResponse(GuardRequest $request, ?GuardResponse $response): void { - if ($this->behavioralProcessor === null) { - return; + if ($this->behavioralProcessor !== null) { + $now = microtime(true); + $clientIp = $request->state()->clientIp ?? ''; + $this->behavioralProcessor->processReturnRules($request, $response, $clientIp, $request->state()->routeConfig, $now); + $this->behavioralProcessor->processGlobalReturnRules($request, $response, $clientIp, $now); + } + // The reference response factory collects the request metrics once + // per outbound response (metrics.collect_request_metrics), gated by + // the attached agent handler and agent_enable_metrics; response_time + // runs from the pipeline start. + if ($response !== null && $this->metricsCollector->hasAgentHandler()) { + $startedAt = $request->state()->pipelineStartedAt; + $responseTime = $startedAt === null ? 0.0 : max(0.0, hrtime(true) / 1e9 - $startedAt); + $this->metricsCollector->collectRequestMetrics($request, $responseTime, $response->statusCode()); } - $now = microtime(true); - $clientIp = $request->state()->clientIp ?? ''; - $this->behavioralProcessor->processReturnRules($request, $response, $clientIp, $request->state()->routeConfig, $now); - $this->behavioralProcessor->processGlobalReturnRules($request, $response, $clientIp, $now); } /** @@ -510,6 +547,7 @@ public function initializationStatus(): array public function execute(GuardRequest $request): ?GuardResponse { $state = $request->state(); + $state->pipelineStartedAt = hrtime(true) / 1e9; $cors = $this->corsPolicy; $preflight = $cors !== null && CorsPolicy::isPreflight($request); diff --git a/src/Events/MetricsCollector.php b/src/Events/MetricsCollector.php index 3007293..9b6541a 100644 --- a/src/Events/MetricsCollector.php +++ b/src/Events/MetricsCollector.php @@ -43,6 +43,11 @@ public function __construct( $this->agentHandler = $agentHandler; } + public function hasAgentHandler(): bool + { + return $this->agentHandler !== null; + } + public function setAgentHandler(object $agentHandler): void { $this->agentHandler = $agentHandler; diff --git a/src/Request/RequestState.php b/src/Request/RequestState.php index d2e1b9e..ca1beba 100644 --- a/src/Request/RequestState.php +++ b/src/Request/RequestState.php @@ -30,6 +30,12 @@ final class RequestState public ?bool $guardRouteUnresolved = null; + /** + * Monotonic seconds (hrtime) stamped when the engine starts the pipeline + * for this request; the response_time metric is measured from it. + */ + public ?float $pipelineStartedAt = null; + /** @var array{reason: string, trigger_info: string}|null */ public ?array $guardBlockStash = null; From e69fb6b711ce80fbc60e0bd424218ad19a57d99d Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 06:53:20 +0200 Subject: [PATCH 6/6] build(coverage): move the unreachable-line coordinates after the metrics and config edits --- .github/coverage-unreachable.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index b3a671f..772fff8 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -248,14 +248,14 @@ 85, 86, 88, ], - // The bare-string guards inside validateSensitiveSet (1091) and - // validateExclusionSet (1115) sit behind typed ?array constructor + // The bare-string guards inside validateSensitiveSet (1105) and + // validateExclusionSet (1129) sit behind typed ?array constructor // parameters: PHP raises the TypeError at the call boundary, so a // string can never reach the is_string($names) checks (the same shape // as the BehaviorRule entry above). The suites assert the boundary // TypeError instead (test_json_logging.php). 'src/Config/SecurityConfig.php' => [ - 1091, 1115, + 1105, 1129, ], // 219: the non-array/non-scalar json_decode guard - json_decode can @@ -271,7 +271,7 @@ 219, 240, 241, 242, 243, ], - // 481-485: the ip_ban-side initialization catch. Every collaborator + // 518-522: the ip_ban-side initialization catch. Every collaborator // inside the try fails open or swallows: RateLimitHandler:: // initializeRedis wraps scriptLoad in its own catch, initializeIpBan // only assigns the manager, IpBanManager::initializeRedis migrates @@ -279,6 +279,6 @@ // refreshes through the single-flight path that logs its own // failures - no input steers a Throwable out of the block. 'src/Engine/GuardEngine.php' => [ - 481, 482, 483, 484, 485, + 518, 519, 520, 521, 522, ], ];