mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44:12 +00:00
- Remove obsolete instruction documents (PROMPT_*.md, SECURITY_FIXES.md) - Update CLAUDE.md with security features, test suite, audit logs - Merge Techstack_Dokumentation into INFRASTRUCTURE.md - Update SECURITY.md with custom login route documentation - Add changelog to TODO.md - Update email service and data masking for SMTP error handling - Extend test coverage for CSRF and data masking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
547 lines
16 KiB
TypeScript
547 lines
16 KiB
TypeScript
/**
|
|
* Data Masking Unit Tests
|
|
*
|
|
* Tests for the data masking module.
|
|
* Covers field detection, pattern matching, recursive masking, and special formats.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import {
|
|
maskString,
|
|
maskObject,
|
|
maskError,
|
|
isSensitiveField,
|
|
safeStringify,
|
|
createSafeLogger,
|
|
maskSmtpError,
|
|
} from '@/lib/security/data-masking'
|
|
|
|
describe('Data Masking', () => {
|
|
describe('isSensitiveField', () => {
|
|
it('detects password field', () => {
|
|
expect(isSensitiveField('password')).toBe(true)
|
|
expect(isSensitiveField('PASSWORD')).toBe(true)
|
|
expect(isSensitiveField('userPassword')).toBe(true)
|
|
expect(isSensitiveField('password_hash')).toBe(true)
|
|
})
|
|
|
|
it('detects token fields', () => {
|
|
expect(isSensitiveField('token')).toBe(true)
|
|
expect(isSensitiveField('accessToken')).toBe(true)
|
|
expect(isSensitiveField('refreshToken')).toBe(true)
|
|
expect(isSensitiveField('apiToken')).toBe(true)
|
|
})
|
|
|
|
it('detects API key fields', () => {
|
|
expect(isSensitiveField('apiKey')).toBe(true)
|
|
expect(isSensitiveField('api_key')).toBe(true)
|
|
expect(isSensitiveField('apikey')).toBe(true)
|
|
})
|
|
|
|
it('detects secret fields', () => {
|
|
expect(isSensitiveField('secret')).toBe(true)
|
|
expect(isSensitiveField('clientSecret')).toBe(true)
|
|
expect(isSensitiveField('payloadSecret')).toBe(true)
|
|
})
|
|
|
|
it('detects credential fields', () => {
|
|
expect(isSensitiveField('credentials')).toBe(true)
|
|
expect(isSensitiveField('authCredentials')).toBe(true)
|
|
})
|
|
|
|
it('detects private key fields', () => {
|
|
expect(isSensitiveField('privateKey')).toBe(true)
|
|
expect(isSensitiveField('private_key')).toBe(true)
|
|
})
|
|
|
|
it('detects SMTP-related fields', () => {
|
|
expect(isSensitiveField('smtpPassword')).toBe(true)
|
|
expect(isSensitiveField('smtp_pass')).toBe(true)
|
|
})
|
|
|
|
it('returns false for non-sensitive fields', () => {
|
|
expect(isSensitiveField('email')).toBe(false)
|
|
expect(isSensitiveField('name')).toBe(false)
|
|
expect(isSensitiveField('id')).toBe(false)
|
|
expect(isSensitiveField('status')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('maskString', () => {
|
|
it('masks JWT tokens', () => {
|
|
const jwt =
|
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
|
|
|
|
const masked = maskString(jwt)
|
|
|
|
// JWT format: keeps header, redacts payload and signature
|
|
expect(masked).toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
|
|
expect(masked).toContain('[PAYLOAD REDACTED]')
|
|
expect(masked).toContain('[SIGNATURE REDACTED]')
|
|
})
|
|
|
|
it('masks connection strings', () => {
|
|
const connectionString = 'postgresql://user:secret123@localhost:5432/mydb'
|
|
|
|
const masked = maskString(connectionString)
|
|
|
|
expect(masked).toContain('[REDACTED]')
|
|
expect(masked).not.toContain('secret123')
|
|
})
|
|
|
|
it('masks private keys (inline format)', () => {
|
|
// Test with inline private key format
|
|
const privateKey = '-----BEGIN RSA PRIVATE KEY-----MIIEpAIBAAKCAQEA...-----END RSA PRIVATE KEY-----'
|
|
|
|
const masked = maskString(privateKey)
|
|
|
|
expect(masked).toBe('[PRIVATE KEY REDACTED]')
|
|
})
|
|
|
|
it('masks password patterns in text', () => {
|
|
const text = 'User logged in with password=secret123 successfully'
|
|
|
|
const masked = maskString(text)
|
|
|
|
// The pattern replaces password=... with password: [REDACTED] or pass: [REDACTED]
|
|
expect(masked).toContain('[REDACTED]')
|
|
expect(masked).not.toContain('secret123')
|
|
})
|
|
|
|
it('preserves non-sensitive text', () => {
|
|
const text = 'Normal log message without sensitive data'
|
|
|
|
const masked = maskString(text)
|
|
|
|
expect(masked).toBe(text)
|
|
})
|
|
|
|
it('handles empty string', () => {
|
|
expect(maskString('')).toBe('')
|
|
})
|
|
|
|
it('handles null/undefined gracefully', () => {
|
|
// The function returns the input for non-strings
|
|
expect(maskString(null as unknown as string)).toBeNull()
|
|
expect(maskString(undefined as unknown as string)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('maskObject', () => {
|
|
it('masks sensitive fields in flat object', () => {
|
|
const obj = {
|
|
email: 'user@example.com',
|
|
password: 'supersecret',
|
|
name: 'John Doe',
|
|
}
|
|
|
|
const masked = maskObject(obj)
|
|
|
|
expect(masked.email).toBe('user@example.com')
|
|
expect(masked.password).toBe('[REDACTED]')
|
|
expect(masked.name).toBe('John Doe')
|
|
})
|
|
|
|
it('masks sensitive fields in nested object', () => {
|
|
const obj = {
|
|
user: {
|
|
email: 'user@example.com',
|
|
credentials: {
|
|
password: 'secret',
|
|
apiKey: 'key-12345',
|
|
},
|
|
},
|
|
status: 'active',
|
|
}
|
|
|
|
const masked = maskObject(obj) as typeof obj
|
|
|
|
expect(masked.user.email).toBe('user@example.com')
|
|
// credentials itself is a sensitive field name
|
|
expect(masked.user.credentials).toBe('[REDACTED OBJECT]')
|
|
expect(masked.status).toBe('active')
|
|
})
|
|
|
|
it('masks arrays with sensitive values', () => {
|
|
const obj = {
|
|
tokens: ['token1', 'token2'],
|
|
users: [{ name: 'Alice', password: 'secret1' }, { name: 'Bob', password: 'secret2' }],
|
|
}
|
|
|
|
const masked = maskObject(obj) as typeof obj
|
|
|
|
// tokens is a sensitive field name (contains 'token')
|
|
expect(masked.tokens).toBe('[REDACTED OBJECT]')
|
|
expect(masked.users[0].name).toBe('Alice')
|
|
expect(masked.users[0].password).toBe('[REDACTED]')
|
|
})
|
|
|
|
it('handles circular references gracefully', () => {
|
|
const obj: Record<string, unknown> = { name: 'test' }
|
|
obj.self = obj
|
|
|
|
// Should not throw
|
|
expect(() => maskObject(obj)).not.toThrow()
|
|
})
|
|
|
|
it('respects depth limit', () => {
|
|
const deepObj = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: {
|
|
level5: {
|
|
password: 'secret',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
const masked = maskObject(deepObj, { maxDepth: 3 })
|
|
|
|
// Deep nested should still be handled
|
|
expect(masked).toBeDefined()
|
|
})
|
|
|
|
it('preserves specified fields', () => {
|
|
const obj = {
|
|
password: 'secret',
|
|
debugPassword: 'debug-secret',
|
|
}
|
|
|
|
const masked = maskObject(obj, { preserveFields: ['debugPassword'] })
|
|
|
|
expect(masked.password).toBe('[REDACTED]')
|
|
expect(masked.debugPassword).toBe('debug-secret')
|
|
})
|
|
|
|
it('masks additional custom fields', () => {
|
|
const obj = {
|
|
customSecret: 'my-custom-value',
|
|
normalField: 'normal',
|
|
}
|
|
|
|
const masked = maskObject(obj, { additionalFields: ['customSecret'] })
|
|
|
|
expect(masked.customSecret).toBe('[REDACTED]')
|
|
expect(masked.normalField).toBe('normal')
|
|
})
|
|
|
|
it('masks string values containing secrets', () => {
|
|
const obj = {
|
|
config:
|
|
'database_url=postgresql://admin:password123@localhost/db apiKey=sk-12345',
|
|
}
|
|
|
|
const masked = maskObject(obj) as typeof obj
|
|
|
|
expect(masked.config).not.toContain('password123')
|
|
})
|
|
|
|
it('handles null and undefined values', () => {
|
|
const obj = {
|
|
password: null,
|
|
token: undefined,
|
|
name: 'test',
|
|
}
|
|
|
|
const masked = maskObject(obj)
|
|
|
|
expect(masked.password).toBeNull()
|
|
expect(masked.token).toBeUndefined()
|
|
expect(masked.name).toBe('test')
|
|
})
|
|
})
|
|
|
|
describe('maskError', () => {
|
|
it('masks error message', () => {
|
|
const error = new Error('Database connection failed: password=secret123')
|
|
|
|
const masked = maskError(error)
|
|
|
|
expect(masked.message).not.toContain('secret123')
|
|
expect(masked.message).toContain('[REDACTED]')
|
|
})
|
|
|
|
it('masks error stack', () => {
|
|
const error = new Error('Error with password=secret')
|
|
|
|
const masked = maskError(error)
|
|
|
|
if (masked.stack) {
|
|
expect(masked.stack).not.toContain('secret')
|
|
}
|
|
})
|
|
|
|
it('preserves error structure', () => {
|
|
const error = new Error('Test error')
|
|
error.name = 'CustomError'
|
|
|
|
const masked = maskError(error)
|
|
|
|
expect(masked.name).toBe('CustomError')
|
|
expect(masked.message).toBe('Test error')
|
|
})
|
|
|
|
it('handles non-Error objects', () => {
|
|
const notAnError = { message: 'password=secret', code: 500 }
|
|
|
|
const masked = maskError(notAnError as Error)
|
|
|
|
expect(masked).toBeDefined()
|
|
})
|
|
|
|
it('handles string errors', () => {
|
|
const stringError = 'Connection failed: apiKey=12345'
|
|
|
|
const masked = maskError(stringError as unknown as Error)
|
|
|
|
expect(masked).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('safeStringify', () => {
|
|
it('stringifies and masks object', () => {
|
|
const obj = {
|
|
user: 'admin',
|
|
password: 'secret',
|
|
}
|
|
|
|
const result = safeStringify(obj)
|
|
|
|
expect(result).toContain('admin')
|
|
expect(result).toContain('[REDACTED]')
|
|
expect(result).not.toContain('secret')
|
|
})
|
|
|
|
it('handles circular references', () => {
|
|
const obj: Record<string, unknown> = { name: 'test' }
|
|
obj.self = obj
|
|
|
|
// Should not throw
|
|
expect(() => safeStringify(obj)).not.toThrow()
|
|
})
|
|
|
|
it('formats with spaces when specified', () => {
|
|
const obj = { name: 'test' }
|
|
|
|
const result = safeStringify(obj, 2)
|
|
|
|
expect(result).toContain('\n')
|
|
})
|
|
|
|
it('handles arrays', () => {
|
|
const arr = [{ password: 'secret' }, { name: 'test' }]
|
|
|
|
const result = safeStringify(arr)
|
|
|
|
expect(result).toContain('[REDACTED]')
|
|
expect(result).toContain('test')
|
|
})
|
|
})
|
|
|
|
describe('createSafeLogger', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
})
|
|
|
|
it('creates logger with info method', () => {
|
|
const logger = createSafeLogger('TestModule')
|
|
|
|
logger.info('Test message')
|
|
|
|
expect(console.log).toHaveBeenCalledWith(
|
|
expect.stringContaining('[TestModule]'),
|
|
expect.any(String),
|
|
expect.any(String),
|
|
)
|
|
})
|
|
|
|
it('masks sensitive data in log messages', () => {
|
|
const logger = createSafeLogger('Auth')
|
|
|
|
logger.info('User login', { password: 'secret123' })
|
|
|
|
const lastCall = (console.log as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
const logOutput = JSON.stringify(lastCall)
|
|
|
|
expect(logOutput).toContain('[REDACTED]')
|
|
expect(logOutput).not.toContain('secret123')
|
|
})
|
|
|
|
it('provides error method', () => {
|
|
const logger = createSafeLogger('Error')
|
|
|
|
logger.error('Error occurred', new Error('password=secret'))
|
|
|
|
expect(console.error).toHaveBeenCalled()
|
|
})
|
|
|
|
it('provides warn method', () => {
|
|
const logger = createSafeLogger('Warn')
|
|
|
|
logger.warn('Warning', { apiKey: 'key-123' })
|
|
|
|
expect(console.warn).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('maskSmtpError', () => {
|
|
it('masks SMTP authentication failure with credentials', () => {
|
|
const error = 'AUTH LOGIN failed: Username and Password not accepted.'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).toContain('[')
|
|
expect(masked).not.toContain('Username and Password')
|
|
})
|
|
|
|
it('masks 535 authentication error codes with details', () => {
|
|
const error = '535 5.7.8 Error: Username and Password not accepted for user@example.com'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).not.toContain('user@example.com')
|
|
expect(masked).toContain('535')
|
|
})
|
|
|
|
it('masks invalid login errors with user details', () => {
|
|
const error = 'Invalid login: 535-5.7.8 Username and Password not accepted'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).toContain('[')
|
|
})
|
|
|
|
it('masks connection strings in SMTP errors', () => {
|
|
const error = 'Connection failed to smtp://admin:secret123@mail.example.com:587'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).not.toContain('secret123')
|
|
expect(masked).toContain('[REDACTED]')
|
|
})
|
|
|
|
it('preserves safe SMTP error codes', () => {
|
|
const error = '530 5.7.0 Must issue a STARTTLS command first'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
// Should keep the diagnostic error code
|
|
expect(masked).toContain('530')
|
|
expect(masked).toContain('STARTTLS')
|
|
})
|
|
|
|
it('truncates excessively long error messages', () => {
|
|
const longError = 'Error: ' + 'A'.repeat(500)
|
|
|
|
const masked = maskSmtpError(longError)
|
|
|
|
expect(masked!.length).toBeLessThanOrEqual(203) // 200 + "..."
|
|
expect(masked).toContain('...')
|
|
})
|
|
|
|
it('handles null and undefined gracefully', () => {
|
|
expect(maskSmtpError(null)).toBeNull()
|
|
expect(maskSmtpError(undefined)).toBeNull()
|
|
})
|
|
|
|
it('handles empty string', () => {
|
|
expect(maskSmtpError('')).toBeNull()
|
|
})
|
|
|
|
it('passes through simple error messages unchanged', () => {
|
|
const error = 'Connection timeout'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).toBe('Connection timeout')
|
|
})
|
|
|
|
it('masks Base64 encoded credentials in AUTH', () => {
|
|
const error = 'AUTH PLAIN dXNlcm5hbWU6cGFzc3dvcmQxMjM= failed'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).not.toContain('dXNlcm5hbWU6cGFzc3dvcmQxMjM=')
|
|
})
|
|
|
|
it('masks password= patterns in SMTP error details', () => {
|
|
const error = 'SMTP config: host=mail.example.com password=supersecret123'
|
|
|
|
const masked = maskSmtpError(error)
|
|
|
|
expect(masked).not.toContain('supersecret123')
|
|
expect(masked).toContain('[REDACTED]')
|
|
})
|
|
})
|
|
|
|
describe('Real-world Scenarios', () => {
|
|
it('masks SMTP configuration', () => {
|
|
const smtpConfig = {
|
|
host: 'smtp.example.com',
|
|
port: 587,
|
|
smtpUser: 'sender@example.com',
|
|
smtpPassword: 'smtp-password-123',
|
|
}
|
|
|
|
const masked = maskObject(smtpConfig) as typeof smtpConfig
|
|
|
|
expect(masked.host).toBe('smtp.example.com')
|
|
expect(masked.smtpUser).toBe('sender@example.com')
|
|
expect(masked.smtpPassword).toBe('[REDACTED]')
|
|
})
|
|
|
|
it('masks tenant data', () => {
|
|
const tenant = {
|
|
id: 1,
|
|
name: 'Test Tenant',
|
|
smtpSettings: {
|
|
host: 'smtp.tenant.com',
|
|
password: 'tenant-smtp-pass',
|
|
apiKey: 'sendgrid-key-123',
|
|
},
|
|
}
|
|
|
|
const masked = maskObject(tenant) as typeof tenant
|
|
|
|
expect(masked.id).toBe(1)
|
|
expect(masked.name).toBe('Test Tenant')
|
|
expect(masked.smtpSettings.host).toBe('smtp.tenant.com')
|
|
expect(masked.smtpSettings.password).toBe('[REDACTED]')
|
|
expect(masked.smtpSettings.apiKey).toBe('[REDACTED]')
|
|
})
|
|
|
|
it('masks audit log data', () => {
|
|
const auditLog = {
|
|
action: 'login_failed',
|
|
userEmail: 'user@example.com',
|
|
metadata: {
|
|
attemptedPassword: 'wrong-password',
|
|
ipAddress: '192.168.1.1',
|
|
},
|
|
}
|
|
|
|
const masked = maskObject(auditLog) as typeof auditLog
|
|
|
|
expect(masked.action).toBe('login_failed')
|
|
expect(masked.userEmail).toBe('user@example.com')
|
|
expect(masked.metadata.attemptedPassword).toBe('[REDACTED]')
|
|
expect(masked.metadata.ipAddress).toBe('192.168.1.1')
|
|
})
|
|
|
|
it('masks database connection errors', () => {
|
|
const error = new Error(
|
|
'FATAL: password authentication failed for user "admin" at host "db.example.com"',
|
|
)
|
|
|
|
const masked = maskError(error)
|
|
|
|
expect(masked.message).not.toContain('authentication failed')
|
|
})
|
|
})
|
|
})
|