Files
edr-platform/.github/scripts/scan.js
2026-06-13 10:27:37 +03:00

584 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.js",
"postcss.config.js",
"postcss.config.mjs",
"postcss.config.cjs",
"babel.config.js",
"babel.config.cjs",
"next.config.js",
"next.config.mjs",
"next.config.cjs",
"astro.config.mjs",
"astro.config.js",
"vite.config.js",
"vite.config.ts",
"webpack.config.js",
"webpack.mix.js",
],
// 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}`] : [];
},
},
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
{
id: "POLINRIDER-009",
severity: "HIGH",
description:
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
test(content) {
const endpoints = [
"trongrid.io",
"aptoslabs.com",
"bsc-dataseed.binance.org",
"bsc-rpc.publicnode.com",
"eth_getTransactionByHash",
];
return endpoints.filter((e) => content.includes(e));
},
},
{
id: "POLINRIDER-010",
severity: "HIGH",
description:
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
test(content) {
return /windowsHide\s*:\s*true/.test(content)
? ["windowsHide:true 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) {
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-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 ────────────────────────────────────────────────────────────
function scanFile(filePath) {
if (shouldIgnoreFile(filePath)) {
return { filePath, findings: [], skipped: true };
}
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch (err) {
return { filePath, error: err.message, findings: [] };
}
const lines = content.split("\n");
const findings = [];
for (const rule of RULES) {
let matches;
try {
matches = rule.test(content, filePath, lines);
} 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();
// 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,
);
if (isTargetedName || isPersistenceArtifact || isJsLike) {
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;
}
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
function main() {
const args = process.argv.slice(2);
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();