UNPKG

pkg-guardian

Version:

A security CLI tool to scan and protect your Node.js projects from compromised npm packages and supply chain attacks.

347 lines (307 loc) 14.1 kB
#!/usr/bin/env node "use strict"; const fs = require("fs"); const path = require("path"); const os = require("os"); const { spawnSync } = require("child_process"); const argv = process.argv.slice(2); const ONE_DAY_MS = 24 * 60 * 60 * 1000; function getArg(name) { const i = argv.indexOf(name); if (i === -1) return null; return argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : null; } if (argv.includes("--help") || argv.includes("-h")) { console.log("Usage: scan-compromised [--path DIR] [--repos FILE] [--verbose] [--deep]"); process.exit(0); } const reposFile = getArg("--repos"); const singlePath = getArg("--path"); const overriddenCompromised = getArg("--compromised"); const compromisedUrl = getArg("--compromised-url"); const outFile = getArg("--output") || path.resolve(process.cwd(), "scan-report.csv"); const format = (getArg("--format") || "csv").toLowerCase(); const verbose = argv.includes("--verbose"); const deep = argv.includes("--deep"); const failOnFind = argv.includes("--fail-on-find") || true; function readListFile(p) { return fs.readFileSync(p, "utf8").split(/\r?\n/).map(x => x.trim()).filter(x => x && !x.startsWith("#")); } async function loadCompromised() { if (overriddenCompromised) return new Set(readListFile(overriddenCompromised)); const bundled = path.join(__dirname, "..", "compromised.txt"); const now = Date.now(); let shouldFetch = true; if (fs.existsSync(bundled)) { try { const firstLine = fs.readFileSync(bundled, "utf8").split(/\r?\n/)[0] || ""; if (firstLine.startsWith("# updatedAt=")) { const ts = Number(firstLine.slice("# updatedAt=".length)); if (Number.isFinite(ts) && now - ts < ONE_DAY_MS) { shouldFetch = false; } } } catch { } } const url = compromisedUrl || null; if (url && shouldFetch) { try { const res = await fetch(url); if (!res.ok) throw new Error("Bad status " + res.status); const data = await res.json(); if (!Array.isArray(data)) throw new Error("Expected array from compromised list endpoint"); const packages = data.map(x => String(x || "").trim()).filter(Boolean); const body = ["# updatedAt=" + now, ...packages].join("\n"); fs.writeFileSync(bundled, body); return new Set(packages); } catch (e) { if (verbose) console.error("Failed to fetch compromised list:", e.message); } } return new Set(readListFile(bundled)); } /** * scanNodeModules: * - scans node_modules recursively for package.json files * - detects if a package itself is compromised (its name in compromised set) * - detects if the package lists any compromised packages in its peerDependencies * - returns items with optional requiredBy field (the package that needs the compromised package) */ function scanNodeModules(root, compromised, topDeps) { const results = []; if (!fs.existsSync(root)) return results; const stack = [root]; const seen = new Set(); while (stack.length) { const cur = stack.pop(); let list; try { list = fs.readdirSync(cur); } catch { continue; } for (const name of list) { if (name === ".bin") continue; const fp = path.join(cur, name); let st; try { st = fs.lstatSync(fp); } catch { continue; } if (st.isDirectory() && name.startsWith("@")) { try { fs.readdirSync(fp).forEach(c => stack.push(path.join(fp, c))); } catch { } continue; } if (st.isDirectory()) { const pkgJson = path.join(fp, "package.json"); if (fs.existsSync(pkgJson)) { try { const real = fs.realpathSync(fp); if (!seen.has(real)) { seen.add(real); const pj = JSON.parse(fs.readFileSync(pkgJson, "utf8")); // 1) If the package itself is compromised if (pj.name && compromised.has(pj.name)) { results.push({ package: pj.name, version: pj.version || "", type: topDeps.has(pj.name) ? "direct" : "transitive", location: fp, file: pkgJson, requiredBy: "" // package itself is compromised (not requiredBy another package) }); } // 2) If this package declares peerDependencies that are compromised if (pj.peerDependencies && typeof pj.peerDependencies === "object") { Object.keys(pj.peerDependencies).forEach(peerName => { if (compromised.has(peerName)) { results.push({ package: peerName, version: pj.peerDependencies[peerName] || "", type: "peer-dep", location: fp, file: pkgJson, requiredBy: (pj.name ? `${pj.name}@${pj.version || ''}` : path.relative(root, fp)) }); } }); } // 3) If this package declares regular dependencies (it requires others) that are compromised // (useful if you want to know who requires the compromised package) ["dependencies", "devDependencies", "optionalDependencies"].forEach(depField => { if (pj[depField] && typeof pj[depField] === "object") { Object.keys(pj[depField]).forEach(depName => { if (compromised.has(depName)) { results.push({ package: depName, version: pj[depField][depName] || "", type: depField === "dependencies" ? "required" : depField, location: fp, file: pkgJson, requiredBy: (pj.name ? `${pj.name}@${pj.version || ''}` : path.relative(root, fp)) }); } }); } }); } } catch (err) { if (verbose) console.error("err reading package.json:", pkgJson, err.message); } } const nm = path.join(fp, "node_modules"); if (fs.existsSync(nm)) stack.push(nm); } } } return results; } /** * scanRepo: * - scans top-level package.json for peerDependencies and normal deps * - parses lockfiles for text matches and, in deep mode, package-lock.json structured 'requires' * - calls scanNodeModules to find packages that declare peerDependencies of compromised packages */ function scanRepo(repoRoot, compromised) { const results = []; repoRoot = path.resolve(repoRoot); if (!fs.existsSync(repoRoot)) return results; const pkgJsonPath = path.join(repoRoot, "package.json"); const topDeps = new Set(); let pj = null; if (fs.existsSync(pkgJsonPath)) { try { pj = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].forEach(k => { if (pj[k]) Object.keys(pj[k]).forEach(d => topDeps.add(d)); }); } catch (e) { if (verbose) console.error("Failed to parse top-level package.json:", e.message); } } // Check top-level declared peerDependencies explicitly so we report requiredBy = project if (pj && pj.peerDependencies && typeof pj.peerDependencies === "object") { Object.keys(pj.peerDependencies).forEach(peer => { if (compromised.has(peer)) { results.push({ package: peer, version: pj.peerDependencies[peer] || "", type: "peer-dep", location: "package.json", file: pkgJsonPath, requiredBy: (pj.name ? `${pj.name}@${pj.version || ''}` : '(root)') }); } }); } // also check top-level dependencies (direct) ["dependencies", "devDependencies", "optionalDependencies"].forEach(field => { if (pj && pj[field] && typeof pj[field] === "object") { Object.keys(pj[field]).forEach(dep => { if (compromised.has(dep)) { results.push({ package: dep, version: pj[field][dep] || "", type: field === "dependencies" ? "direct" : field, location: "package.json", file: pkgJsonPath, requiredBy: (pj.name ? `${pj.name}@${pj.version || ''}` : '(root)') }); } }); } }); // lockfile checks (text) ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"].forEach(lock => { const p = path.join(repoRoot, lock); if (!fs.existsSync(p)) return; const txt = fs.readFileSync(p, "utf8"); compromised.forEach(c => { if (txt.includes(c)) { results.push({ package: c, version: "", type: "lockfile", location: lock, file: p, requiredBy: "" }); } }); // deep package-lock parsing: find 'requires' keys referencing compromised packages if (deep && lock === "package-lock.json") { try { const json = JSON.parse(txt); // walk dependencies with name tracking const walk = (obj, currentName) => { if (!obj || typeof obj !== "object") return; // 'requires' is often an object of required names -> versions if (obj.requires && typeof obj.requires === "object") { Object.keys(obj.requires).forEach(reqName => { if (compromised.has(reqName)) { results.push({ package: reqName, version: obj.requires[reqName] || "", type: "package-lock-requires", location: lock, file: p, requiredBy: currentName || "(root)" }); } }); } if (obj.dependencies && typeof obj.dependencies === "object") { Object.keys(obj.dependencies).forEach(depName => { walk(obj.dependencies[depName], depName); }); } }; // top-level may have 'dependencies' walk(json, "(root)"); } catch (e) { if (verbose) console.error("Failed parsing package-lock.json:", e.message); } } }); // scan node_modules for packages that are compromised OR that list compromised peerDependencies const nm = scanNodeModules(path.join(repoRoot, "node_modules"), compromised, topDeps); results.push(...nm); return results; } (async function () { const compromised = await loadCompromised(); let repos = []; if (reposFile) repos = readListFile(reposFile); else if (singlePath) repos = [singlePath]; else repos = [process.cwd()]; let findings = []; for (const repo of repos) { if (verbose) console.log("Scanning:", repo); const res = scanRepo(repo, compromised); res.forEach(r => (r.repo = repo)); findings.push(...res); } // output includes requiredBy column now if (format === "json") { fs.writeFileSync(outFile, JSON.stringify(findings, null, 2)); } else { const lines = ["repo,package,version,type,location,file,requiredBy"]; findings.forEach(r => { lines.push([ `"${r.repo}"`, `"${r.package}"`, `"${r.version}"`, `"${r.type}"`, `"${r.location}"`, `"${r.file}"`, `"${r.requiredBy || ""}"` ].join(",")); }); fs.writeFileSync(outFile, lines.join("\n")); } if (findings.length) { const unique = [...new Set(findings.map(r => r.package))]; console.error("FOUND:", unique.join(", ")); // print a few samples for quick debugging findings.slice(0, 10).forEach(f => { console.error(` - ${f.package} (${f.type}) requiredBy=${f.requiredBy || '(unknown)'} @ ${f.location}`); }); process.exit(failOnFind ? 1 : 0); } else { console.error("No compromised packages found."); process.exit(0); } })();