#!/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. * * Exit codes: 0 = clean, 1 = threats found, 2 = scanner error */ "use strict"; const fs = require("fs"); const path = require("path"); // ─── Configuration ──────────────────────────────────────────────────────────── const EXTENSIONS_TO_SCAN = new Set([ ".js", ".cjs", ".mjs", ".ts", ".tsx", ".jsx", ".json", ".html", ".htm", ".vue", ".svelte", ]); const ALWAYS_SKIP = new Set([ "node_modules", ".git", "dist", "build", ".next", ".nuxt", "coverage", ".nyc_output", "__pycache__", "scan-malware.js", ".turbo", ]); 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 // ─── Detection Rules ────────────────────────────────────────────────────────── /** * Each rule: { id, description, severity, test(content, filePath) } * test() returns null | { line, snippet }[] */ const RULES = [ // ── 1. Global require/module hijacking ────────────────────────────────────── { id: "GLOBAL_REQUIRE_ASSIGN", 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) + "…", }); } } } return hits.length ? hits : null; }, }, // ── 15. Global nonce / infection marker ───────────────────────────────────── { id: "GLOBAL_NONCE_MARKER", severity: "CRITICAL", description: "Sets a short global marker string (e.g. 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\['[^']{0,5}'\]='[^']{2,20}'/g, ]); }, }, // ── 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, ]); }, }, // ── 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", 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, ]); }, }, // ── 19. typeof-against-dynamic-string ───────────────────────────────────────── { id: "TYPEOF_DYNAMIC_CHECK", 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, ]); }, }, // ── 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", 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, ]); }, }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── 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 lineOf(src, idx) { return src.slice(0, idx).split("\n").length; } /** 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 } } if (hits.length < minHits) return null; return hits; } // ─── File Walking ────────────────────────────────────────────────────────────── 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; } } } // ─── Main Scanner ───────────────────────────────────────────────────────────── 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`, ); } } } return { findings, scanned, skipped }; } // ─── 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 RESET = "\x1b[0m"; const BOLD = "\x1b[1m"; function color(sev, text) { if (!process.stdout.isTTY) return text; return `${SEVERITY_COLOR[sev] || ""}${text}${RESET}`; } function report({ findings, scanned, skipped }) { const sorted = [...findings].sort( (a, b) => (SEVERITY_ORDER[a.rule.severity] ?? 9) - (SEVERITY_ORDER[b.rule.severity] ?? 9), ); 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`); } } // Summary counts const counts = Object.fromEntries( ["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((s) => [ s, (bySeverity[s] || []).length, ]), ); console.log("\n" + "─".repeat(72)); console.log( ` Summary: ` + color("CRITICAL", `${counts.CRITICAL} CRITICAL`) + " " + color("HIGH", `${counts.HIGH} HIGH`) + " " + color("MEDIUM", `${counts.MEDIUM} MEDIUM`) + " " + `${counts.LOW} LOW`, ); 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 }; } else { result = scan(targetArg); } } catch (err) { console.error(`Scanner internal error: ${err.message}`); process.exit(2); } const exitCode = report(result); 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}`); } } process.exit(exitCode);