mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into alpha
This commit is contained in:
673
.github/scripts/scan-malware.js
vendored
673
.github/scripts/scan-malware.js
vendored
@@ -1,673 +0,0 @@
|
||||
#!/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",
|
||||
]);
|
||||
|
||||
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);
|
||||
583
.github/scripts/scan.js
vendored
Normal file
583
.github/scripts/scan.js
vendored
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* PolinRider / Famous Chollima Supply-Chain Malware Scanner
|
||||
*
|
||||
* Detects the specific injection pattern used in the PolinRider campaign
|
||||
* attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
|
||||
*
|
||||
* IOCs sourced from:
|
||||
* - Direct analysis of the injected tailwind.config.js sample
|
||||
* - Socket Security report (May 2026) on roberts/leads compromise
|
||||
* - PolinRider technical analysis report (Trend Micro / safedep.io)
|
||||
*
|
||||
* Zero external dependencies — runs on any Node.js >= 14.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG = {
|
||||
// Maximum legitimate size for JS config files.
|
||||
// Real tailwind/postcss/babel configs are rarely > 3 KB.
|
||||
// Injected files jump to 5–8 KB instantly.
|
||||
maxLegitConfigBytes: 3072,
|
||||
|
||||
// Minimum whitespace run on a single line that signals hidden payload.
|
||||
// The campaign uses ~280–510 spaces to push payload off-screen.
|
||||
minSuspiciousInlineSpaces: 100,
|
||||
|
||||
// Files that are high-value injection targets for this campaign.
|
||||
targetedFilenames: [
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.ts",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.js",
|
||||
"postcss.config.js",
|
||||
"postcss.config.mjs",
|
||||
"postcss.config.cjs",
|
||||
"babel.config.js",
|
||||
"babel.config.cjs",
|
||||
"next.config.js",
|
||||
"next.config.mjs",
|
||||
"next.config.cjs",
|
||||
"astro.config.mjs",
|
||||
"astro.config.js",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"webpack.config.js",
|
||||
"webpack.mix.js",
|
||||
],
|
||||
|
||||
// Files intentionally containing malware indicators for scanner logic/tests.
|
||||
// These filenames are skipped before malware rules are evaluated.
|
||||
ignoredFilenames: ["scan.js"],
|
||||
|
||||
// Filesystem paths that indicate active persistence mechanisms
|
||||
persistenceArtifacts: [
|
||||
"temp_auto_push.bat",
|
||||
"temp_interactive_push.bat",
|
||||
"branch_structure.json",
|
||||
// Note: queue.bat and .plist are OS-level; checked separately
|
||||
],
|
||||
};
|
||||
|
||||
// ─── IOC Definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Each rule has:
|
||||
* id – unique rule identifier for reporting
|
||||
* severity – CRITICAL | HIGH | MEDIUM
|
||||
* description – human-readable explanation
|
||||
* test(content, filePath, lines) – returns array of match details or []
|
||||
*/
|
||||
const RULES = [
|
||||
// ── Tier 1: Definitive campaign signatures ─────────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-001",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /_\$_1e42/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`offset ${m.index}`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-002",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const snippet = content
|
||||
.slice(m.index, m.index + 40)
|
||||
.replace(/\n/g, "\\n");
|
||||
matches.push(`"${snippet}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-003",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
|
||||
test(content) {
|
||||
return content.includes("rmcej%otb%") ? ["seed string found"] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-004",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known C2 IP addresses associated with PolinRider infrastructure",
|
||||
test(content) {
|
||||
const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
|
||||
return knownC2.filter((ip) => content.includes(ip));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-005",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const wallets = [
|
||||
"TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
|
||||
"TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
|
||||
];
|
||||
return wallets.filter((w) => content.includes(w));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-006",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known Aptos blockchain addresses used as fallback dead-drop resolvers",
|
||||
test(content) {
|
||||
const addrs = [
|
||||
"0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
|
||||
"0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
|
||||
];
|
||||
return addrs.filter((a) => content.includes(a));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-007",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
|
||||
test(content) {
|
||||
const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
|
||||
return keys.filter((k) => content.includes(k));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-008",
|
||||
severity: "CRITICAL",
|
||||
description:
|
||||
"Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
|
||||
test(content) {
|
||||
const knownHashes = new Set([
|
||||
"96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
|
||||
"522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
|
||||
]);
|
||||
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
||||
return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-009",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
const endpoints = [
|
||||
"trongrid.io",
|
||||
"aptoslabs.com",
|
||||
"bsc-dataseed.binance.org",
|
||||
"bsc-rpc.publicnode.com",
|
||||
"eth_getTransactionByHash",
|
||||
];
|
||||
return endpoints.filter((e) => content.includes(e));
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-010",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
|
||||
test(content) {
|
||||
return /windowsHide\s*:\s*true/.test(content)
|
||||
? ["windowsHide:true found"]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-011",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
|
||||
test(content) {
|
||||
const matches =
|
||||
content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
|
||||
return matches.length >= 2
|
||||
? [`Found ${matches.length} duplicate createRequire imports`]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-012",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
|
||||
test(content, _filePath, lines) {
|
||||
const hits = [];
|
||||
lines.forEach((line, i) => {
|
||||
const spaceRun = line.match(/\s{100,}/);
|
||||
if (spaceRun) {
|
||||
hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-013",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB",
|
||||
test(content, filePath) {
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
const isTargeted = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
|
||||
return [
|
||||
`${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-014",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
|
||||
test(_content, filePath) {
|
||||
const base = path.basename(filePath);
|
||||
return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier 3: Supporting behavioral indicators ───────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-015",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
|
||||
test(content) {
|
||||
const matches = [];
|
||||
// Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
|
||||
const re =
|
||||
/global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
matches.push(`marker value: "${m[1]}"`);
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-016",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
|
||||
test(content) {
|
||||
// sfL appears as a named function used to decode the larger payload blob
|
||||
const occurrences = (content.match(/\bsfL\b/g) || []).length;
|
||||
return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "POLINRIDER-017",
|
||||
severity: "MEDIUM",
|
||||
description:
|
||||
"global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
|
||||
test(content) {
|
||||
const hits = [];
|
||||
if (/global\s*\[.*\]\s*=\s*require/.test(content))
|
||||
hits.push("global[x] = require");
|
||||
if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
|
||||
hits.push("global[x] = module");
|
||||
return hits;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
||||
|
||||
function scanFile(filePath) {
|
||||
if (shouldIgnoreFile(filePath)) {
|
||||
return { filePath, findings: [], skipped: true };
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
return { filePath, error: err.message, findings: [] };
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const findings = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
let matches;
|
||||
try {
|
||||
matches = rule.test(content, filePath, lines);
|
||||
} catch (err) {
|
||||
matches = [`[rule error: ${err.message}]`];
|
||||
}
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
findings.push({
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
description: rule.description,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { filePath, findings };
|
||||
}
|
||||
|
||||
function shouldIgnoreFile(filePath) {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
|
||||
}
|
||||
|
||||
function walkDir(dir, results = []) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
||||
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(full, results);
|
||||
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
const base = entry.name.toLowerCase();
|
||||
|
||||
// Scan all JS/TS config files + any file matching a targeted name
|
||||
const isTargetedName = CONFIG.targetedFilenames.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
|
||||
(f) => f.toLowerCase() === base,
|
||||
);
|
||||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||||
ext,
|
||||
);
|
||||
|
||||
if (isTargetedName || isPersistenceArtifact || isJsLike) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Reporting ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
dim: "\x1b[2m",
|
||||
};
|
||||
|
||||
function colorSeverity(sev) {
|
||||
if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
|
||||
if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
|
||||
return `${ANSI.cyan}${sev}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function printReport(allResults, { json = false, outputFile = null } = {}) {
|
||||
const infected = allResults.filter(
|
||||
(r) => r.findings && r.findings.length > 0,
|
||||
);
|
||||
const errors = allResults.filter((r) => r.error);
|
||||
|
||||
if (json) {
|
||||
const report = {
|
||||
scannedAt: new Date().toISOString(),
|
||||
totalFilesScanned: allResults.length,
|
||||
infectedFiles: infected.length,
|
||||
results: infected,
|
||||
errors,
|
||||
};
|
||||
const out = JSON.stringify(report, null, 2);
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, out);
|
||||
console.log(`JSON report written to: ${outputFile}`);
|
||||
} else {
|
||||
console.log(out);
|
||||
}
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
console.log(
|
||||
`\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
|
||||
);
|
||||
console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`);
|
||||
console.log(
|
||||
`╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
|
||||
);
|
||||
console.log(
|
||||
`${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
if (infected.length === 0) {
|
||||
console.log(
|
||||
`${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
|
||||
);
|
||||
|
||||
for (const result of infected) {
|
||||
// Sort findings by severity descending
|
||||
const sorted = [...result.findings].sort(
|
||||
(a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
|
||||
);
|
||||
const topSev = sorted[0].severity;
|
||||
|
||||
console.log(
|
||||
` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
|
||||
);
|
||||
for (const f of sorted) {
|
||||
console.log(
|
||||
` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`,
|
||||
);
|
||||
for (const m of f.matches) {
|
||||
console.log(` → ${m}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
|
||||
console.log(
|
||||
` 1. Immediately isolate the affected machine from the network.`,
|
||||
);
|
||||
console.log(
|
||||
` 2. Do NOT run npm install, npm build, or any script on this repo.`,
|
||||
);
|
||||
console.log(
|
||||
` 3. Check for running node.exe / node processes with obfuscated args.`,
|
||||
);
|
||||
console.log(
|
||||
` 4. Remove all code after the legitimate config closing block.`,
|
||||
);
|
||||
console.log(
|
||||
` 5. Remove duplicate 'import { createRequire }' lines at file top.`,
|
||||
);
|
||||
console.log(
|
||||
` 6. Recover the git repository from a clean local clone (see docs).`,
|
||||
);
|
||||
console.log(
|
||||
` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
|
||||
);
|
||||
console.log(
|
||||
` 8. See full remediation guide in the attached incident report.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
|
||||
for (const e of errors) {
|
||||
console.log(` ${e.filePath}: ${e.error}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
return infected.length > 0;
|
||||
}
|
||||
|
||||
// ─── CLI Entry Point ───────────────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonFlag = args.includes("--json");
|
||||
const outputFileIdx = args.indexOf("--output");
|
||||
const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;
|
||||
|
||||
// Positional args after flags are scan targets
|
||||
const targets = args.filter(
|
||||
(a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error(
|
||||
"Usage: scan.js [--json] [--output report.json] <path> [path...]",
|
||||
);
|
||||
console.error(
|
||||
" path can be a file or directory (directories are walked recursively)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filesToScan = [];
|
||||
for (const target of targets) {
|
||||
if (!fs.existsSync(target)) {
|
||||
console.error(`Path not found: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isDirectory()) {
|
||||
const found = walkDir(target);
|
||||
filesToScan.push(...found);
|
||||
} else {
|
||||
filesToScan.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const unique = [...new Set(filesToScan)];
|
||||
const allResults = unique.map(scanFile);
|
||||
|
||||
const infected = printReport(allResults, { json: jsonFlag, outputFile });
|
||||
|
||||
// Exit code 1 if any infection found — used by CI to block deployments
|
||||
process.exit(infected ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
243
.github/workflows/polinrider-scan.yml
vendored
Normal file
243
.github/workflows/polinrider-scan.yml
vendored
Normal file
@@ -0,0 +1,243 @@
|
||||
name: PolinRider Malware Scan
|
||||
|
||||
# ── Triggers ──────────────────────────────────────────────────────────────────
|
||||
# Runs on every push and every PR targeting main/master/develop.
|
||||
# Also available as a manual trigger (workflow_dispatch) and on a nightly
|
||||
# schedule so dormant infections in older branches are caught too.
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
# Nightly full-repo scan at 02:00 UTC
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# ── Permissions ───────────────────────────────────────────────────────────────
|
||||
permissions:
|
||||
contents: read # checkout
|
||||
security-events: write # upload SARIF to GitHub Security tab
|
||||
actions: read
|
||||
checks: write # annotate PRs with scan findings
|
||||
|
||||
# ── Deployment gate ───────────────────────────────────────────────────────────
|
||||
# All other jobs (build, test, deploy) should list this job under `needs:`.
|
||||
# If this job fails (exit code 1 from the scanner), the whole workflow stops.
|
||||
jobs:
|
||||
polinrider-scan:
|
||||
name: "PolinRider / Famous Chollima Scan"
|
||||
runs-on: ubuntu-latest
|
||||
# Prevent CI from being disabled by any workflow override
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
# ── 1. Checkout full history ─────────────────────────────────────────────
|
||||
# Full depth so we can inspect recent commits for temp_auto_push.bat traces
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── 2. Detect suspicious force-push patterns in git history ──────────────
|
||||
- name: Check git history for force-push and timestamp manipulation
|
||||
id: git-check
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Checking for suspicious git history patterns ==="
|
||||
|
||||
# Check for .gitignore entries hiding known malware artifacts
|
||||
GITIGNORE_HITS=0
|
||||
if [ -f .gitignore ]; then
|
||||
for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do
|
||||
if grep -qF "$pattern" .gitignore 2>/dev/null; then
|
||||
echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern"
|
||||
GITIGNORE_HITS=$((GITIGNORE_HITS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if malware persistence artifacts exist anywhere in the tree
|
||||
ARTIFACTS_FOUND=0
|
||||
for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do
|
||||
FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null)
|
||||
if [ -n "$FOUND" ]; then
|
||||
echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact"
|
||||
echo "$FOUND"
|
||||
ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Scan recent commit messages for --no-verify (used by temp_auto_push.bat)
|
||||
NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true)
|
||||
if [ -n "$NO_VERIFY_COMMITS" ]; then
|
||||
echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):"
|
||||
echo "$NO_VERIFY_COMMITS"
|
||||
fi
|
||||
|
||||
# Check for .woff2 files with unusually large sizes (>50KB is suspicious)
|
||||
find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do
|
||||
SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload"
|
||||
done
|
||||
|
||||
echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT"
|
||||
echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 3. Run the JavaScript malware scanner ────────────────────────────────
|
||||
- name: Run PolinRider malware scanner
|
||||
id: scanner
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Running PolinRider IOC scanner ==="
|
||||
|
||||
# The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest)
|
||||
node .github/scripts/scan.js \
|
||||
--json \
|
||||
--output scan-report.json \
|
||||
.
|
||||
|
||||
SCANNER_EXIT=$?
|
||||
echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Also emit a human-readable summary to the Actions log
|
||||
node .github/scripts/scan.js . || true
|
||||
|
||||
exit $SCANNER_EXIT
|
||||
|
||||
# ── 4. Upload scan report as artifact ────────────────────────────────────
|
||||
# - name: Upload scan report
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: polinrider-scan-report
|
||||
# path: scan-report.json
|
||||
# retention-days: 90
|
||||
|
||||
# # ── 5. Convert to SARIF and upload to GitHub Security tab ─────────────
|
||||
# - name: Convert scan results to SARIF
|
||||
# if: always()
|
||||
# shell: bash
|
||||
# run: |
|
||||
# node - << 'SCRIPT'
|
||||
# const fs = require('fs');
|
||||
|
||||
# let report;
|
||||
# try {
|
||||
# report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8'));
|
||||
# } catch {
|
||||
# // No report = no findings, write empty SARIF
|
||||
# report = { results: [] };
|
||||
# }
|
||||
|
||||
# const severityMap = {
|
||||
# CRITICAL: 'error',
|
||||
# HIGH: 'warning',
|
||||
# MEDIUM: 'note',
|
||||
# };
|
||||
|
||||
# const sarif = {
|
||||
# version: '2.1.0',
|
||||
# $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json',
|
||||
# runs: [{
|
||||
# tool: {
|
||||
# driver: {
|
||||
# name: 'PolinRider Malware Scanner',
|
||||
# version: '1.0.0',
|
||||
# informationUri: 'https://github.com/your-org/your-repo',
|
||||
# rules: [
|
||||
# { id: 'POLINRIDER-001', name: 'StringShufflerVariable',
|
||||
# shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' },
|
||||
# helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' },
|
||||
# { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment',
|
||||
# shortDescription: { text: "global['!'] campaign marker" } },
|
||||
# { id: 'POLINRIDER-003', name: 'ShufflerSeedString',
|
||||
# shortDescription: { text: 'rmcej%otb% seed string' } },
|
||||
# { id: 'POLINRIDER-004', name: 'KnownC2IP',
|
||||
# shortDescription: { text: 'Known PolinRider C2 IP address' } },
|
||||
# { id: 'POLINRIDER-005', name: 'TRONWallet',
|
||||
# shortDescription: { text: 'Known TRON dead-drop wallet' } },
|
||||
# { id: 'POLINRIDER-006', name: 'AptosAddress',
|
||||
# shortDescription: { text: 'Known Aptos dead-drop address' } },
|
||||
# { id: 'POLINRIDER-007', name: 'XORKey',
|
||||
# shortDescription: { text: 'Known XOR decryption key' } },
|
||||
# { id: 'POLINRIDER-008', name: 'KnownMalwareHash',
|
||||
# shortDescription: { text: 'SHA-256 matches known malware sample' } },
|
||||
# { id: 'POLINRIDER-009', name: 'BlockchainC2Contact',
|
||||
# shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } },
|
||||
# { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn',
|
||||
# shortDescription: { text: 'windowsHide:true hidden process spawn' } },
|
||||
# { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire',
|
||||
# shortDescription: { text: 'Duplicate createRequire injection' } },
|
||||
# { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding',
|
||||
# shortDescription: { text: 'Hidden payload via horizontal whitespace' } },
|
||||
# { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly',
|
||||
# shortDescription: { text: 'Config file size anomaly' } },
|
||||
# { id: 'POLINRIDER-014', name: 'PersistenceArtifact',
|
||||
# shortDescription: { text: 'PolinRider persistence artifact present' } },
|
||||
# { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern',
|
||||
# shortDescription: { text: 'Numeric campaign marker pattern' } },
|
||||
# { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction',
|
||||
# shortDescription: { text: 'sfL obfuscation function' } },
|
||||
# { id: 'POLINRIDER-017', name: 'GlobalRequireInjection',
|
||||
# shortDescription: { text: 'global require/module injection' } },
|
||||
# ],
|
||||
# },
|
||||
# },
|
||||
# results: (report.results || []).flatMap(file =>
|
||||
# (file.findings || []).map(finding => ({
|
||||
# ruleId: finding.id,
|
||||
# level: severityMap[finding.severity] || 'warning',
|
||||
# message: { text: finding.description + ' — ' + finding.matches.join('; ') },
|
||||
# locations: [{
|
||||
# physicalLocation: {
|
||||
# artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' },
|
||||
# region: { startLine: 1 },
|
||||
# },
|
||||
# }],
|
||||
# }))
|
||||
# ),
|
||||
# }],
|
||||
# };
|
||||
|
||||
# fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2));
|
||||
# console.log('SARIF written.');
|
||||
# SCRIPT
|
||||
|
||||
# - name: Upload SARIF to GitHub Security tab
|
||||
# if: always()
|
||||
# uses: github/codeql-action/upload-sarif@v3
|
||||
# with:
|
||||
# sarif_file: scan-results.sarif
|
||||
# category: polinrider-malware-scan
|
||||
|
||||
# ── 6. Block deployment if infected ──────────────────────────────────────
|
||||
- name: Enforce clean-scan gate
|
||||
if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0'
|
||||
shell: bash
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║"
|
||||
echo "║ ║"
|
||||
echo "║ This repository contains code signatures consistent with the ║"
|
||||
echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║"
|
||||
echo "║ ║"
|
||||
echo "║ DO NOT run npm install, build, or deploy until remediated. ║"
|
||||
echo "║ ║"
|
||||
echo "║ See scan-report.json artifact for full details. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
exit 1
|
||||
|
||||
# ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ─────
|
||||
# Example: your existing build/deploy jobs should look like this:
|
||||
#
|
||||
# build:
|
||||
# needs: polinrider-scan
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# ...
|
||||
#
|
||||
# deploy:
|
||||
# needs: [polinrider-scan, build]
|
||||
# ...
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,9 +23,6 @@ coverage/
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
|
||||
@@ -56,6 +56,9 @@ export class PaymentClientService {
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
this.logger.log("=====================================================================");
|
||||
this.logger.log(`URL ${url}`);
|
||||
this.logger.log("=====================================================================");
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
- "${PASSENGER_API_PORT:-4000}:${PASSENGER_API_PORT:-4000}"
|
||||
env_file:
|
||||
- apps/edr-passenger-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
freight-portal:
|
||||
build:
|
||||
|
||||
Reference in New Issue
Block a user