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
68 changes: 40 additions & 28 deletions packages/core/src/detection-engine/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export type PoolEventType = 'quarantined' | 'released';

const MAX_RECENT_TIMES = 100;
const MAX_PATTERN_LENGTH = 100;
const MIN_SAMPLES_FOR_STATS = 10;

function mean(values: number[]): number {
/* v8 ignore start -- measured-unreachable path, see the coverage PR notes */
Expand All @@ -49,15 +48,6 @@ function mean(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / values.length;
}

function stdev(values: number[]): number {
/* v8 ignore start -- measured-unreachable path, see the coverage PR notes */
if (values.length <= 1) return 0;
/* v8 ignore stop -- measured-unreachable path, see the coverage PR notes */
const avg = mean(values);
const squareDiffs = values.map((v) => (v - avg) ** 2);
return Math.sqrt(squareDiffs.reduce((a, b) => a + b, 0) / (values.length - 1));
}

function truncatePattern(pattern: string): string {
return pattern.length > 50 ? pattern.slice(0, 50) + '...' : pattern;
}
Expand All @@ -75,6 +65,8 @@ export class PerformanceMonitor {
private readonly anomalyThreshold: number;
private readonly slowPatternThreshold: number;
private readonly historySize: number;
private readonly minSamplesForAnomaly: number;
private readonly recentTimesWindow: number;
private readonly maxTrackedPatterns: number;
/* The reference detection_anomaly_emission_cooldown: minimum seconds
between anomaly events for the same pattern. */
Expand All @@ -91,12 +83,15 @@ export class PerformanceMonitor {
historySize = 1000,
maxTrackedPatterns = 1000,
emissionCooldownSeconds = 60,
minSamplesForAnomaly = 30,
) {
this.anomalyThreshold = Math.max(1.0, Math.min(10.0, anomalyThreshold));
this.slowPatternThreshold = Math.max(0.01, Math.min(10.0, slowPatternThreshold));
this.historySize = Math.max(100, Math.min(10000, historySize));
this.maxTrackedPatterns = Math.max(100, Math.min(5000, maxTrackedPatterns));
this.emissionCooldownSeconds = Math.max(1.0, Math.min(3600.0, emissionCooldownSeconds));
this.minSamplesForAnomaly = Math.max(10, Math.min(1000, Math.trunc(minSamplesForAnomaly)));
this.recentTimesWindow = Math.max(this.minSamplesForAnomaly, MAX_RECENT_TIMES);
}

async recordMetric(
Expand Down Expand Up @@ -158,7 +153,7 @@ export class PerformanceMonitor {

if (!timeout) {
stats.recentTimes.push(executionTime);
if (stats.recentTimes.length > MAX_RECENT_TIMES) {
if (stats.recentTimes.length > this.recentTimesWindow) {
stats.recentTimes.shift();
}
stats.maxExecutionTime = Math.max(stats.maxExecutionTime, executionTime);
Expand All @@ -171,6 +166,36 @@ export class PerformanceMonitor {
await this.checkAnomalies(metric, agentHandler, correlationId);
}

private detectStatisticalAnomaly(
metric: PerformanceMetric,
stats: PatternStats | undefined,
): Record<string, unknown> | null {
if (!stats || stats.recentTimes.length < this.minSamplesForAnomaly) return null;
const times = stats.recentTimes;
const count = times.length;
let sum = 0;
for (let i = 0; i < count; i++) sum += times[i]!;
const avgTime = sum / count;
if (this.anomalyThreshold >= 0 && metric.executionTime <= avgTime) return null;
let squares = 0;
for (let i = 0; i < count; i++) {
const diff = times[i]! - avgTime;
squares += diff * diff;
}
const stdTime = Math.sqrt(squares / (count - 1));
if (stdTime <= 0) return null;
const zScore = (metric.executionTime - avgTime) / stdTime;
if (zScore <= this.anomalyThreshold) return null;
return {
type: 'statistical_anomaly',
pattern: metric.pattern,
executionTime: metric.executionTime,
zScore,
avgTime,
stdTime,
};
}

private async checkAnomalies(
metric: PerformanceMetric,
agentHandler: AgentHandlerProtocol | null,
Expand All @@ -194,23 +219,8 @@ export class PerformanceMonitor {
}

const stats = this.patternStats.get(metric.pattern);
if (stats && stats.recentTimes.length >= MIN_SAMPLES_FOR_STATS) {
const avgTime = mean(stats.recentTimes);
const stdTime = stdev(stats.recentTimes);
if (stdTime > 0) {
const zScore = (metric.executionTime - avgTime) / stdTime;
if (Math.abs(zScore) > this.anomalyThreshold) {
anomalies.push({
type: 'statistical_anomaly',
pattern: metric.pattern,
executionTime: metric.executionTime,
zScore,
avgTime,
stdTime,
});
}
}
}
const statistical = this.detectStatisticalAnomaly(metric, stats);
if (statistical) anomalies.push(statistical);

if (agentHandler && anomalies.length > 0) {
/* The reference emission cooldown: at most one anomaly event batch per
Expand All @@ -237,6 +247,8 @@ export class PerformanceMonitor {
}
}

if (this.anomalyCallbacks.length === 0) return;

for (const anomaly of anomalies) {
const safe: Record<string, unknown> = { ...anomaly };
if (typeof safe['pattern'] === 'string') {
Expand Down
48 changes: 40 additions & 8 deletions packages/core/src/detection-engine/scan-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,15 @@ export class ScanPoolSupervisor {
* 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).
* deltas, user plus system), not wall time, so a busy or CPU-starved host
* does not push ordinary requests over the cap. The CPU is charged per pass
* (one loop over the pattern table), not per pattern: `openPass()` reads the
* clock once, `yieldIfDue()` charges at each EVENT_LOOP_SLICE_MS boundary
* (before the yield, restarting after, so other tasks' CPU is never
* charged), `suspended()` does the same around any await inside the pass,
* and `closePass()` makes the final charge. A budget without a cap never
* reads the CPU clock. 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
Expand Down Expand Up @@ -224,6 +230,7 @@ export class ScanTimeBudget {
private spentMs = 0;
private sliceStartMs = performance.now();
private warned = false;
private markSeconds: number | null = null;

/** capSeconds Infinity means no cap (the reference's None). */
constructor(
Expand All @@ -243,13 +250,36 @@ export class ScanTimeBudget {
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();
/** Opens a pass: one CPU clock read, none without a cap. */
openPass(): void {
this.markSeconds = Number.isFinite(this.capSeconds) ? this.clock() : null;
}

/** Charges the CPU spent since the last mark; a no-op outside a pass. */
charge(): void {
if (this.markSeconds === null) return;
const now = this.clock();
this.record(now - this.markSeconds);
this.markSeconds = now;
}

/** Restarts the mark after a suspension so the gap is not charged. */
restart(): void {
if (this.markSeconds !== null) this.markSeconds = this.clock();
}

closePass(): void {
this.charge();
this.markSeconds = null;
}

/** Awaits work that suspends the pass, charging before and restarting after. */
async suspended<T>(work: Promise<T>): Promise<T> {
this.charge();
try {
return scan();
return await work;
} finally {
this.record(this.clock() - start);
this.restart();
}
}

Expand All @@ -262,7 +292,9 @@ export class ScanTimeBudget {

async yieldIfDue(): Promise<void> {
if (performance.now() - this.sliceStartMs < EVENT_LOOP_SLICE_MS) return;
this.charge();
await yieldToEventLoop();
this.restart();
this.sliceStartMs = performance.now();
}
}
43 changes: 28 additions & 15 deletions packages/core/src/handlers/sus-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export class SusPatternsManager {
config.detectionMonitorHistorySize,
config.detectionMaxTrackedPatterns,
config.detectionAnomalyEmissionCooldown,
config.detectionMinSamplesForAnomaly,
);
this.scanPool = scanPool ?? new ScanPoolSupervisor(config.detectionCompilerTimeout * 1000);
this.scanPool.onPoolEvent((event) => {
Expand Down Expand Up @@ -352,10 +353,25 @@ export class SusPatternsManager {
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.
// yields between slices, it just has no cap. The CPU is charged per pass.
const scanTime = options.scanTime ?? new ScanTimeBudget();
scanTime.openPass();
try {
return await this.scanPatternTable(content, context, correlationId, options, scanTime);
} finally {
scanTime.closePass();
}
}

private async scanPatternTable(
content: string,
context: string,
correlationId: string | null,
options: { rawViewOnly?: boolean | null; urlDecodedViewOnly?: boolean | null; ipAddress?: string },
scanTime: ScanTimeBudget,
): Promise<{ threats: InternalRegexThreat[]; matchedPatterns: string[]; timeouts: string[] }> {
const { rawViewOnly = null, urlDecodedViewOnly = null } = options;
const threats: InternalRegexThreat[] = [];
const matchedPatterns: string[] = [];
const timeouts: string[] = [];
Expand Down Expand Up @@ -505,19 +521,16 @@ export class SusPatternsManager {
if (!this.scanWorkerPoolEnabled || SusPatternsManager.hasStructuralMatcher(pattern.source)) {
return this.scanPool.run(
pattern.source,
() => scanTime.measure(
() => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix),
),
() => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix),
);
}

const pool = await this.ensureWorkerPool();
// Only the first scan builds the pool (a real suspension); later scans reuse it.
const pool = this.workerPool ?? await scanTime.suspended(this.ensureWorkerPool());
if (pool === null) {
return this.scanPool.run(
pattern.source,
() => scanTime.measure(
() => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix),
),
() => this.checkRegexPattern(pattern, content, pattern.category, validatorContext, binaryPrefix),
);
}

Expand All @@ -529,7 +542,7 @@ export class SusPatternsManager {
? pattern.compiled.re.flags.replace('y', 'gm')
: pattern.compiled.re.flags.replace('y', 'g');
// The worker's CPU is not observable from here, so its wall time is charged.
const outcome = await pool.run(
const outcome = await scanTime.suspended(pool.run(
pattern.source,
{ patternSource: pattern.compiled.re.source, flags, content },
(candidates) =>
Expand All @@ -540,7 +553,7 @@ export class SusPatternsManager {
validatorContext,
binaryPrefix,
),
);
));
scanTime.record(outcome.elapsedMs / 1000);
return outcome;
}
Expand All @@ -553,11 +566,11 @@ export class SusPatternsManager {
);
}

/* Lazily built on the first worker-path scan; a runtime without
worker_threads falls back to the inline path with a single warning
(the knob is opt-in and edge runtimes have no pool to run). */
/* Lazily built on the first worker-path scan (the caller reuses a built
pool without a suspension); a runtime without worker_threads falls back
to the inline path with a single warning (the knob is opt-in and edge
runtimes have no pool to run). */
private async ensureWorkerPool(): Promise<WorkerScanPool | null> {
if (this.workerPool !== null) return this.workerPool;
if (this.workerPoolUnavailable) return null;
const pool = await createWorkerScanPool(this.scanPool.deadline, { logger: this.logger });
if (pool === null) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ export const SecurityConfigSchema = z.object({
detectionMaxScanValues: z.number().int().positive().default(512),
detectionMaxScanChars: z.number().int().positive().default(65536),
detectionMaxJsonDepth: z.number().int().positive().default(32),
detectionMinSamplesForAnomaly: z.number().int().positive().default(10),
detectionMinSamplesForAnomaly: z.number().int().min(10).max(1000).default(30),
/* The GeoIP manager surface (reference ipinfo_token / ipinfo_db_path /
geo_ip_db_max_age): the token authenticates the ipinfo.io download, the
path is the local MMDB file and the max age is the mtime freshness gate
Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/conformance/events-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ export class EventsCaseRun {
(drive['slow_pattern_threshold'] as number) ?? 0.1,
1000,
1000,
60,
(drive['min_samples_for_anomaly'] as number) ?? 30,
);
for (const sample of (drive['samples'] as Array<Record<string, unknown>>) ?? []) {
await monitor.recordMetric(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/tests/test-core/small-parity-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ describe('lazyInit + scan tunable config keys (gaps 19, 97)', () => {
expect(config.detectionMaxScanValues).toBe(512);
expect(config.detectionMaxScanChars).toBe(65536);
expect(config.detectionMaxJsonDepth).toBe(32);
expect(config.detectionMinSamplesForAnomaly).toBe(10);
expect(config.detectionMinSamplesForAnomaly).toBe(30);
});

it('carries the lazyInit default', () => {
Expand Down
48 changes: 47 additions & 1 deletion packages/core/tests/test-detection-engine/monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe('PerformanceMonitor', () => {
const callback = vi.fn();
monitor.registerAnomalyCallback(callback);

for (let i = 0; i < 20; i++) {
for (let i = 0; i < 35; i++) {
await monitor.recordMetric('pattern-x', 0.01, 100, false);
}

Expand All @@ -149,6 +149,52 @@ describe('PerformanceMonitor', () => {
expect(statisticalCalls.length).toBeGreaterThan(0);
});

it('does not flag a faster-than-average sample as a statistical anomaly', async () => {
const monitor = new PerformanceMonitor(2.0, 10);
const callback = vi.fn();
monitor.registerAnomalyCallback(callback);

for (let i = 0; i < 40; i++) {
await monitor.recordMetric('pattern-y', 1.0 + (i % 2) * 0.01, 100, false);
}
await monitor.recordMetric('pattern-y', 0.0, 100, false);
expect(callback).not.toHaveBeenCalled();

await monitor.recordMetric('pattern-y', 5.0, 100, false);
expect(callback).toHaveBeenCalledTimes(1);
expect((callback.mock.calls[0]![0] as Record<string, unknown>)['type']).toBe(
'statistical_anomaly',
);
});

it('skips the statistical check when the recorded samples have no spread', async () => {
const monitor = new PerformanceMonitor(2.0, 10);
const callback = vi.fn();
monitor.registerAnomalyCallback(callback);

for (let i = 0; i < 35; i++) {
await monitor.recordMetric('pattern-flat', 0.5, 100, false);
}
await monitor.recordMetric('pattern-flat', 5.0, 100, false, true);

const statisticalCalls = callback.mock.calls.filter(
(c) => (c[0] as Record<string, unknown>)['type'] === 'statistical_anomaly',
);
expect(statisticalCalls).toHaveLength(0);
});

it('waits for the configured minimum sample count', async () => {
const monitor = new PerformanceMonitor(2.0, 10, 1000, 1000, 60, 50);
const callback = vi.fn();
monitor.registerAnomalyCallback(callback);

for (let i = 0; i < 40; i++) {
await monitor.recordMetric('pattern-z', 0.01 + (i % 2) * 0.001, 100, false);
}
await monitor.recordMetric('pattern-z', 5.0, 100, false);
expect(callback).not.toHaveBeenCalled();
});

it('sends anomaly event to agent handler', async () => {
const monitor = new PerformanceMonitor(3.0, 0.001);
const agent = { sendEvent: vi.fn() };
Expand Down
Loading
Loading