Skip to content

Instantly share code, notes, and snippets.

@Fazzani
Last active May 18, 2026 21:10
Show Gist options
  • Select an option

  • Save Fazzani/7a76d5ae430e05c763c6a86cbca9d42f to your computer and use it in GitHub Desktop.

Select an option

Save Fazzani/7a76d5ae430e05c763c6a86cbca9d42f to your computer and use it in GitHub Desktop.
Workstation / runner scanner for the Mini Shai-Hulud campaign (CTI Advisory #002) CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx — CVSS 9.6 Critical Threat actor: TeamPCP (aliases: DeadCatx3, PCPcat, ShellForce, CipherForce)
#!/usr/bin/env node
// AUTO-GENERATED — do not edit directly.
// Source: mini-shai-hulud/mini-shai-hulud-audit.js
// Rebuild: node mini-shai-hulud/build-standalone-mini-shai-hulud.js
//
// ⚠ CRITICAL SAFETY WARNING — Mini Shai-Hulud (CVE-2026-45321)
// DO NOT revoke tokens before isolating the machine.
// The worm watchdog triggers rm -rf ~/ on token revocation.
// Incident response order: ISOLATE → IMAGE → KILL DAEMON → REVOKE → ROTATE
//
// Requirements: Node.js >= 14. No install required.
// Usage: node mini-shai-hulud-audit-standalone.js
// node mini-shai-hulud-audit-standalone.js --output my-report.csv
// node mini-shai-hulud-audit-standalone.js --root /path/to/evidence
'use strict';
const fs = require('node:fs');
const https = require('node:https');
const os = require('node:os');
const path = require('node:path');
const readline = require('node:readline');
const { execSync } = require('node:child_process');
// ─── incident-patterns-mini-shai-hulud.js ────────────────────────────────────
/**
* IOC patterns for the Mini Shai-Hulud campaign (CTI Advisory #002 — May 2026)
* Threat actor: TeamPCP (aliases: DeadCatx3, PCPcat, ShellForce, CipherForce)
* CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx — CVSS 9.6 Critical
* TLP:AMBER — Upcoop Cyberdefense CTI Unit
*
* Severity scale:
* critical — direct IOC confirming compromise (payload file, C2 domain, malicious package version)
* high — strong indicator (persistence artefact, distinctive string, daemon)
* medium — contextual corroborating evidence
*/
const MINI_SHAI_HULUD_PATTERNS = [
// ── Malicious package versions (npm) ───────────────────────────────────────
// @tanstack — vague mai 2026 (confirmed clean: query*, table*, form*, virtual*, store)
{ id: 'tanstack-router', label: '@tanstack/router (malicious versions — May 2026)', category: 'package', severity: 'critical', regex: /@tanstack\/router@(?!0\.|1\.[0-8]\.|2\.0\.0)/i },
{
id: 'tanstack-react-router',
label: '@tanstack/react-router (malicious versions — May 2026)',
category: 'package',
severity: 'critical',
regex: /@tanstack\/react-router@(?!0\.|1\.[0-8]\.|2\.0\.0)/i,
},
{ id: 'mistralai-npm', label: '@mistralai/mistralai (compromised)', category: 'package', severity: 'critical', regex: /@mistralai\/mistralai\b/i },
{ id: 'uipath-apollo', label: '@uipath/apollo-core (compromised)', category: 'package', severity: 'critical', regex: /@uipath\/apollo-core\b/i },
{ id: 'intercom-client', label: 'intercom-client@7.0.4 (compromised)', category: 'package', severity: 'critical', regex: /intercom-client@7\.0\.4\b/i },
// @cap-js — vague avril 2026 (SAP CAP)
{ id: 'mbt-1.2.48', label: 'mbt@1.2.48 (compromised — SAP CAP wave)', category: 'package', severity: 'critical', regex: /mbt@1\.2\.48\b/i },
{ id: 'cap-js-db-service', label: '@cap-js/db-service (compromised)', category: 'package', severity: 'critical', regex: /@cap-js\/db-service\b/i },
{ id: 'cap-js-sqlite', label: '@cap-js/sqlite@2.2.2 (compromised — unpublished)', category: 'package', severity: 'critical', regex: /@cap-js\/sqlite@2\.2\.2\b/i },
{ id: 'cap-js-postgres', label: '@cap-js/postgres (compromised)', category: 'package', severity: 'critical', regex: /@cap-js\/postgres\b/i },
// PyPI — vague mai 2026
{ id: 'mistralai-pypi', label: 'mistralai (PyPI — compromised versions May 2026)', category: 'package', severity: 'critical', regex: /\bmistralai\b/i },
{ id: 'lightning-2.6.2', label: 'lightning@2.6.2 (PyPI — compromised)', category: 'package', severity: 'critical', regex: /\blightning@2\.6\.[23]\b/i },
{ id: 'guardrails-ai', label: 'guardrails-ai (PyPI — compromised)', category: 'package', severity: 'critical', regex: /\bguardrails-ai\b/i },
{ id: 'litellm', label: 'litellm (PyPI — associated campaign)', category: 'package', severity: 'critical', regex: /\blitellm\b/i },
// ── C2 / network IOCs ──────────────────────────────────────────────────────
{ id: 'c2-git-tanstack', label: 'git-tanstack[.]com (C2 typosquat domain)', category: 'network', severity: 'critical', regex: /git-tanstack\.com\b/i },
{ id: 'c2-session', label: 'seed1[.]getsession[.]org (C2 Session Messenger P2P)', category: 'network', severity: 'critical', regex: /seed1\.getsession\.org\b/i },
{ id: 'c2-masscan', label: 'zero[.]masscan[.]cloud (C2)', category: 'network', severity: 'critical', regex: /zero\.masscan\.cloud\b/i },
{ id: 'c2-webhook', label: 'webhook[.]site (secondary exfiltration)', category: 'network', severity: 'high', regex: /webhook\.site\b/i },
// ── Payload files ──────────────────────────────────────────────────────────
{ id: 'file-router-init', label: 'router_init.js (worm payload)', category: 'filesystem', severity: 'critical', regex: /\brouter_init\.js\b/i },
{ id: 'file-tanstack-runner', label: 'tanstack_runner.js (worm payload)', category: 'filesystem', severity: 'critical', regex: /\btanstack_runner\.js\b/i },
{ id: 'file-router-runtime', label: 'router_runtime.js (worm payload)', category: 'filesystem', severity: 'critical', regex: /\brouter_runtime\.js\b/i },
{ id: 'file-setup-bun', label: 'setup_bun.js (Bun runtime downloader)', category: 'filesystem', severity: 'critical', regex: /\bsetup_bun\.js\b/i },
{ id: 'file-bun-environment', label: 'bun_environment.js (worm payload)', category: 'filesystem', severity: 'critical', regex: /\bbun_environment\.js\b/i },
{ id: 'file-transformers', label: '/tmp/transformers.pyz (PyPI mistralai payload — Linux)', category: 'filesystem', severity: 'critical', regex: /\/tmp\/transformers\.pyz\b/i },
// ── SHA-256 payload hashes ─────────────────────────────────────────────────
{ id: 'hash-router-init', label: 'SHA-256 router_init.js', category: 'hash', severity: 'critical', regex: /ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c/i },
{ id: 'hash-tanstack-runner', label: 'SHA-256 tanstack_runner.js', category: 'hash', severity: 'critical', regex: /2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96/i },
// ── Persistence artefacts ──────────────────────────────────────────────────
{ id: 'daemon-gh-token-monitor', label: 'gh-token-monitor daemon (persistence)', category: 'persistence', severity: 'critical', regex: /\bgh-token-monitor\b/i },
{ id: 'modified-claude-settings', label: '.claude/settings.json (modified by worm)', category: 'persistence', severity: 'critical', regex: /\.claude[/\\]settings\.json\b/i },
{ id: 'modified-vscode-tasks', label: '.vscode/tasks.json (modified by worm)', category: 'persistence', severity: 'critical', regex: /\.vscode[/\\]tasks\.json\b/i },
{ id: 'modified-claude-json', label: '~/.claude.json (harvested by worm)', category: 'persistence', severity: 'critical', regex: /[\\/]\.claude\.json\b/i },
{ id: 'setup-mjs', label: '.claude/setup.mjs or .vscode/setup.mjs (worm dropper)', category: 'persistence', severity: 'critical', regex: /(?:\.claude|\.vscode)[/\\]setup\.mjs\b/i },
// ── Distinctive strings / signatures ──────────────────────────────────────
{ id: 'string-appeared', label: '"A Mini Shai-Hulud has Appeared" (exfil dead drop)', category: 'string', severity: 'critical', regex: /A Mini Shai-Hulud has Appeared/i },
{ id: 'string-second-coming', label: '"Sha1-Hulud: The Second Coming" (campaign marker)', category: 'string', severity: 'critical', regex: /Sha1-Hulud:\s*The Second Coming/i },
{ id: 'string-here-we-go', label: '"Shai-Hulud: Here We Go Again"', category: 'string', severity: 'critical', regex: /Shai-Hulud:\s*Here We Go Again/i },
{
id: 'string-revoke-wipe',
label: '"IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner" (npm token desc)',
category: 'string',
severity: 'critical',
regex: /IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner/i,
},
{ id: 'string-ctf-scramble', label: '"ctf-scramble-v2" (campaign marker)', category: 'string', severity: 'critical', regex: /\bctf-scramble-v2\b/i },
{ id: 'string-ohno', label: '"OhNoWhatsGoingOnWithGitHub:" (commit signature)', category: 'string', severity: 'critical', regex: /OhNoWhatsGoingOnWithGitHub:/i },
{ id: 'string-pbkdf2-salt', label: 'PBKDF2 salt "svksjrhjkcejg"', category: 'string', severity: 'critical', regex: /\bsvksjrhjkcejg\b/i },
// ── Git branch IOCs (Dune-themed) ──────────────────────────────────────────
{
id: 'git-branch-dune',
label: 'Dune-themed dependabot branch (worm delivery vector)',
category: 'git',
severity: 'high',
regex: /dependabot\/github_actions\/format\/(?:atreides|cogitor|fedaykin|fremen|gesserit|harkonnen|melange|mentat|sandworm|sardaukar|shai-hulud|sietch|tleilaxu)\b/i,
},
// ── Commit / package reference IOC ─────────────────────────────────────────
{ id: 'git-commit-ioc', label: 'Poisoned commit ref 79ac49ee (optionalDependencies)', category: 'git', severity: 'critical', regex: /79ac49eedf774dd4b0cfa308722bc463cfe5885c/i },
// ── Bun runtime unexpected usage ───────────────────────────────────────────
{ id: 'bun-runtime', label: 'Bun runtime execution (evasion technique)', category: 'execution', severity: 'high', regex: /\boven-sh\/bun\b|bun\/releases\/download\b/i },
// ── Contextual execution traces (corroborating only — no standalone significance) ──
{ id: 'npm-install', label: 'npm install or npm ci', category: 'execution', severity: 'medium', regex: /\bnpm\s+(?:install|ci)\b/i },
{ id: 'npm-publish', label: 'npm publish', category: 'artifact', severity: 'medium', regex: /\bnpm\s+publish\b/i },
{ id: 'docker-push', label: 'docker push', category: 'artifact', severity: 'medium', regex: /\bdocker\s+push\b/i },
{ id: 'pip-install', label: 'pip install', category: 'execution', severity: 'medium', regex: /\bpip\s+install\b/i },
];
// ─── incident-utils.js ──────────────────────────────────────────────────────
function prefersAsciiOutput() {
const forceAscii = ['1', 'true', 'yes', 'on'].includes(String(process.env.FORCE_ASCII || process.env.ASCII_ONLY || '').toLowerCase());
const forceUnicode = ['1', 'true', 'yes', 'on'].includes(String(process.env.FORCE_UNICODE || process.env.UNICODE || '').toLowerCase());
if (forceUnicode) return false;
if (forceAscii) return true;
if (process.platform === 'win32') return true;
return false;
}
function asciiText(value) {
const text = value === null || value === undefined ? '' : String(value);
return text
.replace(/[][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
.replace(/[][^\u0000-\u007F]*/g, '')
.replace(/[]/g, '->')
.replace(/[]/g, '-')
.replace(/[]/g, '!')
.replace(/[]/g, 'OK')
.replace(/[]/g, 'X')
.replace(/[]/g, 'i')
.replace(/[]/g, '>')
.replace(/[]/g, '#')
.replace(/[]/g, '-')
.replace(/[]/g, ' ')
.replace(/[📄]/g, '[report]');
}
function createLogger() {
const ascii = prefersAsciiOutput();
const hasColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
const C = hasColor
? { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', white: '\x1b[37m' }
: Object.fromEntries(['reset', 'bold', 'dim', 'red', 'green', 'yellow', 'cyan', 'white'].map((k) => [k, '']));
const symbols = ascii ? { info: 'i', ok: 'OK', warn: '!', alert: 'X', step: '>' } : { info: 'ℹ', ok: '✔', warn: '⚠', alert: '✖', step: '▶' };
const log = {
info: (m) => console.log(`${C.cyan}${symbols.info}${C.reset} ${ascii ? asciiText(m) : m}`),
ok: (m) => console.log(`${C.green}${symbols.ok}${C.reset} ${ascii ? asciiText(m) : m}`),
warn: (m) => console.log(`${C.yellow}${symbols.warn}${C.reset} ${ascii ? asciiText(m) : m}`),
alert: (m) => console.log(`${C.red}${C.bold}${symbols.alert}${C.reset} ${ascii ? asciiText(m) : m}`),
dim: (m) => console.log(`${C.dim} ${ascii ? asciiText(m) : m}${C.reset}`),
step: (m) => console.log(`\n${C.bold}${C.white}${symbols.step} ${ascii ? asciiText(m) : m}${C.reset}`),
title: (m) => {
const titleText = ascii ? asciiText(m) : m;
const bar = ascii ? '-'.repeat(titleText.length + 4) : '─'.repeat(titleText.length + 4);
if (ascii) {
console.log(`\n${C.cyan}${C.bold}+${bar}+\n| ${titleText} |\n+${bar}+${C.reset}`);
return;
}
console.log(`\n${C.cyan}${C.bold}${bar}┐\n│ ${titleText} │\n└${bar}${C.reset}`);
},
};
return { C, log, ascii, asciiText };
}
function readEnvFileIntoProcess() {
const envFile = path.join(process.cwd(), '.env');
if (fs.existsSync(envFile)) {
const lines = fs.readFileSync(envFile, 'utf8').split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx < 0) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed
.slice(eqIdx + 1)
.trim()
.replace(/^["']|["']$/g, '');
if (key && value && !process.env[key]) process.env[key] = value;
}
}
}
function normalizeConfigValue(value) {
if (value === null || value === undefined) return '';
return String(value).trim();
}
function isInteractiveTerminal() {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
function askQuestion(promptText, options = {}) {
const { hidden = false } = options;
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
if (hidden) {
rl.stdoutMuted = true;
rl._writeToOutput = function _writeToOutput(stringToWrite) {
if (rl.stdoutMuted) {
if (stringToWrite === '\n' || stringToWrite === '\r\n') {
rl.output.write(stringToWrite);
} else {
rl.output.write('*');
}
return;
}
rl.output.write(stringToWrite);
};
process.stdout.write(promptText);
rl.question('', (answer) => {
rl.stdoutMuted = false;
rl.close();
process.stdout.write('\n');
resolve(answer);
});
return;
}
rl.question(promptText, (answer) => {
rl.close();
resolve(answer);
});
});
}
async function resolveConfigValue(currentValue, options = {}) {
const { interactive, promptText, required = true, allowBlank = false, hidden = false, missingMessage } = options;
if (currentValue) {
return currentValue;
}
if (!interactive) {
if (required && !allowBlank) {
throw new Error(missingMessage);
}
return '';
}
const answer = normalizeConfigValue(await askQuestion(promptText, { hidden }));
if (!answer && required && !allowBlank) {
throw new Error(missingMessage);
}
return answer;
}
async function loadAdoConfig(options = {}) {
const { org: orgInput = '', project: projectInput = '', pat: patInput = '', projectOptional = true } = options;
readEnvFileIntoProcess();
let org = normalizeConfigValue(orgInput || process.env.ADO_ORG);
let project = normalizeConfigValue(projectInput || process.env.ADO_PROJECT);
let pat = normalizeConfigValue(patInput || process.env.ADO_PAT);
const interactive = isInteractiveTerminal();
org = await resolveConfigValue(org, {
interactive,
promptText: 'Azure DevOps organization (ADO_ORG) [required]: ',
required: true,
missingMessage: 'Missing ADO_ORG. Pass --org, set ADO_ORG, or run in an interactive terminal.',
});
if (projectOptional) {
project = normalizeConfigValue(project);
} else {
project = await resolveConfigValue(project, {
interactive,
promptText: 'Azure DevOps project (ADO_PROJECT) [required]: ',
required: true,
missingMessage: 'Missing ADO_PROJECT. Pass --project, set ADO_PROJECT, or run in an interactive terminal.',
});
}
pat = await resolveConfigValue(pat, {
interactive,
promptText: 'Azure DevOps PAT (ADO_PAT) [required, input hidden]: ',
required: true,
hidden: true,
missingMessage: 'Missing ADO_PAT. Pass --pat, set ADO_PAT, or run in an interactive terminal.',
});
if (!org || !pat) {
throw new Error('Missing ADO configuration. Organization and PAT are required.');
}
return {
org,
project: project || null,
pat,
baseUrl: project ? `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis` : `https://dev.azure.com/${encodeURIComponent(org)}/_apis`,
authHeader: 'Basic ' + Buffer.from(`:${pat}`).toString('base64'),
};
}
function httpRequest(reqUrl, authHeader, options = {}) {
const { raw = false, method = 'GET', timeoutMs = 30_000, headers = {}, body = null } = options;
return new Promise((resolve, reject) => {
const parsed = new URL(reqUrl);
const requestOptions = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
method,
headers: {
Authorization: authHeader,
Accept: raw ? 'text/plain' : 'application/json',
...headers,
},
};
const req = https.request(requestOptions, (res) => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers,
body: responseBody,
});
});
});
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
req.on('error', reject);
if (body !== null) {
req.write(body);
}
req.end();
});
}
async function httpGetJson(reqUrl, authHeader, options = {}) {
const response = await httpRequest(reqUrl, authHeader, options);
const parsed = new URL(reqUrl);
if (response.statusCode === 404) {
return { body: null, headers: response.headers, statusCode: 404 };
}
if (response.statusCode === 401 || response.statusCode === 203) {
throw new Error('HTTP 401 — Invalid or expired PAT. Check ADO_PAT.');
}
if (response.statusCode >= 400) {
throw new Error(`HTTP ${response.statusCode} on ${parsed.pathname}`);
}
try {
return { body: JSON.parse(response.body), headers: response.headers, statusCode: response.statusCode };
} catch {
throw new Error(`Invalid JSON from ${parsed.pathname}`);
}
}
async function httpGetText(reqUrl, authHeader, options = {}) {
const response = await httpRequest(reqUrl, authHeader, { ...options, raw: true });
const parsed = new URL(reqUrl);
if (response.statusCode === 404) {
return { body: null, headers: response.headers, statusCode: 404 };
}
if (response.statusCode === 401 || response.statusCode === 203) {
throw new Error('HTTP 401 — Invalid or expired PAT. Check ADO_PAT.');
}
if (response.statusCode >= 400) {
throw new Error(`HTTP ${response.statusCode} on ${parsed.pathname}`);
}
return { body: response.body, headers: response.headers, statusCode: response.statusCode };
}
class Semaphore {
constructor(max) {
this.max = max;
this.count = 0;
this.queue = [];
}
acquire() {
return this.count < this.max ? (this.count++, Promise.resolve()) : new Promise((resolve) => this.queue.push(resolve)).then(() => this.count++);
}
release() {
this.count--;
const next = this.queue.shift();
if (next) next();
}
}
async function mapConcurrent(items, fn, limit = 5) {
const sem = new Semaphore(limit);
return Promise.all(
items.map(async (item) => {
await sem.acquire();
try {
return await fn(item);
} finally {
sem.release();
}
}),
);
}
function makeProgress(total, C) {
const ascii = prefersAsciiOutput();
const filledChar = ascii ? '#' : '█';
const emptyChar = ascii ? '-' : '░';
let done = 0;
const width = 28;
return {
tick(label = '') {
done++;
const pct = total > 0 ? Math.round((done / total) * 100) : 100;
const filled = total > 0 ? Math.round((done / total) * width) : width;
const bar = `${filledChar.repeat(filled)}${emptyChar.repeat(Math.max(0, width - filled))}`;
const lbl = label.length > 35 ? label.slice(0, 32) + '...' : label.padEnd(35);
process.stdout.write(`\r ${C.cyan}[${bar}]${C.reset} ${String(pct).padStart(3)}% ${C.dim}${lbl}${C.reset}`);
if (done === total) process.stdout.write('\n');
},
};
}
function normalizeDateRange(day) {
const start = new Date(`${day}T00:00:00Z`);
if (Number.isNaN(start.getTime())) {
throw new TypeError(`Invalid date: ${day}`);
}
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
return {
day,
start: start.toISOString(),
end: end.toISOString(),
};
}
function dedupeByKey(items, keyFn) {
const seen = new Set();
return items.filter((item) => {
const key = keyFn(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function readTextIfExists(filePath) {
try {
const stats = fs.statSync(filePath);
if (!stats.isFile()) return null;
const body = fs.readFileSync(filePath, 'utf8');
return {
path: filePath,
body,
size: stats.size,
mtime: stats.mtime,
};
} catch {
return null;
}
}
function resolveReportFormat(filePath, defaultFormat = 'csv') {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.csv') return 'csv';
if (ext === '.json') return 'json';
return defaultFormat;
}
function csvCell(value) {
if (value === null || value === undefined) return '';
let text = value;
if (typeof text === 'object') {
text = JSON.stringify(text);
}
const stringValue = String(text);
if (/[",\r\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
function serializeCsv(rows, columns) {
const header = columns.map((column) => csvCell(column)).join(',');
const lines = rows.map((row) => columns.map((column) => csvCell(row[column])).join(','));
return [header, ...lines].join('\r\n');
}
function writeReportFile(filePath, jsonData, csvRows, csvColumns, defaultFormat = 'csv') {
const format = resolveReportFormat(filePath, defaultFormat);
if (format === 'json') {
fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2), 'utf8');
} else {
fs.writeFileSync(filePath, serializeCsv(csvRows, csvColumns), 'utf8');
}
return format;
}
function walkFiles(roots, options = {}) {
const maxDepth = options.maxDepth ?? 4;
const filter = options.filter ?? (() => true);
const ignoreDirs = new Set(options.ignoreDirs ?? ['.git', 'node_modules', 'dist', 'build', '.cache', 'coverage']);
const result = [];
const queue = [];
const visited = new Set();
for (const root of roots.filter(Boolean)) {
queue.push({ dir: path.resolve(root), depth: 0 });
}
while (queue.length > 0) {
const current = queue.shift();
if (!current || visited.has(current.dir)) continue;
visited.add(current.dir);
let entries;
try {
entries = fs.readdirSync(current.dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current.dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (current.depth < maxDepth && !ignoreDirs.has(entry.name)) {
queue.push({ dir: fullPath, depth: current.depth + 1 });
}
continue;
}
if (entry.isFile() && filter(fullPath, entry, current.depth)) {
result.push(fullPath);
}
}
}
return dedupeByKey(result, (item) => item);
}
function scanTextForPatterns(text, patterns, contextSize = 80) {
const hits = [];
for (const pattern of patterns) {
const regex = new RegExp(pattern.regex.source, pattern.regex.flags.replace('g', ''));
const match = regex.exec(text);
if (!match) continue;
const index = match.index ?? text.indexOf(match[0]);
const start = Math.max(0, index - contextSize);
const end = Math.min(text.length, index + match[0].length + contextSize);
const snippet = text.slice(start, end).replace(/\s+/g, ' ').trim();
hits.push({
id: pattern.id,
label: pattern.label,
category: pattern.category,
severity: pattern.severity,
match: match[0],
snippet,
});
}
return dedupeByKey(hits, (item) => `${item.id}|${item.match}|${item.snippet}`);
}
// ─── mini-shai-hulud-audit.js ───────────────────────────────────────────────
/**
* mini-shai-hulud-audit.js
* Workstation / runner scanner for the Mini Shai-Hulud campaign (CTI Advisory #002)
* CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx - CVSS 9.6 Critical
* Threat actor: TeamPCP (aliases: DeadCatx3, PCPcat, ShellForce, CipherForce)
*
* ⚠️ CRITICAL SAFETY WARNING ⚠️
* If infection is suspected, DO NOT revoke any tokens before isolating the machine.
* The malware watchdog detects revocation and triggers: rm -rf ~/
* Incident response order: ISOLATE -> IMAGE -> KILL DAEMON -> REVOKE -> ROTATE
*
* Zero runtime dependencies — requires Node.js >= 14 only.
* Usage: node mini-shai-hulud/mini-shai-hulud-audit.js [--root <dir>] [--output <file>]
*/
const REPORT_FILE = 'mini-shai-hulud-audit-report.csv';
const ADVISORY_REF = 'CTI Advisory #002 - CVE-2026-45321 - TLP:AMBER';
const CSV_COLUMNS = [
'recordType',
'auditDate',
'host',
'platform',
'root',
'checkType',
'targetedFiles',
'scannedFiles',
'filesWithHits',
'totalHits',
'criticalHits',
'categories',
'path',
'size',
'mtime',
'id',
'label',
'category',
'severity',
'match',
'snippet',
];
// ── CLI argument parser ─────────────────────────────────────────────────────
function parseArgs(argv) {
const args = argv.slice(2);
const opts = {
output: REPORT_FILE,
root: null,
help: false,
maxDepth: 4,
lockfiles: false, // opt-in: scan lockfiles for IOCs (slow on large repos)
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--output':
case '-o':
opts.output = args[++i];
break;
case '--root':
case '-r':
opts.root = args[++i];
break;
case '--max-depth':
case '--depth':
opts.maxDepth = Number(args[++i]);
break;
case '--lockfiles':
opts.lockfiles = true;
break;
case '--help':
case '-h':
opts.help = true;
break;
}
}
return opts;
}
// ── Help ────────────────────────────────────────────────────────────────────
function showHelp() {
const { C } = createLogger();
console.log(`
${C.bold}${C.red}! CRITICAL SAFETY WARNING${C.reset}
Do NOT revoke any tokens before isolating this machine.
The worm's watchdog triggers ${C.bold}rm -rf ~/${C.reset} on token revocation.
${C.bold}${C.cyan}mini-shai-hulud-audit${C.reset}
Local evidence collector for the Mini Shai-Hulud campaign
${C.dim}${ADVISORY_REF}${C.reset}
USAGE
node mini-shai-hulud/mini-shai-hulud-audit.js [options]
OPTIONS
--root, -r <path> Extra folder to scan recursively for text evidence
--output, -o <file> Output file (default: ${REPORT_FILE})
--max-depth <n> Max recursion depth for --root (default: 4)
--lockfiles Also scan lockfiles (package-lock.json, yarn.lock...)
for IOC references - slow but thorough
--help, -h Show this help
DEFAULT SCAN TARGETS
Payload files setup_bun.js, bun_environment.js, router_init.js,
router_runtime.js, tanstack_runner.js (home + temp dirs)
Linux /tmp/transformers.pyz (PyPI mistralai payload)
Persistence ~/.claude/settings.json, .vscode/tasks.json, ~/.claude.json,
*/.claude/setup.mjs, */.vscode/setup.mjs
Daemons ~/.config/**/gh-token-monitor*, ~/.local/bin/gh-token-monitor.sh
Shell history bash_history, zsh_history, PSReadLine
npm logs ~/.npm/_logs, AppData npm-cache logs
INCIDENT RESPONSE ORDER (if infection confirmed)
1. ISOLATE - disconnect machine from network
2. IMAGE - forensic disk image before any cleanup
3. KILL - disable gh-token-monitor daemon
4. REVOKE - npm, GitHub PAT/OAuth, AWS, Azure, GCP, Kubernetes, SSH
5. ROTATE - all credentials reachable from this host
6. AUDIT - CloudTrail / Azure Activity / GCP Audit logs
REFERENCES
CVE-2026-45321 https://tenable.com/cve/CVE-2026-45321
GHSA-g7cv-rxg3-hmpx https://github.com/TanStack/router/security/advisories/GHSA-g7cv-rxg3-hmpx
TanStack postmortem https://tanstack.com/blog/npm-supply-chain-compromise-postmortem
`);
}
// ── IOC-specific artefact paths ─────────────────────────────────────────────
function joinIfPresent(...parts) {
if (parts.some((part) => !part)) return null;
return path.join(...parts);
}
function getDefaultTargets() {
const home = os.homedir();
const tempDir = process.env.TEMP || process.env.TMP || os.tmpdir();
const appData = process.env.APPDATA;
const localAppData = process.env.LOCALAPPDATA;
// ── Payload files to check for existence ───────────────────────────────────
// These files should not exist on a clean machine — their presence is high-confidence.
const payloadFileNames = ['setup_bun.js', 'bun_environment.js', 'router_init.js', 'router_runtime.js', 'tanstack_runner.js'];
const payloadSearchRoots = [home, tempDir, os.tmpdir()].filter(Boolean);
const knownPayloadPaths = [];
for (const root of payloadSearchRoots) {
for (const name of payloadFileNames) {
knownPayloadPaths.push(path.join(root, name));
}
}
// Linux-specific PyPI payload
knownPayloadPaths.push('/tmp/transformers.pyz');
// ── Persistence configs ────────────────────────────────────────────────────
const persistenceFiles = [
joinIfPresent(home, '.claude', 'settings.json'),
joinIfPresent(home, '.vscode', 'tasks.json'),
joinIfPresent(home, '.claude.json'),
joinIfPresent(home, '.claude', 'setup.mjs'),
joinIfPresent(home, '.vscode', 'setup.mjs'),
].filter(Boolean);
// ── Daemon locations ───────────────────────────────────────────────────────
const daemonFiles = [
joinIfPresent(home, '.local', 'bin', 'gh-token-monitor.sh'),
joinIfPresent(home, '.config', 'systemd', 'user', 'gh-token-monitor.service'),
joinIfPresent(home, 'Library', 'LaunchAgents', 'com.gh-token-monitor.plist'),
].filter(Boolean);
// ── Shell history ──────────────────────────────────────────────────────────
const historyFiles = [
joinIfPresent(home, '.bash_history'),
joinIfPresent(home, '.zsh_history'),
joinIfPresent(home, '.local', 'share', 'fish', 'fish_history'),
joinIfPresent(appData, 'Microsoft', 'Windows', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'),
joinIfPresent(appData, 'Microsoft', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'),
joinIfPresent(home, 'AppData', 'Roaming', 'Microsoft', 'Windows', 'PowerShell', 'PSReadLine', 'ConsoleHost_history.txt'),
].filter(Boolean);
// ── npm debug logs ─────────────────────────────────────────────────────────
const logDirs = [
joinIfPresent(home, '.npm', '_logs'),
joinIfPresent(appData, 'npm-cache', '_logs'),
joinIfPresent(localAppData, 'npm-cache', '_logs'),
joinIfPresent(home, 'Library', 'Logs', 'npm'),
].filter(Boolean);
// ── Daemon config dirs to scan ─────────────────────────────────────────────
const daemonSearchDirs = [joinIfPresent(home, '.config'), joinIfPresent(home, '.local', 'bin'), joinIfPresent(home, 'Library', 'LaunchAgents')].filter(Boolean);
return {
files: [...knownPayloadPaths, ...persistenceFiles, ...daemonFiles, ...historyFiles],
dirs: [...logDirs, ...daemonSearchDirs],
};
}
// ── File filter for directory walks ─────────────────────────────────────────
function isTextLikeEvidence(filePath, opts) {
const name = path.basename(filePath).toLowerCase();
if (!opts.lockfiles) {
// Lock files have high noise for most patterns — skip unless opted in.
// Exception: we DO want to check package-lock.json for the poisoned commit ref
// and optionalDependencies IOC; caller handles that via specific file checks.
if (name === 'yarn.lock' || name === 'pnpm-lock.yaml' || name === 'pnpm-lock.yml' || name === 'composer.lock' || name === 'gemfile.lock' || name === 'poetry.lock' || name === 'cargo.lock')
return false;
}
return (
name.endsWith('.log') ||
name.endsWith('.txt') ||
name.endsWith('.json') ||
name.endsWith('.yaml') ||
name.endsWith('.yml') ||
name.endsWith('.mjs') ||
name.endsWith('.js') ||
name.endsWith('.history') ||
name.endsWith('.ps1') ||
name.endsWith('.sh') ||
name.endsWith('.zsh') ||
name.endsWith('.bash') ||
name.endsWith('.py') ||
name.endsWith('.pyz') ||
name.endsWith('.plist') ||
name.endsWith('.service') ||
name === 'fish_history' ||
name === 'consolehost_history.txt' ||
name.startsWith('npm-debug') ||
name.startsWith('pnpm-debug') ||
name.startsWith('yarn-error') ||
name === 'gh-token-monitor'
);
}
// ── File scanner ─────────────────────────────────────────────────────────────
function scanFile(filePath) {
const file = readTextIfExists(filePath);
if (!file) return [];
const hits = scanTextForPatterns(file.body, MINI_SHAI_HULUD_PATTERNS);
return hits.map((hit) => ({
path: file.path,
size: file.size,
mtime: file.mtime.toISOString(),
...hit,
}));
}
function groupByFile(hits) {
const byFile = new Map();
for (const hit of hits) {
const bucket = byFile.get(hit.path) ?? [];
bucket.push(hit);
byFile.set(hit.path, bucket);
}
return [...byFile.entries()].map(([filePath, fileHits]) => ({
path: filePath,
hits: fileHits,
}));
}
// ── Payload file existence check ─────────────────────────────────────────────
// These files should not normally exist — their presence alone is a critical IOC.
function checkPayloadFileExists(filePath) {
if (!fs.existsSync(filePath)) return null;
try {
const stats = fs.statSync(filePath);
return {
path: filePath,
size: stats.size,
mtime: stats.mtime.toISOString(),
id: 'file-exists-' + path.basename(filePath).replace(/\W+/g, '-'),
label: `[FILE EXISTS] ${path.basename(filePath)} - worm payload detected`,
category: 'filesystem',
severity: 'critical',
match: path.basename(filePath),
snippet: `File exists: ${filePath} (${stats.size} bytes)`,
};
} catch {
return null;
}
}
// ── Process list check ────────────────────────────────────────────────────────
function checkSuspiciousProcesses(C) {
const processHits = [];
const suspicious = ['tanstack_runner', 'router_runtime', 'gh-token-monitor'];
try {
const cmd = process.platform === 'win32' ? 'tasklist /FO CSV /NH' : 'ps aux';
const output = execSync(cmd, { timeout: 5000, encoding: 'utf8' }).toLowerCase();
for (const proc of suspicious) {
if (output.includes(proc.toLowerCase())) {
processHits.push({
path: '[process list]',
size: 0,
mtime: new Date().toISOString(),
id: 'process-' + proc.replace(/\W+/g, '-'),
label: `[PROCESS RUNNING] ${proc} - worm/daemon active`,
category: 'persistence',
severity: 'critical',
match: proc,
snippet: `Process "${proc}" found in running process list`,
});
}
}
// Unexpected bun execution (not in standard package manager positions)
if (/\bbun\b/.test(output) && !output.includes('bun-as-installer')) {
processHits.push({
path: '[process list]',
size: 0,
mtime: new Date().toISOString(),
id: 'process-bun-unexpected',
label: '[PROCESS RUNNING] bun - unexpected Bun runtime (evasion technique)',
category: 'execution',
severity: 'high',
match: 'bun',
snippet: 'Bun runtime process found running — may indicate worm preinstall hook activity',
});
}
} catch {
// Process list not available — not a fatal error
}
return processHits;
}
// ── Core scan orchestration ──────────────────────────────────────────────────
function buildScanTargets(opts) {
const defaults = getDefaultTargets();
const fileTargets = [...defaults.files];
const dirTargets = [...defaults.dirs];
if (opts.root) {
const resolvedRoot = path.resolve(opts.root);
if (fs.existsSync(resolvedRoot)) {
const stats = fs.statSync(resolvedRoot);
if (stats.isDirectory()) {
dirTargets.push(resolvedRoot);
} else if (stats.isFile()) {
fileTargets.push(resolvedRoot);
}
}
}
return { files: fileTargets, dirs: dirTargets };
}
function buildAudit(opts) {
const { C, log } = createLogger();
log.title('MINI SHAI-HULUD AUDIT - CVE-2026-45321');
console.log(` ${C.red}${C.bold}! DO NOT revoke tokens before isolating the machine !${C.reset}`);
console.log(` ${C.dim}${ADVISORY_REF}${C.reset}\n`);
const targets = buildScanTargets(opts);
// ── Step 1: Payload file existence (presence alone = critical) ─────────────
log.step('Checking for payload files (existence check)...');
const payloadNames = ['setup_bun.js', 'bun_environment.js', 'router_init.js', 'router_runtime.js', 'tanstack_runner.js', 'transformers.pyz'];
const payloadExistenceHits = targets.files
.filter((f) => payloadNames.includes(path.basename(f)))
.map(checkPayloadFileExists)
.filter(Boolean);
// ── Step 2: Process list check ─────────────────────────────────────────────
log.step('Checking running processes...');
const processHits = checkSuspiciousProcesses(C);
// ── Step 3: Scan known files for IOC patterns ──────────────────────────────
log.step('Scanning targeted files for IOC patterns...');
const knownFileHits = [];
const progress = makeProgress(targets.files.length, C);
for (const filePath of targets.files) {
progress.tick(path.basename(filePath));
knownFileHits.push(...scanFile(filePath));
}
// ── Step 4: Walk evidence directories ─────────────────────────────────────
log.step('Walking evidence directories...');
const walkedFiles = walkFiles(targets.dirs, {
maxDepth: Number.isFinite(opts.maxDepth) ? opts.maxDepth : 4,
filter: (f) => isTextLikeEvidence(f, opts),
});
const walkedHits = [];
const walkProgress = makeProgress(walkedFiles.length, C);
for (const filePath of walkedFiles) {
walkProgress.tick(path.basename(filePath));
walkedHits.push(...scanFile(filePath));
}
// ── Aggregate ──────────────────────────────────────────────────────────────
const allHits = dedupeByKey([...payloadExistenceHits, ...processHits, ...knownFileHits, ...walkedHits], (hit) => `${hit.path}|${hit.id}|${hit.match}|${hit.snippet}`);
const filesWithHits = groupByFile(allHits);
const criticalHits = allHits.filter((hit) => hit.severity === 'critical');
const scannedFiles = dedupeByKey([...targets.files, ...walkedFiles], (f) => f);
const summary = {
targetedFiles: targets.files.length,
scannedFiles: scannedFiles.length,
filesWithHits: filesWithHits.length,
totalHits: allHits.length,
criticalHits: criticalHits.length,
categories: allHits.reduce((acc, hit) => {
acc[hit.category] = (acc[hit.category] ?? 0) + 1;
return acc;
}, {}),
};
return {
C,
log,
summary,
filesWithHits,
criticalHits,
report: {
auditDate: new Date().toISOString(),
advisory: ADVISORY_REF,
scope: {
root: opts.root ? path.resolve(opts.root) : null,
host: os.hostname(),
platform: process.platform,
},
summary,
findings: allHits,
filesWithHits,
},
};
}
// ── CSV serialisation ─────────────────────────────────────────────────────────
function buildCsvRows(result) {
const { summary, report, filesWithHits } = result;
const rows = [
{
recordType: 'summary',
auditDate: report.auditDate,
host: report.scope.host,
platform: report.scope.platform,
root: report.scope.root,
checkType: ADVISORY_REF,
targetedFiles: summary.targetedFiles,
scannedFiles: summary.scannedFiles,
filesWithHits: summary.filesWithHits,
totalHits: summary.totalHits,
criticalHits: summary.criticalHits,
categories: JSON.stringify(summary.categories),
},
];
for (const file of filesWithHits) {
for (const hit of file.hits) {
rows.push({
recordType: 'finding',
auditDate: report.auditDate,
host: report.scope.host,
platform: report.scope.platform,
root: report.scope.root,
checkType: ADVISORY_REF,
path: file.path,
size: hit.size,
mtime: hit.mtime,
id: hit.id,
label: hit.label,
category: hit.category,
severity: hit.severity,
match: hit.match,
snippet: hit.snippet,
});
}
}
return rows;
}
// ── Terminal report ───────────────────────────────────────────────────────────
function printReport(result) {
const { C, log, summary, filesWithHits } = result;
log.step('Results:');
if (summary.criticalHits === 0) {
log.ok(
'CLEAR - no critical Mini Shai-Hulud IOCs detected.\n' + ' Checked: payload files, persistence artefacts, C2 domains, package versions,\n' + ' distinctive strings, daemon processes.',
);
console.log(`\n [report] ${result.reportPath} (${summary.scannedFiles} files scanned)\n`);
return;
}
// ── Critical IOCs found ────────────────────────────────────────────────────
const criticalFiles = filesWithHits.filter((f) => f.hits.some((h) => h.severity === 'critical'));
console.log(`\n${C.red}${C.bold}=== CRITICAL IOCs DETECTED - Mini Shai-Hulud ===${C.reset}`);
console.log(`${C.red}${C.bold} CVE-2026-45321 / GHSA-g7cv-rxg3-hmpx - CVSS 9.6${C.reset}\n`);
for (const file of criticalFiles) {
console.log(` ${C.bold}${file.path}${C.reset}`);
for (const hit of file.hits.filter((h) => h.severity === 'critical')) {
console.log(` ${C.red}${hit.label}${C.reset} ${C.dim}(${hit.category})${C.reset}`);
if (hit.snippet) {
console.log(` ${C.dim}${hit.snippet}${C.reset}`);
}
}
console.log('');
}
console.log(`${C.red}${C.bold}=== INCIDENT RESPONSE - MANDATORY ORDER ===${C.reset}`);
console.log(`\n ${C.red}${C.bold}! DO NOT revoke tokens before step 3 !${C.reset}`);
console.log(` ${C.dim}The worm watchdog triggers rm -rf ~/ on token revocation (HTTP 40X).${C.reset}\n`);
console.log(` ${C.red}1.${C.reset} ISOLATE - disconnect machine from network immediately`);
console.log(` ${C.red}2.${C.reset} IMAGE - forensic disk image before any cleanup`);
console.log(` ${C.red}3.${C.reset} KILL - disable gh-token-monitor daemon and worm processes`);
console.log(` ${C.red}4.${C.reset} REVOKE - npm, GitHub PAT/OAuth, AWS, Azure, GCP, Kubernetes, SSH`);
console.log(` ${C.red}5.${C.reset} ROTATE - all credentials reachable from this host`);
console.log(` ${C.red}6.${C.reset} AUDIT - AWS CloudTrail, Azure Activity Logs, GCP Audit Logs`);
console.log(` ${C.red}7.${C.reset} VERIFY - GitHub account: recent Dune-themed public repos, new PATs\n`);
console.log(` [report] ${result.reportPath} (full execution trace inside)\n`);
}
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) {
showHelp();
process.exit(0);
}
const result = buildAudit(opts);
result.reportPath = opts.output;
writeReportFile(opts.output, result.report, buildCsvRows(result), CSV_COLUMNS);
printReport(result);
}
main().catch((err) => {
const { log } = createLogger();
log.alert(`Fatal: ${err.message}`);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment