mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1158 from Tria-plc/dev
Removing Blocked Seat report from the Dashboard
This commit is contained in:
401
.github/scripts/scan.js
vendored
401
.github/scripts/scan.js
vendored
@@ -35,23 +35,50 @@ const CONFIG = {
|
|||||||
"tailwind.config.js",
|
"tailwind.config.js",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
"tailwind.config.cjs",
|
"tailwind.config.cjs",
|
||||||
|
"tailwind.config.mjs",
|
||||||
"tailwind.js",
|
"tailwind.js",
|
||||||
"postcss.config.js",
|
"postcss.config.js",
|
||||||
|
"postcss.config.ts",
|
||||||
"postcss.config.mjs",
|
"postcss.config.mjs",
|
||||||
"postcss.config.cjs",
|
"postcss.config.cjs",
|
||||||
"babel.config.js",
|
"babel.config.js",
|
||||||
|
"babel.config.ts",
|
||||||
|
"babel.config.mjs",
|
||||||
"babel.config.cjs",
|
"babel.config.cjs",
|
||||||
"next.config.js",
|
"next.config.js",
|
||||||
|
"next.config.ts",
|
||||||
"next.config.mjs",
|
"next.config.mjs",
|
||||||
"next.config.cjs",
|
"next.config.cjs",
|
||||||
|
"eslint.config.js",
|
||||||
|
"eslint.config.ts",
|
||||||
|
"eslint.config.mjs",
|
||||||
|
"eslint.config.cjs",
|
||||||
"astro.config.mjs",
|
"astro.config.mjs",
|
||||||
"astro.config.js",
|
"astro.config.js",
|
||||||
"vite.config.js",
|
"vite.config.js",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
|
"vite.config.mjs",
|
||||||
|
"vite.config.cjs",
|
||||||
"webpack.config.js",
|
"webpack.config.js",
|
||||||
"webpack.mix.js",
|
"webpack.mix.js",
|
||||||
|
"svelte.config.js",
|
||||||
|
"nuxt.config.ts",
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Font files — the campaign appends JS payloads to web fonts, which are
|
||||||
|
// never executed directly but are fetched by the build and used as a
|
||||||
|
// staging blob. Binary formats, so they are read as latin1.
|
||||||
|
fontExtensions: [".woff", ".woff2", ".ttf", ".otf", ".eot"],
|
||||||
|
|
||||||
|
// Minimum number of \uXXXX escape sequences in one file before it is
|
||||||
|
// treated as deliberately obfuscated. Legitimate source files use a
|
||||||
|
// handful at most; PolinRider samples carry 400–600.
|
||||||
|
maxLegitUnicodeEscapes: 20,
|
||||||
|
|
||||||
|
// Any single line longer than this inside a config file means code was
|
||||||
|
// appended past the real export block.
|
||||||
|
maxLegitConfigLineLength: 1000,
|
||||||
|
|
||||||
// Files intentionally containing malware indicators for scanner logic/tests.
|
// Files intentionally containing malware indicators for scanner logic/tests.
|
||||||
// These filenames are skipped before malware rules are evaluated.
|
// These filenames are skipped before malware rules are evaluated.
|
||||||
ignoredFilenames: ["scan.js"],
|
ignoredFilenames: ["scan.js"],
|
||||||
@@ -187,14 +214,166 @@ const RULES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-018",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Unicode-escape string obfuscation — the payload writes plain ASCII literals such as require and https as \\u0068\\u0074\\u0074\\u0070 so that grep, code review, and GitHub diff search cannot see the API calls it makes",
|
||||||
|
test(content) {
|
||||||
|
// Only printable-ASCII escapes count. Minified vendor bundles legitimately
|
||||||
|
// carry hundreds of \uXXXX escapes, but those decode to emoji, diacritics
|
||||||
|
// and CJK ranges — escaping ASCII that could have been written literally
|
||||||
|
// has no purpose other than hiding it from a reader.
|
||||||
|
const asciiEscapes = (content.match(/\\u00[0-7][0-9a-fA-F]/g) || []).filter(
|
||||||
|
(e) => {
|
||||||
|
const code = parseInt(e.slice(2), 16);
|
||||||
|
return code >= 0x20 && code <= 0x7e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return asciiEscapes.length >= CONFIG.maxLegitUnicodeEscapes
|
||||||
|
? [
|
||||||
|
`${asciiEscapes.length} printable-ASCII \\uXXXX escapes (threshold: ${CONFIG.maxLegitUnicodeEscapes})`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-019",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Ethereum dead-drop C2 resolver — the attacker wallet's latest transaction encodes the C2 IP addresses in the 'to' field, so the C2 can be rotated without touching the implant",
|
||||||
|
test(_content, _filePath, _lines, decoded) {
|
||||||
|
const iocs = [
|
||||||
|
"0xa322e5f3d311d3080e6f0121063e9adc2490ef1a",
|
||||||
|
"eth.blockscout.com",
|
||||||
|
"eth_getBlockByNumber",
|
||||||
|
"eth_getTransactionCount",
|
||||||
|
"eth_blockNumber",
|
||||||
|
"ethereum-rpc.publicnode.com",
|
||||||
|
"eth-mainnet.public.blastapi.io",
|
||||||
|
"1rpc.io/eth",
|
||||||
|
"eth.drpc.org",
|
||||||
|
];
|
||||||
|
const hay = decoded.toLowerCase();
|
||||||
|
return iocs.filter((i) => hay.includes(i));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-020",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Remote code execution stager — fetches a payload over HTTP, XOR-decrypts it, eval()s it in-process and re-launches it as a detached hidden node -e child process that survives the build exiting",
|
||||||
|
test(_content, _filePath, _lines, decoded) {
|
||||||
|
const hits = [];
|
||||||
|
if (/spawn\s*\(\s*["']node["']\s*,\s*\[\s*["']-e["']/.test(decoded))
|
||||||
|
hits.push('spawn("node", ["-e", <remote payload>])');
|
||||||
|
if (/detached\s*:\s*(!0|true)/.test(decoded))
|
||||||
|
hits.push("detached child process");
|
||||||
|
if (/stdio\s*:\s*["']ignore["']/.test(decoded))
|
||||||
|
hits.push('stdio:"ignore" (output suppressed)');
|
||||||
|
if (/\beval\s*\(\s*\w+\s*\+/.test(decoded))
|
||||||
|
hits.push("eval() of concatenated remote string");
|
||||||
|
if (/x-payload-b64/i.test(decoded))
|
||||||
|
hits.push("x-payload-b64 C2 response header");
|
||||||
|
// Only report when this is a genuine stager, not an isolated keyword.
|
||||||
|
return hits.length >= 2 ? hits : [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-021",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Campaign marker + Node internals capture via dot notation — the implant stores its victim/campaign ID and re-exposes require/module on globalThis so later stages can load native modules from inside an ES module",
|
||||||
|
test(_content, _filePath, _lines, decoded) {
|
||||||
|
const hits = [];
|
||||||
|
const marker = decoded.match(
|
||||||
|
/global\s*\.\s*[a-zA-Z_$]\w*\s*=\s*["']([A-Z]{0,2}\d[\d-]{3,})["']/,
|
||||||
|
);
|
||||||
|
if (marker) hits.push(`campaign marker: "${marker[1]}"`);
|
||||||
|
if (/global\s*\.\s*\w+\s*=\s*require\b/.test(decoded))
|
||||||
|
hits.push("global.<x> = require");
|
||||||
|
if (/global\s*\.\s*\w+\s*=\s*module\b/.test(decoded))
|
||||||
|
hits.push("global.<x> = module");
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-022",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
"Web font file carrying an executable payload — .woff/.woff2 files are treated as opaque binary assets by reviewers and linters, so the campaign uses them to smuggle JavaScript past code review",
|
||||||
|
test(content, filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (!CONFIG.fontExtensions.includes(ext)) return [];
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
const magic = content.slice(0, 4);
|
||||||
|
const expected = { ".woff": "wOFF", ".woff2": "wOF2", ".otf": "OTTO" };
|
||||||
|
if (expected[ext] && magic !== expected[ext]) {
|
||||||
|
hits.push(
|
||||||
|
`bad magic bytes: expected "${expected[ext]}", got "${magic.replace(/[^\x20-\x7e]/g, ".")}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const codeMarkers = [
|
||||||
|
"require(",
|
||||||
|
"eval(",
|
||||||
|
"child_process",
|
||||||
|
"global.",
|
||||||
|
"createRequire",
|
||||||
|
"process.env",
|
||||||
|
];
|
||||||
|
const found = codeMarkers.filter((m) => content.includes(m));
|
||||||
|
if (found.length > 0) {
|
||||||
|
hits.push(`embedded JS markers: ${found.join(", ")}`);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-023",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
description:
|
||||||
|
".vscode/tasks.json configured to auto-execute on folder open — gives the campaign code execution the moment a developer opens the repo in VS Code, before any build or install command is run",
|
||||||
|
test(content, filePath) {
|
||||||
|
if (!/\.vscode[\\/]tasks\.json$/.test(filePath.replace(/\\/g, "/")))
|
||||||
|
return [];
|
||||||
|
const hits = [];
|
||||||
|
if (/"runOn"\s*:\s*"folderOpen"/.test(content))
|
||||||
|
hits.push('runOn: "folderOpen" (executes without user action)');
|
||||||
|
const cmd = content.match(/"command"\s*:\s*"([^"]{0,120})"/);
|
||||||
|
if (cmd && /node|curl|wget|powershell|bash|-e\b|eval/i.test(cmd[1]))
|
||||||
|
hits.push(`command: "${cmd[1]}"`);
|
||||||
|
return hits.length >= 1 ? hits : [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-024",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
".gitignore lists this campaign's persistence artifacts — the implant appends these entries so its own dropped files never appear in git status and the developer never sees them",
|
||||||
|
test(content, filePath) {
|
||||||
|
if (path.basename(filePath) !== ".gitignore") return [];
|
||||||
|
const lines = content.split("\n").map((l) => l.trim());
|
||||||
|
return CONFIG.persistenceArtifacts.filter((a) => lines.includes(a)).map(
|
||||||
|
(a) => `.gitignore hides "${a}"`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
|
// ── Tier 2: Behavioral / structural indicators ─────────────────────────────
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "POLINRIDER-009",
|
id: "POLINRIDER-009",
|
||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
description:
|
description:
|
||||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
|
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers",
|
||||||
test(content) {
|
test(_content, _filePath, _lines, decoded) {
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
"trongrid.io",
|
"trongrid.io",
|
||||||
"aptoslabs.com",
|
"aptoslabs.com",
|
||||||
@@ -202,7 +381,7 @@ const RULES = [
|
|||||||
"bsc-rpc.publicnode.com",
|
"bsc-rpc.publicnode.com",
|
||||||
"eth_getTransactionByHash",
|
"eth_getTransactionByHash",
|
||||||
];
|
];
|
||||||
return endpoints.filter((e) => content.includes(e));
|
return endpoints.filter((e) => decoded.includes(e));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -210,11 +389,10 @@ const RULES = [
|
|||||||
id: "POLINRIDER-010",
|
id: "POLINRIDER-010",
|
||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
description:
|
description:
|
||||||
"Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
|
"Hidden process spawn with windowsHide — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly. Matches both true and its minified form !0",
|
||||||
test(content) {
|
test(_content, _filePath, _lines, decoded) {
|
||||||
return /windowsHide\s*:\s*true/.test(content)
|
const m = decoded.match(/windowsHide\s*:\s*(true|!0)/);
|
||||||
? ["windowsHide:true found"]
|
return m ? [`windowsHide:${m[1]} found`] : [];
|
||||||
: [];
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -237,7 +415,10 @@ const RULES = [
|
|||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
description:
|
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",
|
"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) {
|
test(_content, filePath, lines) {
|
||||||
|
// Binary assets contain long runs of 0x20 as padding — not an indicator.
|
||||||
|
if (CONFIG.fontExtensions.includes(path.extname(filePath).toLowerCase()))
|
||||||
|
return [];
|
||||||
const hits = [];
|
const hits = [];
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
const spaceRun = line.match(/\s{100,}/);
|
const spaceRun = line.match(/\s{100,}/);
|
||||||
@@ -269,6 +450,27 @@ const RULES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "POLINRIDER-025",
|
||||||
|
severity: "HIGH",
|
||||||
|
description:
|
||||||
|
"Minified code appended past the end of a config file — real config files are hand-written and line-wrapped; a single multi-thousand-character line means a payload was concatenated onto the original file",
|
||||||
|
test(_content, filePath, lines) {
|
||||||
|
const base = path.basename(filePath).toLowerCase();
|
||||||
|
if (!CONFIG.targetedFilenames.some((f) => f.toLowerCase() === base))
|
||||||
|
return [];
|
||||||
|
const hits = [];
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
if (line.length > CONFIG.maxLegitConfigLineLength) {
|
||||||
|
hits.push(
|
||||||
|
`line ${i + 1}: ${line.length} chars (threshold: ${CONFIG.maxLegitConfigLineLength})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
id: "POLINRIDER-014",
|
id: "POLINRIDER-014",
|
||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
@@ -330,25 +532,58 @@ const RULES = [
|
|||||||
|
|
||||||
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
// ─── Scanner Engine ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve \uXXXX and \xXX escapes so string-matching rules see the real API
|
||||||
|
* calls. The campaign writes every literal as escapes specifically to defeat
|
||||||
|
* grep, so rules that match on plain text must run against this form.
|
||||||
|
* The decoded text is appended to the original rather than replacing it, so a
|
||||||
|
* rule can still match either representation with one pass.
|
||||||
|
*/
|
||||||
|
function deobfuscate(content) {
|
||||||
|
if (!/\\[ux]/.test(content)) return content;
|
||||||
|
const decoded = content
|
||||||
|
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (m, h) => {
|
||||||
|
try {
|
||||||
|
return String.fromCodePoint(parseInt(h, 16));
|
||||||
|
} catch {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) =>
|
||||||
|
String.fromCharCode(parseInt(h, 16)),
|
||||||
|
)
|
||||||
|
.replace(/\\x([0-9a-fA-F]{2})/g, (_m, h) =>
|
||||||
|
String.fromCharCode(parseInt(h, 16)),
|
||||||
|
);
|
||||||
|
return content + "\n/* --- deobfuscated --- */\n" + decoded;
|
||||||
|
}
|
||||||
|
|
||||||
function scanFile(filePath) {
|
function scanFile(filePath) {
|
||||||
if (shouldIgnoreFile(filePath)) {
|
if (shouldIgnoreFile(filePath)) {
|
||||||
return { filePath, findings: [], skipped: true };
|
return { filePath, findings: [], skipped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fonts are binary. latin1 maps bytes 1:1 to chars, so magic-byte checks and
|
||||||
|
// ASCII payload searches both work without mangling the content.
|
||||||
|
const isBinary = CONFIG.fontExtensions.includes(
|
||||||
|
path.extname(filePath).toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
try {
|
try {
|
||||||
content = fs.readFileSync(filePath, "utf8");
|
content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { filePath, error: err.message, findings: [] };
|
return { filePath, error: err.message, findings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
|
const decoded = deobfuscate(content);
|
||||||
const findings = [];
|
const findings = [];
|
||||||
|
|
||||||
for (const rule of RULES) {
|
for (const rule of RULES) {
|
||||||
let matches;
|
let matches;
|
||||||
try {
|
try {
|
||||||
matches = rule.test(content, filePath, lines);
|
matches = rule.test(content, filePath, lines, decoded);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
matches = [`[rule error: ${err.message}]`];
|
matches = [`[rule error: ${err.message}]`];
|
||||||
}
|
}
|
||||||
@@ -388,6 +623,7 @@ function walkDir(dir, results = []) {
|
|||||||
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
||||||
const ext = path.extname(entry.name).toLowerCase();
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
const base = entry.name.toLowerCase();
|
const base = entry.name.toLowerCase();
|
||||||
|
const rel = full.replace(/\\/g, "/");
|
||||||
|
|
||||||
// Scan all JS/TS config files + any file matching a targeted name
|
// Scan all JS/TS config files + any file matching a targeted name
|
||||||
const isTargetedName = CONFIG.targetedFilenames.some(
|
const isTargetedName = CONFIG.targetedFilenames.some(
|
||||||
@@ -399,8 +635,18 @@ function walkDir(dir, results = []) {
|
|||||||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||||||
ext,
|
ext,
|
||||||
);
|
);
|
||||||
|
const isFont = CONFIG.fontExtensions.includes(ext);
|
||||||
|
const isGitignore = base === ".gitignore";
|
||||||
|
const isVsCodeTask = /\.vscode\/tasks\.json$/.test(rel);
|
||||||
|
|
||||||
if (isTargetedName || isPersistenceArtifact || isJsLike) {
|
if (
|
||||||
|
isTargetedName ||
|
||||||
|
isPersistenceArtifact ||
|
||||||
|
isJsLike ||
|
||||||
|
isFont ||
|
||||||
|
isGitignore ||
|
||||||
|
isVsCodeTask
|
||||||
|
) {
|
||||||
results.push(full);
|
results.push(full);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) {
|
|||||||
return infected.length > 0;
|
return infected.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Self-test ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the rule set against synthetic samples. Guards the two properties that
|
||||||
|
* matter: the live payload is still caught, and minified vendor bundles that
|
||||||
|
* legitimately contain \uXXXX escapes are still not flagged.
|
||||||
|
* Run with: node scan.js --self-test
|
||||||
|
*/
|
||||||
|
function selfTest() {
|
||||||
|
const os = require("os");
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "polinrider-selftest-"));
|
||||||
|
const write = (name, body) => {
|
||||||
|
const p = path.join(tmp, name);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, body);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (s) =>
|
||||||
|
[...s].map((c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")).join("");
|
||||||
|
|
||||||
|
const cases = [];
|
||||||
|
|
||||||
|
// 1. The live payload shape: ASCII-escaped strings + ETH dead drop + stager.
|
||||||
|
cases.push({
|
||||||
|
name: "infected postcss.config.js",
|
||||||
|
file: write(
|
||||||
|
"infected/postcss.config.js",
|
||||||
|
`export default { plugins: {} };` +
|
||||||
|
" ".repeat(300) +
|
||||||
|
`global.i="A8-4299";global.r=require;global.m=module;` +
|
||||||
|
`const http=require("${esc("http")}"),{spawn}=require("${esc("child_process")}");` +
|
||||||
|
`S="0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a".toLowerCase(),` +
|
||||||
|
`I="${esc("https://eth.blockscout.com/api")}";` +
|
||||||
|
`rc(t,"${esc("eth_getBlockByNumber")}");eval(r+o);` +
|
||||||
|
`spawn("node",["-e",r+o],{detached:!0,stdio:"${esc("ignore")}",windowsHide:!0});`,
|
||||||
|
),
|
||||||
|
expect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Clean config — must stay silent.
|
||||||
|
cases.push({
|
||||||
|
name: "clean postcss.config.js",
|
||||||
|
file: write(
|
||||||
|
"clean/postcss.config.js",
|
||||||
|
"export default {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n",
|
||||||
|
),
|
||||||
|
expect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Minified vendor bundle with many non-ASCII escapes — must stay silent.
|
||||||
|
cases.push({
|
||||||
|
name: "minified vendor bundle",
|
||||||
|
file: write(
|
||||||
|
"vendor/emoji.min.js",
|
||||||
|
"var e=[" +
|
||||||
|
Array.from({ length: 400 }, (_, i) => `"\\ud83d\\ude${(i % 90) + 10}"`).join(",") +
|
||||||
|
"];",
|
||||||
|
),
|
||||||
|
expect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Font carrying an appended JS payload.
|
||||||
|
cases.push({
|
||||||
|
name: "trojanised .woff2",
|
||||||
|
file: write(
|
||||||
|
"fonts/bad.woff2",
|
||||||
|
"wOF2" + " | ||||||