llm-security/examples/poisoned-claude-md/run-memory-poisoning.mjs
Kjell Tore Guttormsen 7d1de2ce25
test(llm-security): store the poisoned-claude-md fixture encoded
v8.1.0 S2. examples/poisoned-claude-md/fixture/ (CLAUDE.md with a base64
pipe-to-shell blob, plus an agent file) is now fixture.archive.json in the
same format as the demo archive; run-memory-poisoning.mjs materializes it
into a temp dir and deletes it on exit. README shows materialize-then-scan.
payload-trees.test.mjs asserts byte identity for both archives.

av-surface: b 6->5, d 1->0. Walkthrough output identical before/after
(6 pass, 0 fail, 18 findings).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:22:10 +02:00

132 lines
6 KiB
JavaScript

#!/usr/bin/env node
// run-memory-poisoning.mjs — Memory-poisoning scanner walkthrough
// Runs scanners/memory-poisoning-scanner.mjs against a deliberately
// poisoned CLAUDE.md + .claude/agents/health-checker.md fixture
// (materialized from fixture.archive.json into a temp dir) and
// verifies all six detector categories report at least one finding.
//
// Categories (per memory-poisoning-scanner.mjs):
// 1. detectInjection — prompt-injection patterns
// 2. detectShellCommands — curl/wget/bash/eval in memory files
// 3. detectSuspiciousUrls — webhook.site, requestbin, etc.
// 4. detectCredentialPaths — ~/.ssh/, ~/.aws/, .env, kubeconfig
// 5. detectPermissionExpansion — allowed-tools, bypassPermissions
// 6. detectEncodedPayloads — base64 blobs that decode to commands
//
// Usage:
// cd plugins/llm-security
// node examples/poisoned-claude-md/run-memory-poisoning.mjs
// node examples/poisoned-claude-md/run-memory-poisoning.mjs --verbose
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = resolve(__dirname, '../..');
// v8.1.0 (S2): the poisoned fixture is not stored on disk as files — antivirus
// products quarantine it. It lives encoded in fixture.archive.json and is
// written to a temp dir for this run, then deleted on exit.
const { materializeArchive } = await import(resolve(__dirname, '../malicious-skill-demo/materialize.mjs'));
const { dir: FIXTURE, cleanup } = materializeArchive(resolve(__dirname, 'fixture.archive.json'));
process.on('exit', cleanup);
const VERBOSE = process.argv.includes('--verbose');
// ---------------------------------------------------------------------------
// Imports — discoveryFiles + memory-poisoning scanner
// ---------------------------------------------------------------------------
const { discoverFiles } = await import(resolve(PLUGIN_ROOT, 'scanners/lib/file-discovery.mjs'));
const { scan } = await import(resolve(PLUGIN_ROOT, 'scanners/memory-poisoning-scanner.mjs'));
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
console.log('MEMORY-POISONING SCANNER WALKTHROUGH');
console.log('====================================\n');
console.log(`Fixture: ${FIXTURE}`);
console.log('Files in scope:');
console.log(' - CLAUDE.md');
console.log(' - .claude/agents/health-checker.md (E15: agent files are memory surface)\n');
const discovery = await discoverFiles(FIXTURE);
const result = await scan(FIXTURE, discovery);
const findings = result.findings || [];
// ---------------------------------------------------------------------------
// Categorize findings
// ---------------------------------------------------------------------------
//
// memory-poisoning-scanner doesn't tag findings by detector — we infer the
// category from the title/message text. The contract is: at least one
// finding from each category.
const buckets = {
injection: [],
shellCommand: [],
suspiciousUrl: [],
credentialPath: [],
permissionExpansion: [],
encodedPayload: [],
};
// Order matters: more specific patterns first.
for (const f of findings) {
const t = (f.title || '') + ' ' + (f.message || '');
if (/permission\s+expansion|allowed-tools|bypassPermissions|dangerously|skip-permissions/i.test(t)) buckets.permissionExpansion.push(f);
else if (/credential\s+path|\.ssh|\.aws|kubeconfig|wallet|service[\s_-]account|sensitive\s+path|credential[s]?\s+reference/i.test(t)) buckets.credentialPath.push(f);
else if (/suspicious\s+(?:url|domain|exfiltration)|webhook\.site|requestbin|exfiltration\s+(?:url|domain)/i.test(t)) buckets.suspiciousUrl.push(f);
else if (/base64|encoded\s+payload|payload\s+\(encoded\)/i.test(t)) buckets.encodedPayload.push(f);
else if (/shell\s+command|shell-command|curl|wget|eval|chmod|npm\s+install|pip\s+install/i.test(t)) buckets.shellCommand.push(f);
else if (/injection|prompt|spoofed|hidden\s+instruction|override|ignore\s+previous/i.test(t)) buckets.injection.push(f);
}
const expectations = [
['injection', 'detectInjection — prompt-injection / hidden directive patterns'],
['shellCommand', 'detectShellCommands — curl/wget/bash/eval/chmod'],
['suspiciousUrl', 'detectSuspiciousUrls — webhook.site / requestbin / etc'],
['credentialPath', 'detectCredentialPaths — ~/.ssh/, ~/.aws/, .env, kubeconfig, wallet.dat'],
['permissionExpansion', 'detectPermissionExpansion — allowed-tools / bypassPermissions / skip-permissions'],
['encodedPayload', 'detectEncodedPayloads — base64 blob that decodes to a command'],
];
let pass = 0;
let fail = 0;
for (const [key, label] of expectations) {
const ok = buckets[key].length > 0;
if (ok) pass++; else fail++;
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${label}`);
console.log(` findings: ${buckets[key].length}`);
for (const f of buckets[key].slice(0, 2)) {
const sev = (f.severity || '').toUpperCase().padEnd(8);
const title = (f.title || f.message || '').slice(0, 90);
console.log(` ${sev} ${title}`);
}
console.log();
}
console.log(`Total memory-poisoning findings: ${findings.length}`);
console.log(`Files scanned: ${result.filesScanned ?? '?'}`);
console.log(`Scanner status: ${result.status}`);
if (VERBOSE) {
console.log('\nFull findings list:');
for (const f of findings) {
const sev = (f.severity || '').toUpperCase().padEnd(8);
console.log(` ${sev} [${f.file || '?'}:${f.line || '?'}] ${(f.title || f.message || '').slice(0, 110)}`);
}
}
console.log('\n---');
console.log(`Result: ${pass} pass, ${fail} fail`);
if (fail > 0) {
console.log('\nFAILURE — at least one detector category had zero findings.');
console.log('Inspect verbose output (--verbose) to see what was actually returned.');
process.exit(1);
}
console.log('\nSUCCESS — all 6 detector categories caught the planted signals.');
console.log('Read examples/poisoned-claude-md/README.md for category mapping.');
process.exit(0);