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.cjs
CHANGED
|
@@ -1,477 +1,531 @@
|
|
|
1
|
-
'
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
464
|
+
//#endregion
|
|
465
|
+
//#region src/index.ts
|
|
415
466
|
function flattenAuditAttributes(metadata) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|