Last active
May 18, 2026 21:10
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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