import type { ExfilPattern, Finding, ScanResult } from '../types.js'; // Compiled at module load for performance (<20ms detection) export const EXFIL_PATTERNS: ExfilPattern[] = [ { name: 'Markdown image with query params', pattern: /!\[.*?\]\(https?:\/\/[^\s)]+\?[^\s)]*(?:data|token|key|secret|password|auth|session|cookie|q|query)=[^\s)]+\)/i, severity: 'critical', }, { name: 'Suspicious markdown link', pattern: /\[.*?\]\(https?:\/\/(?!(?:github\.com|docs\.|wikipedia|arxiv))[^\s)]+\?[^\s)]{50,}\)/i, severity: 'high', }, { name: 'Reference-style exfil', pattern: /\[.*?\]:\s*https?:\/\/[^\s]+\?[^\s]*(?:data|steal|exfil|leak)=/i, severity: 'critical', }, { name: 'Base64 in URL', pattern: /https?:\/\/[^\s]+\/[A-Za-z0-9+\/=]{50,}/i, severity: 'high', }, ]; /** Known safe image magic bytes (first bytes of base64-decoded data) */ const IMAGE_HEADERS: Array<{ mime: string; prefix: number[] }> = [ { mime: 'image/png', prefix: [0x89, 0x50, 0x4e, 0x47] }, // \x89PNG { mime: 'image/jpeg', prefix: [0xff, 0xd8, 0xff] }, { mime: 'image/gif', prefix: [0x47, 0x49, 0x46, 0x38] }, // GIF8 { mime: 'image/webp', prefix: [0x52, 0x49, 0x46, 0x46] }, // RIFF ]; /** Max data URI size considered safe for inline images (500 KB) */ const MAX_SAFE_IMAGE_BYTES = 500_000; /** * Check if base64 data starts with a known image magic header. */ function hasValidImageHeader(base64Data: string): boolean { try { // Decode enough bytes to check header (32 base64 chars → 24 bytes) const header = Buffer.from(base64Data.slice(0, 32), 'base64'); return IMAGE_HEADERS.some((img) => img.prefix.every((byte, i) => header[i] === byte), ); } catch { return false; } } /** * Extract and assess all data URIs in content. * Returns findings only for suspicious ones: * - Non-image MIME types → always flagged * - Image MIME but no valid header → flagged * - Image MIME but oversized (>500KB) → flagged * - Small valid images → safe, not flagged */ const DATA_URI_REGEX = /data:([^;,\s)]+)(?:;base64)?,([A-Za-z0-9+/=]{100,})/g; export function scanDataUris(content: string): Finding[] { const findings: Finding[] = []; let match: RegExpExecArray | null; // Reset lastIndex for global regex DATA_URI_REGEX.lastIndex = 0; while ((match = DATA_URI_REGEX.exec(content)) !== null) { const mime = match[1]; const data = match[2]; const sizeBytes = Math.ceil((data.length * 3) / 4); // Non-image MIME → always suspicious if (!mime.startsWith('image/')) { findings.push({ rule: 'Non-image data URI', hook: 'exfiltration-detector', severity: 'critical', matched: `data:${mime} (${sizeBytes} bytes)`, }); continue; } // Image MIME but oversized if (sizeBytes > MAX_SAFE_IMAGE_BYTES) { findings.push({ rule: 'Oversized image data URI', hook: 'exfiltration-detector', severity: 'high', matched: `data:${mime} (${sizeBytes} bytes, >${MAX_SAFE_IMAGE_BYTES})`, }); continue; } // Image MIME but invalid header (possible encoded data) if (!hasValidImageHeader(data)) { findings.push({ rule: 'Invalid image header in data URI', hook: 'exfiltration-detector', severity: 'high', matched: `data:${mime} — header mismatch`, }); } // Valid small image → safe, do nothing } return findings; } export function scanForExfiltration(text: string): ScanResult { const findings: Finding[] = []; // Check standard exfil patterns (non-data-URI) for (const p of EXFIL_PATTERNS) { const match = p.pattern.exec(text); if (match) { findings.push({ rule: p.name, hook: 'exfiltration-detector', severity: p.severity ?? 'high', matched: match[0].slice(0, 60) + (match[0].length > 60 ? '...' : ''), }); } } // Check data URIs with smart assessment (replaces old catch-all pattern) findings.push(...scanDataUris(text)); return { matched: findings.length > 0, findings }; }