diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index a69f2a1..b3a671f 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -271,7 +271,7 @@ 219, 240, 241, 242, 243, ], - // 457-461: the ip_ban-side initialization catch. Every collaborator + // 481-485: 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' => [ - 457, 458, 459, 460, 461, + 481, 482, 483, 484, 485, ], ]; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31cdec7..30967c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,9 @@ jobs: REDIS_HOST: 127.0.0.1 run: php bin/test_events_parity.php + - name: test_decorators + run: php bin/test_decorators.php + conformance-pipeline: name: Conformance pipeline (php ${{ matrix.php }}) runs-on: ubuntu-latest @@ -240,7 +243,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; 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; do php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php" done diff --git a/Makefile b/Makefile index 4dd42a6..30fe500 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 +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 .PHONY: install test lint bump-version clean diff --git a/bin/test_decorators.php b/bin/test_decorators.php new file mode 100644 index 0000000..5e532d1 --- /dev/null +++ b/bin/test_decorators.php @@ -0,0 +1,665 @@ +section = $name; + echo "=== {$name} ===\n"; + } + + public function ok(bool $condition, string $label): void + { + if ($condition) { + $this->passed++; + echo " PASS {$label}\n"; + } else { + $this->failed++; + echo " FAIL {$this->section} :: {$label}\n"; + } + } + + public function same(mixed $expected, mixed $actual, string $label): void + { + $ok = $expected === $actual; + if (!$ok) { + echo ' expected: ' . var_export($expected, true) . "\n actual: " . var_export($actual, true) . "\n"; + } + $this->ok($ok, $label); + } + + /** @param class-string<\Throwable> $expectedClass */ + public function throws(string $needle, callable $fn, string $label, string $expectedClass = \InvalidArgumentException::class): void + { + try { + $fn(); + } catch (\Throwable $e) { + $this->ok(str_contains($e->getMessage(), $needle), "{$label} (" . $e::class . ")"); + $this->same(true, $e instanceof $expectedClass, "{$label}: {$expectedClass}"); + + return; + } + $this->failed++; + echo " FAIL {$this->section} :: {$label} (no exception)\n"; + } + + public function done(string $name): int + { + echo "\n{$name}: {$this->passed} passed, {$this->failed} failed\n"; + + return $this->failed; + } +} + +$t = new DT(); + +function dtGuard(?SecurityConfig $config = null): SecurityDecorator +{ + return new SecurityDecorator($config ?? new SecurityConfig(enableRedis: false)); +} + +function dtRequest(string $urlPath = '/', array $headers = [], string $method = 'GET', string $body = ''): SimpleGuardRequest +{ + return new SimpleGuardRequest(urlPath: $urlPath, method: $method, clientHost: '192.0.2.7', headers: $headers, body: $body); +} + +/** A duck-typed agent handler collecting the events it is sent. */ +final class DtAgentHandler +{ + /** @var list */ + public array $events = []; + + public function sendEvent(object $event): void + { + $this->events[] = $event; + } +} + +// A controller pair sharing one method base id (the qualname collision case). +final class DtEndpointHolder +{ + public function show(): string + { + return 'show'; + } +} + +// An invokable-object endpoint (the get_class base id case). +final class DtInvokableEndpoint +{ + public function __invoke(): string + { + return 'invoked'; + } +} + +$t->section('route config: reference defaults'); +$bare = new RouteConfig(); +$t->same(null, $bare->rateLimit, 'rate_limit defaults null'); +$t->same(null, $bare->rateLimitWindow, 'rate_limit_window defaults null'); +$t->same([], $bare->ipWhitelist, 'ip_whitelist defaults empty'); +$t->same([], $bare->ipBlacklist, 'ip_blacklist defaults empty'); +$t->same([], $bare->blockedCountries, 'blocked_countries defaults empty'); +$t->same([], $bare->whitelistCountries, 'whitelist_countries defaults empty'); +$t->same([], $bare->bypassedChecks, 'bypassed_checks defaults empty set'); +$t->same(false, $bare->requireHttps, 'require_https defaults false'); +$t->same(null, $bare->authRequired, 'auth_required defaults null'); +$t->same([], $bare->customValidators, 'custom_validators defaults empty'); +$t->same([], $bare->blockedUserAgents, 'blocked_user_agents defaults empty'); +$t->same([], $bare->requiredHeaders, 'required_headers defaults empty map'); +$t->same([], $bare->behaviorRules, 'behavior_rules defaults empty'); +$t->same([], $bare->blockCloudProviders, 'block_cloud_providers defaults empty set'); +$t->same(null, $bare->maxRequestSize, 'max_request_size defaults null'); +$t->same(null, $bare->allowedContentTypes, 'allowed_content_types defaults null'); +$t->same(null, $bare->timeRestrictions, 'time_restrictions defaults null'); +$t->same(true, $bare->enableSuspiciousDetection, 'enable_suspicious_detection defaults true'); +$t->same(null, $bare->requireReferrer, 'require_referrer defaults null'); +$t->same(false, $bare->apiKeyRequired, 'api_key_required defaults false'); +$t->same(null, $bare->authVerifier, 'auth_verifier defaults null'); +$t->same(null, $bare->apiKeyVerifier, 'api_key_verifier defaults null'); +$t->same(null, $bare->apiKeyHeader, 'api_key_header defaults null'); +$t->same(null, $bare->authorizationHeaderRequired, 'authorization_header_required defaults null'); +$t->same([], $bare->geoRateLimits, 'geo_rate_limits defaults empty'); +$t->same(null, $bare->excludedDetectionHeaders, 'excluded_detection_headers defaults null'); +$t->same(null, $bare->excludedDetectionParams, 'excluded_detection_params defaults null'); +$t->same(null, $bare->excludedDetectionBodyFields, 'excluded_detection_body_fields defaults null'); +$t->same(null, $bare->enabledDetectionCategories, 'enabled_detection_categories defaults null'); +$t->same(null, $bare->detectionScanBody, 'detection_scan_body defaults null'); + +$t->section('shared revision cell'); +$revision = new RouteConfigRevision(); +$t->same(0, $revision->value, 'a fresh cell is zero'); +$revision->bump(); +$t->same(1, $revision->value, 'bump increments'); +$revision->bump(); +$t->same(2, $revision->value, 'bump accumulates'); + +$t->section('base decorator: registry and route identity'); +$config = new SecurityConfig(enableRedis: false); +$guard = dtGuard($config); +$t->same($config, $guard->config(), 'the decorator keeps its config'); +$t->same(0, $guard->routeConfigRevision(), 'a fresh handler has revision zero'); + +$fn = static fn (): string => 'ok'; +$decorated = $guard->rateLimit(7, 30)->decorate($fn); +$t->same(true, $decorated instanceof DecoratedEndpoint, 'decorating returns a DecoratedEndpoint'); +$t->same('closure#' . spl_object_id($fn), $decorated->guardRouteId, 'a closure id is closure#{object id}'); +$rc = $guard->getRouteConfig($decorated->guardRouteId); +$t->same(true, $rc !== null, 'the registry holds the decorated route'); +$t->same(true, $config->enablePenetrationDetection === $rc->enableSuspiciousDetection, 'the route is seeded with enable_penetration_detection'); +$t->same(2, $guard->routeConfigRevision(), 'the registration and its mutation each bump once'); +$t->same($rc, SecurityDecorator::getRouteDecoratorConfig((static function () use ($decorated) { + $r = dtRequest(); + $r->state()->guardRouteId = $decorated->guardRouteId; + + return $r; +})(), $guard), 'the stamped id resolves the registry instance'); +$decoratedAgain = $guard->bypass(['rate_limit'])->decorate($fn); +$t->same($decorated->guardRouteId, $decoratedAgain->guardRouteId, 're-decorating the same endpoint joins its route'); +$t->same(['rate_limit'], $guard->getRouteConfig($decorated->guardRouteId)->bypassedChecks, 'joined routes share one config'); +$t->same(null, $guard->getRouteConfig('non.existent.route'), 'an unknown id resolves null'); +$registry = $guard->routeConfigs(); +$t->same(true, isset($registry[$decorated->guardRouteId]), 'routeConfigs exposes the registry'); + +$t->section('base decorator: get_route_decorator_config'); +$noId = dtRequest(); +$t->same(null, SecurityDecorator::getRouteDecoratorConfig($noId, $guard), 'no stamped id resolves null'); +$request = dtRequest(); +$request->state()->guardRouteId = $decorated->guardRouteId; +$t->same(true, SecurityDecorator::getRouteDecoratorConfig($request, $guard) !== null, 'the stamped id resolves its config'); +$request->state()->guardRouteId = 'unknown.route'; +$t->same(null, SecurityDecorator::getRouteDecoratorConfig($request, $guard), 'an unknown stamped id resolves null'); + +$t->section('base decorator: behavior tracking and agent wiring'); +$guard->initializeBehaviorTracking(); +$trackerBefore = $guard->behaviorTracker(); +$guard->initializeBehaviorTracking(null); +$t->same($trackerBefore, $guard->behaviorTracker(), 'a null redis handler keeps the tracker'); +$guard->initializeBehaviorTracking(new RenzoFranceschini\GuardCore\Redis\RedisHandler(enableRedis: false)); +$t->same(true, $guard->behaviorTracker() !== $trackerBefore, 'a redis handler rewires the tracker'); +$agent = new DtAgentHandler(); +$guard->initializeAgent($agent); +$t->same(true, true, 'initializeAgent accepts a handler'); + +$t->section('access control mixin'); +$g = dtGuard(); +$ep = $g->requireIp(whitelist: ['10.0.0.1'])->decorate('/ip-w'); +$rc = $g->getRouteConfig('/ip-w'); +$t->same(['10.0.0.1'], $rc->ipWhitelist, 'require_ip whitelist lands'); +$g = dtGuard(); +$ep = $g->requireIp(blacklist: ['10.0.0.2'])->decorate('/ip-b'); +$rc = $g->getRouteConfig('/ip-b'); +$t->same(['10.0.0.2'], $rc->ipBlacklist, 'require_ip blacklist lands'); +$g = dtGuard(); +$g->requireIp(whitelist: ['10.0.0.1'], blacklist: ['10.0.0.3'])->decorate('/ip-both'); +$rc = $g->getRouteConfig('/ip-both'); +$t->same(['10.0.0.1'], $rc->ipWhitelist, 'require_ip both: whitelist'); +$t->same(['10.0.0.3'], $rc->ipBlacklist, 'require_ip both: blacklist'); +$g = dtGuard(); +$g->requireIp()->decorate('/ip-none'); +$rc = $g->getRouteConfig('/ip-none'); +$t->same([], $rc->ipWhitelist, 'require_ip without lists is a no-op registration'); + +$g = dtGuard(); +$g->blockCountries(['cn', 'ru'])->decorate('/cc-b'); +$t->same(['CN', 'RU'], $g->getRouteConfig('/cc-b')->blockedCountries, 'block_countries uppercases'); +$g = dtGuard(); +$g->allowCountries(['us', 'uk'])->decorate('/cc-w'); +$t->same(['US', 'UK'], $g->getRouteConfig('/cc-w')->whitelistCountries, 'allow_countries uppercases'); + +$g = dtGuard(); +$g->blockClouds()->decorate('/cloud-all'); +$t->same(CloudProviderRegistry::PROVIDERS, $g->getRouteConfig('/cloud-all')->blockCloudProviders, 'block_clouds without providers blocks every provider'); +$g = dtGuard(); +$g->blockClouds(['AWS', 'GCP:!us'])->decorate('/cloud-some'); +$t->same(['AWS', 'GCP:!us'], $g->getRouteConfig('/cloud-some')->blockCloudProviders, 'block_clouds keeps valid selectors including regions'); +$g = dtGuard(); +$g->blockClouds(['AWS', 'NotACloud'])->decorate('/cloud-bad'); +$t->same(['AWS'], $g->getRouteConfig('/cloud-bad')->blockCloudProviders, 'block_clouds drops unknown providers'); + +$g = dtGuard(); +$g->bypass(['ip', 'rate_limit'])->decorate('/bypass-1'); +$rc = $g->getRouteConfig('/bypass-1'); +$t->same(true, in_array('ip', $rc->bypassedChecks, true), 'bypass keeps ip'); +$t->same(true, in_array('rate_limit', $rc->bypassedChecks, true), 'bypass keeps rate_limit'); +$g->bypass(['all', 'not-a-check'])->decorate('/bypass-1'); +$rc = $g->getRouteConfig('/bypass-1'); +$t->same(['ip', 'rate_limit', 'all'], $rc->bypassedChecks, 'bypass unions across applications and drops unknown names'); + +$t->section('rate limiting mixin'); +$g = dtGuard(); +$g->rateLimit(100, 30)->decorate('/rl'); +$rc = $g->getRouteConfig('/rl'); +$t->same(100, $rc->rateLimit, 'rate_limit sets the limit'); +$t->same(30, $rc->rateLimitWindow, 'rate_limit sets the window'); +$g = dtGuard(); +$g->rateLimit(50)->decorate('/rl-default'); +$t->same(60, $g->getRouteConfig('/rl-default')->rateLimitWindow, 'rate_limit window defaults to 60'); +$g = dtGuard(); +$g->geoRateLimit(['US' => ['limit' => 100, 'window' => 60], '*' => ['limit' => 50, 'window' => 60]])->decorate('/geo'); +$rc = $g->getRouteConfig('/geo'); +$t->same(true, isset($rc->geoRateLimits['US']), 'geo_rate_limit registers a tier'); +$t->same(true, isset($rc->geoRateLimits['*']), 'geo_rate_limit registers the fallback tier'); + +$t->section('behavioral mixin'); +$g = dtGuard(); +$g->usageMonitor(maxCalls: 100, window: 3600, action: 'ban')->decorate('/usage'); +$rules = $g->getRouteConfig('/usage')->behaviorRules; +$t->same(1, count($rules), 'usage_monitor appends one rule'); +$t->same('usage', $rules[0]->ruleType, 'usage_monitor rule type'); +$t->same(100, $rules[0]->threshold, 'usage_monitor threshold'); +$t->same(3600, $rules[0]->window, 'usage_monitor window'); +$t->same('ban', $rules[0]->action, 'usage_monitor action'); + +$g = dtGuard(new SecurityConfig(enableRedis: false, behaviorScanResponseBody: true)); +$g->returnMonitor(pattern: 'error', maxOccurrences: 5, window: 86400, action: 'log')->decorate('/return'); +$rules = $g->getRouteConfig('/return')->behaviorRules; +$t->same(1, count($rules), 'return_monitor appends one rule'); +$t->same('return_pattern', $rules[0]->ruleType, 'return_monitor rule type'); +$t->same('error', $rules[0]->pattern, 'return_monitor pattern'); + +$g = dtGuard(); +$g->returnMonitor(pattern: 'status:500', maxOccurrences: 5, window: 86400, action: 'log')->decorate('/return-status'); +$t->same('status:500', $g->getRouteConfig('/return-status')->behaviorRules[0]->pattern, 'a status: return pattern never needs the body scan'); +$g = dtGuard(); +$t->throws('behavior_scan_response_body', static fn () => $g->returnMonitor('error', 5), 'return_monitor body pattern rejected with the scan off'); + +$g = dtGuard(); +$g->returnMonitor('', 5)->decorate('/return-empty'); +$emptyRules = $g->getRouteConfig('/return-empty')->behaviorRules; +$t->same(1, count($emptyRules), 'an empty pattern skips the scan gate like the list validator'); +$t->same('', $emptyRules[0]->pattern, 'the empty-pattern rule still lands'); + +$g = dtGuard(); +$g->behaviorAnalysis([new BehaviorRule(ruleType: 'usage', threshold: 10, window: 60, action: 'log')])->decorate('/behavior'); +$rules = $g->getRouteConfig('/behavior')->behaviorRules; +$t->same(1, count($rules), 'behavior_analysis extends the route rules'); +$g = dtGuard(); +$t->throws('behavior_scan_response_body', static fn () => $g->behaviorAnalysis([new BehaviorRule(ruleType: 'return_pattern', threshold: 5, pattern: 'json:status==ok')]), 'behavior_analysis body rule rejected with the scan off'); + +$g = dtGuard(); +$g->suspiciousFrequency(maxFrequency: 2.0, window: 300, action: 'alert')->decorate('/freq'); +$rules = $g->getRouteConfig('/freq')->behaviorRules; +$t->same('frequency', $rules[0]->ruleType, 'suspicious_frequency rule type'); +$t->same(600, $rules[0]->threshold, 'suspicious_frequency threshold is max_frequency * window'); +$t->same('alert', $rules[0]->action, 'suspicious_frequency action'); + +$t->section('authentication mixin'); +$g = dtGuard(); +$g->requireHttps()->decorate('/https'); +$t->same(true, $g->getRouteConfig('/https')->requireHttps, 'require_https flags the route'); + +$g = dtGuard(); +$g->requireAuth(type: 'bearer')->decorate('/auth-bearer'); +$rc = $g->getRouteConfig('/auth-bearer'); +$t->same('bearer', $rc->authRequired, 'require_auth bearer'); +$t->same(null, $rc->authVerifier, 'require_auth without a verifier'); +$verifier = static fn (object $request, string $credential): string => $credential; +$g = dtGuard(); +$g->requireAuth(type: 'bearer', verifier: $verifier)->decorate('/auth-verifier'); +$t->same($verifier, $g->getRouteConfig('/auth-verifier')->authVerifier, 'require_auth keeps its verifier'); +$g = dtGuard(); +$g->requireAuth(type: 'basic')->decorate('/auth-basic'); +$t->same('basic', $g->getRouteConfig('/auth-basic')->authRequired, 'require_auth basic'); + +$g = dtGuard(); +$g->apiKeyAuth(headerName: 'X-API-Key')->decorate('/key'); +$rc = $g->getRouteConfig('/key'); +$t->same(true, $rc->apiKeyRequired, 'api_key_auth flags the route'); +$t->same('required', $rc->requiredHeaders['X-API-Key'], 'api_key_auth requires its header'); +$t->same('X-API-Key', $rc->apiKeyHeader, 'api_key_auth records the header'); +$t->same(null, $rc->apiKeyVerifier, 'api_key_auth without a verifier'); +$g = dtGuard(); +$g->apiKeyAuth(headerName: 'X-Key', verifier: $verifier)->decorate('/key2'); +$rc = $g->getRouteConfig('/key2'); +$t->same('X-Key', $rc->apiKeyHeader, 'api_key_auth custom header'); +$t->same($verifier, $rc->apiKeyVerifier, 'api_key_auth keeps its verifier'); + +$g = dtGuard(); +$g->requireAuthorizationHeader(scheme: 'bearer')->decorate('/authz'); +$rc = $g->getRouteConfig('/authz'); +$t->same('bearer', $rc->authorizationHeaderRequired, 'require_authorization_header records the scheme'); +$t->same(null, $rc->authRequired, 'require_authorization_header leaves auth empty'); +$t->same(null, $rc->authVerifier, 'require_authorization_header leaves the verifier empty'); + +$g = dtGuard(); +$g->requireAuthorizationHeader('bearer')->decorate('/conflict-1'); +$t->throws('require_authorization_header', static fn () => $g->requireAuth('bearer')->decorate('/conflict-1'), 'require_auth conflicts with require_authorization_header'); +$g = dtGuard(); +$g->requireAuthorizationHeader('bearer')->decorate('/conflict-2'); +$t->throws('require_authorization_header', static fn () => $g->apiKeyAuth('X-Key')->decorate('/conflict-2'), 'api_key_auth conflicts with require_authorization_header'); +$g = dtGuard(); +$g->requireAuth('bearer')->decorate('/conflict-3'); +$t->throws('require_authorization_header', static fn () => $g->requireAuthorizationHeader('bearer')->decorate('/conflict-3'), 'require_authorization_header conflicts with require_auth'); +$g = dtGuard(); +$g->apiKeyAuth('X-Key')->decorate('/conflict-4'); +$t->throws('require_authorization_header', static fn () => $g->requireAuthorizationHeader('bearer')->decorate('/conflict-4'), 'require_authorization_header conflicts with api_key_auth'); + +$g = dtGuard(); +$g->requireHeaders(['X-Custom' => 'required'])->decorate('/headers'); +$t->same('required', $g->getRouteConfig('/headers')->requiredHeaders['X-Custom'], 'require_headers records the header'); +$g->requireHeaders(['X-Other' => 'yes'])->decorate('/headers'); +$rc = $g->getRouteConfig('/headers'); +$t->same(true, isset($rc->requiredHeaders['X-Custom'], $rc->requiredHeaders['X-Other']), 'require_headers merges across applications'); + +$t->section('content filtering mixin'); +$g = dtGuard(); +$g->blockUserAgents(['badbot', 'scanner'])->decorate('/ua'); +$t->same(true, in_array('badbot', $g->getRouteConfig('/ua')->blockedUserAgents, true), 'block_user_agents extends the route list'); +$g->blockUserAgents(['crawler'])->decorate('/ua'); +$t->same(['badbot', 'scanner', 'crawler'], $g->getRouteConfig('/ua')->blockedUserAgents, 'block_user_agents merges across applications'); +$g = dtGuard(); +$t->throws('rejected by ReDoS validator', static fn () => $g->blockUserAgents(['(?:a+)+$']), 'block_user_agents rejects a catastrophic pattern before decoration'); + +$g = dtGuard(); +$g->contentTypeFilter(['application/json'])->decorate('/ct'); +$t->same(['application/json'], $g->getRouteConfig('/ct')->allowedContentTypes, 'content_type_filter sets the allow list'); + +$g = dtGuard(); +$g->maxRequestSize(1024)->decorate('/size'); +$t->same(1024, $g->getRouteConfig('/size')->maxRequestSize, 'max_request_size sets the bound'); + +$g = dtGuard(); +$g->requireReferrer(['example.com'])->decorate('/ref'); +$t->same(['example.com'], $g->getRouteConfig('/ref')->requireReferrer, 'require_referrer sets the domains'); + +$g = dtGuard(); +$called = false; +$validator = static function (object $request) use (&$called) { + $called = true; + + return null; +}; +$g->customValidation($validator)->decorate('/custom'); +$validators = $g->getRouteConfig('/custom')->customValidators; +$t->same(1, count($validators), 'custom_validation appends the validator'); +$t->same(true, $validators[0] === $validator, 'custom_validation keeps the exact closure'); + +$g = dtGuard(); +$g->detectionExclusion(headers: ['X-Noise'], params: ['q'], bodyFields: ['secret'], categories: ['sqli'], scanBody: false)->decorate('/dex'); +$rc = $g->getRouteConfig('/dex'); +$t->same(['X-Noise'], $rc->excludedDetectionHeaders, 'detection_exclusion headers'); +$t->same(['q'], $rc->excludedDetectionParams, 'detection_exclusion params'); +$t->same(['secret'], $rc->excludedDetectionBodyFields, 'detection_exclusion body fields'); +$t->same(['sqli'], $rc->enabledDetectionCategories, 'detection_exclusion categories'); +$t->same(false, $rc->detectionScanBody, 'detection_exclusion scan body'); +$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'); +$t->same(null, $rc->excludedDetectionHeaders, 'detection_exclusion untouched fields stay null'); +$t->same(null, $rc->detectionScanBody, 'detection_exclusion untouched scan body stays null'); +$g = dtGuard(); +$g->detectionExclusion()->decorate('/dex-none'); +$t->same(null, $g->getRouteConfig('/dex-none')->excludedDetectionParams, 'detection_exclusion without arguments is a registration no-op'); + +$t->section('advanced mixin'); +$g = dtGuard(); +$g->timeWindow('09:00', '17:00', 'US/Eastern')->decorate('/window'); +$rc = $g->getRouteConfig('/window'); +$t->same('09:00', $rc->timeRestrictions['start'], 'time_window start'); +$t->same('17:00', $rc->timeRestrictions['end'], 'time_window end'); +$t->same('US/Eastern', $rc->timeRestrictions['timezone'], 'time_window timezone'); +$g = dtGuard(); +$g->timeWindow('09:00', '17:00')->decorate('/window-default'); +$t->same('UTC', $g->getRouteConfig('/window-default')->timeRestrictions['timezone'], 'time_window timezone defaults to UTC'); + +$g = dtGuard(); +$g->suspiciousDetection(enabled: false)->decorate('/susp'); +$t->same(false, $g->getRouteConfig('/susp')->enableSuspiciousDetection, 'suspicious_detection disables the route gate'); +$g = dtGuard(); +$g->suspiciousDetection()->decorate('/susp-default'); +$t->same(true, $g->getRouteConfig('/susp-default')->enableSuspiciousDetection, 'suspicious_detection defaults enabled'); + +$t->section('honeypot detection'); +$honeypotGuard = dtGuard(); +$honeypotGuard->honeypotDetection(['honeypot'])->decorate('/hp'); +$hpValidators = $honeypotGuard->getRouteConfig('/hp')->customValidators; +$t->same(1, count($hpValidators), 'honeypot_detection appends one validator'); +$hp = $hpValidators[0]; + +$hpJsonHit = $hp(dtRequest('/hp', ['content-type' => 'application/json'], 'POST', '{"honeypot": "filled"}')); +$t->same(403, $hpJsonHit?->statusCode(), 'honeypot json trigger returns 403'); +$t->same('Forbidden', $hpJsonHit?->body(), 'honeypot json trigger body'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'application/json'], 'POST', '{"name": "test"}')), 'honeypot json clean body passes'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'application/json'], 'POST', 'not json at all')), 'honeypot invalid json skips'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'application/json'], 'POST', '{"honeypot": 0}')), 'honeypot json zero value is falsy'); +$hpJsonStringZero = $hp(dtRequest('/hp', ['content-type' => 'application/json'], 'POST', '{"honeypot": "0"}')); +$t->same(403, $hpJsonStringZero?->statusCode(), 'honeypot json "0" string is truthy like the reference'); +$hpFormHit = $hp(dtRequest('/hp', ['content-type' => 'application/x-www-form-urlencoded'], 'POST', 'name=x&honeypot=filled')); +$t->same(403, $hpFormHit?->statusCode(), 'honeypot form trigger returns 403'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'application/x-www-form-urlencoded'], 'POST', 'name=test')), 'honeypot form clean body passes'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'application/x-www-form-urlencoded'], 'POST', 'honeypot=')), 'honeypot empty trap value passes'); +$t->same(null, $hp(dtRequest('/hp'), 'GET'), 'honeypot ignores GET'); +$t->same(null, $hp(dtRequest('/hp', ['content-type' => 'text/plain'], 'POST', 'honeypot=filled')), 'honeypot unknown content type skips'); + +$t->section('route identity: per endpoint, per instance'); +$g = dtGuard(); +$strict = $g->rateLimit(2, 60)->decorate(static fn (): string => 'strict'); +$loose = $g->rateLimit(100, 60)->decorate(static fn (): string => 'loose'); +$t->same(true, $strict->guardRouteId !== $loose->guardRouteId, 'two endpoints built by one factory keep distinct ids'); +$strictRequest = dtRequest(); +$strictRequest->state()->guardRouteId = $strict->guardRouteId; +$looseRequest = dtRequest(); +$looseRequest->state()->guardRouteId = $loose->guardRouteId; +$t->same(2, SecurityDecorator::getRouteDecoratorConfig($strictRequest, $g)?->rateLimit, 'the strict endpoint keeps its own limit'); +$t->same(100, SecurityDecorator::getRouteDecoratorConfig($looseRequest, $g)?->rateLimit, 'the loose endpoint keeps its own limit'); + +$g = dtGuard(); +$admin = $g->requireIp(whitelist: ['10.0.0.1'])->decorate('/admin-endpoint'); +$health = $g->bypass(['all'])->decorate('/health-endpoint'); +$t->same([], $g->getRouteConfig('/admin-endpoint')->bypassedChecks, 'one endpoint never gains another endpoint knobs'); +$t->same([], $g->getRouteConfig('/health-endpoint')->ipWhitelist, 'the other endpoint never gains the first knobs'); + +$g = dtGuard(); +$first = $g->rateLimit(2, 60)->decorate('/endpoint'); +$second = $g->rateLimit(100, 60)->decorate('/endpoint#2'); +$t->same('/endpoint', $first->guardRouteId, 'a pattern endpoint keeps the pattern as its id'); + +$g = dtGuard(); +$holderA = new DtEndpointHolder(); +$holderB = new DtEndpointHolder(); +$epA = $g->rateLimit(1, 60)->decorate([$holderA, 'show']); +$epB = $g->rateLimit(2, 60)->decorate([$holderB, 'show']); +$t->same(true, $epA->guardRouteId !== $epB->guardRouteId, 'two instances of one method get distinct route ids'); +$t->same(true, str_starts_with($epB->guardRouteId, $epA->guardRouteId), 'the second instance suffixes the shared base id'); + +$g = dtGuard(); +$stacked = $g->requireIp(whitelist: ['10.0.0.1'])->decorate(static fn (): string => 'stacked'); +$g->rateLimit(5, 60)->decorate($stacked->endpoint()); +$t->same(1, count($g->routeConfigs()), 'stacked decorations share one route'); +$stackedConfig = $g->getRouteConfig($stacked->guardRouteId); +$t->same(['10.0.0.1'], $stackedConfig->ipWhitelist, 'stacked: the first knob'); +$t->same(5, $stackedConfig->rateLimit, 'stacked: the second knob'); + +$guardA = dtGuard(); +$guardB = dtGuard(); +$firstEp = $guardA->rateLimit(2, 60)->decorate(static fn (): string => 'first'); +$secondEp = $guardB->bypass(['all'])->decorate(static fn (): string => 'second'); +$redecorated = $guardB->requireIp(whitelist: ['10.0.0.1'])->decorate($firstEp->endpoint()); +$firstConfig = $guardB->getRouteConfig($redecorated->guardRouteId); +$secondConfig = $guardB->getRouteConfig($secondEp->guardRouteId); +$t->same(true, $redecorated->guardRouteId !== $secondEp->guardRouteId, 'a stamped id is not reused across decorator instances'); +$t->same(['10.0.0.1'], $firstConfig->ipWhitelist, 'the re-decorated endpoint carries its own knobs'); +$t->same(['all'], $secondConfig->bypassedChecks, 'the other instance endpoint keeps its knobs'); +$t->same([], $firstConfig->bypassedChecks, 'the re-decorated endpoint never joined the other route'); + +$t->section('route identity: exotic endpoint forms'); +$g = dtGuard(); +$invokable = new DtInvokableEndpoint(); +$invokableEp = $g->rateLimit(1, 60)->decorate($invokable); +$t->same(DtInvokableEndpoint::class, $invokableEp->guardRouteId, 'an invokable object ids by its class'); +$t->same('invoked', $invokableEp(), 'an invokable object still calls through'); + +$g = dtGuard(); +$staticEp = $g->requireHttps()->decorate([DtEndpointHolder::class, 'show']); +$t->same(DtEndpointHolder::class . '::show', $staticEp->guardRouteId, 'a class-string method array ids by Class::method'); + +$g = dtGuard(); +$oddEp = $g->requireHttps()->decorate(42); +$t->same('endpoint', $oddEp->guardRouteId, 'an unform endpoint falls back to the generic id'); + +$hpGuard = dtGuard(); +$hpGuard->honeypotDetection(['honeypot'])->decorate('/hp2'); +$hp2 = $hpGuard->getRouteConfig('/hp2')->customValidators[0]; +$t->same(403, $hp2(dtRequest('/hp2', ['content-type' => 'application/x-www-form-urlencoded'], 'POST', 'honeypot=filled&&x=1'))?->statusCode(), 'honeypot form parse skips empty pairs'); +$t->same(null, $hp2(dtRequest('/hp2', ['content-type' => 'application/x-www-form-urlencoded'], 'POST', 'honeypot')), 'honeypot bare key carries an empty value'); + +$t->section('decorated endpoint shell'); +$shell = new DecoratedEndpoint(static fn (string $greeting): string => "{$greeting} world", 'route'); +$t->same(true, $shell->isCallable(), 'a callable shell reports callable'); +$t->same('hello world', $shell('hello'), 'the shell invokes through'); +$patternShell = new DecoratedEndpoint('GET /path', 'GET /path'); +$t->same('GET /path', $patternShell->endpoint(), 'a pattern shell keeps the pattern'); +$t->same(false, $patternShell->isCallable(), 'a pattern shell reports not callable'); +$t->throws('is a route pattern', static fn () => $patternShell(), 'invoking a pattern shell throws', \LogicException::class); + +$t->section('infra events'); +$eventGuard = dtGuard(); +$quiet = dtRequest(); +$eventGuard->sendDecoratorEvent(EventTypes::EVENT_ACCESS_DENIED, $quiet, 'blocked', 'no handler', 'access_control'); +$t->same(true, true, 'sending with no agent handler is a no-op'); +$eventGuard->sendAccessDeniedEvent($quiet, 'no handler', 'access_control'); +$t->same(true, true, 'access denied with no handler is a no-op'); + +$eventConfig = new SecurityConfig(enableRedis: false, agentEnableEvents: false); +$mutedGuard = dtGuard($eventConfig); +$mutedAgent = new DtAgentHandler(); +$mutedGuard->initializeAgent($mutedAgent); +$mutedGuard->sendDecoratorEvent(EventTypes::EVENT_ACCESS_DENIED, dtRequest(), 'blocked', 'muted', 'access_control'); +$t->same([], $mutedAgent->events, 'agent_enable_events false silences the emitters'); + +$sendingGuard = dtGuard(); +$agent = new DtAgentHandler(); +$sendingGuard->initializeAgent($agent); +$eventRequest = dtRequest('/api/data', ['user-agent' => 'test-agent']); +$sendingGuard->sendDecoratorEvent(EventTypes::EVENT_ACCESS_DENIED, $eventRequest, 'blocked', 'nope', 'access_control'); +$t->same(1, count($agent->events), 'send_decorator_event dispatches'); +$event = $agent->events[0]; +$t->same(EventTypes::EVENT_ACCESS_DENIED, $event->eventType, 'envelope event_type'); +$t->same('blocked', $event->actionTaken, 'envelope action_taken'); +$t->same('nope', $event->reason, 'envelope reason'); +$t->same('access_control', $event->metadata['decorator_type'] ?? null, 'envelope decorator_type'); +$t->same('middleware', $event->handlerName, 'envelope handler_name'); + +$agent = new DtAgentHandler(); +$sendingGuard->initializeAgent($agent); +$sendingGuard->sendAccessDeniedEvent($eventRequest, 'ip denied', 'ip_security'); +$t->same(EventTypes::EVENT_ACCESS_DENIED, $agent->events[0]->eventType, 'send_access_denied_event type'); +$t->same('ip_security', $agent->events[0]->metadata['decorator_type'] ?? null, 'send_access_denied_event decorator type'); + +$sendingGuard->sendAuthenticationFailedEvent($eventRequest, 'bad token', 'bearer'); +$t->same(EventTypes::EVENT_AUTHENTICATION_FAILED, $agent->events[1]->eventType, 'send_authentication_failed_event type'); +$t->same('authentication', $agent->events[1]->metadata['decorator_type'] ?? null, 'send_authentication_failed_event decorator type'); +$t->same('bearer', $agent->events[1]->metadata['auth_type'] ?? null, 'send_authentication_failed_event auth_type'); + +$sendingGuard->sendRateLimitEvent($eventRequest, 100, 60); +$t->same(EventTypes::EVENT_RATE_LIMITED, $agent->events[2]->eventType, 'send_rate_limit_event type'); +$t->same('Rate limit exceeded: 100 requests per 60s', $agent->events[2]->reason, 'send_rate_limit_event reason'); +$t->same('rate_limiting', $agent->events[2]->metadata['decorator_type'] ?? null, 'send_rate_limit_event decorator type'); +$t->same(100, $agent->events[2]->metadata['limit'] ?? null, 'send_rate_limit_event limit'); +$t->same(60, $agent->events[2]->metadata['window'] ?? null, 'send_rate_limit_event window'); + +$sendingGuard->sendDecoratorViolationEvent($eventRequest, 'content_filter', 'bad content'); +$t->same(EventTypes::EVENT_DECORATOR_VIOLATION, $agent->events[3]->eventType, 'send_decorator_violation_event type'); +$t->same('content_filter', $agent->events[3]->metadata['decorator_type'] ?? null, 'send_decorator_violation_event decorator type'); + +$geoAgent = new DtAgentHandler(); +$geoGuard = dtGuard(); +$geoGuard->initializeAgent($geoAgent, new class implements RenzoFranceschini\GuardCore\GeoIp\CountryResolver { + public function getCountry(string $ip): ?string + { + return 'US'; + } +}); +$geoGuard->sendDecoratorEvent(EventTypes::EVENT_ACCESS_DENIED, dtRequest(), 'blocked', 'geo wired', 'access_control'); +$t->same(1, count($geoAgent->events), 'the geo handler wiring keeps the emitter live'); + +$t->section('revision semantics'); +$g = dtGuard(); +$t->same(0, $g->routeConfigRevision(), 'no routes: revision zero'); +$g->rateLimit(1, 60)->decorate('/rev-a'); +$revisionAfterFirst = $g->routeConfigRevision(); +$t->same(true, $revisionAfterFirst > 0, 'the first registration bumps the shared revision'); +$g->bypass(['ip'])->decorate('/rev-a'); +$t->same($revisionAfterFirst + 1, $g->routeConfigRevision(), 'a mutation bumps again'); +$g->requireHttps()->decorate('/rev-b'); +$t->same(true, $g->routeConfigRevision() > $revisionAfterFirst + 1, 'a second registration bumps again'); +$before = $g->routeConfigRevision(); +$g->maxRequestSize(5)->decorate('/rev-a'); +$g->maxRequestSize(5)->decorate('/rev-a'); +$t->same(true, $g->routeConfigRevision() >= $before + 2, 'each applied decoration bumps once'); + +$t->section('engine seam: set_decorator_handler'); +$engineConfig = new SecurityConfig(enableRedis: false); +$engine = new GuardEngine($engineConfig); +$sec = dtGuard($engineConfig); +$protected = $sec->requireHeaders(['X-Token' => 'required'])->decorate('GET /admin'); +$t->same(null, $engine->decoratorHandler(), 'a fresh engine has no decorator handler'); +$engine->setDecoratorHandler($sec); +$t->same($sec, $engine->decoratorHandler(), 'set_decorator_handler wires the handler'); +$blocked = $engine->execute((static function () { + $r = dtRequest('/admin'); + $r->state()->clientIp = '192.0.2.7'; + $r->state()->guardRouteId = 'GET /admin'; + + return $r; +})()); +$t->same(400, $blocked?->statusCode(), 'the decorated route enforces its header through the pipeline'); +$allowed = $engine->execute((static function () { + $r = dtRequest('/admin', ['X-Token' => 'required']); + $r->state()->clientIp = '192.0.2.7'; + $r->state()->guardRouteId = 'GET /admin'; + + return $r; +})()); +$t->same(null, $allowed, 'a conforming request passes the decorated route'); + +$unstamped = dtRequest('/admin'); +$unstamped->state()->clientIp = '192.0.2.7'; +$t->same(null, $engine->execute($unstamped), 'an unstamped request runs unscoped'); + +$noHandlerEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$unresolved = dtRequest('/admin'); +$unresolved->state()->clientIp = '192.0.2.7'; +$unresolved->state()->guardRouteId = 'GET /admin'; +$t->same(null, $noHandlerEngine->execute($unresolved), 'without a handler a stamped id resolves nothing'); + +$t->section('adapter seam: the registry feeds a path-pattern route map'); +$adapterGuard = dtGuard(); +$adapterGuard->requireHttps()->decorate('GET /secure'); +$adapterGuard->rateLimit(3, 60)->decorate('/throttled'); +$routes = $adapterGuard->routeConfigs(); +$t->same(true, isset($routes['GET /secure'], $routes['/throttled']), 'the registry keys by pattern'); +$t->same(true, $routes['GET /secure']->requireHttps, 'the decorated config rides the plain route map'); + +exit($t->done('test_decorators')); diff --git a/src/Behavior/BehaviorRuleValidation.php b/src/Behavior/BehaviorRuleValidation.php index 5de0456..7c09ba4 100644 --- a/src/Behavior/BehaviorRuleValidation.php +++ b/src/Behavior/BehaviorRuleValidation.php @@ -20,15 +20,31 @@ public static function validateRulesAgainstScanFlag(array $rules, bool $scanResp if ($rule->ruleType !== 'return_pattern' || $rule->pattern === null || $rule->pattern === '') { continue; } - if (!$rule->requiresResponseBody() || $scanResponseBody) { - continue; - } - throw new \InvalidArgumentException( - "{$fieldName}: return_pattern rule with pattern '{$rule->pattern}' requires reading the " - . 'response body, but behavior_scan_response_body is False. This rule ' - . 'would never match: set behavior_scan_response_body=True to enable ' - . 'response-body inspection, or use a status: pattern instead.' - ); + self::validateReturnPatternAgainstScanFlag($rule->pattern, $scanResponseBody, $fieldName); + } + } + + /** + * Mirrors _validate_return_pattern_requires_scan for a single pattern: + * the decorator family's _validate_return_pattern_body_scan path, where + * the pattern is validated at decoration time before any rule exists. + */ + public static function validateReturnPatternAgainstScanFlag(string $pattern, bool $scanResponseBody, string $fieldName): void + { + if ($pattern === '') { + return; + } + // The status: convention lives on BehaviorRule::requiresResponseBody; + // reuse it through a throwaway rule so it stays in one place. + $requiresBody = (new BehaviorRule(ruleType: 'return_pattern', threshold: 1, pattern: $pattern))->requiresResponseBody(); + if (!$requiresBody || $scanResponseBody) { + return; } + throw new \InvalidArgumentException( + "{$fieldName}: return_pattern rule with pattern '{$pattern}' requires reading the " + . 'response body, but behavior_scan_response_body is False. This rule ' + . 'would never match: set behavior_scan_response_body=True to enable ' + . 'response-body inspection, or use a status: pattern instead.' + ); } } diff --git a/src/Decorators/DecoratedEndpoint.php b/src/Decorators/DecoratedEndpoint.php new file mode 100644 index 0000000..d2417fd --- /dev/null +++ b/src/Decorators/DecoratedEndpoint.php @@ -0,0 +1,56 @@ +endpoint; + } + + /** True when the wrapped endpoint is invokable through this shell. */ + public function isCallable(): bool + { + return is_callable($this->endpoint); + } + + /** + * Invokes the wrapped endpoint (the decorated function's __call__). A + * route-pattern shell (not a callable) throws the same way calling a + * non-endpoint string would. + */ + public function __invoke(mixed ...$args): mixed + { + if (!is_callable($this->endpoint)) { + throw new \LogicException( + "decorated endpoint '{$this->guardRouteId}' is a route pattern, not a callable" + ); + } + + return ($this->endpoint)(...$args); + } +} diff --git a/src/Decorators/RouteConfigRevision.php b/src/Decorators/RouteConfigRevision.php new file mode 100644 index 0000000..ee175af --- /dev/null +++ b/src/Decorators/RouteConfigRevision.php @@ -0,0 +1,23 @@ +value++; + } +} diff --git a/src/Decorators/RouteDecoration.php b/src/Decorators/RouteDecoration.php new file mode 100644 index 0000000..fcf954d --- /dev/null +++ b/src/Decorators/RouteDecoration.php @@ -0,0 +1,394 @@ + $mutations + */ + public function __construct( + private readonly SecurityDecorator $handler, + private readonly array $mutations = [] + ) { + } + + /** + * Applies the chain to an endpoint (the reference decorator application). + * The endpoint is a PHP callable (Closure, "Class::method" string, + * [object, method] array) or a route pattern string ("METHOD /path" or + * bare path, the adapter route key, which doubles as the route id). + */ + public function decorate(mixed $endpoint): DecoratedEndpoint + { + return $this->handler->applyDecoration($this->mutations, $endpoint); + } + + // ------------------------------------------------------------------ + // AccessControlMixin (guard_core/decorators/access_control.py) + // ------------------------------------------------------------------ + + /** + * @param list|null $whitelist + * @param list|null $blacklist + */ + public function requireIp(?array $whitelist = null, ?array $blacklist = null): self + { + return $this->with(static function (RouteConfig $rc) use ($whitelist, $blacklist): RouteConfig { + $values = []; + if ($whitelist !== null && $whitelist !== []) { + $values['ipWhitelist'] = $whitelist; + } + if ($blacklist !== null && $blacklist !== []) { + $values['ipBlacklist'] = $blacklist; + } + + return $values === [] ? $rc : $rc->with($values); + }); + } + + /** @param list $countries */ + public function blockCountries(array $countries): self + { + $upper = array_map(static fn (string $c): string => strtoupper($c), $countries); + + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['blockedCountries' => $upper])); + } + + /** @param list $countries */ + public function allowCountries(array $countries): self + { + $upper = array_map(static fn (string $c): string => strtoupper($c), $countries); + + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['whitelistCountries' => $upper])); + } + + /** + * @param list|null $providers provider selectors ("AWS" or + * "AWS:!region"); null blocks every registered provider + */ + public function blockClouds(?array $providers = null): self + { + $resolved = SecurityDecorator::resolveCloudProviderSelectors($providers); + + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['blockCloudProviders' => $resolved])); + } + + /** + * @param list $checks check names to bypass on this route; + * unknown names are ignored with a warning (decorator-time leniency) + */ + public function bypass(array $checks): self + { + return $this->with(static function (RouteConfig $rc) use ($checks): RouteConfig { + $valid = array_values(array_intersect($checks, SecurityConfig::VALID_BYPASS_CHECKS)); + $merged = array_values(array_unique(array_merge($rc->bypassedChecks, $valid))); + $invalid = array_diff($checks, $valid); + if ($invalid !== []) { + error_log('[guard_core.decorators] @bypass: ignored unknown checks ' . SecurityDecorator::describeList($invalid)); + } + + return $rc->with(['bypassedChecks' => $merged]); + }); + } + + // ------------------------------------------------------------------ + // RateLimitingMixin (guard_core/decorators/rate_limiting.py) + // ------------------------------------------------------------------ + + public function rateLimit(int $requests, int $window = 60): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'rateLimit' => $requests, + 'rateLimitWindow' => $window, + ])); + } + + /** + * @param array $limits country + * code ("DE") or "*" fallback mapped to its limit/window tier + */ + public function geoRateLimit(array $limits): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['geoRateLimits' => $limits])); + } + + // ------------------------------------------------------------------ + // BehavioralMixin (guard_core/decorators/behavioral.py) + // ------------------------------------------------------------------ + + public function usageMonitor(int $maxCalls, int $window = 3600, string $action = 'ban'): self + { + $rule = new BehaviorRule(ruleType: 'usage', threshold: $maxCalls, window: $window, action: $action); + + return $this->appendBehaviorRules([$rule]); + } + + public function returnMonitor(string $pattern, int $maxOccurrences, int $window = 86400, string $action = 'ban'): self + { + SecurityDecorator::assertReturnPatternScanSafe($pattern, $this->handler->config(), 'return_monitor'); + $rule = new BehaviorRule( + ruleType: 'return_pattern', + threshold: $maxOccurrences, + window: $window, + pattern: $pattern, + action: $action + ); + + return $this->appendBehaviorRules([$rule]); + } + + /** @param list $rules */ + public function behaviorAnalysis(array $rules): self + { + SecurityDecorator::assertBehaviorRulesScanSafe($rules, $this->handler->config(), 'behavior_analysis'); + + return $this->appendBehaviorRules($rules); + } + + public function suspiciousFrequency(float $maxFrequency, int $window = 300, string $action = 'ban'): self + { + $rule = new BehaviorRule( + ruleType: 'frequency', + threshold: (int) ($maxFrequency * $window), + window: $window, + action: $action + ); + + return $this->appendBehaviorRules([$rule]); + } + + // ------------------------------------------------------------------ + // AuthenticationMixin (guard_core/decorators/authentication.py) + // ------------------------------------------------------------------ + + public function requireHttps(): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['requireHttps' => true])); + } + + /** + * @param (\Closure(object, string): mixed)|null $verifier + */ + public function requireAuth(string $type = 'bearer', ?\Closure $verifier = null): self + { + return $this->with(static function (RouteConfig $rc) use ($type, $verifier): RouteConfig { + if ($rc->authorizationHeaderRequired !== null) { + throw new \InvalidArgumentException( + 'require_auth cannot be combined with require_authorization_header;' + . ' the latter is presence-only and mutually exclusive with' + . ' authenticated routes' + ); + } + + return $rc->with(['authRequired' => $type, 'authVerifier' => $verifier]); + }); + } + + /** + * @param (\Closure(object, string): mixed)|null $verifier + */ + public function apiKeyAuth(string $headerName = 'X-API-Key', ?\Closure $verifier = null): self + { + return $this->with(static function (RouteConfig $rc) use ($headerName, $verifier): RouteConfig { + if ($rc->authorizationHeaderRequired !== null) { + throw new \InvalidArgumentException( + 'api_key_auth cannot be combined with require_authorization_header;' + . ' the latter is presence-only and mutually exclusive with' + . ' authenticated routes' + ); + } + + return $rc->with([ + 'apiKeyRequired' => true, + 'requiredHeaders' => [...$rc->requiredHeaders, $headerName => 'required'], + 'apiKeyHeader' => $headerName, + 'apiKeyVerifier' => $verifier, + ]); + }); + } + + public function requireAuthorizationHeader(string $scheme = 'bearer'): self + { + return $this->with(static function (RouteConfig $rc) use ($scheme): RouteConfig { + if ($rc->authRequired !== null || $rc->apiKeyRequired) { + throw new \InvalidArgumentException( + 'require_authorization_header cannot be combined with' + . ' require_auth or api_key_auth; it is presence-only and' + . ' mutually exclusive with authenticated routes' + ); + } + + return $rc->with(['authorizationHeaderRequired' => $scheme]); + }); + } + + /** @param array $headers */ + public function requireHeaders(array $headers): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'requiredHeaders' => [...$rc->requiredHeaders, ...$headers], + ])); + } + + // ------------------------------------------------------------------ + // ContentFilteringMixin (guard_core/decorators/content_filtering.py) + // ------------------------------------------------------------------ + + /** + * @param list $patterns every pattern must pass the ReDoS + * safety validator; a rejected pattern raises before decoration + */ + public function blockUserAgents(array $patterns): self + { + SecurityDecorator::assertUserAgentPatternsSafe($patterns); + + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'blockedUserAgents' => [...$rc->blockedUserAgents, ...$patterns], + ])); + } + + /** @param list $allowedTypes */ + public function contentTypeFilter(array $allowedTypes): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'allowedContentTypes' => $allowedTypes, + ])); + } + + public function maxRequestSize(int $sizeBytes): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with(['maxRequestSize' => $sizeBytes])); + } + + /** @param list $allowedDomains */ + public function requireReferrer(array $allowedDomains): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'requireReferrer' => $allowedDomains, + ])); + } + + /** + * @param \Closure(GuardRequest): (?GuardResponse|bool) $validator + */ + public function customValidation(\Closure $validator): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'customValidators' => [...$rc->customValidators, $validator], + ])); + } + + /** + * @param list|null $headers + * @param list|null $params + * @param list|null $bodyFields + * @param list|null $categories + */ + public function detectionExclusion( + ?array $headers = null, + ?array $params = null, + ?array $bodyFields = null, + ?array $categories = null, + ?bool $scanBody = null + ): self { + return $this->with(static function (RouteConfig $rc) use ($headers, $params, $bodyFields, $categories, $scanBody): RouteConfig { + $values = []; + if ($headers !== null) { + $values['excludedDetectionHeaders'] = $headers; + } + if ($params !== null) { + $values['excludedDetectionParams'] = $params; + } + if ($bodyFields !== null) { + $values['excludedDetectionBodyFields'] = $bodyFields; + } + if ($categories !== null) { + $values['enabledDetectionCategories'] = $categories; + } + if ($scanBody !== null) { + $values['detectionScanBody'] = $scanBody; + } + + return $values === [] ? $rc : $rc->with($values); + }); + } + + // ------------------------------------------------------------------ + // AdvancedMixin (guard_core/decorators/advanced.py) + // ------------------------------------------------------------------ + + public function timeWindow(string $startTime, string $endTime, string $timezone = 'UTC'): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'timeRestrictions' => ['start' => $startTime, 'end' => $endTime, 'timezone' => $timezone], + ])); + } + + public function suspiciousDetection(bool $enabled = true): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'enableSuspiciousDetection' => $enabled, + ])); + } + + /** + * @param list $trapFields request body fields that must stay + * empty; a filled trap field fails custom validation with 403 + */ + public function honeypotDetection(array $trapFields): self + { + $validator = SecurityDecorator::honeypotValidator($trapFields); + + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'customValidators' => [...$rc->customValidators, $validator], + ])); + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + /** + * @param list $rules + */ + private function appendBehaviorRules(array $rules): self + { + return $this->with(static fn (RouteConfig $rc): RouteConfig => $rc->with([ + 'behaviorRules' => [...$rc->behaviorRules, ...$rules], + ])); + } + + /** @param \Closure(RouteConfig): RouteConfig $mutation */ + private function with(\Closure $mutation): self + { + return new self($this->handler, [...$this->mutations, $mutation]); + } +} diff --git a/src/Decorators/SecurityDecorator.php b/src/Decorators/SecurityDecorator.php new file mode 100644 index 0000000..12f7ea5 --- /dev/null +++ b/src/Decorators/SecurityDecorator.php @@ -0,0 +1,727 @@ + route id => config */ + private array $routeConfigs = []; + + /** + * Route id => endpoint identity of the endpoint the id was derived from. + * A stamped route id is only reused when THIS instance assigned it to + * THIS endpoint; otherwise a collision would select another route's + * config (the reference _route_id_owner guard). + * + * @var array + */ + private array $routeIdOwner = []; + + /** + * Endpoint identity => stamped route id (the _guard_route_id attribute + * lookup of the reference reuse branch). + * + * @var array + */ + private array $endpointRouteIds = []; + + private RouteConfigRevision $revision; + + private BehaviorTracker $behaviorTracker; + + private ?object $agentHandler = null; + + private ?CountryResolver $geoIpHandler = null; + + public function __construct(private readonly SecurityConfig $config) + { + $this->revision = new RouteConfigRevision(); + $this->behaviorTracker = new BehaviorTracker($config, null, null); + } + + public function config(): SecurityConfig + { + return $this->config; + } + + /** The shared revision cell value (route_config_revision). */ + public function routeConfigRevision(): int + { + return $this->revision->value; + } + + public function getRouteConfig(string $routeId): ?RouteConfig + { + return $this->routeConfigs[$routeId] ?? null; + } + + /** + * The whole registry, keyed by route id. This is the seam the + * path-pattern adapters consume: the decorated configs ride the same + * route map hand-built RouteConfigs use. + * + * @return array + */ + public function routeConfigs(): array + { + return $this->routeConfigs; + } + + public function behaviorTracker(): BehaviorTracker + { + return $this->behaviorTracker; + } + + /** + * initialize_behavior_tracking: wires Redis into the behavior tracker + * (a null handler keeps the in-memory tracker, like the reference's + * `if redis_handler:` guard). + */ + public function initializeBehaviorTracking(?RedisHandler $redisHandler = null): void + { + if ($redisHandler === null) { + return; + } + $this->behaviorTracker = new BehaviorTracker($this->config, $redisHandler, null); + } + + /** The set_decorator_handler companion: wires the agent + geo handlers. */ + public function initializeAgent(object $agentHandler, ?CountryResolver $geoIpHandler = null): void + { + $this->agentHandler = $agentHandler; + $this->geoIpHandler = $geoIpHandler; + } + + // ------------------------------------------------------------------ + // AccessControlMixin (guard_core/decorators/access_control.py) + // ------------------------------------------------------------------ + + /** + * @param list|null $whitelist + * @param list|null $blacklist + */ + public function requireIp(?array $whitelist = null, ?array $blacklist = null): RouteDecoration + { + return (new RouteDecoration($this))->requireIp($whitelist, $blacklist); + } + + /** @param list $countries */ + public function blockCountries(array $countries): RouteDecoration + { + return (new RouteDecoration($this))->blockCountries($countries); + } + + /** @param list $countries */ + public function allowCountries(array $countries): RouteDecoration + { + return (new RouteDecoration($this))->allowCountries($countries); + } + + /** + * @param list|null $providers provider selectors ("AWS" or + * "AWS:!region"); null blocks every registered provider + */ + public function blockClouds(?array $providers = null): RouteDecoration + { + return (new RouteDecoration($this))->blockClouds($providers); + } + + /** @param list $checks */ + public function bypass(array $checks): RouteDecoration + { + return (new RouteDecoration($this))->bypass($checks); + } + + // ------------------------------------------------------------------ + // RateLimitingMixin (guard_core/decorators/rate_limiting.py) + // ------------------------------------------------------------------ + + public function rateLimit(int $requests, int $window = 60): RouteDecoration + { + return (new RouteDecoration($this))->rateLimit($requests, $window); + } + + /** + * @param array $limits + */ + public function geoRateLimit(array $limits): RouteDecoration + { + return (new RouteDecoration($this))->geoRateLimit($limits); + } + + // ------------------------------------------------------------------ + // BehavioralMixin (guard_core/decorators/behavioral.py) + // ------------------------------------------------------------------ + + public function usageMonitor(int $maxCalls, int $window = 3600, string $action = 'ban'): RouteDecoration + { + return (new RouteDecoration($this))->usageMonitor($maxCalls, $window, $action); + } + + public function returnMonitor(string $pattern, int $maxOccurrences, int $window = 86400, string $action = 'ban'): RouteDecoration + { + return (new RouteDecoration($this))->returnMonitor($pattern, $maxOccurrences, $window, $action); + } + + /** @param list $rules */ + public function behaviorAnalysis(array $rules): RouteDecoration + { + return (new RouteDecoration($this))->behaviorAnalysis($rules); + } + + public function suspiciousFrequency(float $maxFrequency, int $window = 300, string $action = 'ban'): RouteDecoration + { + return (new RouteDecoration($this))->suspiciousFrequency($maxFrequency, $window, $action); + } + + // ------------------------------------------------------------------ + // AuthenticationMixin (guard_core/decorators/authentication.py) + // ------------------------------------------------------------------ + + public function requireHttps(): RouteDecoration + { + return (new RouteDecoration($this))->requireHttps(); + } + + /** + * @param (\Closure(object, string): mixed)|null $verifier + */ + public function requireAuth(string $type = 'bearer', ?\Closure $verifier = null): RouteDecoration + { + return (new RouteDecoration($this))->requireAuth($type, $verifier); + } + + /** + * @param (\Closure(object, string): mixed)|null $verifier + */ + public function apiKeyAuth(string $headerName = 'X-API-Key', ?\Closure $verifier = null): RouteDecoration + { + return (new RouteDecoration($this))->apiKeyAuth($headerName, $verifier); + } + + public function requireAuthorizationHeader(string $scheme = 'bearer'): RouteDecoration + { + return (new RouteDecoration($this))->requireAuthorizationHeader($scheme); + } + + /** @param array $headers */ + public function requireHeaders(array $headers): RouteDecoration + { + return (new RouteDecoration($this))->requireHeaders($headers); + } + + // ------------------------------------------------------------------ + // ContentFilteringMixin (guard_core/decorators/content_filtering.py) + // ------------------------------------------------------------------ + + /** @param list $patterns */ + public function blockUserAgents(array $patterns): RouteDecoration + { + return (new RouteDecoration($this))->blockUserAgents($patterns); + } + + /** @param list $allowedTypes */ + public function contentTypeFilter(array $allowedTypes): RouteDecoration + { + return (new RouteDecoration($this))->contentTypeFilter($allowedTypes); + } + + public function maxRequestSize(int $sizeBytes): RouteDecoration + { + return (new RouteDecoration($this))->maxRequestSize($sizeBytes); + } + + /** @param list $allowedDomains */ + public function requireReferrer(array $allowedDomains): RouteDecoration + { + return (new RouteDecoration($this))->requireReferrer($allowedDomains); + } + + /** + * @param \Closure(GuardRequest): (?GuardResponse|bool) $validator + */ + public function customValidation(\Closure $validator): RouteDecoration + { + return (new RouteDecoration($this))->customValidation($validator); + } + + /** + * @param list|null $headers + * @param list|null $params + * @param list|null $bodyFields + * @param list|null $categories + */ + public function detectionExclusion( + ?array $headers = null, + ?array $params = null, + ?array $bodyFields = null, + ?array $categories = null, + ?bool $scanBody = null + ): RouteDecoration { + return (new RouteDecoration($this))->detectionExclusion($headers, $params, $bodyFields, $categories, $scanBody); + } + + // ------------------------------------------------------------------ + // AdvancedMixin (guard_core/decorators/advanced.py) + // ------------------------------------------------------------------ + + public function timeWindow(string $startTime, string $endTime, string $timezone = 'UTC'): RouteDecoration + { + return (new RouteDecoration($this))->timeWindow($startTime, $endTime, $timezone); + } + + public function suspiciousDetection(bool $enabled = true): RouteDecoration + { + return (new RouteDecoration($this))->suspiciousDetection($enabled); + } + + /** @param list $trapFields */ + public function honeypotDetection(array $trapFields): RouteDecoration + { + return (new RouteDecoration($this))->honeypotDetection($trapFields); + } + + // ------------------------------------------------------------------ + // application (base.py _ensure_route_config / _apply_route_config) + // ------------------------------------------------------------------ + + /** + * Applies one decoration chain to an endpoint: resolves the route id + * (_get_route_id), ensures the route's RouteConfig (created seeded with + * enable_penetration_detection), applies every mutation in chain order, + * and stamps the id on the returned endpoint shell. + * + * @param list<\Closure(RouteConfig): RouteConfig> $mutations + */ + public function applyDecoration(array $mutations, mixed $endpoint): DecoratedEndpoint + { + $identity = self::endpointIdentity($endpoint); + $routeId = $this->resolveRouteId($endpoint, $identity); + if (!isset($this->routeConfigs[$routeId])) { + $this->routeConfigs[$routeId] = new RouteConfig( + enableSuspiciousDetection: $this->config->enablePenetrationDetection + ); + $this->revision->bump(); + } + $this->routeIdOwner[$routeId] ??= $identity; + $this->endpointRouteIds[$identity] = $routeId; + $config = $this->routeConfigs[$routeId]; + foreach ($mutations as $mutation) { + $config = $mutation($config); + $this->revision->bump(); + } + $this->routeConfigs[$routeId] = $config; + + return new DecoratedEndpoint($endpoint, $routeId); + } + + /** + * The reference _get_route_id: the stamped id is reused only when this + * instance assigned it to this endpoint (stacked decorations share one + * config); otherwise the base id (the module.qualname equivalent) is + * suffixed "#2", "#3", ... until free. + * + * @param list<\Closure(RouteConfig): RouteConfig> $mutations + */ + private function resolveRouteId(mixed $endpoint, string $identity): string + { + $stamped = $this->endpointRouteIds[$identity] ?? null; + if ( + $stamped !== null + && isset($this->routeConfigs[$stamped]) + && ($this->routeIdOwner[$stamped] ?? null) === $identity + ) { + return $stamped; + } + $base = self::baseRouteId($endpoint); + $routeId = $base; + $suffix = 1; + while (isset($this->routeConfigs[$routeId])) { + $suffix++; + $routeId = $base . '#' . $suffix; + } + + return $routeId; + } + + /** The endpoint identity key (the id(func) owner-chain equivalent). */ + private static function endpointIdentity(mixed $endpoint): string + { + if (is_string($endpoint)) { + // Strings are route patterns: the pattern itself is the identity. + return 'pattern:' . $endpoint; + } + if ($endpoint instanceof \Closure) { + return 'closure#' . spl_object_id($endpoint); + } + if (is_array($endpoint) && array_is_list($endpoint) && count($endpoint) === 2) { + [$class, $method] = $endpoint; + $classPart = is_object($class) ? get_class($class) . '#' . spl_object_id($class) : (string) $class; + + return $classPart . '::' . $method; + } + if (is_object($endpoint)) { + return get_class($endpoint) . '#' . spl_object_id($endpoint); + } + + return 'endpoint:' . serialize($endpoint); + } + + /** The base route id (the module.qualname equivalent). */ + private static function baseRouteId(mixed $endpoint): string + { + if (is_string($endpoint)) { + return $endpoint; + } + if ($endpoint instanceof \Closure) { + return 'closure#' . spl_object_id($endpoint); + } + if (is_array($endpoint) && array_is_list($endpoint) && count($endpoint) === 2) { + [$class, $method] = $endpoint; + + return (is_object($class) ? get_class($class) : (string) $class) . '::' . $method; + } + if (is_object($endpoint)) { + return get_class($endpoint); + } + + return 'endpoint'; + } + + // ------------------------------------------------------------------ + // base infra events (guard_core/decorators/base.py) + // ------------------------------------------------------------------ + + /** + * send_decorator_event: builds the middleware event bus over the wired + * agent + geo handlers and emits one event with the decorator_type + * metadata. A no handler wiring means no event (the reference's + * `if not self.agent_handler: return` gate). + * + * @param array $metadata + */ + public function sendDecoratorEvent( + string $eventType, + object $request, + string $actionTaken, + string $reason, + string $decoratorType, + array $metadata = [] + ): void { + if ($this->agentHandler === null) { + return; + } + $geoIpHandler = $this->geoIpHandler; + $eventBus = new EventBus( + $this->agentHandler, + $this->config, + countryResolver: static fn (string $ip): ?string => $geoIpHandler?->getCountry($ip) + ); + $eventBus->sendMiddlewareEvent( + $eventType, + $request, + $actionTaken, + $reason, + ['decorator_type' => $decoratorType, ...$metadata] + ); + } + + /** + * @param array $metadata + */ + public function sendAccessDeniedEvent(object $request, string $reason, string $decoratorType, array $metadata = []): void + { + $this->sendDecoratorEvent( + eventType: EventTypes::EVENT_ACCESS_DENIED, + request: $request, + actionTaken: 'blocked', + reason: $reason, + decoratorType: $decoratorType, + metadata: $metadata + ); + } + + /** + * @param array $metadata + */ + public function sendAuthenticationFailedEvent(object $request, string $reason, string $authType, array $metadata = []): void + { + $this->sendDecoratorEvent( + eventType: EventTypes::EVENT_AUTHENTICATION_FAILED, + request: $request, + actionTaken: 'blocked', + reason: $reason, + decoratorType: 'authentication', + metadata: ['auth_type' => $authType, ...$metadata] + ); + } + + /** + * @param array $metadata + */ + public function sendRateLimitEvent(object $request, int $limit, int $window, array $metadata = []): void + { + $this->sendDecoratorEvent( + eventType: EventTypes::EVENT_RATE_LIMITED, + request: $request, + actionTaken: 'blocked', + reason: "Rate limit exceeded: {$limit} requests per {$window}s", + decoratorType: 'rate_limiting', + metadata: ['limit' => $limit, 'window' => $window, ...$metadata] + ); + } + + /** + * @param array $metadata + */ + public function sendDecoratorViolationEvent(object $request, string $violationType, string $reason, array $metadata = []): void + { + $this->sendDecoratorEvent( + eventType: EventTypes::EVENT_DECORATOR_VIOLATION, + request: $request, + actionTaken: 'blocked', + reason: $reason, + decoratorType: $violationType, + metadata: $metadata + ); + } + + // ------------------------------------------------------------------ + // the module-level get_route_decorator_config helper + // ------------------------------------------------------------------ + + /** + * get_route_decorator_config: the request's stamped route id resolved + * through the decorator handler's registry (null without an id or for + * an unknown one). + */ + public static function getRouteDecoratorConfig(GuardRequest $request, SecurityDecorator $decoratorHandler): ?RouteConfig + { + $routeId = $request->state()->guardRouteId; + if ($routeId === null || $routeId === '') { + return null; + } + + return $decoratorHandler->getRouteConfig($routeId); + } + + // ------------------------------------------------------------------ + // shared validation + leniency helpers (mixin-level reference logic) + // ------------------------------------------------------------------ + + /** + * The @block_clouds provider-selector resolution: null means every + * registered provider; a selector is kept when its provider prefix + * (the part before the ":!region" marker) is registered, and unknown + * ones are reported with a decorator warning. + * + * @param list|null $providers + * @return list + */ + public static function resolveCloudProviderSelectors(?array $providers): array + { + if ($providers === null) { + return CloudProviderRegistry::PROVIDERS; + } + $valid = []; + foreach ($providers as $selector) { + $marker = strpos($selector, ':!'); + $provider = $marker === false ? $selector : substr($selector, 0, $marker); + if (in_array($provider, CloudProviderRegistry::PROVIDERS, true) && !in_array($selector, $valid, true)) { + $valid[] = $selector; + } + } + $invalid = array_diff($providers, $valid); + if ($invalid !== []) { + error_log('[guard_core.decorators] @block_clouds: ignored unknown cloud providers ' . self::describeList($invalid)); + } + + return $valid; + } + + /** + * The block_user_agents ReDoS gate: every pattern must pass the safety + * validator or decoration is refused before any config is touched. + * + * @param list $patterns + */ + public static function assertUserAgentPatternsSafe(array $patterns): void + { + foreach ($patterns as $pattern) { + [$safe, $reason] = Prefilters::validatePatternSafety($pattern); + if (!$safe) { + throw new \InvalidArgumentException( + 'block_user_agents pattern rejected by ReDoS validator: ' + . "'" . SusPatterns::sanitizeForReporting($pattern) . "' ({$reason})" + ); + } + } + } + + /** + * The return_monitor factory gate (_validate_return_pattern_body_scan): + * a body-reading pattern is refused when the response body scan is off. + */ + public static function assertReturnPatternScanSafe(string $pattern, SecurityConfig $config, string $fieldName): void + { + BehaviorRuleValidation::validateReturnPatternAgainstScanFlag($pattern, $config->behaviorScanResponseBody, $fieldName); + } + + /** + * The behavior_analysis factory gate: every return_pattern rule with a + * body-reading pattern is refused when the response body scan is off. + * + * @param list $rules + */ + public static function assertBehaviorRulesScanSafe(array $rules, SecurityConfig $config, string $fieldName): void + { + BehaviorRuleValidation::validateRulesAgainstScanFlag($rules, $config->behaviorScanResponseBody, $fieldName); + } + + /** + * The @honeypot_detection validator: POST/PUT/PATCH bodies with a + * filled trap field fail custom validation with a 403 "Forbidden" + * response. Form bodies parse like parse_qs (first value per key), + * JSON bodies like json.loads (unparsable bodies skip validation). + * Note: the reference reads the body under the detection scan cap; + * PHP requests arrive pre-buffered (GuardRequest::body()), so the + * cap is inherent in the framework's read. + * + * @param list $trapFields + * @return \Closure(GuardRequest): ?GuardResponse + */ + public static function honeypotValidator(array $trapFields): \Closure + { + return static function (GuardRequest $request) use ($trapFields): ?GuardResponse { + if (!in_array($request->method(), ['POST', 'PUT', 'PATCH'], true)) { + return null; + } + $body = $request->body(); + $contentType = $request->headers()->get('content-type') ?? ''; + if (str_contains($contentType, 'application/x-www-form-urlencoded')) { + return self::honeypotFormViolation($body, $trapFields); + } + if (str_contains($contentType, 'application/json')) { + return self::honeypotJsonViolation($body, $trapFields); + } + + return null; + }; + } + + /** + * @param list $trapFields + */ + private static function honeypotFormViolation(string $body, array $trapFields): ?GuardResponse + { + $flat = []; + foreach (explode('&', $body) as $pair) { + if ($pair === '') { + continue; + } + $eq = strpos($pair, '='); + $key = $eq === false ? $pair : substr($pair, 0, $eq); + $value = $eq === false ? '' : substr($pair, $eq + 1); + $key = urldecode($key); + if (!array_key_exists($key, $flat)) { + $flat[$key] = urldecode($value); + } + } + + return self::honeypotTrapHit($flat, $trapFields); + } + + /** + * @param list $trapFields + */ + private static function honeypotJsonViolation(string $body, array $trapFields): ?GuardResponse + { + $json = json_decode($body, true, 512); + if (!is_array($json)) { + // Unparsable JSON (json.JSONDecodeError / RecursionError family) + // skips validation like the reference's guarded parse. + return null; + } + + return self::honeypotTrapHit($json, $trapFields); + } + + /** + * @param array $data + * @param list $trapFields + */ + private static function honeypotTrapHit(array $data, array $trapFields): ?GuardResponse + { + foreach ($trapFields as $field) { + if (is_string($field) && array_key_exists($field, $data) && self::referenceTruthy($data[$field])) { + return new GuardResponse(403, body: 'Forbidden'); + } + } + + return null; + } + + /** + * Python truthiness for the trap-value check: "", 0, 0.0, false, null + * and [] are falsy; everything else (including "0") is truthy. + */ + private static function referenceTruthy(mixed $value): bool + { + return !in_array($value, [null, false, 0, 0.0, '', []], true); + } + + /** @param list $items */ + public static function describeList(array $items): string + { + $sorted = array_values(array_unique(array_map('strval', $items))); + sort($sorted, SORT_STRING); + + return '[' . implode(', ', $sorted) . ']'; + } +} diff --git a/src/Engine/GuardEngine.php b/src/Engine/GuardEngine.php index 3e685b3..a7304e9 100644 --- a/src/Engine/GuardEngine.php +++ b/src/Engine/GuardEngine.php @@ -14,6 +14,7 @@ use RenzoFranceschini\GuardCore\Detection\PerformanceMonitor; use RenzoFranceschini\GuardCore\Detection\SusPatterns; use RenzoFranceschini\GuardCore\Detection\Redos\ValidationCache; +use RenzoFranceschini\GuardCore\Decorators\SecurityDecorator; use RenzoFranceschini\GuardCore\Events\EventBus; use RenzoFranceschini\GuardCore\Events\EventTypes; use RenzoFranceschini\GuardCore\Logging\LogRedactor; @@ -70,6 +71,8 @@ final class GuardEngine private readonly ?CorsPolicy $corsPolicy; + private ?SecurityDecorator $decoratorHandler = null; + private readonly SusPatterns $susPatterns; private readonly SuspiciousCountStore $suspiciousCountStore; @@ -157,6 +160,7 @@ public function __construct( $config, array_keys($config->mutedCheckLogs), rebuildChecks: fn (): array => $this->checkFactory->buildChecks($this->config), + routeConfigRevision: fn (): ?int => $this->decoratorHandler?->routeConfigRevision(), log: $log, configProvider: fn (): SecurityConfig => $this->config, eventBus: $this->eventBus @@ -221,6 +225,26 @@ public function setAgentHandler(object $agentHandler): void $this->eventBus->setAgentHandler($agentHandler); } + /** + * set_decorator_handler (fastapi-guard middleware.set_decorator_handler): + * attaches the SecurityDecorator family whose decorated endpoints this + * engine serves. Requests stamped with a route id + * (RequestState::$guardRouteId, set by the adapter after its router + * matches a DecoratedEndpoint) resolve their RouteConfig through the + * handler's registry (the reference get_route_decorator_config flow), + * and every decorator mutation rebuilds the pipeline through the + * revision staleness seam. + */ + public function setDecoratorHandler(SecurityDecorator $decoratorHandler): void + { + $this->decoratorHandler = $decoratorHandler; + } + + public function decoratorHandler(): ?SecurityDecorator + { + return $this->decoratorHandler; + } + /** * The dynamic-rule application seam (DynamicRuleManager's * applyConfig): installs a fully validated config and rebuilds the @@ -506,6 +530,13 @@ public function execute(GuardRequest $request): ?GuardResponse } } + // The reference adapter resolves a decorated endpoint's config by + // its stamped route id (get_route_decorator_config) when the + // adapter has not attached a route config directly. + if ($state->routeConfig === null && $state->guardRouteId !== null && $this->decoratorHandler !== null) { + $state->routeConfig = $this->decoratorHandler->getRouteConfig($state->guardRouteId); + } + $response = $this->pipeline->execute($request); if ($response === null) {