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