UNPKG

autotel

Version:
764 lines (644 loc) 23 kB
/** * Tests for AttributeRedactingProcessor */ import { describe, it, expect, beforeEach } from 'vitest'; import { AttributeRedactingProcessor, REDACTOR_PATTERNS, REDACTOR_PRESETS, createRedactedSpan, normalizeAttributeRedactorConfig, type AttributeRedactorFn, type AttributeRedactorConfig, } from './attribute-redacting-processor'; import type { SpanProcessor, ReadableSpan, } from '@opentelemetry/sdk-trace-base'; import type { Context } from '@opentelemetry/api'; import type { Span } from '@opentelemetry/sdk-trace-base'; /** * Mock span processor to capture forwarded spans */ class MockSpanProcessor implements SpanProcessor { public startedSpans: Span[] = []; public endedSpans: ReadableSpan[] = []; public flushed = false; public shutdownCalled = false; onStart(span: Span): void { this.startedSpans.push(span); } onEnd(span: ReadableSpan): void { this.endedSpans.push(span); } async forceFlush(): Promise<void> { this.flushed = true; } async shutdown(): Promise<void> { this.shutdownCalled = true; } } /** * Create a mock ReadableSpan with given attributes */ function createMockReadableSpan( attributes: Record<string, unknown>, ): ReadableSpan { return { name: 'test-span', kind: 0, spanContext: () => ({ traceId: 'trace123', spanId: 'span123', traceFlags: 1, }), startTime: [0, 0], endTime: [1, 0], status: { code: 0 }, attributes, links: [], events: [], duration: [1, 0], ended: true, resource: { attributes: {} }, instrumentationScope: { name: 'test', version: '1.0.0' }, droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0, } as unknown as ReadableSpan; } /** * Create a mock mutable Span */ function createMockSpan(): Span { return { name: 'test-span', spanContext: () => ({ traceId: 'trace123', spanId: 'span123', traceFlags: 1, }), } as unknown as Span; } describe('AttributeRedactingProcessor', () => { let mockProcessor: MockSpanProcessor; beforeEach(() => { mockProcessor = new MockSpanProcessor(); }); describe('custom redactor function', () => { it('should redact attributes using custom function', () => { const redactor: AttributeRedactorFn = (key, value) => { if (key === 'password') return '[REDACTED]'; return value; }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: { redactor }, }); const span = createMockReadableSpan({ password: 'secret123', username: 'john', }); processor.onEnd(span); expect(mockProcessor.endedSpans).toHaveLength(1); expect(mockProcessor.endedSpans[0]!.attributes.password).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes.username).toBe('john'); }); it('should forward span to wrapped processor', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ test: 'value' }); processor.onEnd(span); expect(mockProcessor.endedSpans).toHaveLength(1); }); it('should pass through onStart unchanged', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockSpan(); processor.onStart(span, {} as Context); expect(mockProcessor.startedSpans).toHaveLength(1); }); }); describe('built-in presets', () => { describe('default preset', () => { it('should redact email addresses with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'user.email': 'john.doe@example.com', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['user.email']).toBe( 'j***@***.com', ); }); it('should redact phone numbers with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'user.phone': '555-123-4567', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['user.phone']).toBe( '********67', ); }); it('should redact SSNs', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'user.ssn': '123-45-6789', }); processor.onEnd(span); // SSN has no smart mask; falls back to the default replacement. expect(mockProcessor.endedSpans[0]!.attributes['user.ssn']).toBe( '[REDACTED]', ); }); it('should redact credit card numbers with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'payment.card': '4111-1111-1111-1111', }); processor.onEnd(span); // PCI-DSS compliant: last 4 digits preserved. expect(mockProcessor.endedSpans[0]!.attributes['payment.card']).toBe( '****1111', ); }); it('should redact sensitive keys by name', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ password: 'mypassword123', secret: 'mysecret', token: 'abc123token', apiKey: 'my-api-key', 'db.password': 'dbpass', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.password).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes.secret).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes.token).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes.apiKey).toBe( '[REDACTED]', ); // db.password doesn't match the pattern (not exact match) expect(mockProcessor.endedSpans[0]!.attributes['db.password']).toBe( 'dbpass', ); }); it('should not redact non-sensitive fields', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'user.id': '12345', 'http.method': 'GET', 'http.url': '/api/users', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['user.id']).toBe( '12345', ); expect(mockProcessor.endedSpans[0]!.attributes['http.method']).toBe( 'GET', ); expect(mockProcessor.endedSpans[0]!.attributes['http.url']).toBe( '/api/users', ); }); }); describe('strict preset', () => { it('should redact Bearer tokens with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'strict', }); const span = createMockReadableSpan({ 'http.header.authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', }); processor.onEnd(span); expect( mockProcessor.endedSpans[0]!.attributes['http.header.authorization'], ).toBe('Bearer ***'); }); it('should redact JWTs with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'strict', }); const span = createMockReadableSpan({ 'auth.token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['auth.token']).toBe( 'eyJ***.***', ); }); it('should redact API keys in values', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'strict', }); const span = createMockReadableSpan({ 'request.query': 'apiKey=abc123def456', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['request.query']).toBe( '[REDACTED]', ); }); }); describe('pci-dss preset', () => { it('should redact credit card numbers', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'pci-dss', }); const span = createMockReadableSpan({ 'payment.cardNumber': '4111111111111111', }); processor.onEnd(span); expect( mockProcessor.endedSpans[0]!.attributes['payment.cardNumber'], ).toBe('[REDACTED]'); }); it('should redact card-related keys', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'pci-dss', }); const span = createMockReadableSpan({ cardNumber: '4111111111111111', cvv: '123', pan: '4111111111111111', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.cardNumber).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes.cvv).toBe('[REDACTED]'); expect(mockProcessor.endedSpans[0]!.attributes.pan).toBe('[REDACTED]'); }); }); }); describe('custom configuration', () => { it('should use custom key patterns', () => { const config: AttributeRedactorConfig = { keyPatterns: [/internal_id/i], replacement: '***', }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: config, }); const span = createMockReadableSpan({ internal_id: 'secret-internal-123', public_id: 'public-456', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.internal_id).toBe('***'); expect(mockProcessor.endedSpans[0]!.attributes.public_id).toBe( 'public-456', ); }); it('should use custom value patterns', () => { const config: AttributeRedactorConfig = { builtins: false, valuePatterns: [ { name: 'customerId', pattern: /CUST-\d{8}/g, replacement: 'CUST-***', }, ], }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: config, }); const span = createMockReadableSpan({ 'order.customer': 'CUST-12345678', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['order.customer']).toBe( 'CUST-***', ); }); it('should use custom replacement string', () => { const config: AttributeRedactorConfig = { keyPatterns: [/^secret$/], replacement: '<<HIDDEN>>', }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: config, }); const span = createMockReadableSpan({ secret: 'my-secret-value', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.secret).toBe('<<HIDDEN>>'); }); it('should redact exact dot-path matches from paths config', () => { const config: AttributeRedactorConfig = { builtins: false, paths: ['user.password'], }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: config, }); const span = createMockReadableSpan({ 'user.password': 'super-secret', 'billing.password': 'keep-this', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['user.password']).toBe( '[REDACTED]', ); expect(mockProcessor.endedSpans[0]!.attributes['billing.password']).toBe( 'keep-this', ); }); }); describe('array handling', () => { it('should redact PII in string arrays with smart masking', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'user.emails': ['john@example.com', 'jane@example.org'], }); processor.onEnd(span); const redactedEmails = mockProcessor.endedSpans[0]!.attributes[ 'user.emails' ] as string[]; expect(redactedEmails[0]).toBe('j***@***.com'); expect(redactedEmails[1]).toBe('j***@***.org'); }); it('should preserve non-string array elements', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'request.ids': [1, 2, 3], }); processor.onEnd(span); const ids = mockProcessor.endedSpans[0]!.attributes[ 'request.ids' ] as number[]; expect(ids).toEqual([1, 2, 3]); }); }); describe('non-string values', () => { it('should preserve numeric values', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'http.status_code': 200, 'request.duration_ms': 150.5, }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes['http.status_code']).toBe( 200, ); expect( mockProcessor.endedSpans[0]!.attributes['request.duration_ms'], ).toBe(150.5); }); it('should preserve boolean values', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ 'request.authenticated': true, 'cache.hit': false, }); processor.onEnd(span); expect( mockProcessor.endedSpans[0]!.attributes['request.authenticated'], ).toBe(true); expect(mockProcessor.endedSpans[0]!.attributes['cache.hit']).toBe(false); }); }); describe('error handling', () => { it('should forward original span if redactor throws (fail-open)', () => { const redactor: AttributeRedactorFn = () => { throw new Error('Redactor error'); }; const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: { redactor }, }); const span = createMockReadableSpan({ 'user.email': 'john@example.com', }); processor.onEnd(span); // Should not throw and should forward original span expect(mockProcessor.endedSpans).toHaveLength(1); expect(mockProcessor.endedSpans[0]!.attributes['user.email']).toBe( 'john@example.com', ); }); it('should throw for unknown preset', () => { expect(() => { new AttributeRedactingProcessor(mockProcessor, { redactor: 'unknown-preset' as 'default', }); }).toThrow('Unknown attribute redactor preset'); }); }); describe('lifecycle methods', () => { it('should forward forceFlush to wrapped processor', async () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); await processor.forceFlush(); expect(mockProcessor.flushed).toBe(true); }); it('should forward shutdown to wrapped processor', async () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); await processor.shutdown(); expect(mockProcessor.shutdownCalled).toBe(true); }); }); }); describe('createRedactedSpan', () => { it('should create a proxy that intercepts attributes', () => { const span = createMockReadableSpan({ password: 'secret123', username: 'john', }); const redactor: AttributeRedactorFn = (key, value) => { if (key === 'password') return '[REDACTED]'; return value; }; const redactedSpan = createRedactedSpan(span, redactor); expect(redactedSpan.attributes.password).toBe('[REDACTED]'); expect(redactedSpan.attributes.username).toBe('john'); }); it('should preserve other span properties', () => { const span = createMockReadableSpan({ test: 'value' }); const redactor: AttributeRedactorFn = (_, value) => value; const redactedSpan = createRedactedSpan(span, redactor); expect(redactedSpan.name).toBe('test-span'); expect(redactedSpan.duration).toEqual([1, 0]); expect(redactedSpan.spanContext().traceId).toBe('trace123'); }); it('should handle methods correctly', () => { const span = createMockReadableSpan({ test: 'value' }); const redactor: AttributeRedactorFn = (_, value) => value; const redactedSpan = createRedactedSpan(span, redactor); // spanContext is a method that should still work const context = redactedSpan.spanContext(); expect(context.traceId).toBe('trace123'); expect(context.spanId).toBe('span123'); }); }); describe('REDACTOR_PATTERNS', () => { it('should export regex patterns for advanced users', () => { expect(REDACTOR_PATTERNS.email).toBeInstanceOf(RegExp); expect(REDACTOR_PATTERNS.phone).toBeInstanceOf(RegExp); expect(REDACTOR_PATTERNS.ssn).toBeInstanceOf(RegExp); expect(REDACTOR_PATTERNS.creditCard).toBeInstanceOf(RegExp); expect(REDACTOR_PATTERNS.bearerToken).toBeInstanceOf(RegExp); expect(REDACTOR_PATTERNS.sensitiveKey).toBeInstanceOf(RegExp); }); describe('pattern matching', () => { it('email should match email addresses', () => { REDACTOR_PATTERNS.email.lastIndex = 0; expect(REDACTOR_PATTERNS.email.test('john@example.com')).toBe(true); REDACTOR_PATTERNS.email.lastIndex = 0; expect( REDACTOR_PATTERNS.email.test('john.doe+test@sub.example.org'), ).toBe(true); }); it('creditCard should match credit card numbers', () => { // Reset lastIndex due to global flag REDACTOR_PATTERNS.creditCard.lastIndex = 0; expect(REDACTOR_PATTERNS.creditCard.test('4111111111111111')).toBe(true); REDACTOR_PATTERNS.creditCard.lastIndex = 0; expect(REDACTOR_PATTERNS.creditCard.test('4111-1111-1111-1111')).toBe( true, ); REDACTOR_PATTERNS.creditCard.lastIndex = 0; expect(REDACTOR_PATTERNS.creditCard.test('4111 1111 1111 1111')).toBe( true, ); }); it('ssn should match SSN patterns', () => { REDACTOR_PATTERNS.ssn.lastIndex = 0; expect(REDACTOR_PATTERNS.ssn.test('123-45-6789')).toBe(true); REDACTOR_PATTERNS.ssn.lastIndex = 0; expect(REDACTOR_PATTERNS.ssn.test('123456789')).toBe(true); }); it('phone should match US phone numbers', () => { REDACTOR_PATTERNS.phone.lastIndex = 0; expect(REDACTOR_PATTERNS.phone.test('555-123-4567')).toBe(true); REDACTOR_PATTERNS.phone.lastIndex = 0; expect(REDACTOR_PATTERNS.phone.test('555.123.4567')).toBe(true); REDACTOR_PATTERNS.phone.lastIndex = 0; expect(REDACTOR_PATTERNS.phone.test('5551234567')).toBe(true); }); }); }); describe('REDACTOR_PRESETS', () => { it('should export preset configurations for advanced users', () => { expect(REDACTOR_PRESETS['default']).toBeDefined(); expect(REDACTOR_PRESETS['strict']).toBeDefined(); expect(REDACTOR_PRESETS['pci-dss']).toBeDefined(); }); it('presets should have required properties', () => { expect(REDACTOR_PRESETS['default'].replacement).toBe('[REDACTED]'); expect(REDACTOR_PRESETS['default'].keyPatterns).toBeDefined(); expect(REDACTOR_PRESETS['default'].valuePatterns).toBeDefined(); }); }); describe('edge cases', () => { let mockProcessor: MockSpanProcessor; beforeEach(() => { mockProcessor = new MockSpanProcessor(); }); it('should handle empty attributes', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({}); processor.onEnd(span); expect(mockProcessor.endedSpans).toHaveLength(1); expect(Object.keys(mockProcessor.endedSpans[0]!.attributes)).toHaveLength( 0, ); }); it('should handle partial email redaction in mixed content', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ message: 'User john@example.com signed up', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.message).toBe( 'User j***@***.com signed up', ); }); it('should handle multiple PII in same value', () => { const processor = new AttributeRedactingProcessor(mockProcessor, { redactor: 'default', }); const span = createMockReadableSpan({ contacts: 'Email: john@example.com, Phone: +1 555-123-4567', }); processor.onEnd(span); expect(mockProcessor.endedSpans[0]!.attributes.contacts).toBe( 'Email: j***@***.com, Phone: +1******67', ); }); }); describe('normalizeAttributeRedactorConfig', () => { it('should preserve preset strings', () => { expect(normalizeAttributeRedactorConfig('default')).toBe('default'); }); it('should normalize regex-like values from serialized config', () => { const normalized = normalizeAttributeRedactorConfig({ keyPatterns: ['password'], patterns: [{ source: 'Bearer\\s+\\w+', flags: 'gi' }], valuePatterns: [ { name: 'customerId', pattern: { source: 'CUST-\\d{4}', flags: 'g' }, replacement: 'CUST-***', }, ], paths: ['user.token'], builtins: ['email', 'jwt'], replacement: '[MASKED]', }); expect(typeof normalized).toBe('object'); if (!normalized || typeof normalized === 'string') { throw new Error('Expected object config'); } expect(normalized.keyPatterns?.[0]).toBeInstanceOf(RegExp); expect(normalized.patterns?.[0]).toBeInstanceOf(RegExp); expect(normalized.valuePatterns?.[0]?.pattern).toBeInstanceOf(RegExp); expect(normalized.paths).toEqual(['user.token']); expect(normalized.builtins).toEqual(['email', 'jwt']); expect(normalized.replacement).toBe('[MASKED]'); }); it('should return undefined for unsupported raw values', () => { expect(normalizeAttributeRedactorConfig(42)).toBeUndefined(); expect(normalizeAttributeRedactorConfig(null)).toBeUndefined(); }); });