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:
parent
daa12b3bbb
commit
b3c47330e1
18 changed files with 639 additions and 409 deletions
73
examples/malicious-skill-demo/materialize.mjs
Normal file
73
examples/malicious-skill-demo/materialize.mjs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue