|
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(); |