From 7ea95753a4eeda1dbaf2ad9523f356990376489d Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 13:45:36 +0200 Subject: [PATCH] fix(dynamic-rules): apply rules over the base config and wire the agent, the interval and the response modifier (#83) Dynamic rules now apply exactly as the Python reference does: ip_blacklist bans for ip_ban_duration and ip_whitelist unbans, the country, cloud provider, user agent and endpoint rate limit values replace the base values, emergency mode always halves the auto-ban threshold, each new version is applied over the base config (guard-core #169), and the events carry the reference envelopes. SecurityConfig::with() keeps the agent and dynamic rule fields, enableAgent is stored and required by enableDynamicRules and enableEnrichment, and dynamicRuleInterval and customResponseModifier are new fields. GuardEngine builds the DynamicRuleManager at initialize(), fetches rules through a per-request interval gate, and applies the response modifier once to every response it returns. --- .github/coverage-unreachable.php | 10 +- .github/workflows/ci.yml | 5 +- AGENTS.md | 14 +- Makefile | 2 +- README.md | 1 + bin/conformance_events.php | 2 +- bin/conformance_redis.php | 2 +- bin/test_composite_handlers.php | 2 +- bin/test_dynamic_rules.php | 593 ++++++++++++++++++ bin/test_enrichment.php | 4 +- bin/test_event_bus.php | 61 +- bin/test_m4.php | 2 +- bin/test_pattern_registry.php | 4 +- .../php_events_xfail.json | 2 - .../php_redis_interop_xfail.json | 3 +- docs/configuration.md | 9 +- docs/usage.md | 61 +- examples/advanced_app/README.md | 4 +- examples/advanced_app/src/config.php | 15 +- examples/simple_app/README.md | 7 +- examples/simple_app/index.php | 10 +- src/Config/SecurityConfig.php | 48 +- src/Engine/GuardEngine.php | 113 +++- src/Request/GuardResponseFactory.php | 38 +- src/Rules/DynamicRuleManager.php | 524 ++++++++++------ src/Rules/DynamicRules.php | 80 ++- 26 files changed, 1320 insertions(+), 296 deletions(-) create mode 100644 bin/test_dynamic_rules.php diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index daaa50a..c2ff7bf 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -248,14 +248,14 @@ 85, 86, 88, ], - // The bare-string guards inside validateSensitiveSet (1105) and - // validateExclusionSet (1129) sit behind typed ?array constructor + // The bare-string guards inside validateSensitiveSet (1162) and + // validateExclusionSet (1186) sit behind typed ?array constructor // parameters: PHP raises the TypeError at the call boundary, so a // string can never reach the is_string($names) checks (the same shape // as the BehaviorRule entry above). The suites assert the boundary // TypeError instead (test_json_logging.php). 'src/Config/SecurityConfig.php' => [ - 1120, 1144, + 1162, 1186, ], // 219: the non-array/non-scalar json_decode guard - json_decode can @@ -271,7 +271,7 @@ 219, 240, 241, 242, 243, ], - // 518-522: the ip_ban-side initialization catch. Every collaborator + // 621-625: 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' => [ - 518, 519, 520, 521, 522, + 621, 622, 623, 624, 625, ], ]; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f6865..af41a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,9 @@ jobs: - name: test_scan_time_budget run: php bin/test_scan_time_budget.php + - name: test_dynamic_rules + run: php bin/test_dynamic_rules.php + conformance-pipeline: name: Conformance pipeline (php ${{ matrix.php }}) runs-on: ubuntu-latest @@ -246,7 +249,7 @@ jobs: run: | mkdir -p build php .github/coverage-runner.php --collect bin/test_state.php build/coverage-test_state.php --integration - for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators test_scan_time_budget; do + for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators test_scan_time_budget test_dynamic_rules; do php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php" done diff --git a/AGENTS.md b/AGENTS.md index d36d40e..c29ac74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,9 @@ Guard-Core/guard-core-php (https://github.com/Guard-Core/guard-core-php) is the ## Ecosystem Position - `guard-core` (Python) is the reference engine; this repo ports it to PHP; guard-core-go ports it to Go. The adapters are psr15-guard (PSR-7/PSR-15), slim-guard (Slim 4 composition), laravel-guard (Illuminate), symfony-guard (HttpFoundation). -- guard-agent-php is the telemetry agent; the engine's only telemetry seam is the `onBlock` hook (`SecurityConfig(onBlock: ...)`, payload contract in `src/Pipeline/BlockEvents.php`). `enableAgent` fails config validation (the feature is not ported yet); do not turn it on. +- guard-agent-php is the telemetry agent; the engine's telemetry seams are the event bus and metrics collector (an agent handler attached with `GuardEngine::setAgentHandler()`, which the adapters do) and the `onBlock` hook (`SecurityConfig(onBlock: ...)`, payload contract in `src/Pipeline/BlockEvents.php`). `enableAgent` is stored and declares that an agent handler is attached; `enableDynamicRules` and `enableEnrichment` require it (the reference `validate_agent_config`). The engine never builds the agent itself, so the reference `agent_api_key` field is not modelled. +- Dynamic rules: with `enableAgent` and `enableDynamicRules` on and an agent handler attached, `GuardEngine::initialize()` builds `Rules/DynamicRuleManager`, hydrates the last-known rules, and `execute()` fetches new rules every `dynamicRuleInterval` seconds (the reference background loop becomes a per-request interval gate because a PHP request process has no background task). A rule is applied exactly like the reference `_dynamic_rule_application.py`, over the base config (guard-core #169), never over the previous version. +- `customResponseModifier` (the reference `custom_response_modifier`) is applied once to every response the engine returns; adapters call `GuardEngine::applyResponseModifier()` for pass-through responses. - Cross-implementation Redis interop lives in `bin/interop.php` (Python engine interop; requires `INTEROP_PHASE` and a reachable Redis). ## Architecture @@ -20,7 +22,7 @@ Guard-Core/guard-core-php (https://github.com/Guard-Core/guard-core-php) is the All of `src/`, namespace `RenzoFranceschini\GuardCore`: - `Engine/GuardEngine.php`: the facade. `__construct(SecurityConfig, ?RedisHandler, ?log, ?warn, ?CloudManager)` builds managers plus `CheckFactory` and the `SecurityCheckPipeline`; `initialize()` connects Redis and initializes ban/rate-limit/cloud state; `execute(GuardRequest): ?GuardResponse` resolves the client IP (fail-secure on unresolvable identity), honors `excludePaths`, and runs the pipeline. Accessors: `config()`, `redis()`, `banManager()`, `rateLimitHandler()`, `cloudManager()`, `responseFactory()`, `pipeline()`. -- `Config/SecurityConfig.php`: the single immutable config constructor (named arguments, every field nullable with an engine default) plus validation. `with()` returns an immutable copy with a bumped revision so the pipeline rebuilds. `Config/UnsupportedFeatureError.php`: fail-closed for geo blocking, CORS, agent telemetry, and dynamic rules. +- `Config/SecurityConfig.php`: the single immutable config constructor (named arguments, every field nullable with an engine default) plus validation. `with()` returns an immutable copy with a bumped revision so the pipeline rebuilds. `Config/UnsupportedFeatureError.php`: the fail-closed error for features this port does not implement (none of the config options throws it today; `DeferredCheck` uses it for unknown check names). - `Pipeline/`: `SecurityCheckPipeline` executes the 17 named checks in the fixed slot order of `CheckFactory::DEFAULT_CHECK_NAMES` (mirrors the Python `factory.py` slot order); `Checks/` holds one class per check; `BlockEvents.php` defines the `onBlock` payload and fires the hook (panics guarded, excluded check names per spec). - `Detection/`: the detector (`SusPatterns` entry point), preprocessing, base64/truncation/semantic/xml-xxe/pickle/matchers, all pattern tables under `Support/Generated/` (generated by `tools/gen_tables.py`; never hand-edit) and the table-driven NFKC normalizer in `Support/Unicode.php` (fixtures generated by `tools/gen_nfkc_fixtures.py`). - `Ban/IpBanManager.php`, `RateLimit/RateLimitHandler.php`, `Cloud/`: managers with Redis-backed state plus in-memory fallbacks. `Redis/`: a dependency-free RESP2 client (`RespConnection`, `RespPipeline`) and `RedisHandler` (reads `REDIS_HOST` / `REDIS_PORT` env, `REDIS_PREFIX` for keys). @@ -43,7 +45,7 @@ Known surface gaps (fail closed or documented, never silently no-op): no route r ## Quick Start -This machine has NO `php` and NO `composer` binary. Run everything through Docker (`php:8.3-cli` for tests, `composer:2` for composer), or let CI execute. The host Redis on 6379 is reachable from containers as `host.docker.internal`. +Run the runners with a host `php` and `composer` when present, else through Docker (`php:8.3-cli` for tests, `composer:2` for composer), or let CI execute. The host Redis on 6379 is reachable from containers as `host.docker.internal`. ``` docker run --rm -v "$PWD":/app -w /app composer:2 composer install --no-interaction --no-progress @@ -53,7 +55,7 @@ docker run --rm -v "$PWD":/app -w /app php:8.3-cli php bin/conformance.php ## Development Commands -There is no Makefile. Every command below comes from the CI workflows, the composer scripts, or this file. +The `Makefile` wraps `install`, `test`, `lint`, `bump-version` and `clean`; every command below comes from the CI workflows, the composer scripts, or this file. Several runners (`test_ratelimit`, `test_geo_rate_limits`) issue `FLUSHDB` against the Redis they are pointed at, so give them a throwaway instance. | Command | Purpose | | --- | --- | @@ -67,11 +69,11 @@ There is no Makefile. Every command below comes from the CI workflows, the compo | `composer install` then `docker compose -f examples/simple_app/docker-compose.yml up --build -d --wait` | Live smoke stack for the simple example (advanced: `examples/advanced_app/docker-compose.yml`) | | `pip install mkdocs-material && mkdocs build --strict` | Build the documentation site (CI deploys it on master) | -CI (`.github/workflows/ci.yml`) runs on every push and PR with a PHP matrix of 8.2/8.3/8.4 and a `redis:7-alpine` service: the `php -l` sweep, the eight `bin/` runners above (Redis-backed ones with `REDIS_HOST=127.0.0.1`), plus a `composer audit` job (no-ops cleanly on the zero-dependency lock). Additional workflows: live smoke (both example apps, dockerized), ecosystem gate (the four adapter repos run their suites against this checkout), docs site (strict build + gh-deploy on master), issue link, labeler, stale, summary, greetings, sync labels, release gate at `v*` tags, and scheduled weekly audit + lint. +CI (`.github/workflows/ci.yml`) runs on every push and PR with a PHP matrix of 8.2/8.3/8.4 and a `redis:7-alpine` service: the `php -l` sweep, every `bin/` runner listed in the workflow (`test_*.php` and the `conformance*.php` replays, Redis-backed ones with `REDIS_HOST=127.0.0.1`), a pcov coverage job with a 100% line gate (`.github/coverage-runner.php`, waivers in `.github/coverage-unreachable.php`), plus a `composer audit` job (no-ops cleanly on the zero-dependency lock). Additional workflows: live smoke (both example apps, dockerized), ecosystem gate (the four adapter repos run their suites against this checkout), docs site (strict build + gh-deploy on master), issue link, labeler, stale, summary, greetings, sync labels, release gate at `v*` tags, and scheduled weekly audit + lint. ## Testing Guidelines -- The suite is the eight `bin/test_*.php` runners plus `bin/conformance.php`, a T-harness style (`ok - / FAIL -` lines, exit code 0 only when everything passed). No PHPUnit, no test framework dependency. +- The suite is the `bin/test_*.php` runners plus the `bin/conformance*.php` replays, a T-harness style (`ok - / FAIL -` lines, exit code 0 only when everything passed). No PHPUnit, no test framework dependency. - `REDIS_HOST` controls the integration legs: without it the runners exercise in-memory fallbacks; with it they assert real Redis state. Stale keys from prior runs can trip window-sensitive assertions, so run against a fresh database or flush between runs. - The conformance corpus is drift control: any engine change that alters a verdict fails `bin/conformance.php`. Regenerate fixtures from the Python engine instead of updating expectations by hand. - New behavior needs a test that would fail without it, added to the matching runner. diff --git a/Makefile b/Makefile index fa46982..dd18a08 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ REDIS_HOST ?= host.docker.internal # Runners mirrored from CI (.github/workflows/ci.yml). Redis-backed # runners are listed in REDIS_RUNNERS and get REDIS_HOST passed through. REDIS_RUNNERS = bin/test_ratelimit.php bin/test_geo_rate_limits.php bin/test_pipeline.php bin/test_m3b.php bin/test_m4.php bin/test_behavior.php bin/conformance_redis.php -RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php bin/test_scan_time_budget.php +RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php bin/test_scan_time_budget.php bin/test_dynamic_rules.php .PHONY: install test lint bump-version clean diff --git a/README.md b/README.md index b361dab..1cb393e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ ___ - **IP lists**: whitelist, blacklist, and exemptions with CIDR support - **Geo rate limits and country blocking**: per-country rules via GeoIP - **CORS handling and security headers**: OWASP-aligned defaults +- **Agent-synced dynamic rules** and a custom response modifier, applied exactly as the reference engine does - **Behavior rules**: per-route usage counting and bans - **Per-route detection exclusions** and detection limits (ReDoS-safe custom patterns) - **Redis-backed distributed state** with fail-open / fail-secure modes diff --git a/bin/conformance_events.php b/bin/conformance_events.php index d90cb21..70462b0 100644 --- a/bin/conformance_events.php +++ b/bin/conformance_events.php @@ -479,7 +479,7 @@ private function callDynamicRules(array $drive, EventsAgent $agent): void $rules = $drive['rules']; $rules['timestamp'] = '2026-01-01T00:00:00+00:00'; $manager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: new class($rules) { /** @param array $rules */ public function __construct(private array $rules) diff --git a/bin/conformance_redis.php b/bin/conformance_redis.php index a6d4f6c..8be2603 100644 --- a/bin/conformance_redis.php +++ b/bin/conformance_redis.php @@ -262,7 +262,7 @@ private function driverFor(array $case): ?callable 'dynamic_rules' => function (array $case): void { $args = $case['operation']['args']; $manager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: new class($args['rules']) { /** @param array $rules */ public function __construct(private array $rules) diff --git a/bin/test_composite_handlers.php b/bin/test_composite_handlers.php index a7bda1d..601d02e 100644 --- a/bin/test_composite_handlers.php +++ b/bin/test_composite_handlers.php @@ -611,7 +611,7 @@ final class TruncatedTraceparentSink extends Exception $t->same(1, count($handlers), 'no flags: only the agent handler'); $everything = AgentHandlerComposer::compose( - new SecurityConfig(enableOtel: true, enableLogfire: true, enableEnrichment: true, agentProjectId: 'p'), + new SecurityConfig(enableOtel: true, enableLogfire: true, enableAgent: true, enableEnrichment: true, agentProjectId: 'p'), agentHandler: new Sink('agent'), logfireClient: new LogfireFake() ); diff --git a/bin/test_dynamic_rules.php b/bin/test_dynamic_rules.php new file mode 100644 index 0000000..744d40b --- /dev/null +++ b/bin/test_dynamic_rules.php @@ -0,0 +1,593 @@ +passed++; + echo "ok - {$label}\n"; + + return; + } + $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 throws(string $class, \Closure $fn, string $label): void + { + try { + $fn(); + } catch (\Throwable $e) { + $this->same(true, $e instanceof $class, $label . ' (' . $e::class . ')'); + + return; + } + $this->same(true, false, $label . ' (nothing thrown)'); + } + + public function section(string $name): void + { + echo "\n=== {$name} ===\n"; + } +} + +final class Agent +{ + /** @var list */ + public array $received = []; + + /** @var list|object|null> */ + public array $queue = []; + + public int $fetches = 0; + + public function sendEvent(object $event): void + { + $this->received[] = $event; + } + + public function getDynamicRules(): mixed + { + $this->fetches++; + + return $this->queue === [] ? null : array_shift($this->queue); + } +} + +final class NoGeo implements CountryResolver +{ + public function getCountry(string $ip): ?string + { + return null; + } +} + +function baseOf(DynamicRuleManager $manager): ?SecurityConfig +{ + return method_exists($manager, 'activeBaseSnapshot') ? $manager->activeBaseSnapshot() : null; +} + +function rules(int $version = 1, string $id = 'r1', array $extra = []): DynamicRules +{ + return DynamicRules::fromArray(array_merge([ + 'rule_id' => $id, + 'version' => $version, + 'timestamp' => '2026-01-01T00:00:00+00:00', + ], $extra)); +} + +final class Harness +{ + public SecurityConfig $live; + + public Agent $agent; + + /** @var list */ + public array $logs = []; + + public int $clock = 1_800_000_000; + + public DynamicRuleManager $manager; + + public function __construct(public SecurityConfig $base, ?SusPatterns $patterns = null, ?IpBanManager $ban = null) + { + $this->live = $base; + $this->agent = new Agent(); + $args = [ + 'config' => $base, + 'agentHandler' => $this->agent, + 'eventBus' => new EventBus($this->agent, $base), + 'applyConfig' => function (SecurityConfig $c): void { + $this->live = $c; + }, + 'logger' => function (string $level, string $message): void { + $this->logs[] = $level . ': ' . $message; + }, + 'clock' => fn (): int => $this->clock, + 'susPatterns' => $patterns, + ]; + if ($ban !== null) { + $args['banManager'] = $ban; + } + $this->manager = new DynamicRuleManager(...$args); + } + + /** Delivers one rule version through the real update flow. */ + public function push(mixed $rules): void + { + $this->agent->queue = [$rules]; + $this->manager->updateRules(); + } + + public function logged(string $needle): bool + { + foreach ($this->logs as $line) { + if (str_contains($line, $needle)) { + return true; + } + } + + return false; + } +} + +$t = new T(); + +// --------------------------------------------------------------------- +// guard-core #169: the 8 overlay tests (tests/test_dynamic_rule_overlay.py) +// --------------------------------------------------------------------- + +$t->section('overlay 1: emergency halving does not compound across versions'); +$h = new Harness(new SecurityConfig(autoBanThreshold: 16, enableAgent: true, enableDynamicRules: true)); +$seen = []; +foreach ([1, 2, 3] as $version) { + $h->push(rules($version, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => []])); + $seen[] = $h->live->autoBanThreshold; +} +$t->same([8, 8, 8], $seen, 'v1..v3 keep the threshold at 8 from a base of 16'); +$t->same(16, baseOf($h->manager)?->autoBanThreshold, 'the active base snapshot holds the true base threshold'); + +$t->section('overlay 2: collection values do not accumulate across versions'); +$h = new Harness(new SecurityConfig( + geoIpHandler: new NoGeo(), + blockedUserAgents: ['base-bot'], + blockCloudProviders: ['AWS'], + enableAgent: true, + enableDynamicRules: true +)); +$h->push(rules(1, 'r1', [ + 'blocked_user_agents' => ['bot-a'], + 'blocked_cloud_providers' => ['GCP'], + 'blocked_countries' => ['AA'], + 'endpoint_rate_limits' => ['/a' => [1, 60]], +])); +$h->push(rules(2, 'r1', [ + 'blocked_user_agents' => ['bot-b'], + 'blocked_cloud_providers' => ['Azure'], + 'blocked_countries' => ['BB'], + 'endpoint_rate_limits' => ['/b' => [2, 60]], +])); +$t->same(['bot-b'], $h->live->blockedUserAgents, 'blocked_user_agents replaced by v2'); +$t->same(['Azure'], $h->live->blockCloudProviders, 'block_cloud_providers replaced by v2'); +$t->same(['BB'], $h->live->blockedCountries, 'blocked_countries replaced by v2'); +$t->same(['/b' => ['limit' => 2, 'window' => 60]], $h->live->endpointRateLimits, 'endpoint_rate_limits replaced by v2'); + +$t->section('overlay 3: a new version drops the change from the previous version'); +$h = new Harness(new SecurityConfig(rateLimit: 100, enableIpBanning: true, autoBanThreshold: 5, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['global_rate_limit' => 7, 'enable_ip_banning' => false, 'auto_ban_threshold' => 2])); +$t->same([7, false], [$h->live->rateLimit, $h->live->enableIpBanning], 'v1 applied'); +$h->push(rules(2, 'r1', ['global_rate_limit' => 9])); +$t->same(9, $h->live->rateLimit, 'v2 rate limit'); +$t->same(true, $h->live->enableIpBanning, 'v2 restores enable_ip_banning to the base'); +$t->same(5, $h->live->autoBanThreshold, 'v2 restores auto_ban_threshold to the base'); + +$t->section('overlay 4: a different rule id overlays the base'); +$h = new Harness(new SecurityConfig(rateLimit: 100, autoBanThreshold: 16, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => []])); +$h->push(rules(1, 'r2', ['global_rate_limit' => 3])); +$t->same(3, $h->live->rateLimit, 'r2 rate limit'); +$t->same(16, $h->live->autoBanThreshold, 'r2 does not inherit the halved threshold'); +$t->same(false, $h->live->emergencyMode, 'r2 does not inherit emergency mode'); + +$t->section('overlay 5: a failed apply rolls back to the active version'); +$h = new Harness(new SecurityConfig(rateLimit: 100, autoBanThreshold: 16, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['global_rate_limit' => 7, 'auto_ban_threshold' => 4])); +$h->push(rules(2, 'r1', ['global_rate_limit' => 9, 'emergency_mode' => true, 'emergency_whitelist' => ['not-an-ip']])); +$t->same(7, $h->live->rateLimit, 'rate limit stays at the active version after the failed apply'); +$t->same(4, $h->live->autoBanThreshold, 'threshold stays at the active version after the failed apply'); +$t->same(1, $h->manager->currentRules()?->version, 'the active rule is still v1'); +$t->truthy($h->logged('error: Failed to apply dynamic rules'), 'the failure is logged'); +$t->same(100, baseOf($h->manager)?->rateLimit, 'the active base snapshot still holds the base rate limit'); + +$t->section('overlay 6: expiry restores the true base after several versions'); +$h = new Harness(new SecurityConfig(autoBanThreshold: 16, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => []])); +$h->push(rules(2, 'r1', [ + 'emergency_mode' => true, + 'emergency_whitelist' => [], + 'expires_at' => gmdate('c', $h->clock + 10), +])); +$t->same(8, $h->live->autoBanThreshold, 'two emergency versions hold 8'); +$h->clock += 11; +$h->push(null); +$t->same(16, $h->live->autoBanThreshold, 'expiry restores 16'); +$t->same(false, $h->live->emergencyMode, 'expiry clears emergency mode'); +$t->same(null, $h->manager->currentRules(), 'expiry clears the current rules'); +$t->same(null, baseOf($h->manager), 'expiry clears the base snapshot'); + +$t->section('overlay 7: a new version after hydration overlays the base'); +$cache = sys_get_temp_dir() . '/gcphp-dynrules-' . getmypid() . '.json'; +file_put_contents($cache, rules(1, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => []])->dumpSnapshot()); +$h = new Harness(new SecurityConfig(autoBanThreshold: 16, enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: $cache)); +$h->manager->hydrateLastKnownRules(); +$t->same(8, $h->live->autoBanThreshold, 'hydration applied v1 over the base'); +$h->push(rules(2, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => []])); +$t->same(2, $h->manager->currentRules()?->version, 'v2 is current'); +$t->same(8, $h->live->autoBanThreshold, 'v2 over the base stays at 8, not 4'); +$h->push(rules(3, 'r1', [])); +$t->same(16, $h->live->autoBanThreshold, 'v3 without emergency restores 16'); +@unlink($cache); + +$t->section('overlay 8: suspicious patterns are not duplicated across versions'); +$registry = new SusPatterns(0.99); +$pattern = 'overlay-unique-marker-[0-9]{3}'; +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true), $registry); +foreach ([1, 2, 3] as $version) { + $h->push(rules($version, 'r1', ['suspicious_patterns' => [$pattern]])); +} +$t->same(1, count(array_keys($registry->getDefaultPatterns(), $pattern, true)), 'the pattern is in the registry exactly once'); +$registry->removePattern($pattern); + +// --------------------------------------------------------------------- +// _dynamic_rule_application.py: the exact effect of every field +// --------------------------------------------------------------------- + +$t->section('ip_blacklist is a timed ban, ip_whitelist an unban'); +$ban = new IpBanManager([]); +$h = new Harness(new SecurityConfig(blacklist: ['198.51.100.1'], whitelist: ['198.51.100.2'], enableAgent: true, enableDynamicRules: true), null, $ban); +$h->push(rules(1, 'r1', ['ip_blacklist' => ['203.0.113.5'], 'ip_ban_duration' => 120])); +$t->same(true, $ban->isIpBanned('203.0.113.5'), 'ip_blacklist IP is banned'); +$t->same(['198.51.100.1'], $h->live->blacklist, 'the static blacklist is untouched'); +$t->truthy($h->logged('info: Dynamic rule: Banned IP 203.0.113.5 for 120s'), 'the ban is logged with its duration'); +$h->push(rules(2, 'r1', ['ip_whitelist' => ['203.0.113.5']])); +$t->same(false, $ban->isIpBanned('203.0.113.5'), 'ip_whitelist IP is unbanned'); +$t->same(['198.51.100.2'], $h->live->whitelist, 'the static whitelist is untouched'); +$t->truthy($h->logged('info: Dynamic rule: Whitelisted IP 203.0.113.5'), 'the unban is logged'); + +$t->section('an unban failure is logged and does not abort the rule'); +$brokenConnection = new FakeRespConnection(); +$brokenBan = new IpBanManager([]); +$brokenBan->initializeRedis(new RedisHandler(true, 'dr_unban:', connection: $brokenConnection)); +$brokenConnection->failWrites = true; +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true), null, $brokenBan); +$h->push(rules(1, 'r1', ['ip_whitelist' => ['203.0.113.78'], 'global_rate_limit' => 13])); +$t->truthy($h->logged('error: Failed to whitelist IP 203.0.113.78'), 'the failed unban is logged'); +$t->same(13, $h->live->rateLimit, 'the rest of the rule still applies'); +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['ip_blacklist' => ['203.0.113.77']])); +$t->truthy($h->logged('warning: Dynamic rule: ip_blacklist and ip_whitelist are skipped: no ban manager'), 'IP rules without a ban manager warn and skip'); + +$t->section('a ban failure is logged and does not abort the rule'); +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true), null, new IpBanManager([])); +$h->push(rules(1, 'r1', ['ip_blacklist' => ['203.0.113.6'], 'ip_ban_duration' => 0, 'global_rate_limit' => 11])); +$t->truthy($h->logged('error: Failed to ban IP 203.0.113.6'), 'the failed ban is logged'); +$t->same(11, $h->live->rateLimit, 'the rest of the rule still applies'); + +$t->section('cloud providers: replace, filter by bare name, warn on unknown'); +$h = new Harness(new SecurityConfig(blockCloudProviders: ['AWS'], enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['blocked_cloud_providers' => ['GCP', 'Bogus', 'Azure:!eu-west']])); +$t->same(['GCP', 'Azure:!eu-west'], $h->live->blockCloudProviders, 'valid selectors replace the list, unknown dropped'); +$t->truthy($h->logged("warning: Dynamic rule: ignored unknown cloud providers ['Bogus']"), 'unknown providers are warned'); +$h->push(rules(2, 'r1', ['blocked_cloud_providers' => ['Bogus']])); +$t->same([], $h->live->blockCloudProviders, 'all-invalid replaces with an empty list'); + +$t->section('blocked_user_agents: replaced by the validated list, even when empty'); +$h = new Harness(new SecurityConfig(blockedUserAgents: ['base-bot'], enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['blocked_user_agents' => ['(.+)+']])); +$t->same([], $h->live->blockedUserAgents, 'a rule whose patterns all fail validation empties the list'); +$t->truthy($h->logged('warning: Dynamic rule: rejected blocked_user_agents patterns failing the ReDoS validator'), 'rejection is warned'); + +$t->section('country rules need a geo resolver, else warn and skip'); +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['blocked_countries' => ['br'], 'whitelist_countries' => ['us'], 'global_rate_limit' => 12])); +$t->same([], $h->live->blockedCountries, 'no resolver: blocked_countries not applied'); +$t->same(12, $h->live->rateLimit, 'the rule is still applied'); +$t->same(1, $h->manager->currentRules()?->version, 'the rule is current'); +$t->truthy($h->logged('warning: Dynamic rule: country rules cannot take effect'), 'the skip is warned'); +$h = new Harness(new SecurityConfig(geoIpHandler: new NoGeo(), enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['blocked_countries' => ['br'], 'whitelist_countries' => ['us']])); +$t->same(['BR'], $h->live->blockedCountries, 'with a resolver: upper-cased blocked_countries'); +$t->same(['US'], $h->live->whitelistCountries, 'with a resolver: upper-cased whitelist_countries'); + +$t->section('global_rate_window only applies with a global_rate_limit'); +$h = new Harness(new SecurityConfig(rateLimit: 100, rateLimitWindow: 60, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['global_rate_window' => 5])); +$t->same([100, 60], [$h->live->rateLimit, $h->live->rateLimitWindow], 'a window alone changes nothing'); +$h->push(rules(2, 'r1', ['global_rate_limit' => 20, 'global_rate_window' => 5])); +$t->same([20, 5], [$h->live->rateLimit, $h->live->rateLimitWindow], 'limit and window together apply'); + +$t->section('emergency mode always halves the threshold in effect'); +$h = new Harness(new SecurityConfig(autoBanThreshold: 10, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['emergency_mode' => true, 'emergency_whitelist' => ['10.0.0.1']])); +$t->same(5, $h->live->autoBanThreshold, 'a non-empty whitelist still halves the base threshold'); +$t->same(['10.0.0.1'], $h->live->emergencyWhitelist, 'the emergency whitelist is applied'); +$h->push(rules(2, 'r1', ['emergency_mode' => true, 'auto_ban_threshold' => 7])); +$t->same(3, $h->live->autoBanThreshold, 'the rule threshold is halved: 7 // 2'); +$h->push(rules(3, 'r1', ['emergency_mode' => true, 'auto_ban_threshold' => 1])); +$t->same(1, $h->live->autoBanThreshold, 'never below 1'); + +$t->section('events carry the reference envelopes'); +$h = new Harness(new SecurityConfig(autoBanThreshold: 10, enableAgent: true, enableDynamicRules: true)); +$h->push(rules(4, 'r9', ['emergency_mode' => true, 'emergency_whitelist' => array_map(static fn (int $i): string => "10.0.0.{$i}", range(1, 12)), 'ip_blacklist' => ['203.0.113.7'], 'blocked_countries' => []])); +$types = array_map(static fn (object $e): string => $e->eventType, $h->agent->received); +$t->same([EventTypes::EVENT_DYNAMIC_RULE_UPDATED, EventTypes::EVENT_EMERGENCY_MODE, EventTypes::EVENT_DYNAMIC_RULE_APPLIED], $types, 'updated, emergency_mode_activated, applied in order'); +$e = $h->agent->received; +$t->same(['dynamic_rules', 'system', 'rules_received', 'Received updated rules r9 v4'], [$e[0]->handlerName, $e[0]->ipAddress, $e[0]->actionTaken, $e[0]->reason], 'updated envelope'); +$t->same(['rule_id' => 'r9', 'version' => 4, 'previous_version' => 0], $e[0]->metadata, 'updated metadata'); +$t->same(['emergency_lockdown', '[EMERGENCY MODE] activated via dynamic rules'], [$e[1]->actionTaken, $e[1]->reason], 'emergency envelope'); +$t->same(['whitelist_count' => 12, 'whitelist' => array_slice(array_map(static fn (int $i): string => "10.0.0.{$i}", range(1, 12)), 0, 10)], $e[1]->metadata, 'emergency metadata'); +$t->same(['rule_id' => 'r9', 'version' => 4, 'ip_bans' => 1, 'country_blocks' => 0, 'emergency_mode' => true], $e[2]->metadata, 'applied metadata'); +$h->agent->received = []; +$h->push(rules(5, 'r9', [])); +$t->same(4, $h->agent->received[0]->metadata['previous_version'] ?? null, 'previous_version tracks the current rule'); + +// --------------------------------------------------------------------- +// DynamicRules parsing and snapshot +// --------------------------------------------------------------------- + +$t->section('rule model'); +$t->throws(\InvalidArgumentException::class, static fn () => rules(1, 'r1', ['auto_ban_threshold' => 0]), 'auto_ban_threshold below 1 is rejected (ge=1)'); +$t->throws(\InvalidArgumentException::class, static fn () => rules(1, 'r1', ['auto_ban_duration' => 0]), 'auto_ban_duration below 1 is rejected (ge=1)'); +$agentShaped = new class { + public string $ruleId = 'agent-1'; + + public int $version = 3; + + public \DateTimeImmutable $timestamp; + + public ?\DateTimeImmutable $expiresAt = null; + + public int $ipBanDuration = 90; + + public array $ipBlacklist = ['203.0.113.8']; + + public array $endpointRateLimits = ['/x' => [4, 30]]; + + public array $suspiciousPatterns = []; + + public bool $emergencyMode = false; + + public bool $emergencyWhitelistOnly = true; + + public ?string $message = 'ignored'; + + public function __construct() + { + $this->timestamp = new \DateTimeImmutable('2026-01-01T00:00:00+00:00'); + } +}; +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true), null, new IpBanManager([])); +$h->push($agentShaped); +$t->same('agent-1', $h->manager->currentRules()?->ruleId, 'a guard-agent-php DynamicRules object is accepted as delivered'); +$t->same(90, $h->manager->currentRules()?->ipBanDuration, 'its camelCase fields map across'); +$t->same(['/x' => ['limit' => 4, 'window' => 30]], $h->live->endpointRateLimits, 'its endpoint rate limit pairs apply'); +$timestampless = DynamicRules::fromObject(new class { + public string $ruleId = 'agent-2'; + + public int $version = 1; + + public ?\DateTimeImmutable $timestamp = null; +}); +$t->same('agent-2', $timestampless->ruleId, 'an agent rules object without a timestamp is stamped now'); +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true)); +$h->push(42); +$t->truthy($h->logged('error: Failed to update dynamic rules: the agent returned an unusable dynamic rules payload'), 'an unusable agent payload is logged, not applied'); +$t->same(null, $h->manager->currentRules(), 'and leaves no current rules'); +$snapshot = json_decode(rules(1, 'r1')->dumpSnapshot()); +$t->same('2026-01-01T00:00:00Z', $snapshot->rules->timestamp, 'the snapshot timestamp renders as the reference does (Z)'); +$t->same(true, $snapshot->rules->endpoint_rate_limits instanceof \stdClass, 'an empty endpoint_rate_limits map encodes as an object'); +$roundTrip = DynamicRules::loadSnapshot(rules(2, 'r1', ['endpoint_rate_limits' => ['/a' => [1, 60]]])->dumpSnapshot()); +$t->same(['/a' => [1, 60]], $roundTrip->endpointRateLimits, 'the snapshot round-trips'); + +// --------------------------------------------------------------------- +// SecurityConfig: with() round trip and the agent dependency rules +// --------------------------------------------------------------------- + +$t->section('config'); +$cfg = new SecurityConfig(enableAgent: true, enableDynamicRules: true, agentEnableEvents: false, agentEnableMetrics: false, dynamicRulesCachePath: '/tmp/x.json', dynamicRuleInterval: 120); +$copy = $cfg->with(['rate_limit' => 3]); +$t->same( + [true, true, false, false, '/tmp/x.json', 120], + [$copy->enableAgent, $copy->dynamicRulesEnabled, $copy->agentEnableEvents, $copy->agentEnableMetrics, $copy->dynamicRulesCachePath, $copy->dynamicRuleInterval], + 'with() keeps the agent and dynamic-rule fields' +); +$t->same(true, (new SecurityConfig(enableAgent: true))->enableAgent, 'enable_agent is stored'); +$t->same(false, (new SecurityConfig())->enableAgent, 'enable_agent defaults to false'); +$t->same(300, (new SecurityConfig())->dynamicRuleInterval, 'dynamic_rule_interval defaults to 300'); +$t->throws(\InvalidArgumentException::class, static fn () => new SecurityConfig(enableDynamicRules: true), 'enable_dynamic_rules requires enable_agent'); +$t->throws(\InvalidArgumentException::class, static fn () => new SecurityConfig(enableEnrichment: true), 'enable_enrichment requires enable_agent'); +$t->throws(\InvalidArgumentException::class, static fn () => new SecurityConfig(dynamicRuleInterval: 59), 'dynamic_rule_interval has a floor of 60'); +$h = new Harness(new SecurityConfig(enableAgent: true, enableDynamicRules: true)); +$h->push(rules(1, 'r1', ['global_rate_limit' => 3])); +$first = $h->live->revision(); +$h->push(rules(2, 'r1', ['global_rate_limit' => 4])); +$t->truthy($h->live->revision() > $first, 'each applied version carries a fresh revision so the pipeline rebuilds'); + +// --------------------------------------------------------------------- +// Engine wiring +// --------------------------------------------------------------------- + +$t->section('engine builds and drives the manager from config'); +$engineAgent = new Agent(); +$engineAgent->queue = [rules(1, 'e1', ['global_rate_limit' => 77, 'ip_blacklist' => ['203.0.113.9'], 'ip_ban_duration' => 600])]; +$clock = 1_800_000_000; +$engine = new GuardEngine( + new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true, dynamicRuleInterval: 120), + clock: static function () use (&$clock): int { + return $clock; + } +); +$engine->setAgentHandler($engineAgent); +$engine->initialize(); +$t->truthy($engine->dynamicRuleManager() !== null, 'the engine instantiated a DynamicRuleManager'); +$t->same(0, $engineAgent->fetches, 'no fetch before the first request'); +$engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$t->same(1, $engineAgent->fetches, 'the first request fetches (the loop updates immediately)'); +$t->same(77, $engine->config()->rateLimit, 'the rule is installed on the engine config'); +$t->same(true, $engine->config()->dynamicRulesEnabled, 'the installed config still has dynamic rules on'); +$t->same(true, $engine->banManager()->isIpBanned('203.0.113.9'), 'the rule ban reached the engine ban manager'); +$blocked = $engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '203.0.113.9')); +$t->same(403, $blocked?->statusCode(), 'the banned IP is blocked'); +$t->same(1, $engineAgent->fetches, 'requests inside the interval do not fetch'); +$clock += 119; +$engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$t->same(1, $engineAgent->fetches, 'still inside dynamic_rule_interval at +119s'); +$engineAgent->queue = [rules(2, 'e1', ['global_rate_limit' => 88])]; +$clock += 1; +$engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$t->same(2, $engineAgent->fetches, 'the interval elapsed: the next request fetches'); +$t->same(88, $engine->config()->rateLimit, 'v2 installed over the base'); + +$gate = new DynamicRuleManager(config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null); +$gate->maybeUpdate(); +$t->same(null, $gate->currentRules(), 'maybeUpdate without an agent handler is a no-op'); +$gate = new DynamicRuleManager(config: new SecurityConfig(), agentHandler: new Agent()); +$gate->maybeUpdate(); +$t->same(null, $gate->currentRules(), 'maybeUpdate with dynamic rules off is a no-op'); + +$t->section('engine logging routes through the engine log closure, else error_log'); +$engineLogs = []; +$logged = new GuardEngine( + new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true), + log: static function (string $level, string $message, array $context) use (&$engineLogs): void { + $engineLogs[] = $level . ': ' . $message; + } +); +$loggedAgent = new Agent(); +$loggedAgent->queue = [rules(1, 'l1', ['global_rate_limit' => 21])]; +$logged->setAgentHandler($loggedAgent); +$logged->initialize(); +$logged->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$t->truthy(in_array('info: Applying dynamic rules: l1 v1', $engineLogs, true), 'manager logs reach the engine log closure'); +$errorLog = sys_get_temp_dir() . '/gcphp-dynrules-errorlog-' . getmypid() . '.txt'; +$previousErrorLog = ini_set('error_log', $errorLog); +$bare = new GuardEngine(new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true)); +$bareAgent = new Agent(); +$bareAgent->queue = [rules(1, 'b1', ['blocked_cloud_providers' => ['Bogus']])]; +$bare->setAgentHandler($bareAgent); +$bare->initialize(); +$bare->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$noAgentEither = new GuardEngine(new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true)); +$noAgentEither->initialize(); +ini_set('error_log', (string) $previousErrorLog); +$errorLogText = (string) @file_get_contents($errorLog); +@unlink($errorLog); +$t->truthy(str_contains($errorLogText, 'dynamic rules warning: Dynamic rule: ignored unknown cloud providers'), 'without a log closure non-info manager logs go to error_log'); +$t->truthy(!str_contains($errorLogText, 'Applying dynamic rules'), 'info lines stay quiet'); +$t->truthy(str_contains($errorLogText, 'Dynamic rules enabled but agent unavailable'), 'without a warn closure the missing-agent warning goes to error_log'); + +$t->section('engine hydrates the last-known rules at initialize'); +$cache = sys_get_temp_dir() . '/gcphp-dynrules-engine-' . getmypid() . '.json'; +file_put_contents($cache, rules(5, 'h1', ['global_rate_limit' => 55])->dumpSnapshot()); +$hydrating = new GuardEngine(new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: $cache)); +$hydrating->setAgentHandler(new Agent()); +$hydrating->initialize(); +$t->same(55, $hydrating->config()->rateLimit, 'hydrated before the first request'); +@unlink($cache); + +$t->section('dynamic rules without an agent warn and stay on the static config'); +$warnings = []; +$noAgent = new GuardEngine( + new SecurityConfig(enableRedis: false, enableAgent: true, enableDynamicRules: true), + warn: static function (string $m) use (&$warnings): void { + $warnings[] = $m; + } +); +$noAgent->initialize(); +$t->same(null, $noAgent->dynamicRuleManager(), 'no manager without an agent handler'); +$t->truthy((bool) array_filter($warnings, static fn (string $m): bool => str_contains($m, 'Dynamic rules enabled but agent unavailable')), 'the reference warning is emitted'); +$off = new GuardEngine(new SecurityConfig(enableRedis: false)); +$off->setAgentHandler(new Agent()); +$off->initialize(); +$t->same(null, $off->dynamicRuleManager(), 'no manager when enable_dynamic_rules is off'); + +// --------------------------------------------------------------------- +// Custom response modifier +// --------------------------------------------------------------------- + +$t->section('custom_response_modifier'); +$calls = 0; +$modifier = static function (GuardResponse $response) use (&$calls): GuardResponse { + $calls++; + $response->headers()->set('X-Modified', 'yes'); + + return $response; +}; +$engine = new GuardEngine(new SecurityConfig(enableRedis: false, blacklist: ['203.0.113.50'], customResponseModifier: $modifier)); +$blocked = $engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '203.0.113.50')); +$t->same('yes', $blocked?->headers()->get('X-Modified'), 'a blocked response passes through the modifier'); +$t->same(1, $calls, 'applied exactly once'); +$passthrough = $engine->applyResponseModifier(new GuardResponse(200)); +$t->same('yes', $passthrough->headers()->get('X-Modified'), 'the pass-through seam applies the modifier'); + +$calls = 0; +$engine = new GuardEngine(new SecurityConfig( + enableRedis: false, + customRequestCheck: static fn ($request) => new GuardResponse(418), + customResponseModifier: $modifier +)); +$custom = $engine->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '198.51.100.20')); +$t->same(418, $custom?->statusCode(), 'the custom request check response is returned'); +$t->same(1, $calls, 'the custom request check response is modified exactly once'); + +$throwing = new GuardEngine(new SecurityConfig( + enableRedis: false, + blacklist: ['203.0.113.51'], + customResponseModifier: static function (GuardResponse $response): GuardResponse { + throw new \RuntimeException('boom'); + } +)); +$unmodified = $throwing->execute(new SimpleGuardRequest(urlPath: '/a', clientHost: '203.0.113.51')); +$t->same(403, $unmodified?->statusCode(), 'a throwing modifier returns the unmodified response'); + +echo "\ndynamic rules parity: {$t->passed} passed, {$t->failed} failed\n"; +exit($t->failed === 0 ? 0 : 1); diff --git a/bin/test_enrichment.php b/bin/test_enrichment.php index c78e1eb..dcb1277 100644 --- a/bin/test_enrichment.php +++ b/bin/test_enrichment.php @@ -316,7 +316,7 @@ public function healthCheck(): bool $engine = new GuardEngine($engineConfig); $sink = new CaptureSink(); $composite = AgentHandlerComposer::compose( - new SecurityConfig(enableEnrichment: true, otelServiceName: 'edge-svc'), + new SecurityConfig(enableAgent: true, enableEnrichment: true, otelServiceName: 'edge-svc'), agentHandler: $sink, behaviorTracker: new TrackerFake(3), clock: static fn (): float => 5_000.0 @@ -356,7 +356,7 @@ public function healthCheck(): bool $compositeOnlyAgent = AgentHandlerComposer::compose(new SecurityConfig(), agentHandler: $sink); $t->truthy($compositeOnlyAgent instanceof CompositeAgentHandler, 'composer returns a composite'); $full = AgentHandlerComposer::compose( - new SecurityConfig(enableEnrichment: true, enableOtel: true, enableLogfire: true), + new SecurityConfig(enableAgent: true, enableEnrichment: true, enableOtel: true, enableLogfire: true), agentHandler: $sink, logfireClient: new class { public function configured(): bool diff --git a/bin/test_event_bus.php b/bin/test_event_bus.php index 9a1f52a..2ab540c 100644 --- a/bin/test_event_bus.php +++ b/bin/test_event_bus.php @@ -282,7 +282,7 @@ function busRequest(string $path = '/pay?secret=1', string $method = 'POST'): Si // --------------------------------------------------------------------- $t->section('update flow gates'); -$config = new SecurityConfig(enableDynamicRules: true); +$config = new SecurityConfig(enableAgent: true, enableDynamicRules: true); $agent = new BusAgent(); $logs = []; $logger = static function (string $level, string $message) use (&$logs): void { @@ -359,7 +359,8 @@ public function getDynamicRules(): ?array 'rule_id' => 'rule-bad', 'version' => 2, 'timestamp' => '2026-01-01T00:00:00+00:00', - 'blocked_countries' => ['BR'], + 'emergency_mode' => true, + 'emergency_whitelist' => ['not-an-ip'], ]; $installed = []; $manager = new DynamicRuleManager( @@ -375,13 +376,13 @@ public function getDynamicRules(): ?array } ); $manager->updateRules(); -$t->truthy(count($installed) <= 1, 'at most the base snapshot was re-installed (never the candidate)'); -$t->truthy(count($installed) === 0 || $installed[count($installed) - 1]->blockedCountries === [], 'the currently installed config is the base (fail closed)'); +$t->same([], $installed, 'a candidate that fails validation is never installed (the base stays)'); $t->truthy($manager->currentRules() === null, 'failed application leaves no current rules'); $t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'Failed to apply dynamic rules')), 'the failure is logged'); $t->section('ip, country, cloud and ua rule application'); -$geoConfig = new SecurityConfig(enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); +$geoConfig = new SecurityConfig(enableAgent: true, enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); +$ipBan = new RenzoFranceschini\GuardCore\Ban\IpBanManager([]); $payloadAgent->rules = [ 'rule_id' => 'rule-full', 'version' => 3, @@ -403,10 +404,12 @@ public function getDynamicRules(): ?array logger: $logger, clock: static function () use (&$clock): int { return $clock; - } + }, + banManager: $ipBan ); $manager->updateRules(); -$t->truthy(in_array('10.0.0.9', $installed[0]->blacklist, true), 'ip blacklist merged'); +$t->truthy($ipBan->isIpBanned('10.0.0.9'), 'ip blacklist is a timed ban'); +$t->same([], $installed[0]->blacklist, 'ip blacklist is not merged into the static blacklist'); $t->truthy(in_array('BR', $installed[0]->blockedCountries, true), 'countries applied'); $t->truthy(in_array('AWS', $installed[0]->blockCloudProviders, true), 'cloud providers applied'); $t->truthy(in_array('[a-z]+bot', $installed[0]->blockedUserAgents, true), 'validated user-agent patterns applied'); @@ -434,7 +437,7 @@ public function getDynamicRules(): ?array ); $manager->updateRules(); $t->truthy(!isset($installed[0]) || $installed[0]->blockedUserAgents === [], 'the catastrophic pattern is rejected'); -$t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'Rejected blocked_user_agents pattern')), 'the rejection is warned'); +$t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'rejected blocked_user_agents patterns failing the ReDoS validator')), 'the rejection is warned'); $t->section('emergency mode halves the auto-ban threshold'); $payloadAgent->rules = [ @@ -463,7 +466,7 @@ public function getDynamicRules(): ?array $t->truthy($installed[0]->emergencyMode, 'emergency mode applied'); $t->same(['10.0.0.1', '10.0.0.2'], $installed[0]->emergencyWhitelist, 'emergency whitelist applied'); $t->same(5, $installed[0]->autoBanThreshold, 'auto-ban threshold halved'); -$t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'Emergency lockdown')), 'emergency lockdown logged'); +$t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, '[EMERGENCY MODE] ACTIVATED')), 'emergency lockdown logged'); $t->section('expiry restores the base config'); $clock += 700000000; @@ -523,7 +526,7 @@ public function getDynamicRules(): ?array ]; $installed = []; $manager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: $cachePath), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: $cachePath), agentHandler: $payloadAgent, redisHandler: $redis, eventBus: new EventBus(new BusAgent(), $config), @@ -542,7 +545,7 @@ public function getDynamicRules(): ?array $hydratedInstalled = []; $hydratingManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: $cachePath), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: $cachePath), agentHandler: null, redisHandler: $redis, eventBus: new EventBus(new BusAgent(), $config), @@ -568,7 +571,7 @@ public function getDynamicRules(): ?array ], ])); $hydratingManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: $redis, eventBus: new EventBus(new BusAgent(), $config), @@ -586,7 +589,7 @@ public function getDynamicRules(): ?array $t->section('no readable store keeps the base config'); $hydratingManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: null, eventBus: new EventBus(new BusAgent(), $config), @@ -616,7 +619,7 @@ public function getDynamicRules(): ?array 'blocked_cloud_providers' => ['AWS'], 'blocked_user_agents' => ['bot'], ]); -$configWithGeo = new SecurityConfig(enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); +$configWithGeo = new SecurityConfig(enableAgent: true, enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); $manager = new DynamicRuleManager(config: $configWithGeo, agentHandler: null, logger: $logger); $apply = new ReflectionMethod($manager, 'applyRules'); $apply->setAccessible(true); @@ -848,7 +851,7 @@ public function state(): object $t->section('hydration failure is logged, never raised'); $failInstalled = []; $failManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: $cachePath), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: $cachePath), agentHandler: null, redisHandler: $redis, eventBus: new EventBus(new BusAgent(), $config), @@ -864,7 +867,7 @@ public function state(): object $t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'Failed to hydrate last-known dynamic rules')), 'the hydration failure is logged'); $t->section('full rule field application'); -$geoConfig = new SecurityConfig(enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); +$geoConfig = new SecurityConfig(enableAgent: true, enableDynamicRules: true, geoIpDbPath: 'country.mmdb'); $payloadAgent->rules = [ 'rule_id' => 'rule-all', 'version' => 20, @@ -882,6 +885,8 @@ public function state(): object ]; $installed = []; $logs = []; +$fullBan = new RenzoFranceschini\GuardCore\Ban\IpBanManager([]); +$fullBan->ban('10.9.9.9', 600, 'seed'); $fullManager = new DynamicRuleManager( config: $geoConfig, agentHandler: $payloadAgent, @@ -892,14 +897,16 @@ public function state(): object logger: $logger, clock: static function () use (&$clock): int { return $clock; - } + }, + banManager: $fullBan ); $fullManager->updateRules(); $candidate = $installed[count($installed) - 1] ?? null; -$t->truthy($candidate !== null && in_array('10.9.9.9', $candidate->whitelist ?? [], true), 'ip whitelist merged'); +$t->same(false, $fullBan->isIpBanned('10.9.9.9'), 'ip whitelist unbans the IP'); +$t->same(null, $candidate->whitelist, 'ip whitelist is not merged into the static whitelist'); $t->truthy(in_array('DE', $candidate->whitelistCountries, true), 'whitelist countries applied'); -$t->same(120, $candidate->rateLimitWindow, 'global rate window applied'); -$t->truthy(($candidate->endpointRateLimits['/x']['limit'] ?? null) === 7, 'endpoint rate limits merged in the config format'); +$t->same(60, $candidate->rateLimitWindow, 'a global rate window without a global rate limit is ignored'); +$t->truthy(($candidate->endpointRateLimits['/x']['limit'] ?? null) === 7, 'endpoint rate limits applied in the config format'); $t->same(false, $candidate->enablePenetrationDetection, 'penetration toggle applied'); $t->same(false, $candidate->enableIpBanning, 'ip banning toggle applied'); $t->same(true, $candidate->enableRateLimiting, 'rate limiting toggle applied'); @@ -914,7 +921,7 @@ public function state(): object 'timestamp' => '2026-01-01T00:00:00+00:00', 'blocked_cloud_providers' => ['AWS'], ]; -$cloudyBase = new SecurityConfig(enableDynamicRules: true, blockCloudProviders: ['AWS']); +$cloudyBase = new SecurityConfig(enableAgent: true, enableDynamicRules: true, blockCloudProviders: ['AWS']); $dupInstalled = []; $dupManager = new DynamicRuleManager( config: $cloudyBase, @@ -956,7 +963,7 @@ public function state(): object $brokenRedis = new RedisHandler(true, 'guard_core_rules2:', connection: $fakeBroken); $brokenInstalled = []; $brokenManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: $brokenRedis, eventBus: new EventBus(new BusAgent(), $config), @@ -974,7 +981,7 @@ public function state(): object $fake10->seed('guard_core_rules3:dynamic_rules:last_known', ''); $emptyRedis = new RedisHandler(true, 'guard_core_rules3:', connection: $fake10); $emptyManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: $emptyRedis, eventBus: new EventBus(new BusAgent(), $config), @@ -991,7 +998,7 @@ public function state(): object $fakeMalformed->seed('guard_core_rules_malformed:dynamic_rules:last_known', 'this is not json'); $malformedRedis = new RedisHandler(true, 'guard_core_rules_malformed:', connection: $fakeMalformed); $badPayloadManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: $malformedRedis, eventBus: new EventBus(new BusAgent(), $config), @@ -1007,7 +1014,7 @@ public function state(): object $t->section('unreadable cache file and failed persistence'); $unreadable = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: '/proc/self/mem'), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: '/proc/self/mem'), agentHandler: null, redisHandler: null, eventBus: new EventBus(new BusAgent(), $config), @@ -1022,7 +1029,7 @@ public function state(): object $t->truthy((bool) array_filter($logs, static fn (string $l): bool => str_contains($l, 'Discarding unusable last-known dynamic rules payload')), 'an unreadable cache file yields a discarded empty payload with a log'); $persistFailLogs = []; $persistFailManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: '/dev/null/impossible.json'), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: '/dev/null/impossible.json'), agentHandler: null, redisHandler: $brokenRedis, eventBus: new EventBus(new BusAgent(), $config), @@ -1106,7 +1113,7 @@ public function state(): object $t->section('snapshot build failure keeps the old snapshot'); $persistFailManager = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: null, redisHandler: $brokenRedis, eventBus: new EventBus(new BusAgent(), $config), diff --git a/bin/test_m4.php b/bin/test_m4.php index 6e7898f..c766f95 100644 --- a/bin/test_m4.php +++ b/bin/test_m4.php @@ -314,7 +314,7 @@ function m4Pipeline(SecurityConfig $config, ?array $routeConfigs = null, ?CloudM $t->throws(\InvalidArgumentException::class, fn () => new SecurityConfig(blockCloudProviders: ['AWSX']), 'unknown provider name rejected'); $t->throws(\InvalidArgumentException::class, fn () => new SecurityConfig(blockCloudProviders: ['aws']), 'provider names case-sensitive'); $t->throws(\InvalidArgumentException::class, fn () => new SecurityConfig(blockCloudProviders: ['Foo:!region']), 'carve-out on unknown provider rejected'); -$dynamicConfig = new SecurityConfig(enableDynamicRules: true, dynamicRulesCachePath: '/tmp/dynrules.json'); +$dynamicConfig = new SecurityConfig(enableAgent: true, enableDynamicRules: true, dynamicRulesCachePath: '/tmp/dynrules.json'); $t->same(true, $dynamicConfig->dynamicRulesEnabled, 'dynamic rules un-gated (spec 12 surface)'); $t->same('/tmp/dynrules.json', $dynamicConfig->dynamicRulesCachePath, 'the last-known cache path is honored'); $t->same(true, (new SecurityConfig(blockCloudProviders: ['AWS']))->cloudBlockingEnabled(), 'cloudBlockingEnabled true with providers'); diff --git a/bin/test_pattern_registry.php b/bin/test_pattern_registry.php index 3cc498f..5383527 100644 --- a/bin/test_pattern_registry.php +++ b/bin/test_pattern_registry.php @@ -313,7 +313,7 @@ public function getKey(string $namespace, string $key): ?string // 10. Dynamic rule application // --------------------------------------------------------------------- $t->section('dynamic rule application'); -$ruleConfig = new SecurityConfig(enableDynamicRules: true); +$ruleConfig = new SecurityConfig(enableAgent: true, enableDynamicRules: true); $ruleAgent = new RegAgent(); $ruleLogs = []; $ruleRegistry = new SusPatterns(0.99, eventBus: new EventBus($ruleAgent, $ruleConfig)); @@ -355,7 +355,7 @@ public function getDynamicRules(): ?array $noRegistryLogs = []; $noRegistry = new DynamicRuleManager( - config: new SecurityConfig(enableDynamicRules: true), + config: new SecurityConfig(enableAgent: true, enableDynamicRules: true), agentHandler: $payloadAgent, logger: static function (string $level, string $message) use (&$noRegistryLogs): void { $noRegistryLogs[] = $level . ': ' . $message; diff --git a/conformance/guard-core-spec-4.1.0/php_events_xfail.json b/conformance/guard-core-spec-4.1.0/php_events_xfail.json index effcc1e..c27da82 100644 --- a/conformance/guard-core-spec-4.1.0/php_events_xfail.json +++ b/conformance/guard-core-spec-4.1.0/php_events_xfail.json @@ -25,8 +25,6 @@ "evt_rate_limited_global": "The PHP rate-limit block reason is the composed log format 'Rate limit exceeded for IP: (N requests in Ws window)' and the emit rides the middleware handler with check-hook metadata, led by the trailing security_headers_applied envelope; the reference pins a single rate_limited envelope with the bare 'Rate limit exceeded: ...' reason, a rate_limit handler and request_count metadata (got 2, want 1).", "evt_dynamic_rule_violation_endpoint": "The port emits the trailing security_headers_applied envelope ahead of rate_limited and has no dynamic_rule_violation middleware emit for endpoint-specific limits; the reference pins a single rate_limited envelope carrying the client IP and action request_blocked (envelope[0] got security_headers_applied, want rate_limited).", "evt_rate_limit_script_reloaded": "No envelope seam: the RateLimitHandler NOSCRIPT recovery reloads the script silently (src/RateLimit/RateLimitHandler.php) and its onScriptReloaded callback carries no envelope; the reference's rate_limit_script_reloaded agent event is not emitted; the runner's driver throws for the corpus rate_limit_script_reload call.", - "evt_dynamic_rules_update_apply": "DynamicRuleManager::sendRuleEvent emits through the middleware bus (handler middleware, method SYSTEM, non-null endpoint) while the reference pins the dynamic_rules handler envelope shape (reason 'Received updated rules v', null method and endpoint): envelope[0] reason got 'Dynamic rules corpus-rules v7' want 'Received updated rules corpus-rules v7'; the applied half of the pin matches.", - "evt_emergency_mode_activated": "The PHP DynamicRuleManager emits dynamic_rule_updated/applied but has no emergency_mode_activated emitter; the emergency lockdown path logs only, so the middle envelope of the reference's 3-envelope pin (dynamic_rule_updated, emergency_mode_activated, dynamic_rule_applied) is missing (got 2, want 3).", "evt_redis_connection_established": "No PHP seam: the port's RedisHandler has no manager-level agent surface; the reference RedisManager emits redis_connection from initialize; the runner's driver throws for the corpus redis_connect call.", "evt_redis_error_connection_failed": "No PHP seam: the port's RedisHandler has no manager-level agent surface; the reference RedisManager emits redis_error from a failed initialize; the runner's driver throws for the corpus redis_connect_error call.", "evt_route_unresolved_strict": "No PHP emitter: routeResolutionStrict parses into SecurityConfig but no engine code consumes it, so the bus carries only the headers-applied envelope where the reference pins a single route_unresolved envelope with the client IP and action request_blocked.", diff --git a/conformance/guard-core-spec-4.1.0/php_redis_interop_xfail.json b/conformance/guard-core-spec-4.1.0/php_redis_interop_xfail.json index 18dc149..3757283 100644 --- a/conformance/guard-core-spec-4.1.0/php_redis_interop_xfail.json +++ b/conformance/guard-core-spec-4.1.0/php_redis_interop_xfail.json @@ -7,7 +7,6 @@ "rio_security_headers_custom_headers": "No driver: the Redis-backed security-headers config cache is not ported (KNOWN_GAPS, security headers management; the PHP policy computes the map on demand), so no security_headers:custom_headers write exists.", "rio_custom_pattern_registry_write": "No driver: SusPatterns carries no Redis custom-pattern registry (no add_pattern store), so no patterns:custom write exists.", "rio_ipinfo_database_cache_write": "The port caches the fetched bytes verbatim (src/GeoIp/IpInfoManager.php initialize, setKey('ipinfo', 'database', body)), where the reference stores the spec 08 latin-1-decoded string, so non-ASCII database bytes are stored differently across engines.", - "rio_cloud_ranges_v2_write": "No driver: the PHP cloud refresh path is registry-bound (CloudProviderRegistry::bareProviderNames plus the static CloudFetchers::fetchProviderRanges map, src/Cloud/CloudManager.php refreshProvidersViaRedisHandler); the corpus's arbitrary 'corpus' provider with pinned ranges has no injection seam and fetchProviderRanges returns [] for unknown providers.", - "rio_dynamic_rules_snapshot_write": "The stored snapshot carries timestamp '2026-01-01T00:00:00+00:00' (DynamicRules::dumpSnapshot formats with DateTimeInterface::ATOM, src/Rules/DynamicRules.php) where the reference payload renders '2026-01-01T00:00:00Z'; the empty endpoint_rate_limits map also encodes as a JSON list instead of an object, which a parsed-object comparison cannot observe." + "rio_cloud_ranges_v2_write": "No driver: the PHP cloud refresh path is registry-bound (CloudProviderRegistry::bareProviderNames plus the static CloudFetchers::fetchProviderRanges map, src/Cloud/CloudManager.php refreshProvidersViaRedisHandler); the corpus's arbitrary 'corpus' provider with pinned ranges has no injection seam and fetchProviderRanges returns [] for unknown providers." } } diff --git a/docs/configuration.md b/docs/configuration.md index 0fc0d3d..c1ec94a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,13 +3,18 @@ `SecurityConfig` is built through a single constructor of named arguments (every field nullable with an engine default). Arguments belonging to features this port does not implement, when set to an enabling value, throw -`UnsupportedFeatureError` (fail closed). +`UnsupportedFeatureError` (fail closed). Geo blocking, CORS, agent telemetry, +dynamic rules and the response modifier are implemented and are not among them. ## Dynamic rules | Argument | Default | Notes | |---|---|---| -| `enableDynamicRules` | `false` | Lets the dashboard change protection at runtime without a code change. Needs an agent handler to deliver the rules. A rule adds the IPs in its `ip_blacklist` to the blacklist and the IPs in its `ip_whitelist` to the whitelist (`ip_ban_duration` is parsed but not applied, these are not timed bans), adds its `suspicious_patterns` to detection, appends to `block_cloud_providers` and `blocked_user_agents`, merges `endpoint_rate_limits`, and replaces `blocked_countries`, `whitelist_countries`, `rate_limit`, `rate_limit_window`, `enable_penetration_detection`, `enable_ip_banning`, `enable_rate_limiting`, `enable_rate_limit_auto_ban`, `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode` and `emergency_whitelist`. `emergency_mode` also halves the auto-ban threshold. A rule cannot change any other setting. | +| `enableAgent` | `false` | Declares that a guard-agent handler is attached (`GuardEngine::setAgentHandler()`); required by `enableDynamicRules` and `enableEnrichment`. The engine never builds the agent itself | +| `enableDynamicRules` | `false` | Requires `enableAgent`. Lets the dashboard change protection at runtime without a code change. A rule bans the IPs in its `ip_blacklist` for `ip_ban_duration` seconds, unbans the IPs in its `ip_whitelist`, adds its `suspicious_patterns` to detection, and replaces `blocked_countries`, `whitelist_countries`, `rate_limit`, `rate_limit_window`, `endpoint_rate_limits`, `block_cloud_providers`, `blocked_user_agents`, `enable_penetration_detection`, `enable_ip_banning`, `enable_rate_limiting`, `enable_rate_limit_auto_ban`, `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode` and `emergency_whitelist` (`emergency_mode` also halves the auto-ban threshold). A rule cannot change any other setting. Each new rule version is applied over the base config, never over the previous version | +| `dynamicRuleInterval` | `300` | Seconds between rule fetches, minimum `60`; checked on every request instead of a background loop | +| `dynamicRulesCachePath` | `null` | Optional atomic file copy of the last-known rules snapshot | +| `customResponseModifier` | `null` | `Closure(GuardResponse): GuardResponse` applied once to every response the engine returns and, through `GuardEngine::applyResponseModifier()`, to pass-through responses; an exception leaves the response unmodified | ## CORS diff --git a/docs/usage.md b/docs/usage.md index d8b4b94..e83e49c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -156,22 +156,51 @@ duck-typed `sendEvent(SecurityEvent)`) attaches via `setAgentHandler()`; adapters can also `drain()` the queue. Gating: `agent_enable_events`, `EventFilter` muted types. Send failures log and never raise. `MetricsCollector` mirrors this for `response_time` / `request_count` / -`error_rate` under `agent_enable_metrics`. With a wired `RedisHandler` -and no agent handler the collector persists the queue under -`{prefix}metrics:pending` (a JSON list) so metrics survive across -requests; attaching a handler or calling `drain()` flushes the backlog -in order and clears the key. Redis failures log and never raise. - -`DynamicRuleManager` ports the agent-synced dynamic rules: the update flow -(expiry, staleness gate, updated/applied events), transactional -application over the immutable config (`SecurityConfig::with()` builds the -full validated candidate; on any failure the previous config stays -installed - partial application never survives), last-known persistence -(Redis `dynamic_rules:last_known` plus an optional atomic file copy), one -shot hydration at startup, and the `match_event` correlation. With no -readable store the manager keeps the base config (fail closed). Enable it -with `enable_dynamic_rules` and hand the manager -`$engine->applyDynamicConfig(...)` as its `applyConfig` seam. +`error_rate` under `agent_enable_metrics`. The engine builds its collector +without a `RedisHandler`, so metrics are emitted only once an agent handler +with `sendMetric` is attached and nothing is queued or persisted before that. +A collector you construct yourself with a `RedisHandler` persists the queue +under `{prefix}metrics:pending` (a JSON list) while no handler is attached; +attaching a handler or calling `drain()` flushes the backlog in order and +clears the key. Redis failures log and never raise. + +`DynamicRuleManager` ports the agent-synced dynamic rules. The engine builds +and drives it from config: with `enableAgent` and `enableDynamicRules` set +and an agent handler attached through `setAgentHandler()` (it must expose +`getDynamicRules()`), `GuardEngine::initialize()` creates the manager, +hydrates the last-known rules, and `execute()` fetches new rules on the first +request and then every `dynamicRuleInterval` seconds (default 300, minimum +60). The reference runs a background loop for this; a PHP request process has +no background task, so the interval is checked per request instead, with the +same period (guard-agent-php drives its own rules refresh from `tick()` for +the same reason). Without an agent handler the engine warns and stays on the +static config. Under PHP-FPM each worker keeps its own interval clock, and +the last-known snapshot (Redis `dynamic_rules:last_known` or the cache file) +is what a fresh worker hydrates from. + +A rule is applied exactly as the reference does: `ip_blacklist` IPs are +banned for `ip_ban_duration` seconds and `ip_whitelist` IPs are unbanned +(through the engine's ban manager, not the static lists), `suspicious_patterns` +join detection unless already present, and `blocked_countries`, +`whitelist_countries`, `blocked_cloud_providers` (unknown providers dropped), +`blocked_user_agents` (ReDoS-validated), `endpoint_rate_limits`, the global +rate limit, the feature toggles and the emergency pair REPLACE the matching +config values. Country rules need a geo resolver, else they are skipped with +a warning. Emergency mode halves the auto-ban threshold in effect. + +Each new rule version is applied over the base config (the config before the +first active rule), never over the previous version, so halving and list +changes never compound. A failed application keeps the previously installed +config and re-throws, expiry restores the base, and the last-known snapshot +(Redis plus an optional atomic file copy) is hydrated once at startup. The +manager also exposes the `match_event` correlation. Rules arrive as the +agent's own `DynamicRules` object, an array or the core `DynamicRules` model. + +`customResponseModifier` is applied once to every response the engine +returns (blocks, redirects, failures), after the security headers and before +the CORS verdict headers; adapters call `GuardEngine::applyResponseModifier()` +for the pass-through responses they send. An exception leaves the response +unmodified. ## Conformance diff --git a/examples/advanced_app/README.md b/examples/advanced_app/README.md index eb09e20..6fdece6 100644 --- a/examples/advanced_app/README.md +++ b/examples/advanced_app/README.md @@ -96,8 +96,8 @@ curl -s -X POST http://localhost/admin/unban -H 'X-Admin-Token: admin-token-chan - `logRequestLevel` / `logSuspiciousLevel` - `onBlock` hook: the telemetry seam for [guard-agent-php](https://github.com/Guard-Core/guard-agent-php) wiring - (comment-level guidance in `src/config.php`; `enableAgent` is fail-closed - in this port, so the hook is the integration point) + (guidance in `src/config.php`; the agent itself is attached to the engine + with `setAgentHandler()` and declared with `enableAgent: true`) ## Environment variables diff --git a/examples/advanced_app/src/config.php b/examples/advanced_app/src/config.php index b91850c..9aec74e 100644 --- a/examples/advanced_app/src/config.php +++ b/examples/advanced_app/src/config.php @@ -88,14 +88,13 @@ function advanced_config(): SecurityConfig // Logging levels for request and suspicious-activity logs. logRequestLevel: getenv('LOG_REQUEST_LEVEL') ?: 'INFO', logSuspiciousLevel: getenv('LOG_SUSPICIOUS_LEVEL') ?: 'WARNING', - // Agent wiring (comment-level): guard-core-php's only telemetry seam - // is onBlock. The PHP agent (guard-agent-php, - // https://github.com/Guard-Core/guard-agent-php) mirrors the Python - // guard-agent's API: once its engine-event pipeline accepts these - // payloads, replace the log line below with the agent call and set - // GUARD_AGENT_* env vars here. enableAgent stays false because the - // engine fails config validation on it (the feature is not ported - // yet); do not turn it on. + // Agent wiring: the PHP agent (guard-agent-php, + // https://github.com/Guard-Core/guard-agent-php) is attached to the + // engine (GuardEngine::setAgentHandler(), which the adapters do from + // their agentHandler argument). Set enableAgent: true to declare it; + // enableDynamicRules and enableEnrichment require it. It stays off + // here because the example runs without an agent. onBlock below is + // the in-process hook for the same block events. onBlock: static function (object $request, array $payload): void { file_put_contents('php://stderr', sprintf( "guard blocked %s %s from %s via %s: %s\n", diff --git a/examples/simple_app/README.md b/examples/simple_app/README.md index c726e9c..152ca98 100644 --- a/examples/simple_app/README.md +++ b/examples/simple_app/README.md @@ -78,10 +78,9 @@ Inline comments in `index.php` walk through every knob used: - `excludePaths` for health and docs routes - Redis via `REDIS_HOST` / `REDIS_PORT` / `REDIS_PREFIX` (compose wires Redis in; without it the managers fall back to in-process state) -- The `onBlock` hook: the telemetry seam for wiring - [guard-agent-php](https://github.com/Guard-Core/guard-agent-php) (comment-level - guidance in `index.php`; agent integration is not implemented in the engine - port yet, and `enableAgent` fails config validation) +- The `onBlock` hook: the in-process hook for block events; for telemetry + attach [guard-agent-php](https://github.com/Guard-Core/guard-agent-php) with + `setAgentHandler()` and set `enableAgent: true` (guidance in `index.php`) ## Environment variables diff --git a/examples/simple_app/index.php b/examples/simple_app/index.php index 2d0c965..4f5ccea 100644 --- a/examples/simple_app/index.php +++ b/examples/simple_app/index.php @@ -73,12 +73,10 @@ function simple_engine(): GuardEngine excludePaths: [ '/docs', '/redoc', '/openapi.json', '/favicon.ico', '/static', '/health', ], - // onBlock is the telemetry seam of this port. Guard Agent integration - // is not implemented in guard-core-php yet (setting enableAgent fails - // config validation), so wire the agent from here: forward these - // payloads to guard-agent-php - // (https://github.com/Guard-Core/guard-agent-php) once its event pipeline - // accepts engine events. The payload carries check_name, reason, + // onBlock is the in-process hook for block events. For telemetry attach + // guard-agent-php (https://github.com/Guard-Core/guard-agent-php) to the + // engine with GuardEngine::setAgentHandler() and set enableAgent: true. + // The payload carries check_name, reason, // trigger_info, passive_mode, client_ip, path, method, and status_code. onBlock: static function (object $request, array $payload): void { file_put_contents('php://stderr', sprintf( diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php index 4e65580..e719fcc 100644 --- a/src/Config/SecurityConfig.php +++ b/src/Config/SecurityConfig.php @@ -175,9 +175,18 @@ final class SecurityConfig /** The spec 12 metrics gate (agent_enable_metrics, default true). */ public readonly bool $agentEnableMetrics; + /** Whether a guard-agent handler is expected (enable_agent, default false); the dynamic-rule and enrichment gates require it. */ + public readonly bool $enableAgent; + /** Whether agent-synced dynamic rules are active (enable_dynamic_rules). */ public readonly bool $dynamicRulesEnabled; + /** Seconds between dynamic rule fetches (dynamic_rule_interval, default 300, floor 60). */ + public readonly int $dynamicRuleInterval; + + /** The reference custom_response_modifier: applied to every response the engine returns. */ + public readonly ?\Closure $customResponseModifier; + /** The guard.* enrichment gate (enable_enrichment, default false). */ public readonly bool $enableEnrichment; @@ -355,7 +364,10 @@ final class SecurityConfig * @param list $logSensitiveParams * @param list $logSensitiveBodyFields * @param (\Closure(object, array): void)|null $onBlock - * @param bool|null $enableDynamicRules default false; lets the dashboard change protection at runtime without a code change, and needs an agent handler to deliver the rules: a rule adds the IPs in its ip_blacklist to the blacklist and the IPs in its ip_whitelist to the whitelist (ip_ban_duration is parsed but not applied, these are not timed bans), adds its suspicious_patterns to detection, appends to block_cloud_providers and blocked_user_agents, merges endpoint_rate_limits, and replaces blocked_countries, whitelist_countries, rate_limit, rate_limit_window, enable_penetration_detection, enable_ip_banning, enable_rate_limiting, enable_rate_limit_auto_ban, auto_ban_threshold, auto_ban_duration, emergency_mode and emergency_whitelist (emergency_mode also halves the auto-ban threshold); a rule cannot change any other setting + * @param bool|null $enableAgent default false; declares that a guard-agent handler is attached to the engine (GuardEngine::setAgentHandler) and is required by enable_dynamic_rules and enable_enrichment; the engine never builds the agent itself + * @param bool|null $enableDynamicRules default false; requires enable_agent; lets the dashboard change protection at runtime without a code change, and needs an agent handler to deliver the rules: a rule bans the IPs in its ip_blacklist (for ip_ban_duration seconds), unbans the IPs in its ip_whitelist, adds its suspicious_patterns to detection, and replaces blocked_countries, whitelist_countries, rate_limit, rate_limit_window, endpoint_rate_limits, block_cloud_providers, blocked_user_agents, enable_penetration_detection, enable_ip_banning, enable_rate_limiting, enable_rate_limit_auto_ban, auto_ban_threshold, auto_ban_duration, emergency_mode and emergency_whitelist (emergency_mode also halves the auto-ban threshold); a rule cannot change any other setting; each new rule version is applied over the base config, never over the previous version + * @param int|null $dynamicRuleInterval seconds between dynamic rule fetches (default 300, minimum 60); the engine checks it on every request instead of running a background loop + * @param (\Closure(\RenzoFranceschini\GuardCore\Request\GuardResponse): \RenzoFranceschini\GuardCore\Request\GuardResponse)|null $customResponseModifier applied once to every response the engine returns (blocks and redirects) and to pass-through responses through GuardEngine::applyResponseModifier; an exception leaves the response unmodified * @param list $emergencyWhitelist * @param array $customErrorResponses per-status-code body overrides * @param list $blockedUserAgents regex patterns, subject truncated to 512 chars @@ -469,7 +481,9 @@ public function __construct( ?bool $enableOtel = null, ?bool $enableLogfire = null, ?string $logfireServiceName = null, - ?string $logFormat = null + ?string $logFormat = null, + ?int $dynamicRuleInterval = null, + ?\Closure $customResponseModifier = null ) { $this->enableRedis = $enableRedis ?? true; $this->redisUrl = $redisUrl; @@ -673,7 +687,10 @@ public function __construct( } else { $this->whitelistCountries = []; $this->blockedCountries = []; - $this->geoIpHandler = null; + // An injected resolver is kept (the reference keeps geo_ip_handler + // whether or not a country list is set): dynamic country rules + // and event country enrichment use it. + $this->geoIpHandler = $geoIpHandler; } $this->geoIpDbPath = $geoIpDbPath; // The agent seam (spec 12) is the event bus and metrics collector: @@ -681,12 +698,30 @@ public function __construct( // to the duck-typed handler the adapter attaches. $this->agentEnableEvents = $agentEnableEvents ?? true; $this->agentEnableMetrics = $agentEnableMetrics ?? true; + $this->enableAgent = $enableAgent ?? false; $this->dynamicRulesEnabled = $enableDynamicRules ?? false; + $this->dynamicRuleInterval = $dynamicRuleInterval ?? 300; + if ($this->dynamicRuleInterval < 60) { + throw new \InvalidArgumentException('dynamic_rule_interval must be >= 60'); + } + $this->customResponseModifier = $customResponseModifier; $this->dynamicRulesCachePath = $dynamicRulesCachePath; // The enrichment + observability surface (the reference // enable_enrichment / enable_otel / enable_logfire family): the // guard.* metadata stamping gate and the two extra composite sinks. $this->enableEnrichment = $enableEnrichment ?? false; + // The reference validate_agent_config: both agent-gated tiers need + // enable_agent. (agent_api_key is not modelled: the PHP engine never + // builds the agent, the adapter injects an already-built handler.) + if ($this->dynamicRulesEnabled && !$this->enableAgent) { + throw new \InvalidArgumentException('enable_agent must be true when enable_dynamic_rules is true'); + } + if ($this->enableEnrichment && !$this->enableAgent) { + throw new \InvalidArgumentException( + 'enable_enrichment requires enable_agent=true; enrichment is the guard-agent-gated tier.' + . ' Either enable the agent or set enable_enrichment=false.' + ); + } $this->agentProjectId = $agentProjectId; $this->otelServiceName = $otelServiceName ?? 'guard-core'; $this->otelExporterEndpoint = $otelExporterEndpoint; @@ -923,6 +958,13 @@ private function constructorArgs(): array 'redisMaxConnections' => $this->redisMaxConnections, 'redisRetries' => $this->redisRetries, 'agentStrict' => $this->agentStrict, + 'enableAgent' => $this->enableAgent, + 'enableDynamicRules' => $this->dynamicRulesEnabled, + 'dynamicRuleInterval' => $this->dynamicRuleInterval, + 'customResponseModifier' => $this->customResponseModifier, + 'agentEnableEvents' => $this->agentEnableEvents, + 'agentEnableMetrics' => $this->agentEnableMetrics, + 'dynamicRulesCachePath' => $this->dynamicRulesCachePath, 'customRequestCheck' => $this->customRequestCheck, 'enableEnrichment' => $this->enableEnrichment, 'agentProjectId' => $this->agentProjectId, diff --git a/src/Engine/GuardEngine.php b/src/Engine/GuardEngine.php index b2c1ff6..588fe4e 100644 --- a/src/Engine/GuardEngine.php +++ b/src/Engine/GuardEngine.php @@ -31,6 +31,7 @@ use RenzoFranceschini\GuardCore\Request\GuardResponseFactory; use RenzoFranceschini\GuardCore\Redis\RedisHandler; use RenzoFranceschini\GuardCore\Routing\RouteResolver; +use RenzoFranceschini\GuardCore\Rules\DynamicRuleManager; use RenzoFranceschini\GuardCore\SecurityHeaders\SecurityHeadersPolicy; final class GuardEngine @@ -87,18 +88,33 @@ final class GuardEngine private readonly SecurityCheckPipeline $pipeline; + private ?DynamicRuleManager $dynamicRuleManager = null; + + private ?object $agentHandler = null; + + private ?\Closure $log; + + private bool $initialized = false; + + /** @var \Closure(): int */ + private readonly \Closure $clock; + /** * @param (\Closure(string, string, array): void)|null $log * @param (\Closure(string): void)|null $warn + * @param (\Closure(): int)|null $clock unix time source for the dynamic rule interval (tests) */ public function __construct( SecurityConfig $config, ?RedisHandler $redis = null, ?\Closure $log = null, ?\Closure $warn = null, - ?CloudManager $cloudManager = null + ?CloudManager $cloudManager = null, + ?\Closure $clock = null ) { $this->warn = $warn; + $this->log = $log; + $this->clock = $clock ?? static fn (): int => time(); $this->config = $config; $this->redis = $redis ?? new RedisHandler( enableRedis: $config->enableRedis, @@ -109,7 +125,7 @@ public function __construct( healthCheckInterval: $config->redisHealthCheckInterval, maxConnections: $config->redisMaxConnections ); - $this->responseFactory = new GuardResponseFactory(); + $this->responseFactory = new GuardResponseFactory($config->customResponseModifier); $this->eventBus = new EventBus(null, $config, countryResolver: static function (string $ip) use ($config): ?string { return $config->geoIpHandler?->getCountry($ip); }); @@ -244,7 +260,13 @@ public function setAgentHandler(object $agentHandler): void return; } + $this->agentHandler = $agentHandler; $this->eventBus->setAgentHandler($agentHandler); + // The dynamic rule manager needs the handler: wire it now when the + // engine is already initialized (initialize() wires it otherwise). + if ($this->initialized) { + $this->initializeDynamicRules(); + } // A handler without sendMetric keeps events only: the collector would // log a failure for every metric otherwise. if (is_callable([$agentHandler, 'sendMetric'])) { @@ -337,6 +359,18 @@ public function susPatterns(): SusPatterns return $this->susPatterns; } + /** + * The reference apply_modifier over config.custom_response_modifier: + * the engine applies it once to every response it returns (after the + * security headers, before the CORS verdict headers); adapters call it + * for the pass-through responses they send. An exception leaves the + * response unmodified. + */ + public function applyResponseModifier(GuardResponse $response): GuardResponse + { + return $this->responseFactory->applyModifierOnce($response); + } + public function responseFactory(): GuardResponseFactory { return $this->responseFactory; @@ -357,7 +391,7 @@ public function failClosedResponse(): GuardResponse $response = $this->createErrorResponse(500, 'Security check failed'); $this->applySecurityHeaders($response); - return $response; + return $this->applyResponseModifier($response); } /** @@ -478,7 +512,76 @@ private static function headersCacheKey(SecurityConfig $config, string $path): s return 'cfg_' . spl_object_id($config) . '_' . $base; } + /** + * Connects Redis, initializes the ban, rate-limit and cloud state, and + * (reference HandlerInitializer.initialize_dynamic_rule_manager) builds + * the dynamic rule manager from config when enable_dynamic_rules is on + * and an agent handler is attached, hydrating the last-known rules + * before the first request. + */ public function initialize(): void + { + try { + $this->initializeState(); + } finally { + $this->initialized = true; + $this->initializeDynamicRules(); + } + } + + /** + * The dynamic rule manager, once initialize() built it (enable_dynamic_rules + * on and an agent handler with getDynamicRules attached); null otherwise. + */ + public function dynamicRuleManager(): ?DynamicRuleManager + { + return $this->dynamicRuleManager; + } + + /** + * The reference initialize_dynamic_rule_manager: without an agent the + * engine warns and stays on the static config. The reference runs a + * background loop; a PHP request process has none, so execute() drives + * the manager through its dynamic_rule_interval gate instead. + */ + private function initializeDynamicRules(): void + { + if (!$this->config->dynamicRulesEnabled || $this->dynamicRuleManager !== null) { + return; + } + if ($this->agentHandler === null || !is_callable([$this->agentHandler, 'getDynamicRules'])) { + $message = 'Dynamic rules enabled but agent unavailable; falling back to static config.' + . ' Dashboard rule updates will not propagate until agent connectivity is restored.'; + if ($this->warn !== null) { + ($this->warn)($message); + } else { + error_log('[guard_core] ' . $message); + } + + return; + } + $log = $this->log; + $this->dynamicRuleManager = new DynamicRuleManager( + config: $this->config, + agentHandler: $this->agentHandler, + redisHandler: $this->redis->isEnabled() ? $this->redis : null, + eventBus: $this->eventBus, + applyConfig: fn (SecurityConfig $config) => $this->applyDynamicConfig($config), + logger: static function (string $level, string $message) use ($log): void { + if ($log !== null) { + $log($level, $message, []); + } elseif ($level !== 'info') { + error_log('[guard_core] dynamic rules ' . $level . ': ' . $message); + } + }, + clock: $this->clock, + susPatterns: $this->susPatterns, + banManager: $this->banManager + ); + $this->dynamicRuleManager->hydrateLastKnownRules(); + } + + private function initializeState(): void { $status = [ 'redis' => ['enabled' => $this->redis->isEnabled(), 'ok' => true, 'error' => null], @@ -546,6 +649,7 @@ public function initializationStatus(): array */ public function execute(GuardRequest $request): ?GuardResponse { + $this->dynamicRuleManager?->maybeUpdate(); $state = $request->state(); $state->pipelineStartedAt = hrtime(true) / 1e9; $cors = $this->corsPolicy; @@ -562,6 +666,7 @@ public function execute(GuardRequest $request): ?GuardResponse if ($clientIp === ClientIpResolver::UNKNOWN_CLIENT_IDENTITY && $this->config->failSecure) { $response = $this->unresolvableClientResponse($request); $this->applySecurityHeaders($response, $request); + $response = $this->applyResponseModifier($response); $cors?->injectResponseHeaders($response, $request->headers()); return $response; @@ -600,12 +705,14 @@ public function execute(GuardRequest $request): ?GuardResponse if ($cors === null) { if ($response !== null) { $this->applySecurityHeaders($response, $request); + $response = $this->applyResponseModifier($response); } return $response; } if ($response !== null) { $this->applySecurityHeaders($response, $request); + $response = $this->applyResponseModifier($response); $cors->injectResponseHeaders($response, $request->headers()); return $response; diff --git a/src/Request/GuardResponseFactory.php b/src/Request/GuardResponseFactory.php index ef2c849..a4ae884 100644 --- a/src/Request/GuardResponseFactory.php +++ b/src/Request/GuardResponseFactory.php @@ -6,8 +6,13 @@ final class GuardResponseFactory { + /** @var \WeakMap responses a modifier pass already produced */ + private \WeakMap $modified; + + /** @param (\Closure(mixed): mixed)|null $responseModifier the reference custom_response_modifier */ public function __construct(private readonly ?\Closure $responseModifier = null) { + $this->modified = new \WeakMap(); } /** @@ -35,15 +40,44 @@ public function createRedirectResponse(string $url, int $statusCode = 307): Guar return new GuardResponse($statusCode, $headers, null); } + /** + * The reference apply_modifier: the modifier's result replaces the + * response; an exception is logged and the unmodified response returned. + */ public function applyModifier(mixed $response): mixed { if ($this->responseModifier === null) { return $response; } try { - return ($this->responseModifier)($response); - } catch (\Throwable) { + $result = ($this->responseModifier)($response); + } catch (\Throwable $e) { + error_log('[guard_core] custom_response_modifier raised ' . $e::class . ': ' . $e->getMessage() + . '; returning unmodified response'); + + return $response; + } + if (is_object($result)) { + $this->modified[$result] = true; + } + + return $result; + } + + /** + * The engine's single modifier pass over a response it returns: a + * response a check already modified (the custom request check applies + * the modifier itself, as the reference does) is not modified twice, and + * a modifier that returns anything but a GuardResponse is treated as + * failed, keeping the original. + */ + public function applyModifierOnce(GuardResponse $response): GuardResponse + { + if ($this->responseModifier === null || isset($this->modified[$response])) { return $response; } + $result = $this->applyModifier($response); + + return $result instanceof GuardResponse ? $result : $response; } } diff --git a/src/Rules/DynamicRuleManager.php b/src/Rules/DynamicRuleManager.php index fcc7577..71133a0 100644 --- a/src/Rules/DynamicRuleManager.php +++ b/src/Rules/DynamicRuleManager.php @@ -4,6 +4,8 @@ namespace RenzoFranceschini\GuardCore\Rules; +use RenzoFranceschini\GuardCore\Ban\IpBanManager; +use RenzoFranceschini\GuardCore\Cloud\CloudProviderRegistry; use RenzoFranceschini\GuardCore\Config\SecurityConfig; use RenzoFranceschini\GuardCore\Detection\Redos\Prefilters; use RenzoFranceschini\GuardCore\Detection\Redos\ValidationCache; @@ -16,18 +18,30 @@ * The dynamic rule manager, ported from the reference * dynamic_rule_handler.py plus its application and snapshot mixins (spec * 12 "Dynamic rules"): the update flow (expiry, staleness gate, - * already-expired rejection, updated/applied events), the transactional - * application (config snapshot before, restore on any exception - partial - * application MUST NOT survive), the last-known persistence (Redis + * already-expired rejection, updated/applied events), the application of + * every rule field exactly as _dynamic_rule_application.py does (timed bans + * for ip_blacklist, unbans for ip_whitelist, replace semantics for the + * country, cloud, user-agent and endpoint rate limit lists, the emergency + * halving), the overlay (guard-core #169: every new rule version is applied + * over the BASE config, never over the previous version, so changes do not + * compound or accumulate), the last-known persistence (Redis * `dynamic_rules:last_known`, no TTL, plus an optional atomic file copy) * and the hydration, and match_event. * - * Application surface: the reference mutates a mutable config; the PHP - * config is immutable, so the manager builds the candidate config with - * SecurityConfig::with() (full validation runs there) and hands it to the - * $applyConfig callable only after the candidate constructed cleanly - - * when validation or application throws, the previous config stays - * installed and the exception propagates (fail closed). + * Application surface: the reference mutates a mutable config and keeps a + * deep-copied base snapshot; the PHP config is immutable, so the manager + * builds the candidate config with SecurityConfig::with() on the base (full + * validation runs there) and hands it to the $applyConfig callable. The + * base is the config as it was before the first active rule + * (activeBaseSnapshot); a failed application leaves the previously + * installed config in place (the previous version, or the base) and + * re-throws, expiry installs the base again. + * + * Update schedule: the reference runs a background loop calling + * update_rules every dynamic_rule_interval seconds, first at agent init. + * PHP request processes have no background task, so maybeUpdate() is the + * equivalent: called per request, it runs updateRules() when the interval + * has elapsed (and on the first call), with the same period. * * Redis fail-closed: the last-known store is Redis first, then the * optional file; when neither store is readable the manager refuses to @@ -41,21 +55,36 @@ final class DynamicRuleManager public const LAST_KNOWN_RULES_KEY = 'last_known'; + /** The reference _MAX_USER_AGENT_MATCH_LENGTH used by the user-agent rule validator. */ + private const MAX_USER_AGENT_MATCH_LENGTH = 512; + + private const HANDLER_NAME = 'dynamic_rules'; + private ?DynamicRules $currentRules = null; private float $lastUpdate = 0; private ?SecurityConfig $activeBaseSnapshot = null; + /** The config this manager last handed to $applyConfig (the live state). */ + private SecurityConfig $installed; + + /** Highest config revision installed so far; candidates always exceed it so the pipeline rebuilds. */ + private int $maxRevision; + private ?string $lastSkippedExpiredRule = null; private bool $hydratedLastKnownRules = false; + private ?int $nextPollAt = null; + /** @var \Closure(): int */ private \Closure $clock; + private ?ValidationCache $validationCache; + /** - * @param object|null $agentHandler duck-typed getDynamicRules(): array|null + * @param object|null $agentHandler duck-typed getDynamicRules(): DynamicRules|array|object|null * @param (\Closure(SecurityConfig): void)|null $applyConfig installs a * validated config (the engine swaps it and rebuilds the * pipeline); null records the rules without installing @@ -64,6 +93,11 @@ final class DynamicRuleManager * _apply_pattern_rules over the singleton); null leaves the * patterns unapplied with a warning * @param (\Closure(): int)|null $clock + * @param IpBanManager|null $banManager the engine's ban manager: + * ip_blacklist rules ban through it for ip_ban_duration seconds, + * ip_whitelist rules unban; null leaves the IP rules unapplied + * with a warning + * @param (\Closure(string, string): void)|null $logger (level, message) */ public function __construct( private readonly SecurityConfig $config, @@ -73,9 +107,12 @@ public function __construct( private readonly ?\Closure $applyConfig = null, private readonly ?\Closure $logger = null, ?\Closure $clock = null, - private readonly ?SusPatterns $susPatterns = null + private readonly ?SusPatterns $susPatterns = null, + private readonly ?IpBanManager $banManager = null ) { $this->clock = $clock ?? static fn (): int => time(); + $this->installed = $config; + $this->maxRevision = $config->revision(); // _apply_user_agent_rules builds its validator with the config's // disk-backed validation cache, so re-certifying user-agent // patterns across update passes reuses prior cost verdicts. @@ -84,7 +121,18 @@ public function __construct( : null; } - private ?ValidationCache $validationCache; + private function log(string $level, string $message): void + { + if ($this->logger !== null) { + ($this->logger)($level, $message); + } + } + + /** Python list repr, for log lines that mirror the reference text. */ + private static function pyList(array $items): string + { + return '[' . implode(', ', array_map(static fn (string $item): string => "'{$item}'", $items)) . ']'; + } public function currentRules(): ?DynamicRules { @@ -96,6 +144,33 @@ public function lastUpdate(): float return $this->lastUpdate; } + /** + * The config as it was before the first active rule (the reference + * _active_base_snapshot); null while no rule is active. + */ + public function activeBaseSnapshot(): ?SecurityConfig + { + return $this->activeBaseSnapshot; + } + + /** + * The per-request stand-in for the reference update loop: runs one + * update pass on the first call and then whenever dynamic_rule_interval + * seconds have elapsed since the previous pass. + */ + public function maybeUpdate(): void + { + if (!$this->config->dynamicRulesEnabled || $this->agentHandler === null) { + return; + } + $now = ($this->clock)(); + if ($this->nextPollAt !== null && $now < $this->nextPollAt) { + return; + } + $this->nextPollAt = $now + $this->config->dynamicRuleInterval; + $this->updateRules(); + } + /** * One update pass (the reference update_rules): expire, fetch, reject * already-expired payloads, gate on staleness, emit updated, apply @@ -114,7 +189,12 @@ public function updateRules(): void if ($payload === null || $payload === []) { return; } - $rules = $payload instanceof DynamicRules ? $payload : DynamicRules::fromArray($payload); + $rules = match (true) { + $payload instanceof DynamicRules => $payload, + is_array($payload) => DynamicRules::fromArray($payload), + is_object($payload) => DynamicRules::fromObject($payload), + default => throw new \InvalidArgumentException('the agent returned an unusable dynamic rules payload'), + }; if ($this->rejectIfAlreadyExpired($rules)) { return; @@ -123,24 +203,48 @@ public function updateRules(): void return; } - $this->sendRuleEvent(EventTypes::EVENT_DYNAMIC_RULE_UPDATED, $rules, 'rules_received'); - ($this->logger)('info', 'Applying dynamic rules: ' . $rules->ruleId . ' v' . $rules->version); + $this->eventBus?->sendHandlerEvent( + EventTypes::EVENT_DYNAMIC_RULE_UPDATED, + self::HANDLER_NAME, + 'system', + 'rules_received', + "Received updated rules {$rules->ruleId} v{$rules->version}", + [ + 'rule_id' => $rules->ruleId, + 'version' => $rules->version, + 'previous_version' => $this->currentRules?->version ?? 0, + ] + ); + $this->log('info', 'Applying dynamic rules: ' . $rules->ruleId . ' v' . $rules->version); $this->applyRules($rules); $this->currentRules = $rules; $this->lastUpdate = (float) ($this->clock)(); - $this->sendRuleEvent(EventTypes::EVENT_DYNAMIC_RULE_APPLIED, $rules, 'rules_updated'); + $this->eventBus?->sendHandlerEvent( + EventTypes::EVENT_DYNAMIC_RULE_APPLIED, + self::HANDLER_NAME, + 'system', + 'rules_updated', + "Applied dynamic rules {$rules->ruleId} v{$rules->version}", + [ + 'rule_id' => $rules->ruleId, + 'version' => $rules->version, + 'ip_bans' => count($rules->ipBlacklist), + 'country_blocks' => count($rules->blockedCountries), + 'emergency_mode' => $rules->emergencyMode, + ] + ); } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to update dynamic rules: ' . $e->getMessage()); + $this->log('error', 'Failed to update dynamic rules: ' . $e->getMessage()); } } /** - * Hydrates the last-known snapshot once before the update loop starts - * (Redis first, then the cache file); expired or unparseable payloads - * are skipped, and with no readable store the base config stays - - * stale rules are never resurrected. + * Hydrates the last-known snapshot once before the update schedule + * starts (Redis first, then the cache file); expired or unparseable + * payloads are skipped, and with no readable store the base config + * stays - stale rules are never resurrected. */ public function hydrateLastKnownRules(): void { @@ -156,10 +260,10 @@ public function hydrateLastKnownRules(): void $this->applyRules($rules); $this->currentRules = $rules; $this->lastUpdate = (float) ($this->clock)(); - ($this->logger)('info', 'Hydrated last-known dynamic rules ' . $rules->ruleId + $this->log('info', 'Hydrated last-known dynamic rules ' . $rules->ruleId . ' v' . $rules->version . ' before the update loop started'); } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to hydrate last-known dynamic rules: ' . $e->getMessage()); + $this->log('error', 'Failed to hydrate last-known dynamic rules: ' . $e->getMessage()); } } @@ -218,7 +322,7 @@ private function checkRuleExpiry(): void } $this->currentRules = null; $this->activeBaseSnapshot = null; - ($this->logger)('info', 'Dynamic rule ' . $rules->ruleId . ' v' . $rules->version + $this->log('info', 'Dynamic rule ' . $rules->ruleId . ' v' . $rules->version . ' expired; restored base config'); } @@ -234,7 +338,7 @@ private function rejectIfAlreadyExpired(DynamicRules $rules): bool $key = $rules->ruleId . '|' . $rules->version; if ($this->lastSkippedExpiredRule !== $key) { $this->lastSkippedExpiredRule = $key; - ($this->logger)('warning', 'Dynamic rule ' . $rules->ruleId . ' v' . $rules->version + $this->log('warning', 'Dynamic rule ' . $rules->ruleId . ' v' . $rules->version . ' already expired on receipt; ignoring'); } @@ -242,163 +346,261 @@ private function rejectIfAlreadyExpired(DynamicRules $rules): bool } /** - * The transactional application (_apply_rules): snapshot the live - * config, build the candidate (full validation), install, and on any - * failure restore the snapshot and re-throw - partial application - * never survives. The first successful rule's snapshot is retained as - * the active base so expiry restores pre-rule state across rules. + * The transactional application (_apply_rules): the candidate is built + * over the active base (the base config before the first active rule), + * never over the previously applied version. A failure before the + * install leaves the installed config untouched (the previous version, + * or the base) and re-throws; a failure of the install itself + * re-installs the previous config. The first successful application + * retains the base as the active base so expiry restores the pre-rule + * state across consecutive versions. */ private function applyRules(DynamicRules $rules): void { - $snapshot = $this->config; + $previous = $this->installed; + $base = $this->activeBaseSnapshot ?? $this->installed; + $installAttempted = false; try { - $candidate = $this->buildCandidateConfig($snapshot, $rules); + $this->applyIpRules($rules); + $candidate = $this->buildCandidateConfig($base, $rules); + $installAttempted = true; $this->installConfig($candidate); } catch (\Throwable $e) { - try { - $this->installConfig($snapshot); - } catch (\Throwable $restoreError) { - ($this->logger)('error', 'Failed to restore the base config: ' . $restoreError->getMessage()); + if ($installAttempted) { + try { + $this->installConfig($previous); + } catch (\Throwable $restoreError) { + $this->log('error', 'Failed to restore the previous config: ' . $restoreError->getMessage()); + } } - ($this->logger)('error', 'Failed to apply dynamic rules: ' . $e->getMessage()); + $this->log('error', 'Failed to apply dynamic rules: ' . $e->getMessage()); throw $e; } if ($this->currentRules === null && $this->activeBaseSnapshot === null) { - $this->activeBaseSnapshot = $snapshot; + $this->activeBaseSnapshot = $base; + } + if ($rules->emergencyMode && $this->eventBus !== null) { + $this->eventBus->sendHandlerEvent( + EventTypes::EVENT_EMERGENCY_MODE, + self::HANDLER_NAME, + 'system', + 'emergency_lockdown', + '[EMERGENCY MODE] activated via dynamic rules', + [ + 'whitelist_count' => count($rules->emergencyWhitelist), + 'whitelist' => array_slice($rules->emergencyWhitelist, 0, 10), + ] + ); } $this->persistLastKnownRules($rules); } + /** _apply_ip_rules: ip_blacklist bans for ip_ban_duration, ip_whitelist unbans. */ + private function applyIpRules(DynamicRules $rules): void + { + if ($rules->ipBlacklist === [] && $rules->ipWhitelist === []) { + return; + } + if ($this->banManager === null) { + $this->log('warning', 'Dynamic rule: ip_blacklist and ip_whitelist are skipped: no ban manager is attached to the dynamic rule manager'); + + return; + } + foreach ($rules->ipBlacklist as $ip) { + try { + if ($this->banManager->ban($ip, $rules->ipBanDuration, 'dynamic_rule')) { + $this->log('info', "Dynamic rule: Banned IP {$ip} for {$rules->ipBanDuration}s"); + } + } catch (\Throwable $e) { + $this->log('error', "Failed to ban IP {$ip}: {$e->getMessage()}"); + } + } + foreach ($rules->ipWhitelist as $ip) { + try { + $this->banManager->unban($ip); + $this->log('info', "Dynamic rule: Whitelisted IP {$ip}"); + } catch (\Throwable $e) { + $this->log('error', "Failed to whitelist IP {$ip}: {$e->getMessage()}"); + } + } + } + /** - * The field application (_apply_rules order): ip rules, blocking - * rules, rate limits, feature toggles, emergency mode. Unknown cloud - * providers and unvalidatable user-agent patterns are warned and - * dropped; suspicious patterns register through the engine's runtime - * pattern registry (warned and skipped only when no registry is - * attached). + * The config fields a rule changes (_apply_blocking_rules, + * _apply_rate_limit_rules, _apply_feature_toggles and + * _activate_emergency_mode, in that order), as with() values over the + * base. Country rules are skipped with a warning when no resolver is + * configured; unknown cloud providers and unvalidatable user-agent + * patterns are warned and dropped, and the surviving lists REPLACE the + * base values (an all-invalid list empties them, as in the reference); + * suspicious patterns register through the engine's runtime pattern + * registry (those already in the pool are not added again). * * @return array the with() values */ - private function ruleFields(DynamicRules $rules): array + private function ruleFields(SecurityConfig $base, DynamicRules $rules): array { $fields = []; - if ($rules->ipBlacklist !== []) { - $fields['blacklist'] = array_values(array_unique(array_merge($this->config->blacklist, $rules->ipBlacklist))); - } - if ($rules->ipWhitelist !== []) { - $fields['whitelist'] = array_values(array_unique(array_merge($this->config->whitelist ?? [], $rules->ipWhitelist))); - } - if ($rules->blockedCountries !== []) { - $fields['blocked_countries'] = $rules->blockedCountries; - } - if ($rules->whitelistCountries !== []) { - $fields['whitelist_countries'] = $rules->whitelistCountries; + if ($rules->blockedCountries !== [] || $rules->whitelistCountries !== []) { + if ($base->geoIpHandler === null && $base->geoIpDbPath === '') { + $this->log('warning', 'Dynamic rule: country rules cannot take effect (blocked=' + . self::pyList($rules->blockedCountries) . ', allowed=' . self::pyList($rules->whitelistCountries) + . '); no geo_ip_handler or geo_ip_db_path is configured to resolve IPs to countries'); + } else { + if ($rules->blockedCountries !== []) { + $blocked = array_values(array_unique(array_map(strtoupper(...), $rules->blockedCountries))); + $fields['blocked_countries'] = $blocked; + $sorted = $blocked; + sort($sorted); + $this->log('info', 'Dynamic rule: Blocked countries ' . self::pyList($sorted)); + } + if ($rules->whitelistCountries !== []) { + $allowed = array_values(array_unique(array_map(strtoupper(...), $rules->whitelistCountries))); + $fields['whitelist_countries'] = $allowed; + $sorted = $allowed; + sort($sorted); + $this->log('info', 'Dynamic rule: Whitelisted countries ' . self::pyList($sorted)); + } + } } if ($rules->blockedCloudProviders !== []) { - $selectors = []; + $valid = []; + $invalid = []; foreach ($rules->blockedCloudProviders as $provider) { - if (in_array($provider, $this->config->blockCloudProviders, true)) { - continue; + $bare = explode(':!', $provider, 2)[0]; + if (in_array($bare, CloudProviderRegistry::PROVIDERS, true)) { + $valid[] = $provider; + } else { + $invalid[] = $provider; } - $selectors[] = $provider; } - $fields['block_cloud_providers'] = array_values(array_merge($this->config->blockCloudProviders, $selectors)); + $fields['block_cloud_providers'] = $valid; + if ($invalid !== []) { + sort($invalid); + $this->log('warning', 'Dynamic rule: ignored unknown cloud providers ' . self::pyList($invalid)); + } + $this->log('info', 'Dynamic rule: Blocked cloud providers ' . self::pyList($valid)); } if ($rules->blockedUserAgents !== []) { - $validated = []; + $valid = []; + $rejected = []; foreach ($rules->blockedUserAgents as $pattern) { - [$safe, $reason] = Prefilters::validatePatternSafety($pattern, validationCache: $this->validationCache); + [$safe, $reason] = Prefilters::validatePatternSafety( + $pattern, + maxContentLength: self::MAX_USER_AGENT_MATCH_LENGTH, + validationCache: $this->validationCache + ); if ($safe) { - $validated[] = $pattern; + $valid[] = $pattern; } else { - ($this->logger)('warning', "Rejected blocked_user_agents pattern '{$pattern}': {$reason}"); + $rejected[] = '(' . SusPatterns::sanitizeForReporting($pattern) . ', ' . $reason . ')'; } } - if ($validated !== []) { - $fields['blocked_user_agents'] = array_values(array_merge($this->config->blockedUserAgents, $validated)); + $fields['blocked_user_agents'] = $valid; + if ($rejected !== []) { + $this->log('warning', 'Dynamic rule: rejected blocked_user_agents patterns failing the ReDoS validator: [' . implode(', ', $rejected) . ']'); } + $this->log('info', 'Dynamic rule: Blocked user agents ' . self::pyList(array_map(SusPatterns::sanitizeForReporting(...), $valid))); } - if ($rules->globalRateLimit !== null) { - $fields['rate_limit'] = $rules->globalRateLimit; - } - if ($rules->globalRateWindow !== null) { - $fields['rate_limit_window'] = $rules->globalRateWindow; + if ($rules->suspiciousPatterns !== []) { + $this->applyPatternRules($rules->suspiciousPatterns); } - if ($rules->endpointRateLimits !== []) { - // The rule wire format carries [requests, window] pairs; the - // config surface takes {limit, window} maps. - $converted = []; - foreach ($rules->endpointRateLimits as $endpoint => $pair) { - $converted[$endpoint] = ['limit' => $pair[0], 'window' => $pair[1]]; + if ($rules->globalRateLimit || $rules->endpointRateLimits !== []) { + if ($rules->globalRateLimit) { + $fields['rate_limit'] = $rules->globalRateLimit; + if ($rules->globalRateWindow) { + $fields['rate_limit_window'] = $rules->globalRateWindow; + } + $this->log('info', "Dynamic rule: Global rate limit {$rules->globalRateLimit} per " . ($rules->globalRateWindow ?? 'null') . 's'); + } + if ($rules->endpointRateLimits !== []) { + // The rule wire format carries [requests, window] pairs; the + // config surface takes {limit, window} maps. The rule's map + // replaces the base map. + $converted = []; + foreach ($rules->endpointRateLimits as $endpoint => $pair) { + $converted[$endpoint] = ['limit' => $pair[0], 'window' => $pair[1]]; + } + $fields['endpoint_rate_limits'] = $converted; + $this->log('info', 'Dynamic rule: Applied endpoint-specific rate limits for ' + . count($converted) . ' endpoints: ' . self::pyList(array_map(strval(...), array_keys($converted)))); } - $fields['endpoint_rate_limits'] = array_merge($this->config->endpointRateLimits, $converted); - } - if ($rules->enablePenetrationDetection !== null) { - $fields['enable_penetration_detection'] = $rules->enablePenetrationDetection; - } - if ($rules->enableIpBanning !== null) { - $fields['enable_ip_banning'] = $rules->enableIpBanning; - } - if ($rules->enableRateLimiting !== null) { - $fields['enable_rate_limiting'] = $rules->enableRateLimiting; - } - if ($rules->enableRateLimitAutoBan !== null) { - $fields['enable_rate_limit_auto_ban'] = $rules->enableRateLimitAutoBan; - } - if ($rules->autoBanThreshold !== null) { - $fields['auto_ban_threshold'] = $rules->autoBanThreshold; } - if ($rules->autoBanDuration !== null) { - $fields['auto_ban_duration'] = $rules->autoBanDuration; + $toggles = [ + 'enable_penetration_detection' => [$rules->enablePenetrationDetection, 'Penetration detection'], + 'enable_ip_banning' => [$rules->enableIpBanning, 'IP banning'], + 'enable_rate_limiting' => [$rules->enableRateLimiting, 'Rate limiting'], + 'enable_rate_limit_auto_ban' => [$rules->enableRateLimitAutoBan, 'Rate-limit auto-ban'], + 'auto_ban_threshold' => [$rules->autoBanThreshold, 'Auto-ban threshold'], + 'auto_ban_duration' => [$rules->autoBanDuration, 'Auto-ban duration'], + ]; + foreach ($toggles as $field => [$value, $label]) { + if ($value !== null) { + $fields[$field] = $value; + $this->log('info', "Dynamic rule: {$label} " . (is_bool($value) ? ($value ? 'True' : 'False') : (string) $value)); + } } if ($rules->emergencyMode) { + $this->log('critical', '[EMERGENCY MODE] ACTIVATED - Enhanced security posture enabled'); + $original = $fields['auto_ban_threshold'] ?? $base->autoBanThreshold; $fields['emergency_mode'] = true; $fields['emergency_whitelist'] = $rules->emergencyWhitelist; - // Emergency mode halves the auto-ban threshold. - if (!$rules->emergencyWhitelist || $rules->autoBanThreshold !== null) { - $fields['auto_ban_threshold'] = max(1, intdiv($rules->autoBanThreshold ?? $this->config->autoBanThreshold, 2)); - } - ($this->logger)('critical', 'Emergency lockdown mode activated with ' - . count($rules->emergencyWhitelist) . ' whitelisted entries' - . ($rules->emergencyWhitelist !== [] ? ': ' . implode(', ', array_slice($rules->emergencyWhitelist, 0, 10)) : '')); + $fields['auto_ban_threshold'] = max(1, intdiv($original, 2)); + $this->log('warning', "[EMERGENCY MODE] Reduced auto-ban threshold from {$original} to {$fields['auto_ban_threshold']}"); } - if ($rules->suspiciousPatterns !== []) { - // _apply_pattern_rules: every pattern goes through the - // detection engine's add_pattern gate (ReDoS-unsafe rows are - // rejected there, returning false); the applied list is - // reported at info, the rejected at warning. The registrations - // are runtime detection state, not config fields - they stay - // installed after rule expiry, exactly like the reference - // singleton pool. - if ($this->susPatterns === null) { - ($this->logger)('warning', 'suspicious_patterns from dynamic rules are skipped: no pattern registry is attached to the dynamic rule manager'); + + return $fields; + } + + /** + * _apply_pattern_rules: patterns already in the default pool are not + * added again, every other pattern goes through the detection engine's + * add_pattern gate (ReDoS-unsafe rows are rejected there, returning + * false); the applied list is reported at info, the rejected at + * warning. The registrations are runtime detection state, not config + * fields: they stay installed after rule expiry, exactly like the + * reference singleton pool. + * + * @param list $patterns + */ + private function applyPatternRules(array $patterns): void + { + if ($this->susPatterns === null) { + $this->log('warning', 'suspicious_patterns from dynamic rules are skipped: no pattern registry is attached to the dynamic rule manager'); + + return; + } + $known = array_flip($this->susPatterns->getDefaultPatterns()); + $pending = array_values(array_filter($patterns, static fn (string $p): bool => !isset($known[$p]))); + $added = []; + $rejected = []; + foreach ($pending as $pattern) { + if ($this->susPatterns->addPattern($pattern)) { + $added[] = $pattern; } else { - $added = []; - $rejected = []; - foreach ($rules->suspiciousPatterns as $pattern) { - if ($this->susPatterns->addPattern($pattern)) { - $added[] = $pattern; - } else { - $rejected[] = $pattern; - } - } - if ($added !== []) { - ($this->logger)('info', 'Dynamic rule: Added suspicious patterns ' . implode(', ', $added)); - } - if ($rejected !== []) { - ($this->logger)('warning', 'Dynamic rule: rejected patterns ' . implode(', ', $rejected)); - } + $rejected[] = $pattern; } } - - return $fields; + if ($added !== []) { + $this->log('info', 'Dynamic rule: Added suspicious patterns ' . implode(', ', $added)); + } + if ($rejected !== []) { + $this->log('warning', 'Dynamic rule: rejected patterns ' . implode(', ', $rejected)); + } } - private function buildCandidateConfig(SecurityConfig $snapshot, DynamicRules $rules): SecurityConfig + private function buildCandidateConfig(SecurityConfig $base, DynamicRules $rules): SecurityConfig { - $fields = $this->ruleFields($rules); + $fields = $this->ruleFields($base, $rules); + $candidate = $fields === [] ? $base : $base->with($fields); + // The pipeline rebuilds when the installed revision changes; with() + // always lands on base+1, so lift each candidate past every + // revision already installed. + while ($candidate !== $base && $candidate->revision() <= $this->maxRevision) { + $candidate = $candidate->with([]); + } - return $fields === [] ? $snapshot : $snapshot->with($fields); + return $candidate; } private function installConfig(SecurityConfig $config): void @@ -406,48 +608,8 @@ private function installConfig(SecurityConfig $config): void if ($this->applyConfig !== null) { ($this->applyConfig)($config); } - } - - private function sendRuleEvent(string $eventType, DynamicRules $rules, string $actionTaken): void - { - $this->eventBus?->sendMiddlewareEvent( - $eventType, - new class { - public function state(): object - { - return new class { - public ?string $clientIp = 'system'; - }; - } - - public function urlPath(): string - { - return ''; - } - - public function method(): string - { - return 'SYSTEM'; - } - - public function headers(): object - { - return new class { - public function get(string $name): ?string - { - return null; - } - }; - } - }, - $actionTaken, - "Dynamic rules {$rules->ruleId} v{$rules->version}", - [ - 'rule_id' => $rules->ruleId, - 'version' => $rules->version, - 'rule_type' => 'dynamic_rules', - ] - ); + $this->installed = $config; + $this->maxRevision = max($this->maxRevision, $config->revision()); } private function loadLastKnownRules(): ?DynamicRules @@ -461,12 +623,12 @@ private function loadLastKnownRules(): ?DynamicRules try { $rules = DynamicRules::loadSnapshot($payload); } catch (\Throwable $e) { - ($this->logger)('error', 'Discarding unusable last-known dynamic rules payload: ' . $e->getMessage()); + $this->log('error', 'Discarding unusable last-known dynamic rules payload: ' . $e->getMessage()); continue; } if ($this->snapshotExpired($rules)) { - ($this->logger)('error', 'Discarding expired last-known dynamic rules ' + $this->log('error', 'Discarding expired last-known dynamic rules ' . $rules->ruleId . ' v' . $rules->version . '; trying the next store'); continue; @@ -486,7 +648,7 @@ private function readRedisPayload(): ?string try { $raw = $this->redisHandler->getKey(self::DYNAMIC_RULES_REDIS_NAMESPACE, self::LAST_KNOWN_RULES_KEY); } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to read last-known dynamic rules from Redis: ' . $e->getMessage()); + $this->log('error', 'Failed to read last-known dynamic rules from Redis: ' . $e->getMessage()); return null; } @@ -515,7 +677,7 @@ private function persistLastKnownRules(DynamicRules $rules): void try { $payload = $rules->dumpSnapshot(); } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to build last-known dynamic rules snapshot: ' . $e->getMessage()); + $this->log('error', 'Failed to build last-known dynamic rules snapshot: ' . $e->getMessage()); return; } @@ -523,7 +685,7 @@ private function persistLastKnownRules(DynamicRules $rules): void try { $this->redisHandler->setKey(self::DYNAMIC_RULES_REDIS_NAMESPACE, self::LAST_KNOWN_RULES_KEY, $payload); } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to persist dynamic rules to Redis: ' . $e->getMessage()); + $this->log('error', 'Failed to persist dynamic rules to Redis: ' . $e->getMessage()); } } $cachePath = $this->config->dynamicRulesCachePath; @@ -537,7 +699,7 @@ private function persistLastKnownRules(DynamicRules $rules): void throw new \RuntimeException('atomic write failed'); } } catch (\Throwable $e) { - ($this->logger)('error', 'Failed to persist dynamic rules to cache file ' . $cachePath . ': ' . $e->getMessage()); + $this->log('error', 'Failed to persist dynamic rules to cache file ' . $cachePath . ': ' . $e->getMessage()); } } diff --git a/src/Rules/DynamicRules.php b/src/Rules/DynamicRules.php index 2b8d607..83153a5 100644 --- a/src/Rules/DynamicRules.php +++ b/src/Rules/DynamicRules.php @@ -67,19 +67,10 @@ public static function fromArray(array $payload): self throw new \InvalidArgumentException("dynamic rules require '{$required}'"); } } - $timestamp = \DateTimeImmutable::createFromInterface(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); - $parsedTimestamp = strtotime((string) $payload['timestamp']); - if ($parsedTimestamp === false) { - throw new \InvalidArgumentException('dynamic rules timestamp is unparseable'); - } - $timestamp = new \DateTimeImmutable('@' . $parsedTimestamp); + $timestamp = self::instant($payload['timestamp'], 'timestamp'); $expiresAt = null; if (isset($payload['expires_at']) && $payload['expires_at'] !== null) { - $parsedExpiry = strtotime((string) $payload['expires_at']); - if ($parsedExpiry === false) { - throw new \InvalidArgumentException('dynamic rules expires_at is unparseable'); - } - $expiresAt = new \DateTimeImmutable('@' . $parsedExpiry); + $expiresAt = self::instant($payload['expires_at'], 'expires_at'); } $endpointRateLimits = []; foreach ($payload['endpoint_rate_limits'] ?? [] as $endpoint => $pair) { @@ -103,20 +94,40 @@ public static function fromArray(array $payload): self globalRateLimit: isset($payload['global_rate_limit']) ? (int) $payload['global_rate_limit'] : null, globalRateWindow: isset($payload['global_rate_window']) ? (int) $payload['global_rate_window'] : null, endpointRateLimits: $endpointRateLimits, - blockedCloudProviders: self::stringList($payload['blocked_cloud_providers'] ?? []), + blockedCloudProviders: array_values(array_unique(self::stringList($payload['blocked_cloud_providers'] ?? []))), blockedUserAgents: self::stringList($payload['blocked_user_agents'] ?? []), suspiciousPatterns: self::stringList($payload['suspicious_patterns'] ?? []), enablePenetrationDetection: self::nullableBool($payload['enable_penetration_detection'] ?? null), enableIpBanning: self::nullableBool($payload['enable_ip_banning'] ?? null), enableRateLimiting: self::nullableBool($payload['enable_rate_limiting'] ?? null), - autoBanThreshold: isset($payload['auto_ban_threshold']) ? (int) $payload['auto_ban_threshold'] : null, - autoBanDuration: isset($payload['auto_ban_duration']) ? (int) $payload['auto_ban_duration'] : null, + autoBanThreshold: self::atLeastOne($payload['auto_ban_threshold'] ?? null, 'auto_ban_threshold'), + autoBanDuration: self::atLeastOne($payload['auto_ban_duration'] ?? null, 'auto_ban_duration'), enableRateLimitAutoBan: self::nullableBool($payload['enable_rate_limit_auto_ban'] ?? null), emergencyMode: (bool) ($payload['emergency_mode'] ?? false), emergencyWhitelist: self::stringList($payload['emergency_whitelist'] ?? []) ); } + /** + * Reads a rules object a guard-agent returns (guard-agent-php's + * Model\DynamicRules: public camelCase properties) through the same + * strict parse as an array payload. Properties the reference model does + * not carry (the agent's own extras) are ignored, like pydantic's + * default extra handling. + */ + public static function fromObject(object $rules): self + { + $payload = []; + foreach (get_object_vars($rules) as $name => $value) { + $payload[strtolower((string) preg_replace('/(? $this->ruleId, 'version' => $this->version, - 'timestamp' => $this->timestamp->format(\DateTimeInterface::ATOM), - 'expires_at' => $this->expiresAt?->format(\DateTimeInterface::ATOM), + 'timestamp' => self::utc($this->timestamp), + 'expires_at' => $this->expiresAt === null ? null : self::utc($this->expiresAt), 'ttl' => $this->ttl, 'ip_blacklist' => $this->ipBlacklist, 'ip_whitelist' => $this->ipWhitelist, @@ -136,7 +147,8 @@ public function dumpSnapshot(): string 'whitelist_countries' => $this->whitelistCountries, 'global_rate_limit' => $this->globalRateLimit, 'global_rate_window' => $this->globalRateWindow, - 'endpoint_rate_limits' => $this->endpointRateLimits, + // An empty map must encode as a JSON object (pydantic dumps a dict). + 'endpoint_rate_limits' => $this->endpointRateLimits === [] ? new \stdClass() : $this->endpointRateLimits, 'blocked_cloud_providers' => $this->blockedCloudProviders, 'blocked_user_agents' => $this->blockedUserAgents, 'suspicious_patterns' => $this->suspiciousPatterns, @@ -184,6 +196,40 @@ public static function loadSnapshot(string $payload): self return self::fromArray($decoded['rules']); } + /** A rule instant as a UTC instant: a DateTimeInterface or any strtotime-parseable string. */ + private static function instant(mixed $value, string $field): \DateTimeImmutable + { + if ($value instanceof \DateTimeInterface) { + return new \DateTimeImmutable('@' . $value->getTimestamp()); + } + $parsed = is_string($value) ? strtotime($value) : false; + if ($parsed === false) { + throw new \InvalidArgumentException("dynamic rules {$field} is unparseable"); + } + + return new \DateTimeImmutable('@' . $parsed); + } + + /** The reference JSON rendering of a UTC datetime (pydantic: trailing Z). */ + private static function utc(\DateTimeImmutable $instant): string + { + return $instant->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); + } + + /** An optional integer field with the reference ge=1 bound. */ + private static function atLeastOne(mixed $value, string $field): ?int + { + if ($value === null) { + return null; + } + $int = (int) $value; + if ($int < 1) { + throw new \InvalidArgumentException("dynamic rules {$field} must be >= 1"); + } + + return $int; + } + /** @return list */ private static function stringList(mixed $value): array {