diff --git a/src/security/interactive-prompts.ts b/src/security/interactive-prompts.ts new file mode 100644 index 000000000..b16167ca4 --- /dev/null +++ b/src/security/interactive-prompts.ts @@ -0,0 +1,176 @@ +/** + * State management for pending interactive security prompts. + * Tracks users who are in the middle of responding to secret detection alerts. + */ + +import type { DetectedSecret } from './entropy.js'; + +export type PendingPromptAction = 'redact' | 'cancel' | 'allow'; + +export type PendingSecurityPrompt = { + /** Unique key for this prompt (channel:senderId or sessionKey). */ + key: string; + /** Detected secrets that triggered the prompt. */ + secrets: DetectedSecret[]; + /** When the prompt was created. */ + createdAt: number; + /** Timeout in milliseconds. */ + timeoutMs: number; + /** Promise resolver for the user's choice. */ + resolve: (action: PendingPromptAction | null) => void; + /** Timeout handle for cleanup. */ + timeoutHandle: ReturnType; +}; + +// Global in-memory store for pending prompts +const pendingPrompts = new Map(); + +/** + * Generate a unique key for a pending prompt. + */ +export function generatePromptKey(channel: string, senderId: string): string { + return `${channel}:${senderId}`; +} + +/** + * Register a pending security prompt and return a promise that resolves when the user responds. + * + * @param key - Unique key (channel:senderId) + * @param secrets - Detected secrets + * @param timeoutMs - How long to wait for user response + * @returns Promise that resolves with user's choice or null on timeout + */ +export function registerPendingPrompt( + key: string, + secrets: DetectedSecret[], + timeoutMs: number, +): Promise { + // Cancel any existing prompt for this user + const existing = pendingPrompts.get(key); + if (existing) { + clearTimeout(existing.timeoutHandle); + existing.resolve(null); // Timeout the old one + } + + return new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + // Timeout reached - clean up and resolve with null + pendingPrompts.delete(key); + resolve(null); + }, timeoutMs); + + const prompt: PendingSecurityPrompt = { + key, + secrets, + createdAt: Date.now(), + timeoutMs, + resolve, + timeoutHandle, + }; + + pendingPrompts.set(key, prompt); + }); +} + +/** + * Check if there's a pending prompt for a given key. + */ +export function hasPendingPrompt(key: string): boolean { + return pendingPrompts.has(key); +} + +/** + * Get a pending prompt by key. + */ +export function getPendingPrompt(key: string): PendingSecurityPrompt | undefined { + return pendingPrompts.get(key); +} + +/** + * Resolve a pending prompt with the user's choice. + * This should be called when the user responds to the security alert. + * + * @param key - Unique key for the prompt + * @param action - User's chosen action + * @returns True if a pending prompt was found and resolved + */ +export function resolvePendingPrompt( + key: string, + action: PendingPromptAction | null, +): boolean { + const prompt = pendingPrompts.get(key); + if (!prompt) { + return false; + } + + // Clean up + clearTimeout(prompt.timeoutHandle); + pendingPrompts.delete(key); + + // Resolve the promise + prompt.resolve(action); + return true; +} + +/** + * Cancel a pending prompt (e.g., if the user sends a different message). + * + * @param key - Unique key for the prompt + * @returns True if a pending prompt was found and cancelled + */ +export function cancelPendingPrompt(key: string): boolean { + return resolvePendingPrompt(key, null); +} + +/** + * Parse a user's response to a security prompt. + * Expects "1", "2", or "3" (or variations like "1.", "option 1", etc.) + * + * @param text - User's message text + * @returns Parsed action or null if invalid + */ +export function parsePromptResponse(text: string): PendingPromptAction | null { + const normalized = text.trim().toLowerCase(); + + // Direct number matches + if (normalized === '1' || normalized === '1.' || normalized.includes('option 1')) { + return 'redact'; + } + if (normalized === '2' || normalized === '2.' || normalized.includes('option 2')) { + return 'cancel'; + } + if (normalized === '3' || normalized === '3.' || normalized.includes('option 3')) { + return 'allow'; + } + + // Keyword matches + if (normalized.includes('redact') || normalized.includes('hide')) { + return 'redact'; + } + if (normalized.includes('cancel') || normalized.includes('abort')) { + return 'cancel'; + } + if (normalized.includes('allow') || normalized.includes('continue')) { + return 'allow'; + } + + return null; +} + +/** + * Clear all pending prompts (useful for testing or shutdown). + */ +export function clearAllPendingPrompts(): void { + for (const prompt of pendingPrompts.values()) { + clearTimeout(prompt.timeoutHandle); + prompt.resolve(null); + } + pendingPrompts.clear(); +} + +/** + * Get count of pending prompts (useful for debugging/monitoring). + */ +export function getPendingPromptCount(): number { + return pendingPrompts.size; +} diff --git a/src/security/process-secrets.ts b/src/security/process-secrets.ts new file mode 100644 index 000000000..d9f4c6bc9 --- /dev/null +++ b/src/security/process-secrets.ts @@ -0,0 +1,270 @@ +/** + * Secret detection and handling for inbound messages. + * Integrates entropy detection with interactive user prompts and storage. + */ + +import type { MoltbotConfig } from '../config/config.js'; +import type { FinalizedMsgContext } from '../auto-reply/templating.js'; +import type { ReplyDispatcher } from '../auto-reply/reply/reply-dispatcher.js'; +import { + detectHighEntropyStrings, + redactSecrets, + type DetectedSecret, +} from './entropy.js'; +import { + logHighEntropyDetected, + logSecretRedacted, + logSecretAllowedByUser, + logInteractivePromptTimeout, + logInteractivePromptCancelled, +} from './events.js'; + +export type SecretProcessingResult = { + /** Whether secrets were detected. */ + detected: boolean; + /** Whether the message was modified (redacted or replaced). */ + modified: boolean; + /** Updated context with safe body text. */ + ctx: FinalizedMsgContext; + /** Whether the message should be blocked (user cancelled). */ + blocked: boolean; +}; + +/** + * Get default action from config with fallback. + */ +function getDefaultAction( + cfg: MoltbotConfig, +): 'redact' | 'block' | 'allow' { + return cfg.security?.secrets?.handling?.defaultAction || 'redact'; +} + +/** + * Get timeout for interactive prompts from config. + */ +function getConfirmationTimeoutMs(cfg: MoltbotConfig): number { + return cfg.security?.secrets?.handling?.confirmationTimeoutMs || 15000; +} + +/** + * Check if secret detection is enabled in config. + */ +function isSecretDetectionEnabled(cfg: MoltbotConfig): boolean { + return cfg.security?.secrets?.detection?.enabled !== false; // Default: true +} + +/** + * Check if interactive mode is enabled in config. + */ +function isInteractiveModeEnabled(cfg: MoltbotConfig): boolean { + return cfg.security?.secrets?.handling?.interactive !== false; // Default: true +} + +/** + * Check if the channel supports interactive prompts. + * For Phase 1, we support interactive mode for most channels. + */ +function supportsInteractiveMode(ctx: FinalizedMsgContext): boolean { + const channel = ctx.Provider || ctx.Surface; + // Most channels support sending replies for interactive prompts + // Exceptions might be added later for channels with limitations + return !!channel; +} + +/** + * Send an interactive prompt to the user and wait for response. + * Returns the selected action or null if timeout/cancelled. + */ +async function promptUserForAction( + secrets: DetectedSecret[], + ctx: FinalizedMsgContext, + dispatcher: ReplyDispatcher, + timeoutMs: number, +): Promise<'redact' | 'cancel' | 'allow' | null> { + const { + generatePromptKey, + registerPendingPrompt, + } = await import('./interactive-prompts.js'); + + // Build secret summary + const secretSummary = secrets + .slice(0, 3) // Show up to 3 secrets + .map((s) => { + const preview = s.value.slice(0, 12) + '...' + s.value.slice(-6); + const label = s.pattern || s.type; + return `• ${preview} (${label})`; + }) + .join('\n'); + + const moreSummary = + secrets.length > 3 ? `\n• ...and ${secrets.length - 3} more` : ''; + + const promptMessage = `🔒 **Security Alert** + +Your message contains what appears to be ${secrets.length} secret${secrets.length > 1 ? 's' : ''} or API key${secrets.length > 1 ? 's' : ''}: + +${secretSummary}${moreSummary} + +**Options:** +1️⃣ Redact - Replace with [REDACTED] before processing +2️⃣ Cancel - Don't process this message +3️⃣ Continue anyway ⚠️ - Send to AI as-is (not recommended) + +Reply with **1**, **2**, or **3** (timeout in ${Math.round(timeoutMs / 1000)}s)`; + + // Generate unique key for this user + const channel = ctx.Provider || ctx.Surface || 'unknown'; + const senderId = ctx.SenderId || 'unknown'; + const promptKey = generatePromptKey(channel, senderId); + + // Register the pending prompt (returns a promise that will be resolved when user responds) + const responsePromise = registerPendingPrompt(promptKey, secrets, timeoutMs); + + // Send the prompt message + await dispatcher.sendText(promptMessage); + + // Wait for user response (or timeout) + const action = await responsePromise; + + return action; +} + +/** + * Apply the selected action to the context. + */ +function applyAction( + action: 'redact' | 'allow', + secrets: DetectedSecret[], + ctx: FinalizedMsgContext, + cfg: MoltbotConfig, +): { ctx: FinalizedMsgContext; modified: boolean } { + if (action === 'allow') { + // No modification + logSecretAllowedByUser(secrets.length, { + channel: ctx.Provider || ctx.Surface, + senderId: ctx.SenderId, + }); + return { ctx, modified: false }; + } + + if (action === 'redact') { + // Redact secrets from all body fields + const redactedBody = redactSecrets(ctx.Body || '', secrets); + const redactedBodyForAgent = redactSecrets(ctx.BodyForAgent || '', secrets); + const redactedCommandBody = redactSecrets(ctx.CommandBody || '', secrets); + const redactedBodyForCommands = redactSecrets(ctx.BodyForCommands || '', secrets); + + logSecretRedacted(secrets.length, 'auto', { + channel: ctx.Provider || ctx.Surface, + senderId: ctx.SenderId, + }); + + return { + ctx: { + ...ctx, + Body: redactedBody, + BodyForAgent: redactedBodyForAgent, + CommandBody: redactedCommandBody, + BodyForCommands: redactedBodyForCommands, + }, + modified: true, + }; + } + + // Shouldn't reach here + return { ctx, modified: false }; +} + +/** + * Process a message for secret detection and handling. + * This is the main entry point for secret security processing. + * + * @param ctx - Finalized message context + * @param cfg - Moltbot configuration + * @param dispatcher - Reply dispatcher for interactive prompts + * @returns SecretProcessingResult with updated context and metadata + */ +export async function processSecretsInMessage( + ctx: FinalizedMsgContext, + cfg: MoltbotConfig, + dispatcher: ReplyDispatcher, +): Promise { + // Check if detection is enabled + if (!isSecretDetectionEnabled(cfg)) { + return { detected: false, modified: false, ctx, blocked: false }; + } + + // Detect secrets in the main body (BodyForAgent is most comprehensive) + const body = ctx.BodyForAgent || ctx.Body || ''; + const detectionConfig = cfg.security?.secrets?.detection; + const result = detectHighEntropyStrings(body, { + minEntropyThreshold: detectionConfig?.minEntropyThreshold, + minLength: detectionConfig?.minLength, + customPatterns: detectionConfig?.customPatterns, + }); + + if (!result.hasSecrets) { + return { detected: false, modified: false, ctx, blocked: false }; + } + + // Secrets detected - log event + logHighEntropyDetected( + result.secrets.length, + result.secrets.map((s) => s.type), + { + channel: ctx.Provider || ctx.Surface, + senderId: ctx.SenderId, + }, + ); + + // Determine how to handle the secrets + let action: 'store' | 'redact' | 'allow' | null = null; + + const interactive = isInteractiveModeEnabled(cfg); + const supportsInteractive = supportsInteractiveMode(ctx); + + if (interactive && supportsInteractive) { + // Try interactive prompt + const timeoutMs = getConfirmationTimeoutMs(cfg); + const userChoice = await promptUserForAction( + result.secrets, + ctx, + dispatcher, + timeoutMs, + ); + + if (userChoice === 'cancel') { + logInteractivePromptCancelled({ + channel: ctx.Provider || ctx.Surface, + senderId: ctx.SenderId, + }); + return { detected: true, modified: false, ctx, blocked: true }; + } + + if (userChoice && userChoice !== 'cancel') { + action = userChoice; + } else { + // Timeout or not implemented - fall back to default + const defaultAction = getDefaultAction(cfg); + logInteractivePromptTimeout(defaultAction, { + channel: ctx.Provider || ctx.Surface, + senderId: ctx.SenderId, + }); + action = defaultAction === 'block' ? 'redact' : defaultAction; // Treat block as redact in non-interactive + } + } else { + // Non-interactive mode or channel doesn't support it + const defaultAction = getDefaultAction(cfg); + action = defaultAction === 'block' ? 'redact' : defaultAction; // Treat block as redact + } + + // Apply the action + const { ctx: updatedCtx, modified } = applyAction(action, result.secrets, ctx, cfg); + + return { + detected: true, + modified, + ctx: updatedCtx, + blocked: false, + }; +}