423 lines
15 KiB
JavaScript
423 lines
15 KiB
JavaScript
// 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<object>} - 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,
|
|
);
|
|
}
|
|
}
|