v8.1.0 S2. examples/malicious-skill-demo/evil-project-health/ (7 files, 30 Unicode Tag chars, a base64 exfil blob) is now one archive, evil-project-health.archive.json: rot13 text, every codepoint above U+007E stored as a number, sha256 of each retired file recorded. materialize.mjs writes it to a temp dir (CLI prints the path); run-demo.sh materializes and deletes it itself; the six scanner tests that scanned the tree use it. payload-trees.test.mjs asserts byte identity (mutation-checked). av-surface: b 8->6, c 1->0, d 2->1. Demo 13/13 before and after. All scanners report identical findings except git-forensics: it used to scan this repository's own history (21 findings, none about the demo) and now reports skipped in a temp dir, which git.test.mjs already accepts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73 lines
3 KiB
JavaScript
73 lines
3 KiB
JavaScript
#!/usr/bin/env node
|
|
// materialize.mjs — write an example's poisoned tree into a temp dir.
|
|
//
|
|
// v8.1.0 AV surface (S2, 2026-09-22): a Windows user cloning this repository
|
|
// must not have Defender quarantine anything, so the demo trees no longer sit
|
|
// on disk. Each one is stored as ONE encoded archive next to its example:
|
|
// - text is rot13, so no payload sits in the file as a contiguous literal;
|
|
// - every codepoint above U+007E (the Unicode Tag steganography, em dashes)
|
|
// is stored as a NUMBER, never as a character.
|
|
// Each file also carries the sha256 of the bytes that were on disk before the
|
|
// move; tests/helpers/payload-trees.test.mjs asserts the materialized bytes
|
|
// still match it, the same check the S1 trees get.
|
|
//
|
|
// Usage (prints the materialized directory; delete it when you are done):
|
|
// node examples/malicious-skill-demo/materialize.mjs
|
|
// node examples/malicious-skill-demo/materialize.mjs examples/poisoned-claude-md/fixture.archive.json
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
export const DEMO_ARCHIVE = join(HERE, 'evil-project-health.archive.json');
|
|
|
|
function rot13(s) {
|
|
return s.replace(/[A-Za-z]/g, c => {
|
|
const base = c <= 'Z' ? 65 : 97;
|
|
return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
|
|
});
|
|
}
|
|
|
|
/** Parts are rot13 strings and codepoint numbers, in order. */
|
|
export function decodeContent(parts) {
|
|
return parts.map(p => (typeof p === 'number' ? String.fromCodePoint(p) : rot13(p))).join('');
|
|
}
|
|
|
|
/**
|
|
* Read an archive. Returns `{ name, files: { rel: { sha256, content } } }`
|
|
* with content decoded.
|
|
* @param {string} archivePath
|
|
*/
|
|
export function readArchive(archivePath) {
|
|
const archive = JSON.parse(readFileSync(archivePath, 'utf8'));
|
|
const files = {};
|
|
for (const [rel, { sha256, parts }] of Object.entries(archive.files)) {
|
|
files[rel] = { sha256, content: decodeContent(parts) };
|
|
}
|
|
return { name: archive.name, files };
|
|
}
|
|
|
|
/**
|
|
* Write one archive into a fresh temp dir. The leaf directory keeps the old
|
|
* tree's name (e.g. `evil-project-health`), so paths in findings look the same.
|
|
* @param {string} [archivePath]
|
|
* @returns {{ dir: string, cleanup: () => void }}
|
|
*/
|
|
export function materializeArchive(archivePath = DEMO_ARCHIVE) {
|
|
const { name, files } = readArchive(archivePath);
|
|
const root = mkdtempSync(join(tmpdir(), 'llm-sec-example-'));
|
|
const dir = join(root, name);
|
|
for (const [rel, { content }] of Object.entries(files)) {
|
|
const target = join(dir, rel);
|
|
mkdirSync(dirname(target), { recursive: true });
|
|
writeFileSync(target, content);
|
|
}
|
|
return { dir, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const { dir } = materializeArchive(process.argv[2] ? resolve(process.argv[2]) : DEMO_ARCHIVE);
|
|
console.log(dir);
|
|
}
|