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
4 changes: 2 additions & 2 deletions .github/coverage-unreachable.php
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,14 @@
219, 240, 241, 242, 243,
],

// 457-461: the ip_ban-side initialization catch. Every collaborator
// 481-485: the ip_ban-side initialization catch. Every collaborator
// inside the try fails open or swallows: RateLimitHandler::
// initializeRedis wraps scriptLoad in its own catch, initializeIpBan
// only assigns the manager, IpBanManager::initializeRedis migrates
// 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' => [
457, 458, 459, 460, 461,
481, 482, 483, 484, 485,
],
];
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ jobs:
REDIS_HOST: 127.0.0.1
run: php bin/test_events_parity.php

- name: test_decorators
run: php bin/test_decorators.php

conformance-pipeline:
name: Conformance pipeline (php ${{ matrix.php }})
runs-on: ubuntu-latest
Expand Down Expand Up @@ -240,7 +243,7 @@ jobs:
run: |
mkdir -p build
php .github/coverage-runner.php --collect bin/test_state.php build/coverage-test_state.php --integration
for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity; do
for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators; do
php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php"
done

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
RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php

.PHONY: install test lint bump-version clean

Expand Down
665 changes: 665 additions & 0 deletions bin/test_decorators.php

Large diffs are not rendered by default.

34 changes: 25 additions & 9 deletions src/Behavior/BehaviorRuleValidation.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,31 @@ public static function validateRulesAgainstScanFlag(array $rules, bool $scanResp
if ($rule->ruleType !== 'return_pattern' || $rule->pattern === null || $rule->pattern === '') {
continue;
}
if (!$rule->requiresResponseBody() || $scanResponseBody) {
continue;
}
throw new \InvalidArgumentException(
"{$fieldName}: return_pattern rule with pattern '{$rule->pattern}' requires reading the "
. 'response body, but behavior_scan_response_body is False. This rule '
. 'would never match: set behavior_scan_response_body=True to enable '
. 'response-body inspection, or use a status: pattern instead.'
);
self::validateReturnPatternAgainstScanFlag($rule->pattern, $scanResponseBody, $fieldName);
}
}

/**
* Mirrors _validate_return_pattern_requires_scan for a single pattern:
* the decorator family's _validate_return_pattern_body_scan path, where
* the pattern is validated at decoration time before any rule exists.
*/
public static function validateReturnPatternAgainstScanFlag(string $pattern, bool $scanResponseBody, string $fieldName): void
{
if ($pattern === '') {
return;
}
// The status: convention lives on BehaviorRule::requiresResponseBody;
// reuse it through a throwaway rule so it stays in one place.
$requiresBody = (new BehaviorRule(ruleType: 'return_pattern', threshold: 1, pattern: $pattern))->requiresResponseBody();
if (!$requiresBody || $scanResponseBody) {
return;
}
throw new \InvalidArgumentException(
"{$fieldName}: return_pattern rule with pattern '{$pattern}' requires reading the "
. 'response body, but behavior_scan_response_body is False. This rule '
. 'would never match: set behavior_scan_response_body=True to enable '
. 'response-body inspection, or use a status: pattern instead.'
);
}
}
56 changes: 56 additions & 0 deletions src/Decorators/DecoratedEndpoint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace RenzoFranceschini\GuardCore\Decorators;

/**
* The decorated endpoint, the port of the reference's _guard_route_id-stamped
* function (guard_core/decorators/base.py DecoratedFunction): a PHP endpoint
* (any callable, or a route pattern string for the path-pattern adapters)
* carrying the route identity the decorator family assigned it. Adapters
* stamp the id onto the request state (RequestState::$guardRouteId) after
* their router matches the endpoint, mirroring the reference middleware's
* `request.state.guard_route_id = ep._guard_route_id`.
*/
final class DecoratedEndpoint
{
/**
* @param mixed $endpoint the wrapped endpoint: a PHP callable (Closure,
* "Class::method" string, [object, method] array) or a route pattern
* string ("METHOD /path" or bare path, the adapter route key)
*/
public function __construct(
private readonly mixed $endpoint,
public readonly string $guardRouteId
) {
}

/** The wrapped endpoint value. */
public function endpoint(): mixed
{
return $this->endpoint;
}

/** True when the wrapped endpoint is invokable through this shell. */
public function isCallable(): bool
{
return is_callable($this->endpoint);
}

/**
* Invokes the wrapped endpoint (the decorated function's __call__). A
* route-pattern shell (not a callable) throws the same way calling a
* non-endpoint string would.
*/
public function __invoke(mixed ...$args): mixed
{
if (!is_callable($this->endpoint)) {
throw new \LogicException(
"decorated endpoint '{$this->guardRouteId}' is a route pattern, not a callable"
);
}

return ($this->endpoint)(...$args);
}
}
23 changes: 23 additions & 0 deletions src/Decorators/RouteConfigRevision.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace RenzoFranceschini\GuardCore\Decorators;

/**
* The shared revision cell of the decorator family, ported from the
* reference RouteConfigRevision (guard_core/decorators/route_config.py):
* one cell is shared by every RouteConfig a SecurityDecorator hands out and
* bumped on route registration and on every decoration mutation, so the
* engine's pipeline staleness seam can rebuild around decorator-driven
* config changes exactly like the reference's revision-gated rebuild.
*/
final class RouteConfigRevision
{
public int $value = 0;

public function bump(): void
{
$this->value++;
}
}
Loading
Loading