// 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*(?:\/\/|#(?!!)|\/\*|\*|