Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/coverage-unreachable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -271,14 +271,14 @@
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
// legacy keys under its own catch, and CloudManager::initializeRedis
// 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,
],
];
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ 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

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).
Expand All @@ -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
Expand All @@ -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 |
| --- | --- |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion bin/conformance_events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $rules */
public function __construct(private array $rules)
Expand Down
2 changes: 1 addition & 1 deletion bin/conformance_redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $rules */
public function __construct(private array $rules)
Expand Down
2 changes: 1 addition & 1 deletion bin/test_composite_handlers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
Expand Down
Loading
Loading