From 222afa143df75e225113c80a0afaf4afee76dca3 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 8 Oct 2026 10:01:48 +0200 Subject: [PATCH] feat(decorators): the SecurityDecorator family - the per-endpoint decorator surface with route identity The decorator mixin family port (guard_core/decorators/__init__.py: BaseSecurityDecorator + access_control, rate_limiting, behavioral, authentication, content_filtering, advanced): - src/Decorators/SecurityDecorator.php: the 25 config methods (requireIp, blockCountries, allowCountries, blockClouds, bypass, rateLimit, geoRateLimit, usageMonitor, returnMonitor, behaviorAnalysis, suspiciousFrequency, requireHttps, requireAuth, apiKeyAuth, requireAuthorizationHeader, requireHeaders, blockUserAgents, contentTypeFilter, maxRequestSize, requireReferrer, customValidation, detectionExclusion, timeWindow, suspiciousDetection, honeypotDetection) plus the base infra (config, routeConfigRevision, getRouteConfig, routeConfigs, initializeBehaviorTracking, initializeAgent and the five send_*_event emitters) and the getRouteDecoratorConfig helper. - src/Decorators/RouteDecoration.php: the immutable decoration chain, the port of the decorator closures the mixin methods return; decorate() is the application step against a callable endpoint or a route pattern. - src/Decorators/DecoratedEndpoint.php: the _guard_route_id-stamped endpoint shell (invokable call-through, pattern shells throw). - src/Decorators/RouteConfigRevision.php: the shared revision cell. - Route identity (the _get_route_id port): the stamped id is reused only when this instance assigned it to this endpoint, otherwise the base id suffixes #2..#n; new routes seed enable_suspicious_detection from the config like _ensure_route_config; every applied decoration bumps the shared cell. - GuardEngine::setDecoratorHandler (the set_decorator_handler port): requests stamped with a route id resolve their RouteConfig through the handler's registry, and decorator mutations rebuild the pipeline via the revision staleness seam. - BehaviorRuleValidation::validateReturnPatternAgainstScanFlag: the single-pattern scan gate (the _validate_return_pattern_body_scan path). - bin/test_decorators.php (210 assertions) mirrors the reference decorator tests and joins the CI test steps, the coverage collect list and the Makefile runners; the ip_ban initialization catch waiver moves with its shifted lines in coverage-unreachable.php. --- .github/coverage-unreachable.php | 4 +- .github/workflows/ci.yml | 5 +- Makefile | 2 +- bin/test_decorators.php | 665 ++++++++++++++++++++++ src/Behavior/BehaviorRuleValidation.php | 34 +- src/Decorators/DecoratedEndpoint.php | 56 ++ src/Decorators/RouteConfigRevision.php | 23 + src/Decorators/RouteDecoration.php | 394 +++++++++++++ src/Decorators/SecurityDecorator.php | 727 ++++++++++++++++++++++++ src/Engine/GuardEngine.php | 31 + 10 files changed, 1928 insertions(+), 13 deletions(-) create mode 100644 bin/test_decorators.php create mode 100644 src/Decorators/DecoratedEndpoint.php create mode 100644 src/Decorators/RouteConfigRevision.php create mode 100644 src/Decorators/RouteDecoration.php create mode 100644 src/Decorators/SecurityDecorator.php 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) {