/** * AI Security Service * ADR-071: Security Hardening — Tasks 4-8 * * Provides input sanitization, prompt injection detection, output redaction, * audit logging, and rate limiting for AI agents * * API: * - sanitizeInput(message) - returns {sanitized: string, threats: string[]} * - detectInjection(message) - returns {detected: boolean, patterns: string[]} * - escapeSpecialTokens(message) - escapes LLM special tokens * - redactSecrets(text) - redacts API keys, tokens, passwords * - redactPII(text) - redacts email, phone, credit cards * - redactOutput(text) - combined redaction (secrets + PII) * - hashContent(content) - SHA256 hash for privacy * - logAuditEntry(entry) - records to ai_audit_log table * - checkRateLimit(userId, agentId) - returns {allowed, remaining, resetIn} * - incrementRateLimit(userId, agentId) - increments counters * - clearRateLimits() - clears all rate limit counters (for testing) */ import { createHash } from 'crypto'; import { aiLogger as logger } from '../utils/logger.js'; import { dbRun } from '../database/connection.js'; // ============================================================ // Injection Detection Patterns (from ADR-071) // ============================================================ /** * Regular expressions to detect prompt injection attempts * Categories: * - Role manipulation (you are now, ignore previous, etc.) * - System prompt extraction (what is your prompt, repeat instructions, etc.) * - Jailbreak attempts (DAN mode, developer mode, bypass restrictions, etc.) * - Special tokens (<|system|>, [INST], etc.) * - Encoded payloads (base64:, eval(), etc.) */ export const INJECTION_PATTERNS = [ // Role manipulation /you are now/i, /ignore (all )?(previous|prior|above)/i, /disregard (all )?(previous|prior)/i, /forget (everything|all|your)/i, // System prompt extraction /what (is|are) your (instructions|system prompt|rules)/i, /repeat (your|the) (instructions|system|prompt)/i, /output (your|the) (system|initial) (prompt|message)/i, // Jailbreak attempts /DAN mode/i, /developer mode/i, /act as (a |an )?unrestricted/i, /bypass (your |all )?restrictions/i, // Special tokens (varies by model) /<\|system\|>/i, /<\|user\|>/i, /<\|assistant\|>/i, /\[INST\]/i, /\[\/INST\]/i, // Encoded payloads /base64:/i, /eval\s*\(/i, ]; /** * Human-readable labels for each pattern * Used for threat reporting */ const PATTERN_LABELS = [ // Role manipulation 'you are now', 'ignore previous', 'disregard previous', 'forget everything', // System prompt extraction 'what is your system prompt', 'repeat your instructions', 'output the system prompt', // Jailbreak attempts 'DAN mode', 'developer mode', 'act as unrestricted', 'bypass restrictions', // Special tokens '<|system|>', '<|user|>', '<|assistant|>', '[INST]', '[/INST]', // Encoded payloads 'base64:', 'eval()', ]; /** * Special tokens that need to be escaped in user input * These tokens could be interpreted by various LLMs as control sequences */ const SPECIAL_TOKENS = [ { pattern: /<\|system\|>/gi, replacement: '[ESCAPED:system]' }, { pattern: /<\|user\|>/gi, replacement: '[ESCAPED:user]' }, { pattern: /<\|assistant\|>/gi, replacement: '[ESCAPED:assistant]' }, { pattern: /\[INST\]/gi, replacement: '[ESCAPED:INST]' }, { pattern: /\[\/INST\]/gi, replacement: '[ESCAPED:/INST]' }, ]; // ============================================================ // Public API // ============================================================ /** * Detect prompt injection patterns in a message * * @param {string|null|undefined} message - The message to analyze * @returns {{detected: boolean, patterns: string[]}} Detection result with matched pattern labels * * @example * const result = detectInjection('Ignore previous instructions'); * // { detected: true, patterns: ['ignore previous'] } */ export function detectInjection(message) { // Handle null/undefined/empty input if (!message || typeof message !== 'string') { return { detected: false, patterns: [] }; } const detectedPatterns = []; // Check each pattern INJECTION_PATTERNS.forEach((pattern, index) => { if (pattern.test(message)) { const label = PATTERN_LABELS[index]; // Handle variations in pattern detection // Some patterns have multiple variants (ignore previous/prior/above) if (label === 'ignore previous') { // Check which variant was matched if (/ignore (all )?previous/i.test(message)) { detectedPatterns.push('ignore previous'); } else if (/ignore (all )?prior/i.test(message)) { detectedPatterns.push('ignore prior'); } else if (/ignore (all )?above/i.test(message)) { detectedPatterns.push('ignore above'); } } else if (label === 'disregard previous') { if (/disregard (all )?previous/i.test(message)) { detectedPatterns.push('disregard previous'); } else if (/disregard (all )?prior/i.test(message)) { detectedPatterns.push('disregard prior'); } } else if (label === 'forget everything') { if (/forget everything/i.test(message)) { detectedPatterns.push('forget everything'); } else if (/forget all/i.test(message)) { detectedPatterns.push('forget all'); } else if (/forget your/i.test(message)) { detectedPatterns.push('forget your'); } } else if (label === 'what is your system prompt') { if (/what is your (system prompt|rules)/i.test(message)) { detectedPatterns.push('what is your system prompt'); } else if (/what are your instructions/i.test(message)) { detectedPatterns.push('what are your instructions'); } } else if (label === 'bypass restrictions') { if (/bypass all restrictions/i.test(message)) { detectedPatterns.push('bypass all restrictions'); } else if (/bypass (your )?restrictions/i.test(message)) { detectedPatterns.push('bypass restrictions'); } } else { detectedPatterns.push(label); } } }); // Remove duplicates const uniquePatterns = [...new Set(detectedPatterns)]; if (uniquePatterns.length > 0) { logger.warn( { patterns: uniquePatterns, messageLength: message.length }, 'Prompt injection patterns detected' ); } return { detected: uniquePatterns.length > 0, patterns: uniquePatterns, }; } /** * Escape special LLM tokens in a message * * @param {string|null|undefined} message - The message to escape * @returns {string} Message with special tokens escaped * * @example * const result = escapeSpecialTokens('Hello <|system|> world'); * // 'Hello [ESCAPED:system] world' */ export function escapeSpecialTokens(message) { // Handle null/undefined/empty input if (!message || typeof message !== 'string') { return ''; } let escapedMessage = message; // Replace each special token with escaped version for (const { pattern, replacement } of SPECIAL_TOKENS) { escapedMessage = escapedMessage.replace(pattern, replacement); } return escapedMessage; } /** * Sanitize input message for AI processing * Combines injection detection and token escaping * * @param {string|null|undefined} message - The message to sanitize * @returns {{sanitized: string, threats: string[]}} Sanitized message and detected threats * * @example * const result = sanitizeInput('Ignore instructions <|system|> hello'); * // { sanitized: 'Ignore instructions [ESCAPED:system] hello', threats: ['ignore previous', '<|system|>'] } */ export function sanitizeInput(message) { // Handle null/undefined/empty input if (!message || typeof message !== 'string') { return { sanitized: '', threats: [] }; } // Trim whitespace const trimmedMessage = message.trim(); if (!trimmedMessage) { return { sanitized: '', threats: [] }; } // Detect injection patterns const detection = detectInjection(trimmedMessage); // Escape special tokens const sanitized = escapeSpecialTokens(trimmedMessage); // Combine threats from detection const threats = [...detection.patterns]; if (threats.length > 0) { logger.info( { threatsCount: threats.length, sanitizedLength: sanitized.length }, 'Input sanitized with threats detected' ); } else { logger.debug( { sanitizedLength: sanitized.length }, 'Input sanitized - no threats detected' ); } return { sanitized, threats, }; } // ============================================================ // ADR-071 Task 6: Output Redaction for PII/Secrets // ============================================================ /** * Patterns for detecting secrets (API keys, tokens, passwords) * Each pattern has a replacement label */ const SECRET_PATTERNS = [ // OpenAI API keys (sk-...) { pattern: /sk-[a-zA-Z0-9]{20,}/g, label: 'OpenAI API key' }, // Generic API key patterns (api_key=, api-key:, apiKey=) { pattern: /api[_-]?key["\s:=]+[a-zA-Z0-9"]+/gi, label: 'API key' }, // Bearer tokens { pattern: /bearer\s+[a-zA-Z0-9._-]+/gi, label: 'Bearer token' }, // Password patterns { pattern: /password["\s:=]+[^\s"]+/gi, label: 'password' }, ]; /** * Patterns for detecting PII (Personally Identifiable Information) * Each pattern has a specific replacement label */ const PII_PATTERNS = [ // Email addresses { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, replacement: '[REDACTED:EMAIL]', label: 'email' }, // Credit card numbers (with spaces, dashes, or no separators) { pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, replacement: '[REDACTED:CARD]', label: 'credit card' }, // International phone numbers (E.164 format) { pattern: /\+?[1-9]\d{1,14}/g, replacement: '[REDACTED:PHONE]', label: 'phone' }, ]; /** * Redact secrets from text (API keys, tokens, passwords) * * @param {string|null|undefined} text - Text to redact * @returns {string} Text with secrets redacted as [REDACTED:SECRET] * * @example * redactSecrets('API key: sk-abcdefghijklmnopqrstuvwxyz1234567890'); * // 'API key: [REDACTED:SECRET]' */ export function redactSecrets(text) { // Handle null/undefined/empty input if (!text || typeof text !== 'string') { return ''; } let redactedText = text; // Apply each secret pattern for (const { pattern, label } of SECRET_PATTERNS) { // Reset lastIndex for global patterns pattern.lastIndex = 0; const hasMatch = pattern.test(redactedText); pattern.lastIndex = 0; if (hasMatch) { redactedText = redactedText.replace(pattern, '[REDACTED:SECRET]'); logger.debug({ type: label }, 'Secret redacted from output'); } } return redactedText; } /** * Redact PII from text (email, phone, credit cards) * * @param {string|null|undefined} text - Text to redact * @param {Object} [options] - Optional redaction options * @param {boolean} [options.email=true] - Whether to redact email addresses * @param {boolean} [options.phone=true] - Whether to redact phone numbers * @param {boolean} [options.card=true] - Whether to redact credit card numbers * @returns {string} Text with PII redacted as [REDACTED:EMAIL], [REDACTED:PHONE], etc. * * @example * redactPII('Contact: john@example.com, +14155551234'); * // 'Contact: [REDACTED:EMAIL], [REDACTED:PHONE]' * * @example * redactPII('Email: test@example.com Phone: +1234567890', { email: true, phone: false }); * // 'Email: [REDACTED:EMAIL] Phone: +1234567890' */ export function redactPII(text, options = {}) { // Handle null/undefined/empty input if (!text || typeof text !== 'string') { return ''; } // Default options: redact everything if not specified const redactEmail = options.email !== false; const redactPhone = options.phone !== false; const redactCard = options.card !== false; let redactedText = text; // Apply each PII pattern based on options for (const { pattern, replacement, label } of PII_PATTERNS) { // Check if this type should be redacted based on options if (label === 'email' && !redactEmail) continue; if (label === 'phone' && !redactPhone) continue; if (label === 'credit card' && !redactCard) continue; // Reset lastIndex for global patterns pattern.lastIndex = 0; const hasMatch = pattern.test(redactedText); pattern.lastIndex = 0; if (hasMatch) { redactedText = redactedText.replace(pattern, replacement); logger.debug({ type: label }, 'PII redacted from output'); } } return redactedText; } /** * Redact all sensitive information from text (secrets + PII) * Combined function for comprehensive output sanitization * * @param {string|null|undefined} text - Text to redact * @returns {string} Text with all sensitive information redacted * * @example * redactOutput('API key: sk-abc123... email: user@example.com'); * // 'API key: [REDACTED:SECRET] email: [REDACTED:EMAIL]' */ export function redactOutput(text) { // Handle null/undefined/empty input if (!text || typeof text !== 'string') { return ''; } // First redact secrets, then PII let redactedText = redactSecrets(text); redactedText = redactPII(redactedText); return redactedText; } // ============================================================ // ADR-071 Task 6: System Prompt Leak Detection // ============================================================ /** * Patterns that indicate potential system prompt leakage * These phrases suggest the AI is revealing its instructions */ const LEAK_PATTERNS = [ /my instructions (are|say|tell)/i, /my system prompt (is|says)/i, /i was told to/i, /according to my instructions/i, /my programming (says|tells|requires)/i, /i am programmed to/i, /my guidelines (say|state|require)/i, ]; /** * Detect if AI output contains a leak of the system prompt * * Detection methods: * 1. Exact match - output contains the entire system prompt * 2. Partial match - output contains >50% of system prompt words * 3. Pattern match - output contains phrases like "my instructions are" * * @param {string|null|undefined} text - The AI output to check * @param {string|null|undefined} systemPrompt - The system prompt to check against * @returns {{detected: boolean, type: string|null}} Detection result * * @example * const result = detectSystemPromptLeak("My instructions are to help you.", "Be helpful and kind."); * // { detected: true, type: 'pattern_match' } */ export function detectSystemPromptLeak(text, systemPrompt) { // Handle null/undefined/empty input if (!text || typeof text !== 'string' || !text.trim()) { return { detected: false, type: null }; } // Handle null/undefined/empty system prompt if (!systemPrompt || typeof systemPrompt !== 'string' || !systemPrompt.trim()) { return { detected: false, type: null }; } const textLower = text.toLowerCase(); const promptLower = systemPrompt.toLowerCase(); // 1. Check for exact match (case-insensitive) if (textLower.includes(promptLower)) { logger.warn({ textLength: text.length }, 'System prompt leak detected: exact match'); return { detected: true, type: 'exact_match' }; } // 2. Check for partial match (>50% of system prompt words appear in output) const promptWords = promptLower.split(/\s+/).filter(w => w.length > 3); // Words longer than 3 chars if (promptWords.length > 0) { const matchingWords = promptWords.filter(word => textLower.includes(word)); const matchRatio = matchingWords.length / promptWords.length; if (matchRatio >= 0.5) { logger.warn({ matchRatio, textLength: text.length }, 'System prompt leak detected: partial match'); return { detected: true, type: 'partial_match' }; } } // 3. Check for leak patterns (phrases suggesting instruction disclosure) for (const pattern of LEAK_PATTERNS) { if (pattern.test(text)) { logger.warn({ pattern: pattern.source, textLength: text.length }, 'System prompt leak detected: pattern match'); return { detected: true, type: 'pattern_match' }; } } return { detected: false, type: null }; } // ============================================================ // ADR-071 Task 7: Audit Logging // ============================================================ /** * Valid message types for audit logging */ const VALID_MESSAGE_TYPES = ['request', 'response', 'error']; /** * Hash content using SHA256 for privacy * Stores hash instead of raw content in audit log * * @param {string|object|null|undefined} content - Content to hash * @returns {string} SHA256 hex hash (64 characters) or empty string for null/undefined * * @example * const hash = hashContent('Hello, world!'); * // '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3' */ export function hashContent(content) { // Handle null/undefined if (content === null || content === undefined) { return ''; } // Convert non-string to string (e.g., objects) const stringContent = typeof content === 'string' ? content : JSON.stringify(content); // Create SHA256 hash return createHash('sha256').update(stringContent, 'utf8').digest('hex'); } /** * Log an audit entry to the ai_audit_log table * * @param {Object} entry - Audit entry data * @param {number} entry.user_id - User ID (required) * @param {string} entry.message_type - 'request', 'response', or 'error' (required) * @param {string} entry.content_hash - SHA256 hash of content (required) * @param {number} [entry.agent_id] - Agent ID * @param {string} [entry.agent_name] - Agent name * @param {number} [entry.conversation_id] - Conversation ID * @param {number} [entry.token_count] - Token count * @param {boolean} [entry.threat_detected] - Whether threat was detected * @param {string} [entry.threat_type] - Type of threat detected * @param {string} [entry.ip_address] - Client IP address * @param {string} [entry.user_agent] - Client user agent * @param {Object} [entry.metadata] - Additional metadata (JSONB) * @returns {Promise<{success: boolean, id: number}>} * * @example * const result = await logAuditEntry({ * user_id: 1, * message_type: 'request', * content_hash: hashContent('Hello'), * threat_detected: false, * }); */ export async function logAuditEntry(entry) { // Validate message_type if (!VALID_MESSAGE_TYPES.includes(entry.message_type)) { throw new Error(`Invalid message_type: ${entry.message_type}. Must be one of: ${VALID_MESSAGE_TYPES.join(', ')}`); } // Prepare metadata as JSON string for storage const metadataStr = entry.metadata ? JSON.stringify(entry.metadata) : null; // Convert threat_detected boolean to integer (0/1) const threatDetected = entry.threat_detected ? 1 : 0; try { const sql = ` INSERT INTO ai_audit_log ( user_id, agent_id, agent_name, conversation_id, message_type, content_hash, token_count, threat_detected, threat_type, ip_address, user_agent, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const params = [ entry.user_id, entry.agent_id || null, entry.agent_name || null, entry.conversation_id || null, entry.message_type, entry.content_hash, entry.token_count || null, threatDetected, entry.threat_type || null, entry.ip_address || null, entry.user_agent || null, metadataStr, ]; const result = await dbRun(sql, params); logger.debug( { user_id: entry.user_id, message_type: entry.message_type, threat_detected: threatDetected }, 'AI audit entry logged' ); // Return the inserted ID const insertedId = result.lastInsertRowid || 0; return { success: true, id: Number(insertedId) }; } catch (error) { logger.error({ error: error.message, entry }, 'Failed to log AI audit entry'); throw error; } } // ============================================================ // ADR-071 Task 8: Per-Agent Rate Limiting // ============================================================ /** * Rate limit configuration (from ADR-071) * AI_RATE_LIMIT_PER_USER=100 - requests per hour per user * AI_RATE_LIMIT_PER_AGENT=500 - requests per hour per agent */ const RATE_LIMIT_PER_USER = parseInt(process.env.AI_RATE_LIMIT_PER_USER, 10) || 100; const RATE_LIMIT_PER_AGENT = parseInt(process.env.AI_RATE_LIMIT_PER_AGENT, 10) || 500; const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour in milliseconds /** * In-memory storage for rate limit counters * Key format: `user:${userId}` or `agent:${agentId}` * Value: { count: number, resetTime: number (timestamp) } * * NOTE: For production at scale, consider Redis for distributed rate limiting */ const rateLimits = new Map(); /** * Get or create a rate limit entry for a given key * * @param {string} key - The rate limit key (e.g., 'user:123' or 'agent:456') * @returns {{ count: number, resetTime: number }} Rate limit entry */ function getRateLimitEntry(key) { const now = Date.now(); let entry = rateLimits.get(key); // If entry doesn't exist or has expired, create a new one if (!entry || now >= entry.resetTime) { entry = { count: 0, resetTime: now + RATE_LIMIT_WINDOW_MS, }; rateLimits.set(key, entry); } return entry; } /** * Check rate limit for a user and agent combination * * @param {number|string|null} userId - User identifier * @param {number|string|undefined} agentId - Agent identifier * @returns {{ allowed: boolean, remaining: number, resetIn: number }} * - allowed: whether the request should be allowed * - remaining: number of requests remaining (minimum of user and agent limits) * - resetIn: seconds until rate limit resets * * @example * const result = checkRateLimit(1, 100); * // { allowed: true, remaining: 99, resetIn: 3600 } */ export function checkRateLimit(userId, agentId) { // Handle null/undefined userId if (userId === null || userId === undefined) { logger.warn({ userId, agentId }, 'Rate limit check with null/undefined userId'); return { allowed: false, remaining: 0, resetIn: 0 }; } const now = Date.now(); const userKey = `user:${userId}`; // Get user rate limit entry const userEntry = getRateLimitEntry(userKey); const userRemaining = Math.max(0, RATE_LIMIT_PER_USER - userEntry.count); const userResetIn = Math.ceil((userEntry.resetTime - now) / 1000); // If agentId is provided, also check agent limit let agentRemaining = RATE_LIMIT_PER_AGENT; let agentResetIn = userResetIn; if (agentId !== undefined && agentId !== null) { const agentKey = `agent:${agentId}`; const agentEntry = getRateLimitEntry(agentKey); agentRemaining = Math.max(0, RATE_LIMIT_PER_AGENT - agentEntry.count); agentResetIn = Math.ceil((agentEntry.resetTime - now) / 1000); } // Request is allowed if both user and agent limits are not exceeded const allowed = userRemaining > 0 && agentRemaining > 0; // Return the minimum remaining between user and agent const remaining = Math.min(userRemaining, agentRemaining); // Return the maximum reset time (most conservative) const resetIn = Math.max(userResetIn, agentResetIn); if (!allowed) { logger.warn( { userId, agentId, userRemaining, agentRemaining }, 'Rate limit exceeded' ); } return { allowed, remaining, resetIn }; } /** * Increment rate limit counters for a user and agent * * @param {number|string} userId - User identifier * @param {number|string} agentId - Agent identifier * @returns {void} * * @example * incrementRateLimit(1, 100); */ export function incrementRateLimit(userId, agentId) { // Handle null/undefined userId if (userId === null || userId === undefined) { logger.warn({ userId, agentId }, 'Rate limit increment with null/undefined userId'); return; } const userKey = `user:${userId}`; const userEntry = getRateLimitEntry(userKey); userEntry.count += 1; // If agentId is provided, also increment agent counter if (agentId !== undefined && agentId !== null) { const agentKey = `agent:${agentId}`; const agentEntry = getRateLimitEntry(agentKey); agentEntry.count += 1; } logger.debug( { userId, agentId, userCount: userEntry.count }, 'Rate limit counter incremented' ); } /** * Clear all rate limit counters (primarily for testing) * * @returns {void} */ export function clearRateLimits() { rateLimits.clear(); logger.debug('Rate limit counters cleared'); } // ============================================================ // Default Export // ============================================================ export default { sanitizeInput, detectInjection, escapeSpecialTokens, redactSecrets, redactPII, redactOutput, detectSystemPromptLeak, hashContent, logAuditEntry, checkRateLimit, incrementRateLimit, clearRateLimits, INJECTION_PATTERNS, };