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 {