import { describe, it, expect } from 'vitest'; import { sanitizeError } from './errorSanitizer.js'; describe('Error Sanitization', () => { it('should redact JWT tokens from error messages', () => { const error = new Error( 'Invalid token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' ); const sanitized = sanitizeError(error); expect(sanitized.message).toBe('Invalid token: [REDACTED]'); expect(sanitized.message).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'); }); it('should redact bearer tokens from error messages', () => { const error = new Error('Authentication failed with bearer abc123token456'); const sanitized = sanitizeError(error); expect(sanitized.message).toBe('Authentication failed with bearer [REDACTED]'); }); it('should redact API keys from error messages', () => { const error = new Error('API request failed: api_key=sk_live_abc123def456'); const sanitized = sanitizeError(error); expect(sanitized.message).toBe('API request failed: api_key=[REDACTED]'); }); it('should remove sensitive fields from error objects', () => { const error = new Error('Test error') as Error & { token?: string; apiKey?: string; password?: string; requestData?: { username: string; authorization: string }; }; error.token = 'secret-token-123'; error.apiKey = 'api-key-456'; error.password = 'mypassword'; error.requestData = { username: 'john', authorization: 'Bearer token123' }; const sanitized = sanitizeError(error) as Error & Record; expect(sanitized.token).toBe('[REDACTED]'); expect(sanitized.apiKey).toBe('[REDACTED]'); expect(sanitized.password).toBe('[REDACTED]'); expect((sanitized.requestData as Record).username).toBe('john'); // Non-sensitive field preserved expect((sanitized.requestData as Record).authorization).toBe('Bearer [REDACTED]'); }); it('should sanitize stack traces', () => { const error = new Error('Test error'); error.stack = `Error: Invalid token eyJhbGciOiJIUzI1NiI... at AuthService.validateToken (/app/src/auth.js:42:15) at api_key=sk_live_abc123def456ghi789 (/app/src/api.js:20:10)`; const sanitized = sanitizeError(error); expect(sanitized.stack).toContain('[REDACTED]'); expect(sanitized.stack).not.toContain('eyJhbGciOiJIUzI1NiI'); expect(sanitized.stack).not.toContain('sk_live_abc123def456ghi789'); }); it('should preserve error type and non-sensitive properties', () => { const error = new TypeError('Invalid token: abc123def456ghi789') as TypeError & { statusCode?: number; userId?: string; }; error.statusCode = 401; error.userId = 'user-123'; const sanitized = sanitizeError(error) as unknown as Error & Record; expect(sanitized.name).toBe('TypeError'); expect(sanitized.statusCode).toBe(401); expect(sanitized.userId).toBe('user-123'); expect(sanitized.message).toBe('Invalid token: [REDACTED]'); }); it('should handle non-Error objects', () => { const errorObj = { message: 'Auth failed with token abc123def456ghi789jkl012', token: 'secret-token', data: { user: 'john', password: 'secret123' } }; const sanitized = sanitizeError(errorObj) as unknown as Record; expect(sanitized.message).toBe('Auth failed with token [REDACTED]'); expect(sanitized.token).toBe('[REDACTED]'); expect((sanitized.data as Record).user).toBe('john'); expect((sanitized.data as Record).password).toBe('[REDACTED]'); }); it('should preserve short values after token/api_key (false positive prevention)', () => { const testCases = [ { input: 'Token does not have apps:update scope', expected: 'Token does not have apps:update scope' }, { input: 'token usage exceeded limit', expected: 'token usage exceeded limit' }, { input: 'API key not found', expected: 'API key not found' }, { input: 'access token expired', expected: 'access token expired' }, { input: 'refresh token is missing', expected: 'refresh token is missing' }, { input: 'token: abcdef012345678', expected: 'token: abcdef012345678' }, { input: 'token: abcdef0123456789', expected: 'token: [REDACTED]' } ]; for (const { input, expected } of testCases) { const error = new Error(input); const sanitized = sanitizeError(error); expect(sanitized.message).toBe(expected); } }); it('should not double-sanitize already sanitized errors', () => { const error = new Error('Invalid token: eyJhbGciOiJIUzI1NiI...'); const firstSanitization = sanitizeError(error) as Error & { _sanitized?: boolean }; const secondSanitization = sanitizeError(firstSanitization); expect(firstSanitization.message).toBe(secondSanitization.message); expect(firstSanitization._sanitized).toBe(true); }); it('should handle null and undefined gracefully', () => { expect(sanitizeError(null)).toBe(null); expect(sanitizeError(undefined)).toBe(undefined); }); describe('Headers Sanitization', () => { it('should sanitize authorization headers while preserving structure', () => { const error = new Error('Request failed') as Error & { headers?: Record; }; error.headers = { authorization: 'Bearer eyJhbGciOiJIUzI1NiI...', 'content-type': 'application/json', 'x-api-key': 'sk_live_abc123' }; const sanitized = sanitizeError(error) as Error & Record; expect((sanitized.headers as Record).authorization).toBe('Bearer [REDACTED]'); expect((sanitized.headers as Record)['content-type']).toBe('application/json'); // Non-sensitive preserved expect((sanitized.headers as Record)['x-api-key']).toBe('[REDACTED]'); // Contains 'key' -> completely redacted }); it('should sanitize headers in HTTP request/response objects', () => { const error = new Error('HTTP request failed') as Error & { request?: { url: string; method: string; headers: Record }; response?: { status: number; headers: Record }; }; error.request = { url: 'https://api.example.com/users', method: 'GET', headers: { Authorization: 'Bearer token123', Cookie: 'session=abc123; auth_token=xyz789', 'User-Agent': 'MyApp/1.0' } }; error.response = { status: 401, headers: { 'WWW-Authenticate': 'Bearer realm="api"', 'Set-Cookie': 'refresh_token=def456; HttpOnly', 'Content-Type': 'application/json' } }; const sanitized = sanitizeError(error) as Error & Record; // Request headers expect(((sanitized.request as Record).headers as Record).Authorization).toBe('Bearer [REDACTED]'); expect(((sanitized.request as Record).headers as Record).Cookie).toBe('[REDACTED]'); // Contains 'cookie' -> completely redacted expect(((sanitized.request as Record).headers as Record)['User-Agent']).toBe('MyApp/1.0'); // Response headers expect(((sanitized.response as Record).headers as Record)['WWW-Authenticate']).toBe('[REDACTED]'); // Contains 'bearer' pattern -> redacted expect(((sanitized.response as Record).headers as Record)['Set-Cookie']).toBe('[REDACTED]'); // Contains 'cookie' -> completely redacted expect(((sanitized.response as Record).headers as Record)['Content-Type']).toBe('application/json'); }); it('should handle case-insensitive header names', () => { const error = new Error('Request failed') as Error & { Headers?: Record; }; error.Headers = { // Capital H - this field name doesn't contain sensitive keywords AUTHORIZATION: 'Bearer token123', authorization: 'Basic user:pass', 'X-API-KEY': 'secret-key' }; const sanitized = sanitizeError(error) as Error & Record; // Headers (capital H) is not in VALUE_SANITIZED_FIELDS, so individual fields get processed normally // But Basic auth pattern still applies for string value sanitization expect((sanitized.Headers as Record).AUTHORIZATION).toBe('Bearer [REDACTED]'); // Pattern matching on string value expect((sanitized.Headers as Record).authorization).toBe('Basic [REDACTED]'); // Basic auth pattern applies expect((sanitized.Headers as Record)['X-API-KEY']).toBe('[REDACTED]'); // Contains 'key' -> completely redacted }); it('should sanitize headers with various token formats', () => { const errorObj = { message: 'Authentication failed', headers: { authorization: 'JWT eyJhbGciOiJIUzI1NiI...', 'x-auth-token': 'Bearer abc123def456', 'api-key': 'sk_test_1234567890', cookie: 'sessionId=sess_abc123; csrfToken=csrf_xyz789', 'x-forwarded-for': '192.168.1.1' // Should be preserved } }; const sanitized = sanitizeError(errorObj) as unknown as Record; expect((sanitized.headers as Record).authorization).toBe('JWT [REDACTED]'); expect((sanitized.headers as Record)['x-auth-token']).toBe('[REDACTED]'); // Contains 'auth' -> completely redacted expect((sanitized.headers as Record)['api-key']).toBe('[REDACTED]'); // Contains 'key' -> completely redacted expect((sanitized.headers as Record).cookie).toBe('[REDACTED]'); // Contains 'cookie' -> completely redacted expect((sanitized.headers as Record)['x-forwarded-for']).toBe('192.168.1.1'); }); it('should handle nested headers in complex objects', () => { const error = new Error('Network error') as Error & { requestConfig?: { baseURL: string; timeout: number; headers: Record; data: { username: string; password: string }; }; }; error.requestConfig = { baseURL: 'https://api.example.com', timeout: 5000, headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiI...', 'Content-Type': 'application/json' }, data: { username: 'john', password: 'secret123' } }; const sanitized = sanitizeError(error) as Error & Record; expect((sanitized.requestConfig as Record).baseURL).toBe('https://api.example.com'); expect((sanitized.requestConfig as Record).timeout).toBe(5000); expect(((sanitized.requestConfig as Record).headers as Record).Authorization).toBe( 'Bearer [REDACTED]' ); expect(((sanitized.requestConfig as Record).headers as Record)['Content-Type']).toBe( 'application/json' ); expect(((sanitized.requestConfig as Record).data as Record).username).toBe('john'); expect(((sanitized.requestConfig as Record).data as Record).password).toBe('[REDACTED]'); }); it('should handle headers as strings vs objects', () => { const error = new Error('Invalid headers') as Error & { headers?: string; requestHeaders?: Record; }; error.headers = 'Authorization: Bearer token123\r\nContent-Type: application/json'; error.requestHeaders = { authorization: 'Basic dXNlcjpwYXNz', // base64 encoded user:pass 'user-agent': 'MyApp/1.0' }; const sanitized = sanitizeError(error) as Error & Record; // String headers get pattern-based sanitization (Bearer preserves prefix) expect(sanitized.headers).toBe('Authorization: Bearer [REDACTED]\r\nContent-Type: application/json'); // Any field named 'authorization' should be value-sanitized regardless of parent object name expect((sanitized.requestHeaders as Record).authorization).toBe('Basic [REDACTED]'); expect((sanitized.requestHeaders as Record)['user-agent']).toBe('MyApp/1.0'); }); it('should redact GitHub tokens', () => { const errorObj = { message: 'GitHub API failed', personalToken: 'ghp_1234567890abcdef1234567890abcdef12345678', appToken: 'ghs_abcdef1234567890abcdef1234567890abcdef12', finegrainedToken: 'github_pat_11ABCDEFG0001234567890_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', config: { auth: 'token ghp_1234567890abcdef1234567890abcdef12345678' } }; const sanitized = sanitizeError(errorObj) as unknown as Record; expect(sanitized.personalToken).toBe('[REDACTED]'); expect(sanitized.appToken).toBe('[REDACTED]'); expect(sanitized.finegrainedToken).toBe('[REDACTED]'); expect((sanitized.config as Record).auth).toBe('[REDACTED]'); // "token " pattern matches the whole thing }); it('should handle mixed secrets in complex error objects', () => { const error = new Error('Multiple services failed') as Error & { aws?: { accessKeyId: string; secretAccessKey: string; sessionToken: string }; github?: { token: string }; database?: { url: string }; nonSensitive?: { userId: string; timestamp: string }; }; error.aws = { accessKeyId: 'AKIAIOSFODNN7EXAMPLE', secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', sessionToken: 'aws_session_token_12345abcdef' }; error.github = { token: 'ghp_1234567890abcdef1234567890abcdef12345678' }; error.nonSensitive = { userId: 'user123', timestamp: '2023-01-01T00:00:00Z' }; const sanitized = sanitizeError(error) as Error & Record; // AWS credentials should be redacted expect((sanitized.aws as Record).accessKeyId).toBe('[REDACTED]'); expect((sanitized.aws as Record).secretAccessKey).toBe('[REDACTED]'); expect((sanitized.aws as Record).sessionToken).toBe('[REDACTED]'); // GitHub token should be redacted expect((sanitized.github as Record).token).toBe('[REDACTED]'); // Non-sensitive data should be preserved expect((sanitized.nonSensitive as Record).userId).toBe('user123'); expect((sanitized.nonSensitive as Record).timestamp).toBe('2023-01-01T00:00:00Z'); }); }); });