test(llm-security): store the malicious-skill demo encoded, materialize at run time

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 13:21:22 +02:00
commit b3c47330e1
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 639 additions and 409 deletions

View file

@ -0,0 +1,73 @@
#!/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);
}