autotel-audit 0.3.2 → 0.4.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.
@@ -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
- }