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