test(security): add comprehensive test suite for secret detection

This commit is contained in:
Rome Thorstenson 2026-01-30 07:26:00 -08:00
parent 64be499d96
commit af64c12977
3 changed files with 632 additions and 0 deletions

View File

@ -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);
});
});
});

View File

@ -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
});
});

View File

@ -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();
});
});
});