Skip to content

Instantly share code, notes, and snippets.

@sitek94
Last active March 31, 2026 08:43
Show Gist options
  • Select an option

  • Save sitek94/3aec3499df0ee4e84bf6de963e7f38bf to your computer and use it in GitHub Desktop.

Select an option

Save sitek94/3aec3499df0ee4e84bf6de963e7f38bf to your computer and use it in GitHub Desktop.
Recursive scanner for Axios supply-chain IOCs across npm/yarn/pnpm/Bun projects and local host artifacts (axios 1.14.1 / 0.30.4, plain-crypto-js 4.2.1).

scan-axios-ioc.mjs

A small recursive scanner to check whether local Node.js repositories may have been affected by the Axios npm supply-chain attack from March 2026.

References

What happened

A compromised Axios maintainer account was used to publish malicious versions of Axios:

  • axios@1.14.1
  • axios@0.30.4

Those versions pulled in:

  • plain-crypto-js@4.2.1

The malicious package used a postinstall script to deploy a cross-platform RAT.

What this script checks

  • Lockfiles across local repos:
    • package-lock.json
    • npm-shrinkwrap.json
    • yarn.lock
    • pnpm-lock.yaml
    • bun.lock
    • bun.lockb
  • Installed package versions in node_modules
  • Presence of plain-crypto-js
  • Known host-level artifacts on macOS, Linux, and Windows

Requirements

  • Node.js 18+
  • bun is optional, but recommended if you want proper inspection of bun.lockb

Run it

curl -fsSL "https://gist.githubusercontent.com/sitek94/3aec3499df0ee4e84bf6de963e7f38bf/raw/scan-axios-ioc.mjs" \
  -o /tmp/scan-axios-ioc.mjs \
  && node /tmp/scan-axios-ioc.mjs ~/code

Replace ~/code with the directory that contains your local repositories.

Example:

curl -fsSL "https://gist.githubusercontent.com/sitek94/3aec3499df0ee4e84bf6de963e7f38bf/raw/scan-axios-ioc.mjs" \
  -o /tmp/scan-axios-ioc.mjs \
  && node /tmp/scan-axios-ioc.mjs ~/Developer

Exit codes

Code Meaning
0 No suspicious indicators found
2 Indicators found — investigate immediately

If something is found

Treat the machine as potentially compromised.

Recommended next steps:

  1. Rotate any secrets that may have been accessible from that machine.
  2. Review CI/CD runs that may have installed the malicious Axios versions.
  3. Rebuild affected environments from a known-good state.
  4. Avoid reinstalling dependencies blindly until the impact is understood.
import fs from "fs";
import path from "path";
import os from "os";
import { spawnSync } from "child_process";
const ROOT = path.resolve(process.argv[2] || process.cwd());
const IOC = {
badAxiosVersions: new Set(["1.14.1", "0.30.4"]),
badPlainCrypto: "plain-crypto-js@4.2.1",
};
const LOCKFILES = new Set([
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lock",
"bun.lockb",
]);
const SKIP_DIRS = new Set([
"node_modules",
".git",
".hg",
".svn",
"dist",
"build",
"out",
".next",
".turbo",
".cache",
]);
function fileExists(p) {
try {
fs.accessSync(p, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
function readTextSafe(p) {
try {
return fs.readFileSync(p, "utf8");
} catch {
return null;
}
}
function findProjects(root) {
const projects = new Map(); // dir -> { lockfiles: [] }
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
let hasLock = false;
for (const e of entries) {
if (e.isFile() && LOCKFILES.has(e.name)) {
hasLock = true;
const rec = projects.get(dir) || { lockfiles: [] };
rec.lockfiles.push(path.join(dir, e.name));
projects.set(dir, rec);
}
}
for (const e of entries) {
if (!e.isDirectory()) continue;
if (SKIP_DIRS.has(e.name)) continue;
stack.push(path.join(dir, e.name));
}
}
return [...projects.entries()].map(([dir, info]) => ({ dir, ...info }));
}
function matchInTextLock(lockPath, txt) {
const findings = [];
const badAxiosRegex = /(?:^|[^\w@-])axios(?:@npm:|@)?\s*[^0-9]*?(1\.14\.1|0\.30\.4)(?:[^\d]|$)/gim;
const plainCryptoRegex = /plain-crypto-js\s*[^0-9]*?4\.2\.1/gim;
let m;
while ((m = badAxiosRegex.exec(txt)) !== null) {
findings.push({
type: "LOCK_IOC",
what: `axios@${m[1]}`,
file: lockPath,
});
}
if (plainCryptoRegex.test(txt)) {
findings.push({
type: "LOCK_IOC",
what: "plain-crypto-js@4.2.1",
file: lockPath,
});
}
// Extra yarn v1/v2/v3 style heuristic:
// If the lock contains an axios key block + a version line equal to a bad version.
for (const v of IOC.badAxiosVersions) {
const yarnBlock = new RegExp(
String.raw`(^|\n)[^\n]*axios[^\n]*\n(?:[^\n]*\n){0,20}?\s*version[: ]\s*"?${v}"?`,
"im"
);
if (yarnBlock.test(txt)) {
findings.push({
type: "LOCK_IOC",
what: `axios@${v} (yarn-style block)`,
file: lockPath,
});
}
}
return findings;
}
function bunRenderLockb(lockbPath) {
// Bun docs: executing bun on bun.lockb prints a human-readable representation. [page:3]
// We try `bun <lockbPath>` and capture stdout.
const r = spawnSync("bun", [lockbPath], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (r.status === 0 && typeof r.stdout === "string" && r.stdout.length) return r.stdout;
return null;
}
function scanInstalledNodeModules(projectDir) {
const findings = [];
const axiosPkg = path.join(projectDir, "node_modules", "axios", "package.json");
if (fileExists(axiosPkg)) {
const txt = readTextSafe(axiosPkg);
if (txt) {
try {
const j = JSON.parse(txt);
const v = j?.version;
if (IOC.badAxiosVersions.has(v)) {
findings.push({
type: "NODE_MODULES_IOC",
what: `axios@${v}`,
file: axiosPkg,
});
}
} catch {}
}
}
const plainCryptoDir = path.join(projectDir, "node_modules", "plain-crypto-js");
if (fileExists(plainCryptoDir)) {
findings.push({
type: "NODE_MODULES_IOC",
what: "plain-crypto-js directory present",
file: plainCryptoDir,
});
}
return findings;
}
function scanHostIOCs() {
const findings = [];
const plat = os.platform();
if (plat === "darwin") {
const p = "/Library/Caches/com.apple.act.mond";
if (fileExists(p)) findings.push({ type: "HOST_IOC", what: p, file: p });
} else if (plat === "linux") {
const p = "/tmp/ld.py";
if (fileExists(p)) findings.push({ type: "HOST_IOC", what: p, file: p });
} else if (plat === "win32") {
const p = path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "wt.exe");
if (fileExists(p)) findings.push({ type: "HOST_IOC", what: p, file: p });
}
return findings;
}
function main() {
const projects = findProjects(ROOT);
const findings = [];
for (const proj of projects) {
for (const lockPath of proj.lockfiles) {
const base = path.basename(lockPath);
if (base === "bun.lockb") {
const rendered = bunRenderLockb(lockPath);
if (rendered) {
findings.push(...matchInTextLock(lockPath + " (rendered via bun)", rendered).map(f => ({...f, project: proj.dir})));
} else {
// If bun isn't available, we can't reliably parse bun.lockb; fall back to node_modules check.
findings.push({
type: "INFO",
what: "bun.lockb found but could not render (bun not available?) — relying on node_modules check",
file: lockPath,
project: proj.dir,
});
}
continue;
}
const txt = readTextSafe(lockPath);
if (!txt) continue;
for (const f of matchInTextLock(lockPath, txt)) {
findings.push({ ...f, project: proj.dir });
}
}
// Optional: verifies what is actually installed (if node_modules exists).
findings.push(...scanInstalledNodeModules(proj.dir).map(f => ({...f, project: proj.dir})));
}
findings.push(...scanHostIOCs().map(f => ({ ...f, project: "(this machine)" })));
if (!findings.length) {
console.log(`OK: No IOCs found under ${ROOT}`);
process.exit(0);
}
console.log(`FOUND ${findings.length} finding(s):`);
for (const f of findings) {
console.log(`- [${f.type}] ${f.what}`);
console.log(` project: ${f.project}`);
console.log(` path: ${f.file}`);
}
// Non-zero so you can use it in CI / MDM.
const hasIOC = findings.some(f => f.type !== "INFO");
process.exit(hasIOC ? 2 : 0);
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment