#!/usr/bin/env node // Hook: post-mcp-verify.mjs // Event: PostToolUse (ALL tools) // Purpose: Monitor tool output for data leakage and indirect prompt injection. // // Protocol: // - Read JSON from stdin: { tool_name, tool_input, tool_output } // - Advisory only: always exit 0. Output systemMessage via stdout to warn user. // // v2.3.0: Expanded from Bash-only to ALL tools. // - Bash-specific: secret scanning, external URL detection, large MCP output // - Universal: indirect prompt injection scanning (OWASP LLM01) // - Short output (<100 chars) skipped for performance // v5.0.0: MEDIUM injection patterns included in advisory output. // v5.0.0-S4: HITL trap patterns (HIGH), sub-agent spawn (MEDIUM), NL indirection (MEDIUM), // cognitive load trap (MEDIUM) — all via scanForInjection() from injection-patterns.mjs. import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { scanForInjection } from '../../scanners/lib/injection-patterns.mjs'; import { checkDescriptionDrift } from '../../scanners/lib/mcp-description-cache.mjs'; import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs'; import { decodeHtmlEntities } from '../../scanners/lib/string-utils.mjs'; // --------------------------------------------------------------------------- // Secret patterns — same set as pre-edit-secrets.mjs so any secret that // slips through a write guard will at least be flagged in command output. // Only checked for Bash tool output. // --------------------------------------------------------------------------- const SECRET_PATTERNS = [ { name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ }, { name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ }, { name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ }, { name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ }, { name: 'Azure Connection String', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ }, { name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/ }, { name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i }, { name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i, }, ]; // --------------------------------------------------------------------------- // MCP-indicator keywords — commands that suggest MCP tool usage. // We give extra weight to findings when the command looks MCP-related. // Only relevant for Bash tool. // --------------------------------------------------------------------------- const MCP_INDICATORS = [ 'mcp', 'model_context_protocol', 'claude mcp', 'npx @anthropic', 'mcp-server', 'tool_use', 'tool_result', ]; // --------------------------------------------------------------------------- // Large data dump heuristic — output longer than this threshold (bytes) from // an MCP-related command may indicate exfiltration or accidental bulk dump. // Only checked for Bash tool. // --------------------------------------------------------------------------- const LARGE_OUTPUT_THRESHOLD = 50_000; // 50 KB // --------------------------------------------------------------------------- // Minimum output length for injection scanning (performance optimization). // Short output is unlikely to contain meaningful injection payloads. // --------------------------------------------------------------------------- const MIN_INJECTION_SCAN_LENGTH = 100; // --------------------------------------------------------------------------- // Per-tool volume tracking — tracks cumulative output per MCP tool within // a session. Warns when a single tool produces disproportionate output. // State file: ${os.tmpdir()}/llm-security-mcp-volume-${ppid}.json // --------------------------------------------------------------------------- const MCP_TOOL_VOLUME_THRESHOLD = getPolicyValue('mcp', 'volume_threshold_bytes', 100_000); const VOLUME_STATE_FILE = join(tmpdir(), `llm-security-mcp-volume-${process.ppid}.json`); // --------------------------------------------------------------------------- // Unexpected external URL patterns in curl/wget invocations within output. // Only checked for Bash tool. // --------------------------------------------------------------------------- const EXTERNAL_URL_PATTERN = /(?:curl|wget)\s+(?:-[a-zA-Z]+\s+)*['"]?(https?:\/\/(?!localhost|127\.|0\.0\.0\.|::1)[^\s'"]+)/gi; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function isMcpRelatedCommand(command) { if (!command) return false; const lower = command.toLowerCase(); return MCP_INDICATORS.some((indicator) => lower.includes(indicator)); } function scanForSecrets(text) { const matches = []; for (const { name, pattern } of SECRET_PATTERNS) { if (pattern.test(text)) { matches.push(name); } } return matches; } function extractExternalUrls(text) { const urls = []; let match; const re = new RegExp(EXTERNAL_URL_PATTERN.source, EXTERNAL_URL_PATTERN.flags); while ((match = re.exec(text)) !== null) { urls.push(match[1]); } return [...new Set(urls)]; // deduplicate } function emitAdvisory(message) { process.stdout.write( JSON.stringify({ systemMessage: message }) ); } /** * Format a tool identifier for advisory messages. * For Bash: includes the command. For other tools: includes tool name and relevant input. */ function formatToolContext(toolName, toolInput) { if (toolName === 'Bash') { const cmd = toolInput?.command ?? ''; return `Command: ${cmd.slice(0, 150)}${cmd.length > 150 ? '...' : ''}`; } if (toolName === 'Read') { const target = toolInput?.file_path ?? ''; return `Tool: Read, file: ${target.slice(0, 150)}`; } if (toolName === 'WebFetch') { const target = toolInput?.url ?? ''; return `Tool: WebFetch, url: ${target.slice(0, 150)}`; } // MCP tools often have descriptive names if (toolName?.startsWith('mcp__')) { return `MCP tool: ${toolName}`; } return `Tool: ${toolName}`; } // --------------------------------------------------------------------------- // Per-tool MCP volume state // --------------------------------------------------------------------------- /** * Load per-tool volume state. * @returns {{ volumes: Record, warned: Record }} */ function loadVolumeState() { try { if (existsSync(VOLUME_STATE_FILE)) { return JSON.parse(readFileSync(VOLUME_STATE_FILE, 'utf-8')); } } catch { /* ignore */ } return { volumes: {}, warned: {} }; } /** * Save per-tool volume state. * @param {{ volumes: Record, warned: Record }} state */ function saveVolumeState(state) { try { writeFileSync(VOLUME_STATE_FILE, JSON.stringify(state), 'utf-8'); } catch { /* ignore */ } } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- let input; try { const raw = readFileSync(0, 'utf-8'); input = JSON.parse(raw); } catch { // Cannot parse stdin — exit silently. process.exit(0); } const toolName = input?.tool_name ?? ''; const toolInput = input?.tool_input ?? {}; const toolOutput = input?.tool_output ?? ''; const command = toolInput?.command ?? ''; // Convert tool_output to string if it isn't already (some hooks pass objects) const outputText = typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput); if (!outputText.trim()) { process.exit(0); } const advisories = []; const isBash = toolName === 'Bash'; // Policy: trusted MCP servers are exempt from volume tracking and drift checks const trustedServers = new Set(getPolicyValue('mcp', 'trusted_servers', [])); const mcpServerName = toolName.includes('mcp__') ? toolName.split('__')[1] : null; const isTrustedMcp = mcpServerName && trustedServers.has(mcpServerName); // ========================================================================= // Bash-specific checks: secrets, external URLs, large MCP output // These checks are only relevant for shell command output. // ========================================================================= if (isBash) { const isMcp = isMcpRelatedCommand(command); const secretHits = scanForSecrets(outputText); const externalUrls = extractExternalUrls(outputText); const isLargeOutput = outputText.length > LARGE_OUTPUT_THRESHOLD; // --- Secret detection in output --- if (secretHits.length > 0) { advisories.push( `Potential secret(s) detected in command output:\n` + secretHits.map((n) => ` - ${n}`).join('\n') + '\n' + ` Review the output above before sharing logs, screenshots, or copying to external systems.\n` + ` Rotate any exposed credentials immediately.` ); } // --- Unexpected external URLs (only flag when in MCP context or multiple hits) --- if (externalUrls.length > 0 && (isMcp || externalUrls.length > 2)) { advisories.push( `External URL(s) accessed via curl/wget in command output:\n` + externalUrls.slice(0, 5).map((u) => ` - ${u}`).join('\n') + (externalUrls.length > 5 ? `\n ... and ${externalUrls.length - 5} more` : '') + '\n' + ` Verify these requests are expected and that no sensitive data was sent.` ); } // --- Large output from MCP-related command --- if (isMcp && isLargeOutput) { const kb = Math.round(outputText.length / 1024); advisories.push( `Large output (${kb} KB) from an MCP-related command.\n` + ` Unexpectedly large MCP responses may indicate bulk data retrieval or exfiltration.\n` + ` ${formatToolContext(toolName, toolInput)}` ); } } // ========================================================================= // Universal check: indirect prompt injection in tool output (LLM01) // Runs for ALL tools. External content fetched by any tool may contain // injection payloads targeting the model. // Skip short output for performance. // v5.0.0: Now includes MEDIUM patterns in advisory. // ========================================================================= if (outputText.length >= MIN_INJECTION_SCAN_LENGTH) { const scanSlice = outputText.slice(0, 100_000); // first 100 KB const injection = scanForInjection(scanSlice); if (injection.critical.length > 0 || injection.high.length > 0 || injection.medium.length > 0) { const lines = []; if (injection.critical.length > 0) { lines.push(` Critical injection patterns:`); for (const c of injection.critical) lines.push(` - ${c}`); } if (injection.high.length > 0) { lines.push(` Manipulation signals:`); for (const h of injection.high) lines.push(` - ${h}`); } if (injection.medium.length > 0) { // When critical/high are present, just append count. When medium-only, list them. if (injection.critical.length > 0 || injection.high.length > 0) { lines.push(` Additionally, ${injection.medium.length} lower-confidence signal(s) (MEDIUM).`); } else { lines.push(` Obfuscation/manipulation signals (MEDIUM):`); for (const m of injection.medium) lines.push(` - ${m}`); } } const severity = injection.critical.length > 0 ? 'CRITICAL' : injection.high.length > 0 ? 'HIGH' : 'MEDIUM'; advisories.push( `Indirect prompt injection detected in tool output — ${severity} (OWASP LLM01).\n` + lines.join('\n') + '\n' + ` External content may be attempting to manipulate the model.\n` + ` ${formatToolContext(toolName, toolInput)}` ); } } // ========================================================================= // HTML content check: CSS-hidden content detection (AI Agent Traps) // WebFetch and Read may return HTML with visually hidden elements that // contain adversarial instructions. Agents parse these; humans do not. // ========================================================================= const isHtmlSource = toolName === 'WebFetch' || toolName === 'Read' || toolName?.startsWith('mcp__'); if (isHtmlSource && outputText.length >= MIN_INJECTION_SCAN_LENGTH) { const htmlSlice = outputText.slice(0, 100_000); // ------------------------------------------------------------------------- // E4 (v7.2.0): Markdown link title-attribute injection. // Pattern: [text](url "title") — the quoted title is rendered as a tooltip // and parsed by agents, but rarely inspected by humans during review. // Markdown does not require HTML tags, so this runs outside the HTML gate. // ------------------------------------------------------------------------- const linkTitleRegex = /\[[^\]]*\]\([^)]*\s+"([^"]+)"\s*\)/g; const linkTitles = []; let linkTitleMatch; while ((linkTitleMatch = linkTitleRegex.exec(htmlSlice)) !== null) { linkTitles.push(decodeHtmlEntities(linkTitleMatch[1])); } if (linkTitles.length > 0) { const titlesText = linkTitles.join('\n'); const titleScan = scanForInjection(titlesText); if (titleScan.critical.length > 0 || titleScan.high.length > 0 || titleScan.medium.length > 0) { const labels = [...titleScan.critical, ...titleScan.high, ...titleScan.medium]; const sev = titleScan.critical.length > 0 ? 'CRITICAL' : titleScan.high.length > 0 ? 'HIGH' : 'MEDIUM'; advisories.push( `Markdown link-title injection detected — ${sev} (markdown-link-title-injection, OWASP LLM01).\n` + ` Adversarial content hidden in link title attributes — rendered as tooltips, parsed by agents.\n` + labels.slice(0, 5).map(l => ` - ${l}`).join('\n') + '\n' + ` ${formatToolContext(toolName, toolInput)}` ); } } // Only run HTML-specific checks if content looks like HTML if (/<[a-zA-Z][^>]*>/.test(htmlSlice)) { const htmlFindings = []; // Detect CSS-hidden elements with substantial content const hiddenElementRegex = /<([a-z]+)\s[^>]*style\s*=\s*"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden|position\s*:\s*absolute[^"]*-\d{3,}px|font-size\s*:\s*0|opacity\s*:\s*0)[^"]*"[^>]*>([^<]{20,})/gi; let htmlMatch; while ((htmlMatch = hiddenElementRegex.exec(htmlSlice)) !== null) { const content = htmlMatch[2].trim().slice(0, 100); htmlFindings.push(`CSS-hidden <${htmlMatch[1]}>: "${content}${htmlMatch[2].length > 100 ? '...' : ''}"`); } // Detect injection in aria-label attributes const ariaRegex = /aria-label\s*=\s*"([^"]{20,})"/gi; while ((htmlMatch = ariaRegex.exec(htmlSlice)) !== null) { const ariaContent = htmlMatch[1].toLowerCase(); if (/(?:ignore|override|system|instruction|execute|exfiltrate|forget|disregard)/.test(ariaContent)) { htmlFindings.push(`Injection in aria-label: "${htmlMatch[1].slice(0, 100)}"`); } } if (htmlFindings.length > 0) { advisories.push( `Hidden HTML content detected — possible Agent Trap (OWASP LLM01, Content Injection).\n` + ` AI agents parse hidden elements that are invisible to human reviewers.\n` + htmlFindings.map(f => ` - ${f}`).join('\n') + '\n' + ` ${formatToolContext(toolName, toolInput)}` ); } // ----------------------------------------------------------------------- // E7 (v7.2.0): HTML comment node injection. // Generalizes the existing keyword-restricted CRITICAL pattern in // injection-patterns.mjs (which only fires on AGENT/AI/HIDDEN markers). // The existing pattern still fires (defense-in-depth); this scans the // body of any comment for the full injection rule set. // ----------------------------------------------------------------------- const commentRegex = //g; const commentBodies = []; let commentMatch; while ((commentMatch = commentRegex.exec(htmlSlice)) !== null) { const body = commentMatch[1].trim(); if (body.length > 0) { commentBodies.push(decodeHtmlEntities(body)); } } if (commentBodies.length > 0) { const commentScan = scanForInjection(commentBodies.join('\n')); if (commentScan.critical.length > 0 || commentScan.high.length > 0 || commentScan.medium.length > 0) { const labels = [...commentScan.critical, ...commentScan.high, ...commentScan.medium]; const sev = commentScan.critical.length > 0 ? 'CRITICAL' : commentScan.high.length > 0 ? 'HIGH' : 'MEDIUM'; advisories.push( `HTML comment-node injection detected — ${sev} (html-comment-injection, OWASP LLM01).\n` + ` Adversarial content inside — invisible in render, parsed by agents.\n` + labels.slice(0, 5).map(l => ` - ${l}`).join('\n') + '\n' + ` ${formatToolContext(toolName, toolInput)}` ); } } // ----------------------------------------------------------------------- // E5 (v7.2.0): SVG element-content injection. // Adversarial text inside , , <metadata>, <foreignObject> // is invisible in rendered SVG yet parsed by agents reading the source. // ----------------------------------------------------------------------- const isSvgSource = /<svg[\s>]/i.test(htmlSlice); if (isSvgSource) { const svgElementRegex = /<(desc|title|metadata|foreignObject)\b[^>]*>([\s\S]*?)<\/\1>/gi; const svgTexts = []; let svgMatch; while ((svgMatch = svgElementRegex.exec(htmlSlice)) !== null) { const inner = svgMatch[2].trim(); if (inner.length > 0) { svgTexts.push(decodeHtmlEntities(inner)); } } if (svgTexts.length > 0) { const svgScan = scanForInjection(svgTexts.join('\n')); if (svgScan.critical.length > 0 || svgScan.high.length > 0 || svgScan.medium.length > 0) { const labels = [...svgScan.critical, ...svgScan.high, ...svgScan.medium]; const sev = svgScan.critical.length > 0 ? 'CRITICAL' : svgScan.high.length > 0 ? 'HIGH' : 'MEDIUM'; advisories.push( `SVG element-content injection detected — ${sev} (svg-element-injection, OWASP LLM01).\n` + ` Adversarial text inside <desc>/<title>/<metadata>/<foreignObject> — invisible in render, parsed by agents.\n` + labels.slice(0, 5).map(l => ` - ${l}`).join('\n') + '\n' + ` ${formatToolContext(toolName, toolInput)}` ); } } } } } // ========================================================================= // MCP description drift detection (OWASP MCP05 — Rug Pull) // Checks if the MCP tool's description has changed since first seen. // Two signals: // - per-update drift (>10% Levenshtein vs previous) // - cumulative drift (>=25% Levenshtein vs sticky baseline) — catches // slow-burn rug-pulls where each update stays under the per-update // threshold but cumulatively diverges from the original. v7.3.0 / E14. // Only relevant for MCP tools that provide a description in tool_input. // ========================================================================= const isMcpTool = toolName?.startsWith('mcp__'); if (isMcpTool && !isTrustedMcp) { const description = toolInput?.description || toolInput?.tool_description || ''; if (description && typeof description === 'string' && description.length > 10) { try { const driftResult = checkDescriptionDrift(toolName, description); if (driftResult.drift) { advisories.push( `MCP tool description drift detected (OWASP MCP05 — Rug Pull).\n` + ` ${driftResult.detail}\n` + ` Previous: "${(driftResult.cached || '').slice(0, 120)}${(driftResult.cached || '').length > 120 ? '...' : ''}"\n` + ` Current: "${description.slice(0, 120)}${description.length > 120 ? '...' : ''}"\n` + ` A changed tool description may indicate the MCP server has been compromised.` ); } // Cumulative-drift advisory (mcp-cumulative-drift, MEDIUM). Independent // of per-update drift — a slow-burn rug-pull triggers this without // ever crossing the per-update threshold. if (driftResult.cumulative && driftResult.cumulative.drifted) { const baselineDesc = driftResult.cumulative.baseline || ''; advisories.push( `MCP tool cumulative description drift — MEDIUM (mcp-cumulative-drift, OWASP MCP05).\n` + ` ${driftResult.cumulative.detail}\n` + ` Baseline: "${baselineDesc.slice(0, 120)}${baselineDesc.length > 120 ? '...' : ''}"\n` + ` Current: "${description.slice(0, 120)}${description.length > 120 ? '...' : ''}"\n` + ` Reset the baseline after a legitimate MCP server upgrade with: /security mcp-baseline-reset` ); } } catch { /* drift check is advisory, never block */ } } } // ========================================================================= // Per-MCP-tool volume tracking // Tracks cumulative output size per MCP tool within a session. Warns when // a single tool produces disproportionate output (>100 KB cumulative). // ========================================================================= if (isMcpTool && !isTrustedMcp && outputText.length > 0) { const volState = loadVolumeState(); volState.volumes[toolName] = (volState.volumes[toolName] || 0) + outputText.length; const toolTotal = volState.volumes[toolName]; if (toolTotal >= MCP_TOOL_VOLUME_THRESHOLD && !volState.warned[toolName]) { const kb = Math.round(toolTotal / 1024); advisories.push( `MCP tool cumulative output exceeded ${Math.round(MCP_TOOL_VOLUME_THRESHOLD / 1024)} KB.\n` + ` Tool: ${toolName}\n` + ` Cumulative output this session: ~${kb} KB\n` + ` High per-tool volume may indicate bulk data harvesting (OWASP ASI02, MCP03).` ); volState.warned[toolName] = true; } saveVolumeState(volState); } // Emit combined advisory if anything was flagged if (advisories.length > 0) { const header = 'SECURITY ADVISORY (post-mcp-verify): Potential data leakage detected.'; const body = advisories.map((a, i) => `[${i + 1}] ${a}`).join('\n\n'); emitAdvisory(`${header}\n\n${body}`); } // PostToolUse hooks are always advisory — never block. process.exit(0);