autotel-audit 0.3.2 → 0.4.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 +107 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +69 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +69 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -3
- package/src/context.ts +0 -145
- package/src/index.test.ts +0 -183
- package/src/index.ts +0 -153
- package/src/lazy-counter.ts +0 -24
- package/src/security-heartbeat.test.ts +0 -65
- package/src/security-heartbeat.ts +0 -63
- package/src/security-signals.test.ts +0 -490
- package/src/security-signals.ts +0 -472
- package/src/security.test.ts +0 -342
- package/src/security.ts +0 -334
package/src/security.test.ts
DELETED
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
hashIdentifier,
|
|
4
|
-
securityEvent,
|
|
5
|
-
withSecurity,
|
|
6
|
-
type SecurityEventMetadata,
|
|
7
|
-
} from './security';
|
|
8
|
-
|
|
9
|
-
const setAttribute = vi.fn();
|
|
10
|
-
const setAttributes = vi.fn();
|
|
11
|
-
const mockCtx = {
|
|
12
|
-
traceId: 'trace-1',
|
|
13
|
-
spanId: 'span-1',
|
|
14
|
-
correlationId: 'corr-1',
|
|
15
|
-
setAttribute,
|
|
16
|
-
setAttributes,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const logger = {
|
|
20
|
-
set: vi.fn(),
|
|
21
|
-
info: vi.fn(),
|
|
22
|
-
warn: vi.fn(),
|
|
23
|
-
error: vi.fn(),
|
|
24
|
-
getContext: vi.fn(() => ({})),
|
|
25
|
-
emitNow: vi.fn(),
|
|
26
|
-
fork: vi.fn(),
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const counterAdd = vi.fn();
|
|
30
|
-
|
|
31
|
-
vi.mock('autotel', () => ({
|
|
32
|
-
AUTOTEL_SAMPLING_TAIL_EVALUATED: 'autotel.sampling.tail.evaluated',
|
|
33
|
-
AUTOTEL_SAMPLING_TAIL_KEEP: 'autotel.sampling.tail.keep',
|
|
34
|
-
createCounter: vi.fn(() => ({ add: counterAdd })),
|
|
35
|
-
REDACTOR_PATTERNS: {
|
|
36
|
-
sensitiveKey:
|
|
37
|
-
/^(password|passwd|pwd|secret|token|api[_-]?key|auth|credential|private[_-]?key|authorization)$/i,
|
|
38
|
-
},
|
|
39
|
-
getRequestLogger: vi.fn(() => logger),
|
|
40
|
-
getRequestLoggerSafe: vi.fn(() => logger),
|
|
41
|
-
createNoopRequestLogger: vi.fn(() => logger),
|
|
42
|
-
getTraceContext: vi.fn(() => mockCtx),
|
|
43
|
-
otelTrace: {
|
|
44
|
-
getActiveSpan: vi.fn(() => ({
|
|
45
|
-
setAttribute,
|
|
46
|
-
setAttributes,
|
|
47
|
-
spanContext: () => ({ traceId: 'trace-1', spanId: 'span-1' }),
|
|
48
|
-
})),
|
|
49
|
-
},
|
|
50
|
-
}));
|
|
51
|
-
|
|
52
|
-
describe('securityEvent', () => {
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
vi.clearAllMocks();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('writes security.* attributes with the stable schema', () => {
|
|
58
|
-
securityEvent(
|
|
59
|
-
{
|
|
60
|
-
name: 'auth.login.failed',
|
|
61
|
-
category: 'authentication',
|
|
62
|
-
outcome: 'failure',
|
|
63
|
-
severity: 'warning',
|
|
64
|
-
actorId: 'user-1',
|
|
65
|
-
tenantId: 'tenant-1',
|
|
66
|
-
reason: 'invalid_password',
|
|
67
|
-
},
|
|
68
|
-
{ ctx: mockCtx as never },
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
72
|
-
expect.objectContaining({
|
|
73
|
-
'autotel.security': true,
|
|
74
|
-
'security.event': 'auth.login.failed',
|
|
75
|
-
'security.category': 'authentication',
|
|
76
|
-
'security.outcome': 'failure',
|
|
77
|
-
'security.severity': 'warning',
|
|
78
|
-
'security.actor_id': 'user-1',
|
|
79
|
-
'security.tenant_id': 'tenant-1',
|
|
80
|
-
'security.reason': 'invalid_password',
|
|
81
|
-
}),
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('force-keeps through tail sampling by default', () => {
|
|
86
|
-
securityEvent(
|
|
87
|
-
{ name: 'access.denied', category: 'authorization', outcome: 'denied' },
|
|
88
|
-
{ ctx: mockCtx as never },
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
expect(setAttribute).toHaveBeenCalledWith(
|
|
92
|
-
'autotel.sampling.tail.evaluated',
|
|
93
|
-
true,
|
|
94
|
-
);
|
|
95
|
-
expect(setAttribute).toHaveBeenCalledWith('autotel.sampling.tail.keep', true);
|
|
96
|
-
expect(setAttribute).toHaveBeenCalledWith(
|
|
97
|
-
'autotel.security.force_keep',
|
|
98
|
-
true,
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('can opt out of force-keep', () => {
|
|
103
|
-
securityEvent(
|
|
104
|
-
{ name: 'auth.login.success', category: 'authentication', outcome: 'success' },
|
|
105
|
-
{ ctx: mockCtx as never, forceKeep: false },
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
expect(setAttribute).not.toHaveBeenCalledWith(
|
|
109
|
-
'autotel.sampling.tail.keep',
|
|
110
|
-
true,
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('defaults severity to info', () => {
|
|
115
|
-
securityEvent(
|
|
116
|
-
{ name: 'config.changed', category: 'configuration', outcome: 'success' },
|
|
117
|
-
{ ctx: mockCtx as never },
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
121
|
-
expect.objectContaining({ 'security.severity': 'info' }),
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('drops values under credential-shaped keys', () => {
|
|
126
|
-
securityEvent(
|
|
127
|
-
{
|
|
128
|
-
name: 'api_key.created',
|
|
129
|
-
category: 'secrets',
|
|
130
|
-
outcome: 'success',
|
|
131
|
-
token: 'npm_70abc',
|
|
132
|
-
apiKey: 'sk-live-123',
|
|
133
|
-
keyId: 'key-1',
|
|
134
|
-
},
|
|
135
|
-
{ ctx: mockCtx as never },
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
const attrs = setAttributes.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
139
|
-
expect(attrs['security.token']).toBeUndefined();
|
|
140
|
-
expect(attrs['security.apiKey']).toBeUndefined();
|
|
141
|
-
expect(attrs['security.keyId']).toBe('key-1');
|
|
142
|
-
expect(attrs['security.dropped_keys']).toEqual(
|
|
143
|
-
expect.arrayContaining(['token', 'apiKey']),
|
|
144
|
-
);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('flattens custom metadata under security.*', () => {
|
|
148
|
-
securityEvent(
|
|
149
|
-
{
|
|
150
|
-
name: 'rate_limit.exceeded',
|
|
151
|
-
category: 'rate_limit',
|
|
152
|
-
outcome: 'blocked',
|
|
153
|
-
limit: 100,
|
|
154
|
-
windowSeconds: 60,
|
|
155
|
-
},
|
|
156
|
-
{ ctx: mockCtx as never },
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
160
|
-
expect.objectContaining({
|
|
161
|
-
'security.limit': 100,
|
|
162
|
-
'security.windowSeconds': 60,
|
|
163
|
-
}),
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('sets logger context and optionally emits', () => {
|
|
168
|
-
securityEvent(
|
|
169
|
-
{
|
|
170
|
-
name: 'webhook.signature.failed',
|
|
171
|
-
category: 'validation',
|
|
172
|
-
outcome: 'blocked',
|
|
173
|
-
severity: 'error',
|
|
174
|
-
reason: 'bad_signature',
|
|
175
|
-
},
|
|
176
|
-
{ ctx: mockCtx as never, emitNow: true },
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
expect(logger.set).toHaveBeenCalledWith({
|
|
180
|
-
security: {
|
|
181
|
-
name: 'webhook.signature.failed',
|
|
182
|
-
category: 'validation',
|
|
183
|
-
outcome: 'blocked',
|
|
184
|
-
severity: 'error',
|
|
185
|
-
reason: 'bad_signature',
|
|
186
|
-
forceKeep: true,
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
expect(logger.emitNow).toHaveBeenCalledTimes(1);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('feeds the autotel.security.events counter by default', () => {
|
|
193
|
-
securityEvent(
|
|
194
|
-
{
|
|
195
|
-
name: 'access.denied',
|
|
196
|
-
category: 'authorization',
|
|
197
|
-
outcome: 'denied',
|
|
198
|
-
severity: 'warning',
|
|
199
|
-
},
|
|
200
|
-
{ ctx: mockCtx as never },
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
expect(counterAdd).toHaveBeenCalledWith(1, {
|
|
204
|
-
event: 'access.denied',
|
|
205
|
-
category: 'authorization',
|
|
206
|
-
outcome: 'denied',
|
|
207
|
-
severity: 'warning',
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('can opt out of metrics', () => {
|
|
212
|
-
securityEvent(
|
|
213
|
-
{ name: 'auth.login.success', category: 'authentication', outcome: 'success' },
|
|
214
|
-
{ ctx: mockCtx as never, metrics: false },
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
expect(counterAdd).not.toHaveBeenCalled();
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe('withSecurity', () => {
|
|
222
|
-
beforeEach(() => {
|
|
223
|
-
vi.clearAllMocks();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('records the event with the given outcome on success', async () => {
|
|
227
|
-
const metadata: SecurityEventMetadata = {
|
|
228
|
-
name: 'api_key.created',
|
|
229
|
-
category: 'secrets',
|
|
230
|
-
outcome: 'success',
|
|
231
|
-
actorId: 'admin-1',
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const result = await withSecurity(metadata, async () => 'created', {
|
|
235
|
-
ctx: mockCtx as never,
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
expect(result).toBe('created');
|
|
239
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
240
|
-
expect.objectContaining({
|
|
241
|
-
'security.event': 'api_key.created',
|
|
242
|
-
'security.outcome': 'success',
|
|
243
|
-
}),
|
|
244
|
-
);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('records outcome error, logs, and rethrows on failure', async () => {
|
|
248
|
-
await expect(
|
|
249
|
-
withSecurity(
|
|
250
|
-
{
|
|
251
|
-
name: 'secret.accessed',
|
|
252
|
-
category: 'secrets',
|
|
253
|
-
outcome: 'success',
|
|
254
|
-
},
|
|
255
|
-
async () => {
|
|
256
|
-
throw new Error('vault unreachable');
|
|
257
|
-
},
|
|
258
|
-
{ ctx: mockCtx as never },
|
|
259
|
-
),
|
|
260
|
-
).rejects.toThrow('vault unreachable');
|
|
261
|
-
|
|
262
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
263
|
-
expect.objectContaining({
|
|
264
|
-
'security.outcome': 'error',
|
|
265
|
-
'security.severity': 'error',
|
|
266
|
-
}),
|
|
267
|
-
);
|
|
268
|
-
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('escalates an explicit low severity to error on failure', async () => {
|
|
272
|
-
await expect(
|
|
273
|
-
withSecurity(
|
|
274
|
-
{
|
|
275
|
-
name: 'api_key.created',
|
|
276
|
-
category: 'secrets',
|
|
277
|
-
outcome: 'success',
|
|
278
|
-
severity: 'info',
|
|
279
|
-
},
|
|
280
|
-
async () => {
|
|
281
|
-
throw new Error('boom');
|
|
282
|
-
},
|
|
283
|
-
{ ctx: mockCtx as never },
|
|
284
|
-
),
|
|
285
|
-
).rejects.toThrow('boom');
|
|
286
|
-
|
|
287
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
288
|
-
expect.objectContaining({
|
|
289
|
-
'security.outcome': 'error',
|
|
290
|
-
'security.severity': 'error',
|
|
291
|
-
}),
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('keeps an explicit critical severity on failure', async () => {
|
|
296
|
-
await expect(
|
|
297
|
-
withSecurity(
|
|
298
|
-
{
|
|
299
|
-
name: 'secret.rotation.failed',
|
|
300
|
-
category: 'secrets',
|
|
301
|
-
outcome: 'success',
|
|
302
|
-
severity: 'critical',
|
|
303
|
-
},
|
|
304
|
-
async () => {
|
|
305
|
-
throw new Error('boom');
|
|
306
|
-
},
|
|
307
|
-
{ ctx: mockCtx as never },
|
|
308
|
-
),
|
|
309
|
-
).rejects.toThrow('boom');
|
|
310
|
-
|
|
311
|
-
expect(setAttributes).toHaveBeenCalledWith(
|
|
312
|
-
expect.objectContaining({
|
|
313
|
-
'security.outcome': 'error',
|
|
314
|
-
'security.severity': 'critical',
|
|
315
|
-
}),
|
|
316
|
-
);
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
describe('hashIdentifier', () => {
|
|
321
|
-
it('is stable for the same input', () => {
|
|
322
|
-
expect(hashIdentifier('user@example.com')).toBe(
|
|
323
|
-
hashIdentifier('user@example.com'),
|
|
324
|
-
);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it('differs across inputs and salts', () => {
|
|
328
|
-
expect(hashIdentifier('a@example.com')).not.toBe(
|
|
329
|
-
hashIdentifier('b@example.com'),
|
|
330
|
-
);
|
|
331
|
-
expect(hashIdentifier('a@example.com', { salt: 's1' })).not.toBe(
|
|
332
|
-
hashIdentifier('a@example.com', { salt: 's2' }),
|
|
333
|
-
);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it('never contains the raw value and defaults to 16 chars', () => {
|
|
337
|
-
const digest = hashIdentifier('user@example.com');
|
|
338
|
-
expect(digest).toHaveLength(16);
|
|
339
|
-
expect(digest).not.toContain('user');
|
|
340
|
-
expect(digest).toMatch(/^[0-9a-f]+$/);
|
|
341
|
-
});
|
|
342
|
-
});
|
package/src/security.ts
DELETED
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
AUTOTEL_SAMPLING_TAIL_EVALUATED,
|
|
4
|
-
AUTOTEL_SAMPLING_TAIL_KEEP,
|
|
5
|
-
REDACTOR_PATTERNS,
|
|
6
|
-
createNoopRequestLogger,
|
|
7
|
-
getRequestLoggerSafe,
|
|
8
|
-
} from 'autotel';
|
|
9
|
-
import type { RequestLogger } from 'autotel';
|
|
10
|
-
import {
|
|
11
|
-
SECURITY_ATTR,
|
|
12
|
-
SECURITY_METRICS,
|
|
13
|
-
escalateSecuritySeverity,
|
|
14
|
-
} from 'autotel/security-schema';
|
|
15
|
-
import type { SecuritySeverity } from 'autotel/security-schema';
|
|
16
|
-
import {
|
|
17
|
-
MISSING_CONTEXT_MESSAGE,
|
|
18
|
-
noopAuditContext,
|
|
19
|
-
resolveContextSafe,
|
|
20
|
-
toAttributeValue,
|
|
21
|
-
warnMissingContextOnce,
|
|
22
|
-
type AuditContext,
|
|
23
|
-
type OnMissingContext,
|
|
24
|
-
} from './context';
|
|
25
|
-
import { lazyCounter } from './lazy-counter';
|
|
26
|
-
|
|
27
|
-
export type { SecuritySeverity };
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Security event categories, aligned with OWASP A09:2025
|
|
31
|
-
* (Security Logging & Alerting Failures) and ASVS V7.
|
|
32
|
-
*/
|
|
33
|
-
export type SecurityEventCategory =
|
|
34
|
-
| 'authentication'
|
|
35
|
-
| 'authorization'
|
|
36
|
-
| 'data_access'
|
|
37
|
-
| 'admin_action'
|
|
38
|
-
| 'configuration'
|
|
39
|
-
| 'secrets'
|
|
40
|
-
| 'rate_limit'
|
|
41
|
-
| 'validation'
|
|
42
|
-
| 'supply_chain'
|
|
43
|
-
| 'llm';
|
|
44
|
-
|
|
45
|
-
export type SecurityOutcome =
|
|
46
|
-
| 'success'
|
|
47
|
-
| 'failure'
|
|
48
|
-
| 'denied'
|
|
49
|
-
| 'blocked'
|
|
50
|
-
| 'error';
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Well-known security event names. Free-form names are allowed —
|
|
54
|
-
* this union exists for autocomplete and consistency across services.
|
|
55
|
-
*/
|
|
56
|
-
export type SuggestedSecurityEventName =
|
|
57
|
-
| 'auth.login.success'
|
|
58
|
-
| 'auth.login.failed'
|
|
59
|
-
| 'auth.mfa.failed'
|
|
60
|
-
| 'auth.session.revoked'
|
|
61
|
-
| 'auth.password.reset'
|
|
62
|
-
| 'auth.account.locked'
|
|
63
|
-
| 'access.denied'
|
|
64
|
-
| 'access.role.changed'
|
|
65
|
-
| 'access.permission.changed'
|
|
66
|
-
| 'access.tenant.violation'
|
|
67
|
-
| 'admin.action'
|
|
68
|
-
| 'config.changed'
|
|
69
|
-
| 'secret.accessed'
|
|
70
|
-
| 'secret.rotation.failed'
|
|
71
|
-
| 'api_key.created'
|
|
72
|
-
| 'api_key.revoked'
|
|
73
|
-
| 'rate_limit.exceeded'
|
|
74
|
-
| 'validation.failed'
|
|
75
|
-
| 'webhook.signature.failed'
|
|
76
|
-
| 'dependency.scan.failed'
|
|
77
|
-
| 'llm.prompt_injection.detected'
|
|
78
|
-
| 'llm.tool_call.denied'
|
|
79
|
-
| 'llm.output.blocked';
|
|
80
|
-
|
|
81
|
-
export interface SecurityEventMetadata {
|
|
82
|
-
/** Stable, dot-separated event name, e.g. `auth.login.failed`. */
|
|
83
|
-
name: SuggestedSecurityEventName | (string & {});
|
|
84
|
-
category: SecurityEventCategory;
|
|
85
|
-
outcome: SecurityOutcome;
|
|
86
|
-
/** Defaults to `info`. */
|
|
87
|
-
severity?: SecuritySeverity;
|
|
88
|
-
/** Stable identifier of the actor — an id or a `hashIdentifier()` digest, never raw PII. */
|
|
89
|
-
actorId?: string;
|
|
90
|
-
targetType?: string;
|
|
91
|
-
targetId?: string;
|
|
92
|
-
tenantId?: string;
|
|
93
|
-
/** Short machine-readable reason, e.g. `invalid_password`. */
|
|
94
|
-
reason?: string;
|
|
95
|
-
[key: string]: unknown;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface SecurityEventOptions {
|
|
99
|
-
ctx?: AuditContext;
|
|
100
|
-
/**
|
|
101
|
-
* Security events are exempt from tail sampling by default —
|
|
102
|
-
* an attack you sampled away is an attack you cannot investigate.
|
|
103
|
-
* Pass `false` to opt out (e.g. very high-volume info events).
|
|
104
|
-
*/
|
|
105
|
-
forceKeep?: boolean;
|
|
106
|
-
emitNow?: boolean;
|
|
107
|
-
logger?: RequestLogger;
|
|
108
|
-
/**
|
|
109
|
-
* Also increment the `autotel.security.events` counter
|
|
110
|
-
* (attributes: event, category, outcome, severity) so security teams
|
|
111
|
-
* can alert on rates without log-based alerting. Default true.
|
|
112
|
-
*
|
|
113
|
-
* Cardinality note: the event name is a counter attribute — keep names
|
|
114
|
-
* to a stable catalogue, never interpolate user input into them.
|
|
115
|
-
*/
|
|
116
|
-
metrics?: boolean;
|
|
117
|
-
/**
|
|
118
|
-
* Behaviour when no trace context can be resolved. Defaults to `warn`
|
|
119
|
-
* (best-effort: record nothing, warn once). A dropped security event is still
|
|
120
|
-
* better than a crashed request — but the warning makes the gap visible.
|
|
121
|
-
*/
|
|
122
|
-
onMissingContext?: OnMissingContext;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export type WithSecurityOptions = SecurityEventOptions;
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Standard metadata fields and the schema attribute each maps to.
|
|
129
|
-
* Drives both standard-field emission and the reserved-key check for the
|
|
130
|
-
* custom-attribute loop — adding a field here is the whole change.
|
|
131
|
-
*/
|
|
132
|
-
const FIELD_ATTRIBUTES: Record<string, string> = {
|
|
133
|
-
name: SECURITY_ATTR.event,
|
|
134
|
-
category: SECURITY_ATTR.category,
|
|
135
|
-
outcome: SECURITY_ATTR.outcome,
|
|
136
|
-
severity: SECURITY_ATTR.severity,
|
|
137
|
-
actorId: SECURITY_ATTR.actorId,
|
|
138
|
-
targetType: SECURITY_ATTR.targetType,
|
|
139
|
-
targetId: SECURITY_ATTR.targetId,
|
|
140
|
-
tenantId: SECURITY_ATTR.tenantId,
|
|
141
|
-
reason: SECURITY_ATTR.reason,
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
function flattenSecurityAttributes(
|
|
145
|
-
metadata: SecurityEventMetadata,
|
|
146
|
-
): Record<string, string | number | boolean | string[] | number[] | boolean[]> {
|
|
147
|
-
const attributes: Record<
|
|
148
|
-
string,
|
|
149
|
-
string | number | boolean | string[] | number[] | boolean[]
|
|
150
|
-
> = {
|
|
151
|
-
[SECURITY_ATTR.marker]: true,
|
|
152
|
-
[SECURITY_ATTR.severity]: metadata.severity ?? 'info',
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const droppedKeys: string[] = [];
|
|
156
|
-
for (const [key, value] of Object.entries(metadata)) {
|
|
157
|
-
const standardAttribute = FIELD_ATTRIBUTES[key];
|
|
158
|
-
// Never emit values under credential-shaped custom keys, even by
|
|
159
|
-
// accident. Reuses the core redactor's sensitive-key pattern so the
|
|
160
|
-
// deny-list stays in one place.
|
|
161
|
-
if (
|
|
162
|
-
standardAttribute === undefined &&
|
|
163
|
-
REDACTOR_PATTERNS.sensitiveKey.test(key)
|
|
164
|
-
) {
|
|
165
|
-
droppedKeys.push(key);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const attr = toAttributeValue(value);
|
|
170
|
-
if (attr !== undefined) {
|
|
171
|
-
attributes[standardAttribute ?? `security.${key}`] = attr;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (droppedKeys.length > 0) {
|
|
176
|
-
attributes[SECURITY_ATTR.droppedKeys] = droppedKeys;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return attributes;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const eventsCounter = lazyCounter(
|
|
183
|
-
SECURITY_METRICS.events,
|
|
184
|
-
'Security events by name, category, outcome, and severity',
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
function countSecurityEvent(metadata: SecurityEventMetadata): void {
|
|
188
|
-
eventsCounter.add(1, {
|
|
189
|
-
event: metadata.name,
|
|
190
|
-
category: metadata.category,
|
|
191
|
-
outcome: metadata.outcome,
|
|
192
|
-
severity: metadata.severity ?? 'info',
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Record a security event on the active trace and request logger.
|
|
198
|
-
*
|
|
199
|
-
* Events are force-kept through tail sampling by default and carry
|
|
200
|
-
* `security.*` attributes (`security.event`, `security.category`,
|
|
201
|
-
* `security.outcome`, `security.severity`) so backends can build
|
|
202
|
-
* detection rules and dashboards from a stable schema.
|
|
203
|
-
*
|
|
204
|
-
* ```typescript
|
|
205
|
-
* securityEvent({
|
|
206
|
-
* name: 'auth.login.failed',
|
|
207
|
-
* category: 'authentication',
|
|
208
|
-
* outcome: 'failure',
|
|
209
|
-
* severity: 'warning',
|
|
210
|
-
* actorId: hashIdentifier(email),
|
|
211
|
-
* reason: 'invalid_password',
|
|
212
|
-
* });
|
|
213
|
-
* ```
|
|
214
|
-
*/
|
|
215
|
-
export function securityEvent(
|
|
216
|
-
metadata: SecurityEventMetadata,
|
|
217
|
-
options: SecurityEventOptions = {},
|
|
218
|
-
): void {
|
|
219
|
-
const traceCtx = resolveContextSafe(options.ctx);
|
|
220
|
-
|
|
221
|
-
// Counters are independent of trace context — always record the security signal
|
|
222
|
-
// even when there's no span to attach attributes to.
|
|
223
|
-
if (options.metrics !== false) {
|
|
224
|
-
countSecurityEvent(metadata);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (!traceCtx) {
|
|
228
|
-
const mode = options.onMissingContext ?? 'warn';
|
|
229
|
-
if (mode === 'throw') {
|
|
230
|
-
throw new Error(MISSING_CONTEXT_MESSAGE);
|
|
231
|
-
}
|
|
232
|
-
if (mode === 'warn') {
|
|
233
|
-
warnMissingContextOnce(metadata.name);
|
|
234
|
-
}
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (options.forceKeep !== false) {
|
|
239
|
-
traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true);
|
|
240
|
-
traceCtx.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, true);
|
|
241
|
-
traceCtx.setAttribute(SECURITY_ATTR.forceKeep, true);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
traceCtx.setAttributes(flattenSecurityAttributes(metadata));
|
|
245
|
-
|
|
246
|
-
const logger = options.logger ?? getRequestLoggerSafe() ?? createNoopRequestLogger();
|
|
247
|
-
logger.set({
|
|
248
|
-
security: {
|
|
249
|
-
name: metadata.name,
|
|
250
|
-
category: metadata.category,
|
|
251
|
-
outcome: metadata.outcome,
|
|
252
|
-
severity: metadata.severity ?? 'info',
|
|
253
|
-
...(metadata.reason !== undefined && { reason: metadata.reason }),
|
|
254
|
-
forceKeep: options.forceKeep !== false,
|
|
255
|
-
},
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
if (options.emitNow) {
|
|
259
|
-
logger.emitNow();
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Wrap a security-sensitive operation. On success the event outcome is
|
|
265
|
-
* recorded as given (default `success`); a thrown error records
|
|
266
|
-
* `outcome: 'error'`, escalates the severity to at least `error`, and
|
|
267
|
-
* rethrows.
|
|
268
|
-
*
|
|
269
|
-
* ```typescript
|
|
270
|
-
* await withSecurity(
|
|
271
|
-
* { name: 'api_key.created', category: 'secrets', outcome: 'success', actorId: userId },
|
|
272
|
-
* async () => createApiKey(userId),
|
|
273
|
-
* );
|
|
274
|
-
* ```
|
|
275
|
-
*/
|
|
276
|
-
export async function withSecurity<T>(
|
|
277
|
-
metadata: SecurityEventMetadata,
|
|
278
|
-
fn: (ctx: AuditContext, logger: RequestLogger) => T | Promise<T>,
|
|
279
|
-
options: WithSecurityOptions = {},
|
|
280
|
-
): Promise<T> {
|
|
281
|
-
const traceCtx = resolveContextSafe(options.ctx);
|
|
282
|
-
const logger =
|
|
283
|
-
options.logger ?? getRequestLoggerSafe() ?? createNoopRequestLogger();
|
|
284
|
-
const ctx = traceCtx ?? noopAuditContext();
|
|
285
|
-
|
|
286
|
-
try {
|
|
287
|
-
const result = await fn(ctx, logger);
|
|
288
|
-
securityEvent(metadata, { ...options, ctx: traceCtx ?? undefined, logger });
|
|
289
|
-
return result;
|
|
290
|
-
} catch (error) {
|
|
291
|
-
const asError = error instanceof Error ? error : new Error(String(error));
|
|
292
|
-
securityEvent(
|
|
293
|
-
{
|
|
294
|
-
...metadata,
|
|
295
|
-
outcome: 'error',
|
|
296
|
-
// A failed security-sensitive operation is never less than an error,
|
|
297
|
-
// but an explicit `critical` stays critical.
|
|
298
|
-
severity: escalateSecuritySeverity(metadata.severity ?? 'info', 'error'),
|
|
299
|
-
},
|
|
300
|
-
{ ...options, ctx: traceCtx ?? undefined, logger },
|
|
301
|
-
);
|
|
302
|
-
logger.error(asError, {
|
|
303
|
-
security: {
|
|
304
|
-
name: metadata.name,
|
|
305
|
-
category: metadata.category,
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
throw asError;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export interface HashIdentifierOptions {
|
|
313
|
-
/** Optional salt; use one stable per-deployment salt to defeat rainbow lookups. */
|
|
314
|
-
salt?: string;
|
|
315
|
-
/** Digest length in hex chars (default 16). */
|
|
316
|
-
length?: number;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Stable one-way digest for correlating PII-bearing identifiers
|
|
321
|
-
* (emails, IPs) across events WITHOUT logging the raw value.
|
|
322
|
-
*
|
|
323
|
-
* NOT for secrets — never log secrets in any form, hashed or not.
|
|
324
|
-
*/
|
|
325
|
-
export function hashIdentifier(
|
|
326
|
-
value: string,
|
|
327
|
-
options: HashIdentifierOptions = {},
|
|
328
|
-
): string {
|
|
329
|
-
const length = options.length ?? 16;
|
|
330
|
-
return createHash('sha256')
|
|
331
|
-
.update(options.salt ? `${options.salt}:${value}` : value)
|
|
332
|
-
.digest('hex')
|
|
333
|
-
.slice(0, length);
|
|
334
|
-
}
|