From 4bc2caf4c25f84da10f8e93c0b33ea7bb9b2e4ce Mon Sep 17 00:00:00 2001 From: SennayT Date: Thu, 11 Jun 2026 10:10:56 +0300 Subject: [PATCH] add malware scan script --- .github/scripts/scan-malware.js | 672 +++++++++++++++++++++++++++++ .github/workflows/malware-scan.yml | 241 +++++++++++ 2 files changed, 913 insertions(+) create mode 100644 .github/scripts/scan-malware.js create mode 100644 .github/workflows/malware-scan.yml diff --git a/.github/scripts/scan-malware.js b/.github/scripts/scan-malware.js new file mode 100644 index 000000000..1401cd1e2 --- /dev/null +++ b/.github/scripts/scan-malware.js @@ -0,0 +1,672 @@ +#!/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__", +]); + +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. 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, + ]); + }, + }, + + // ── 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); diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 000000000..8e05957ae --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,241 @@ +name: Malware & Obfuscation Scan + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + # Allow manual triggering for ad-hoc full scans + workflow_dispatch: + inputs: + scan_path: + description: "Sub-directory to scan (leave blank for full repo)" + required: false + default: "." + +# Prevent concurrent scans on the same ref from stepping on each other +concurrency: + group: malware-scan-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Needed if you later add GitHub Code Scanning / SARIF upload + security-events: write + +jobs: + malware-scan: + name: Scan for malicious / obfuscated code + runs-on: self-hosted + timeout-minutes: 15 + + steps: + # ── 1. Checkout ──────────────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history lets the scanner see every file, not just the diff. + # For very large repos you can set fetch-depth: 1 to speed things up, + # but you may miss injected files in unchanged paths. + fetch-depth: 0 + + # ── 2. Setup Node ────────────────────────────────────────────────────── + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # ── 3. Install scanner ───────────────────────────────────────────────── + # The scanner is pure Node.js stdlib — no npm install needed. + # We just copy the script into a known location inside the runner. + - name: Install scanner script + run: | + mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner" + cp .github/scripts/scan-malware.js \ + "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + + # ── 4. Run the scanner ───────────────────────────────────────────────── + - name: Run malware scanner + id: scan + env: + SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json + run: | + SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}" + echo "Scanning path: $SCAN_PATH" + echo "────────────────────────────────────────" + + node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH" + # The script exits 0 (clean), 1 (threats found), or 2 (internal error). + # We want the step to "succeed" so the upload-artifact step always runs, + # but we'll fail the job in the gate step below. + continue-on-error: true + + # ── 5. Upload JSON report as artifact (always, even on failure) ──────── + # Retained so the full per-file, per-rule detail is always downloadable. + # The Telegram message below links directly to the Actions run where + # this artifact appears. + - name: Upload scan report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: malware-scan-report-${{ github.sha }} + path: ${{ runner.temp }}/scan-results.json + retention-days: 90 + if-no-files-found: ignore + + # ── 6. Send Telegram notification (always, even on failure) ────────────── + # Requires two repository secrets: + # TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx) + # TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx) + # + # Intentionally short — plain HTML mode, no code spans, no snippets. + # All special characters that would break MarkdownV2 are avoided entirely. + # Full details are in the artifact linked via the Actions run URL. + - name: Send Telegram notification + if: always() + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + SCAN_JSON: ${{ runner.temp }}/scan-results.json + GH_REPO: ${{ github.repository }} + GH_SHA: ${{ github.sha }} + GH_REF: ${{ github.ref_name }} + GH_ACTOR: ${{ github.actor }} + GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + node - << 'EOF' + const fs = require('fs'); + const https = require('https'); + + const token = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + const repo = process.env.GH_REPO; + const sha = process.env.GH_SHA.slice(0, 7); + const ref = process.env.GH_REF; + const actor = process.env.GH_ACTOR; + const runUrl = process.env.GH_RUN_URL; + + // HTML-escape only the four characters HTML cares about. + // Using HTML parse_mode means code snippets, file paths, and rule IDs + // with special characters can never break the parser. + const h = s => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + let findings = []; + try { + findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); + } catch { /* missing file = clean run or scanner error */ } + + const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; + for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; + + const isClean = findings.length === 0; + + // ── Unique affected files ────────────────────────────────────────── + const affectedFiles = [...new Set(findings.map(f => f.file))]; + + // ── Build a short, fixed-size message ───────────────────────────── + // No snippets, no descriptions — just counts, affected files, and a + // direct link to the artifact. Stays well under 500 chars. + let lines = []; + + if (isClean) { + lines.push('✅ Malware Scan — Clean'); + } else { + lines.push('🚨 Malware Scan — THREATS DETECTED'); + } + + lines.push(''); + lines.push(`Repo: ${h(repo)}`); + lines.push(`Branch: ${h(ref)} Commit: ${h(sha)}`); + lines.push(`Actor: ${h(actor)}`); + + if (!isClean) { + lines.push(''); + lines.push( + `Findings: ` + + `🔴 ${counts.CRITICAL} CRITICAL ` + + `🟠 ${counts.HIGH} HIGH ` + + `🟡 ${counts.MEDIUM} MEDIUM ` + + `⚪ ${counts.LOW} LOW` + ); + lines.push(''); + lines.push(`Affected files (${affectedFiles.length}):`); + // Cap at 10 files to keep the message short + const shown = affectedFiles.slice(0, 10); + for (const f of shown) lines.push(` • ${h(f)}`); + if (affectedFiles.length > 10) { + lines.push(` • … and ${affectedFiles.length - 10} more`); + } + } + + lines.push(''); + lines.push(`📋 View full run & download report artifact`); + + const text = lines.join('\n'); + + // ── Send via Bot API (HTML parse mode) ──────────────────────────── + const body = JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: true, + }); + + const options = { + hostname: 'api.telegram.org', + path: `/bot${token}/sendMessage`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const parsed = JSON.parse(data); + if (!parsed.ok) { + console.error('Telegram API error:', JSON.stringify(parsed)); + process.exit(1); + } + console.log('Telegram notification sent successfully.'); + }); + }); + req.on('error', err => { + console.error('Request failed:', err.message); + process.exit(1); + }); + req.write(body); + req.end(); + EOF + + # ── 7. Gate — fail the workflow if threats were found ────────────────── + # Runs after the Telegram step so the alert always fires first. + - name: Fail workflow if threats detected + if: steps.scan.outcome == 'failure' + run: | + echo "::error::⛔ Malicious or highly-suspicious code patterns were detected." + echo "::error::Check your Telegram channel for the summary." + echo "::error::Download the 'malware-scan-report' artifact for full details." + echo "::error::Do NOT merge or deploy this branch until findings are reviewed." + exit 1 + + # ── 7. (Optional) Diff-only scan on PRs for faster feedback ─────────── + # Uncomment this block if you want a second, faster pass that only + # checks the files changed in the PR diff. + # + # - name: Diff-only scan (PR only) + # if: github.event_name == 'pull_request' + # env: + # SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json + # run: | + # git diff --name-only origin/${{ github.base_ref }}...HEAD \ + # | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \ + # | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {}