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