autotel-audit 0.2.1 → 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.cjs +486 -433
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +175 -150
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +175 -150
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +485 -431
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/context.ts +81 -13
- package/src/index.test.ts +50 -0
- package/src/index.ts +47 -8
- package/src/security.test.ts +3 -0
- package/src/security.ts +43 -13
package/dist/index.js
CHANGED
|
@@ -1,475 +1,529 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash } from
|
|
3
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/index.ts
|
|
413
465
|
function flattenAuditAttributes(metadata) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|