import type { HookEvent, RedactionRule } from '../../src/types.js'; import { writeAuditLog } from '../../src/utils/logger.js'; /** * Redaction patterns — broader than secrets-scanner because we also * catch contextual leaks (e.g., config file values, bearer headers). */ const REDACTION_RULES: RedactionRule[] = [ { regex: /sk-[a-zA-Z0-9]{20,}/g, label: 'openai_key' }, { regex: /sk-ant-[a-zA-Z0-9-]{20,}/g, label: 'anthropic_key' }, { regex: /gh[pousr]_[A-Za-z0-9_]{36,}/g, label: 'github_token' }, { regex: /AKIA[0-9A-Z]{16}/g, label: 'aws_access_key' }, { regex: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*\S{20,}/gi, label: 'aws_secret' }, { regex: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, label: 'slack_token' }, { regex: /Bearer\s+[A-Za-z0-9\-_.~+/]{20,}/gi, label: 'bearer_token' }, { regex: /-----BEGIN\s+(?:RSA|EC|OPENSSH|PGP|DSA)?\s*PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA|EC|OPENSSH|PGP|DSA)?\s*PRIVATE\s+KEY-----/g, label: 'private_key' }, { regex: /AIza[0-9A-Za-z_-]{35}/g, label: 'google_key' }, { regex: /[sr]k_(?:live|test)_[a-zA-Z0-9]{20,}/g, label: 'stripe_key' }, { regex: /npm_[a-zA-Z0-9]{36}/g, label: 'npm_token' }, { regex: /vcp_[a-zA-Z0-9]{50,}/g, label: 'vercel_token' }, ]; /** Max chars to scan (performance guard for huge messages) */ const MAX_SCAN_CHARS = 100_000; /** * Detect secrets in text and return count + labels. */ function detectSecrets(text: string): { count: number; labels: string[] } { const scanText = text.length > MAX_SCAN_CHARS ? text.slice(0, MAX_SCAN_CHARS) : text; const labels: string[] = []; let count = 0; for (const rule of REDACTION_RULES) { // Reset global regex lastIndex rule.regex.lastIndex = 0; const matches = scanText.match(rule.regex); if (matches) { count += matches.length; labels.push(rule.label); } } return { count, labels: [...new Set(labels)] }; } /** Regex to detect [SECURITY_FLAG: ...] markers from bootstrap-injected rules */ const SECURITY_FLAG_REGEX = /\[SECURITY_FLAG(?::\s*([^\]]+))?\]/g; function detectSecurityFlags(text: string): string[] { const flags: string[] = []; let match: RegExpExecArray | null; SECURITY_FLAG_REGEX.lastIndex = 0; while ((match = SECURITY_FLAG_REGEX.exec(text)) !== null) { flags.push(match[1]?.trim() || 'unspecified'); } return flags; } const handler = async (event: HookEvent): Promise => { try { const { type, action, context, sessionKey, timestamp, messages } = event; if (type !== 'message' || action !== 'sent') return; const content = context.content ?? ''; if (!content) return; // 1. Check for security flags const flags = detectSecurityFlags(content); if (flags.length > 0) { messages.push( `🚨 **Security Flag Detected**\n` + `The AI flagged a potential security issue:\n` + flags.map((f) => ` • ${f}`).join('\n'), ); writeAuditLog({ timestamp: timestamp.toISOString(), event: 'security_flag', sessionKey, channel: context.channelId, isGroup: context.isGroup, alerts: flags.map((f) => ({ rule: 'SECURITY_FLAG', hook: 'secret-redactor', severity: 'critical', matched: f.slice(0, 60), })), metadata: { flagCount: flags.length }, }); } // 2. Check for leaked secrets (contextual patterns not covered by secrets-scanner) // secrets-scanner already handles message:sent for known key prefixes, // so we only log to audit trail here to avoid duplicate user-facing warnings. const secrets = detectSecrets(content); if (secrets.count > 0) { writeAuditLog({ timestamp: timestamp.toISOString(), event: 'secret_in_response', sessionKey, channel: context.channelId, isGroup: context.isGroup, alerts: secrets.labels.map((label) => ({ rule: label, hook: 'secret-redactor', severity: 'critical', matched: `[${label}]`, })), metadata: { secretCount: secrets.count, labels: secrets.labels }, }); } } catch (err) { console.error( '[security-guardrails:secret-redactor] Error:', err instanceof Error ? err.message : String(err), ); } }; export default handler;