Files
emaui/.github/scripts/scan-malware.js

675 lines
23 KiB
JavaScript
Raw 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.

#!/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);