diff --git a/CHANGELOG.md b/CHANGELOG.md index b73a33b..28f7eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/src/content/docs/configuration/detection-engine.md b/docs/src/content/docs/configuration/detection-engine.md index 35dd31a..6336f66 100644 --- a/docs/src/content/docs/configuration/detection-engine.md +++ b/docs/src/content/docs/configuration/detection-engine.md @@ -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 | diff --git a/docs/src/content/docs/configuration/security-config.md b/docs/src/content/docs/configuration/security-config.md index 3b34c22..5176884 100644 --- a/docs/src/content/docs/configuration/security-config.md +++ b/docs/src/content/docs/configuration/security-config.md @@ -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) | diff --git a/docs/src/content/docs/decorators/content-filtering.md b/docs/src/content/docs/decorators/content-filtering.md index 110c75e..b224b94 100644 --- a/docs/src/content/docs/decorators/content-filtering.md +++ b/docs/src/content/docs/decorators/content-filtering.md @@ -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({ diff --git a/packages/core/src/core/routing/detection-exclusions.ts b/packages/core/src/core/routing/detection-exclusions.ts index 2c034bc..3fc5ee9 100644 --- a/packages/core/src/core/routing/detection-exclusions.ts +++ b/packages/core/src/core/routing/detection-exclusions.ts @@ -15,6 +15,9 @@ export interface ResolvedDetectionExclusions { excludedParams: ReadonlySet; excludedBodyFields: ReadonlySet; excludedHeaders: ReadonlySet; + /* Lowercased header names whose values skip the semantic analyzer only + (reference _resolve_semantic_excluded_headers: config set union route set). */ + excludedSemanticHeaders: ReadonlySet; enabledCategories: ReadonlySet | null; scanBody: boolean; } @@ -39,13 +42,18 @@ export function resolveDetectionExclusions( excludedParams: new Set(), excludedBodyFields: new Set(), excludedHeaders: new Set(), + excludedSemanticHeaders: new Set(), enabledCategories: null, scanBody: true, }; const excludedParams = new Set(); const excludedBodyFields = new Set(); const excludedHeaders = new Set(); + const excludedSemanticHeaders = new Set(); if (config !== null) { + for (const name of config.excludedSemanticDetectionHeaders) { + excludedSemanticHeaders.add(name.trim().toLowerCase()); + } for (const name of config.excludedDetectionParams) { excludedParams.add(name.toLowerCase()); } @@ -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) { @@ -105,6 +118,7 @@ export function resolveDetectionExclusions( resolved.excludedParams = excludedParams; resolved.excludedBodyFields = excludedBodyFields; resolved.excludedHeaders = excludedHeaders; + resolved.excludedSemanticHeaders = excludedSemanticHeaders; return resolved; } diff --git a/packages/core/src/decorators/content-filtering.ts b/packages/core/src/decorators/content-filtering.ts index 7564a49..2f469ff 100644 --- a/packages/core/src/decorators/content-filtering.ts +++ b/packages/core/src/decorators/content-filtering.ts @@ -56,6 +56,7 @@ export function ContentFiltering(Base: T) { scan. */ detectionExclusion(options: { headers?: Set; + semanticHeaders?: Set; params?: Set; bodyFields?: Set; categories?: Set; @@ -64,6 +65,7 @@ export function ContentFiltering(Base: T) { return (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); diff --git a/packages/core/src/detection-engine/scan-pool.ts b/packages/core/src/detection-engine/scan-pool.ts index 0a0069e..f415ed1 100644 --- a/packages/core/src/detection-engine/scan-pool.ts +++ b/packages/core/src/detection-engine/scan-pool.ts @@ -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 { + 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(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 { + if (performance.now() - this.sliceStartMs < EVENT_LOOP_SLICE_MS) return; + await yieldToEventLoop(); + this.sliceStartMs = performance.now(); + } +} diff --git a/packages/core/src/handlers/sus-patterns.ts b/packages/core/src/handlers/sus-patterns.ts index 032d721..41d15be 100644 --- a/packages/core/src/handlers/sus-patterns.ts +++ b/packages/core/src/handlers/sus-patterns.ts @@ -12,7 +12,11 @@ import { ContentPreprocessor } from '../detection-engine/preprocessor.js'; import { PatternCompiler } from '../detection-engine/compiler.js'; import { PatternValidationCache } from '../detection-engine/validation-cache.js'; import { PerformanceMonitor } from '../detection-engine/monitor.js'; -import { ScanPoolSupervisor } from '../detection-engine/scan-pool.js'; +import { + SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, + ScanPoolSupervisor, + ScanTimeBudget, +} from '../detection-engine/scan-pool.js'; import type { ScanOutcome } from '../detection-engine/scan-pool.js'; import { createWorkerScanPool } from '../detection-engine/scan-worker-pool.js'; import type { RegexCandidate, WorkerScanPool } from '../detection-engine/scan-worker-pool.js'; @@ -341,9 +345,17 @@ export class SusPatternsManager { content: string, context: string, correlationId: string | null, - options: { rawViewOnly?: boolean | null; urlDecodedViewOnly?: boolean | null } = {}, + options: { + rawViewOnly?: boolean | null; + urlDecodedViewOnly?: boolean | null; + scanTime?: ScanTimeBudget; + ipAddress?: string; + } = {}, ): Promise<{ threats: InternalRegexThreat[]; matchedPatterns: string[]; timeouts: string[] }> { const { rawViewOnly = null, urlDecodedViewOnly = null } = options; + // Without a request budget (standalone detectPatternMatch) the scan still + // yields between slices, it just has no cap. + const scanTime = options.scanTime ?? new ScanTimeBudget(); const threats: InternalRegexThreat[] = []; const matchedPatterns: string[] = []; const timeouts: string[] = []; @@ -377,12 +389,38 @@ export class SusPatternsManager { } for (const pattern of allPatterns) { + // Reference per-request scan budget (_scan_time_budget_exhausted): the + // exhaustion is recorded exactly like a per-pattern timeout (same + // pattern_timeout shape, weighted, fail-closed) and ends this pass. + if (scanTime.exhausted) { + const budgetThreat: InternalRegexThreat = { + type: 'pattern_timeout', + pattern: SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, + match: '', + position: 0, + category: 'custom', + weight: resolvePatternWeight(SCAN_TIME_BUDGET_EXHAUSTED_PATTERN, 'custom'), + }; + if (scanTime.takeWarning()) { + this.logger.warn( + `detectionMaxScanSeconds (${scanTime.cap}) reached for client ${options.ipAddress ?? 'unknown'}; remaining patterns and request values are not scanned`, + ); + } + threats.push(budgetThreat); + matchedPatterns.push(budgetThreat.pattern); + timeouts.push(budgetThreat.pattern); + break; + } if (SusPatternsManager.excludedFromView(pattern.source, rawViewOnly, urlDecodedViewOnly)) continue; if (!skipFilter && !pattern.contexts.has(normalized)) continue; const patternStart = performance.now(); - const outcome = await this.runPatternScan(pattern, content, validatorContext, binaryPrefix); + const outcome = await this.runPatternScan(pattern, content, validatorContext, binaryPrefix, scanTime); const elapsed = (performance.now() - patternStart) / 1000; + // A synchronous scan never returns to the event loop on its own: hand + // it back every EVENT_LOOP_SLICE_MS so one request's scan cannot + // starve every other request's I/O. + await scanTime.yieldIfDue(); // Reference timeout semantics (_suspatterns_regex.py): a scan that // overshoots detection_compiler_timeout yields no regex verdict, the @@ -462,11 +500,14 @@ export class SusPatternsManager { content: string, validatorContext: string, binaryPrefix: number[], + scanTime: ScanTimeBudget, ): Promise> { if (!this.scanWorkerPoolEnabled || SusPatternsManager.hasStructuralMatcher(pattern.source)) { return this.scanPool.run( pattern.source, - () => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix), + () => scanTime.measure( + () => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix), + ), ); } @@ -474,7 +515,9 @@ export class SusPatternsManager { if (pool === null) { return this.scanPool.run( pattern.source, - () => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix), + () => scanTime.measure( + () => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix), + ), ); } @@ -485,7 +528,8 @@ export class SusPatternsManager { const flags = pattern.custom ? pattern.compiled.re.flags.replace('y', 'gm') : pattern.compiled.re.flags.replace('y', 'g'); - return pool.run( + // The worker's CPU is not observable from here, so its wall time is charged. + const outcome = await pool.run( pattern.source, { patternSource: pattern.compiled.re.source, flags, content }, (candidates) => @@ -497,6 +541,8 @@ export class SusPatternsManager { binaryPrefix, ), ); + scanTime.record(outcome.elapsedMs / 1000); + return outcome; } private static hasStructuralMatcher(source: string): boolean { @@ -586,6 +632,18 @@ export class SusPatternsManager { }; } + /* `header:` and `header::embedded_json` contexts, matched + case-insensitively against the trimmed excluded names. */ + private static contextHeaderIsSemanticExcluded( + context: string, + excluded: ReadonlySet | undefined, + ): boolean { + if (excluded === undefined || excluded.size === 0) return false; + if (!context.startsWith('header:')) return false; + const name = context.slice('header:'.length).replace(/:embedded_json$/, ''); + return excluded.has(name.trim().toLowerCase()); + } + private checkSemanticThreats( processedContent: string, rawContent: string, @@ -643,7 +701,11 @@ export class SusPatternsManager { ipAddress: string, context = 'unknown', correlationId: string | null = null, - options?: { skipCategories?: ReadonlySet }, + options?: { + skipCategories?: ReadonlySet; + scanTime?: ScanTimeBudget; + semanticExcludedHeaders?: ReadonlySet; + }, ): Promise { const startTime = performance.now(); const originalLength = countCodePoints(content); @@ -654,10 +716,11 @@ export class SusPatternsManager { decodeBudgetExhausted, ); const normalizedCtx = this.normalizeContext(context); + const passOptions = { ipAddress, ...(options?.scanTime ? { scanTime: options.scanTime } : {}) }; - const mainPass = await this.checkRegexPatterns(processedContent, context, correlationId, { rawViewOnly: false }); + const mainPass = await this.checkRegexPatterns(processedContent, context, correlationId, { rawViewOnly: false, ...passOptions }); const rawViewContent = this.preprocessor.preprocessSignalPreserving(content); - const rawPass = await this.checkRegexPatterns(rawViewContent, context, correlationId, { rawViewOnly: true }); + const rawPass = await this.checkRegexPatterns(rawViewContent, context, correlationId, { rawViewOnly: true, ...passOptions }); // The raw view rescans rows that already ran on the processed views (now // including the recon rows), so a row matching both views on the same // text must be reported once (Python `_merge_raw_view_results`: merge @@ -683,6 +746,7 @@ export class SusPatternsManager { const urlDecodedViewContent = this.preprocessor.truncateSafely(decodedContent); const urlDecodedPass = await this.checkRegexPatterns(urlDecodedViewContent, context, correlationId, { urlDecodedViewOnly: true, + ...passOptions, }); regexThreats.push(...urlDecodedPass.threats); matchedPatterns.push(...urlDecodedPass.matchedPatterns); @@ -708,13 +772,21 @@ export class SusPatternsManager { const additiveViewContent = this.preprocessor.preprocessShortBase64AdditiveView(content); if (additiveViewContent) { - const additivePass = await this.checkRegexPatterns(additiveViewContent, context, correlationId); + const additivePass = await this.checkRegexPatterns(additiveViewContent, context, correlationId, passOptions); regexThreats.push(...additivePass.threats); matchedPatterns.push(...additivePass.matchedPatterns); timeouts.push(...additivePass.timeouts); } - const { threats: semanticThreats } = this.checkSemanticThreats(processedContent, content); + // Opt-in semantic-only header exclusion (reference + // _context_header_is_semantic_excluded): the regex passes above already + // ran on this value, only the semantic analyzer is skipped. + const semanticThreats = SusPatternsManager.contextHeaderIsSemanticExcluded( + context, + options?.semanticExcludedHeaders, + ) + ? [] + : this.checkSemanticThreats(processedContent, content).threats; // Excluded-header category skip (the reference's // _scan_excluded_header_component effective categories): threats whose diff --git a/packages/core/src/models/config.ts b/packages/core/src/models/config.ts index 24d1101..7c4c965 100644 --- a/packages/core/src/models/config.ts +++ b/packages/core/src/models/config.ts @@ -179,6 +179,13 @@ export const SecurityConfigSchema = z.object({ behaviorMaxResponseBodyInspectBytes: z.number().int().min(1).default(262144), detectionCompilerTimeout: z.number().min(0.1).max(10).default(2.0), + /* Cap on the CPU time (seconds) spent regex scanning one request, not wall + time, summed across every field and every pattern (reference + detection_max_scan_seconds). Once reached, remaining patterns and values + are not scanned and the request receives a weighted pattern_timeout + threat that counts toward the anomaly score, so padding a request cannot + skip detection. */ + detectionMaxScanSeconds: z.number().min(0.1).max(60).default(10.0), detectionMaxContentLength: z.number().int().min(1000).max(100000).default(10000), detectionPreserveAttackPatterns: z.boolean().default(true), detectionSemanticThreshold: z.number().min(0).max(1).default(0.7), @@ -216,6 +223,12 @@ export const SecurityConfigSchema = z.object({ excludedDetectionParams: z.array(z.string()).default([]), excludedDetectionBodyFields: z.array(z.string()).default([]), excludedDetectionHeaders: z.array(z.string()).default([]), + /* Opt-in (reference excluded_semantic_detection_headers): header names + whose values skip the semantic analyzer only, matched case-insensitively. + Regex category scanning of these headers is unchanged. Useful for apps + whose JWT session cookies score near detectionSemanticThreshold (for + example cookie, authorization). Default empty. */ + excludedSemanticDetectionHeaders: z.array(z.string()).default([]), /* Detection categories that scan (the TS port of the reference enabled_detection_categories frozenset, diff --git a/packages/core/src/models/route-config.ts b/packages/core/src/models/route-config.ts index fb48f3d..a8d31f1 100644 --- a/packages/core/src/models/route-config.ts +++ b/packages/core/src/models/route-config.ts @@ -47,6 +47,10 @@ export class RouteConfig { and the route set, see resolveDetectionExclusions). Entries are matched lowercased like the reference. */ excludedDetectionHeaders: Set | null = null; + /* Header names whose values skip the semantic analyzer only (regex + scanning is unchanged); merged with the global + excludedSemanticDetectionHeaders, never a replacement. */ + excludedSemanticDetectionHeaders: Set | null = null; excludedDetectionParams: Set | null = null; excludedDetectionBodyFields: Set | null = null; /* When non-null, replaces the global enabled category set for this route diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 0a5a2ca..6f0e92b 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -16,6 +16,7 @@ import { resolveDetectionExclusions, } from './core/routing/detection-exclusions.js'; import type { ResolvedDetectionExclusions } from './core/routing/detection-exclusions.js'; +import { ScanTimeBudget } from './detection-engine/scan-pool.js'; import { fireBlockHook } from './core/block-events.js'; import { readCappedBody } from './core/bounded-body-reader.js'; @@ -385,6 +386,7 @@ export async function isIpAllowed( const MAX_SCAN_VALUES = 512; const MAX_SCAN_CHARS = 65536; const MAX_JSON_DEPTH = 32; +const DEFAULT_MAX_SCAN_SECONDS = 10.0; /** Mongo operator keys, ported from _MONGO_OPERATOR_KEY_RE in body_json_scan.py. */ const MONGO_OPERATOR_KEY_RE = @@ -398,6 +400,11 @@ interface ScanBudget { at the first hit, so after a threat this holds that hit's categories, which the caller reports to the autoban counter. */ categories: string[]; + /* Per-request regex scan time budget (detectionMaxScanSeconds) and the + semantic-only header exclusions, threaded into every manager.detect call + like the reference's request-scoped contextvars. */ + scanTime: ScanTimeBudget; + semanticHeaders: ReadonlySet; } /** @@ -411,6 +418,7 @@ interface ScanExclusions { params: ReadonlySet; bodyFields: ReadonlySet; headers: ReadonlySet; + semanticHeaders: ReadonlySet; /* Categories that must not produce a threat for this request: the complement of the resolved enabled-category set (empty when every category scans). Folded into every detect() skip set so a match in a @@ -431,6 +439,7 @@ function resolveScanExclusions( params: exclusions.excludedParams, bodyFields: exclusions.excludedBodyFields, headers: exclusions.excludedHeaders, + semanticHeaders: exclusions.excludedSemanticHeaders, disabledCategories: disabledCategoriesOf(exclusions), scanBody: exclusions.scanBody, }; @@ -465,6 +474,14 @@ function isExcludedBodyField(exclusions: ScanExclusions, name: string): boolean return exclusions.bodyFields.has(name.toLowerCase()); } +function detectOptions(budget: ScanBudget, skipCategories?: ReadonlySet) { + return { + ...(skipCategories ? { skipCategories } : {}), + scanTime: budget.scanTime, + semanticExcludedHeaders: budget.semanticHeaders, + }; +} + function scanBudgetExhausted(budget: ScanBudget, value: string): boolean { budget.values++; if (budget.values > MAX_SCAN_VALUES) return true; @@ -493,6 +510,16 @@ function getDefaultSusPatternsManager(): Promise<{ manager: SusPatternsManager; function buildThreatMessage(result: DetectionResult): string { const threat = result.threats[0]; if (threat === undefined) return 'Threat detected'; + const message = describeThreat(threat); + if (threat.detectionMethod === 'pattern_timeout') return message; + // A per-pattern timeout or the spent scan budget can be what tips the + // request over the threshold behind an unrelated first threat: name it too + // (the reference _build_threats_message). + const timeout = result.threats.find((t) => t.detectionMethod === 'pattern_timeout'); + return timeout === undefined ? message : `${message}; ${describeThreat(timeout)}`; +} + +function describeThreat(threat: DetectionResult['threats'][number]): string { if (threat.detectionMethod === 'semantic') { /* v8 ignore start -- measured-unreachable path, see the coverage PR notes */ const attackType = threat.pattern.slice('semantic:'.length) || 'suspicious'; @@ -503,6 +530,9 @@ function buildThreatMessage(result: DetectionResult): string { /* v8 ignore stop -- measured-unreachable path, see the coverage PR notes */ return `Semantic attack: ${attackType} (score: ${score})`; } + if (threat.detectionMethod === 'pattern_timeout') { + return `Pattern exceeded scan time budget: '${threat.pattern}'`; + } return `Value matched pattern '${threat.pattern}'`; } @@ -549,13 +579,41 @@ export async function scanRequestWithManager( /* v8 ignore start -- measured-unreachable path, see the coverage PR notes */ const clientIp = request.clientHost ?? 'unknown'; /* v8 ignore stop -- measured-unreachable path, see the coverage PR notes */ - const budget: ScanBudget = { values: 0, chars: 0, categories: [] }; const exclusions = resolveScanExclusions(config, resolvedExclusions); + const budget: ScanBudget = { + values: 0, + chars: 0, + categories: [], + scanTime: new ScanTimeBudget(config?.detectionMaxScanSeconds ?? DEFAULT_MAX_SCAN_SECONDS), + semanticHeaders: exclusions.semanticHeaders, + }; const [isThreat, info] = await runRequestSurfaceScan(manager, request, clientIp, budget, exclusions, config); return [isThreat, info, budget.categories]; } +/* The Cookie header scans one name=value pair at a time, like query + parameters (reference _header_items_to_scan): as one value the `;` + between cookies read as command separators in the semantic score, so an + ordinary logged-in header could reach the threshold. Only spaces around a + pair are trimmed (a CR or LF stays in the scanned value) and empty pairs + are skipped. Each pair then takes the normal header path and counts + against the value budget. */ +function* headerItemsToScan( + headers: Readonly>, +): Generator<[string, string]> { + for (const [key, value] of Object.entries(headers)) { + if (key.trim().toLowerCase() !== 'cookie') { + yield [key, value]; + continue; + } + for (const pair of value.split(';')) { + const stripped = pair.replace(/^ +| +$/g, ''); + if (stripped !== '') yield [key, stripped]; + } + } +} + async function runRequestSurfaceScan( manager: SusPatternsManager, request: GuardRequest, @@ -578,7 +636,7 @@ async function runRequestSurfaceScan( if (hit[0]) return [true, `URL path: ${hit[1]}`]; } - for (const [headerName, headerValue] of Object.entries(request.headers)) { + for (const [headerName, headerValue] of headerItemsToScan(request.headers)) { const nameLower = headerName.toLowerCase(); // Excluded headers (the hardcoded proxy identity set merged with the // configured and per-route excludedDetectionHeaders) are not skipped @@ -754,7 +812,7 @@ async function detectValueEnhanced( let result: DetectionResult; try { /* v8 ignore start -- measured-unreachable path, see the coverage PR notes */ - result = await manager.detect(value, clientIp, context, null, skipCategories ? { skipCategories } : undefined); + result = await manager.detect(value, clientIp, context, null, detectOptions(budget, skipCategories)); /* v8 ignore stop -- measured-unreachable path, see the coverage PR notes */ } catch { return [false, '']; @@ -789,7 +847,7 @@ async function detectComponent( if (scanBudgetExhausted(budget, value)) return [false, '']; try { /* v8 ignore start -- measured-unreachable path, see the coverage PR notes */ - const result = await manager.detect(value, clientIp, context, null, skipCategories ? { skipCategories } : undefined); + const result = await manager.detect(value, clientIp, context, null, detectOptions(budget, skipCategories)); /* v8 ignore stop -- measured-unreachable path, see the coverage PR notes */ if (!result.isThreat) return [false, '']; recordHitCategories(budget, result); diff --git a/packages/core/tests/test-detection-engine/scan-time-budget.test.ts b/packages/core/tests/test-detection-engine/scan-time-budget.test.ts new file mode 100644 index 0000000..7e647f0 --- /dev/null +++ b/packages/core/tests/test-detection-engine/scan-time-budget.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { ScanTimeBudget, cpuSeconds, yieldToEventLoop } from '../../src/detection-engine/scan-pool.js'; +import { SusPatternsManager } from '../../src/handlers/sus-patterns.js'; +import { defaultLogger } from '../../src/models/logger.js'; +import { createMockRequest, createTestConfig } from '../helpers.js'; +import { scanRequestWithManager } from '../../src/utils.js'; + +const BUDGET_PATTERN = 'scan_time_budget_exhausted'; + +function manager(overrides: Record = {}): SusPatternsManager { + return new SusPatternsManager(createTestConfig(overrides), defaultLogger); +} + +describe('detectionMaxScanSeconds config field', () => { + it('defaults to 10 and enforces the 0.1 to 60 bounds', () => { + expect(createTestConfig().detectionMaxScanSeconds).toBe(10.0); + expect(createTestConfig({ detectionMaxScanSeconds: 0.1 }).detectionMaxScanSeconds).toBe(0.1); + expect(createTestConfig({ detectionMaxScanSeconds: 60 }).detectionMaxScanSeconds).toBe(60); + for (const bad of [0, 0.09, 60.1]) { + expect(() => createTestConfig({ detectionMaxScanSeconds: bad })).toThrow(); + } + }); +}); + +describe('ScanTimeBudget', () => { + it('is unbounded without a cap', () => { + const budget = new ScanTimeBudget(); + budget.record(1e6); + expect(budget.exhausted).toBe(false); + }); + + it('tracks recorded time against the cap', () => { + const budget = new ScanTimeBudget(1.0); + expect(budget.exhausted).toBe(false); + budget.record(0.4); + expect(budget.exhausted).toBe(false); + budget.record(0.6); + expect(budget.exhausted).toBe(true); + }); + + it('hands out the cap warning once', () => { + const budget = new ScanTimeBudget(1.0); + expect(budget.takeWarning()).toBe(true); + expect(budget.takeWarning()).toBe(false); + }); + + it('yieldToEventLoop falls back to setTimeout where setImmediate is absent (edge runtimes)', async () => { + vi.stubGlobal('setImmediate', undefined); + try { + await yieldToEventLoop(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('yieldToEventLoop lets a pending I/O-phase callback run', async () => { + const order: string[] = []; + setImmediate(() => order.push('immediate')); + await yieldToEventLoop(); + order.push('after'); + expect(order).toEqual(['immediate', 'after']); + }); +}); + +describe('per-request scan time budget in the engine', () => { + it('a spent budget stops the scan and flags the request fail-closed', async () => { + const m = manager(); + const scanTime = new ScanTimeBudget(0.1); + scanTime.record(1.0); + const result = await m.detect('hello world', '127.0.0.1', 'query_param:q', null, { scanTime }); + + const budgetThreats = result.threats.filter((t) => t.pattern === BUDGET_PATTERN); + expect(budgetThreats.length).toBeGreaterThan(0); + expect(budgetThreats[0]?.detectionMethod).toBe('pattern_timeout'); + expect(result.timeouts).toContain(BUDGET_PATTERN); + expect(result.isThreat).toBe(true); + expect(result.threatScore).toBeGreaterThanOrEqual(createTestConfig().detectionThreatScoreThreshold); + }); + + it('the budget is shared across the fields of one request', async () => { + const m = manager(); + const scanTime = new ScanTimeBudget(0.1); + const first = await m.detect('hello', '127.0.0.1', 'query_param:a', null, { scanTime }); + expect(first.threats.some((t) => t.pattern === BUDGET_PATTERN)).toBe(false); + + scanTime.record(1.0); + const second = await m.detect('world', '127.0.0.1', 'query_param:b', null, { scanTime }); + expect(second.threats.some((t) => t.pattern === BUDGET_PATTERN)).toBe(true); + expect(second.isThreat).toBe(true); + }); + + it('a request without a budget scans every pattern and is not flagged', async () => { + const result = await manager().detect('hello world', '127.0.0.1', 'query_param:q'); + expect(result.isThreat).toBe(false); + expect(result.timeouts).not.toContain(BUDGET_PATTERN); + }); + + it('names the budget in the trigger when it tips the request over the threshold', { timeout: 60_000 }, async () => { + const cfg = createTestConfig({ detectionMaxScanSeconds: 0.1 }); + const m = new SusPatternsManager(cfg, defaultLogger); + for (let i = 0; i < 150; i++) await m.addPattern(`[a-z ]+zzz${i}q`); + const queryParams: Record = {}; + for (let i = 0; i < 200; i++) queryParams[`p${i}`] = 'the quick brown fox jumps over the lazy dog '.repeat(2); + const [isThreat, info] = await scanRequestWithManager(m, createMockRequest({ queryParams }), cfg); + expect(isThreat).toBe(true); + expect(info).toContain("Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"); + }); + + it('keeps a real match first and appends the timeout behind it', async () => { + const cfg = createTestConfig(); + const m = new SusPatternsManager(cfg, defaultLogger); + // Exhausted only after the main pass, so the raw-view pass adds the timeout. + const allowed = m.getAllPatterns().length + 5; + class LateBudget extends ScanTimeBudget { + private checks = 0; + override get exhausted(): boolean { return ++this.checks > allowed; } + } + const result = await m.detect('select name from users', '127.0.0.1', 'query_param:q', null, { scanTime: new LateBudget() }); + expect(result.threats[0]?.detectionMethod).toBe('regex'); + expect(result.threats.some((t) => t.detectionMethod === 'pattern_timeout')).toBe(true); + + }); + + it('appends a later timeout to the first threat in the trigger text', async () => { + const stub = { + detect: async () => ({ + isThreat: true, + threats: [ + { pattern: 'a', context: 'query_param', matchedContent: 'x', detectionMethod: 'regex' }, + { pattern: 'slow', context: 'query_param', matchedContent: '', detectionMethod: 'pattern_timeout' }, + ], + }), + } as unknown as SusPatternsManager; + const [, info] = await scanRequestWithManager(stub, createMockRequest({ queryParams: { q: 'v' } })); + expect(info).toBe("Query param name 'q': Value matched pattern 'a'; Pattern exceeded scan time budget: 'slow'"); + }); + + it('the request scan stops once the configured cap is spent by padded values', { timeout: 60_000 }, async () => { + const cfg = createTestConfig({ detectionMaxScanSeconds: 0.1 }); + const m = new SusPatternsManager(cfg, defaultLogger); + // Slow custom patterns pad the per-pattern time until the cap is spent. + for (let i = 0; i < 150; i++) await m.addPattern(`[a-z ]+zzz${i}q`); + const queryParams: Record = {}; + for (let i = 0; i < 200; i++) queryParams[`p${i}`] = 'the quick brown fox jumps over the lazy dog '.repeat(2); + const [isThreat, info] = await scanRequestWithManager(m, createMockRequest({ queryParams }), cfg); + expect(isThreat).toBe(true); + expect(info).toContain(BUDGET_PATTERN); + }); +}); + +describe('the budget counts CPU time, not wall time', () => { + it('a wall-slow but CPU-cheap scan does not exhaust the budget', async () => { + const m = manager(); + // Each pattern scan takes ~1 ms of wall time but the injected CPU clock never moves. + vi.spyOn(m as unknown as { checkRegexPattern: () => null }, 'checkRegexPattern').mockImplementation(() => { + const end = performance.now() + 1; + while (performance.now() < end) { /* busy wait */ } + return null; + }); + const scanTime = new ScanTimeBudget(0.05, () => 0); + const wallStart = performance.now(); + const result = await m.detect('hello world', '127.0.0.1', 'query_param:q', null, { scanTime }); + expect(performance.now() - wallStart).toBeGreaterThan(50); + expect(result.threats.some((t) => t.pattern === BUDGET_PATTERN)).toBe(false); + expect(result.isThreat).toBe(false); + }); + + it('CPU-heavy scanning still exhausts the budget', async () => { + const m = manager(); + let cpu = 0; + const scanTime = new ScanTimeBudget(0.1, () => (cpu += 1)); + const result = await m.detect('hello world', '127.0.0.1', 'query_param:q', null, { scanTime }); + expect(result.threats.some((t) => t.pattern === BUDGET_PATTERN)).toBe(true); + }); + + it('measure charges the clock delta even when the scan throws', () => { + let cpu = 0; + const scanTime = new ScanTimeBudget(1.0, () => (cpu += 0.6)); + expect(() => scanTime.measure(() => { throw new Error('boom'); })).toThrow('boom'); + expect(scanTime.exhausted).toBe(false); + scanTime.measure(() => 1); + expect(scanTime.exhausted).toBe(true); + }); + + it('cpuSeconds reads process CPU time and falls back to wall time without process', () => { + expect(cpuSeconds()).toBeGreaterThan(0); + vi.stubGlobal('process', undefined); + try { + expect(cpuSeconds()).toBeGreaterThan(0); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe('event loop yielding during a scan', () => { + it('other callbacks run while one request is still scanning', async () => { + const cfg = createTestConfig(); + const m = new SusPatternsManager(cfg, defaultLogger); + for (let i = 0; i < 100; i++) await m.addPattern(`[a-z ]+zzz${i}q`); + const queryParams: Record = {}; + for (let i = 0; i < 100; i++) queryParams[`p${i}`] = 'the quick brown fox jumps over the lazy dog '.repeat(3); + + let ticks = 0; + const timer = setInterval(() => { ticks++; }, 1); + const scan = scanRequestWithManager(m, createMockRequest({ queryParams }), cfg); + let scanDone = false; + void scan.then(() => { scanDone = true; }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const ticksWhileScanning = scanDone ? -1 : ticks; + await scan; + clearInterval(timer); + + expect(ticksWhileScanning).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/tests/test-handlers/semantic-excluded-headers.test.ts b/packages/core/tests/test-handlers/semantic-excluded-headers.test.ts new file mode 100644 index 0000000..bd9641e --- /dev/null +++ b/packages/core/tests/test-handlers/semantic-excluded-headers.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveDetectionExclusions } from '../../src/core/routing/detection-exclusions.js'; +import { SecurityDecorator } from '../../src/decorators/index.js'; +import { SusPatternsManager } from '../../src/handlers/sus-patterns.js'; +import { defaultLogger } from '../../src/models/logger.js'; +import { RouteConfig } from '../../src/models/route-config.js'; +import { scanRequestWithManager } from '../../src/utils.js'; +import { createMockRequest, createTestConfig } from '../helpers.js'; + +const JWT_COOKIE = + 'session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' + + 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + +function config(overrides: Record = {}) { + return createTestConfig({ detectionSemanticThreshold: 0.3, ...overrides }); +} + +async function scan( + headers: Record, + cfg: ReturnType, + route: RouteConfig | null = null, +) { + const manager = new SusPatternsManager(cfg, defaultLogger); + return scanRequestWithManager( + manager, + createMockRequest({ headers }), + cfg, + resolveDetectionExclusions(cfg, route), + ); +} + +describe('excludedSemanticDetectionHeaders', () => { + it('defaults to empty', () => { + expect(createTestConfig().excludedSemanticDetectionHeaders).toEqual([]); + }); + + it('flags a JWT cookie semantically by default', async () => { + const [isThreat, info] = await scan({ cookie: JWT_COOKIE }, config()); + expect(isThreat).toBe(true); + expect(info).toContain('Semantic attack'); + }); + + it('skips semantic analysis for a listed header, case-insensitively and trimmed', async () => { + const [isThreat] = await scan( + { cookie: JWT_COOKIE }, + config({ excludedSemanticDetectionHeaders: [' Cookie '] }), + ); + expect(isThreat).toBe(false); + }); + + it('still analyses an unlisted header semantically', async () => { + const [isThreat] = await scan( + { 'x-session': JWT_COOKIE }, + config({ excludedSemanticDetectionHeaders: ['cookie'] }), + ); + expect(isThreat).toBe(true); + }); + + it('keeps regex scanning of a listed header', async () => { + const [isThreat, info] = await scan( + { cookie: "id=1' OR '1'='1' UNION SELECT 1--" }, + config({ excludedSemanticDetectionHeaders: ['cookie'] }), + ); + expect(isThreat).toBe(true); + expect(info).not.toContain('Semantic attack'); + }); + + it('merges the route-level set through detectionExclusion', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the mixin composition needs the any cast + const decorator = new (SecurityDecorator as any)(config()); + const handler = (): string => 'ok'; + decorator.detectionExclusion({ semanticHeaders: new Set(['cookie']) })(handler); + const route = decorator.getRouteConfig((handler as unknown as Record)['_guardRouteId'] as string) as RouteConfig; + expect(route.excludedSemanticDetectionHeaders).toEqual(new Set(['cookie'])); + + const [isThreat] = await scan({ cookie: JWT_COOKIE }, config(), route); + expect(isThreat).toBe(false); + }); + + it('does not leak the exclusion into the next request', async () => { + const excluded = config({ excludedSemanticDetectionHeaders: ['cookie'] }); + expect((await scan({ cookie: JWT_COOKIE }, excluded))[0]).toBe(false); + expect((await scan({ cookie: JWT_COOKIE }, config()))[0]).toBe(true); + }); +}); diff --git a/packages/core/tests/test-models/route-config.test.ts b/packages/core/tests/test-models/route-config.test.ts index 1338131..26fafe1 100644 --- a/packages/core/tests/test-models/route-config.test.ts +++ b/packages/core/tests/test-models/route-config.test.ts @@ -29,10 +29,10 @@ describe('RouteConfig', () => { expect(rc.geoRateLimits).toBeNull(); }); - it('has 31 properties', () => { + it('has 32 properties', () => { const rc = new RouteConfig(); const keys = Object.keys(rc); - expect(keys.length).toBe(31); + expect(keys.length).toBe(32); }); it('allows mutation of all fields', () => { diff --git a/packages/core/tests/test-utils/cookie-header-pairs.test.ts b/packages/core/tests/test-utils/cookie-header-pairs.test.ts new file mode 100644 index 0000000..4fab71f --- /dev/null +++ b/packages/core/tests/test-utils/cookie-header-pairs.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; + +import { SusPatternsManager } from '../../src/handlers/sus-patterns.js'; +import { defaultLogger } from '../../src/models/logger.js'; +import { scanRequestWithManager } from '../../src/utils.js'; +import { createMockRequest, createTestConfig } from '../helpers.js'; + +/* The issue #161 repro shape: an ordinary logged-in Cookie header whose JWT + session scored 0.71 semantically when the whole header was one value. */ +function b64url(data: Buffer): string { + return data.toString('base64url'); +} + +function sessionJwt(): string { + const claims = { + sub: '3f2c1b9e-8a47-4d2e-9c1f-5b6a7d8e9f01', + name: 'Jane Doe (Acme)', + email: 'jane.doe@example.com', + roles: ['admin'], + iat: 1792000000, + exp: 1792000900, + }; + return [ + b64url(Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT' }))), + b64url(Buffer.from(JSON.stringify(claims))), + b64url(Buffer.from(Array.from({ length: 64 }, (_, i) => i))), + ].join('.'); +} + +async function scan(cookie: string, overrides: Record = {}) { + const cfg = createTestConfig(overrides); + const manager = new SusPatternsManager(cfg, defaultLogger); + return scanRequestWithManager(manager, createMockRequest({ headers: { Cookie: cookie } }), cfg); +} + +describe('Cookie header is scanned one name=value pair at a time', () => { + const loggedIn = `session=${sessionJwt()}; theme=system; _ga=GA1.1.123456789.1792000000`; + + it('does not flag an ordinary logged-in cookie header', async () => { + const [isThreat, info] = await scan(loggedIn); + expect(info).toBe(''); + expect(isThreat).toBe(false); + }); + + it.each([ + ['xss', 'theme=dark; q='], + ['sqli', "theme=dark; id=1' OR 1=1--"], + ['crlf', 'theme=dark;\r\nSet-Cookie: admin=1'], + ])('still detects %s inside one cookie', async (_id, cookie) => { + expect((await scan(cookie))[0]).toBe(true); + }); + + it('skips empty pairs and trims spaces only', async () => { + expect((await scan(' ;; a=1 ; ; b=2;'))[0]).toBe(false); + }); + + it('counts each pair against the value budget', async () => { + // 1 budget unit per name and per value: 600 harmless pairs exhaust the + // 512 value cap, so a payload behind them is not scanned. + const filler = Array.from({ length: 600 }, (_, i) => `k${i}=v`).join('; '); + expect((await scan(`${filler}; q=`))[0]).toBe(false); + }); + + it('applies the semantic-only header opt-out to each pair', async () => { + const [flagged] = await scan(`session=${sessionJwt()}`, { detectionSemanticThreshold: 0.3 }); + expect(flagged).toBe(true); + const [skipped] = await scan(`session=${sessionJwt()}; theme=system`, { + detectionSemanticThreshold: 0.3, + excludedSemanticDetectionHeaders: ['cookie'], + }); + expect(skipped).toBe(false); + }); +});