Last active
March 31, 2026 21:04
-
-
Save Livog/fd27719cf5ef711b72d7f1464a8694ec to your computer and use it in GitHub Desktop.
Axios supply chain attack scanner (March 31, 2026) — detects compromised axios@1.14.1/0.30.4, RAT artifacts, C2 connections. Supports npm, yarn, pnpm, bun, deno lockfiles + system-wide scan. Based on theNetworkChuck/axios-attack-guide with community PR improvements.
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 | |
| // Compile: bun build --compile scanner.ts --outfile axios-scan | |
| import { existsSync } from "fs"; | |
| import { basename, dirname, relative } from "path"; | |
| import { homedir, platform } from "os"; | |
| // --- constants --- | |
| const COMPROMISED = new Set(["1.14.1", "0.30.4"]); | |
| const C2 = { ip: "142.11.206.73", domain: "sfrclak.com" }; | |
| const DEP_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const; | |
| // --- colors --- | |
| const red = (s: string) => `\x1b[31m${s}\x1b[0m`; | |
| const green = (s: string) => `\x1b[32m${s}\x1b[0m`; | |
| const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; | |
| const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; | |
| const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; | |
| const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; | |
| // --- types --- | |
| type ScanResult = { | |
| dir: string; | |
| lockfile: string; | |
| version: string | null; | |
| compromised: boolean; | |
| plainCryptoJs: boolean; | |
| }; | |
| type Fixable = { | |
| dir: string; | |
| packageJson: string; | |
| section: string; | |
| specifier: string; | |
| lockVersion: string; | |
| checked: boolean; | |
| }; | |
| // --- scanning --- | |
| async function findLockfiles(root: string): Promise<string[]> { | |
| const proc = Bun.spawn( | |
| [ | |
| "find", root, "-maxdepth", "8", | |
| "(", "-name", "node_modules", "-o", "-name", ".git", "-o", "-name", ".cache", | |
| "-o", "-name", ".Trash", "-o", "-name", ".npm", "-o", "-name", ".bun", | |
| "-o", "-name", ".pnpm-store", ")", "-prune", | |
| "-o", "(", "-name", "package-lock.json", "-o", "-name", "yarn.lock", | |
| "-o", "-name", "pnpm-lock.yaml", "-o", "-name", "bun.lock", | |
| "-o", "-name", "bun.lockb", "-o", "-name", "deno.lock", ")", "-print", | |
| ], | |
| { stdout: "pipe", stderr: "ignore" }, | |
| ); | |
| const text = await new Response(proc.stdout).text(); | |
| return text.trim().split("\n").filter(Boolean); | |
| } | |
| function extractVersion(name: string, content: string): string | null { | |
| switch (name) { | |
| case "package-lock.json": { | |
| try { | |
| const d = JSON.parse(content); | |
| return d.packages?.["node_modules/axios"]?.version ?? d.dependencies?.axios?.version ?? null; | |
| } catch { return null; } | |
| } | |
| case "yarn.lock": { | |
| const m = content.match(/^"?axios@[^\n]*\n\s+version "(\d+\.\d+\.\d+)"/m); | |
| return m?.[1] ?? null; | |
| } | |
| case "pnpm-lock.yaml": { | |
| const m = content.match(/axios[@/](\d+\.\d+\.\d+)/); | |
| return m?.[1] ?? null; | |
| } | |
| case "bun.lock": { | |
| const m = content.match(/axios@(\d+\.\d+\.\d+)/); | |
| return m?.[1] ?? null; | |
| } | |
| case "deno.lock": { | |
| try { | |
| const d = JSON.parse(content); | |
| const key = Object.keys(d.npm?.packages ?? {}).find(k => /^axios@/.test(k)); | |
| return key?.match(/@(\d+\.\d+\.\d+)/)?.[1] ?? null; | |
| } catch { return null; } | |
| } | |
| default: return null; | |
| } | |
| } | |
| async function analyze(path: string): Promise<ScanResult | null> { | |
| const name = basename(path); | |
| const dir = dirname(path); | |
| let content: string; | |
| if (name === "bun.lockb") { | |
| try { | |
| const proc = Bun.spawn(["bun", path], { stdout: "pipe", stderr: "ignore" }); | |
| content = await new Response(proc.stdout).text(); | |
| } catch { return null; } | |
| if (!content.includes("axios")) return null; | |
| const m = content.match(/^"?axios@[^\n]*\n\s+version "(\d+\.\d+\.\d+)"/m); | |
| const version = m?.[1] ?? null; | |
| if (!version) return null; | |
| return { dir, lockfile: name, version, compromised: COMPROMISED.has(version), plainCryptoJs: content.includes("plain-crypto-js") }; | |
| } | |
| try { content = await Bun.file(path).text(); } catch { return null; } | |
| if (!content.includes("axios")) return null; | |
| const version = extractVersion(name, content); | |
| if (!version) return null; | |
| return { dir, lockfile: name, version, compromised: COMPROMISED.has(version), plainCryptoJs: content.includes("plain-crypto-js") }; | |
| } | |
| async function checkHost(): Promise<boolean> { | |
| let found = false; | |
| const os = platform(); | |
| const artifacts: [string, string][] = os === "darwin" | |
| ? [["/Library/Caches/com.apple.act.mond", "macOS RAT"], [`${process.env.TMPDIR || "/tmp"}/6202033`, "staging file"]] | |
| : [["/tmp/ld.py", "Linux RAT"], ["/tmp/6202033", "staging file"]]; | |
| for (const [p, label] of artifacts) { | |
| if (existsSync(p)) { console.log(red(` !! ${label} found at ${p}`)); found = true; } | |
| else console.log(` ${green("OK")}: No ${label} (${dim(p)})`); | |
| } | |
| const profiles = [".bashrc", ".zshrc", ".bash_profile", ".profile", ".zprofile"]; | |
| let clean = true; | |
| for (const p of profiles) { | |
| try { | |
| const c = await Bun.file(`${homedir()}/${p}`).text(); | |
| if (/sfrclak|142\.11\.206\.73|com\.apple\.act\.mond|6202033/.test(c)) { | |
| console.log(red(` !! Suspicious entry in ~/${p}`)); found = true; clean = false; | |
| } | |
| } catch {} | |
| } | |
| if (clean) console.log(` ${green("OK")}: Shell profiles clean`); | |
| process.stdout.write(dim(" Checking network connections (this can take a moment)...")); | |
| try { | |
| const proc = Bun.spawn(["lsof", "-i", "-nP"], { stdout: "pipe", stderr: "ignore" }); | |
| const out = await new Response(proc.stdout).text(); | |
| process.stdout.write("\x1b[2K\r"); // clear the "checking" line | |
| if (out.includes(C2.ip)) { console.log(red(` !! Active C2 connection to ${C2.ip}`)); found = true; } | |
| else console.log(` ${green("OK")}: No C2 connections (${dim(C2.ip)})`); | |
| } catch { | |
| process.stdout.write("\x1b[2K\r"); | |
| console.log(dim(" SKIP: Could not check network connections")); | |
| } | |
| return found; | |
| } | |
| // --- fix analysis --- | |
| function findFixable(results: ScanResult[]): Fixable[] { | |
| // Get highest lockfile version per dir | |
| const versionByDir = new Map<string, string>(); | |
| for (const r of results) { | |
| if (!r.version) continue; | |
| const current = versionByDir.get(r.dir); | |
| if (!current || r.version.localeCompare(current, undefined, { numeric: true }) > 0) { | |
| versionByDir.set(r.dir, r.version); | |
| } | |
| } | |
| const fixable: Fixable[] = []; | |
| const seen = new Set<string>(); | |
| for (const [dir, lockVersion] of versionByDir) { | |
| if (seen.has(dir)) continue; | |
| seen.add(dir); | |
| const pkgPath = `${dir}/package.json`; | |
| let pkg: Record<string, any>; | |
| try { pkg = JSON.parse(Bun.file(pkgPath).textSync()); } catch { continue; } | |
| for (const section of DEP_SECTIONS) { | |
| const spec = pkg[section]?.axios; | |
| if (typeof spec === "string" && /^[\^~]/.test(spec)) { | |
| fixable.push({ dir, packageJson: pkgPath, section, specifier: spec, lockVersion, checked: true }); | |
| } | |
| } | |
| } | |
| return fixable; | |
| } | |
| // --- interactive select --- | |
| async function multiSelect(items: Fixable[], root: string): Promise<Fixable[]> { | |
| if (!items.length) return []; | |
| const { stdin, stdout } = process; | |
| if (!stdin.setRawMode) return items; // non-interactive, return all | |
| let cursor = 0; | |
| let firstRender = true; | |
| const lines = () => items.length + 2; | |
| const render = () => { | |
| stdout.write("\x1b[?25l"); // hide cursor | |
| if (!firstRender) stdout.write(`\x1b[${lines()}A`); | |
| firstRender = false; | |
| for (let i = 0; i < items.length; i++) { | |
| const arrow = i === cursor ? bold(">") : " "; | |
| const check = items[i].checked ? green("●") : dim("○"); | |
| const rel = relative(root, items[i].dir) || "."; | |
| const change = `${dim(items[i].specifier)} ${dim("→")} ${bold(items[i].lockVersion)}`; | |
| stdout.write(`\x1b[2K ${arrow} ${check} ${rel} ${change}\n`); | |
| } | |
| stdout.write(`\x1b[2K\n`); | |
| stdout.write(`\x1b[2K ${dim("↑↓ move space toggle a all n none enter apply q quit")}\n`); | |
| }; | |
| return new Promise((resolve) => { | |
| stdin.setRawMode(true); | |
| stdin.resume(); | |
| stdin.setEncoding("utf8"); | |
| render(); | |
| const onData = (key: string) => { | |
| if (key === "\x1b[A") cursor = Math.max(0, cursor - 1); | |
| else if (key === "\x1b[B") cursor = Math.min(items.length - 1, cursor + 1); | |
| else if (key === " ") items[cursor].checked = !items[cursor].checked; | |
| else if (key === "a") items.forEach(i => (i.checked = true)); | |
| else if (key === "n") items.forEach(i => (i.checked = false)); | |
| else if (key === "\r") { cleanup(); resolve(items.filter(i => i.checked)); return; } | |
| else if (key === "q" || key === "\x03") { cleanup(); resolve([]); return; } | |
| render(); | |
| }; | |
| const cleanup = () => { | |
| stdin.removeListener("data", onData); | |
| stdin.setRawMode(false); | |
| stdin.pause(); | |
| stdout.write("\x1b[?25h"); // show cursor | |
| }; | |
| stdin.on("data", onData); | |
| }); | |
| } | |
| // --- apply fixes --- | |
| async function applyFixes(items: Fixable[]): Promise<void> { | |
| for (const item of items) { | |
| const content = await Bun.file(item.packageJson).text(); | |
| // Replace the specifier with the exact lockfile version, preserving formatting | |
| const pattern = new RegExp(`("axios"\\s*:\\s*")${item.specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`); | |
| const fixed = content.replace(pattern, `$1${item.lockVersion}"`); | |
| if (fixed === content) { | |
| console.log(yellow(` SKIP: ${item.packageJson} — specifier not found`)); | |
| continue; | |
| } | |
| await Bun.write(item.packageJson, fixed); | |
| const rel = relative(root, item.packageJson); | |
| console.log(` ${green("OK")} ${rel}: ${red(item.specifier)} → ${green(item.lockVersion)}`); | |
| } | |
| } | |
| // --- main --- | |
| const root = process.argv[2] || homedir(); | |
| const skipFix = process.argv.includes("--scan-only"); | |
| console.log(` | |
| ${cyan("============================================")} | |
| ${cyan(" Axios Supply Chain Attack Scanner")} | |
| ${cyan("============================================")} | |
| ${dim(" Mar 31 2026: axios@1.14.1 and @0.30.4 were")} | |
| ${dim(" backdoored with a RAT via plain-crypto-js.")} | |
| ${dim(" Safe versions: 1.14.0, 0.30.3")} | |
| `); | |
| // Step 1: Find lockfiles | |
| console.log(yellow("[1/3] Scanning for lockfiles...")); | |
| console.log(dim(` Root: ${root}`)); | |
| process.stdout.write(dim(" Searching...")); | |
| const lockfiles = await findLockfiles(root); | |
| process.stdout.write(`\x1b[2K\r ${bold(String(lockfiles.length))} lockfile(s) found\n\n`); | |
| // Step 2: Analyze each lockfile for axios | |
| console.log(yellow("[2/3] Analyzing lockfiles for axios...")); | |
| let analyzed = 0; | |
| const analyzeWithProgress = async (path: string) => { | |
| const result = await analyze(path); | |
| analyzed++; | |
| if (analyzed % 25 === 0 || analyzed === lockfiles.length) { | |
| process.stdout.write(`\x1b[2K\r ${dim(`${analyzed}/${lockfiles.length} checked...`)}`); | |
| } | |
| return result; | |
| }; | |
| const results = (await Promise.all(lockfiles.map(analyzeWithProgress))).filter((r): r is ScanResult => r !== null); | |
| process.stdout.write(`\x1b[2K\r ${bold(String(results.length))} project(s) with axios found\n\n`); | |
| // Step 3: Host-level checks | |
| console.log(yellow("[3/3] Host checks (RAT, C2, shell profiles)...")); | |
| const hostHit = await checkHost(); | |
| // Group by dir | |
| const byDir = new Map<string, ScanResult[]>(); | |
| for (const r of results) { | |
| const arr = byDir.get(r.dir) ?? []; | |
| arr.push(r); | |
| byDir.set(r.dir, arr); | |
| } | |
| const compromisedProjects = [...byDir.entries()].filter(([, rs]) => rs.some(r => r.compromised || r.plainCryptoJs)); | |
| const safeProjects = [...byDir.entries()].filter(([, rs]) => !rs.some(r => r.compromised || r.plainCryptoJs)); | |
| // Summary | |
| console.log(` | |
| ${cyan("============================================")} | |
| Projects with axios: ${bold(String(byDir.size))} | |
| Compromised: ${compromisedProjects.length ? red(bold(String(compromisedProjects.length))) : bold("0")} | |
| Safe: ${bold(String(safeProjects.length))} | |
| `); | |
| if (compromisedProjects.length) { | |
| console.log(red(" COMPROMISED:")); | |
| for (const [dir, rs] of compromisedProjects) { | |
| const info = rs.map(r => `${r.lockfile}: ${r.version}`).join(", "); | |
| console.log(red(` !! ${dir} (${info})`)); | |
| } | |
| console.log(); | |
| } | |
| if (safeProjects.length) { | |
| console.log(green(" SAFE:")); | |
| for (const [dir, rs] of safeProjects) { | |
| const info = rs.map(r => `${r.lockfile}: ${dim(r.version ?? "?")}`).join(", "); | |
| console.log(` ${dir} ${dim(`(${info})`)}`); | |
| } | |
| } | |
| console.log(cyan("\n============================================")); | |
| if (compromisedProjects.length || hostHit) { | |
| console.log(red(bold("\n !! POTENTIAL COMPROMISE DETECTED\n"))); | |
| console.log(" 1. Pin: npm install axios@1.14.0 --save-exact"); | |
| console.log(" 2. Clean: rm -rf node_modules && npm ci"); | |
| console.log(" 3. Rotate ALL credentials"); | |
| console.log(` 4. Block ${C2.domain} + ${C2.ip}`); | |
| console.log(" 5. If RAT found: FULL SYSTEM REBUILD\n"); | |
| } | |
| if (skipFix) process.exit(compromisedProjects.length || hostHit ? 1 : 0); | |
| // --- interactive fix --- | |
| const fixable = findFixable(results); | |
| if (!fixable.length) { | |
| console.log(dim("\n All axios versions are already pinned (no ^ or ~). Nothing to fix.\n")); | |
| process.exit(0); | |
| } | |
| console.log(`\n${yellow(` Found ${bold(String(fixable.length))} project(s) with unpinned axios (^ or ~).`)}`); | |
| console.log(dim(" These could pull a compromised version on next update.\n")); | |
| console.log(bold(" Select projects to pin:\n")); | |
| const selected = await multiSelect(fixable, root); | |
| if (!selected.length) { | |
| console.log(dim("\n No projects selected. Exiting.\n")); | |
| process.exit(0); | |
| } | |
| console.log(`\n${yellow(" Pinning versions...\n")}`); | |
| await applyFixes(selected); | |
| console.log(green(`\n Done. ${selected.length} package.json file(s) updated.`)); | |
| console.log(dim(" Lockfiles are unchanged — run your package manager's install to verify.\n")); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment