test(llm-security): add red AV-surface probe for the v8.1.0 Defender plan

tests/av-surface.test.mjs walks git ls-files and fails on four surfaces a
clone or the plugin cache puts on a user's disk: (a) own SIG payloads
(webshell/reverse_shell/cryptominer) in a runnable file type, (b) base64
blobs decoding to a shell command, (c) Tag/zero-width/bidi carriers in text
files outside the conformance corpus, (d) known payload trees.

Red on purpose (order S0): it is the failing test for S1-S3. Measured on
this tree: a=3 b=9 (8 files) c=5 d=5. Chosen definitions are documented in
the file header: .mjs/.js/.cjs/.json and scanners/commons/** are outside (a)
so the untouchable signature tables stay out of a gate that must reach
zero; binaries are outside (c) because decoding PNG/WOFF2 as UTF-8 yields
the codepoints by chance.

PLAN.md is local-only, so .gitignore now names it. No production code, no
fixtures, no signature tables touched; golden baseline unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 12:39:26 +02:00
commit c7bfd2cfd0
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 167 additions and 0 deletions

3
.gitignore vendored
View file

@ -24,6 +24,9 @@ HANDOFF-FINDINGS.local.md
# Untracked 2026-08-01 (org-ops finding) after being tracked since project start;
# git history keeps the old commits, this only stops future ones.
STATE.md
# PLAN.md is local-only for the same reason (destination, open questions,
# what NOT to build) — it never reaches the public remote.
PLAN.md
REMEMBER.md
ROADMAP.md
TODO.md

164
tests/av-surface.test.mjs Normal file
View file

@ -0,0 +1,164 @@
// av-surface.test.mjs — the antivirus surface of the tracked tree.
//
// Outcome this gates (operator, 2026-09-22): a Windows user can clone the
// repository and install the plugin without Defender or a similar product
// quarantining anything, and the plugin still finds what it finds today.
//
// What a documented AV quarantine in a security repository reacts to is a
// payload sitting on disk in the file type that would run it (a reverse shell
// in `.sh`, a webshell in `.php`, a SKILL.md clustering attack techniques) —
// not a regex table. So this test walks `git ls-files` — the same set a clone
// and the plugin cache put on a user's disk — and fails on four surfaces:
//
// (a) a file whose non-comment content matches one of our OWN SIG rules in
// the webshell / reverse_shell / cryptominer families, through the same
// decode variants the SIG scanner tests (raw, decoded, homoglyph-folded,
// rot13). The rules are reused from scanners/lib/malware-signatures.mjs;
// no new regexes. Excluded from the denominator:
// - code/data hosts (.mjs .js .cjs .json), where a payload is a quoted
// string or a detection-table entry, not a runnable file. This keeps
// the untouchable signature tables (commons JSON, golden
// patterns.json, the supply-chain blocklist) out of a gate that must
// reach zero. Literal payloads inside test .mjs files are therefore
// NOT gated here; S1 moves them to test-time construction anyway.
// - scanners/commons/**, the vendored pull-only subtree this
// repository may not edit.
// (b) a base64 blob of 24+ characters that decodes to printable text
// containing curl, wget, bash, sh, eval or http as a WORD. Word
// boundaries are deliberate: a bare substring `sh` matches "should" and
// "hash", which are not shell commands.
// (c) a Unicode Tag (U+E0000-U+E007F), zero-width (U+200B-U+200D, U+2060,
// U+FEFF) or bidi-control (U+202A-U+202E, U+2066-U+2069) codepoint in a
// TEXT file outside scanners/commons/conformance/**. Binary files (a NUL
// byte in the first 8 KiB, the rule readTextFile uses) are outside the
// denominator: decoding compressed PNG/WOFF2 bytes as UTF-8 yields these
// codepoints by chance, and no reader ever sees them as characters.
// (d) any tracked file under a known payload tree.
//
// This test was written RED on purpose (order S0, 2026-09-22): it is the
// failing test for sessions S1-S3 of the v8.1.0 plan. It is expected to fail
// until those sessions land.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SIGNATURE_RULES } from '../scanners/lib/malware-signatures.mjs';
import { normalizeForScan, foldHomoglyphs, rot13 } from '../scanners/lib/string-utils.mjs';
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const PAYLOAD_FAMILIES = new Set(['webshell', 'reverse_shell', 'cryptominer']);
const QUOTING_HOSTS = new Set(['.mjs', '.js', '.cjs', '.json']);
const VENDORED = 'scanners/commons/';
const CONFORMANCE = 'scanners/commons/conformance/';
// A line counts as a comment when it opens with a comment marker. `#!` is a
// shebang, not a comment, and stays in.
const COMMENT_LINE = /^\s*(?:\/\/|#(?!!)|\/\*|\*|<!--)/;
const BASE64_BLOB = /[A-Za-z0-9+/]{24,}={0,2}/g;
const PRINTABLE = /^[\x20-\x7e\t\r\n]+$/;
const SHELL_WORD = /\b(?:curl|wget|bash|sh|eval|http)\b/;
const CARRIER = /[\u{E0000}-\u{E007F}\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/u;
const PAYLOAD_TREES = [
'tests/fixtures/signature-scan/poisoned/',
'tests/fixtures/memory-scan/poisoned-project/',
'tests/fixtures/trigger-scan/poisoned/',
'examples/malicious-skill-demo/evil-project-health/',
'examples/poisoned-claude-md/fixture/',
];
function trackedFiles() {
return execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8' })
.split('\0')
.filter(Boolean);
}
function isBinary(buf) {
return buf.subarray(0, 8192).includes(0);
}
/** Measure all four surfaces over the tracked tree. */
function measureAvSurface() {
const files = trackedFiles();
const rules = SIGNATURE_RULES.filter(r => PAYLOAD_FAMILIES.has(r.family));
const a = [];
const b = [];
const c = [];
let textFiles = 0;
for (const rel of files) {
const buf = readFileSync(resolve(ROOT, rel));
if (isBinary(buf)) continue;
textFiles++;
const text = buf.toString('utf8');
if (!QUOTING_HOSTS.has(extname(rel).toLowerCase()) && !rel.startsWith(VENDORED)) {
const body = text.split('\n').filter(line => !COMMENT_LINE.test(line)).join('\n');
const variants = [
body,
normalizeForScan(body, { decodeEmbedded: true }),
normalizeForScan(body.trim(), { decodeEmbedded: true }),
foldHomoglyphs(body),
rot13(body),
];
const hits = rules.filter(r => variants.some(v => r.re.test(v))).map(r => r.id);
if (hits.length > 0) a.push(`${rel} [${hits.join(', ')}]`);
}
for (const m of text.matchAll(BASE64_BLOB)) {
const decoded = Buffer.from(m[0], 'base64').toString('latin1');
if (PRINTABLE.test(decoded) && SHELL_WORD.test(decoded)) {
b.push(`${rel} :: ${decoded.slice(0, 60).replace(/\s+/g, ' ')}`);
}
}
if (!rel.startsWith(CONFORMANCE) && CARRIER.test(text)) c.push(rel);
}
const d = PAYLOAD_TREES
.map(tree => ({ tree, files: files.filter(f => f.startsWith(tree)) }))
.filter(t => t.files.length > 0);
return {
tracked: files.length,
textFiles,
a,
b,
bFiles: new Set(b.map(x => x.split(' :: ')[0])).size,
c,
d,
};
}
const report = (label, items) => `${label}: ${items.length}\n ${items.join('\n ')}`;
describe('av-surface: tracked tree carries no AV-triggering payloads', () => {
const m = measureAvSurface();
it(`(a) no SIG payload in a runnable file type (of ${m.textFiles} text files)`, (t) => {
t.diagnostic(`a=${m.a.length}`);
assert.deepEqual(m.a, [], report('files with a SIG payload', m.a));
});
it(`(b) no base64 blob decoding to a shell command (of ${m.textFiles} text files)`, (t) => {
t.diagnostic(`b=${m.b.length} blobs in ${m.bFiles} files`);
assert.deepEqual(m.b, [], report('base64 blobs', m.b));
});
it(`(c) no Tag/zero-width/bidi carrier outside the conformance corpus (of ${m.textFiles} text files)`, (t) => {
t.diagnostic(`c=${m.c.length}`);
assert.deepEqual(m.c, [], report('files with a carrier codepoint', m.c));
});
it(`(d) no known payload tree on disk (of ${PAYLOAD_TREES.length} trees)`, (t) => {
const lines = m.d.map(x => `${x.tree} (${x.files.length} files)`);
t.diagnostic(`d=${m.d.length}`);
assert.deepEqual(lines, [], report('payload trees', lines));
});
});