mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
961 lines
32 KiB
JavaScript
961 lines
32 KiB
JavaScript
/**
|
||
* 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", <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 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" + " |