Skip to content

Commit eca3449

Browse files
chore(security): add malware/injected-code scanner
1 parent ace457c commit eca3449

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

scripts/scan-malware.cjs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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

Comments
 (0)