Files
edr-platform/.github/scripts/scan.js
2026-08-07 07:45:14 +00:00

961 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 58 KB instantly.
maxLegitConfigBytes: 3072,
// Minimum whitespace run on a single line that signals hidden payload.
// The campaign uses ~280510 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 400600.
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", <remote payload>])');
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.<x> = require");
if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded))
hits.push("global.<x> = 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 58 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> [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();