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.ts",
|
||||
"tailwind.config.cjs",
|
||||
"tailwind.config.mjs",
|
||||
"tailwind.js",
|
||||
"postcss.config.js",
|
||||
"postcss.config.ts",
|
||||
"postcss.config.mjs",
|
||||
"postcss.config.cjs",
|
||||
"babel.config.js",
|
||||
"babel.config.ts",
|
||||
"babel.config.mjs",
|
||||
"babel.config.cjs",
|
||||
"next.config.js",
|
||||
"next.config.ts",
|
||||
"next.config.mjs",
|
||||
"next.config.cjs",
|
||||
"eslint.config.js",
|
||||
"eslint.config.ts",
|
||||
"eslint.config.mjs",
|
||||
"eslint.config.cjs",
|
||||
"astro.config.mjs",
|
||||
"astro.config.js",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"vite.config.mjs",
|
||||
"vite.config.cjs",
|
||||
"webpack.config.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.
|
||||
// These filenames are skipped before malware rules are evaluated.
|
||||
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 ─────────────────────────────
|
||||
|
||||
{
|
||||
id: "POLINRIDER-009",
|
||||
severity: "HIGH",
|
||||
description:
|
||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
|
||||
test(content) {
|
||||
"Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, BSC and Ethereum as dead-drop C2 resolvers",
|
||||
test(_content, _filePath, _lines, decoded) {
|
||||
const endpoints = [
|
||||
"trongrid.io",
|
||||
"aptoslabs.com",
|
||||
@@ -202,7 +381,7 @@ const RULES = [
|
||||
"bsc-rpc.publicnode.com",
|
||||
"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",
|
||||
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"]
|
||||
: [];
|
||||
"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, _filePath, _lines, decoded) {
|
||||
const m = decoded.match(/windowsHide\s*:\s*(true|!0)/);
|
||||
return m ? [`windowsHide:${m[1]} found`] : [];
|
||||
},
|
||||
},
|
||||
|
||||
@@ -237,7 +415,10 @@ const RULES = [
|
||||
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) {
|
||||
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 = [];
|
||||
lines.forEach((line, i) => {
|
||||
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",
|
||||
severity: "HIGH",
|
||||
@@ -330,25 +532,58 @@ const RULES = [
|
||||
|
||||
// ─── 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) {
|
||||
if (shouldIgnoreFile(filePath)) {
|
||||
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;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
content = fs.readFileSync(filePath, isBinary ? "latin1" : "utf8");
|
||||
} catch (err) {
|
||||
return { filePath, error: err.message, findings: [] };
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const decoded = deobfuscate(content);
|
||||
const findings = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
let matches;
|
||||
try {
|
||||
matches = rule.test(content, filePath, lines);
|
||||
matches = rule.test(content, filePath, lines, decoded);
|
||||
} catch (err) {
|
||||
matches = [`[rule error: ${err.message}]`];
|
||||
}
|
||||
@@ -388,6 +623,7 @@ function walkDir(dir, results = []) {
|
||||
} else if (entry.isFile() && !shouldIgnoreFile(full)) {
|
||||
const ext = path.extname(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
|
||||
const isTargetedName = CONFIG.targetedFilenames.some(
|
||||
@@ -399,8 +635,18 @@ function walkDir(dir, results = []) {
|
||||
const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -532,10 +778,141 @@ function printReport(allResults, { json = false, outputFile = null } = {}) {
|
||||
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" + " | ||||