diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index d45ec9d..7098652 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -271,7 +271,7 @@ 219, 240, 241, 242, 243, ], - // 373-377: the ip_ban-side initialization catch. Every collaborator + // 457-461: 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' => [ - 373, 374, 375, 376, 377, + 457, 458, 459, 460, 461, ], ]; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02240f6..31cdec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,11 @@ jobs: REDIS_HOST: 127.0.0.1 run: php bin/test_config_fields.php + - name: test_events_parity + env: + REDIS_HOST: 127.0.0.1 + run: php bin/test_events_parity.php + conformance-pipeline: name: Conformance pipeline (php ${{ matrix.php }}) runs-on: ubuntu-latest @@ -235,7 +240,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; 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; do php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php" done diff --git a/bin/test_enrichment.php b/bin/test_enrichment.php index c4d3020..c78e1eb 100644 --- a/bin/test_enrichment.php +++ b/bin/test_enrichment.php @@ -324,8 +324,17 @@ public function healthCheck(): bool $engine->setAgentHandler($composite); $blocked = new SimpleGuardRequest(method: 'GET', urlPath: '/data', headers: [], clientHost: '203.0.113.99'); $engine->execute($blocked); -$t->same(1, count($sink->events), 'blocked request emitted exactly one event'); -$emitted = $sink->events[0] ?? null; +// The block event plus the security_headers_applied tick the blocked +// response carries (the reference emits both: the block through the bus, +// the headers through the response factory's headers manager). +$t->same(2, count($sink->events), 'blocked request emitted the block event and the headers tick'); +$emitted = null; +foreach ($sink->events as $candidate) { + if ($candidate->eventType !== EventTypes::EVENT_SECURITY_HEADERS_APPLIED) { + $emitted = $candidate; + break; + } +} $t->truthy($emitted !== null && isset($emitted->metadata[EventTypes::ENRICHMENT_KEY_THREAT_SCORE]), 'emitted event carries a threat score'); $t->truthy($emitted !== null && ($emitted->metadata[EventTypes::ENRICHMENT_KEY_THREAT_SCORE] ?? 0) === ThreatScorer::scoreFor($emitted->eventType), 'emitted threat score matches the event type map'); $t->truthy($emitted !== null && ($emitted->metadata[EventTypes::ENRICHMENT_KEY_SERVICE_NAME] ?? '') === 'edge-svc', 'emitted event carries the service name'); diff --git a/bin/test_event_bus.php b/bin/test_event_bus.php index 92489c4..615e1b0 100644 --- a/bin/test_event_bus.php +++ b/bin/test_event_bus.php @@ -670,7 +670,10 @@ public function __construct( $emergencyEngine->eventBus()->setAgentHandler($emergencyAgent); $allowedEmergencyRequest = new SimpleGuardRequest(method: 'GET', urlPath: '/status', headers: [], clientHost: '10.0.0.1'); $t->same(null, $emergencyEngine->execute($allowedEmergencyRequest), 'a whitelisted ip passes emergency mode'); -$t->same([], $emergencyAgent->received, 'the whitelisted pass emitted nothing'); +// The pass now carries exactly the security_headers_applied tick (the +// reference headers manager emits for the response the adapter sends). +$emergencyTypes = array_map(static fn (object $e): string => $e->eventType, $emergencyAgent->received); +$t->same([EventTypes::EVENT_SECURITY_HEADERS_APPLIED], $emergencyTypes, 'the whitelisted pass emits only the headers tick'); $emergencyRequest = new SimpleGuardRequest(method: 'GET', urlPath: '/status', headers: [], clientHost: '203.0.113.22'); $t->same(503, $emergencyEngine->execute($emergencyRequest)?->statusCode(), 'emergency mode blocks non-whitelisted ips'); $emergencyTypes = array_map(static fn (object $e): string => $e->eventType, $emergencyAgent->received); diff --git a/bin/test_events_parity.php b/bin/test_events_parity.php new file mode 100644 index 0000000..b339256 --- /dev/null +++ b/bin/test_events_parity.php @@ -0,0 +1,324 @@ +passed++; + echo "ok - {$label}\n"; + } else { + $this->failed++; + echo "FAIL - {$label}\n"; + echo ' expected: ' . var_export($expected, true) . "\n"; + echo ' actual: ' . var_export($actual, true) . "\n"; + } + } + + public function truthy(mixed $actual, string $label): void + { + $this->same(true, (bool) $actual, $label); + } + + public function section(string $name): void + { + echo "\n=== {$name} ===\n"; + } + + public function finish(string $label): int + { + echo "\n{$label}: {$this->passed} passed, {$this->failed} failed\n"; + + return $this->failed === 0 ? 0 : 1; + } +} + +final class EvAgent +{ + /** @var list */ + public array $received = []; + + public function sendEvent(object $event): void + { + $this->received[] = $event; + } + + /** @return list */ + public function types(): array + { + return array_map(static fn (object $e): string => $e->eventType, $this->received); + } +} + +final class StaticCountry implements CountryResolver +{ + public function __construct(private readonly ?string $country) + { + } + + public function getCountry(string $ip): ?string + { + return $this->country; + } +} + +final class ExplodingResolver implements CountryResolver +{ + public function getCountry(string $ip): ?string + { + throw new RuntimeException('mmdb exploded'); + } +} + +final class AwsStubClient implements HttpClient +{ + public function get(string $url, array $options = []): HttpResponse + { + return new HttpResponse(200, (string) json_encode([ + 'prefixes' => [ + ['ip_prefix' => '203.0.113.0/24', 'region' => 'us-east-1', 'service' => 'AMAZON'], + ], + ])); + } +} + +$t = new EvT(); + +// --------------------------------------------------------------------- +// 1. The bus cloud-detection method +// --------------------------------------------------------------------- +$t->section('send_cloud_detection_events'); +$agent = new EvAgent(); +$config = new SecurityConfig(); +$bus = new EventBus($agent, $config); +$cloudManager = new CloudManager(new AwsStubClient(), new InMemoryCloudIpStore()); +$cloudManager->refreshAsync(['AWS'], 3600); +$request = new SimpleGuardRequest(urlPath: '/api', clientHost: '203.0.113.9'); + +$bus->sendCloudDetectionEvents($request, '203.0.113.9', ['AWS'], null, $cloudManager, false); +$cloudEvents = array_values(array_filter($agent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_CLOUD_BLOCKED)); +$t->same(1, count($cloudEvents), 'a cloud ip emits cloud_blocked'); +$cloudEvent = $cloudEvents[0]; +$t->same('cloud', $cloudEvent->handlerName, 'the cloud handler name'); +$t->same('request_blocked', $cloudEvent->actionTaken, 'the active-mode action'); +$t->same('IP belongs to blocked cloud provider: AWS', $cloudEvent->reason, 'the reference reason'); +$t->same('AWS', $cloudEvent->metadata['cloud_provider'] ?? null, 'the provider metadata'); +$t->truthy(str_contains((string) $cloudEvent->metadata['network'], '203.0.113.0/24'), 'the network metadata'); +$t->same(null, $cloudEvent->country, 'no country on the cloud event'); + +$bus->sendCloudDetectionEvents($request, '203.0.113.9', ['AWS'], null, $cloudManager, true); +$cloudEvents = array_values(array_filter($agent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_CLOUD_BLOCKED)); +$t->same(2, count($cloudEvents), 'the second lookup emits again'); +$t->same('logged_only', $cloudEvents[1]->actionTaken, 'the passive-mode action'); + +$agent->received = []; +$bus->sendCloudDetectionEvents($request, '198.51.100.1', ['AWS'], null, $cloudManager, false); +$t->same([], $agent->received, 'a non-cloud ip emits nothing'); + +$agent->received = []; +$routeBlocks = new RouteConfig(blockCloudProviders: ['AWS']); +$bus->sendCloudDetectionEvents($request, '203.0.113.9', ['AWS'], $routeBlocks, $cloudManager, false); +$types = $agent->types(); +$t->truthy(in_array(EventTypes::EVENT_DECORATOR_VIOLATION, $types, true), 'a blocking route emits decorator_violation'); +$decorator = $agent->received[array_search(EventTypes::EVENT_DECORATOR_VIOLATION, $types, true)]; +$t->same('access_control', $decorator->metadata['decorator_type'] ?? null, 'the access_control decorator type'); +$t->same('cloud_provider', $decorator->metadata['violation_type'] ?? null, 'the cloud_provider violation type'); +$t->same(['AWS'], $decorator->metadata['blocked_providers'] ?? null, 'the blocked providers metadata'); + +$agent->received = []; +$bus->sendCloudDetectionEvents($request, '203.0.113.9', ['AWS'], new RouteConfig(), $cloudManager, false); +$t->truthy(!in_array(EventTypes::EVENT_DECORATOR_VIOLATION, $agent->types(), true), 'a route without cloud rules emits no decorator event'); + +// --------------------------------------------------------------------- +// 2. The cloud_provider check through the engine +// --------------------------------------------------------------------- +$t->section('cloud_provider check emissions'); +$globalAgent = new EvAgent(); +$globalEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, blockCloudProviders: ['AWS']), + cloudManager: $cloudManager +); +$globalEngine->setAgentHandler($globalAgent); +$globalEngine->initialize(); +$blocked = $globalEngine->execute(new SimpleGuardRequest(urlPath: '/download', clientHost: '203.0.113.9')); +$t->same(403, $blocked?->statusCode(), 'the global cloud block denies'); +$t->truthy(in_array(EventTypes::EVENT_CLOUD_BLOCKED, $globalAgent->types(), true), 'the pipeline emits cloud_blocked'); +$t->truthy(!in_array(EventTypes::EVENT_DECORATOR_VIOLATION, $globalAgent->types(), true), 'a global block emits no decorator violation'); + +$routeAgent = new EvAgent(); +$routeEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, blockCloudProviders: ['AWS']), + cloudManager: $cloudManager +); +$routeEngine->setAgentHandler($routeAgent); +$routeEngine->initialize(); +$routeRequest = new SimpleGuardRequest(urlPath: '/download', clientHost: '203.0.113.9'); +$routeRequest->state()->routeConfig = new RouteConfig(blockCloudProviders: ['AWS']); +$routeBlocked = $routeEngine->execute($routeRequest); +$t->same(403, $routeBlocked?->statusCode(), 'the route cloud block denies'); +$t->truthy(in_array(EventTypes::EVENT_CLOUD_BLOCKED, $routeAgent->types(), true), 'the route block emits cloud_blocked'); +$blockClouds = array_values(array_filter($routeAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_DECORATOR_VIOLATION)); +$t->same(1, count($blockClouds), 'exactly one decorator violation'); +$t->same('block_clouds', $blockClouds[0]->metadata['decorator_type'] ?? null, 'the block_clouds decorator type'); +$t->same('cloud_provider', $blockClouds[0]->metadata['violation_type'] ?? null, 'the violation type'); + +// --------------------------------------------------------------------- +// 3. Country events through the ip_security check +// --------------------------------------------------------------------- +$t->section('country events'); +$countryAgent = new EvAgent(); +$blacklistEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, blockedCountries: ['RU'], geoIpHandler: new StaticCountry('RU')) +); +$blacklistEngine->setAgentHandler($countryAgent); +$blacklistEngine->initialize(); +$denied = $blacklistEngine->execute(new SimpleGuardRequest(urlPath: '/', clientHost: '198.51.100.7')); +$t->same(403, $denied?->statusCode(), 'a blocked-country ip denies'); +$countryEvents = array_values(array_filter($countryAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_COUNTRY_BLOCKED)); +$t->same(1, count($countryEvents), 'the blacklist deny emits country_blocked'); +$t->same('ipinfo', $countryEvents[0]->handlerName, 'the ipinfo handler name'); +$t->same('Country RU is blocked', $countryEvents[0]->reason, 'the blacklist reason'); +$t->same('RU', $countryEvents[0]->metadata['country'] ?? null, 'the country metadata'); +$t->same('country_blacklist', $countryEvents[0]->metadata['rule_type'] ?? null, 'the blacklist rule type'); + +$whitelistAgent = new EvAgent(); +$whitelistEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, whitelistCountries: ['US'], geoIpHandler: new StaticCountry('CA')) +); +$whitelistEngine->setAgentHandler($whitelistAgent); +$whitelistEngine->initialize(); +$denied = $whitelistEngine->execute(new SimpleGuardRequest(urlPath: '/', clientHost: '198.51.100.7')); +$t->same(403, $denied?->statusCode(), 'an allowlist miss denies'); +$countryEvents = array_values(array_filter($whitelistAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_COUNTRY_BLOCKED)); +$t->same(1, count($countryEvents), 'the allowlist deny emits country_blocked'); +$t->same('Country CA not in allowed list', $countryEvents[0]->reason, 'the allowlist reason'); +$t->same('country_whitelist', $countryEvents[0]->metadata['rule_type'] ?? null, 'the whitelist rule type'); + +$passAgent = new EvAgent(); +$passEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, blockedCountries: ['RU'], geoIpHandler: new StaticCountry('CA')) +); +$passEngine->setAgentHandler($passAgent); +$passEngine->initialize(); +$t->same(null, $passEngine->execute(new SimpleGuardRequest(urlPath: '/', clientHost: '198.51.100.7'))?->statusCode(), 'a non-blocked country passes'); +$t->truthy(!in_array(EventTypes::EVENT_COUNTRY_BLOCKED, $passAgent->types(), true), 'a passing country emits no country_blocked'); + +$geoFailAgent = new EvAgent(); +$geoFailEngine = new GuardEngine( + new SecurityConfig(enableRedis: false, whitelistCountries: ['US'], geoIpHandler: new ExplodingResolver()) +); +$geoFailEngine->setAgentHandler($geoFailAgent); +$geoFailEngine->initialize(); +$denied = $geoFailEngine->execute(new SimpleGuardRequest(urlPath: '/', clientHost: '198.51.100.7')); +$t->same(403, $denied?->statusCode(), 'a failing resolver under a restrictive allowlist denies (unresolved)'); +$geoEvents = array_values(array_filter($geoFailAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_GEO_LOOKUP_FAILED)); +$t->same(1, count($geoEvents), 'the resolver failure emits geo_lookup_failed'); +$t->same('lookup_failed', $geoEvents[0]->actionTaken, 'the lookup_failed action'); +$t->same('Geographic lookup failed: RuntimeException', $geoEvents[0]->reason, 'the failure reason names the exception'); +$t->same('198.51.100.7', $geoEvents[0]->ipAddress, 'the failing lookup carries the scanned ip'); + +// --------------------------------------------------------------------- +// 4. security_headers_applied behind the TTL cache +// --------------------------------------------------------------------- +$t->section('security_headers_applied'); +$headersAgent = new EvAgent(); +$headersEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$headersEngine->setAgentHandler($headersAgent); +$headersEngine->initialize(); +$first = $headersEngine->execute(new SimpleGuardRequest(urlPath: '/page', clientHost: '198.51.100.8')); +$t->truthy($first === null, 'the pass-through request executes'); +$headerEvents = array_values(array_filter($headersAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)); +$t->same(1, count($headerEvents), 'the first response emits security_headers_applied'); +$headerEvent = $headerEvents[0]; +$t->same('security_headers', $headerEvent->handlerName, 'the security_headers handler name'); +$t->same('headers_added', $headerEvent->actionTaken, 'the headers_added action'); +$t->same('/page', $headerEvent->metadata['path'] ?? null, 'the path metadata'); +$t->truthy(($headerEvent->metadata['headers_count'] ?? 0) > 0, 'the headers_count metadata'); +$t->truthy(is_bool($headerEvent->metadata['has_csp'] ?? null), 'the has_csp metadata'); +$t->truthy(is_bool($headerEvent->metadata['has_hsts'] ?? null), 'the has_hsts metadata'); + +$headersEngine->execute(new SimpleGuardRequest(urlPath: '/page', clientHost: '198.51.100.8')); +$headerEvents = array_values(array_filter($headersAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)); +$t->same(1, count($headerEvents), 'a second response on the same path does not re-emit inside the TTL window'); + +$headersEngine->execute(new SimpleGuardRequest(urlPath: '/other', clientHost: '198.51.100.8')); +$headerEvents = array_values(array_filter($headersAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)); +$t->same(2, count($headerEvents), 'a different path emits again'); + +$blockedHeadersEngine = new GuardEngine(new SecurityConfig(enableRedis: false, blacklist: ['192.0.2.9'])); +$blockedHeadersEngine->setAgentHandler($headersAgent); +$blockedHeadersEngine->initialize(); +$blockedHeadersEngine->execute(new SimpleGuardRequest(urlPath: '/blocked-page', clientHost: '192.0.2.9')); +$headerEvents = array_values(array_filter($headersAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)); +$t->same(3, count($headerEvents), 'a blocked response emits on its own path'); + +// The eviction branch: flood past the maxsize and the cache stays bounded. +$floodAgent = new EvAgent(); +$floodEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$floodEngine->setAgentHandler($floodAgent); +$floodEngine->initialize(); +for ($i = 0; $i < 1001; $i++) { + $floodEngine->execute(new SimpleGuardRequest(urlPath: '/flood/' . $i, clientHost: '198.51.100.8')); +} +$t->same(1001, count(array_filter($floodAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)), 'each distinct path emits exactly once through the flood'); + +$emptyPathAgent = new EvAgent(); +$emptyPathEngine = new GuardEngine(new SecurityConfig(enableRedis: false)); +$emptyPathEngine->setAgentHandler($emptyPathAgent); +$emptyPathEngine->initialize(); +$emptyPathEngine->execute(new SimpleGuardRequest(urlPath: '', clientHost: '198.51.100.8')); +$t->truthy(!in_array(EventTypes::EVENT_SECURITY_HEADERS_APPLIED, $emptyPathAgent->types(), true), 'an empty path emits nothing (the reference gates on request_path)'); + +$disabledAgent = new EvAgent(); +$disabledEngine = new GuardEngine(new SecurityConfig(enableRedis: false, securityHeaders: ['enabled' => false])); +$disabledEngine->setAgentHandler($disabledAgent); +$disabledEngine->initialize(); +$disabledEngine->execute(new SimpleGuardRequest(urlPath: '/quiet', clientHost: '198.51.100.8')); +$t->truthy(!in_array(EventTypes::EVENT_SECURITY_HEADERS_APPLIED, $disabledAgent->types(), true), 'disabled headers emit nothing'); + +$t->section('sensitive-set redaction through the headers event'); +// The sensitive-log config fields are array membership maps; +// the emission converts them to the redactor's name lists (a non-empty set +// must not fatal the request, the regression behind this case). +$sensitiveAgent = new EvAgent(); +$sensitiveEngine = new GuardEngine(new SecurityConfig( + enableRedis: false, + logSensitiveHeaders: ['x-forwarded-for', 'x-real-ip'], + logSensitiveParams: ['token'] +)); +$sensitiveEngine->setAgentHandler($sensitiveAgent); +$sensitiveEngine->initialize(); +$sensitiveEngine->execute(new SimpleGuardRequest(urlPath: '/redact-check', clientHost: '198.51.100.8')); +$sensitiveEvents = array_values(array_filter($sensitiveAgent->received, static fn (object $e): bool => $e->eventType === EventTypes::EVENT_SECURITY_HEADERS_APPLIED)); +$t->same(1, count($sensitiveEvents), 'a non-empty sensitive set still emits the headers event'); +$t->same('/redact-check', $sensitiveEvents[0]->metadata['path'] ?? null, 'the plain path survives the redaction pass'); + +$t->same(0, $t->failed, 'no failures above'); +exit($t->finish('event emitters')); diff --git a/src/Engine/GuardEngine.php b/src/Engine/GuardEngine.php index 3d119cd..3e685b3 100644 --- a/src/Engine/GuardEngine.php +++ b/src/Engine/GuardEngine.php @@ -15,6 +15,7 @@ use RenzoFranceschini\GuardCore\Detection\SusPatterns; use RenzoFranceschini\GuardCore\Detection\Redos\ValidationCache; use RenzoFranceschini\GuardCore\Events\EventBus; +use RenzoFranceschini\GuardCore\Events\EventTypes; use RenzoFranceschini\GuardCore\Logging\LogRedactor; use RenzoFranceschini\GuardCore\Pipeline\BlockEvents; use RenzoFranceschini\GuardCore\Pipeline\CheckFactory; @@ -33,6 +34,12 @@ final class GuardEngine { public const UNRESOLVABLE_CLIENT_CHECK_NAME = 'client_address_unresolved'; + /** The reference security-headers cache TTL (TTLCache ttl=300). */ + private const HEADERS_CACHE_TTL = 300; + + /** The reference security-headers cache size (TTLCache maxsize=1000). */ + private const HEADERS_CACHE_MAX = 1000; + private SecurityConfig $config; private ?\Closure $warn; @@ -52,6 +59,15 @@ final class GuardEngine /** @var array */ private array $initializationStatus = []; + /** + * The reference security-headers TTL cache (TTLCache(1000, 300)): the + * security_headers_applied event fires once per config+path per TTL + * window, not per response. + * + * @var array cache key => expiry unix timestamp + */ + private array $headersCache = []; + private readonly ?CorsPolicy $corsPolicy; private readonly SusPatterns $susPatterns; @@ -132,7 +148,8 @@ public function __construct( $this->susPatterns, $this->cloudManager, $config->geoIpHandler, - $this->suspiciousCountStore + $this->suspiciousCountStore, + $this->eventBus ); $this->checkFactory = $checkFactory; $this->pipeline = new SecurityCheckPipeline( @@ -229,7 +246,8 @@ public function applyDynamicConfig(SecurityConfig $config): void $this->susPatterns, $this->cloudManager, $config->geoIpHandler, - $this->suspiciousCountStore + $this->suspiciousCountStore, + $this->eventBus ); } @@ -321,16 +339,82 @@ public function processResponse(GuardRequest $request, ?GuardResponse $response) * * @return array */ + /** + * Computes the security-header set (the adapter seam for pass-through + * responses; the engine applies the same set to blocked responses). + * + * @return array + */ public function responseHeaders(): array { return $this->config->securityHeaders->responseHeaders(); } - private function applySecurityHeaders(GuardResponse $response): void + private function applySecurityHeaders(GuardResponse $response, ?GuardRequest $request = null): void { foreach ($this->responseHeaders() as $name => $value) { $response->headers()->set($name, $value); } + if ($request !== null) { + $this->recordHeadersApplied($request, $this->responseHeaders()); + } + } + + /** + * The reference _send_headers_applied_event behind the security-headers + * TTL cache (TTLCache(1000, 300), keyed cfg_{config id}_path_{sha256 + * prefix}): one event per config+path per TTL window, and never for an + * empty path (the reference gates on request_path). + * + * @param array $headers + */ + private function recordHeadersApplied(GuardRequest $request, array $headers): void + { + $path = $request->urlPath(); + $now = time(); + $key = $path === '' ? null : self::headersCacheKey($this->config, $path); + $fresh = $key === null || ($this->headersCache[$key] ?? 0) > $now; + if ($fresh) { + return; + } + $this->headersCache[$key] = $now + self::HEADERS_CACHE_TTL; + // Over capacity: trim oldest-first, the TTLCache eviction order for + // a single shared TTL (the entry nearest expiry is the oldest + // insertion), so the bound and the per-path emission window hold. + while (count($this->headersCache) > self::HEADERS_CACHE_MAX) { + array_shift($this->headersCache); + } + $this->eventBus->sendHandlerEvent( + EventTypes::EVENT_SECURITY_HEADERS_APPLIED, + 'security_headers', + '', + 'headers_added', + 'Security headers applied to response', + [ + 'path' => LogRedactor::redactUrlForDisplay( + $path, + array_keys($this->config->logSensitiveParams), + array_keys($this->config->logSensitiveBodyFields), + array_keys($this->config->logSensitiveHeaders) + ), + 'headers_count' => count($headers), + 'has_csp' => isset($headers['Content-Security-Policy']), + 'has_hsts' => isset($headers['Strict-Transport-Security']), + ] + ); + } + + /** + * The reference _generate_cache_key: cfg_{config id}_path_{sha256 of the + * lowercased, slash-stripped path, first 16 hex} - or the default key + * for an empty path. + */ + private static function headersCacheKey(SecurityConfig $config, string $path): string + { + $normalized = strtolower($path); + $base = 'path_' . substr(hash('sha256', trim($normalized, '/')), 0, 16); + + return 'cfg_' . spl_object_id($config) . '_' . $base; } public function initialize(): void @@ -415,7 +499,7 @@ public function execute(GuardRequest $request): ?GuardResponse $state->clientIp = $clientIp; if ($clientIp === ClientIpResolver::UNKNOWN_CLIENT_IDENTITY && $this->config->failSecure) { $response = $this->unresolvableClientResponse($request); - $this->applySecurityHeaders($response); + $this->applySecurityHeaders($response, $request); $cors?->injectResponseHeaders($response, $request->headers()); return $response; @@ -434,17 +518,25 @@ public function execute(GuardRequest $request): ?GuardResponse $state->routeConfig, microtime(true) ); + // The adapter deterministically applies this pure header set to + // the pass-through response (the responseHeaders() seam), so + // the security_headers_applied emission fires here, on the + // reference's per-config+path TTL-cache schedule. + $headers = $this->responseHeaders(); + if ($headers !== []) { + $this->recordHeadersApplied($request, $headers); + } } if ($cors === null) { if ($response !== null) { - $this->applySecurityHeaders($response); + $this->applySecurityHeaders($response, $request); } return $response; } if ($response !== null) { - $this->applySecurityHeaders($response); + $this->applySecurityHeaders($response, $request); $cors->injectResponseHeaders($response, $request->headers()); return $response; @@ -452,7 +544,7 @@ public function execute(GuardRequest $request): ?GuardResponse $preflightResponse = $preflight ? $cors->buildPreflightResponse($request, $this->responseFactory) : null; if ($preflightResponse !== null) { - $this->applySecurityHeaders($preflightResponse); + $this->applySecurityHeaders($preflightResponse, $request); } return $preflightResponse; diff --git a/src/Events/EventBus.php b/src/Events/EventBus.php index 8bba3d1..673ff23 100644 --- a/src/Events/EventBus.php +++ b/src/Events/EventBus.php @@ -4,6 +4,7 @@ namespace RenzoFranceschini\GuardCore\Events; +use RenzoFranceschini\GuardCore\Cloud\CloudManager; use RenzoFranceschini\GuardCore\Config\SecurityConfig; use RenzoFranceschini\GuardCore\Logging\LogRedactor; use RenzoFranceschini\GuardCore\Routing\RouteConfig; @@ -143,9 +144,9 @@ public function sendHttpsViolationEvent(object $request, ?RouteConfig $routeConf { $httpsUrl = LogRedactor::redactUrlForDisplay( $request->urlReplaceScheme('https'), - $this->config->logSensitiveParams, - $this->config->logSensitiveBodyFields, - $this->config->logSensitiveHeaders + array_keys($this->config->logSensitiveParams), + array_keys($this->config->logSensitiveBodyFields), + array_keys($this->config->logSensitiveHeaders) ); if ($routeConfig !== null && $routeConfig->requireHttps) { $this->sendMiddlewareEvent( @@ -175,6 +176,53 @@ public function sendHttpsViolationEvent(object $request, ?RouteConfig $routeConf ); } + /** + * The reference send_cloud_detection_events (middleware_events.py): the + * cloud-handler verdict event (cloud_blocked with the provider and + * network, handler "cloud", action request_blocked or logged_only in + * passive mode) plus, when the caller forwards a route that blocks + * clouds itself, the route's decorator_violation (decorator_type + * access_control, violation_type cloud_provider). The engine's check + * passes a null route for its own route-level block and emits the + * block_clouds decorator event itself, exactly like the reference. + * + * @param list $providers + */ + public function sendCloudDetectionEvents( + object $request, + string $clientIp, + array $providers, + ?RouteConfig $routeConfig, + CloudManager $cloudHandler, + bool $passiveMode + ): void { + $details = $cloudHandler->getCloudProviderDetails($clientIp, $providers); + if ($details !== null) { + [$provider, $network] = $details; + $this->sendHandlerEvent( + EventTypes::EVENT_CLOUD_BLOCKED, + 'cloud', + $clientIp, + $passiveMode ? 'logged_only' : 'request_blocked', + "IP belongs to blocked cloud provider: {$provider}", + ['cloud_provider' => $provider, 'network' => $network] + ); + } + if ($routeConfig !== null && $routeConfig->blockCloudProviders !== []) { + $this->sendMiddlewareEvent( + EventTypes::EVENT_DECORATOR_VIOLATION, + $request, + $passiveMode ? 'logged_only' : 'request_blocked', + "Cloud provider IP {$clientIp} blocked", + [ + 'decorator_type' => 'access_control', + 'violation_type' => 'cloud_provider', + 'blocked_providers' => array_values($providers), + ] + ); + } + } + /** * @param array $metadata */ @@ -198,9 +246,9 @@ private function buildEvent( ? LogRedactor::redactBlob( $rawUserAgent, LogRedactor::sensitiveNames( - $this->config->logSensitiveParams, - $this->config->logSensitiveBodyFields, - $this->config->logSensitiveHeaders + array_keys($this->config->logSensitiveParams), + array_keys($this->config->logSensitiveBodyFields), + array_keys($this->config->logSensitiveHeaders) ) ) : $rawUserAgent, @@ -208,9 +256,9 @@ private function buildEvent( reason: $reason, endpoint: LogRedactor::redactUrlForDisplay( $request->urlPath(), - $this->config->logSensitiveParams, - $this->config->logSensitiveBodyFields, - $this->config->logSensitiveHeaders + array_keys($this->config->logSensitiveParams), + array_keys($this->config->logSensitiveBodyFields), + array_keys($this->config->logSensitiveHeaders) ), method: $request->method(), handlerName: 'middleware', diff --git a/src/Pipeline/CheckFactory.php b/src/Pipeline/CheckFactory.php index 7674894..c0d3a80 100644 --- a/src/Pipeline/CheckFactory.php +++ b/src/Pipeline/CheckFactory.php @@ -11,6 +11,7 @@ use RenzoFranceschini\GuardCore\Detection\PerformanceMonitor; use RenzoFranceschini\GuardCore\Detection\SusPatterns; use RenzoFranceschini\GuardCore\Detection\Redos\ValidationCache; +use RenzoFranceschini\GuardCore\Events\EventBus; use RenzoFranceschini\GuardCore\GeoIp\CountryResolver; use RenzoFranceschini\GuardCore\Pipeline\Checks\AuthenticationCheck; use RenzoFranceschini\GuardCore\Pipeline\Checks\CloudIpRefreshCheck; @@ -57,6 +58,8 @@ final class CheckFactory private readonly ?CountryResolver $geoIpHandler; + private readonly ?EventBus $eventBus; + public function __construct( private readonly GuardResponseFactory $responseFactory, private readonly RouteResolver $routeResolver = new RouteResolver(), @@ -65,11 +68,13 @@ public function __construct( ?SusPatterns $susPatterns = null, ?CloudManager $cloudManager = null, ?CountryResolver $geoIpHandler = null, - private readonly ?SuspiciousCountStore $suspiciousCountStore = null + private readonly ?SuspiciousCountStore $suspiciousCountStore = null, + ?EventBus $eventBus = null ) { $this->susPatterns = $susPatterns; $this->cloudManager = $cloudManager ?? new CloudManager(); $this->geoIpHandler = $geoIpHandler; + $this->eventBus = $eventBus; } /** * Spec 03 helpers.route_config_applies: when route_configs is null (no @@ -120,8 +125,8 @@ private function buildOne(string $name, SecurityConfig $config): ?SecurityCheck 'custom_validators' => new CustomValidatorsCheck($config, $this->responseFactory), 'time_window' => new TimeWindowCheck($config, $this->responseFactory), 'cloud_ip_refresh' => new CloudIpRefreshCheck($config, $this->responseFactory, $this->cloudManager, $this->routeResolver), - 'ip_security' => new IpSecurityCheck($config, $this->responseFactory, $this->ipBanManager, $this->routeResolver, $this->geoIpHandler), - 'cloud_provider' => new CloudProviderCheck($config, $this->responseFactory, $this->cloudManager, $this->routeResolver), + 'ip_security' => new IpSecurityCheck($config, $this->responseFactory, $this->ipBanManager, $this->routeResolver, $this->geoIpHandler, null, $this->eventBus), + 'cloud_provider' => new CloudProviderCheck($config, $this->responseFactory, $this->cloudManager, $this->routeResolver, $this->eventBus), 'user_agent' => new UserAgentCheck($config, $this->responseFactory), 'rate_limit' => new RateLimitCheck($config, $this->responseFactory, $this->rateLimitHandler, $this->routeResolver), 'suspicious_activity' => new SuspiciousActivityCheck( diff --git a/src/Pipeline/Checks/CloudProviderCheck.php b/src/Pipeline/Checks/CloudProviderCheck.php index 9e1ca5f..fb84b67 100644 --- a/src/Pipeline/Checks/CloudProviderCheck.php +++ b/src/Pipeline/Checks/CloudProviderCheck.php @@ -6,6 +6,8 @@ use RenzoFranceschini\GuardCore\Cloud\CloudManager; use RenzoFranceschini\GuardCore\Config\SecurityConfig; +use RenzoFranceschini\GuardCore\Events\EventBus; +use RenzoFranceschini\GuardCore\Events\EventTypes; use RenzoFranceschini\GuardCore\Logging\LogActivity; use RenzoFranceschini\GuardCore\Pipeline\CheckFactory; use RenzoFranceschini\GuardCore\Pipeline\SecurityCheck; @@ -21,7 +23,8 @@ public function __construct( SecurityConfig $config, GuardResponseFactory $responseFactory, private readonly CloudManager $cloudManager, - private readonly RouteResolver $routeResolver + private readonly RouteResolver $routeResolver, + private readonly ?EventBus $eventBus = null ) { parent::__construct($config, $responseFactory); } @@ -86,6 +89,34 @@ public function check(GuardRequest $request): ?GuardResponse return null; } + // The reference _emit_cloud_block_events: the bus-level cloud events + // (cloud_blocked plus the access_control decorator_violation when a + // forwarded route blocks), then the check's own block_clouds + // decorator_violation for a route-level block - the reference check + // passes a null route to the bus for exactly that case. + $routeBlocksClouds = $routeConfig !== null && $routeConfig->blockCloudProviders !== []; + $this->eventBus?->sendCloudDetectionEvents( + $request, + $clientIp, + $providers, + $routeBlocksClouds ? null : $routeConfig, + $this->cloudManager, + $this->config->passiveMode + ); + if ($routeBlocksClouds) { + $this->eventBus?->sendMiddlewareEvent( + EventTypes::EVENT_DECORATOR_VIOLATION, + $request, + $this->config->passiveMode ? 'logged_only' : 'request_blocked', + "Cloud provider IP {$clientIp} blocked", + [ + 'decorator_type' => 'block_clouds', + 'violation_type' => 'cloud_provider', + 'blocked_providers' => $providers, + ] + ); + } + LogActivity::log( $request, $this->logger, diff --git a/src/Pipeline/Checks/IpSecurityCheck.php b/src/Pipeline/Checks/IpSecurityCheck.php index 28f9388..940f918 100644 --- a/src/Pipeline/Checks/IpSecurityCheck.php +++ b/src/Pipeline/Checks/IpSecurityCheck.php @@ -6,6 +6,8 @@ use RenzoFranceschini\GuardCore\Ban\IpBanManager; use RenzoFranceschini\GuardCore\Config\SecurityConfig; +use RenzoFranceschini\GuardCore\Events\EventBus; +use RenzoFranceschini\GuardCore\Events\EventTypes; use RenzoFranceschini\GuardCore\GeoIp\CountryResolver; use RenzoFranceschini\GuardCore\Ip\CanonicalIp; use RenzoFranceschini\GuardCore\Logging\RequestLogger; @@ -24,7 +26,8 @@ public function __construct( private readonly ?IpBanManager $ipBanManager, private readonly RouteResolver $routeResolver, private readonly ?CountryResolver $geoIpHandler = null, - ?RequestLogger $logger = null + ?RequestLogger $logger = null, + private readonly ?EventBus $eventBus = null ) { parent::__construct($config, $responseFactory, $logger); } @@ -160,9 +163,23 @@ public function check(GuardRequest $request): ?GuardResponse "IP from whitelisted country {$clientIp} - {$country} - IP from whitelisted country" ); } else { + $this->sendCountryBlockedEvent( + $clientIp, + $country, + "Country {$country} not in allowed list", + 'country_whitelist' + ); + return $this->deny($request, $clientIp, "IP from blocked country: {$country}"); } } elseif (in_array($country, $blocked, true)) { + $this->sendCountryBlockedEvent( + $clientIp, + $country, + "Country {$country} is blocked", + 'country_blacklist' + ); + return $this->deny($request, $clientIp, "IP from blocked country: {$country}"); } else { $this->logCountryVerdict( @@ -277,12 +294,43 @@ private function hasCountryRules(): bool } /** - * The country lookup through the injected resolver; PR-seamed so the - * lookup-failure handling attaches to exactly one site. + * The country lookup through the injected resolver; a resolver failure + * emits the reference geo_lookup_failed event (handler "ipinfo", + * action lookup_failed) and reports a miss, never a raise - the + * reference get_country catches internally and emits the same event. */ private function resolveCountry(GuardRequest $request, string $clientIp): ?string { - return $this->geoIpHandler?->getCountry($clientIp); + try { + return $this->geoIpHandler->getCountry($clientIp); + } catch (\Throwable $e) { + $this->eventBus?->sendHandlerEvent( + EventTypes::EVENT_GEO_LOOKUP_FAILED, + 'ipinfo', + $clientIp, + 'lookup_failed', + 'Geographic lookup failed: ' . (new \ReflectionClass($e))->getShortName() + ); + + return null; + } + } + + /** + * The reference check_country_access country_blocked event (handler + * "ipinfo", action request_blocked, the country and rule_type + * metadata). + */ + private function sendCountryBlockedEvent(string $clientIp, string $country, string $reason, string $ruleType): void + { + $this->eventBus?->sendHandlerEvent( + EventTypes::EVENT_COUNTRY_BLOCKED, + 'ipinfo', + $clientIp, + 'request_blocked', + $reason, + ['country' => $country, 'rule_type' => $ruleType] + ); } /**