Created
April 1, 2026 11:19
-
-
Save UrbanChrisy/8edfff4893bc4c4e9cb030d0f8b716f6 to your computer and use it in GitHub Desktop.
axios Supply Chain Compromise Scanner
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 bun | |
| /** | |
| * axios Supply Chain Compromise Scanner | |
| * ====================================== | |
| * Incident: March 30-31, 2026 | |
| * Ref: https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan | |
| * | |
| * Scans all repos inside a parent folder for indicators of the | |
| * compromised axios versions and the malicious plain-crypto-js dependency. | |
| * | |
| * Usage: | |
| * bun run scan-axios-compromise.ts [path-to-parent-folder] | |
| * | |
| * The script discovers all subdirectories containing a package.json, | |
| * scans each one independently, and produces a summary table. | |
| * | |
| * If no path is provided, scans the current directory. | |
| */ | |
| import { readdir, readFile, stat } from "node:fs/promises"; | |
| import { join, relative } from "node:path"; | |
| async function exists(p: string): Promise<boolean> { | |
| try { await stat(p); return true; } catch { return false; } | |
| } | |
| // ─── Compromise Indicators ─────────────────────────────────────────────────── | |
| const COMPROMISED_AXIOS_VERSIONS = ["1.14.1", "0.30.4"]; | |
| const SAFE_AXIOS_VERSIONS = ["1.14.0", "0.30.3"]; | |
| const MALICIOUS_PACKAGES = [ | |
| "plain-crypto-js", | |
| "@shadanai/openclaw", | |
| "@qqbrowser/openclaw-qbot", | |
| ]; | |
| const C2_DOMAINS = ["sfrclak.com", "sfrclak.com:8000"]; | |
| const COMPROMISED_NPM_USER = "nrwise"; | |
| const SUSPICIOUS_EMAILS = ["ifstap@proton.me", "nrwise@proton.me"]; | |
| // ─── ANSI Colors ───────────────────────────────────────────────────────────── | |
| const RED = "\x1b[31m"; | |
| const GREEN = "\x1b[32m"; | |
| const YELLOW = "\x1b[33m"; | |
| const CYAN = "\x1b[36m"; | |
| const BOLD = "\x1b[1m"; | |
| const DIM = "\x1b[2m"; | |
| const RESET = "\x1b[0m"; | |
| const CRITICAL = `${RED}${BOLD}✗ CRITICAL${RESET}`; | |
| const WARNING = `${YELLOW}⚠ WARNING${RESET}`; | |
| const OK = `${GREEN}✓ OK${RESET}`; | |
| const INFO = `${CYAN}ℹ INFO${RESET}`; | |
| // ─── Types ─────────────────────────────────────────────────────────────────── | |
| interface Finding { | |
| severity: "critical" | "warning" | "info"; | |
| file: string; | |
| message: string; | |
| detail?: string; | |
| } | |
| interface ScanResult { | |
| repoName: string; | |
| repoPath: string; | |
| findings: Finding[]; | |
| stats: { | |
| filesScanned: number; | |
| lockfilesChecked: number; | |
| nodeModulesChecked: number; | |
| packageJsonsChecked: number; | |
| }; | |
| usesAxios: boolean; | |
| axiosVersions: string[]; | |
| } | |
| // ─── Helpers ───────────────────────────────────────────────────────────────── | |
| async function* walk( | |
| dir: string, | |
| skipDirs = new Set([".git", ".cache", ".next", "dist", "build", ".turbo"]) | |
| ): AsyncGenerator<string> { | |
| let entries; | |
| try { | |
| entries = await readdir(dir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const entry of entries) { | |
| const fullPath = join(dir, entry.name); | |
| if (entry.isDirectory()) { | |
| if (skipDirs.has(entry.name)) continue; | |
| // We DO want to walk into node_modules to find plain-crypto-js | |
| yield* walk(fullPath, skipDirs); | |
| } else { | |
| yield fullPath; | |
| } | |
| } | |
| } | |
| function matchVersion(version: string, targets: string[]): boolean { | |
| // Strip semver prefixes like ^, ~, >=, etc | |
| const cleaned = version.replace(/^[\^~>=<\s]+/, ""); | |
| return targets.some((t) => cleaned === t || cleaned.startsWith(t + "-")); | |
| } | |
| // ─── Scanners ──────────────────────────────────────────────────────────────── | |
| async function scanLockfile( | |
| filePath: string, | |
| findings: Finding[], | |
| rel: (p: string) => string | |
| ) { | |
| const content = await readFile(filePath, "utf-8"); | |
| const relPath = rel(filePath); | |
| // Check for compromised axios versions | |
| for (const ver of COMPROMISED_AXIOS_VERSIONS) { | |
| // package-lock.json patterns | |
| const patterns = [ | |
| `"axios": "${ver}"`, | |
| `"version": "${ver}"`, | |
| `axios@${ver}`, | |
| `"axios@npm:${ver}"`, | |
| `axios@^${ver}`, | |
| `axios@~${ver}`, | |
| `/axios/-/axios-${ver}.tgz`, | |
| `"axios": "npm:axios@${ver}"`, | |
| ]; | |
| for (const pat of patterns) { | |
| if (content.includes(pat)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `Lockfile pins compromised axios@${ver}`, | |
| detail: `Found pattern: ${pat}`, | |
| }); | |
| break; | |
| } | |
| } | |
| } | |
| // Check for malicious dependency | |
| for (const pkg of MALICIOUS_PACKAGES) { | |
| if (content.includes(pkg)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `Lockfile references malicious package "${pkg}"`, | |
| detail: | |
| pkg === "plain-crypto-js" | |
| ? "This package is the payload delivery vehicle for the axios compromise. If present, the dropper likely executed." | |
| : `Known compromised package associated with the axios supply chain attack.`, | |
| }); | |
| } | |
| } | |
| // Check for C2 domain references | |
| for (const domain of C2_DOMAINS) { | |
| if (content.includes(domain)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `Lockfile contains C2 domain reference: ${domain}`, | |
| detail: "This is the command-and-control server used by the RAT.", | |
| }); | |
| } | |
| } | |
| } | |
| async function scanPackageJson( | |
| filePath: string, | |
| findings: Finding[], | |
| rel: (p: string) => string | |
| ) { | |
| const content = await readFile(filePath, "utf-8"); | |
| const relPath = rel(filePath); | |
| let pkg: any; | |
| try { | |
| pkg = JSON.parse(content); | |
| } catch { | |
| return; | |
| } | |
| const allDeps = { | |
| ...pkg.dependencies, | |
| ...pkg.devDependencies, | |
| ...pkg.peerDependencies, | |
| ...pkg.optionalDependencies, | |
| }; | |
| // Check axios version | |
| if (allDeps.axios) { | |
| const ver = allDeps.axios; | |
| for (const compromised of COMPROMISED_AXIOS_VERSIONS) { | |
| if (matchVersion(ver, [compromised])) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `package.json specifies compromised axios version: ${ver}`, | |
| detail: `Downgrade immediately to axios@${compromised.startsWith("1.") ? "1.14.0" : "0.30.3"}`, | |
| }); | |
| } | |
| } | |
| // Info: report any axios usage for awareness | |
| if ( | |
| !COMPROMISED_AXIOS_VERSIONS.some((v) => matchVersion(ver, [v])) | |
| ) { | |
| findings.push({ | |
| severity: "info", | |
| file: relPath, | |
| message: `Uses axios@${ver}`, | |
| detail: `Verify this resolves to a safe version (1.14.0 or earlier, 0.30.3 or earlier). Ranges like ^1.x could have resolved to 1.14.1 during the attack window.`, | |
| }); | |
| } | |
| } | |
| // Check for malicious packages | |
| for (const malPkg of MALICIOUS_PACKAGES) { | |
| if (allDeps[malPkg]) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `package.json directly depends on malicious package: ${malPkg}`, | |
| }); | |
| } | |
| } | |
| // Check overrides / resolutions (people might have pinned a bad version) | |
| const overrides = { | |
| ...pkg.overrides, | |
| ...pkg.resolutions, | |
| }; | |
| if (overrides?.axios) { | |
| for (const compromised of COMPROMISED_AXIOS_VERSIONS) { | |
| if (matchVersion(overrides.axios, [compromised])) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `overrides/resolutions pins axios to compromised version: ${overrides.axios}`, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| async function scanNodeModules( | |
| rootDir: string, | |
| findings: Finding[], | |
| rel: (p: string) => string | |
| ): Promise<number> { | |
| let checked = 0; | |
| // Recursively find all node_modules directories | |
| async function findNodeModules(dir: string): Promise<string[]> { | |
| const results: string[] = []; | |
| let entries; | |
| try { | |
| entries = await readdir(dir, { withFileTypes: true }); | |
| } catch { | |
| return results; | |
| } | |
| for (const entry of entries) { | |
| if (!entry.isDirectory()) continue; | |
| if (entry.name === ".git") continue; | |
| const full = join(dir, entry.name); | |
| if (entry.name === "node_modules") { | |
| results.push(full); | |
| } | |
| // Also check nested (hoisted) node_modules | |
| if (entry.name !== "node_modules") { | |
| results.push(...(await findNodeModules(full))); | |
| } | |
| } | |
| return results; | |
| } | |
| const nmDirs = await findNodeModules(rootDir); | |
| for (const nmDir of nmDirs) { | |
| checked++; | |
| // 1. Check if plain-crypto-js directory exists (smoking gun) | |
| const plainCryptoDir = join(nmDir, "plain-crypto-js"); | |
| if (await exists(plainCryptoDir)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(plainCryptoDir), | |
| message: | |
| "plain-crypto-js found in node_modules — THE DROPPER LIKELY EXECUTED", | |
| detail: | |
| "This package is NEVER a legitimate dependency of axios. Its presence means the malicious postinstall hook ran. Rotate ALL credentials immediately.", | |
| }); | |
| // Check the setup.js | |
| const setupJs = join(plainCryptoDir, "setup.js"); | |
| if (await exists(setupJs)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(setupJs), | |
| message: "Malicious setup.js dropper found", | |
| detail: | |
| "This is the obfuscated Node.js dropper that downloads and executes the RAT binary.", | |
| }); | |
| } | |
| } | |
| // 2. Check for @shadanai/openclaw | |
| const openclawDir = join(nmDir, "@shadanai", "openclaw"); | |
| if (await exists(openclawDir)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(openclawDir), | |
| message: "@shadanai/openclaw found — known to vendor the malicious payload", | |
| }); | |
| } | |
| // 3. Check for @qqbrowser/openclaw-qbot | |
| const qbotDir = join(nmDir, "@qqbrowser", "openclaw-qbot"); | |
| if (await exists(qbotDir)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(qbotDir), | |
| message: | |
| "@qqbrowser/openclaw-qbot found — ships tampered axios with plain-crypto-js", | |
| }); | |
| } | |
| // 4. Check installed axios version | |
| const axiosPkg = join(nmDir, "axios", "package.json"); | |
| if (await exists(axiosPkg)) { | |
| try { | |
| const axiosData = JSON.parse(await readFile(axiosPkg, "utf-8")); | |
| const installedVer = axiosData.version; | |
| if (COMPROMISED_AXIOS_VERSIONS.includes(installedVer)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(axiosPkg), | |
| message: `Installed axios version is ${installedVer} — COMPROMISED`, | |
| detail: `This version contains the malicious plain-crypto-js dependency. Downgrade immediately.`, | |
| }); | |
| } else { | |
| findings.push({ | |
| severity: "info", | |
| file: rel(axiosPkg), | |
| message: `Installed axios version: ${installedVer}`, | |
| }); | |
| } | |
| // Check if installed axios has plain-crypto-js in its deps (tampered) | |
| const axiosDeps = axiosData.dependencies || {}; | |
| if (axiosDeps["plain-crypto-js"]) { | |
| findings.push({ | |
| severity: "critical", | |
| file: rel(axiosPkg), | |
| message: | |
| "Installed axios has plain-crypto-js as a dependency — TAMPERED PACKAGE", | |
| detail: | |
| "Legitimate axios only depends on follow-redirects, form-data, and proxy-from-env. This is unambiguous tampering.", | |
| }); | |
| } | |
| } catch { } | |
| } | |
| } | |
| return checked; | |
| } | |
| // ─── Bun-specific: Check bun.lockb ────────────────────────────────────────── | |
| async function scanBunLockb( | |
| filePath: string, | |
| findings: Finding[], | |
| rel: (p: string) => string | |
| ) { | |
| // bun.lockb is a binary file — check for a text bun.lock first, then string-search the binary | |
| const relPath = rel(filePath); | |
| try { | |
| // Prefer the text lockfile if it exists alongside bun.lockb | |
| const textLockPath = filePath.replace("bun.lockb", "bun.lock"); | |
| if (await exists(textLockPath)) { | |
| await scanLockfile(textLockPath, findings, rel); | |
| return; | |
| } | |
| // For binary lockfile, do a raw string search | |
| const buf = await readFile(filePath); | |
| const asText = new TextDecoder("utf-8", { fatal: false }).decode(buf); | |
| for (const ver of COMPROMISED_AXIOS_VERSIONS) { | |
| if (asText.includes(`axios@${ver}`) || asText.includes(`axios-${ver}`)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `bun.lockb references compromised axios@${ver}`, | |
| }); | |
| } | |
| } | |
| for (const pkg of MALICIOUS_PACKAGES) { | |
| if (asText.includes(pkg)) { | |
| findings.push({ | |
| severity: "critical", | |
| file: relPath, | |
| message: `bun.lockb references malicious package: ${pkg}`, | |
| }); | |
| } | |
| } | |
| } catch { | |
| findings.push({ | |
| severity: "info", | |
| file: relPath, | |
| message: | |
| "Could not fully parse bun.lockb — check manually or use `bun install --yarn` to generate a text lockfile", | |
| }); | |
| } | |
| } | |
| // ─── Main Scanner (per-repo, quiet) ────────────────────────────────────────── | |
| async function scan(rootDir: string): Promise<ScanResult> { | |
| const findings: Finding[] = []; | |
| const stats = { | |
| filesScanned: 0, | |
| lockfilesChecked: 0, | |
| nodeModulesChecked: 0, | |
| packageJsonsChecked: 0, | |
| }; | |
| const rel = (p: string) => relative(rootDir, p) || "."; | |
| // Phase 1: Scan lockfiles and package.json files | |
| for await (const filePath of walk(rootDir)) { | |
| stats.filesScanned++; | |
| const name = filePath.split("/").pop() || ""; | |
| if ( | |
| name === "package-lock.json" || | |
| name === "yarn.lock" || | |
| name === "pnpm-lock.yaml" || | |
| name === "bun.lock" | |
| ) { | |
| stats.lockfilesChecked++; | |
| await scanLockfile(filePath, findings, rel); | |
| } | |
| if (name === "bun.lockb") { | |
| stats.lockfilesChecked++; | |
| await scanBunLockb(filePath, findings, rel); | |
| } | |
| if (name === "package.json" && !filePath.includes("node_modules")) { | |
| stats.packageJsonsChecked++; | |
| await scanPackageJson(filePath, findings, rel); | |
| } | |
| } | |
| // Phase 2: Scan node_modules | |
| stats.nodeModulesChecked = await scanNodeModules(rootDir, findings, rel); | |
| // Derive axios info from findings | |
| const axiosVersions: string[] = []; | |
| let usesAxios = false; | |
| for (const f of findings) { | |
| const installedMatch = f.message.match(/Installed axios version: (.+)/); | |
| const usesMatch = f.message.match(/Uses axios@(.+)/); | |
| const compromisedMatch = f.message.match(/specifies compromised axios version: (.+)/); | |
| if (installedMatch) { | |
| axiosVersions.push(installedMatch[1]); | |
| usesAxios = true; | |
| } | |
| if (usesMatch) { | |
| if (!axiosVersions.includes(usesMatch[1])) axiosVersions.push(usesMatch[1]); | |
| usesAxios = true; | |
| } | |
| if (compromisedMatch) { | |
| if (!axiosVersions.includes(compromisedMatch[1])) axiosVersions.push(compromisedMatch[1]); | |
| usesAxios = true; | |
| } | |
| } | |
| // Get repo name from package.json | |
| let repoName = rootDir.split("/").pop() || rootDir; | |
| try { | |
| const rootPkg = JSON.parse(await readFile(join(rootDir, "package.json"), "utf-8")); | |
| if (rootPkg.name) repoName = rootPkg.name; | |
| } catch { } | |
| return { repoName, repoPath: rootDir, findings, stats, usesAxios, axiosVersions }; | |
| } | |
| // ─── Repo Discovery ────────────────────────────────────────────────────────── | |
| async function discoverRepos(parentDir: string): Promise<string[]> { | |
| const repos: string[] = []; | |
| let entries; | |
| try { | |
| entries = await readdir(parentDir, { withFileTypes: true }); | |
| } catch { | |
| return repos; | |
| } | |
| for (const entry of entries) { | |
| if (!entry.isDirectory()) continue; | |
| if (entry.name.startsWith(".")) continue; // skip .git, .cache, etc. | |
| if (entry.name === "node_modules") continue; | |
| const dirPath = join(parentDir, entry.name); | |
| // A "repo" is any directory with a package.json at its root | |
| if (await exists(join(dirPath, "package.json"))) { | |
| repos.push(dirPath); | |
| } | |
| } | |
| return repos.sort(); | |
| } | |
| // ─── Output ────────────────────────────────────────────────────────────────── | |
| function severityIcon(s: string): string { | |
| if (s === "critical") return `${RED}✗${RESET}`; | |
| if (s === "warning") return `${YELLOW}⚠${RESET}`; | |
| return `${GREEN}✓${RESET}`; | |
| } | |
| function statusLabel(result: ScanResult): string { | |
| const crits = result.findings.filter((f) => f.severity === "critical").length; | |
| if (crits > 0) return `${RED}${BOLD}COMPROMISED (${crits})${RESET}`; | |
| if (!result.usesAxios) return `${DIM}no axios${RESET}`; | |
| return `${GREEN}${BOLD}clean${RESET}`; | |
| } | |
| function printRepoDetails(result: ScanResult, parentDir: string) { | |
| const relPath = relative(parentDir, result.repoPath) || "."; | |
| const criticals = result.findings.filter((f) => f.severity === "critical"); | |
| const warnings = result.findings.filter((f) => f.severity === "warning"); | |
| const infos = result.findings.filter((f) => f.severity === "info"); | |
| const icon = criticals.length > 0 ? `${RED}✗${RESET}` : result.usesAxios ? `${GREEN}✓${RESET}` : `${DIM}─${RESET}`; | |
| console.log(`\n ${icon} ${BOLD}${result.repoName}${RESET} ${DIM}(${relPath})${RESET}`); | |
| if (!result.usesAxios && criticals.length === 0) { | |
| console.log(` ${DIM}Does not use axios — skipped${RESET}`); | |
| return; | |
| } | |
| if (result.axiosVersions.length > 0) { | |
| console.log(` ${DIM}axios versions: ${result.axiosVersions.join(", ")}${RESET}`); | |
| } | |
| if (criticals.length > 0) { | |
| for (const f of criticals) { | |
| console.log(` ${CRITICAL} ${f.message}`); | |
| console.log(` ${DIM} ${f.file}${RESET}`); | |
| if (f.detail) console.log(` ${DIM} ${f.detail}${RESET}`); | |
| } | |
| } | |
| if (warnings.length > 0) { | |
| for (const f of warnings) { | |
| console.log(` ${WARNING} ${f.message}`); | |
| console.log(` ${DIM} ${f.file}${RESET}`); | |
| } | |
| } | |
| // Only show info findings if there are no criticals (keep it concise) | |
| if (criticals.length === 0 && infos.length > 0) { | |
| for (const f of infos) { | |
| console.log(` ${INFO} ${f.message}`); | |
| if (f.detail) console.log(` ${DIM} ${f.detail}${RESET}`); | |
| } | |
| } | |
| } | |
| function printSummaryTable(results: ScanResult[], parentDir: string) { | |
| const totalRepos = results.length; | |
| const reposWithAxios = results.filter((r) => r.usesAxios); | |
| const reposCompromised = results.filter( | |
| (r) => r.findings.some((f) => f.severity === "critical") | |
| ); | |
| const reposClean = reposWithAxios.filter( | |
| (r) => !r.findings.some((f) => f.severity === "critical") | |
| ); | |
| const reposWithoutAxios = results.filter((r) => !r.usesAxios); | |
| // ─── Summary table ─── | |
| console.log(`\n${BOLD}─── Summary Table ──────────────────────────────────────────${RESET}\n`); | |
| // Column widths | |
| const nameWidth = Math.max(20, ...results.map((r) => r.repoName.length + 2)); | |
| const header = ` ${"Repo".padEnd(nameWidth)} ${"Status".padEnd(22)} ${"axios version(s)".padEnd(24)} ${"Findings"}`; | |
| console.log(`${BOLD}${header}${RESET}`); | |
| console.log(` ${"─".repeat(nameWidth)} ${"─".repeat(22)} ${"─".repeat(24)} ${"─".repeat(12)}`); | |
| for (const r of results) { | |
| const crits = r.findings.filter((f) => f.severity === "critical").length; | |
| const warns = r.findings.filter((f) => f.severity === "warning").length; | |
| const infs = r.findings.filter((f) => f.severity === "info").length; | |
| let status: string; | |
| if (crits > 0) status = `${RED}COMPROMISED${RESET}`; | |
| else if (!r.usesAxios) status = `${DIM}no axios${RESET}`; | |
| else status = `${GREEN}clean${RESET}`; | |
| const versions = r.axiosVersions.length > 0 ? r.axiosVersions.join(", ") : "—"; | |
| const findingCounts: string[] = []; | |
| if (crits > 0) findingCounts.push(`${RED}${crits}C${RESET}`); | |
| if (warns > 0) findingCounts.push(`${YELLOW}${warns}W${RESET}`); | |
| if (infs > 0) findingCounts.push(`${DIM}${infs}I${RESET}`); | |
| const findingsStr = findingCounts.length > 0 ? findingCounts.join(" ") : `${DIM}—${RESET}`; | |
| // We need to account for ANSI codes in padding — use raw length for display | |
| const rawStatus = status.replace(/\x1b\[[0-9;]*m/g, ""); | |
| const rawVersions = versions.replace(/\x1b\[[0-9;]*m/g, ""); | |
| const statusPad = " ".repeat(Math.max(0, 22 - rawStatus.length)); | |
| const versionPad = " ".repeat(Math.max(0, 24 - rawVersions.length)); | |
| console.log(` ${r.repoName.padEnd(nameWidth)} ${status}${statusPad} ${versions}${versionPad} ${findingsStr}`); | |
| } | |
| // ─── Totals ─── | |
| console.log(`\n${BOLD}─── Totals ─────────────────────────────────────────────────${RESET}\n`); | |
| console.log(` Repos scanned: ${totalRepos}`); | |
| console.log(` Uses axios: ${reposWithAxios.length}`); | |
| if (reposCompromised.length > 0) { | |
| console.log(` ${RED}${BOLD}Compromised: ${reposCompromised.length}${RESET}`); | |
| } else { | |
| console.log(` Compromised: 0`); | |
| } | |
| console.log(` Clean (with axios):${reposClean.length > 0 ? " " + reposClean.length : " 0"}`); | |
| console.log(` No axios: ${reposWithoutAxios.length}`); | |
| const totalFiles = results.reduce((s, r) => s + r.stats.filesScanned, 0); | |
| const totalLockfiles = results.reduce((s, r) => s + r.stats.lockfilesChecked, 0); | |
| const totalPkgJsons = results.reduce((s, r) => s + r.stats.packageJsonsChecked, 0); | |
| const totalNodeMods = results.reduce((s, r) => s + r.stats.nodeModulesChecked, 0); | |
| console.log(); | |
| console.log(` ${DIM}Total files walked: ${totalFiles}${RESET}`); | |
| console.log(` ${DIM}Total lockfiles checked: ${totalLockfiles}${RESET}`); | |
| console.log(` ${DIM}Total package.json checked: ${totalPkgJsons}${RESET}`); | |
| console.log(` ${DIM}Total node_modules checked: ${totalNodeMods}${RESET}`); | |
| // ─── Remediation (only if compromised) ─── | |
| if (reposCompromised.length > 0) { | |
| console.log(`\n${RED}${BOLD}═══════════════════════════════════════════════════════════${RESET}`); | |
| console.log(`${RED}${BOLD} 🚨 COMPROMISE DETECTED — IMMEDIATE ACTION REQUIRED${RESET}`); | |
| console.log(`${RED}${BOLD}═══════════════════════════════════════════════════════════${RESET}`); | |
| console.log(); | |
| console.log(` Affected repos:`); | |
| for (const r of reposCompromised) { | |
| const relPath = relative(parentDir, r.repoPath); | |
| console.log(` ${RED}✗${RESET} ${r.repoName} ${DIM}(${relPath})${RESET}`); | |
| } | |
| console.log(); | |
| console.log(` ${BOLD}Remediation steps:${RESET}`); | |
| console.log(` 1. Downgrade axios to 1.14.0 (or 0.30.3 for legacy)`); | |
| console.log(` 2. Delete node_modules/plain-crypto-js/ directories`); | |
| console.log(` 3. Clean install: rm -rf node_modules && bun install`); | |
| console.log(` 4. ${RED}${BOLD}ROTATE ALL SECRETS${RESET} — SSH keys, cloud tokens, API keys, .env files`); | |
| console.log(` 5. Audit CI/CD pipeline secrets and deployment credentials`); | |
| console.log(` 6. Check for connections to sfrclak.com (C2 server)`); | |
| console.log(` 7. Review for persistence mechanisms (cron jobs, launch agents)`); | |
| } else { | |
| console.log(`\n ${GREEN}${BOLD}✓ No indicators of compromise across any repo.${RESET}`); | |
| console.log(` ${DIM} If you ran \`npm/bun install\` between 00:21–03:15 UTC on March 31 2026,${RESET}`); | |
| console.log(` ${DIM} consider auditing CI/CD logs and rotating secrets as a precaution.${RESET}`); | |
| } | |
| console.log( | |
| `\n${DIM} Ref: https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan${RESET}` | |
| ); | |
| console.log( | |
| `${DIM} Ref: https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all${RESET}\n` | |
| ); | |
| } | |
| // ─── Entry ─────────────────────────────────────────────────────────────────── | |
| async function main() { | |
| const targetDir = process.argv[2] || process.cwd(); | |
| try { | |
| const s = await stat(targetDir); | |
| if (!s.isDirectory()) { | |
| console.error(`${RED}Error: ${targetDir} is not a directory${RESET}`); | |
| process.exit(2); | |
| } | |
| } catch { | |
| console.error(`${RED}Error: ${targetDir} does not exist${RESET}`); | |
| process.exit(2); | |
| } | |
| console.log(`\n${BOLD}═══════════════════════════════════════════════════════════${RESET}`); | |
| console.log(`${BOLD} axios Supply Chain Compromise Scanner${RESET}`); | |
| console.log(`${DIM} Incident: March 30-31, 2026 | Multi-repo mode${RESET}`); | |
| console.log(`${BOLD}═══════════════════════════════════════════════════════════${RESET}`); | |
| // Discover repos | |
| console.log(`\n${CYAN}Discovering repos in:${RESET} ${targetDir}\n`); | |
| const repoPaths = await discoverRepos(targetDir); | |
| if (repoPaths.length === 0) { | |
| console.log(` ${YELLOW}No repos found.${RESET} Looking for directories containing a package.json.`); | |
| console.log(` ${DIM}Make sure you're pointing at a parent folder that contains your repos.${RESET}\n`); | |
| process.exit(0); | |
| } | |
| console.log(` Found ${BOLD}${repoPaths.length}${RESET} repo(s):\n`); | |
| for (const p of repoPaths) { | |
| console.log(` ${DIM}•${RESET} ${relative(targetDir, p)}`); | |
| } | |
| // Scan each repo | |
| console.log(`\n${BOLD}─── Scanning ───────────────────────────────────────────────${RESET}`); | |
| const results: ScanResult[] = []; | |
| for (const repoPath of repoPaths) { | |
| const relPath = relative(targetDir, repoPath); | |
| process.stdout.write(` ${DIM}Scanning ${relPath}...${RESET}`); | |
| const result = await scan(repoPath); | |
| results.push(result); | |
| const crits = result.findings.filter((f) => f.severity === "critical").length; | |
| if (crits > 0) { | |
| process.stdout.write(` ${RED}${crits} critical${RESET}\n`); | |
| } else if (result.usesAxios) { | |
| process.stdout.write(` ${GREEN}clean${RESET}\n`); | |
| } else { | |
| process.stdout.write(` ${DIM}no axios${RESET}\n`); | |
| } | |
| } | |
| // Detailed findings for repos with issues | |
| const reposWithFindings = results.filter( | |
| (r) => r.findings.some((f) => f.severity === "critical" || f.severity === "warning") || r.usesAxios | |
| ); | |
| if (reposWithFindings.length > 0) { | |
| console.log(`\n${BOLD}─── Detailed Findings ──────────────────────────────────────${RESET}`); | |
| for (const r of reposWithFindings) { | |
| printRepoDetails(r, targetDir); | |
| } | |
| } | |
| // Summary table | |
| printSummaryTable(results, targetDir); | |
| // Exit code | |
| const anyCompromised = results.some((r) => | |
| r.findings.some((f) => f.severity === "critical") | |
| ); | |
| process.exit(anyCompromised ? 1 : 0); | |
| } | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment