// 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 with // a COMMAND SHAPE: it starts with curl, wget, bash, sh, python, eval or // nc as a word, or it contains a pipe into bash/sh or `$(`. Word // boundaries are deliberate: a bare substring `sh` matches "should". // Narrowed in S2 (2026-09-22, PM decision): the S0 rule fired on `http` // ANYWHERE in the decoded text, which caught an SVG data URI in // tests/fixtures/skill-scan/hyperframes-like (xmlns="http://www.w3.org/ // 2000/svg") — markup, not a command, and not what an AV reacts to. // Measured before the real blobs were removed: 8 blobs in 7 files under // the S0 rule, 8 in 6 under this one — the SVG dropped out, and `$(` // caught the second half of a payload split across two blobs in // examples/malicious-skill-demo/security-assessment.md, which the S0 // rule missed. All 7 command blobs the S0 rule saw still hit. // (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 (six since S3, which added // tests/fixtures/ast-scan/). // (a2) a test source (tests/**/*.mjs|.js, tests/golden/** excluded) whose RAW // text holds a contiguous literal matching one of the same SIG rules. // Raw bytes only, comments included: this is what sits on disk, and a // payload built at test time from fragments ('ev' + 'al') or rot13 does // not match here — which is the point. Added in S1 (2026-09-22) because // (a) keeps .mjs out of its denominator. // (e) a file under agents/**, commands/** or hooks/** — what a Claude Code // session loads at the user's end, so a quarantine there breaks the // installed plugin, not just a clone — whose RAW text holds a contiguous // literal matching one of the same SIG rules, a pipe into a shell // (curl/wget ... | sh|bash) or a base64 decode piped into a shell. Raw // text, comments included, one line at a time, every file type: a hook // script's comment sits on disk like any other byte. knowledge/** is // measured by the same rule and REPORTED as its own number in the // diagnostic, not gated (S3, 2026-09-22): part of it is a scanner's or // the attack simulator's input, and S3 records which literal stays and // why. Added in S3 (2026-09-22). // // 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/'; const TEST_SOURCE = /^tests\/(?!golden\/).*\.(?:mjs|js)$/; // 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*(?:\/\/|#(?!!)|\/\*|\*|