/** * PolinRider / Famous Chollima Supply-Chain Malware Scanner * * Detects the specific injection pattern used in the PolinRider campaign * attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342). * * IOCs sourced from: * - Direct analysis of the injected tailwind.config.js sample * - Socket Security report (May 2026) on roberts/leads compromise * - PolinRider technical analysis report (Trend Micro / safedep.io) * * Zero external dependencies — runs on any Node.js >= 14. */ "use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); // ─── Configuration ──────────────────────────────────────────────────────────── const CONFIG = { // Maximum legitimate size for JS config files. // Real tailwind/postcss/babel configs are rarely > 3 KB. // Injected files jump to 5–8 KB instantly. maxLegitConfigBytes: 3072, // Minimum whitespace run on a single line that signals hidden payload. // The campaign uses ~280–510 spaces to push payload off-screen. minSuspiciousInlineSpaces: 100, // Files that are high-value injection targets for this campaign. targetedFilenames: [ "tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.js", "postcss.config.js", "postcss.config.ts", "postcss.config.mjs", "postcss.config.cjs", "babel.config.js", "babel.config.ts", "babel.config.mjs", "babel.config.cjs", "next.config.js", "next.config.ts", "next.config.mjs", "next.config.cjs", "eslint.config.js", "eslint.config.ts", "eslint.config.mjs", "eslint.config.cjs", "astro.config.mjs", "astro.config.js", "vite.config.js", "vite.config.ts", "vite.config.mjs", "vite.config.cjs", "webpack.config.js", "webpack.mix.js", "svelte.config.js", "nuxt.config.ts", ], // Font files — the campaign appends JS payloads to web fonts, which are // never executed directly but are fetched by the build and used as a // staging blob. Binary formats, so they are read as latin1. fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"], // Minimum number of \uXXXX escape sequences in one file before it is // treated as deliberately obfuscated. Legitimate source files use a // handful at most; PolinRider samples carry 400–600. maxLegitUnicodeEscapes: 20, // Any single line longer than this inside a config file means code was // appended past the real export block. maxLegitConfigLineLength: 1000, // Files intentionally containing malware indicators for scanner logic/tests. // These filenames are skipped before malware rules are evaluated. ignoredFilenames: ["scan.js"], // Filesystem paths that indicate active persistence mechanisms persistenceArtifacts: [ "temp_auto_push.bat", "temp_interactive_push.bat", "branch_structure.json", // Note: queue.bat and .plist are OS-level; checked separately ], }; // ─── IOC Definitions ────────────────────────────────────────────────────────── /** * Each rule has: * id – unique rule identifier for reporting * severity – CRITICAL | HIGH | MEDIUM * description – human-readable explanation * test(content, filePath, lines) – returns array of match details or [] */ const RULES = [ // ── Tier 1: Definitive campaign signatures ───────────────────────────────── { id: "POLINRIDER-001", severity: "CRITICAL", description: "PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign", test(content) { const matches = []; const re = /_\$_1e42/g; let m; while ((m = re.exec(content)) !== null) { matches.push(`offset ${m.index}`); } return matches; }, }, { id: "POLINRIDER-002", severity: "CRITICAL", description: "PolinRider campaign marker global['!'] assignment — used to route C2 traffic", test(content) { const matches = []; const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g; let m; while ((m = re.exec(content)) !== null) { const snippet = content .slice(m.index, m.index + 40) .replace(/\n/g, "\\n"); matches.push(`"${snippet}"`); } return matches; }, }, { id: "POLINRIDER-003", severity: "CRITICAL", description: 'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo', test(content) { return content.includes("rmcej%otb%") ? ["seed string found"] : []; }, }, { id: "POLINRIDER-004", severity: "CRITICAL", description: "Known C2 IP addresses associated with PolinRider infrastructure", test(content) { const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"]; return knownC2.filter((ip) => content.includes(ip)); }, }, { id: "POLINRIDER-005", severity: "CRITICAL", description: "Known TRON blockchain wallet addresses used as dead-drop C2 resolvers", test(content) { const wallets = [ "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP", "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG", ]; return wallets.filter((w) => content.includes(w)); }, }, { id: "POLINRIDER-006", severity: "CRITICAL", description: "Known Aptos blockchain addresses used as fallback dead-drop resolvers", test(content) { const addrs = [ "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e", "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3", ]; return addrs.filter((a) => content.includes(a)); }, }, { id: "POLINRIDER-007", severity: "CRITICAL", description: "Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions", test(content) { const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"]; return keys.filter((k) => content.includes(k)); }, }, { id: "POLINRIDER-008", severity: "CRITICAL", description: "Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)", test(content) { const knownHashes = new Set([ "96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3", "522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f", ]); const hash = crypto.createHash("sha256").update(content).digest("hex"); return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : []; }, }, { id: "POLINRIDER-018", severity: "CRITICAL", description: "Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes", test(content) { // Only printable-ASCII escapes count. Minified vendor bundles legitimately // carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics // and CJK ranges — escaping ASCII that could have been written literally // has no purpose other than hiding it from a reader. const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter( (e) => { const code = parseInt(e.slice(2), 16); return code >= 0x20 && code <= 0x7e; }, ); return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes ? [ `${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`, ] : []; }, }, { id: "POLINRIDER-019", severity: "CRITICAL", description: "Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant", test(_content, _filePath, _lines, decoded) { const iocs = [ "0xa322e5f3d311d3080e6f0121063e9adc2490ef1a", "eth.blockscout.com", "eth_getBlockByNumber", "eth_getTransactionCount", "eth_blockNumber", "ethereum-rpc.publicnode.com", "eth-mainnet.public.blastapi.io", "1rpc.io/eth", "eth.drpc.org", ]; const hay = decoded.toLowerCase(); return iocs.filter((i) => hay.includes(i)); }, }, { id: "POLINRIDER-020", severity: "CRITICAL", description: "Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting", test(_content, _filePath, _lines, decoded) { const hits = []; if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded)) hits.push('spawn("node", ["-e", ])'); if (/detached\s*:\s*(!0|true)/.test(decoded)) hits.push("detached child process"); if (/stdio\s*:\s*["']ignore["']/.test(decoded)) hits.push('stdio:"ignore" (output suppressed)'); if (/\beval\s*\(\s*\w+\s*\+/.test(decoded)) hits.push("eval() of concatenated remote string"); if (/x-payload-b64/i.test(decoded)) hits.push("x-payload-b64 C2 response header"); // Only report when this is a genuine stager, not an isolated keyword. return hits.length >= 2 ? hits : []; }, }, { id: "POLINRIDER-021", severity: "CRITICAL", description: "Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module", test(_content, _filePath, _lines, decoded) { const hits = []; const marker = decoded.match( /global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/, ); if (marker) hits.push(`campaign marker: "${marker[1]}"`); if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded)) hits.push("global. = require"); if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded)) hits.push("global. = module"); return hits; }, }, { id: "POLINRIDER-022", severity: "CRITICAL", description: "Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review", test(content, filePath) { const ext = path.extname(filePath).toLowerCase(); if (!CONFIG.fontExtensions.includes(ext)) return []; const hits = []; const magic = content.slice(0, 4); const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" }; if (expected[ext] && magic !== expected[ext]) { hits.push( `bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`, ); } const codeMarkers = [ "require(", "eval(", "child_process", "global.", "createRequire", "process.env", ]; const found = codeMarkers.filter((m) => content.includes(m)); if (found.length > 0) { hits.push(`embedded JS markers: ${found.join(", ")}`); } return hits; }, }, { id: "POLINRIDER-023", severity: "CRITICAL", description: ".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run", test(content, filePath) { if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/"))) return []; const hits = []; if (/"runOn"\s*:\s*"folderOpen"/.test(content)) hits.push('runOn: "folderOpen" (executes without user action)'); const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/); if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1])) hits.push(`command: "${cmd[1]}"`); return hits.length >= 1 ? hits : []; }, }, { id: "POLINRIDER-024", severity: "HIGH", description: ".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them", test(content, filePath) { if (path.basename(filePath) !== ".gitignore") return []; const lines = content.split("\n").map((l) => l.trim()); return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map( (a) => `.gitignore hides "${a}"`, ); }, }, // ── Tier 2: Behavioral / structural indicators ───────────────────────────── { id: "POLINRIDER-009", severity: "HIGH", description: "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers", test(_content, _filePath, _lines, decoded) { const endpoints = [ "trongrid.io", "aptoslabs.com", "bsc-dataseed.binance.org", "bsc-rpc.publicnode.com", "eth_getTransactionByHash", ]; return endpoints.filter((e) => decoded.includes(e)); }, }, { id: "POLINRIDER-010", severity: "HIGH", description: "Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0", test(_content, _filePath, _lines, decoded) { const m = decoded.match(/windowsHide\s*:\s*(true|!0)/); return m ? [`windowsHide:${m[1]} found`] : []; }, }, { id: "POLINRIDER-011", severity: "HIGH", description: "Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements", test(content) { const matches = content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || []; return matches.length >= 2 ? [`Found ${matches.length} duplicate createRequire imports`] : []; }, }, { id: "POLINRIDER-012", severity: "HIGH", description: "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", test(_content, filePath, lines) { // Binary assets contain long runs of 0x20 as padding — not an indicator. if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase())) return []; const hits = []; lines.forEach((line, i) => { const spaceRun = line.match(/\s{100,}/); if (spaceRun) { hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`); } }); return hits; }, }, { id: "POLINRIDER-013", severity: "HIGH", description: "Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB", test(content, filePath) { const bytes = Buffer.byteLength(content, "utf8"); const base = path.basename(filePath).toLowerCase(); const isTargeted = CONFIG.targetedFilenames.some( (f) => f.toLowerCase() === base, ); if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) { return [ `${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`, ]; } return []; }, }, { id: "POLINRIDER-025", severity: "HIGH", description: "Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file", test(_content, filePath, lines) { const base = path.basename(filePath).toLowerCase(); if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base)) return []; const hits = []; lines.forEach((line, i) => { if (line.length > CONFIG.maxLegitConfigLineLength) { hits.push( `line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`, ); } }); return hits; }, }, { id: "POLINRIDER-014", severity: "HIGH", description: "Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches", test(_content, filePath) { const base = path.basename(filePath); return CONFIG.persistenceArtifacts.includes(base) ? [base] : []; }, }, // ── Tier 3: Supporting behavioral indicators ─────────────────────────────── { id: "POLINRIDER-015", severity: "MEDIUM", description: "Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)", test(content) { const matches = []; // Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928' const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g; let m; while ((m = re.exec(content)) !== null) { matches.push(`marker value: "${m[1]}"`); } return matches; }, }, { id: "POLINRIDER-016", severity: "MEDIUM", description: "sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant", test(content) { // sfL appears as a named function used to decode the larger payload blob const occurrences = (content.match(/\bsfL\b/g) || []).length; return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : []; }, }, { id: "POLINRIDER-017", severity: "MEDIUM", description: "global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions", test(content) { const hits = []; if (/global\s*\[.*\]\s*=\s*require/.test(content)) hits.push("global[x] = require"); if (/global\s*\[.*module.*\]\s*=\s*module/.test(content)) hits.push("global[x] = module"); return hits; }, }, ]; // ─── Scanner Engine ──────────────────────────────────────────────────────────── /** * Resolve \uXXXX and \xXX escapes so string-matching rules see the real API * calls. The campaign writes every literal as escapes specifically to defeat * grep, so rules that match on plain text must run against this form. * The decoded text is appended to the original rather than replacing it, so a * rule can still match either representation with one pass. */ function deobfuscate(content) { if (!/\\[ux]/.test(content)) return content; const decoded = content .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return m; } }) .replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)), ) .replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)), ); return content + "\n/* --- deobfuscated --- */\n" + decoded; } function scanFile(filePath) { if (shouldIgnoreFile(filePath)) { return { filePath, findings: [], skipped: true }; } // Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and // ASCII payload searches both work without mangling the content. const isBinary = CONFIG.fontExtensions.includes( path.extname(filePath).toLowerCase(), ); let content; try { content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8"); } catch (err) { return { filePath, error: err.message, findings: [] }; } const lines = content.split("\n"); const decoded = deobfuscate(content); const findings = []; for (const rule of RULES) { let matches; try { matches = rule.test(content, filePath, lines, decoded); } catch (err) { matches = [`[rule error: ${err.message}]`]; } if (matches && matches.length > 0) { findings.push({ id: rule.id, severity: rule.severity, description: rule.description, matches, }); } } return { filePath, findings }; } function shouldIgnoreFile(filePath) { const base = path.basename(filePath).toLowerCase(); return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base); } function walkDir(dir, results = []) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; } for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git") continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { walkDir(full, results); } else if (entry.isFile() && !shouldIgnoreFile(full)) { const ext = path.extname(entry.name).toLowerCase(); const base = entry.name.toLowerCase(); const rel = full.replace(/\\/g, "/"); // Scan all JS/TS config files + any file matching a targeted name const isTargetedName = CONFIG.targetedFilenames.some( (f) => f.toLowerCase() === base, ); const isPersistenceArtifact = CONFIG.persistenceArtifacts.some( (f) => f.toLowerCase() === base, ); const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( ext, ); const isFont = CONFIG.fontExtensions.includes(ext); const isGitignore = base === ".gitignore"; const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel); if ( isTargetedName || isPersistenceArtifact || isJsLike || isFont || isGitignore || isVsCodeTask ) { results.push(full); } } } return results; } // ─── Reporting ───────────────────────────────────────────────────────────────── const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 }; const ANSI = { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m", green: "\x1b[32m", dim: "\x1b[2m", }; function colorSeverity(sev) { if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`; if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`; return `${ANSI.cyan}${sev}${ANSI.reset}`; } function printReport(allResults, { json = false, outputFile = null } = {}) { const infected = allResults.filter( (r) => r.findings && r.findings.length > 0, ); const errors = allResults.filter((r) => r.error); if (json) { const report = { scannedAt: new Date().toISOString(), totalFilesScanned: allResults.length, infectedFiles: infected.length, results: infected, errors, }; const out = JSON.stringify(report, null, 2); if (outputFile) { fs.writeFileSync(outputFile, out); console.log(`JSON report written to: ${outputFile}`); } else { console.log(out); } return infected.length > 0; } // Human-readable output console.log( `\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`, ); console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`); console.log( `╚══════════════════════════════════════════════════════════╝${ANSI.reset}`, ); console.log( `${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`, ); if (infected.length === 0) { console.log( `${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`, ); } else { console.log( `${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`, ); for (const result of infected) { // Sort findings by severity descending const sorted = [...result.findings].sort( (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity], ); const topSev = sorted[0].severity; console.log( ` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`, ); for (const f of sorted) { console.log( ` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`, ); for (const m of f.matches) { console.log(` → ${m}`); } } console.log(); } console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`); console.log( ` 1. Immediately isolate the affected machine from the network.`, ); console.log( ` 2. Do NOT run npm install, npm build, or any script on this repo.`, ); console.log( ` 3. Check for running node.exe / node processes with obfuscated args.`, ); console.log( ` 4. Remove all code after the legitimate config closing block.`, ); console.log( ` 5. Remove duplicate 'import { createRequire }' lines at file top.`, ); console.log( ` 6. Recover the git repository from a clean local clone (see docs).`, ); console.log( ` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`, ); console.log( ` 8. See full remediation guide in the attached incident report.\n`, ); } if (errors.length > 0) { console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`); for (const e of errors) { console.log(` ${e.filePath}: ${e.error}`); } console.log(); } return infected.length > 0; } // ─── Self-test ───────────────────────────────────────────────────────────────── /** * Runs the rule set against synthetic samples. Guards the two properties that * matter: the live payload is still caught, and minified vendor bundles that * legitimately contain \uXXXX escapes are still not flagged. * Run with: node scan.js --self-test */ function selfTest() { const os = require("os"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-")); const write = (name, body) => { const p = path.join(tmp, name); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); return p; }; const esc = (s) => [...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join(""); const cases = []; // 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager. cases.push({ name: "infected postcss.config.js", file: write( "infected/postcss.config.js", `export default { plugins: {} };` + " ".repeat(300) + `global.i="A8-4299";global.r=require;global.m=module;` + `const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` + `S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` + `I="${esc("https://eth.blockscout.com/api")}";` + `rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` + `spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`, ), expect: true, }); // 2. Clean config — must stay silent. cases.push({ name: "clean postcss.config.js", file: write( "clean/postcss.config.js", "export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n", ), expect: false, }); // 3. Minified vendor bundle with many non-ASCII escapes — must stay silent. cases.push({ name: "minified vendor bundle", file: write( "vendor/emoji.min.js", "var e=[" + Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") + "];", ), expect: false, }); // 4. Font carrying an appended JS payload. cases.push({ name: "trojanised .woff2", file: write( "fonts/bad.woff2", "wOF2" + "".repeat(64) + 'require("child_process");global.x=1;eval(a+b);', ), expect: true, }); // 5. Clean font — correct magic, no code markers. cases.push({ name: "clean .woff2", file: write("fonts/good.woff2", "wOF2" + "".repeat(200)), expect: false, }); // 6. .gitignore hiding persistence artifacts. cases.push({ name: ".gitignore with persistence artifacts", file: write( "ignore/.gitignore", "node_modules/\ntemp_auto_push.bat\nbranch_structure.json\n", ), expect: true, }); // 7. VS Code task auto-executing on folder open. cases.push({ name: ".vscode/tasks.json auto-exec", file: write( "vscode/.vscode/tasks.json", JSON.stringify({ version: "2.0.0", tasks: [ { label: "build", command: "node -e require('http')", runOptions: { runOn: "folderOpen" }, }, ], }), ), expect: true, }); let failed = 0; for (const c of cases) { const { findings } = scanFile(c.file); const detected = findings.length > 0; const ok = detected === c.expect; if (!ok) failed++; const ids = findings.map((f) => f.id).join(", ") || "none"; console.log( ` ${ok ? `${ANSI.green}PASS${ANSI.reset}` : `${ANSI.red}FAIL${ANSI.reset}`} ` + `${c.name} — expected ${c.expect ? "detection" : "clean"}, got: ${ids}`, ); } fs.rmSync(tmp, { recursive: true, force: true }); console.log( failed === 0 ? `\n${ANSI.green}${ANSI.bold}Self-test passed (${cases.length}/${cases.length}).${ANSI.reset}\n` : `\n${ANSI.red}${ANSI.bold}Self-test FAILED: ${failed}/${cases.length} case(s).${ANSI.reset}\n`, ); process.exit(failed === 0 ? 0 : 1); } // ─── CLI Entry Point ─────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2); if (args.includes("--self-test")) return selfTest(); const jsonFlag = args.includes("--json"); const outputFileIdx = args.indexOf("--output"); const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; // Positional args after flags are scan targets const targets = args.filter( (a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output", ); if (targets.length === 0) { console.error( "Usage: scan.js [--json] [--output report.json] [path...]", ); console.error( " path can be a file or directory (directories are walked recursively)", ); process.exit(1); } const filesToScan = []; for (const target of targets) { if (!fs.existsSync(target)) { console.error(`Path not found: ${target}`); process.exit(1); } const stat = fs.statSync(target); if (stat.isDirectory()) { const found = walkDir(target); filesToScan.push(...found); } else { filesToScan.push(target); } } // Deduplicate const unique = [...new Set(filesToScan)]; const allResults = unique.map(scanFile); const infected = printReport(allResults, { json: jsonFlag, outputFile }); // Exit code 1 if any infection found — used by CI to block deployments process.exit(infected ? 1 : 0); } main();