feat(security): add interactive prompt system for secret handling
This commit is contained in:
parent
9ff93bf433
commit
83bca1871b
176
src/security/interactive-prompts.ts
Normal file
176
src/security/interactive-prompts.ts
Normal file
@ -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<typeof setTimeout>;
|
||||
};
|
||||
|
||||
// Global in-memory store for pending prompts
|
||||
const pendingPrompts = new Map<string, PendingSecurityPrompt>();
|
||||
|
||||
/**
|
||||
* 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<PendingPromptAction | null> {
|
||||
// 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<PendingPromptAction | null>((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;
|
||||
}
|
||||
270
src/security/process-secrets.ts
Normal file
270
src/security/process-secrets.ts
Normal file
@ -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<SecretProcessingResult> {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user