|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * scan-malware.cjs — install-time malware / injected-code validator. |
| 4 | + * Detects: PolinRider injectors (global['!']=/global['_V']=, fromCharCode(127), |
| 5 | + * require-hijack, constructor-eval, _$_ vars, long obfuscated lines), GlassWorm |
| 6 | + * zero-width unicode, BeaverTail blockchain/Telegram C2, file masquerade, |
| 7 | + * Shai-Hulud binding.gyp shell-exec, dropper lifecycle scripts. |
| 8 | + * Exit 0 = clean, 1 = CRITICAL (block), 2 = scan error. Reads as text only. |
| 9 | + */ |
| 10 | +'use strict'; |
| 11 | +const fs = require('fs'); |
| 12 | +const path = require('path'); |
| 13 | +const ROOT = path.join(__dirname, '..'); |
| 14 | +const SELF = path.resolve(__filename); |
| 15 | +const args = process.argv.slice(2); |
| 16 | +const DEEP = args.includes('--deep') || process.env.MALWARE_SCAN_DEEP === '1'; |
| 17 | +const QUIET = args.includes('--quiet'); |
| 18 | +const CODE_EXT = new Set(['.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx']); |
| 19 | +const BINARY_EXT = new Set(['.woff', '.woff2', '.ttf', '.otf', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.wasm']); |
| 20 | +const SKIP_DIRS = new Set(['.git', '.next', 'dist', 'build', 'coverage', '.turbo', '_archive']); |
| 21 | +const MAX_FILE_BYTES = 2 * 1024 * 1024; |
| 22 | +const C = { |
| 23 | + red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, |
| 24 | + green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`, |
| 25 | +}; |
| 26 | +const SIGNATURES = [ |
| 27 | + { id: 'charcode-127', re: /String\.fromCharCode\(\s*127\s*\)/, desc: 'fromCharCode(127) string-descrambler (known injector)' }, |
| 28 | + { id: 'global-bang', re: /global\s*\[\s*['"]!['"]\s*\]\s*=/, desc: "global['!']=<any> persistence marker (PolinRider)" }, |
| 29 | + { id: 'global-V', re: /global\s*\[\s*['"]_V['"]\s*\]\s*=/, desc: "global['_V']=<any> persistence marker (PolinRider variant)" }, |
| 30 | + { id: 'require-hijack', re: /=\s*require\s*;\s*if\s*\(\s*typeof\s+module/, desc: 'require/module global hijack' }, |
| 31 | + { id: 'constructor-eval', re: /\[\s*['"]constructor['"]\s*\]\s*\[\s*['"]constructor['"]\s*\]/, desc: 'Function-constructor eval (obfuscated)' }, |
| 32 | + { id: 'underscore-dollar', re: /_\$_[0-9a-fA-F]{3,}\b/, desc: '_$_XXXX obfuscation variables' }, |
| 33 | +]; |
| 34 | +const C2_ENDPOINTS = /\b(?:trongrid\.io|api\.trongrid\.io|aptoslabs\.com|fullnode\.[a-z]+\.aptoslabs\.com|bsc-dataseed[0-9]*\.(?:binance|bnbchain)\.org|api\.telegram\.org\/bot[0-9]+:[A-Za-z0-9_-]+)\b/i; |
| 35 | +// Zero-width / joiner code points. Emoji selectors U+FE00–FE0F EXCLUDED on purpose. |
| 36 | +const ZERO_WIDTH = /[\u200B\u200C\u200D\u2060\uFEFF]/; |
| 37 | +const ZERO_WIDTH_G = /[\u200B\u200C\u200D\u2060\uFEFF]/g; |
| 38 | +const ZERO_WIDTH_RUN = /[\u200B\u200C\u200D\u2060\uFEFF]{4,}/; |
| 39 | +const SCRIPT_DANGER = |
| 40 | + /fromCharCode|base64\s+(?:-d|--decode)|\batob\s*\(|\beval\s*\(|(?:curl|wget)\b[^\n]*\|\s*(?:sh|bash)|\bcurl\b[^\n]*\bhttp|\bwget\b[^\n]*\bhttp/i; |
| 41 | +const LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare', 'preuninstall']; |
| 42 | +const findings = []; |
| 43 | +let scannedFiles = 0; |
| 44 | +const BUNDLE_PATH = /(?:\.min\.(?:c|m)?js|[.-]bundle\.js|bundled\.js|standalone-preset\.js)$|(?:^|[\\/])(?:dist|client-dist|umd|esm|generated)[\\/]/i; |
| 45 | + |
| 46 | +function scanText(rel, text, inNodeModules) { |
| 47 | + const lines = text.split('\n'); |
| 48 | + const longLines = lines.reduce((n, l) => n + (l.length > 1500 ? 1 : 0), 0); |
| 49 | + const looksBundled = |
| 50 | + lines.length <= 3 || longLines / Math.max(lines.length, 1) > 0.05 || BUNDLE_PATH.test(rel); |
| 51 | + for (let i = 0; i < lines.length; i++) { |
| 52 | + const line = lines[i]; |
| 53 | + for (const sig of SIGNATURES) { |
| 54 | + if (sig.re.test(line)) { |
| 55 | + findings.push({ sev: 'CRITICAL', file: rel, line: i + 1, id: sig.id, desc: sig.desc, snippet: line.trim().slice(0, 100) }); |
| 56 | + } |
| 57 | + } |
| 58 | + if (C2_ENDPOINTS.test(line)) { |
| 59 | + findings.push({ sev: 'CRITICAL', file: rel, line: i + 1, id: 'c2-endpoint', desc: 'blockchain/Telegram C2 endpoint (second-stage fetch or exfiltration)', snippet: line.trim().slice(0, 100) }); |
| 60 | + } |
| 61 | + if (!looksBundled && line.length > 1500 && /String\.fromCharCode/.test(line) && |
| 62 | + /\beval\s*\(|\bFunction\s*\(|\[\s*['"]constructor['"]\s*\]|global\s*\[\s*['"]/.test(line)) { |
| 63 | + findings.push({ sev: 'CRITICAL', file: rel, line: i + 1, id: 'long-obfuscated-line', desc: 'anomalous long obfuscated line (descrambler + exec sink) in a non-bundled file', snippet: line.trim().slice(0, 80) + '…' }); |
| 64 | + } |
| 65 | + } |
| 66 | + if (!inNodeModules) { |
| 67 | + const zwAll = text.match(ZERO_WIDTH_G) || []; |
| 68 | + if (ZERO_WIDTH_RUN.test(text) || zwAll.length >= 8) { |
| 69 | + const idx = text.search(ZERO_WIDTH); |
| 70 | + const lineNo = idx < 0 ? 0 : text.slice(0, idx).split('\n').length; |
| 71 | + findings.push({ sev: 'CRITICAL', file: rel, line: lineNo, id: 'zero-width-unicode', desc: `invisible zero-width/joiner payload in source (${zwAll.length} chars) — GlassWorm injection`, snippet: `${zwAll.length} hidden zero-width chars` }); |
| 72 | + } |
| 73 | + } |
| 74 | +} |
| 75 | +function scanFile(abs, inNodeModules) { |
| 76 | + const rel = path.relative(ROOT, abs); |
| 77 | + if (path.resolve(abs) === SELF) return; |
| 78 | + let stat; try { stat = fs.statSync(abs); } catch { return; } |
| 79 | + if (stat.size > MAX_FILE_BYTES) return; |
| 80 | + let text; try { text = fs.readFileSync(abs, 'utf8'); } catch { return; } |
| 81 | + scannedFiles++; scanText(rel, text, inNodeModules); |
| 82 | +} |
| 83 | +const MAGIC = { |
| 84 | + '.woff': [0x77, 0x4f, 0x46, 0x46], '.woff2': [0x77, 0x4f, 0x46, 0x32], |
| 85 | + '.ttf': [0x00, 0x01, 0x00, 0x00], '.otf': [0x4f, 0x54, 0x54, 0x4f], |
| 86 | + '.png': [0x89, 0x50, 0x4e, 0x47], '.jpg': [0xff, 0xd8, 0xff], '.jpeg': [0xff, 0xd8, 0xff], |
| 87 | + '.gif': [0x47, 0x49, 0x46, 0x38], '.ico': [0x00, 0x00, 0x01, 0x00], '.wasm': [0x00, 0x61, 0x73, 0x6d], |
| 88 | +}; |
| 89 | +const JS_TELLS = /\brequire\s*\(|\bmodule\.exports\b|\bprocess\.(env|argv|exit)\b|\bchild_process\b|=>|\bfunction\b|\beval\s*\(|\bglobal\s*\[/; |
| 90 | +function scanMasquerade(abs) { |
| 91 | + const rel = path.relative(ROOT, abs); |
| 92 | + const ext = path.extname(abs).toLowerCase(); |
| 93 | + let fd; try { fd = fs.openSync(abs, 'r'); } catch { return; } |
| 94 | + const buf = Buffer.alloc(256); let n = 0; |
| 95 | + try { n = fs.readSync(fd, buf, 0, 256, 0); } finally { fs.closeSync(fd); } |
| 96 | + if (n === 0) return; |
| 97 | + const head = buf.subarray(0, n); |
| 98 | + const expected = MAGIC[ext]; |
| 99 | + const altFontOk = (ext === '.ttf' || ext === '.otf') && |
| 100 | + (head.subarray(0, 4).toString('latin1') === 'true' || head.subarray(0, 4).toString('latin1') === 'OTTO' || head.subarray(0, 4).toString('latin1') === 'ttcf'); |
| 101 | + const magicOk = altFontOk || (expected && expected.every((b, i) => head[i] === b)); |
| 102 | + if (magicOk) return; |
| 103 | + const asText = head.toString('utf8'); |
| 104 | + const printableRatio = [...head].filter((b) => b === 9 || b === 10 || b === 13 || (b >= 32 && b < 127)).length / n; |
| 105 | + if (printableRatio > 0.85 && JS_TELLS.test(asText)) { |
| 106 | + findings.push({ sev: 'CRITICAL', file: rel, line: 0, id: 'file-masquerade', desc: `"${ext}" file contains JavaScript/text instead of binary (payload masquerade)`, snippet: asText.replace(/\s+/g, ' ').trim().slice(0, 100) }); |
| 107 | + } |
| 108 | +} |
| 109 | +function walk(dir, intoNodeModules) { |
| 110 | + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } |
| 111 | + for (const e of entries) { |
| 112 | + const abs = path.join(dir, e.name); |
| 113 | + if (e.isDirectory()) { |
| 114 | + if (SKIP_DIRS.has(e.name)) continue; |
| 115 | + if (e.name === 'node_modules' && !intoNodeModules) continue; |
| 116 | + walk(abs, intoNodeModules); |
| 117 | + } else { |
| 118 | + const ext = path.extname(e.name).toLowerCase(); |
| 119 | + if (CODE_EXT.has(ext)) scanFile(abs, intoNodeModules); |
| 120 | + else if (BINARY_EXT.has(ext)) scanMasquerade(abs); |
| 121 | + } |
| 122 | + } |
| 123 | +} |
| 124 | +function scanDependencyScripts() { |
| 125 | + const nm = path.join(ROOT, 'node_modules'); |
| 126 | + if (!fs.existsSync(nm)) return; |
| 127 | + const stack = [nm]; let pkgCount = 0; |
| 128 | + while (stack.length) { |
| 129 | + const dir = stack.pop(); |
| 130 | + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } |
| 131 | + for (const e of entries) { |
| 132 | + if (!e.isDirectory()) continue; |
| 133 | + const sub = path.join(dir, e.name); |
| 134 | + const pj = path.join(sub, 'package.json'); |
| 135 | + if (fs.existsSync(pj)) { |
| 136 | + pkgCount++; |
| 137 | + try { |
| 138 | + const json = JSON.parse(fs.readFileSync(pj, 'utf8')); |
| 139 | + const scripts = json.scripts || {}; |
| 140 | + for (const hook of LIFECYCLE) { |
| 141 | + const cmd = scripts[hook]; |
| 142 | + if (typeof cmd === 'string' && SCRIPT_DANGER.test(cmd)) { |
| 143 | + findings.push({ sev: 'CRITICAL', file: path.relative(ROOT, pj), line: 0, id: `dep-script:${hook}`, desc: `dependency "${json.name || e.name}" has a malicious-looking ${hook} script`, snippet: cmd.slice(0, 100) }); |
| 144 | + } |
| 145 | + } |
| 146 | + } catch { /* ignore */ } |
| 147 | + const nestedNm = path.join(sub, 'node_modules'); |
| 148 | + if (fs.existsSync(nestedNm)) stack.push(nestedNm); |
| 149 | + } else { stack.push(sub); } |
| 150 | + } |
| 151 | + } |
| 152 | + return pkgCount; |
| 153 | +} |
| 154 | +const GYP_DANGER = /<!@?\s*\([^)]*?(?:\bcurl\b|\bwget\b|\bfetch\b|https?:\/\/|\|\s*(?:sh|bash)\b|\bbase64\b|\beval\b|child_process|node\s+-e\b)[^)]*\)/i; |
| 155 | +function scanNativeBuildConfigs() { |
| 156 | + const nm = path.join(ROOT, 'node_modules'); |
| 157 | + if (!fs.existsSync(nm)) return 0; |
| 158 | + const stack = [nm]; let gypCount = 0; |
| 159 | + while (stack.length) { |
| 160 | + const dir = stack.pop(); |
| 161 | + let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } |
| 162 | + for (const e of entries) { |
| 163 | + const abs = path.join(dir, e.name); |
| 164 | + if (e.isDirectory()) { stack.push(abs); continue; } |
| 165 | + if (e.name === 'binding.gyp' || path.extname(e.name) === '.gyp' || path.extname(e.name) === '.gypi') { |
| 166 | + gypCount++; |
| 167 | + let text; try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; } |
| 168 | + if (GYP_DANGER.test(text)) { |
| 169 | + const m = text.match(GYP_DANGER); |
| 170 | + findings.push({ sev: 'CRITICAL', file: path.relative(ROOT, abs), line: 0, id: 'gyp-shell-exec', desc: 'binding.gyp runs download/shell during node-gyp rebuild (Shai-Hulud vector)', snippet: (m ? m[0] : '').replace(/\s+/g, ' ').slice(0, 100) }); |
| 171 | + } |
| 172 | + } |
| 173 | + } |
| 174 | + } |
| 175 | + return gypCount; |
| 176 | +} |
| 177 | +function main() { |
| 178 | + walk(ROOT, false); |
| 179 | + const pkgs = scanDependencyScripts(); |
| 180 | + scanNativeBuildConfigs(); |
| 181 | + if (DEEP) walk(path.join(ROOT, 'node_modules'), true); |
| 182 | + const critical = findings.filter((f) => f.sev === 'CRITICAL'); |
| 183 | + const warns = findings.filter((f) => f.sev === 'WARN'); |
| 184 | + if (!findings.length) { |
| 185 | + if (!QUIET) console.log(C.green('✓ [malware-scan] clean') + C.dim(` — ${scannedFiles} files, ${pkgs || 0} packages${DEEP ? ' (deep)' : ''}`)); |
| 186 | + process.exit(0); |
| 187 | + } |
| 188 | + console.log(C.bold('\n[malware-scan] findings:')); |
| 189 | + for (const f of critical) { |
| 190 | + console.log(' ' + C.red('CRITICAL ') + C.bold(f.id) + ' — ' + f.desc); |
| 191 | + console.log(C.dim(` ${f.file}${f.line ? ':' + f.line : ''} ${f.snippet}`)); |
| 192 | + } |
| 193 | + for (const f of warns) { |
| 194 | + console.log(' ' + C.yellow('WARN ') + C.bold(f.id) + ' — ' + f.desc); |
| 195 | + console.log(C.dim(` ${f.file} ${f.snippet}`)); |
| 196 | + } |
| 197 | + if (critical.length) { |
| 198 | + console.log(C.red(`\n✗ ${critical.length} CRITICAL malware signature(s) found.`) + C.dim('\n Do NOT run/build. Clean the file(s) above, rotate any secrets, and inspect the dependency that introduced it.\n')); |
| 199 | + process.exit(1); |
| 200 | + } |
| 201 | + console.log(C.yellow('\n warnings only.\n')); |
| 202 | + process.exit(0); |
| 203 | +} |
| 204 | +try { main(); } catch (err) { console.error('[malware-scan] scan error:', err && err.message ? err.message : err); process.exit(2); } |
0 commit comments