openclaw/src/security/process-secrets.ts

271 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};
}