autotel-audit 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,472 +0,0 @@
1
- import {
2
- AUTOTEL_SAMPLING_TAIL_EVALUATED,
3
- AUTOTEL_SAMPLING_TAIL_KEEP,
4
- } from 'autotel';
5
- import {
6
- HTTP_STATUS_ATTRIBUTES,
7
- SECURITY_ATTR,
8
- SECURITY_DENIED_STATUSES,
9
- SECURITY_METRICS,
10
- } from 'autotel/security-schema';
11
- import { lazyCounter } from './lazy-counter';
12
-
13
- /**
14
- * Zero-code security signal derivation from spans you already have.
15
- *
16
- * `createSecuritySignalProcessor()` watches ordinary HTTP server spans and:
17
- *
18
- * - flags suspicious request paths (traversal, `.env`/`.git` probes,
19
- * SQLi/XSS probes) at span start, marking them `security.suspicious_request`
20
- * and force-keeping them through tail sampling
21
- * - counts denied responses (401/403/429 by default) into the
22
- * `autotel.security.http.denied` metric
23
- * - detects auth-failure bursts per client (sliding window) and surfaces
24
- * them via the `autotel.security.anomaly` metric and an `onSignal` callback
25
- *
26
- * ```typescript
27
- * init({
28
- * service: 'api',
29
- * spanProcessors: [createSecuritySignalProcessor()],
30
- * });
31
- * ```
32
- *
33
- * Detection rules, alert thresholds, and dashboards belong in your
34
- * observability backend — this processor's job is to make the signals
35
- * exist, survive sampling, and stay queryable under a stable schema.
36
- */
37
-
38
- // Structural subset of @opentelemetry/sdk-trace-base types — kept local so
39
- // autotel-audit adds no new dependencies. Objects returned here satisfy the
40
- // real SpanProcessor interface structurally (must mirror @opentelemetry/api's
41
- // AttributeValue, including nullable array entries).
42
- type AttributeValue =
43
- | string
44
- | number
45
- | boolean
46
- | Array<null | undefined | string>
47
- | Array<null | undefined | number>
48
- | Array<null | undefined | boolean>;
49
-
50
- interface MutableSpanLike {
51
- attributes: Record<string, AttributeValue | undefined>;
52
- setAttribute(key: string, value: AttributeValue): unknown;
53
- }
54
-
55
- interface ReadableSpanLike {
56
- attributes: Record<string, AttributeValue | undefined>;
57
- }
58
-
59
- export interface SecuritySignalProcessor {
60
- onStart(span: MutableSpanLike, parentContext?: unknown): void;
61
- onEnd(span: ReadableSpanLike): void;
62
- shutdown(): Promise<void>;
63
- forceFlush(): Promise<void>;
64
- }
65
-
66
- export interface SuspiciousRequestSignal {
67
- signal: 'suspicious_request';
68
- /** Which pattern matched, e.g. `path_traversal`. */
69
- pattern: string;
70
- /** The matched request path/URL (as found on the span). */
71
- target: string;
72
- }
73
-
74
- export interface AuthFailureBurstSignal {
75
- signal: 'auth_failure_burst';
76
- /** Value of the configured key attribute (e.g. client address). */
77
- key: string;
78
- /** Denied responses observed inside the window. */
79
- count: number;
80
- windowMs: number;
81
- status: number;
82
- }
83
-
84
- export interface LlmExcessiveTokensSignal {
85
- signal: 'llm_excessive_tokens';
86
- /** Total tokens consumed by the single LLM call. */
87
- tokens: number;
88
- maxTokens: number;
89
- model?: string;
90
- }
91
-
92
- export interface LlmTokenBudgetSignal {
93
- signal: 'llm_token_budget_exceeded';
94
- /** Value of the configured key attribute (e.g. end-user id). */
95
- key: string;
96
- /** Tokens consumed inside the window. */
97
- tokens: number;
98
- budget: number;
99
- windowMs: number;
100
- }
101
-
102
- export type SecuritySignal =
103
- | SuspiciousRequestSignal
104
- | AuthFailureBurstSignal
105
- | LlmExcessiveTokensSignal
106
- | LlmTokenBudgetSignal;
107
-
108
- export interface BurstOptions {
109
- /** HTTP statuses counted toward a burst. Default `[401, 403]`. */
110
- statuses?: number[];
111
- /** Denied responses within the window that trigger a signal. Default 10. */
112
- threshold?: number;
113
- /** Sliding window size in milliseconds. Default 60_000. */
114
- windowMs?: number;
115
- /**
116
- * Span attribute identifying the client. Default `client.address`
117
- * (falls back to `http.client_ip`).
118
- */
119
- keyAttribute?: string;
120
- /** Max distinct clients tracked (oldest evicted). Default 10_000. */
121
- maxKeys?: number;
122
- }
123
-
124
- export interface LlmSignalOptions {
125
- /**
126
- * Single-call token ceiling (`gen_ai.usage.total_tokens`, or input+output).
127
- * Default 100_000. Pass `false` to disable the per-call check.
128
- */
129
- maxTokensPerCall?: number | false;
130
- /**
131
- * Sliding-window token budget per key — catches slow-drip abuse that
132
- * stays under the per-call ceiling (OWASP LLM10: Unbounded Consumption).
133
- * Off unless configured.
134
- */
135
- tokenBudget?: {
136
- budget: number;
137
- /** Window size in milliseconds. Default 300_000 (5 min). */
138
- windowMs?: number;
139
- /**
140
- * Span attribute identifying the consumer. Default `enduser.id`
141
- * (falls back to `client.address`).
142
- */
143
- keyAttribute?: string;
144
- /** Max distinct keys tracked (oldest evicted). Default 10_000. */
145
- maxKeys?: number;
146
- };
147
- }
148
-
149
- export interface SecuritySignalProcessorOptions {
150
- /** Flag suspicious request paths on span start. Default true. */
151
- detectSuspiciousRequests?: boolean;
152
- /** Additional name → pattern pairs checked against the request target. */
153
- extraPatterns?: Record<string, RegExp>;
154
- /** Force-keep flagged spans through tail sampling. Default true. */
155
- forceKeepSuspicious?: boolean;
156
- /** HTTP statuses counted as denied. Default `[401, 403, 429]`. */
157
- deniedStatuses?: number[];
158
- /** Burst detection over denied responses. Pass `false` to disable. */
159
- burst?: BurstOptions | false;
160
- /**
161
- * LLM consumption signals from `gen_ai.*` spans (OWASP LLM10).
162
- * Enabled with the per-call ceiling by default; pass `false` to disable.
163
- */
164
- llm?: LlmSignalOptions | false;
165
- /** Emit `autotel.security.*` metrics. Default true. */
166
- metrics?: boolean;
167
- /** Called whenever a signal fires. Keep it fast and non-throwing. */
168
- onSignal?: (signal: SecuritySignal) => void;
169
- /** Clock override for tests. */
170
- now?: () => number;
171
- }
172
-
173
- /**
174
- * Conservative request-target patterns. Tuned for scanner/probe traffic —
175
- * high signal, low false-positive — not as a WAF. Extend via `extraPatterns`.
176
- */
177
- export const SUSPICIOUS_REQUEST_PATTERNS: Record<string, RegExp> = {
178
- path_traversal: /(\.\.[/\\]|%2e%2e(%2f|%5c|\/)|\.\.%2f|%252e%252e)/i,
179
- sensitive_file_probe:
180
- /(\/\.env\b|\/\.git\b|\/etc\/passwd|\/wp-admin\b|\/\.aws\b|\/id_rsa\b)/i,
181
- sqli_probe:
182
- /(\bunion\b[\s+%20]+(all[\s+%20]+)?select\b|'[\s+%20]*or[\s+%20]*'?1'?[\s+%20]*=[\s+%20]*'?1)/i,
183
- xss_probe: /(<script\b|%3cscript)/i,
184
- null_byte: /%00/,
185
- };
186
-
187
- const TARGET_ATTRIBUTES = [
188
- 'url.path',
189
- 'url.full',
190
- 'http.target',
191
- 'http.url',
192
- ] as const;
193
-
194
- function readAttribute(
195
- attributes: Record<string, AttributeValue | undefined>,
196
- keys: readonly string[],
197
- ): AttributeValue | undefined {
198
- for (const key of keys) {
199
- const value = attributes[key];
200
- if (value !== undefined) return value;
201
- }
202
- return undefined;
203
- }
204
-
205
- /**
206
- * Weighted sliding-window counter with bounded key cardinality.
207
- * Weight 1 per hit counts occurrences; token counts as weights sum usage.
208
- */
209
- class SlidingWindow {
210
- private readonly hits = new Map<string, Array<[number, number]>>();
211
-
212
- constructor(
213
- private readonly windowMs: number,
214
- private readonly maxKeys: number,
215
- ) {}
216
-
217
- /**
218
- * Record a hit; returns the totals inside the window before and after it,
219
- * so callers can signal exactly once on a threshold crossing.
220
- */
221
- record(key: string, now: number, weight = 1): { before: number; after: number } {
222
- let entries = this.hits.get(key);
223
- if (!entries) {
224
- // Bound memory: random client addresses must not grow the map forever.
225
- if (this.hits.size >= this.maxKeys) {
226
- const oldest = this.hits.keys().next().value;
227
- if (oldest !== undefined) this.hits.delete(oldest);
228
- }
229
- entries = [];
230
- this.hits.set(key, entries);
231
- }
232
-
233
- const cutoff = now - this.windowMs;
234
- while (entries.length > 0 && (entries[0] as [number, number])[0] < cutoff) {
235
- entries.shift();
236
- }
237
-
238
- let before = 0;
239
- for (const [, w] of entries) before += w;
240
- entries.push([now, weight]);
241
- return { before, after: before + weight };
242
- }
243
- }
244
-
245
- /** Burst detection options with defaults applied and the window attached. */
246
- interface BurstConfig {
247
- statuses: Set<number>;
248
- threshold: number;
249
- windowMs: number;
250
- keyAttribute: string;
251
- window: SlidingWindow;
252
- }
253
-
254
- function resolveBurstConfig(
255
- option: BurstOptions | false | undefined,
256
- ): BurstConfig | undefined {
257
- if (option === false) return undefined;
258
- const opts = option ?? {};
259
- const windowMs = opts.windowMs ?? 60_000;
260
- return {
261
- statuses: new Set(opts.statuses ?? [401, 403]),
262
- threshold: opts.threshold ?? 10,
263
- windowMs,
264
- keyAttribute: opts.keyAttribute ?? 'client.address',
265
- window: new SlidingWindow(windowMs, opts.maxKeys ?? 10_000),
266
- };
267
- }
268
-
269
- /** LLM consumption options with defaults applied and windows attached. */
270
- interface LlmConfig {
271
- maxTokensPerCall?: number;
272
- budget?: {
273
- budget: number;
274
- windowMs: number;
275
- keyAttribute: string;
276
- window: SlidingWindow;
277
- };
278
- }
279
-
280
- function resolveLlmConfig(
281
- option: LlmSignalOptions | false | undefined,
282
- ): LlmConfig | undefined {
283
- if (option === false) return undefined;
284
- const opts = option ?? {};
285
- const tokenBudget = opts.tokenBudget;
286
- const windowMs = tokenBudget?.windowMs ?? 300_000;
287
- return {
288
- maxTokensPerCall:
289
- opts.maxTokensPerCall === false
290
- ? undefined
291
- : (opts.maxTokensPerCall ?? 100_000),
292
- budget: tokenBudget && {
293
- budget: tokenBudget.budget,
294
- windowMs,
295
- keyAttribute: tokenBudget.keyAttribute ?? 'enduser.id',
296
- window: new SlidingWindow(windowMs, tokenBudget.maxKeys ?? 10_000),
297
- },
298
- };
299
- }
300
-
301
- export function createSecuritySignalProcessor(
302
- options: SecuritySignalProcessorOptions = {},
303
- ): SecuritySignalProcessor {
304
- const detect = options.detectSuspiciousRequests !== false;
305
- const forceKeep = options.forceKeepSuspicious !== false;
306
- const metricsEnabled = options.metrics !== false;
307
- const deniedStatuses = new Set(
308
- options.deniedStatuses ?? SECURITY_DENIED_STATUSES,
309
- );
310
- const now = options.now ?? Date.now;
311
-
312
- const patterns: Record<string, RegExp> = {
313
- ...SUSPICIOUS_REQUEST_PATTERNS,
314
- ...options.extraPatterns,
315
- };
316
-
317
- const burst = resolveBurstConfig(options.burst);
318
- const llm = resolveLlmConfig(options.llm);
319
-
320
- const counters = {
321
- suspicious: lazyCounter(
322
- SECURITY_METRICS.httpSuspicious,
323
- 'Requests matching suspicious-path patterns',
324
- ),
325
- denied: lazyCounter(
326
- SECURITY_METRICS.httpDenied,
327
- 'HTTP responses with denied status codes (401/403/429)',
328
- ),
329
- anomaly: lazyCounter(
330
- SECURITY_METRICS.anomaly,
331
- 'Security anomaly signals (e.g. auth-failure bursts)',
332
- ),
333
- };
334
-
335
- function count(
336
- which: keyof typeof counters,
337
- attributes: Record<string, string | number>,
338
- ): void {
339
- if (!metricsEnabled) return;
340
- counters[which].add(1, attributes);
341
- }
342
-
343
- function emit(signal: SecuritySignal): void {
344
- try {
345
- options.onSignal?.(signal);
346
- } catch {
347
- // Callbacks must never break the span pipeline.
348
- }
349
- }
350
-
351
- function checkDeniedResponse(span: ReadableSpanLike): void {
352
- const status = readAttribute(span.attributes, HTTP_STATUS_ATTRIBUTES);
353
- if (typeof status !== 'number' || !deniedStatuses.has(status)) return;
354
-
355
- count('denied', { status });
356
-
357
- if (!burst || !burst.statuses.has(status)) return;
358
-
359
- const key = readAttribute(span.attributes, [
360
- burst.keyAttribute,
361
- 'http.client_ip',
362
- ]);
363
- if (typeof key !== 'string' || key.length === 0) return;
364
-
365
- const { before, after } = burst.window.record(key, now());
366
- // Signal once per window on the exact crossing, not on every
367
- // subsequent hit — keeps anomaly volume bounded under attack.
368
- if (before < burst.threshold && after >= burst.threshold) {
369
- count('anomaly', { signal: 'auth_failure_burst', status });
370
- emit({
371
- signal: 'auth_failure_burst',
372
- key,
373
- count: after,
374
- windowMs: burst.windowMs,
375
- status,
376
- });
377
- }
378
- }
379
-
380
- function checkLlmConsumption(span: ReadableSpanLike): void {
381
- if (!llm) return;
382
-
383
- const total = readAttribute(span.attributes, ['gen_ai.usage.total_tokens']);
384
- let tokens: number | undefined;
385
- if (typeof total === 'number') {
386
- tokens = total;
387
- } else {
388
- const input = readAttribute(span.attributes, ['gen_ai.usage.input_tokens']);
389
- const output = readAttribute(span.attributes, [
390
- 'gen_ai.usage.output_tokens',
391
- ]);
392
- if (typeof input === 'number' || typeof output === 'number') {
393
- tokens =
394
- (typeof input === 'number' ? input : 0) +
395
- (typeof output === 'number' ? output : 0);
396
- }
397
- }
398
- if (tokens === undefined || tokens <= 0) return;
399
-
400
- if (llm.maxTokensPerCall !== undefined && tokens > llm.maxTokensPerCall) {
401
- const model = readAttribute(span.attributes, [
402
- 'gen_ai.response.model',
403
- 'gen_ai.request.model',
404
- ]);
405
- count('anomaly', { signal: 'llm_excessive_tokens' });
406
- emit({
407
- signal: 'llm_excessive_tokens',
408
- tokens,
409
- maxTokens: llm.maxTokensPerCall,
410
- ...(typeof model === 'string' && { model }),
411
- });
412
- }
413
-
414
- const budget = llm.budget;
415
- if (!budget) return;
416
-
417
- const key = readAttribute(span.attributes, [
418
- budget.keyAttribute,
419
- 'client.address',
420
- ]);
421
- if (typeof key !== 'string' || key.length === 0) return;
422
-
423
- const { before, after } = budget.window.record(key, now(), tokens);
424
- if (before < budget.budget && after >= budget.budget) {
425
- count('anomaly', { signal: 'llm_token_budget_exceeded' });
426
- emit({
427
- signal: 'llm_token_budget_exceeded',
428
- key,
429
- tokens: after,
430
- budget: budget.budget,
431
- windowMs: budget.windowMs,
432
- });
433
- }
434
- }
435
-
436
- return {
437
- onStart(span) {
438
- if (!detect) return;
439
-
440
- const target = readAttribute(span.attributes, TARGET_ATTRIBUTES);
441
- if (typeof target !== 'string' || target.length === 0) return;
442
-
443
- for (const [name, pattern] of Object.entries(patterns)) {
444
- if (!pattern.test(target)) continue;
445
-
446
- span.setAttribute(SECURITY_ATTR.suspiciousRequest, true);
447
- span.setAttribute(SECURITY_ATTR.signal, name);
448
- if (forceKeep) {
449
- span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
450
- span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
451
- }
452
-
453
- count('suspicious', { pattern: name });
454
- emit({ signal: 'suspicious_request', pattern: name, target });
455
- return; // first match wins — one signal per span
456
- }
457
- },
458
-
459
- onEnd(span) {
460
- checkDeniedResponse(span);
461
- checkLlmConsumption(span);
462
- },
463
-
464
- shutdown() {
465
- return Promise.resolve();
466
- },
467
-
468
- forceFlush() {
469
- return Promise.resolve();
470
- },
471
- };
472
- }