godcrm/backend/services/AiSecurityService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

774 lines
24 KiB
JavaScript

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