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" {}