// memory-poisoning-scanner.mjs — Detects cognitive state poisoning in CLAUDE.md, // memory files, .claude/rules, and other agent configuration files. // // "Cognitive State Traps" (Franklin et al., Google DeepMind, 2025): // Latent Memory Poisoning + RAG Knowledge Poisoning. CLAUDE.md and memory/*.md // are Claude Code's equivalent of RAG corpora — loaded automatically into context, // potentially containing instructions the agent follows uncritically. // // Zero external dependencies — Node.js builtins only. // OWASP coverage: LLM01 (Prompt Injection), ASI02 (Excessive Agency) import { readTextFile } from './lib/file-discovery.mjs'; import { finding, scannerResult } from './lib/output.mjs'; import { SEVERITY } from './lib/severity.mjs'; import { scanForInjection } from './lib/injection-patterns.mjs'; import { isBase64Like, isHexBlob } from './lib/string-utils.mjs'; // --------------------------------------------------------------------------- // Target file patterns — files that influence agent cognition // --------------------------------------------------------------------------- /** Glob-like path matchers for memory/config files */ const MEMORY_FILE_PATTERNS = [ /(?:^|\/)CLAUDE\.md$/i, /(?:^|\/)\.claude\/rules\/[^/]+\.md$/, /(?:^|\/)memory\/[^/]+\.md$/, /(?:^|\/)REMEMBER\.md$/i, /\.local\.md$/, /(?:^|\/)\.claude-plugin\/plugin\.json$/, ]; /** Files that are CLAUDE.md (may legitimately contain shell examples) */ const CLAUDE_MD_PATTERN = /(?:^|\/)CLAUDE\.md$/i; /** Files that should NOT contain shell commands (memory, rules, REMEMBER) */ const STRICT_FILES_PATTERN = /(?:^|\/)(?:memory\/[^/]+\.md|REMEMBER\.md|\.claude\/rules\/[^/]+\.md)$/i; // --------------------------------------------------------------------------- // Detection patterns // --------------------------------------------------------------------------- /** Shell commands that should not appear in memory/rules files */ const SHELL_COMMAND_RE = /(?:^|[`'";\s|&])(?:curl|wget|bash|sh|eval|exec|chmod\s+[+0-7]|npm\s+install|pip3?\s+install|gem\s+install|cargo\s+install)\b/i; /** Credential path references */ const CREDENTIAL_PATH_RE = /(?:~\/|\/home\/|\/root\/)?\.(?:ssh|aws|gnupg|config\/gcloud)\/|(?:^|[\s/"'`])(?:id_rsa|id_ed25519|id_ecdsa|wallet\.dat|keystore|credentials\.json|\.env(?:\.\w+)?|\.netrc|\.pgpass|kubeconfig|service[_-]account[_-]key)(?:[\s/"'`]|$)/i; /** Permission expansion directives */ const PERMISSION_EXPANSION_RE = /(?:allowed-tools\s*[=:]\s*.*(?:Bash|Write|Edit|all)|bypassPermissions\s*[=:]\s*true|dangerouslySkipPermissions|--dangerously-skip-permissions|dangerouslyAllowArbitraryPaths\s*[=:]\s*true)/i; /** Suspicious exfiltration domains (subset of network-mapper) */ const SUSPICIOUS_DOMAINS = new Set([ 'webhook.site', 'webhookinbox.com', 'requestbin.com', 'requestbin.net', 'pipedream.net', 'hookbin.com', 'beeceptor.com', 'requestcatcher.com', 'ngrok.io', 'ngrok.app', 'ngrok-free.app', 'serveo.net', 'localtunnel.me', 'pastebin.com', 'paste.ee', 'transfer.sh', 'file.io', 'temp.sh', '0x0.st', ]); /** URL extraction regex */ const URL_RE = /https?:\/\/([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)(?:\/[^\s"'`)\]]*)?/g; /** Base64 token regex (40+ chars) */ const BASE64_TOKEN_RE = /[A-Za-z0-9+/]{40,}={0,3}/g; /** Hex blob regex (64+ chars) */ const HEX_TOKEN_RE = /(?:0x)?[0-9a-fA-F]{64,}/g; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function isMemoryFile(relPath) { return MEMORY_FILE_PATTERNS.some(p => p.test(relPath)); } function isStrictFile(relPath) { return STRICT_FILES_PATTERN.test(relPath); } function isClaudeMd(relPath) { return CLAUDE_MD_PATTERN.test(relPath); } /** * Extract domain from a URL match. * @param {string} host - hostname from URL regex * @returns {string} - domain to check against suspicious set */ function getDomainForCheck(host) { const parts = host.toLowerCase().split('.'); // Return last two parts (e.g., "webhook.site" from "abc.webhook.site") if (parts.length >= 2) { return parts.slice(-2).join('.'); } return host.toLowerCase(); } // --------------------------------------------------------------------------- // Detection functions — each returns an array of findings // --------------------------------------------------------------------------- /** * Check 1: Injection patterns via shared injection-patterns.mjs */ function detectInjection(content, relPath) { const results = []; const scan = scanForInjection(content); if (!scan.found) return results; for (const { label, severity } of scan.patterns) { let sev; if (severity === 'critical') sev = SEVERITY.CRITICAL; else if (severity === 'high') sev = SEVERITY.HIGH; else sev = SEVERITY.MEDIUM; results.push(finding({ scanner: 'MEM', severity: sev, title: `Injection pattern in cognitive state file: ${label}`, description: `Memory/config file "${relPath}" contains a prompt injection pattern: "${label}". ` + 'These files are loaded into agent context automatically and can manipulate agent behavior.', file: relPath, evidence: label, owasp: 'LLM01', recommendation: 'Remove the injection pattern. Review the file for unauthorized modifications. ' + 'Consider adding integrity checks for CLAUDE.md and memory files.', })); } return results; } /** * Check 2: Shell commands in files that shouldn't have them */ function detectShellCommands(content, relPath) { const results = []; const lines = content.split('\n'); const strict = isStrictFile(relPath); const claudeMd = isClaudeMd(relPath); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!SHELL_COMMAND_RE.test(line)) continue; // In CLAUDE.md, shell commands in code blocks are legitimate if (claudeMd) { // Check if we're inside a code block (simple heuristic: track ``` state) let inCodeBlock = false; for (let j = 0; j <= i; j++) { if (lines[j].trim().startsWith('```')) inCodeBlock = !inCodeBlock; } if (inCodeBlock) continue; // legitimate code example results.push(finding({ scanner: 'MEM', severity: SEVERITY.LOW, title: 'Shell command outside code block in CLAUDE.md', description: `CLAUDE.md line ${i + 1} contains a shell command outside a code block. ` + 'This may be a legitimate instruction or an attempt to get the agent to execute commands.', file: relPath, line: i + 1, evidence: line.trim().slice(0, 120), owasp: 'LLM01', recommendation: 'Verify this shell command is intentional. If it is an instruction for the agent, ' + 'consider whether it could be exploited via CLAUDE.md poisoning.', })); } else if (strict) { results.push(finding({ scanner: 'MEM', severity: SEVERITY.HIGH, title: 'Shell command in memory/rules file', description: `Memory/rules file "${relPath}" line ${i + 1} contains a shell command. ` + 'Memory and rules files should not contain executable commands — this is ' + 'a strong indicator of cognitive state poisoning.', file: relPath, line: i + 1, evidence: line.trim().slice(0, 120), owasp: 'LLM01', recommendation: 'Remove the shell command from this memory file. Memory files should contain ' + 'state and context only, never executable instructions.', })); } } return results; } /** * Check 3: Suspicious URLs (exfiltration domains) */ function detectSuspiciousUrls(content, relPath) { const results = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; URL_RE.lastIndex = 0; let match; while ((match = URL_RE.exec(line)) !== null) { const host = match[1]; const domain = getDomainForCheck(host); if (SUSPICIOUS_DOMAINS.has(domain)) { results.push(finding({ scanner: 'MEM', severity: SEVERITY.HIGH, title: 'Suspicious exfiltration URL in cognitive state file', description: `File "${relPath}" line ${i + 1} references "${domain}" — a known ` + 'data exfiltration / webhook interception service. In a memory or CLAUDE.md file, ' + 'this could instruct the agent to send data to an attacker-controlled endpoint.', file: relPath, line: i + 1, evidence: match[0].slice(0, 100), owasp: 'LLM01', recommendation: 'Remove the suspicious URL. Investigate how it was introduced. ' + 'Memory and config files should never reference webhook/tunnel services.', })); } } } return results; } /** * Check 4: Credential path references */ function detectCredentialPaths(content, relPath) { const results = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(CREDENTIAL_PATH_RE); if (!match) continue; // In CLAUDE.md inside code blocks, credential path refs may be legitimate documentation if (isClaudeMd(relPath)) { let inCodeBlock = false; for (let j = 0; j <= i; j++) { if (lines[j].trim().startsWith('```')) inCodeBlock = !inCodeBlock; } if (inCodeBlock) continue; } results.push(finding({ scanner: 'MEM', severity: SEVERITY.HIGH, title: 'Credential path reference in cognitive state file', description: `File "${relPath}" line ${i + 1} references a credential path (${match[0].trim()}). ` + 'Memory files that reference credential locations could be used to instruct the agent ' + 'to access sensitive key material.', file: relPath, line: i + 1, evidence: match[0].trim().slice(0, 80), owasp: 'ASI02', recommendation: 'Remove credential path references from memory/config files. ' + 'If credential paths need documentation, use a separate secured document.', })); } return results; } /** * Check 5: Permission expansion directives */ function detectPermissionExpansion(content, relPath) { const results = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(PERMISSION_EXPANSION_RE); if (!match) continue; results.push(finding({ scanner: 'MEM', severity: SEVERITY.CRITICAL, title: 'Permission expansion directive in cognitive state file', description: `File "${relPath}" line ${i + 1} contains a permission expansion directive: ` + `"${match[0].trim()}". This could grant the agent excessive capabilities through ` + 'configuration poisoning.', file: relPath, line: i + 1, evidence: match[0].trim().slice(0, 100), owasp: 'ASI02', recommendation: 'Remove the permission expansion directive. Agent permissions should be configured ' + 'in settings.json with deny-first approach, not in memory or rules files.', })); } return results; } /** * Check 6: Encoded payloads (base64 / hex blobs) */ function detectEncodedPayloads(content, relPath) { const results = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check base64 BASE64_TOKEN_RE.lastIndex = 0; let match; while ((match = BASE64_TOKEN_RE.exec(line)) !== null) { if (isBase64Like(match[0])) { results.push(finding({ scanner: 'MEM', severity: SEVERITY.MEDIUM, title: 'Base64-encoded payload in cognitive state file', description: `File "${relPath}" line ${i + 1} contains a base64-encoded string (${match[0].length} chars). ` + 'Encoded payloads in memory files can hide injection instructions or exfiltration commands ' + 'that evade text-based detection.', file: relPath, line: i + 1, evidence: `${match[0].slice(0, 40)}... (${match[0].length} chars)`, owasp: 'LLM01', recommendation: 'Decode and inspect the base64 content. Remove if it contains instructions or commands. ' + 'Memory files should contain plain text only.', })); } } // Check hex blobs HEX_TOKEN_RE.lastIndex = 0; while ((match = HEX_TOKEN_RE.exec(line)) !== null) { if (isHexBlob(match[0])) { results.push(finding({ scanner: 'MEM', severity: SEVERITY.MEDIUM, title: 'Hex-encoded blob in cognitive state file', description: `File "${relPath}" line ${i + 1} contains a hex-encoded blob (${match[0].length} chars). ` + 'Hex-encoded data in memory files can conceal malicious payloads.', file: relPath, line: i + 1, evidence: `${match[0].slice(0, 40)}... (${match[0].length} chars)`, owasp: 'LLM01', recommendation: 'Decode and inspect the hex content. Remove if it contains instructions or commands.', })); } } } return results; } // --------------------------------------------------------------------------- // Main scanner export // --------------------------------------------------------------------------- /** * Scan all discovered files for memory/cognitive state poisoning. * Only processes files matching MEMORY_FILE_PATTERNS. * * @param {string} targetPath - Absolute root path being scanned * @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery * @returns {Promise} - scannerResult envelope */ export async function scan(targetPath, discovery) { const startMs = Date.now(); const findings = []; let filesScanned = 0; try { for (const fileInfo of discovery.files) { // Only scan memory/config files if (!isMemoryFile(fileInfo.relPath)) continue; const content = await readTextFile(fileInfo.absPath); if (content === null) continue; filesScanned++; // Run all 6 detectors findings.push(...detectInjection(content, fileInfo.relPath)); findings.push(...detectShellCommands(content, fileInfo.relPath)); findings.push(...detectSuspiciousUrls(content, fileInfo.relPath)); findings.push(...detectCredentialPaths(content, fileInfo.relPath)); findings.push(...detectPermissionExpansion(content, fileInfo.relPath)); findings.push(...detectEncodedPayloads(content, fileInfo.relPath)); } const durationMs = Date.now() - startMs; return scannerResult('memory-poisoning-scanner', 'ok', findings, filesScanned, durationMs); } catch (err) { const durationMs = Date.now() - startMs; return scannerResult( 'memory-poisoning-scanner', 'error', findings, filesScanned, durationMs, err.message, ); } }