diff --git a/.github/scripts/scan-malware.js b/.github/scripts/scan-malware.js index 4d0b8b052..afef0bdd4 100644 --- a/.github/scripts/scan-malware.js +++ b/.github/scripts/scan-malware.js @@ -1,673 +1,570 @@ -#!/usr/bin/env node /** - * Malicious Code Scanner - * Detects obfuscated droppers, eval-based loaders, suspicious global assignments, - * blockchain C2 patterns, high-entropy payload strings, and stealthy child processes. + * PolinRider / Famous Chollima Supply-Chain Malware Scanner * - * Exit codes: 0 = clean, 1 = threats found, 2 = scanner error + * 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 EXTENSIONS_TO_SCAN = new Set([ - ".js", - ".cjs", - ".mjs", - ".ts", - ".tsx", - ".jsx", - ".json", - ".html", - ".htm", - ".vue", - ".svelte", -]); +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, -const ALWAYS_SKIP = new Set([ - "node_modules", - ".git", - "dist", - "build", - ".next", - ".nuxt", - "coverage", - ".nyc_output", - "__pycache__", - "scan-malware.js", -]); + // Minimum whitespace run on a single line that signals hidden payload. + // The campaign uses ~280–510 spaces to push payload off-screen. + minSuspiciousInlineSpaces: 100, -const ENTROPY_THRESHOLD = 5.2; // Shannon bits/char – high = likely encoded payload -const ENTROPY_MIN_STRING_LEN = 64; // only test strings at least this long -const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // skip files > 2 MB + // 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", + ], -// ─── Detection Rules ────────────────────────────────────────────────────────── + // 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: { id, description, severity, test(content, filePath) } - * test() returns null | { line, snippet }[] + * 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 = [ - // ── 1. Global require/module hijacking ────────────────────────────────────── + // ── Tier 1: Definitive campaign signatures ───────────────────────────────── + { - id: "GLOBAL_REQUIRE_ASSIGN", + id: "POLINRIDER-001", severity: "CRITICAL", description: - "Assigns require/module/process to a global slot to survive closure boundaries", - test(src) { - return matchAll(src, [ - // Quoted-key form: global['literal'] = require - /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*require\b/g, - /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*module\b/g, - /global\s*\[\s*['"][^'"]{0,40}['"]\s*\]\s*=\s*process\b/g, - // Computed-key form: global[_$_1e42[0]] = require (real sample pattern) - /global\s*\[\s*[^\]]{1,60}\]\s*=\s*require\b/g, - /global\s*\[\s*[^\]]{1,60}\]\s*=\s*module\b/g, - /global\s*\[\s*[^\]]{1,60}\]\s*=\s*process\b/g, - // Dot form: global.x = require - /global\s*\.\s*\w+\s*=\s*require\b/g, - ]); - }, - }, - - // ── 2. Obfuscator fingerprints ─────────────────────────────────────────────── - { - id: "OBFUSCATOR_VAR_NAMES", - severity: "HIGH", - description: - "Variable names matching known obfuscator output patterns (_$_, _$af…, sfL…)", - test(src) { - return matchAll( - src, - [ - // _$_1e42 family — the exact dropper signature (minHits=1, one hit is definitive) - /\b_\$_[0-9a-zA-Z]{4,}\b/g, - // _$af163278 style - /\b_\$[a-f0-9]{6,}\b/g, - // sfL as a standalone identifier (the shuffler function name in this dropper family) - /\bsfL\b/g, - // sfLxxx variants - /\bsfL[A-Za-z0-9]{2,}\b/g, - // generic hex-suffix identifiers (broader catch for other obfuscators) - /\b[a-zA-Z]{1,3}[0-9a-f]{8,}\b/g, - ], - 1, - ); // one strong match is enough — these patterns don't appear in legitimate code - }, - }, - - // ── 3. Function-constructor eval-by-constructor ────────────────────────────── - { - id: "FUNCTION_CONSTRUCTOR_EVAL", - severity: "CRITICAL", - description: - "Dynamically constructs and executes code via the Function constructor", - test(src) { - return matchAll(src, [ - // Explicit new Function(...) - /new\s+Function\s*\(\s*[^)]{40,}\)/g, - /Function\s*\(\s*['"`][^'"`]{40,}['"`]\s*\)\s*\(\)/g, - /\(\s*new\s+Function\s*\(/g, - /\bFunction\b[^(]*\([^)]*\)\s*\(\s*\)/g, - // ── Stolen-property pattern (the real sample's technique) ── - // var x = fn[computed] then x('', decode(bigString)) - // Step 1: extract constructor via computed property on a function - /var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]\s*;[\s\S]{0,120}var\s+\w+\s*=\s*\w+\s*\(\s*(?:''|""|``|\w+)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, - // Step 2: direct two-arg call with empty first arg (how Function ctor is invoked) - /\w+\s*\(\s*(?:''|""|``)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, - // Step 3: fn[computed](empty, decoder(str)) in one expression - /\w+\s*\[\s*\w+\s*\]\s*\(\s*(?:''|""|``|\w*)\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, - ]); - }, - }, - - // ── 4. Encoded-string eval calls ──────────────────────────────────────────── - { - id: "EVAL_ENCODED_STRING", - severity: "CRITICAL", - description: "Passes a large encoded/obfuscated literal directly to eval()", - test(src) { - return matchAll(src, [ - /\beval\s*\(\s*['"`][A-Za-z0-9+/=%\\]{80,}['"`]\s*\)/g, - /\beval\s*\(\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*[^)]{0,60}\)\s*\)/g, // eval(decode(...)) - /\beval\s*\(\s*atob\s*\(/g, - /\beval\s*\(\s*Buffer\s*\.from\s*\(/g, - ]); - }, - }, - - // ── 5. Plain suspicious eval ──────────────────────────────────────────────── - { - id: "SUSPICIOUS_EVAL", - severity: "MEDIUM", - description: "eval() used in a context that suggests dynamic code loading", - test(src) { - return matchAll(src, [ - /\beval\s*\(\s*(?!\/[\/*])(?!\s*['"`]\s*['"`])[^;)]{20,}\)/g, - ]); - }, - }, - - // ── 6. Stealthy child_process spawn ───────────────────────────────────────── - { - id: "DETACHED_CHILD_PROCESS", - severity: "CRITICAL", - description: - "Spawns a detached, stdio-less child process — classic dropper persistence", - test(src) { - return matchAll(src, [ - /detached\s*:\s*true/g, - /stdio\s*:\s*['"`]ignore['"`]/g, - /windowsHide\s*:\s*true/g, - /spawn\s*\(\s*['"`]node['"`]\s*,\s*\[\s*['"`]-e['"`]/g, - /execFile\s*\([^)]+detached/g, - ]); - }, - }, - - // ── 7. Blockchain C2 endpoints ────────────────────────────────────────────── - { - id: "BLOCKCHAIN_C2", - severity: "CRITICAL", - description: - "Contacts blockchain APIs (TronGrid, Aptos, etc.) to retrieve a payload", - test(src) { - return matchAll(src, [ - /trongrid\.io/gi, - /tronscan\.org/gi, - /fullnode\.mainnet\.aptoslabs\.com/gi, - /aptos\.dev\/v1\/accounts/gi, - /getTransactionInfo|getTransactionById/g, - /\.resource\.data\.value\b/g, // Aptos on-chain data access pattern - /wallet_address.*blockchain|blockchain.*wallet_address/gi, - ]); - }, - }, - - // ── 8. XOR-based decryption of a payload ──────────────────────────────────── - { - id: "XOR_PAYLOAD_DECRYPT", - severity: "HIGH", - description: - "XOR-decryption loop over a fetched or hardcoded payload buffer", - test(src) { - return matchAll(src, [ - /charCodeAt\s*\([^)]*\)\s*\^\s*\w+\.charCodeAt\s*\(/g, - /\^\s*key\.charCodeAt\s*\(/g, - /\.map\s*\(\s*\([^)]*\)\s*=>\s*[^.]+\.\s*charCodeAt[^)]*\s*\^/g, - /fromCharCode\s*\([^)]*\^[^)]*\)/g, - ]); - }, - }, - - // ── 9. String.fromCharCode(127) split (known dropper separator) ─────────────── - { - id: "FROMCHARCODE_SEPARATOR", - severity: "HIGH", - description: - "Uses String.fromCharCode() as a string delimiter/split marker", - test(src) { - return matchAll(src, [ - /String\.fromCharCode\s*\(\s*1[0-2][0-9]\s*\)/g, // DEL, extended ctrl chars - /\.split\s*\(\s*String\.fromCharCode\s*\(/g, - /String\.fromCharCode\s*\(\s*0\s*\)/g, // null byte as delimiter - ]); - }, - }, - - // ── 10. Rate-limiting / re-execution guard (anti-detection) ────────────────── - { - id: "EXECUTION_RATE_LIMIT", - severity: "MEDIUM", - description: - "Hardcoded timing gate (30 s window) used to avoid repeated execution", - test(src) { - return matchAll(src, [ - /Date\.now\s*\(\)\s*-\s*\w+\s*[<>]=?\s*3000[0-9]/g, // 30 000 ms - /setTimeout[^)]+3[0-9]{4}/g, - /lastRun|_lastExec|_rateLimit|__ts/g, - ]); - }, - }, - - // ── 11. High-entropy string literals ───────────────────────────────────────── - { - id: "HIGH_ENTROPY_STRING", - severity: "HIGH", - description: - "Long string literal with entropy above threshold — likely encoded payload", - test(src, filePath) { - // Skip minified bundles and lockfiles - if (/\.(min\.js|lock|map)$/.test(filePath)) return null; - const hits = []; - // Broader charset: the real dropper's joW/pYd blobs contain spaces, brackets, - // semicolons, etc. — any non-newline, non-quote content of 100+ chars qualifies. - // We use two regexes: one for tightly-packed base64-like strings, one for the - // looser mixed-content payload strings this dropper family actually uses. - const patterns = [ - // Tight: no spaces (base64, hex, classic obfuscation) - /(['"`])([A-Za-z0-9+/=\\%^&*!@#$\-_.~]{64,})\1/g, - // Loose: mixed printable chars including spaces — catches joW / pYd style blobs - /(['"`])([^'"` \t\r\n]{50,}[^'"` \t\r\n])\1/g, - // Single-quoted with internal spaces — the exact form used in this dropper - /'([^'\r\n]{100,})'/g, - ]; - const seen = new Set(); - for (const strRe of patterns) { - strRe.lastIndex = 0; - let m; - while ((m = strRe.exec(src)) !== null) { - const s = - m[1] !== undefined && m[1].length === 1 ? (m[2] ?? m[1]) : m[1]; - const payload = typeof s === "string" ? s : m[0].slice(1, -1); - if (payload.length < 50) continue; - const key = payload.slice(0, 32); // dedup by prefix - if (seen.has(key)) continue; - seen.add(key); - if (shannonEntropy(payload) >= ENTROPY_THRESHOLD) { - hits.push({ - line: lineOf(src, m.index), - snippet: payload.slice(0, 80) + "…", - }); - } - } + "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 hits.length ? hits : null; + return matches; }, }, - // ── 15. Global nonce / infection marker ───────────────────────────────────── { - id: "GLOBAL_NONCE_MARKER", + id: "POLINRIDER-002", severity: "CRITICAL", description: - "Sets a short global marker string (e.g. global['!']='8-3946') as an infection flag / re-execution guard", - test(src) { - return matchAll(src, [ - // global['!'] = '8-3946' or global["x"] = "abc-123" - /global\s*\[\s*['"][^'"]{0,5}['"]\s*\]\s*=\s*['"][0-9!@#$%^&*\-]{3,20}['"]/g, - // global['!']='...' with no spaces (minified form) - /global\['[^']{0,5}'\]='[^']{2,20}'/g, + "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}`] : []; }, }, - // ── 16. String-shuffler IIFE ────────────────────────────────────────────────── - { - id: "STRING_SHUFFLER_IIFE", - severity: "CRITICAL", - description: - "Self-invoking string-shuffler function (seeded character-swap loop) used to decode obfuscated identifiers and payloads", - test(src) { - return matchAll(src, [ - // The core pattern: (function(x,y){ ... charAt ... % bignum ... })(str, bigint) - /\(function\s*\(\s*\w\s*,\s*\w\s*\)\s*\{[^}]{30,}charAt[^}]{10,}%\s*[0-9]{5,}/g, - // Seeded arithmetic inside a loop: e = (s + w) % bignum - /[a-z]\s*=\s*\(\s*[a-z]\s*\+\s*[a-z]\s*\)\s*%\s*[0-9]{6,}/g, - // The characteristic swap: var y=g[t]; g[t]=g[p]; g[p]=y - /var\s+\w\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w\s*\[\s*\w\s*\]\s*;\s*\w\s*\[\s*\w\s*\]\s*=\s*\w/g, - ]); - }, - }, + // ── Tier 2: Behavioral / structural indicators ───────────────────────────── - // ── 17. Method-extraction constructor theft ─────────────────────────────────── { - id: "CONSTRUCTOR_THEFT", - severity: "CRITICAL", - description: - "Extracts the Function constructor via a computed property on a function object (e.g. sfL['constructor']), bypassing direct 'Function' keyword detection", - test(src) { - return matchAll( - src, - [ - // var EKc = sfL('...').substr(0, N) — computing the property name 'constructor' - /\w+\s*\([^)]{5,50}\)\.substr\s*\(\s*0\s*,\s*\w+\s*\)/g, - // var dgC = sfL[EKc] — stealing the constructor via computed key - /var\s+\w+\s*=\s*\w+\s*\[\s*\w+\s*\]/g, - // multi-step: var x=fn[computed]; var y=x; var z=x(empty, decode(blob)) - /var\s+\w+\s*=\s*\w+;\s*var\s+\w+\s*=\s*\w+\s*\(\s*(?:\w+|''|"")\s*,\s*\w+\s*\(\s*\w+\s*\)\s*\)/g, - ], - 2, - ); // need ≥2: the substr alone can appear legitimately, but substr + bracket-access together is the tell - }, - }, - - // ── 18. Multi-step join/split decode chain ──────────────────────────────────── - { - id: "JOIN_SPLIT_DECODE_CHAIN", + id: "POLINRIDER-009", severity: "HIGH", description: - "Repeated join/split/join sequence used to reassemble an obfuscated string — characteristic of this dropper family", - test(src) { - return matchAll(src, [ - // .join(x).split(y).join(z) — at least two chained steps - /\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*\.join\s*\([^)]{0,20}\)/g, - // Three+ steps (the real sample has 4): .join.split.join.split.join - /(?:\.join\s*\([^)]{0,20}\)\s*\.split\s*\([^)]{0,20}\)\s*){2,}/g, - ]); + "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)); }, }, - // ── 19. typeof-against-dynamic-string ───────────────────────────────────────── { - id: "TYPEOF_DYNAMIC_CHECK", + id: "POLINRIDER-010", severity: "HIGH", description: - "Uses typeof x === decoded_var[n] instead of typeof x === 'object' to hide the string 'object' from static analysis", - test(src) { - return matchAll(src, [ - // typeof module === _$_1e42[1] - /typeof\s+\w+\s*===\s*_\$_/g, - // typeof x === anyVar[digit] - /typeof\s+\w+\s*===\s*\w+\s*\[\s*\d+\s*\]/g, - // typeof x === dynamicVar (no bracket, just a variable holding the type string) - /typeof\s+(?:module|require|process|exports)\s*===\s*[A-Za-z_$][A-Za-z0-9_$]*(?!\s*[[(])/g, - ]); + "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"] + : []; }, }, - // ── 20. IIFE with numeric seed + numeric return (dropper wrapper) ────────────── { - id: "DROPPER_IIFE_WRAPPER", - severity: "CRITICAL", - description: - "Top-level IIFE that calls the final compiled payload with a numeric seed and returns a fake numeric value — canonical dropper wrapper structure", - test(src) { - return matchAll(src, [ - // Tgw(2509); return 1358})() - /\w+\s*\(\s*\d{4,5}\s*\)\s*;\s*return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g, - // return NNNN})() — the fake return at the end of the outer IIFE - /return\s+\d{3,6}\s*\}\s*\)\s*\(\s*\)/g, - // (function(){...})() containing a numeric final call + numeric return - /\w+\s*\(\s*[0-9]{4}\s*\)[^)]*return\s+[0-9]{4}/g, - ]); - }, - }, - - // ── 12. Dynamic property access on require/module ──────────────────────────── - { - id: "DYNAMIC_REQUIRE", - severity: "HIGH", - description: "require() called with a computed or obfuscated argument", - test(src) { - return matchAll(src, [ - /require\s*\(\s*\w+\s*\[\s*\d+\s*\]\s*\)/g, // require(arr[0]) - /require\s*\(\s*[A-Za-z_$]+\s*\(\s*[^)]{20,}\)\s*\)/g, // require(decode(...)) - /\[['"`]require['"`]\]\s*\(/g, // ['require'](...) - ]); - }, - }, - - // ── 13. Self-deletion / evidence wiping ────────────────────────────────────── - { - id: "SELF_DELETE", - severity: "CRITICAL", - description: "File deletes itself or wipes evidence after running", - test(src) { - return matchAll(src, [ - /fs\.unlink.*__filename/g, - /fs\.unlinkSync.*__filename/g, - /rimraf.*__dirname/g, - /process\.argv\[1\].*unlink/g, - ]); - }, - }, - - // ── 14. Exfiltration patterns ──────────────────────────────────────────────── - { - id: "EXFILTRATION_PATTERN", + id: "POLINRIDER-011", severity: "HIGH", description: - "Reads sensitive files or env vars and sends them over the network", - test(src) { - return matchAll(src, [ - /readFileSync.*\.ssh/g, - /readFileSync.*\.aws/g, - /readFileSync.*\.env/g, - /process\.env\.[A-Z_]{4,}.*fetch|fetch.*process\.env\.[A-Z_]{4,}/g, - /HOME.*\.npmrc.*post|post.*HOME.*\.npmrc/g, - ]); + "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 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-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; }, }, ]; -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Scanner Engine ──────────────────────────────────────────────────────────── -function shannonEntropy(str) { - const freq = {}; - for (const c of str) freq[c] = (freq[c] || 0) + 1; - const len = str.length; - return -Object.values(freq).reduce((acc, f) => { - const p = f / len; - return acc + p * Math.log2(p); - }, 0); -} +function scanFile(filePath) { + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (err) { + return { filePath, error: err.message, findings: [] }; + } -function lineOf(src, idx) { - return src.slice(0, idx).split("\n").length; -} + const lines = content.split("\n"); + const findings = []; -/** Run one or more regexes; return hits if total unique-line matches >= minHits */ -function matchAll(src, patterns, minHits = 1) { - const hits = []; - for (const re of patterns) { - re.lastIndex = 0; - let m; - while ((m = re.exec(src)) !== null) { - hits.push({ line: lineOf(src, m.index), snippet: m[0].slice(0, 120) }); - if (re.lastIndex === m.index) re.lastIndex++; // guard zero-width + 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, + }); } } - if (hits.length < minHits) return null; - return hits; + + return { filePath, findings }; } -// ─── File Walking ────────────────────────────────────────────────────────────── +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; -function* walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (ALWAYS_SKIP.has(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { - yield* walk(full); - } else if ( - entry.isFile() && - EXTENSIONS_TO_SCAN.has(path.extname(entry.name).toLowerCase()) - ) { - yield full; - } - } -} + walkDir(full, results); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + const base = entry.name.toLowerCase(); -// ─── Main Scanner ───────────────────────────────────────────────────────────── + // 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, + ); -function scan(rootDir) { - const findings = []; // { file, rule, hits } - let scanned = 0; - let skipped = 0; - - for (const filePath of walk(rootDir)) { - const stat = fs.statSync(filePath); - if (stat.size > MAX_FILE_SIZE_BYTES) { - skipped++; - continue; - } - if (stat.size === 0) { - continue; - } - - let src; - try { - src = fs.readFileSync(filePath, "utf8"); - } catch { - skipped++; - continue; - } - - scanned++; - const relPath = path.relative(rootDir, filePath); - - for (const rule of RULES) { - try { - const hits = rule.test(src, filePath); - if (hits && hits.length > 0) { - findings.push({ file: relPath, rule, hits }); - } - } catch (err) { - // Never let a broken rule crash the whole scan - process.stderr.write( - `[WARN] Rule ${rule.id} threw on ${relPath}: ${err.message}\n`, - ); + if (isTargetedName || isPersistenceArtifact || isJsLike) { + results.push(full); } } } - return { findings, scanned, skipped }; + return results; } -// ─── Reporting ──────────────────────────────────────────────────────────────── +// ─── Reporting ───────────────────────────────────────────────────────────────── -const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; -const SEVERITY_COLOR = { - CRITICAL: "\x1b[31;1m", // bold red - HIGH: "\x1b[33;1m", // bold yellow - MEDIUM: "\x1b[36m", // cyan - LOW: "\x1b[37m", // white +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", }; -const RESET = "\x1b[0m"; -const BOLD = "\x1b[1m"; -function color(sev, text) { - if (!process.stdout.isTTY) return text; - return `${SEVERITY_COLOR[sev] || ""}${text}${RESET}`; +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 report({ findings, scanned, skipped }) { - const sorted = [...findings].sort( - (a, b) => - (SEVERITY_ORDER[a.rule.severity] ?? 9) - - (SEVERITY_ORDER[b.rule.severity] ?? 9), +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); - console.log("\n" + "═".repeat(72)); - console.log(`${BOLD} Malicious Code Scanner — Results${RESET}`); - console.log("═".repeat(72)); - console.log(` Files scanned : ${scanned}`); - console.log(` Files skipped : ${skipped}`); - console.log(` Findings : ${sorted.length}`); - console.log("─".repeat(72)); - - if (sorted.length === 0) { - console.log("\n ✅ No suspicious patterns detected.\n"); - return 0; - } - - // Group by severity - const bySeverity = {}; - for (const f of sorted) { - (bySeverity[f.rule.severity] ??= []).push(f); - } - - for (const sev of ["CRITICAL", "HIGH", "MEDIUM", "LOW"]) { - const group = bySeverity[sev]; - if (!group) continue; - console.log(`\n ${color(sev, `── ${sev} (${group.length})`)}`); - for (const { file, rule, hits } of group) { - console.log(`\n ${BOLD}${file}${RESET}`); - console.log(` Rule : ${rule.id}`); - console.log(` Detail : ${rule.description}`); - const shown = hits.slice(0, 3); - for (const h of shown) { - console.log(` Line ~${h.line}: ${color(sev, h.snippet)}`); - } - if (hits.length > 3) - console.log(` … and ${hits.length - 3} more occurrences`); + 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; } - // Summary counts - const counts = Object.fromEntries( - ["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((s) => [ - s, - (bySeverity[s] || []).length, - ]), - ); - - console.log("\n" + "─".repeat(72)); + // Human-readable output console.log( - ` Summary: ` + - color("CRITICAL", `${counts.CRITICAL} CRITICAL`) + - " " + - color("HIGH", `${counts.HIGH} HIGH`) + - " " + - color("MEDIUM", `${counts.MEDIUM} MEDIUM`) + - " " + - `${counts.LOW} LOW`, + `\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`, ); - console.log("═".repeat(72) + "\n"); - // Fail CI on CRITICAL or HIGH - return counts.CRITICAL + counts.HIGH > 0 ? 1 : 0; -} - -// ─── JSON output for upload-artifact / SARIF consumers ─────────────────────── - -function writeJsonReport(findings, outPath) { - const out = findings.map(({ file, rule, hits }) => ({ - file, - rule_id: rule.id, - severity: rule.severity, - description: rule.description, - occurrences: hits, - })); - fs.writeFileSync(outPath, JSON.stringify(out, null, 2)); -} - -// ─── Entry point ───────────────────────────────────────────────────────────── - -const targetArg = process.argv[2] || process.cwd(); -const jsonOut = process.env.SCAN_JSON_OUT || ""; - -// Accept either a directory OR a single file as the scan target -let result; -try { - const targetStat = fs.statSync(targetArg); - if (targetStat.isFile()) { - // Single-file mode: scan just that file regardless of extension - const src = fs.readFileSync(targetArg, "utf8"); - const findings = []; - for (const rule of RULES) { - try { - const hits = rule.test(src, targetArg); - if (hits && hits.length > 0) { - findings.push({ file: path.basename(targetArg), rule, hits }); - } - } catch (err) { - process.stderr.write(`[WARN] Rule ${rule.id} threw: ${err.message}\n`); - } - } - result = { findings, scanned: 1, skipped: 0 }; + if (infected.length === 0) { + console.log( + `${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`, + ); } else { - result = scan(targetArg); + 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`, + ); } -} catch (err) { - console.error(`Scanner internal error: ${err.message}`); - process.exit(2); + + 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; } -const exitCode = report(result); +// ─── CLI Entry Point ─────────────────────────────────────────────────────────── -if (jsonOut) { - try { - writeJsonReport(result.findings, jsonOut); - console.log(`JSON report written to: ${jsonOut}`); - } catch (err) { - console.error(`Failed to write JSON report: ${err.message}`); +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...]", + ); + 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); } -process.exit(exitCode); +main(); diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 9932e158b..f9ae9f63d 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,10 +1,3 @@ -import { createRequire } from "module"; -import { createRequire } from "module"; - -const require = createRequire(import.meta.url); - -const require = createRequire(import.meta.url); - /** @type {import('tailwindcss').Config} */ export default { darkMode: "class",