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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Received dynamic rules are now applied. `DynamicRuleManager` fetched, validated, stored and persisted rules but never acted on them, so an `ipBlacklist`, `emergencyMode` or `globalRateLimit` rule had no effect on request handling. The apply step (the reference `_dynamic_rule_application`) now bans the `ipBlacklist` IPs for `ipBanDuration`, unbans the `ipWhitelist` IPs, adds `suspiciousPatterns` through the pattern validator, and replaces the live country lists, rate limits, cloud providers, `blockedUserAgents` (ReDoS validated), feature toggles, auto-ban threshold and duration, and emergency mode with its whitelist (which also halves the auto-ban threshold). A failed apply restores the previous config, an expiring rule restores the pre-rule config, and boot hydration applies the last-known rules. The rule model gains the `autoBanThreshold`, `autoBanDuration` and `enableRateLimitAutoBan` overrides. `SusPatternsManager.addPattern` now resolves a boolean (false when the validator refuses the pattern).
- The `Cookie` header is scanned one `name=value` pair at a time, like query parameters. As a single value, the `;` between cookies counted as command separators in the semantic score, so an ordinary logged-in header (a JWT session cookie plus `theme` and `_ga`) could reach 0.71 and be blocked. Each pair takes the normal header path, empty pairs are skipped, only spaces around a pair are trimmed (a CR or LF stays in the scanned value), and each pair counts against the value budget. Ports guard-core #162 (issue #161).

### Added

- `excludedSemanticDetectionHeaders` (default empty) and `detectionExclusion({ semanticHeaders })`: opt-in header names whose values skip the semantic analyzer only; regex scanning of those headers is unchanged.
- `detectionMaxScanSeconds` (default 10.0, range 0.1 to 60): a per-request cap on the summed regex scan CPU time (process CPU time around each synchronous scan, not wall time, so a busy or CPU-starved host does not exhaust it). Once spent, remaining patterns and values are skipped and a weighted `pattern_timeout` threat (`scan_time_budget_exhausted`) is recorded like a per-pattern timeout, so padding a request cannot skip detection. A synchronous RegExp cannot be preempted, so the cap is enforced between patterns.

### Changed

- The `enableDynamicRules` description lists exactly what a rule can change.
- The detection scan yields to the event loop every ~2 ms of scanning, so one request's scan no longer holds the loop for its whole duration.

### Fixed

- Received dynamic rules are now applied. `DynamicRuleManager` fetched, validated, stored and persisted rules but never acted on them, so an `ipBlacklist`, `emergencyMode` or `globalRateLimit` rule had no effect on request handling. The apply step (the reference `_dynamic_rule_application`) now bans the `ipBlacklist` IPs for `ipBanDuration`, unbans the `ipWhitelist` IPs, adds `suspiciousPatterns` through the pattern validator, and replaces the live country lists, rate limits, cloud providers, `blockedUserAgents` (ReDoS validated), feature toggles, auto-ban threshold and duration, and emergency mode with its whitelist (which also halves the auto-ban threshold). A failed apply restores the previous config, an expiring rule restores the pre-rule config, and boot hydration applies the last-known rules. The rule model gains the `autoBanThreshold`, `autoBanDuration` and `enableRateLimitAutoBan` overrides. `SusPatternsManager.addPattern` now resolves a boolean (false when the validator refuses the pattern).

## [4.3.3] - 2026-10-12

Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/configuration/detection-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,17 @@ With the knob on:

All other timeout semantics (the `pattern_timeout` threat, the 0.9x slow-completion arm, the `timeouts` result list, the anomaly events) are identical in both modes.

## Cookie Header

The `Cookie` header scans one `name=value` pair at a time, as `header`, the same way query parameters scan one by one. Empty pairs are skipped, only spaces around a pair are trimmed (a CR or LF stays in the scanned value), and each pair counts against the value budget. Every regex and semantic check still runs on each cookie, so an attack inside one cookie is still detected.

## Tuning Knobs

| Config Field | Default | Effect |
|-------------|---------|--------|
| `detectionCompilerTimeout` | `2.0` | Per-scan verdict deadline in seconds (a scan that overshoots it is discarded as a timeout and heads toward quarantine) |
| `detectionMaxScanSeconds` | `10.0` | Per-request cap on the summed regex scan CPU time (not wall time); once spent, remaining patterns and values are skipped and a weighted `pattern_timeout` threat (`scan_time_budget_exhausted`) is recorded like a per-pattern timeout. A scan also yields to the event loop every ~2 ms, so one request's scan cannot starve other requests' I/O |
| `excludedSemanticDetectionHeaders` | `[]` | Header names whose values skip the semantic analyzer only; regex scanning is unchanged |
| `detectionMaxContentLength` | `10000` | Max characters to scan per request |
| `detectionPreserveAttackPatterns` | `true` | Keep attack regions when truncating |
| `detectionSemanticThreshold` | `0.7` | Minimum semantic score to flag as threat |
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/configuration/security-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ whitelist match.
|-------|------|---------|-------------|
| `enablePenetrationDetection` | `boolean` | `true` | Enable the 75-pattern detection engine |
| `detectionCompilerTimeout` | `number` | `2.0` | Regex compilation timeout in seconds (0.1-10) |
| `detectionMaxScanSeconds` | `number` | `10.0` | Cap on the CPU time spent regex scanning one request (not wall time, so a busy host does not exhaust it), summed across every field and pattern (0.1-60). Once reached, remaining patterns and values are not scanned and the request receives a weighted `pattern_timeout` threat (`scan_time_budget_exhausted`) that counts toward the anomaly score |
| `excludedSemanticDetectionHeaders` | `string[]` | `[]` | Opt-in: header names whose values skip the semantic analyzer only (case-insensitive). Regex scanning of these headers is unchanged, e.g. `cookie` or `authorization` for apps whose JWTs score near `detectionSemanticThreshold` |
| `detectionMaxContentLength` | `number` | `10000` | Max content length for scanning (1000-100000) |
| `detectionPreserveAttackPatterns` | `boolean` | `true` | Preserve attack regions when truncating long content |
| `detectionSemanticThreshold` | `number` | `0.7` | Semantic analysis threat score threshold (0-1) |
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/decorators/content-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Multiple `customValidation` calls on the same route stack -- all validators run

## `detectionExclusion(options)`

Configure per-route detection exclusions, the port of the reference `detection_exclusion` decorator (guard_core/decorators/content_filtering.py). Each given surface replaces the global `SecurityConfig` value for that route; an omitted surface keeps inheriting the global config. `headers` always merges with the hardcoded proxy-identity defaults and the global `excludedDetectionHeaders` (an excluded header keeps scanning every category except the ssrf skip, so an attack payload in it still detects). Entries are matched case-insensitively.
Configure per-route detection exclusions, the port of the reference `detection_exclusion` decorator (guard_core/decorators/content_filtering.py). Each given surface replaces the global `SecurityConfig` value for that route; an omitted surface keeps inheriting the global config. `headers` always merges with the hardcoded proxy-identity defaults and the global `excludedDetectionHeaders` (an excluded header keeps scanning every category except the ssrf skip, so an attack payload in it still detects). Entries are matched case-insensitively. `semanticHeaders` names headers whose values skip the semantic analyzer only (regex scanning is unchanged); it merges with the global `excludedSemanticDetectionHeaders`.

```typescript
const handler = guard.detectionExclusion({
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/core/routing/detection-exclusions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export interface ResolvedDetectionExclusions {
excludedParams: ReadonlySet<string>;
excludedBodyFields: ReadonlySet<string>;
excludedHeaders: ReadonlySet<string>;
/* Lowercased header names whose values skip the semantic analyzer only
(reference _resolve_semantic_excluded_headers: config set union route set). */
excludedSemanticHeaders: ReadonlySet<string>;
enabledCategories: ReadonlySet<string> | null;
scanBody: boolean;
}
Expand All @@ -39,13 +42,18 @@ export function resolveDetectionExclusions(
excludedParams: new Set<string>(),
excludedBodyFields: new Set<string>(),
excludedHeaders: new Set<string>(),
excludedSemanticHeaders: new Set<string>(),
enabledCategories: null,
scanBody: true,
};
const excludedParams = new Set<string>();
const excludedBodyFields = new Set<string>();
const excludedHeaders = new Set<string>();
const excludedSemanticHeaders = new Set<string>();
if (config !== null) {
for (const name of config.excludedSemanticDetectionHeaders) {
excludedSemanticHeaders.add(name.trim().toLowerCase());
}
for (const name of config.excludedDetectionParams) {
excludedParams.add(name.toLowerCase());
}
Expand All @@ -71,6 +79,11 @@ export function resolveDetectionExclusions(
}
}
if (route !== null) {
if (route.excludedSemanticDetectionHeaders !== null) {
for (const name of route.excludedSemanticDetectionHeaders) {
excludedSemanticHeaders.add(name.trim().toLowerCase());
}
}
// _resolve_excluded_params / _resolve_excluded_body_fields: a non-null
// route set replaces the global one.
if (route.excludedDetectionParams !== null) {
Expand Down Expand Up @@ -105,6 +118,7 @@ export function resolveDetectionExclusions(
resolved.excludedParams = excludedParams;
resolved.excludedBodyFields = excludedBodyFields;
resolved.excludedHeaders = excludedHeaders;
resolved.excludedSemanticHeaders = excludedSemanticHeaders;
return resolved;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/decorators/content-filtering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function ContentFiltering<T extends AnyConstructor>(Base: T) {
scan. */
detectionExclusion(options: {
headers?: Set<string>;
semanticHeaders?: Set<string>;
params?: Set<string>;
bodyFields?: Set<string>;
categories?: Set<string>;
Expand All @@ -64,6 +65,7 @@ export function ContentFiltering<T extends AnyConstructor>(Base: T) {
return <F extends Function>(fn: F): F => {
const rc = this.ensureRouteConfig(fn);
if (options.headers !== undefined) rc.excludedDetectionHeaders = new Set(options.headers);
if (options.semanticHeaders !== undefined) rc.excludedSemanticDetectionHeaders = new Set(options.semanticHeaders);
if (options.params !== undefined) rc.excludedDetectionParams = new Set(options.params);
if (options.bodyFields !== undefined) rc.excludedDetectionBodyFields = new Set(options.bodyFields);
if (options.categories !== undefined) rc.enabledDetectionCategories = new Set(options.categories);
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/detection-engine/scan-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,85 @@ export class ScanPoolSupervisor {
}
}
}

/**
* Per-request regex scan time budget and event-loop slicing (reference
* detection_max_scan_seconds, _record_scan_time and _EVENT_LOOP_SLICE_SECONDS
* in guard_core/_utils/detection_scan.py). The reference carries this in
* contextvars; here one instance rides the request scan explicitly.
*
* The budget counts CPU time actually spent scanning (process.cpuUsage()
* deltas around each synchronous scan, user plus system), not wall time, so a
* busy or CPU-starved host does not push ordinary requests over the cap. The
* clock falls back to wall time on runtimes without process.cpuUsage (edge).
* A synchronous RegExp cannot be preempted, so the budget is enforced
* between patterns: once the summed per-pattern scan time reaches the cap
* the remaining patterns are skipped and the caller records the exhaustion
* as a weighted pattern_timeout threat. `yieldIfDue()` hands the event loop
* back to I/O callbacks after every EVENT_LOOP_SLICE_MS of scanning, which
* microtask awaits alone never do.
*/
export const EVENT_LOOP_SLICE_MS = 2;
export const SCAN_TIME_BUDGET_EXHAUSTED_PATTERN = 'scan_time_budget_exhausted';

export function cpuSeconds(): number {
if (typeof process !== 'undefined' && typeof process.cpuUsage === 'function') {
const usage = process.cpuUsage();
return (usage.user + usage.system) / 1e6;
}
return performance.now() / 1000;
}

export function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => {
if (typeof setImmediate === 'function') setImmediate(resolve);
else setTimeout(resolve, 0);
});
}

export class ScanTimeBudget {
private spentMs = 0;
private sliceStartMs = performance.now();
private warned = false;

/** capSeconds Infinity means no cap (the reference's None). */
constructor(
private readonly capSeconds: number = Infinity,
private readonly clock: () => number = cpuSeconds,
) {}

get cap(): number {
return this.capSeconds;
}

get exhausted(): boolean {
return this.spentMs / 1000 >= this.capSeconds;
}

record(elapsedSeconds: number): void {
this.spentMs += elapsedSeconds * 1000;
}

/** Runs one synchronous scan and charges its CPU time to the budget. */
measure<T>(scan: () => T): T {
const start = this.clock();
try {
return scan();
} finally {
this.record(this.clock() - start);
}
}

/** True exactly once per request: the caller logs the cap warning then. */
takeWarning(): boolean {
if (this.warned) return false;
this.warned = true;
return true;
}

async yieldIfDue(): Promise<void> {
if (performance.now() - this.sliceStartMs < EVENT_LOOP_SLICE_MS) return;
await yieldToEventLoop();
this.sliceStartMs = performance.now();
}
}
Loading
Loading