autotel-audit 0.2.0 → 0.3.0

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.
package/dist/index.js CHANGED
@@ -1,475 +1,529 @@
1
- import { createCounter, AUTOTEL_SAMPLING_TAIL_EVALUATED, AUTOTEL_SAMPLING_TAIL_KEEP, getRequestLogger, getTraceContext, otelTrace, REDACTOR_PATTERNS } from 'autotel';
2
- import { createHash } from 'crypto';
3
- import { SECURITY_ATTR, SECURITY_METRICS, escalateSecuritySeverity, SECURITY_DENIED_STATUSES, HTTP_STATUS_ATTRIBUTES } from 'autotel/security-schema';
1
+ import { AUTOTEL_SAMPLING_TAIL_EVALUATED, AUTOTEL_SAMPLING_TAIL_KEEP, REDACTOR_PATTERNS, createCounter, createNoopRequestLogger, getRequestLoggerSafe, getTraceContext, otelTrace } from "autotel";
2
+ import { createHash } from "node:crypto";
3
+ import { HTTP_STATUS_ATTRIBUTES, SECURITY_ATTR, SECURITY_DENIED_STATUSES, SECURITY_METRICS, escalateSecuritySeverity } from "autotel/security-schema";
4
4
 
5
- // src/index.ts
6
- function resolveContext(ctx) {
7
- if (ctx) return ctx;
8
- const ids = getTraceContext();
9
- const span = otelTrace.getActiveSpan();
10
- if (ids && span) {
11
- return {
12
- traceId: ids.traceId,
13
- spanId: ids.spanId,
14
- correlationId: ids.correlationId,
15
- setAttribute: (key, value) => span.setAttribute(key, value),
16
- setAttributes: (attrs) => span.setAttributes(attrs)
17
- };
18
- }
19
- throw new Error(
20
- "[autotel-audit] No active trace context. Wrap your handler with trace() or pass options.ctx."
21
- );
5
+ //#region src/context.ts
6
+ const MISSING_CONTEXT_MESSAGE = "[autotel-audit] No active trace context. Wrap the call in trace()/instrument(), pass options.ctx, or set options.onMissingContext to \"warn\"/\"skip\" to degrade gracefully instead of throwing.";
7
+ /**
8
+ * Resolve an audit context without throwing. Returns `null` when no trace context
9
+ * is available, so callers can degrade gracefully (best-effort instrumentation).
10
+ */
11
+ const INVALID_TRACE_ID = "00000000000000000000000000000000";
12
+ function resolveContextSafe(ctx) {
13
+ if (ctx) return ctx;
14
+ const span = otelTrace.getActiveSpan();
15
+ if (!span) return null;
16
+ const ids = getTraceContext();
17
+ const sc = span.spanContext();
18
+ const traceId = ids?.traceId ?? sc.traceId;
19
+ if (!traceId || traceId === INVALID_TRACE_ID) return null;
20
+ return {
21
+ traceId,
22
+ spanId: ids?.spanId ?? sc.spanId,
23
+ correlationId: ids?.correlationId ?? traceId.slice(0, 16),
24
+ setAttribute: (key, value) => span.setAttribute(key, value),
25
+ setAttributes: (attrs) => span.setAttributes(attrs)
26
+ };
27
+ }
28
+ /** A no-op {@link AuditContext} whose attribute setters do nothing. */
29
+ function noopAuditContext() {
30
+ return {
31
+ traceId: "",
32
+ spanId: "",
33
+ correlationId: "",
34
+ setAttribute() {},
35
+ setAttributes() {}
36
+ };
37
+ }
38
+ const warnedMissingContext = /* @__PURE__ */ new Set();
39
+ const warnedMissingLogger = /* @__PURE__ */ new Set();
40
+ /** Warn (once per action) that instrumentation is running without a trace context. */
41
+ function warnMissingContextOnce(action) {
42
+ if (warnedMissingContext.has(action)) return;
43
+ warnedMissingContext.add(action);
44
+ console.warn(`[autotel-audit] No active trace context for "${action}" — running un-audited. Wrap the call in trace()/instrument() or pass options.ctx to capture telemetry. (set options.onMissingContext: "throw" to fail fast, or "skip" to silence this warning)`);
45
+ }
46
+ /** Warn (once per action) that attributes were recorded but no canonical log line emitted. */
47
+ function warnMissingLoggerOnce(action) {
48
+ if (warnedMissingLogger.has(action)) return;
49
+ warnedMissingLogger.add(action);
50
+ console.warn(`[autotel-audit] No request logger for "${action}" — attributes were recorded on the span, but no canonical log line was emitted. Pass options.logger or run inside runWithRequestContext().`);
22
51
  }
23
52
  function toAttributeValue(value) {
24
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
25
- return value;
26
- }
27
- if (Array.isArray(value)) {
28
- if (value.every((entry) => typeof entry === "string")) {
29
- return value;
30
- }
31
- if (value.every((entry) => typeof entry === "number")) {
32
- return value;
33
- }
34
- if (value.every((entry) => typeof entry === "boolean")) {
35
- return value;
36
- }
37
- try {
38
- return JSON.stringify(value);
39
- } catch {
40
- return "<serialization-failed>";
41
- }
42
- }
43
- if (value instanceof Date) {
44
- return value.toISOString();
45
- }
46
- if (value === null || value === void 0) {
47
- return void 0;
48
- }
49
- try {
50
- return JSON.stringify(value);
51
- } catch {
52
- return "<serialization-failed>";
53
- }
53
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
54
+ if (Array.isArray(value)) {
55
+ if (value.every((entry) => typeof entry === "string")) return value;
56
+ if (value.every((entry) => typeof entry === "number")) return value;
57
+ if (value.every((entry) => typeof entry === "boolean")) return value;
58
+ try {
59
+ return JSON.stringify(value);
60
+ } catch {
61
+ return "<serialization-failed>";
62
+ }
63
+ }
64
+ if (value instanceof Date) return value.toISOString();
65
+ if (value === null || value === void 0) return;
66
+ try {
67
+ return JSON.stringify(value);
68
+ } catch {
69
+ return "<serialization-failed>";
70
+ }
54
71
  }
72
+
73
+ //#endregion
74
+ //#region src/lazy-counter.ts
75
+ /**
76
+ * Counter that is created on first use (the meter may not be configured
77
+ * until `init()` completes) and whose failures are swallowed — metrics
78
+ * must never break event emission or the span pipeline.
79
+ */
55
80
  function lazyCounter(name, description) {
56
- let counter;
57
- return {
58
- add(value, attributes) {
59
- try {
60
- counter ??= createCounter(name, { description });
61
- counter.add(value, attributes);
62
- } catch {
63
- }
64
- }
65
- };
81
+ let counter;
82
+ return { add(value, attributes) {
83
+ try {
84
+ counter ??= createCounter(name, { description });
85
+ counter.add(value, attributes);
86
+ } catch {}
87
+ } };
66
88
  }
67
89
 
68
- // src/security.ts
69
- var FIELD_ATTRIBUTES = {
70
- name: SECURITY_ATTR.event,
71
- category: SECURITY_ATTR.category,
72
- outcome: SECURITY_ATTR.outcome,
73
- severity: SECURITY_ATTR.severity,
74
- actorId: SECURITY_ATTR.actorId,
75
- targetType: SECURITY_ATTR.targetType,
76
- targetId: SECURITY_ATTR.targetId,
77
- tenantId: SECURITY_ATTR.tenantId,
78
- reason: SECURITY_ATTR.reason
90
+ //#endregion
91
+ //#region src/security.ts
92
+ /**
93
+ * Standard metadata fields and the schema attribute each maps to.
94
+ * Drives both standard-field emission and the reserved-key check for the
95
+ * custom-attribute loop — adding a field here is the whole change.
96
+ */
97
+ const FIELD_ATTRIBUTES = {
98
+ name: SECURITY_ATTR.event,
99
+ category: SECURITY_ATTR.category,
100
+ outcome: SECURITY_ATTR.outcome,
101
+ severity: SECURITY_ATTR.severity,
102
+ actorId: SECURITY_ATTR.actorId,
103
+ targetType: SECURITY_ATTR.targetType,
104
+ targetId: SECURITY_ATTR.targetId,
105
+ tenantId: SECURITY_ATTR.tenantId,
106
+ reason: SECURITY_ATTR.reason
79
107
  };
80
108
  function flattenSecurityAttributes(metadata) {
81
- const attributes = {
82
- [SECURITY_ATTR.marker]: true,
83
- [SECURITY_ATTR.severity]: metadata.severity ?? "info"
84
- };
85
- const droppedKeys = [];
86
- for (const [key, value] of Object.entries(metadata)) {
87
- const standardAttribute = FIELD_ATTRIBUTES[key];
88
- if (standardAttribute === void 0 && REDACTOR_PATTERNS.sensitiveKey.test(key)) {
89
- droppedKeys.push(key);
90
- continue;
91
- }
92
- const attr = toAttributeValue(value);
93
- if (attr !== void 0) {
94
- attributes[standardAttribute ?? `security.${key}`] = attr;
95
- }
96
- }
97
- if (droppedKeys.length > 0) {
98
- attributes[SECURITY_ATTR.droppedKeys] = droppedKeys;
99
- }
100
- return attributes;
109
+ const attributes = {
110
+ [SECURITY_ATTR.marker]: true,
111
+ [SECURITY_ATTR.severity]: metadata.severity ?? "info"
112
+ };
113
+ const droppedKeys = [];
114
+ for (const [key, value] of Object.entries(metadata)) {
115
+ const standardAttribute = FIELD_ATTRIBUTES[key];
116
+ if (standardAttribute === void 0 && REDACTOR_PATTERNS.sensitiveKey.test(key)) {
117
+ droppedKeys.push(key);
118
+ continue;
119
+ }
120
+ const attr = toAttributeValue(value);
121
+ if (attr !== void 0) attributes[standardAttribute ?? `security.${key}`] = attr;
122
+ }
123
+ if (droppedKeys.length > 0) attributes[SECURITY_ATTR.droppedKeys] = droppedKeys;
124
+ return attributes;
101
125
  }
102
- var eventsCounter = lazyCounter(
103
- SECURITY_METRICS.events,
104
- "Security events by name, category, outcome, and severity"
105
- );
126
+ const eventsCounter = lazyCounter(SECURITY_METRICS.events, "Security events by name, category, outcome, and severity");
106
127
  function countSecurityEvent(metadata) {
107
- eventsCounter.add(1, {
108
- event: metadata.name,
109
- category: metadata.category,
110
- outcome: metadata.outcome,
111
- severity: metadata.severity ?? "info"
112
- });
128
+ eventsCounter.add(1, {
129
+ event: metadata.name,
130
+ category: metadata.category,
131
+ outcome: metadata.outcome,
132
+ severity: metadata.severity ?? "info"
133
+ });
113
134
  }
135
+ /**
136
+ * Record a security event on the active trace and request logger.
137
+ *
138
+ * Events are force-kept through tail sampling by default and carry
139
+ * `security.*` attributes (`security.event`, `security.category`,
140
+ * `security.outcome`, `security.severity`) so backends can build
141
+ * detection rules and dashboards from a stable schema.
142
+ *
143
+ * ```typescript
144
+ * securityEvent({
145
+ * name: 'auth.login.failed',
146
+ * category: 'authentication',
147
+ * outcome: 'failure',
148
+ * severity: 'warning',
149
+ * actorId: hashIdentifier(email),
150
+ * reason: 'invalid_password',
151
+ * });
152
+ * ```
153
+ */
114
154
  function securityEvent(metadata, options = {}) {
115
- const traceCtx = resolveContext(options.ctx);
116
- if (options.forceKeep !== false) {
117
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
118
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
119
- traceCtx.setAttribute(SECURITY_ATTR.forceKeep, true);
120
- }
121
- traceCtx.setAttributes(flattenSecurityAttributes(metadata));
122
- if (options.metrics !== false) {
123
- countSecurityEvent(metadata);
124
- }
125
- const logger = options.logger ?? getRequestLogger();
126
- logger.set({
127
- security: {
128
- name: metadata.name,
129
- category: metadata.category,
130
- outcome: metadata.outcome,
131
- severity: metadata.severity ?? "info",
132
- ...metadata.reason !== void 0 && { reason: metadata.reason },
133
- forceKeep: options.forceKeep !== false
134
- }
135
- });
136
- if (options.emitNow) {
137
- logger.emitNow();
138
- }
155
+ const traceCtx = resolveContextSafe(options.ctx);
156
+ if (options.metrics !== false) countSecurityEvent(metadata);
157
+ if (!traceCtx) {
158
+ const mode = options.onMissingContext ?? "warn";
159
+ if (mode === "throw") throw new Error(MISSING_CONTEXT_MESSAGE);
160
+ if (mode === "warn") warnMissingContextOnce(metadata.name);
161
+ return;
162
+ }
163
+ if (options.forceKeep !== false) {
164
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
165
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
166
+ traceCtx.setAttribute(SECURITY_ATTR.forceKeep, true);
167
+ }
168
+ traceCtx.setAttributes(flattenSecurityAttributes(metadata));
169
+ const logger = options.logger ?? getRequestLoggerSafe() ?? createNoopRequestLogger();
170
+ logger.set({ security: {
171
+ name: metadata.name,
172
+ category: metadata.category,
173
+ outcome: metadata.outcome,
174
+ severity: metadata.severity ?? "info",
175
+ ...metadata.reason !== void 0 && { reason: metadata.reason },
176
+ forceKeep: options.forceKeep !== false
177
+ } });
178
+ if (options.emitNow) logger.emitNow();
139
179
  }
180
+ /**
181
+ * Wrap a security-sensitive operation. On success the event outcome is
182
+ * recorded as given (default `success`); a thrown error records
183
+ * `outcome: 'error'`, escalates the severity to at least `error`, and
184
+ * rethrows.
185
+ *
186
+ * ```typescript
187
+ * await withSecurity(
188
+ * { name: 'api_key.created', category: 'secrets', outcome: 'success', actorId: userId },
189
+ * async () => createApiKey(userId),
190
+ * );
191
+ * ```
192
+ */
140
193
  async function withSecurity(metadata, fn, options = {}) {
141
- const traceCtx = resolveContext(options.ctx);
142
- const logger = options.logger ?? getRequestLogger();
143
- try {
144
- const result = await fn(traceCtx, logger);
145
- securityEvent(metadata, { ...options, ctx: traceCtx, logger });
146
- return result;
147
- } catch (error) {
148
- const asError = error instanceof Error ? error : new Error(String(error));
149
- securityEvent(
150
- {
151
- ...metadata,
152
- outcome: "error",
153
- // A failed security-sensitive operation is never less than an error,
154
- // but an explicit `critical` stays critical.
155
- severity: escalateSecuritySeverity(metadata.severity ?? "info", "error")
156
- },
157
- { ...options, ctx: traceCtx, logger }
158
- );
159
- logger.error(asError, {
160
- security: {
161
- name: metadata.name,
162
- category: metadata.category
163
- }
164
- });
165
- throw asError;
166
- }
194
+ const traceCtx = resolveContextSafe(options.ctx);
195
+ const logger = options.logger ?? getRequestLoggerSafe() ?? createNoopRequestLogger();
196
+ const ctx = traceCtx ?? noopAuditContext();
197
+ try {
198
+ const result = await fn(ctx, logger);
199
+ securityEvent(metadata, {
200
+ ...options,
201
+ ctx: traceCtx ?? void 0,
202
+ logger
203
+ });
204
+ return result;
205
+ } catch (error) {
206
+ const asError = error instanceof Error ? error : new Error(String(error));
207
+ securityEvent({
208
+ ...metadata,
209
+ outcome: "error",
210
+ severity: escalateSecuritySeverity(metadata.severity ?? "info", "error")
211
+ }, {
212
+ ...options,
213
+ ctx: traceCtx ?? void 0,
214
+ logger
215
+ });
216
+ logger.error(asError, { security: {
217
+ name: metadata.name,
218
+ category: metadata.category
219
+ } });
220
+ throw asError;
221
+ }
167
222
  }
223
+ /**
224
+ * Stable one-way digest for correlating PII-bearing identifiers
225
+ * (emails, IPs) across events WITHOUT logging the raw value.
226
+ *
227
+ * NOT for secrets — never log secrets in any form, hashed or not.
228
+ */
168
229
  function hashIdentifier(value, options = {}) {
169
- const length = options.length ?? 16;
170
- return createHash("sha256").update(options.salt ? `${options.salt}:${value}` : value).digest("hex").slice(0, length);
230
+ const length = options.length ?? 16;
231
+ return createHash("sha256").update(options.salt ? `${options.salt}:${value}` : value).digest("hex").slice(0, length);
171
232
  }
172
- var SUSPICIOUS_REQUEST_PATTERNS = {
173
- path_traversal: /(\.\.[/\\]|%2e%2e(%2f|%5c|\/)|\.\.%2f|%252e%252e)/i,
174
- sensitive_file_probe: /(\/\.env\b|\/\.git\b|\/etc\/passwd|\/wp-admin\b|\/\.aws\b|\/id_rsa\b)/i,
175
- sqli_probe: /(\bunion\b[\s+%20]+(all[\s+%20]+)?select\b|'[\s+%20]*or[\s+%20]*'?1'?[\s+%20]*=[\s+%20]*'?1)/i,
176
- xss_probe: /(<script\b|%3cscript)/i,
177
- null_byte: /%00/
233
+
234
+ //#endregion
235
+ //#region src/security-signals.ts
236
+ /**
237
+ * Conservative request-target patterns. Tuned for scanner/probe traffic —
238
+ * high signal, low false-positive — not as a WAF. Extend via `extraPatterns`.
239
+ */
240
+ const SUSPICIOUS_REQUEST_PATTERNS = {
241
+ path_traversal: /(\.\.[/\\]|%2e%2e(%2f|%5c|\/)|\.\.%2f|%252e%252e)/i,
242
+ sensitive_file_probe: /(\/\.env\b|\/\.git\b|\/etc\/passwd|\/wp-admin\b|\/\.aws\b|\/id_rsa\b)/i,
243
+ sqli_probe: /(\bunion\b[\s+%20]+(all[\s+%20]+)?select\b|'[\s+%20]*or[\s+%20]*'?1'?[\s+%20]*=[\s+%20]*'?1)/i,
244
+ xss_probe: /(<script\b|%3cscript)/i,
245
+ null_byte: /%00/
178
246
  };
179
- var TARGET_ATTRIBUTES = [
180
- "url.path",
181
- "url.full",
182
- "http.target",
183
- "http.url"
247
+ const TARGET_ATTRIBUTES = [
248
+ "url.path",
249
+ "url.full",
250
+ "http.target",
251
+ "http.url"
184
252
  ];
185
253
  function readAttribute(attributes, keys) {
186
- for (const key of keys) {
187
- const value = attributes[key];
188
- if (value !== void 0) return value;
189
- }
190
- return void 0;
254
+ for (const key of keys) {
255
+ const value = attributes[key];
256
+ if (value !== void 0) return value;
257
+ }
191
258
  }
259
+ /**
260
+ * Weighted sliding-window counter with bounded key cardinality.
261
+ * Weight 1 per hit counts occurrences; token counts as weights sum usage.
262
+ */
192
263
  var SlidingWindow = class {
193
- constructor(windowMs, maxKeys) {
194
- this.windowMs = windowMs;
195
- this.maxKeys = maxKeys;
196
- }
197
- windowMs;
198
- maxKeys;
199
- hits = /* @__PURE__ */ new Map();
200
- /**
201
- * Record a hit; returns the totals inside the window before and after it,
202
- * so callers can signal exactly once on a threshold crossing.
203
- */
204
- record(key, now, weight = 1) {
205
- let entries = this.hits.get(key);
206
- if (!entries) {
207
- if (this.hits.size >= this.maxKeys) {
208
- const oldest = this.hits.keys().next().value;
209
- if (oldest !== void 0) this.hits.delete(oldest);
210
- }
211
- entries = [];
212
- this.hits.set(key, entries);
213
- }
214
- const cutoff = now - this.windowMs;
215
- while (entries.length > 0 && entries[0][0] < cutoff) {
216
- entries.shift();
217
- }
218
- let before = 0;
219
- for (const [, w] of entries) before += w;
220
- entries.push([now, weight]);
221
- return { before, after: before + weight };
222
- }
264
+ windowMs;
265
+ maxKeys;
266
+ hits = /* @__PURE__ */ new Map();
267
+ constructor(windowMs, maxKeys) {
268
+ this.windowMs = windowMs;
269
+ this.maxKeys = maxKeys;
270
+ }
271
+ /**
272
+ * Record a hit; returns the totals inside the window before and after it,
273
+ * so callers can signal exactly once on a threshold crossing.
274
+ */
275
+ record(key, now, weight = 1) {
276
+ let entries = this.hits.get(key);
277
+ if (!entries) {
278
+ if (this.hits.size >= this.maxKeys) {
279
+ const oldest = this.hits.keys().next().value;
280
+ if (oldest !== void 0) this.hits.delete(oldest);
281
+ }
282
+ entries = [];
283
+ this.hits.set(key, entries);
284
+ }
285
+ const cutoff = now - this.windowMs;
286
+ while (entries.length > 0 && entries[0][0] < cutoff) entries.shift();
287
+ let before = 0;
288
+ for (const [, w] of entries) before += w;
289
+ entries.push([now, weight]);
290
+ return {
291
+ before,
292
+ after: before + weight
293
+ };
294
+ }
223
295
  };
224
296
  function resolveBurstConfig(option) {
225
- if (option === false) return void 0;
226
- const opts = option ?? {};
227
- const windowMs = opts.windowMs ?? 6e4;
228
- return {
229
- statuses: new Set(opts.statuses ?? [401, 403]),
230
- threshold: opts.threshold ?? 10,
231
- windowMs,
232
- keyAttribute: opts.keyAttribute ?? "client.address",
233
- window: new SlidingWindow(windowMs, opts.maxKeys ?? 1e4)
234
- };
297
+ if (option === false) return void 0;
298
+ const opts = option ?? {};
299
+ const windowMs = opts.windowMs ?? 6e4;
300
+ return {
301
+ statuses: new Set(opts.statuses ?? [401, 403]),
302
+ threshold: opts.threshold ?? 10,
303
+ windowMs,
304
+ keyAttribute: opts.keyAttribute ?? "client.address",
305
+ window: new SlidingWindow(windowMs, opts.maxKeys ?? 1e4)
306
+ };
235
307
  }
236
308
  function resolveLlmConfig(option) {
237
- if (option === false) return void 0;
238
- const opts = option ?? {};
239
- const tokenBudget = opts.tokenBudget;
240
- const windowMs = tokenBudget?.windowMs ?? 3e5;
241
- return {
242
- maxTokensPerCall: opts.maxTokensPerCall === false ? void 0 : opts.maxTokensPerCall ?? 1e5,
243
- budget: tokenBudget && {
244
- budget: tokenBudget.budget,
245
- windowMs,
246
- keyAttribute: tokenBudget.keyAttribute ?? "enduser.id",
247
- window: new SlidingWindow(windowMs, tokenBudget.maxKeys ?? 1e4)
248
- }
249
- };
309
+ if (option === false) return void 0;
310
+ const opts = option ?? {};
311
+ const tokenBudget = opts.tokenBudget;
312
+ const windowMs = tokenBudget?.windowMs ?? 3e5;
313
+ return {
314
+ maxTokensPerCall: opts.maxTokensPerCall === false ? void 0 : opts.maxTokensPerCall ?? 1e5,
315
+ budget: tokenBudget && {
316
+ budget: tokenBudget.budget,
317
+ windowMs,
318
+ keyAttribute: tokenBudget.keyAttribute ?? "enduser.id",
319
+ window: new SlidingWindow(windowMs, tokenBudget.maxKeys ?? 1e4)
320
+ }
321
+ };
250
322
  }
251
323
  function createSecuritySignalProcessor(options = {}) {
252
- const detect = options.detectSuspiciousRequests !== false;
253
- const forceKeep = options.forceKeepSuspicious !== false;
254
- const metricsEnabled = options.metrics !== false;
255
- const deniedStatuses = new Set(
256
- options.deniedStatuses ?? SECURITY_DENIED_STATUSES
257
- );
258
- const now = options.now ?? Date.now;
259
- const patterns = {
260
- ...SUSPICIOUS_REQUEST_PATTERNS,
261
- ...options.extraPatterns
262
- };
263
- const burst = resolveBurstConfig(options.burst);
264
- const llm = resolveLlmConfig(options.llm);
265
- const counters = {
266
- suspicious: lazyCounter(
267
- SECURITY_METRICS.httpSuspicious,
268
- "Requests matching suspicious-path patterns"
269
- ),
270
- denied: lazyCounter(
271
- SECURITY_METRICS.httpDenied,
272
- "HTTP responses with denied status codes (401/403/429)"
273
- ),
274
- anomaly: lazyCounter(
275
- SECURITY_METRICS.anomaly,
276
- "Security anomaly signals (e.g. auth-failure bursts)"
277
- )
278
- };
279
- function count(which, attributes) {
280
- if (!metricsEnabled) return;
281
- counters[which].add(1, attributes);
282
- }
283
- function emit(signal) {
284
- try {
285
- options.onSignal?.(signal);
286
- } catch {
287
- }
288
- }
289
- function checkDeniedResponse(span) {
290
- const status = readAttribute(span.attributes, HTTP_STATUS_ATTRIBUTES);
291
- if (typeof status !== "number" || !deniedStatuses.has(status)) return;
292
- count("denied", { status });
293
- if (!burst || !burst.statuses.has(status)) return;
294
- const key = readAttribute(span.attributes, [
295
- burst.keyAttribute,
296
- "http.client_ip"
297
- ]);
298
- if (typeof key !== "string" || key.length === 0) return;
299
- const { before, after } = burst.window.record(key, now());
300
- if (before < burst.threshold && after >= burst.threshold) {
301
- count("anomaly", { signal: "auth_failure_burst", status });
302
- emit({
303
- signal: "auth_failure_burst",
304
- key,
305
- count: after,
306
- windowMs: burst.windowMs,
307
- status
308
- });
309
- }
310
- }
311
- function checkLlmConsumption(span) {
312
- if (!llm) return;
313
- const total = readAttribute(span.attributes, ["gen_ai.usage.total_tokens"]);
314
- let tokens;
315
- if (typeof total === "number") {
316
- tokens = total;
317
- } else {
318
- const input = readAttribute(span.attributes, ["gen_ai.usage.input_tokens"]);
319
- const output = readAttribute(span.attributes, [
320
- "gen_ai.usage.output_tokens"
321
- ]);
322
- if (typeof input === "number" || typeof output === "number") {
323
- tokens = (typeof input === "number" ? input : 0) + (typeof output === "number" ? output : 0);
324
- }
325
- }
326
- if (tokens === void 0 || tokens <= 0) return;
327
- if (llm.maxTokensPerCall !== void 0 && tokens > llm.maxTokensPerCall) {
328
- const model = readAttribute(span.attributes, [
329
- "gen_ai.response.model",
330
- "gen_ai.request.model"
331
- ]);
332
- count("anomaly", { signal: "llm_excessive_tokens" });
333
- emit({
334
- signal: "llm_excessive_tokens",
335
- tokens,
336
- maxTokens: llm.maxTokensPerCall,
337
- ...typeof model === "string" && { model }
338
- });
339
- }
340
- const budget = llm.budget;
341
- if (!budget) return;
342
- const key = readAttribute(span.attributes, [
343
- budget.keyAttribute,
344
- "client.address"
345
- ]);
346
- if (typeof key !== "string" || key.length === 0) return;
347
- const { before, after } = budget.window.record(key, now(), tokens);
348
- if (before < budget.budget && after >= budget.budget) {
349
- count("anomaly", { signal: "llm_token_budget_exceeded" });
350
- emit({
351
- signal: "llm_token_budget_exceeded",
352
- key,
353
- tokens: after,
354
- budget: budget.budget,
355
- windowMs: budget.windowMs
356
- });
357
- }
358
- }
359
- return {
360
- onStart(span) {
361
- if (!detect) return;
362
- const target = readAttribute(span.attributes, TARGET_ATTRIBUTES);
363
- if (typeof target !== "string" || target.length === 0) return;
364
- for (const [name, pattern] of Object.entries(patterns)) {
365
- if (!pattern.test(target)) continue;
366
- span.setAttribute(SECURITY_ATTR.suspiciousRequest, true);
367
- span.setAttribute(SECURITY_ATTR.signal, name);
368
- if (forceKeep) {
369
- span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
370
- span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
371
- }
372
- count("suspicious", { pattern: name });
373
- emit({ signal: "suspicious_request", pattern: name, target });
374
- return;
375
- }
376
- },
377
- onEnd(span) {
378
- checkDeniedResponse(span);
379
- checkLlmConsumption(span);
380
- },
381
- shutdown() {
382
- return Promise.resolve();
383
- },
384
- forceFlush() {
385
- return Promise.resolve();
386
- }
387
- };
324
+ const detect = options.detectSuspiciousRequests !== false;
325
+ const forceKeep = options.forceKeepSuspicious !== false;
326
+ const metricsEnabled = options.metrics !== false;
327
+ const deniedStatuses = new Set(options.deniedStatuses ?? SECURITY_DENIED_STATUSES);
328
+ const now = options.now ?? Date.now;
329
+ const patterns = {
330
+ ...SUSPICIOUS_REQUEST_PATTERNS,
331
+ ...options.extraPatterns
332
+ };
333
+ const burst = resolveBurstConfig(options.burst);
334
+ const llm = resolveLlmConfig(options.llm);
335
+ const counters = {
336
+ suspicious: lazyCounter(SECURITY_METRICS.httpSuspicious, "Requests matching suspicious-path patterns"),
337
+ denied: lazyCounter(SECURITY_METRICS.httpDenied, "HTTP responses with denied status codes (401/403/429)"),
338
+ anomaly: lazyCounter(SECURITY_METRICS.anomaly, "Security anomaly signals (e.g. auth-failure bursts)")
339
+ };
340
+ function count(which, attributes) {
341
+ if (!metricsEnabled) return;
342
+ counters[which].add(1, attributes);
343
+ }
344
+ function emit(signal) {
345
+ try {
346
+ options.onSignal?.(signal);
347
+ } catch {}
348
+ }
349
+ function checkDeniedResponse(span) {
350
+ const status = readAttribute(span.attributes, HTTP_STATUS_ATTRIBUTES);
351
+ if (typeof status !== "number" || !deniedStatuses.has(status)) return;
352
+ count("denied", { status });
353
+ if (!burst || !burst.statuses.has(status)) return;
354
+ const key = readAttribute(span.attributes, [burst.keyAttribute, "http.client_ip"]);
355
+ if (typeof key !== "string" || key.length === 0) return;
356
+ const { before, after } = burst.window.record(key, now());
357
+ if (before < burst.threshold && after >= burst.threshold) {
358
+ count("anomaly", {
359
+ signal: "auth_failure_burst",
360
+ status
361
+ });
362
+ emit({
363
+ signal: "auth_failure_burst",
364
+ key,
365
+ count: after,
366
+ windowMs: burst.windowMs,
367
+ status
368
+ });
369
+ }
370
+ }
371
+ function checkLlmConsumption(span) {
372
+ if (!llm) return;
373
+ const total = readAttribute(span.attributes, ["gen_ai.usage.total_tokens"]);
374
+ let tokens;
375
+ if (typeof total === "number") tokens = total;
376
+ else {
377
+ const input = readAttribute(span.attributes, ["gen_ai.usage.input_tokens"]);
378
+ const output = readAttribute(span.attributes, ["gen_ai.usage.output_tokens"]);
379
+ if (typeof input === "number" || typeof output === "number") tokens = (typeof input === "number" ? input : 0) + (typeof output === "number" ? output : 0);
380
+ }
381
+ if (tokens === void 0 || tokens <= 0) return;
382
+ if (llm.maxTokensPerCall !== void 0 && tokens > llm.maxTokensPerCall) {
383
+ const model = readAttribute(span.attributes, ["gen_ai.response.model", "gen_ai.request.model"]);
384
+ count("anomaly", { signal: "llm_excessive_tokens" });
385
+ emit({
386
+ signal: "llm_excessive_tokens",
387
+ tokens,
388
+ maxTokens: llm.maxTokensPerCall,
389
+ ...typeof model === "string" && { model }
390
+ });
391
+ }
392
+ const budget = llm.budget;
393
+ if (!budget) return;
394
+ const key = readAttribute(span.attributes, [budget.keyAttribute, "client.address"]);
395
+ if (typeof key !== "string" || key.length === 0) return;
396
+ const { before, after } = budget.window.record(key, now(), tokens);
397
+ if (before < budget.budget && after >= budget.budget) {
398
+ count("anomaly", { signal: "llm_token_budget_exceeded" });
399
+ emit({
400
+ signal: "llm_token_budget_exceeded",
401
+ key,
402
+ tokens: after,
403
+ budget: budget.budget,
404
+ windowMs: budget.windowMs
405
+ });
406
+ }
407
+ }
408
+ return {
409
+ onStart(span) {
410
+ if (!detect) return;
411
+ const target = readAttribute(span.attributes, TARGET_ATTRIBUTES);
412
+ if (typeof target !== "string" || target.length === 0) return;
413
+ for (const [name, pattern] of Object.entries(patterns)) {
414
+ if (!pattern.test(target)) continue;
415
+ span.setAttribute(SECURITY_ATTR.suspiciousRequest, true);
416
+ span.setAttribute(SECURITY_ATTR.signal, name);
417
+ if (forceKeep) {
418
+ span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
419
+ span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
420
+ }
421
+ count("suspicious", { pattern: name });
422
+ emit({
423
+ signal: "suspicious_request",
424
+ pattern: name,
425
+ target
426
+ });
427
+ return;
428
+ }
429
+ },
430
+ onEnd(span) {
431
+ checkDeniedResponse(span);
432
+ checkLlmConsumption(span);
433
+ },
434
+ shutdown() {
435
+ return Promise.resolve();
436
+ },
437
+ forceFlush() {
438
+ return Promise.resolve();
439
+ }
440
+ };
388
441
  }
442
+
443
+ //#endregion
444
+ //#region src/security-heartbeat.ts
389
445
  function startSecurityHeartbeat(options = {}) {
390
- const intervalMs = options.intervalMs ?? 6e4;
391
- const attributes = options.attributes ?? {};
392
- const counter = lazyCounter(
393
- SECURITY_METRICS.heartbeat,
394
- "Security-telemetry liveness signal \u2014 alert on its absence"
395
- );
396
- function beat() {
397
- counter.add(1, attributes);
398
- }
399
- beat();
400
- const timer = setInterval(beat, intervalMs);
401
- timer.unref?.();
402
- let stopped = false;
403
- return {
404
- stop() {
405
- if (stopped) return;
406
- stopped = true;
407
- clearInterval(timer);
408
- }
409
- };
446
+ const intervalMs = options.intervalMs ?? 6e4;
447
+ const attributes = options.attributes ?? {};
448
+ const counter = lazyCounter(SECURITY_METRICS.heartbeat, "Security-telemetry liveness signal — alert on its absence");
449
+ function beat() {
450
+ counter.add(1, attributes);
451
+ }
452
+ beat();
453
+ const timer = setInterval(beat, intervalMs);
454
+ timer.unref?.();
455
+ let stopped = false;
456
+ return { stop() {
457
+ if (stopped) return;
458
+ stopped = true;
459
+ clearInterval(timer);
460
+ } };
410
461
  }
411
462
 
412
- // src/index.ts
463
+ //#endregion
464
+ //#region src/index.ts
413
465
  function flattenAuditAttributes(metadata) {
414
- const attributes = {
415
- "autotel.audit": true
416
- };
417
- for (const [key, value] of Object.entries(metadata)) {
418
- const attr = toAttributeValue(value);
419
- if (attr !== void 0) {
420
- attributes[`audit.${key}`] = attr;
421
- }
422
- }
423
- return attributes;
466
+ const attributes = { "autotel.audit": true };
467
+ for (const [key, value] of Object.entries(metadata)) {
468
+ const attr = toAttributeValue(value);
469
+ if (attr !== void 0) attributes[`audit.${key}`] = attr;
470
+ }
471
+ return attributes;
424
472
  }
425
473
  function forceKeepAuditEvent(ctx) {
426
- const traceCtx = resolveContext(ctx);
427
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
428
- traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
429
- traceCtx.setAttribute("autotel.audit.force_keep", true);
474
+ const traceCtx = resolveContextSafe(ctx);
475
+ if (!traceCtx) return;
476
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
477
+ traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
478
+ traceCtx.setAttribute("autotel.audit.force_keep", true);
430
479
  }
431
480
  function setAuditAttributes(metadata, ctx) {
432
- const traceCtx = resolveContext(ctx);
433
- traceCtx.setAttributes(flattenAuditAttributes(metadata));
481
+ const traceCtx = resolveContextSafe(ctx);
482
+ if (!traceCtx) return;
483
+ traceCtx.setAttributes(flattenAuditAttributes(metadata));
434
484
  }
435
485
  async function withAudit(metadata, fn, options = {}) {
436
- const traceCtx = resolveContext(options.ctx);
437
- if (options.forceKeep !== false) {
438
- forceKeepAuditEvent(traceCtx);
439
- }
440
- setAuditAttributes(metadata, traceCtx);
441
- const logger = options.logger ?? getRequestLogger();
442
- logger.set({
443
- audit: {
444
- ...metadata,
445
- forceKeep: options.forceKeep !== false
446
- }
447
- });
448
- try {
449
- const result = await fn(traceCtx, logger);
450
- if (!metadata.outcome) {
451
- setAuditAttributes({ ...metadata, outcome: "success" }, traceCtx);
452
- }
453
- if (options.emitNow) {
454
- logger.emitNow();
455
- }
456
- return result;
457
- } catch (error) {
458
- const asError = error instanceof Error ? error : new Error(String(error));
459
- setAuditAttributes({ ...metadata, outcome: "failure" }, traceCtx);
460
- logger.error(asError, {
461
- audit: {
462
- action: metadata.action,
463
- resource: metadata.resource
464
- }
465
- });
466
- if (options.emitNow) {
467
- logger.emitNow();
468
- }
469
- throw asError;
470
- }
486
+ const traceCtx = resolveContextSafe(options.ctx);
487
+ if (!traceCtx) {
488
+ const mode = options.onMissingContext ?? "warn";
489
+ if (mode === "throw") throw new Error(MISSING_CONTEXT_MESSAGE);
490
+ if (mode === "warn") warnMissingContextOnce(metadata.action);
491
+ return fn(noopAuditContext(), options.logger ?? createNoopRequestLogger());
492
+ }
493
+ if (options.forceKeep !== false) forceKeepAuditEvent(traceCtx);
494
+ setAuditAttributes(metadata, traceCtx);
495
+ let logger = options.logger ?? getRequestLoggerSafe() ?? void 0;
496
+ if (!logger) {
497
+ if ((options.onMissingContext ?? "warn") === "warn") warnMissingLoggerOnce(metadata.action);
498
+ logger = createNoopRequestLogger();
499
+ }
500
+ logger.set({ audit: {
501
+ ...metadata,
502
+ forceKeep: options.forceKeep !== false
503
+ } });
504
+ try {
505
+ const result = await fn(traceCtx, logger);
506
+ if (!metadata.outcome) setAuditAttributes({
507
+ ...metadata,
508
+ outcome: "success"
509
+ }, traceCtx);
510
+ if (options.emitNow) logger.emitNow();
511
+ return result;
512
+ } catch (error) {
513
+ const asError = error instanceof Error ? error : new Error(String(error));
514
+ setAuditAttributes({
515
+ ...metadata,
516
+ outcome: "failure"
517
+ }, traceCtx);
518
+ logger.error(asError, { audit: {
519
+ action: metadata.action,
520
+ resource: metadata.resource
521
+ } });
522
+ if (options.emitNow) logger.emitNow();
523
+ throw asError;
524
+ }
471
525
  }
472
526
 
527
+ //#endregion
473
528
  export { SUSPICIOUS_REQUEST_PATTERNS, createSecuritySignalProcessor, forceKeepAuditEvent, hashIdentifier, securityEvent, setAuditAttributes, startSecurityHeartbeat, withAudit, withSecurity };
474
- //# sourceMappingURL=index.js.map
475
529
  //# sourceMappingURL=index.js.map