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 @@ -152,7 +152,7 @@
// there (it emits the \xNN reporting escape); the 8.2 matrix jobs run
// without pcov.
'src/Detection/SusPatterns.php' => [
200, 201,
318, 319,
],

// The scheme position comes from an https?:// match, so the anchored
Expand Down Expand Up @@ -255,7 +255,7 @@
// as the BehaviorRule entry above). The suites assert the boundary
// TypeError instead (test_json_logging.php).
'src/Config/SecurityConfig.php' => [
1105, 1129,
1120, 1144,
],

// 219: the non-array/non-scalar json_decode guard - json_decode can
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ jobs:
- name: test_decorators
run: php bin/test_decorators.php

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

conformance-pipeline:
name: Conformance pipeline (php ${{ matrix.php }})
runs-on: ubuntu-latest
Expand Down Expand Up @@ -243,7 +246,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; 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; 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 bin/test_decorators.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

.PHONY: install test lint bump-version clean

Expand Down
257 changes: 257 additions & 0 deletions bin/test_scan_time_budget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<?php

declare(strict_types=1);

use RenzoFranceschini\GuardCore\Config\SecurityConfig;
use RenzoFranceschini\GuardCore\Detection\SusPatterns;
use RenzoFranceschini\GuardCore\Engine\GuardEngine;
use RenzoFranceschini\GuardCore\Pipeline\Checks\SuspiciousActivityCheck;
use RenzoFranceschini\GuardCore\Request\GuardResponse;
use RenzoFranceschini\GuardCore\Request\GuardResponseFactory;
use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest;
use RenzoFranceschini\GuardCore\Routing\RouteResolver;

require __DIR__ . '/../vendor/autoload.php';

// Port of tests/test_scan_time_budget.py (guard-core 4.3.x, detection_max_scan_seconds):
// the per-request cap on summed regex scan CPU time, checked between
// patterns. The event-loop-yield half of the reference change does not
// apply to PHP (a scan never shares a loop with other requests).

final class T
{
public int $passed = 0;
public int $failed = 0;

public function same(mixed $expected, mixed $actual, string $label): void
{
if ($expected === $actual) {
$this->passed++;
echo "ok - {$label}\n";
} else {
$this->failed++;
echo "FAIL - {$label}\n";
echo ' expected: ' . var_export($expected, true) . "\n";
echo ' actual: ' . var_export($actual, true) . "\n";
}
}

public function section(string $name): void
{
echo "\n=== {$name} ===\n";
}
}

ini_set('error_log', '/dev/null');

/** A fake CPU clock: every read advances by $step seconds (mirrors _FakeClock). */
function fakeClock(float $step): void
{
$now = 0.0;
SusPatterns::$cpuClock = static function () use (&$now, $step): float {
$now += $step;

return $now;
};
}

function makeCheck(SecurityConfig $config, float $threshold = 1.0): SuspiciousActivityCheck
{
return new SuspiciousActivityCheck(
$config,
new GuardResponseFactory(),
new SusPatterns($config->detectionSemanticThreshold, threatScoreThreshold: $threshold),
null,
new RouteResolver()
);
}

/** @return array{0: bool, 1: string} [blocked, block reason] */
function run(SuspiciousActivityCheck $check, string $path = '/items', array $headers = []): array
{
$request = new SimpleGuardRequest(urlPath: $path, method: 'GET', headers: $headers);
$request->state()->clientIp = '9.9.9.9';
$response = $check->check($request);

return [$response instanceof GuardResponse, (string) ($request->state()->guardBlockStash['reason'] ?? '')];
}

const BUDGET_PATTERN = 'scan_time_budget_exhausted';
const BUDGET_MESSAGE = "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'";

$t = new T();

$t->section('config: detection_max_scan_seconds default and bounds');
$t->same(10.0, (new SecurityConfig())->detectionMaxScanSeconds, 'default is 10.0');
$t->same(0.1, (new SecurityConfig(detectionMaxScanSeconds: 0.1))->detectionMaxScanSeconds, 'lower bound 0.1 accepted');
$t->same(60.0, (new SecurityConfig(detectionMaxScanSeconds: 60.0))->detectionMaxScanSeconds, 'upper bound 60.0 accepted');
foreach ([0.0, 0.09, 60.1] as $bad) {
$message = null;
try {
new SecurityConfig(detectionMaxScanSeconds: $bad);
} catch (\InvalidArgumentException $e) {
$message = $e->getMessage();
}
$t->same('detection_max_scan_seconds must be within [0.1, 60.0]', $message, "{$bad} rejects naming the field");
}
$t->same(2.5, (new SecurityConfig())->with(['detection_max_scan_seconds' => 2.5])->detectionMaxScanSeconds, 'with() carries the field');

$t->section('detect() is unbounded outside a request scan');
fakeClock(1.0);
$sus = new SusPatterns();
$result = $sus->detect('hello world', '9.9.9.9', 'query_param:q');
$names = array_column($result['threats'], 'pattern');
$t->same(false, in_array(BUDGET_PATTERN, $names, true), 'no budget threat without beginScan');
$t->same(false, $result['is_threat'], 'benign value is not a threat');

$t->section('exhausted budget: threat shape');
fakeClock(1.0);
$sus = new SusPatterns();
$sus->beginScan(0.1);
$result = $sus->detect('hello world', '9.9.9.9', 'query_param:q');
$sus->endScan();
$budget = array_values(array_filter($result['threats'], static fn (array $th): bool => $th['pattern'] === BUDGET_PATTERN));
$t->same(1, count(array_filter($budget, static fn (array $th): bool => $th['type'] === 'pattern_timeout')) >= 1 ? 1 : 0, 'a pattern_timeout budget threat is present');
$t->same(
['type' => 'pattern_timeout', 'pattern' => BUDGET_PATTERN, 'match' => '', 'position' => 0, 'execution_time' => 0.0, 'category' => 'custom', 'weight' => 1.0],
$budget[0] ?? null,
'threat has the reference shape and weight 1.0'
);
$t->same(true, in_array(BUDGET_PATTERN, $result['timeouts'], true), 'budget pattern is reported in timeouts');
$t->same(true, $result['is_threat'], 'default threshold 1.0 flags the request');

$t->section('exhausted budget: remaining patterns are skipped');
$calls = 0;
$now = 0.0;
SusPatterns::$cpuClock = static function () use (&$calls, &$now): float {
$calls++;

return $now += 1.0;
};
$sus = new SusPatterns();
$sus->beginScan(0.1);
$sus->detect('hello world', '9.9.9.9', 'query_param:q');
$sus->endScan();
$t->same(true, $calls < 40, "clock is read a handful of times, not once per catalog pattern ({$calls})");

$t->section('budget is shared across values of one request scan');
fakeClock(1.0);
$sus = new SusPatterns();
$sus->beginScan(2.5);
$first = $sus->detect('hello', '9.9.9.9', 'query_param:a');
$second = $sus->detect('world', '9.9.9.9', 'query_param:b');
$sus->endScan();
$names = array_merge(array_column($first['threats'], 'pattern'), array_column($second['threats'], 'pattern'));
$t->same(true, in_array(BUDGET_PATTERN, $names, true), 'budget threat appears');
$t->same(true, $second['is_threat'], 'the second value is flagged from the already spent budget');

$t->section('beginScan resets the spent time');
fakeClock(0.0);
$sus = new SusPatterns();
$sus->beginScan(0.1);
$clean = $sus->detect('hello', '9.9.9.9', 'query_param:q');
$sus->endScan();
$t->same(false, $clean['is_threat'], 'zero-cost clock never exhausts the budget');

$t->section('within budget: unaffected');
SusPatterns::$cpuClock = null;
$sus = new SusPatterns();
$sus->beginScan(10.0);
$benign = $sus->detect('page=2', '9.9.9.9', 'query_param:q');
$attack = $sus->detect("1' OR '1'='1", '9.9.9.9', 'query_param:q');
$sus->endScan();
$t->same(false, $benign['is_threat'], 'benign value under the real clock is not a threat');
$t->same(true, $attack['is_threat'], 'attack value is still detected');
$t->same(false, in_array(BUDGET_PATTERN, array_column($attack['threats'], 'pattern'), true), 'no budget threat within budget');

$t->section('threshold wiring: benign request, exhausted budget');
fakeClock(1.0);
$config = new SecurityConfig(detectionMaxScanSeconds: 0.1);
[$blocked, $reason] = run(makeCheck($config, 1.0));
$t->same(true, $blocked, 'threshold 1.0 blocks (weight 1.0 reaches it)');
$t->same(true, str_contains($reason, "URL path: " . BUDGET_MESSAGE), 'reason carries the budget text');
fakeClock(1.0);
[$blocked, $reason] = run(makeCheck(new SecurityConfig(detectionMaxScanSeconds: 0.1, detectionThreatScoreThreshold: 5.0), 5.0));
$t->same(false, $blocked, 'threshold 5.0 does not block');
$t->same('', $reason, 'no block reason at threshold 5.0');

$t->section('threshold wiring through GuardEngine (config field reaches the verdict)');
foreach ([[1.0, true], [5.0, false]] as [$threshold, $expectBlock]) {
fakeClock(1.0);
$engine = new GuardEngine(new SecurityConfig(detectionMaxScanSeconds: 0.1, detectionThreatScoreThreshold: $threshold, enableRateLimiting: false, enableIpBanning: false));
$request = new SimpleGuardRequest(urlPath: '/items', method: 'GET', headers: ['x-forwarded-for' => '9.9.9.9'], clientHost: '9.9.9.9');
$response = $engine->execute($request);
$t->same($expectBlock, $response instanceof GuardResponse, "engine threshold {$threshold} blocks={$expectBlock}");
}

$t->section('reason text: budget threat first, and appended after another threat');
fakeClock(1.0);
[, $reason] = run(makeCheck($config), '/items', ['x-pad' => 'hello']);
$t->same(false, str_contains($reason, '; ' . BUDGET_MESSAGE), 'budget threat alone has no appended form');
// Calibrate: how many patterns does an attack header scan across every view?
$attackHeader = "1' OR '1'='1 UNION SELECT password FROM users--";
$reads = 0;
SusPatterns::$cpuClock = static function () use (&$reads): float {
$reads++;

return 0.0;
};
run(makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0)), '/items', ['x-attack' => $attackHeader]);
$scanned = intdiv($reads, 2);
// Each pattern costs $step; the 60 s cap is spent late in the request scan,
// after the attack header has already been scanned.
$step = 60.0 / ($scanned * 0.85);
$now = 0.0;
SusPatterns::$cpuClock = static function () use (&$now, $step): float {
return $now += $step;
};
$check = makeCheck(new SecurityConfig(detectionMaxScanSeconds: 60.0));
[$blocked, $reason] = run($check, '/items', ['x-attack' => $attackHeader]);
$t->same(true, $blocked, 'attack header with a mid-scan exhausted budget blocks');
$t->same(true, str_ends_with($reason, '; ' . BUDGET_MESSAGE), 'reason is "<first threat message>; <budget message>"');
$t->same(true, str_contains($reason, "Value matched pattern"), 'the first threat message leads the reason');

$t->section('budget spent exactly after the catalog stops the registry additions too');
// A one-second step with an integer cap spends the budget after exactly that
// many patterns; sweeping the cap lands one inside the registry scan loop.
$withRegistry = 0;
for ($cap = 1; $cap <= 400; $cap++) {
fakeClock(1.0);
$sus = new SusPatterns();
$sus->addPattern('zzregistryprobe\\d+', true);
$sus->beginScan((float) $cap);
$result = $sus->detect('hello', '9.9.9.9', 'query_param:q');
$sus->endScan();
if (in_array(BUDGET_PATTERN, array_column($result['threats'], 'pattern'), true)) {
$withRegistry++;
}
}
$t->same(true, $withRegistry > 0, 'budget threats are recorded across the cap sweep with a registry pattern present');

$t->section('check() leaves the scan unbounded afterwards');
SusPatterns::$cpuClock = null;
$shared = new SusPatterns();
$check = new SuspiciousActivityCheck(new SecurityConfig(), new GuardResponseFactory(), $shared, null, new RouteResolver());
run($check);
fakeClock(1.0);
$after = $shared->detect('hello', '9.9.9.9', 'query_param:q');
$t->same(false, in_array(BUDGET_PATTERN, array_column($after['threats'], 'pattern'), true), 'a direct detect() after check() has no cap');

$t->section('default clock: process CPU time, not wall time');
SusPatterns::$cpuClock = null;
$before = SusPatterns::cpuSeconds();
usleep(200000);
$slept = SusPatterns::cpuSeconds() - $before;
$t->same(true, $slept < 0.1, 'a 200 ms sleep costs almost no CPU time (' . round($slept, 4) . 's)');
$before = SusPatterns::cpuSeconds();
$deadline = hrtime(true) + 50_000_000;
while (hrtime(true) < $deadline) {
}
$t->same(true, SusPatterns::cpuSeconds() - $before > 0.02, 'a 50 ms busy loop is counted');

$t->same(3.5, SusPatterns::cpuSecondsFrom(['ru_utime.tv_sec' => 1, 'ru_stime.tv_sec' => 2, 'ru_utime.tv_usec' => 250000, 'ru_stime.tv_usec' => 250000]), 'getrusage user plus system seconds and microseconds add up');
$wall = SusPatterns::cpuSecondsFrom(false);
$t->same(true, $wall > 0.0 && abs($wall - hrtime(true) / 1e9) < 1.0, 'without getrusage the monotonic wall clock stands in');

echo "\n{$t->passed} passed, {$t->failed} failed\n";
exit($t->failed === 0 ? 0 : 1);
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ rule lists to combine with).
| `enablePenetrationDetection` | `true` | |
| `enabledDetectionCategories` | all 19 categories | `xss`, `sqli`, `cmd_injection`, `path_traversal`, and more |
| `detectionSemanticThreshold` | `0.7` | Semantic model threshold, in `[0.0, 1.0]` |
| `logSensitiveHeaders` | `[]` | Headers skipped by detection scanning and redacted from logs; use for the address headers (`host`, `x-forwarded-for`, ...) until a dedicated detection-exclusion knob lands |
| `detectionThreatScoreThreshold` | `1.0` | Anomaly score (sum of regex threat weights) a value must reach to count as a threat, in `[0.0, 10.0]` |
| `detectionMaxScanSeconds` | `10.0` | Cap on the CPU seconds (process user plus system time, not wall time) spent regex scanning one request, summed across every value and pattern, in `[0.1, 60.0]`. Checked between patterns. Once spent, the remaining patterns and values are not scanned and each scanned view records a weighted `pattern_timeout` threat (`scan_time_budget_exhausted`, category `custom`, weight 1.0) that counts toward the anomaly score, so the request is flagged when `detectionThreatScoreThreshold` is at or below that weight. Where `getrusage()` is unavailable the monotonic wall clock is used instead |
| `logSensitiveHeaders` | `[]` | Headers skipped by detection scanning and redacted from logs; prefer `excludedDetectionHeaders` for the address headers (`host`, `x-forwarded-for`, ...) |

## Cloud provider blocking, user agents, auth

Expand Down
15 changes: 15 additions & 0 deletions src/Config/SecurityConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ final class SecurityConfig

public readonly int $detectionBinaryMinRunLength;

/**
* Cap on the CPU seconds spent regex scanning one request, summed over
* every scanned value and pattern (detection_max_scan_seconds, reference
* default 10.0, bounds [0.1, 60.0]). Once spent, the remaining patterns
* and values are not scanned and each scanned view records a weighted
* pattern_timeout threat that counts toward the anomaly score.
*/
public readonly float $detectionMaxScanSeconds;

/** @var array<string, true> */
public readonly array $excludedDetectionParams;

Expand Down Expand Up @@ -402,6 +411,7 @@ public function __construct(
?float $detectionThreatScoreThreshold = null,
?string $detectionPatternValidationCachePath = null,
?int $detectionBinaryMinRunLength = null,
?float $detectionMaxScanSeconds = null,
?array $excludedDetectionParams = null,
?array $excludedDetectionBodyFields = null,
?array $excludedDetectionHeaders = null,
Expand Down Expand Up @@ -507,6 +517,10 @@ public function __construct(
if ($this->detectionBinaryMinRunLength < 4 || $this->detectionBinaryMinRunLength > 1024) {
throw new \InvalidArgumentException('detection_binary_min_run_length must be within [4, 1024]');
}
$this->detectionMaxScanSeconds = $detectionMaxScanSeconds ?? 10.0;
if ($this->detectionMaxScanSeconds < 0.1 || $this->detectionMaxScanSeconds > 60.0) {
throw new \InvalidArgumentException('detection_max_scan_seconds must be within [0.1, 60.0]');
}
$this->excludedDetectionParams = $this->validateExclusionSet($excludedDetectionParams, 'excluded_detection_params');
$this->excludedDetectionBodyFields = $this->validateExclusionSet($excludedDetectionBodyFields, 'excluded_detection_body_fields');
$this->excludedDetectionHeaders = $this->validateExclusionSet($excludedDetectionHeaders, 'excluded_detection_headers');
Expand Down Expand Up @@ -865,6 +879,7 @@ private function constructorArgs(): array
'detectionThreatScoreThreshold' => $this->detectionThreatScoreThreshold,
'detectionPatternValidationCachePath' => $this->detectionPatternValidationCachePath,
'detectionBinaryMinRunLength' => $this->detectionBinaryMinRunLength,
'detectionMaxScanSeconds' => $this->detectionMaxScanSeconds,
'excludedDetectionParams' => array_keys($this->excludedDetectionParams),
'excludedDetectionBodyFields' => array_keys($this->excludedDetectionBodyFields),
'excludedDetectionHeaders' => array_keys($this->excludedDetectionHeaders),
Expand Down
Loading
Loading