From af64c1297751b9e8129135d766f7ff96393a9f4b Mon Sep 17 00:00:00 2001 From: Rome Thorstenson Date: Fri, 30 Jan 2026 07:26:00 -0800 Subject: [PATCH] test(security): add comprehensive test suite for secret detection --- src/security/entropy.test.ts | 179 ++++++++++++++++ src/security/interactive-flow.test.ts | 200 ++++++++++++++++++ src/security/interactive-prompts.test.ts | 253 +++++++++++++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 src/security/entropy.test.ts create mode 100644 src/security/interactive-flow.test.ts create mode 100644 src/security/interactive-prompts.test.ts diff --git a/src/security/entropy.test.ts b/src/security/entropy.test.ts new file mode 100644 index 000000000..92dd0b310 --- /dev/null +++ b/src/security/entropy.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { + detectHighEntropyStrings, + redactSecrets, +} from './entropy.js'; + +describe('entropy detection', () => { + describe('detectHighEntropyStrings', () => { + it('should detect OpenAI API keys', () => { + const text = 'My API key is sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets).toHaveLength(1); + expect(result.secrets[0].type).toBe('api_key'); + expect(result.secrets[0].pattern).toContain('OpenAI'); + expect(result.secrets[0].confidence).toBe('high'); + }); + + it('should detect Anthropic API keys', () => { + const text = + 'Use this: sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKlMnOpQrStUvWxYz0123456789AbCdEfGhIjKlMnOpQrStUvWxYzAA'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('api_key'); + expect(result.secrets[0].pattern).toContain('Anthropic'); + }); + + it('should detect GitHub tokens', () => { + const text = 'Token: ghp_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op5678Qr78Qr'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('api_key'); + expect(result.secrets[0].pattern).toContain('GitHub'); + }); + + it('should detect Bearer tokens', () => { + const text = 'Authorization: Bearer AbCdEfGhIjKlMnOpQrStUvWxYz0123456789'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('bearer_token'); + expect(result.secrets[0].pattern).toContain('Bearer'); + }); + + it('should detect JWT tokens', () => { + const text = + 'JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('jwt'); + }); + + it('should detect private keys', () => { + const text = 'Here is my key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIE...'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('private_key'); + }); + + it('should detect generic secret assignments', () => { + const text = 'api_secret = "AbCdEfGhIjKlMnOpQrStUvWxYz0123"'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets[0].type).toBe('generic_secret'); + }); + + it('should detect high-entropy strings without pattern match', () => { + const text = 'Random secret: Kj8mN2pQ5rT9vXwZ3bC7dFgH1iLaMnOqPs4tUy6'; + const result = detectHighEntropyStrings(text, { minEntropyThreshold: 4.0, minLength: 20 }); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets.length).toBeGreaterThan(0); + }); + + it('should not flag UUIDs as secrets', () => { + const text = 'Request ID: 550e8400-e29b-41d4-a716-446655440000'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(false); + }); + + it('should not flag base64 images as secrets', () => { + const text = 'Image: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(false); + }); + + it('should not flag URLs as secrets', () => { + const text = 'Visit https://example.com/path?query=param&key=value'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(false); + }); + + it('should not flag short strings', () => { + const text = 'Short: abc123'; + const result = detectHighEntropyStrings(text, { minLength: 24 }); + + expect(result.hasSecrets).toBe(false); + }); + + it('should respect custom entropy threshold', () => { + const text = 'Medium entropy: abcdefghijklmnopqrstuvwxyz12345678'; + const lowThreshold = detectHighEntropyStrings(text, { minEntropyThreshold: 3.0 }); + const highThreshold = detectHighEntropyStrings(text, { minEntropyThreshold: 6.0 }); + + expect(lowThreshold.hasSecrets).toBe(true); + expect(highThreshold.hasSecrets).toBe(false); + }); + + it('should support custom patterns', () => { + const text = 'Custom token: CUSTOM-Ab12Cd34Ef56Gh78Ij90Kl12'; + const result = detectHighEntropyStrings(text, { + customPatterns: ['CUSTOM-[A-Za-z0-9]{24}'], + }); + + expect(result.hasSecrets).toBe(true); + }); + + it('should detect multiple secrets in one message', () => { + const text = + 'OpenAI: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56 and GitHub: ghp_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op5678Qr'; + const result = detectHighEntropyStrings(text); + + expect(result.hasSecrets).toBe(true); + expect(result.secrets.length).toBeGreaterThanOrEqual(2); + }); + + it('should sort secrets by position', () => { + const text = + 'First: ghp_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op5678Qr Second: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'; + const result = detectHighEntropyStrings(text); + + expect(result.secrets[0].start).toBeLessThan(result.secrets[1].start); + }); + }); + + describe('redactSecrets', () => { + it('should redact detected secrets', () => { + const text = 'My API key is sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'; + const result = detectHighEntropyStrings(text); + const redacted = redactSecrets(text, result.secrets); + + expect(redacted).toBe('My API key is [REDACTED]'); + }); + + it('should use custom placeholder', () => { + const text = 'Secret: ghp_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op5678Qr'; + const result = detectHighEntropyStrings(text); + const redacted = redactSecrets(text, result.secrets, '***'); + + expect(redacted).toBe('Secret: ***'); + }); + + it('should redact multiple secrets', () => { + const text = + 'Key1: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56 Key2: ghp_Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op5678Qr'; + const result = detectHighEntropyStrings(text); + const redacted = redactSecrets(text, result.secrets); + + expect(redacted).toBe('Key1: [REDACTED] Key2: [REDACTED]'); + }); + + it('should handle empty secrets array', () => { + const text = 'No secrets here'; + const redacted = redactSecrets(text, []); + + expect(redacted).toBe(text); + }); + }); + +}); diff --git a/src/security/interactive-flow.test.ts b/src/security/interactive-flow.test.ts new file mode 100644 index 000000000..5cd175ea3 --- /dev/null +++ b/src/security/interactive-flow.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { processSecretsInMessage } from './process-secrets.js'; +import { + generatePromptKey, + resolvePendingPrompt, + clearAllPendingPrompts, + hasPendingPrompt, +} from './interactive-prompts.js'; +import type { FinalizedMsgContext } from '../auto-reply/templating.js'; +import type { MoltbotConfig } from '../config/config.js'; + +describe('interactive security flow', () => { + beforeEach(() => { + clearAllPendingPrompts(); + }); + + afterEach(() => { + clearAllPendingPrompts(); + }); + + // Mock dispatcher for testing + function createMockDispatcher() { + const sentMessages: string[] = []; + return { + sendText: async (text: string) => { + sentMessages.push(text); + }, + messages: sentMessages, + }; + } + + // Mock config with interactive mode enabled + const interactiveConfig: MoltbotConfig = { + security: { + secrets: { + detection: { + enabled: true, + }, + handling: { + interactive: true, + defaultAction: 'redact', + confirmationTimeoutMs: 5000, + }, + }, + }, + }; + + // Mock context + function createMockContext(body: string): FinalizedMsgContext { + return { + Body: body, + BodyForAgent: body, + CommandBody: body, + BodyForCommands: body, + Provider: 'telegram', + SenderId: 'user123', + ChatType: 'dm', + SessionKey: 'test-session', + CommandAuthorized: true, + }; + } + + it('should send prompt and wait for user response (redact action)', async () => { + const ctx = createMockContext('My API key is sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + // Start processing (this will block until user responds) + const processPromise = processSecretsInMessage(ctx, interactiveConfig, dispatcher as any); + + // Wait a bit for the prompt to be registered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify prompt was sent + expect(dispatcher.messages.length).toBe(1); + expect(dispatcher.messages[0]).toContain('🔒 **Security Alert**'); + expect(dispatcher.messages[0]).toContain('Reply with **1**, **2**, or **3**'); + + // Verify pending prompt was registered + const promptKey = generatePromptKey('telegram', 'user123'); + expect(hasPendingPrompt(promptKey)).toBe(true); + + // Simulate user responding with "1" (redact) + resolvePendingPrompt(promptKey, 'redact'); + + // Wait for processing to complete + const result = await processPromise; + + // Verify result + expect(result.detected).toBe(true); + expect(result.modified).toBe(true); + expect(result.blocked).toBe(false); + expect(result.ctx.Body).toBe('My API key is [REDACTED]'); + }); + + it('should send prompt and wait for user response (cancel action)', async () => { + const ctx = createMockContext('Secret: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + const processPromise = processSecretsInMessage(ctx, interactiveConfig, dispatcher as any); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const promptKey = generatePromptKey('telegram', 'user123'); + resolvePendingPrompt(promptKey, 'cancel'); + + const result = await processPromise; + + expect(result.detected).toBe(true); + expect(result.modified).toBe(false); + expect(result.blocked).toBe(true); // Message should be blocked + }); + + it('should send prompt and wait for user response (allow action)', async () => { + const ctx = createMockContext('Key: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + const processPromise = processSecretsInMessage(ctx, interactiveConfig, dispatcher as any); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const promptKey = generatePromptKey('telegram', 'user123'); + resolvePendingPrompt(promptKey, 'allow'); + + const result = await processPromise; + + expect(result.detected).toBe(true); + expect(result.modified).toBe(false); // Not modified because allowed + expect(result.blocked).toBe(false); + expect(result.ctx.Body).toContain('sk-proj-'); // Secret still in message + }); + + it('should timeout and apply default action if no response', async () => { + const config: MoltbotConfig = { + security: { + secrets: { + detection: { enabled: true }, + handling: { + interactive: true, + defaultAction: 'redact', + confirmationTimeoutMs: 100, // Short timeout for testing + }, + }, + }, + }; + + const ctx = createMockContext('API: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + const result = await processSecretsInMessage(ctx, config, dispatcher as any); + + // Should have timed out and applied default action (redact) + expect(result.detected).toBe(true); + expect(result.modified).toBe(true); + expect(result.blocked).toBe(false); + expect(result.ctx.Body).toBe('API: [REDACTED]'); + }); + + it('should fall back to default action when interactive mode is disabled', async () => { + const config: MoltbotConfig = { + security: { + secrets: { + detection: { enabled: true }, + handling: { + interactive: false, // Disabled + defaultAction: 'redact', + }, + }, + }, + }; + + const ctx = createMockContext('Key: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + const result = await processSecretsInMessage(ctx, config, dispatcher as any); + + // Should have immediately applied default action without prompting + expect(result.detected).toBe(true); + expect(result.modified).toBe(true); + expect(result.blocked).toBe(false); + expect(result.ctx.Body).toBe('Key: [REDACTED]'); + expect(dispatcher.messages.length).toBe(0); // No prompt sent + }); + + it('should not detect secrets when detection is disabled', async () => { + const config: MoltbotConfig = { + security: { + secrets: { + detection: { enabled: false }, + }, + }, + }; + + const ctx = createMockContext('Key: sk-proj-Ab12Cd34Ef56Gh78Ij90Kl12Mn34Op56Qr78St90Uv12Wx34Yz56'); + const dispatcher = createMockDispatcher(); + + const result = await processSecretsInMessage(ctx, config, dispatcher as any); + + expect(result.detected).toBe(false); + expect(result.modified).toBe(false); + expect(result.ctx.Body).toContain('sk-proj-'); // Secret still in message + }); +}); diff --git a/src/security/interactive-prompts.test.ts b/src/security/interactive-prompts.test.ts new file mode 100644 index 000000000..127342e3c --- /dev/null +++ b/src/security/interactive-prompts.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + generatePromptKey, + registerPendingPrompt, + hasPendingPrompt, + getPendingPrompt, + resolvePendingPrompt, + cancelPendingPrompt, + parsePromptResponse, + clearAllPendingPrompts, + getPendingPromptCount, +} from './interactive-prompts.js'; +import type { DetectedSecret } from './entropy.js'; + +describe('interactive-prompts', () => { + beforeEach(() => { + clearAllPendingPrompts(); + }); + + afterEach(() => { + clearAllPendingPrompts(); + }); + + describe('generatePromptKey', () => { + it('should generate a unique key from channel and senderId', () => { + const key = generatePromptKey('telegram', 'user123'); + expect(key).toBe('telegram:user123'); + }); + + it('should generate different keys for different users', () => { + const key1 = generatePromptKey('telegram', 'user123'); + const key2 = generatePromptKey('telegram', 'user456'); + expect(key1).not.toBe(key2); + }); + }); + + describe('registerPendingPrompt', () => { + it('should register a pending prompt', async () => { + const secrets: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const key = 'telegram:user123'; + const promise = registerPendingPrompt(key, secrets, 5000); + + expect(hasPendingPrompt(key)).toBe(true); + expect(getPendingPromptCount()).toBe(1); + + // Resolve it + resolvePendingPrompt(key, 'redact'); + const result = await promise; + expect(result).toBe('redact'); + expect(hasPendingPrompt(key)).toBe(false); + }); + + it('should timeout and resolve with null', async () => { + const secrets: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const key = 'telegram:user123'; + const promise = registerPendingPrompt(key, secrets, 100); // 100ms timeout + + expect(hasPendingPrompt(key)).toBe(true); + + // Wait for timeout + const result = await promise; + expect(result).toBeNull(); + expect(hasPendingPrompt(key)).toBe(false); + }); + + it('should replace existing prompt for same user', async () => { + const secrets1: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + const secrets2: DetectedSecret[] = [ + { + value: 'ghp-xyz789', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const key = 'telegram:user123'; + const promise1 = registerPendingPrompt(key, secrets1, 5000); + const promise2 = registerPendingPrompt(key, secrets2, 5000); + + // First promise should resolve with null (replaced) + const result1 = await promise1; + expect(result1).toBeNull(); + + // Second promise should still be pending + expect(hasPendingPrompt(key)).toBe(true); + + // Resolve second + resolvePendingPrompt(key, 'redact'); + const result2 = await promise2; + expect(result2).toBe('redact'); + }); + }); + + describe('resolvePendingPrompt', () => { + it('should resolve a pending prompt with chosen action', async () => { + const secrets: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const key = 'telegram:user123'; + const promise = registerPendingPrompt(key, secrets, 5000); + + const resolved = resolvePendingPrompt(key, 'redact'); + expect(resolved).toBe(true); + + const result = await promise; + expect(result).toBe('redact'); + expect(hasPendingPrompt(key)).toBe(false); + }); + + it('should return false if no pending prompt exists', () => { + const resolved = resolvePendingPrompt('nonexistent:key', 'store'); + expect(resolved).toBe(false); + }); + }); + + describe('cancelPendingPrompt', () => { + it('should cancel a pending prompt', async () => { + const secrets: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const key = 'telegram:user123'; + const promise = registerPendingPrompt(key, secrets, 5000); + + const cancelled = cancelPendingPrompt(key); + expect(cancelled).toBe(true); + + const result = await promise; + expect(result).toBeNull(); + expect(hasPendingPrompt(key)).toBe(false); + }); + }); + + describe('parsePromptResponse', () => { + it('should parse numeric responses', () => { + expect(parsePromptResponse('1')).toBe('redact'); + expect(parsePromptResponse('2')).toBe('cancel'); + expect(parsePromptResponse('3')).toBe('allow'); + }); + + it('should parse responses with periods', () => { + expect(parsePromptResponse('1.')).toBe('redact'); + expect(parsePromptResponse('2.')).toBe('cancel'); + expect(parsePromptResponse('3.')).toBe('allow'); + }); + + it('should parse "option N" responses', () => { + expect(parsePromptResponse('option 1')).toBe('redact'); + expect(parsePromptResponse('option 2')).toBe('cancel'); + expect(parsePromptResponse('option 3')).toBe('allow'); + }); + + it('should parse keyword responses', () => { + expect(parsePromptResponse('redact')).toBe('redact'); + expect(parsePromptResponse('hide')).toBe('redact'); + expect(parsePromptResponse('cancel')).toBe('cancel'); + expect(parsePromptResponse('abort')).toBe('cancel'); + expect(parsePromptResponse('allow')).toBe('allow'); + expect(parsePromptResponse('continue')).toBe('allow'); + }); + + it('should be case-insensitive', () => { + expect(parsePromptResponse('REDACT')).toBe('redact'); + expect(parsePromptResponse('Cancel')).toBe('cancel'); + expect(parsePromptResponse('ALLOW')).toBe('allow'); + }); + + it('should ignore whitespace', () => { + expect(parsePromptResponse(' 1 ')).toBe('redact'); + expect(parsePromptResponse('\n2\n')).toBe('cancel'); + }); + + it('should return null for invalid responses', () => { + expect(parsePromptResponse('4')).toBeNull(); + expect(parsePromptResponse('invalid')).toBeNull(); + expect(parsePromptResponse('yes')).toBeNull(); + expect(parsePromptResponse('')).toBeNull(); + }); + }); + + describe('clearAllPendingPrompts', () => { + it('should clear all pending prompts', async () => { + const secrets: DetectedSecret[] = [ + { + value: 'sk-abc123', + type: 'api_key', + start: 0, + end: 10, + entropy: 4.5, + confidence: 'high', + }, + ]; + + const promise1 = registerPendingPrompt('telegram:user1', secrets, 5000); + const promise2 = registerPendingPrompt('discord:user2', secrets, 5000); + + expect(getPendingPromptCount()).toBe(2); + + clearAllPendingPrompts(); + + expect(getPendingPromptCount()).toBe(0); + expect(await promise1).toBeNull(); + expect(await promise2).toBeNull(); + }); + }); +});