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 (1091) and
// validateExclusionSet (1115) sit behind typed ?array constructor
// The bare-string guards inside validateSensitiveSet (1105) and
// validateExclusionSet (1129) 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' => [
1091, 1115,
1105, 1129,
],

// 219: the non-array/non-scalar json_decode guard - json_decode can
Expand All @@ -271,14 +271,14 @@
219, 240, 241, 242, 243,
],

// 481-485: the ip_ban-side initialization catch. Every collaborator
// 518-522: 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' => [
481, 482, 483, 484, 485,
518, 519, 520, 521, 522,
],
];
12 changes: 12 additions & 0 deletions bin/test_config_fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -473,5 +473,17 @@ public function getDynamicRules(): ?array
$standaloneChecks = $standaloneFactory->buildChecks(new SecurityConfig(detectionPatternValidationCachePath: $tmpPath, blockCloudProviders: ['AWS']));
$t->truthy($standaloneChecks !== [], 'a standalone CheckFactory builds the cache for its fallback detection engine');

// enable_dynamic_rules documents exactly what a rule changes (guard-core #165).
$ctorDoc = (string) (new ReflectionMethod(SecurityConfig::class, '__construct'))->getDocComment();
$dynamicDoc = '';
if (preg_match('/@param bool\|null \$enableDynamicRules(.*?)(?=\n\s*\* @param|\n\s*\*\/)/s', $ctorDoc, $m)) {
$dynamicDoc = $m[1];
}
$t->truthy($dynamicDoc !== '', 'enableDynamicRules has a @param doc line');
foreach (['ip_blacklist', 'ip_whitelist', 'suspicious_patterns', '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', 'emergency_whitelist'] as $ruleField) {
$t->truthy(str_contains($dynamicDoc, $ruleField), "enableDynamicRules doc lists {$ruleField}");
}
$t->truthy(str_contains($dynamicDoc, 'cannot change any other setting'), 'enableDynamicRules doc states the closed set');

$t->same(0, $t->failed, 'no failures above');
exit($t->finish('config fields'));
3 changes: 3 additions & 0 deletions bin/test_decorators.php
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ public function __invoke(): string
$t->same(['sqli'], $rc->enabledDetectionCategories, 'detection_exclusion categories');
$t->same(false, $rc->detectionScanBody, 'detection_exclusion scan body');
$g = dtGuard();
$g->detectionExclusion(semanticHeaders: ['X-Session'])->decorate('/dex-sem');
$t->same(['X-Session'], $g->getRouteConfig('/dex-sem')->excludedSemanticDetectionHeaders, 'detection_exclusion semantic headers');
$g = dtGuard();
$g->detectionExclusion(params: ['only-one'])->decorate('/dex-partial');
$rc = $g->getRouteConfig('/dex-partial');
$t->same(['only-one'], $rc->excludedDetectionParams, 'detection_exclusion partial application');
Expand Down
62 changes: 62 additions & 0 deletions bin/test_event_bus.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use RenzoFranceschini\GuardCore\Events\SecurityMetric;
use RenzoFranceschini\GuardCore\Pipeline\SecurityCheckPipeline;
use RenzoFranceschini\GuardCore\RateLimit\RateLimitHandler;
use RenzoFranceschini\GuardCore\Request\GuardResponse;
use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest;
use RenzoFranceschini\GuardCore\Rules\DynamicRuleManager;
use RenzoFranceschini\GuardCore\Rules\DynamicRules;
Expand Down Expand Up @@ -1122,4 +1123,65 @@ public function state(): object
$persistApply->invoke($persistFailManager, new DynamicRules("\xB1\xB1", 1, new DateTimeImmutable()));
$t->truthy((bool) array_filter($persistFailLogs, static fn (string $l): bool => str_contains($l, 'Failed to build last-known dynamic rules snapshot')), 'the snapshot build failure is logged');

// ---------------------------------------------------------------------
// 9. Engine per-request metrics on the response path
// ---------------------------------------------------------------------

$t->section('engine processResponse emits the request metrics');
$metricsAgent = new BusAgent();
$metricsEngine = new GuardEngine(new SecurityConfig(enableRedis: false));
$metricsEngine->setAgentHandler($metricsAgent);
$metricsRequest = busRequest('/orders?token=1', 'GET');
$t->same(null, $metricsEngine->execute($metricsRequest), 'the request passes the pipeline');
usleep(60000);
$metricsEngine->processResponse($metricsRequest, new GuardResponse(200));
$t->same([EventTypes::METRIC_RESPONSE_TIME, EventTypes::METRIC_REQUEST_COUNT], array_map(static fn (SecurityMetric $m): string => $m->metricType, $metricsAgent->metrics), 'a 2xx response emits response_time and request_count');
$t->truthy($metricsAgent->metrics[0]->value >= 0.05 && $metricsAgent->metrics[0]->value < 5.0, 'response_time is measured from the pipeline start');
$t->same('200', $metricsAgent->metrics[0]->tags['status'], 'response_time carries the status tag');
$t->same('GET', $metricsAgent->metrics[1]->tags['method'], 'request_count carries the method tag');
$metricsAgent->metrics = [];
$metricsEngine->processResponse($metricsRequest, new GuardResponse(503));
$t->same([EventTypes::METRIC_RESPONSE_TIME, EventTypes::METRIC_REQUEST_COUNT, EventTypes::METRIC_ERROR_RATE], array_map(static fn (SecurityMetric $m): string => $m->metricType, $metricsAgent->metrics), 'a 5xx response adds error_rate');
$metricsAgent->metrics = [];
$metricsEngine->processResponse($metricsRequest, null);
$t->same([], $metricsAgent->metrics, 'no response, no metrics');

$offAgent = new BusAgent();
$offEngine = new GuardEngine(new SecurityConfig(enableRedis: false, agentEnableMetrics: false));
$offEngine->setAgentHandler($offAgent);
$offRequest = busRequest('/orders', 'GET');
$offEngine->execute($offRequest);
$offEngine->processResponse($offRequest, new GuardResponse(200));
$t->same([], $offAgent->metrics, 'agentEnableMetrics false emits nothing');

$mutedAgent = new BusAgent();
$mutedEngine = new GuardEngine(new SecurityConfig(enableRedis: false, mutedMetricTypes: [EventTypes::METRIC_REQUEST_COUNT]));
$mutedEngine->setAgentHandler($mutedAgent);
$mutedRequest = busRequest('/orders', 'GET');
$mutedEngine->execute($mutedRequest);
$mutedEngine->processResponse($mutedRequest, new GuardResponse(200));
$t->same([EventTypes::METRIC_RESPONSE_TIME], array_map(static fn (SecurityMetric $m): string => $m->metricType, $mutedAgent->metrics), 'muted metric types are not emitted');

$bareEngine = new GuardEngine(new SecurityConfig(enableRedis: false));
$bareRequest = busRequest('/orders', 'GET');
$bareEngine->execute($bareRequest);
$bareEngine->processResponse($bareRequest, new GuardResponse(200));
$t->same([], $bareEngine->metricsCollector()->drain(), 'with no agent handler nothing queues');

$legacyAgent = new class {
/** @var list<object> */
public array $received = [];

public function sendEvent(object $event): void
{
$this->received[] = $event;
}
};
$legacyEngine = new GuardEngine(new SecurityConfig(enableRedis: false));
$legacyEngine->setAgentHandler($legacyAgent);
$legacyRequest = busRequest('/orders', 'GET');
$legacyEngine->execute($legacyRequest);
$legacyEngine->processResponse($legacyRequest, new GuardResponse(200));
$t->same([], $legacyEngine->metricsCollector()->drain(), 'a handler without sendMetric gets no metrics and nothing queues');

exit($t->finish('EVENT BUS'));
63 changes: 63 additions & 0 deletions bin/test_excluded_headers.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use RenzoFranceschini\GuardCore\Request\GuardResponse;
use RenzoFranceschini\GuardCore\Request\GuardResponseFactory;
use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest;
use RenzoFranceschini\GuardCore\Routing\RouteConfig;
use RenzoFranceschini\GuardCore\Routing\RouteResolver;

require __DIR__ . '/../vendor/autoload.php';
Expand Down Expand Up @@ -169,5 +170,67 @@ function blockedHeaders(SuspiciousActivityCheck $check, array $headers): bool
$t->same(true, blockedHeaders(makeCheck(), ['x-plain-header' => SCRIPT]), 'header attack blocks with no exclusions');
$t->same(false, blockedHeaders(makeCheck(), ['user-agent' => 'Mozilla/5.0', 'accept' => 'application/json']), 'benign proxy identity values do not block');

$t->section('opt-in semantic-only header exclusion (excluded_semantic_detection_headers)');
// A detection threshold of 0.1 makes the benign value below a semantic-only
// hit (no regex pattern matches it), the shape of a session cookie scoring
// near the production threshold.
const SEMANTIC_ONLY = 'theme=system';

/** @param array<string, string> $headers */
function blockedHeadersWithRoute(SuspiciousActivityCheck $check, array $headers, ?RouteConfig $route): bool
{
$request = new SimpleGuardRequest(urlPath: '/items', method: 'POST', headers: $headers);
$request->state()->clientIp = '9.9.9.9';
$request->state()->routeConfig = $route;

return $check->check($request) instanceof GuardResponse;
}

$lowThreshold = new SecurityConfig(detectionSemanticThreshold: 0.1);
$t->same([], array_keys($lowThreshold->excludedSemanticDetectionHeaders), 'excludedSemanticDetectionHeaders defaults empty');
$t->same(true, blockedHeaders(makeCheck($lowThreshold), ['x-session' => SEMANTIC_ONLY]), 'with no opt-in a semantic-only header value blocks');

$semanticExcluded = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: [' X-Session ']);
$t->same(false, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => SEMANTIC_ONLY]), 'a semantic-excluded header (any case, trimmed) skips the semantic analyzer');
$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-other' => SEMANTIC_ONLY]), 'headers outside the set keep the semantic analyzer');
$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => SCRIPT]), 'regex scanning of a semantic-excluded header is unchanged (xss)');
$t->same(true, blockedHeaders(makeCheck($semanticExcluded), ['x-session' => JNDI]), 'regex scanning of a semantic-excluded header is unchanged (jndi)');

$routeSet = new RouteConfig(excludedSemanticDetectionHeaders: ['X-Route']);
$t->same(false, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SEMANTIC_ONLY], $routeSet), 'a route-level semantic exclusion skips the semantic analyzer');
$t->same(true, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SEMANTIC_ONLY], null), 'without the route set the same header blocks');
$t->same(true, blockedHeadersWithRoute(makeCheck($lowThreshold), ['x-route' => SCRIPT], $routeSet), 'a route-level semantic exclusion keeps the regex scan');
$mergedGlobal = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: ['x-global']);
$t->same(false, blockedHeadersWithRoute(makeCheck($mergedGlobal), ['x-global' => SEMANTIC_ONLY], $routeSet), 'the route set merges with the global set (global entry)');
$t->same(false, blockedHeadersWithRoute(makeCheck($mergedGlobal), ['x-route' => SEMANTIC_ONLY], $routeSet), 'the route set merges with the global set (route entry)');
$t->same(null, (new RouteConfig())->excludedSemanticDetectionHeaders, 'RouteConfig semantic exclusion defaults null');

$base = new SecurityConfig();
$mutated = $base->with(['excluded_semantic_detection_headers' => ['x-late']]);
$t->same([], array_keys($base->excludedSemanticDetectionHeaders), 'with() leaves the original semantic set untouched');
$t->same(['x-late'], array_keys($mutated->excludedSemanticDetectionHeaders), 'with() carries the new semantic exclusion');

$t->section('the Cookie header scans one name=value pair at a time');
$enc = static fn (string $raw): string => rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$sig = '';
for ($i = 0; $i < 64; $i++) {
$sig .= chr($i);
}
$jwt = $enc('{"alg": "ES256", "typ": "JWT"}') . '.'
. $enc('{"sub": "3f2c1b9e-8a47-4d2e-9c1f-5b6a7d8e9f01", "name": "Jane Doe (Acme)", "email": "jane.doe@example.com", "roles": ["admin"], "iat": 1792000000, "exp": 1792000900}') . '.'
. $enc($sig);
$sessionCookie = 'session=' . $jwt . '; theme=system; _ga=GA1.1.123456789.1792000000';
$t->same(false, blockedHeaders(makeCheck(), ['cookie' => $sessionCookie]), 'a benign session cookie does not score as one semantic blob (each pair scans alone)');
$t->same(false, blockedHeaders(makeCheck(), ['Cookie' => $sessionCookie]), 'the Cookie header name matches case-insensitively');
$t->same(true, blockedHeaders(makeCheck(), ['x-session' => $sessionCookie]), 'the same value in another header keeps the whole-value scan');
$t->same(true, blockedHeaders(makeCheck(), ['cookie' => 'a=1; b=' . SCRIPT . '; c=3']), 'an attack pair in a cookie still blocks');
$t->same(true, blockedHeaders(makeCheck(), ['cookie' => 'a=1;' . JNDI]), 'a pair with no space after the separator still blocks');
$t->same(['a=b', 'c=d', "e=\r\nf", "\t"], SuspiciousActivityCheck::cookiePairs("a=b; c=d ;;e=\r\nf; ;\t"), 'pairs trim spaces only (CR/LF and tab stay) and empty pairs are skipped');
$t->same([], SuspiciousActivityCheck::cookiePairs(' ; ;'), 'a cookie of empty pairs yields nothing');
$t->same(true, blockedHeaders(makeCheck($lowThreshold), ['cookie' => SEMANTIC_ONLY]), 'a semantic-only cookie pair blocks with no opt-in');
$cookieSemanticExcluded = new SecurityConfig(detectionSemanticThreshold: 0.1, excludedSemanticDetectionHeaders: ['Cookie']);
$t->same(false, blockedHeaders(makeCheck($cookieSemanticExcluded), ['cookie' => 'theme=system; _ga=GA1.1.123456789.1792000000']), 'the semantic opt-out applies to each cookie pair');
$t->same(true, blockedHeaders(makeCheck($cookieSemanticExcluded), ['cookie' => 'theme=system; b=' . SCRIPT]), 'regex still scans every pair of a semantic-excluded cookie');

echo "\npassed={$t->passed} failed={$t->failed}\n";
exit($t->failed === 0 ? 0 : 1);
9 changes: 9 additions & 0 deletions bin/test_m3c.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ function m3cPipeline(SecurityConfig $config, array $routeConfigs = []): Security
$t->truthy(str_contains($logger->records()[0]['message'], '[PASSIVE MODE] Penetration attempt detected from'), 'passive line lead');
$t->truthy(str_contains($logger->records()[0]['message'], 'Trigger: trig - Headers:'), 'passive trigger segment');

$logger = new SimpleRequestLogger();
$req = m3cRequest('/login');
LogActivity::log($req, $logger, $passiveConfig, logType: 'suspicious', level: 'WARNING', reason: 'Rate limit exceeded for IP: 9.9.9.9', passiveMode: true, checkName: 'rate_limit');
$passiveNoTrigger = $logger->records()[0]['message'];
$t->truthy(str_contains($passiveNoTrigger, '[PASSIVE MODE] Suspicious activity detected from'), 'passive line without trigger names itself suspicious activity');
$t->truthy(!str_contains($passiveNoTrigger, 'Penetration attempt'), 'passive line without trigger is not a penetration attempt');
$t->truthy(str_contains($passiveNoTrigger, 'Reason: Rate limit exceeded for IP: 9.9.9.9 - Headers:'), 'passive line without trigger keeps the reason');
$t->truthy(!str_contains($passiveNoTrigger, 'Trigger:'), 'passive line without trigger has no trigger segment');

$payloads = [];
$hook = function ($request, $payload) use (&$payloads) {
$payloads[] = $payload;
Expand Down
13 changes: 11 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
`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): guard agent telemetry, and dynamic
rules.
`UnsupportedFeatureError` (fail closed).

## 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. |

## CORS

Expand Down Expand Up @@ -63,6 +68,10 @@ statically-built pipeline schedules the check from the global flag; the
route-aware scheduling gate lives at the `CheckFactory` level (see
KNOWN_GAPS).

`excludedSemanticDetectionHeaders` (global `SecurityConfig` field, default empty, and a `RouteConfig` field that merges with it) is an opt-in list of header names whose values skip the semantic analyzer only: the regex passes still scan them. Names match case-insensitively and trimmed. Use it for values such as JWT session cookies that score near the semantic threshold. The decorator form is `detectionExclusion(semanticHeaders: [...])`.

The `Cookie` header is scanned one `name=value` pair at a time (split on `;`, only spaces trimmed so CR and LF stay, empty pairs skipped), each pair taking the same header path, so a session cookie no longer scores as one blob and the semantic opt-out applies to each pair.

## Behavior rules

| Argument | Default | Notes |
Expand Down
Loading
Loading