Skip to content

Instantly share code, notes, and snippets.

@Livog
Last active March 31, 2026 21:04
Show Gist options
  • Select an option

  • Save Livog/fd27719cf5ef711b72d7f1464a8694ec to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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