check-compromised-npm-packages
Version:
Scan your project for compromised npm packages
231 lines (198 loc) • 7 kB
JavaScript
/**
* Core functions for checking compromised packages
* Extracted for testing purposes
*/
export function compare(installedMap, knownBad) {
const badIndex = new Map();
for (const entry of knownBad.packages) {
badIndex.set(entry.name, new Set(entry.badVersions.map(String)));
}
const findings = [];
for (const [name, versions] of installedMap.entries()) {
if (!badIndex.has(name)) continue;
for (const v of versions) {
if (badIndex.get(name).has(v)) {
findings.push({ name, version: v });
}
}
}
return findings;
}
export function uniqFindings(findings) {
const seen = new Set();
return findings.filter((f) => {
const key = `${f.name}@${f.version}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function add(map, name, version) {
if (!map.has(name)) map.set(name, new Set());
map.get(name).add(String(version));
}
export function collectFromPackageLock(projectRoot) {
const hits = new Map();
const lockPath = path.join(projectRoot, "package-lock.json");
if (!fs.existsSync(lockPath)) return hits;
const lock = readJSONSafe(lockPath);
if (!lock) return hits;
if (lock.packages && typeof lock.packages === "object") {
for (const [key, meta] of Object.entries(lock.packages)) {
if (!meta || !meta.version) continue;
const name = key.replace(/^node_modules\//, "");
add(hits, name, meta.version);
}
}
function walkDeps(obj) {
if (!obj || typeof obj !== "object") return;
for (const [name, meta] of Object.entries(obj)) {
if (!meta) continue;
if (meta.version) add(hits, name, meta.version);
if (meta.dependencies) walkDeps(meta.dependencies);
}
}
if (lock.dependencies) walkDeps(lock.dependencies);
return hits;
}
export function collectFromYarnLock(projectRoot) {
const hits = new Map();
const lockPath = path.join(projectRoot, "yarn.lock");
if (!fs.existsSync(lockPath)) return hits;
try {
const content = fs.readFileSync(lockPath, "utf8");
const lines = content.split("\n");
let currentPackage = null;
let currentVersion = null;
let inPackageBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Match package declaration: "package-name@version:" or "@scope/package@version:"
// Handle both scoped and non-scoped packages
// Pattern: "@scope/package@version:" or "package@version:"
const pkgMatch = line.match(/^"?(@?[^"@\s]+\/[^"@\s]+@[^":\s,]+|[^"@\s]+@[^":\s,]+)(?:,\s*[^"]+)*"?:$/);
if (pkgMatch) {
// Save previous package if we have one
if (currentPackage && currentVersion) {
add(hits, currentPackage, currentVersion);
}
const fullName = pkgMatch[1];
// Find the last @ which separates package name from version
const atIndex = fullName.lastIndexOf("@");
if (atIndex > 0) {
currentPackage = fullName.substring(0, atIndex);
// Extract version from the first specifier
currentVersion = fullName.substring(atIndex + 1);
inPackageBlock = true;
}
continue;
}
// Match version field: " version "version-string""
if (inPackageBlock && trimmed.startsWith("version ")) {
const versionMatch = trimmed.match(/version\s+"([^"]+)"/);
if (versionMatch) {
currentVersion = versionMatch[1];
}
continue;
}
// Empty line or next package starts - save current package
if (inPackageBlock && (trimmed === "" || line.match(/^"?(@?[^"@\s]+\/[^"@\s]+@[^":\s,]+|[^"@\s]+@[^":\s,]+)"?:$/))) {
if (currentPackage && currentVersion) {
add(hits, currentPackage, currentVersion);
}
if (trimmed !== "") {
// New package starting, reset
const newPkgMatch = line.match(/^"?(@?[^"@\s]+\/[^"@\s]+@[^":\s,]+|[^"@\s]+@[^":\s,]+)"?:$/);
if (newPkgMatch) {
const fullName = newPkgMatch[1];
const atIndex = fullName.lastIndexOf("@");
if (atIndex > 0) {
currentPackage = fullName.substring(0, atIndex);
currentVersion = fullName.substring(atIndex + 1);
}
} else {
currentPackage = null;
currentVersion = null;
inPackageBlock = false;
}
} else {
currentPackage = null;
currentVersion = null;
inPackageBlock = false;
}
}
}
// Don't forget the last package
if (currentPackage && currentVersion) {
add(hits, currentPackage, currentVersion);
}
} catch (err) {
// Silently fail if parsing fails
return hits;
}
return hits;
}
export function collectFromPnpmLock(projectRoot) {
const hits = new Map();
const lockPath = path.join(projectRoot, "pnpm-lock.yaml");
if (!fs.existsSync(lockPath)) return hits;
try {
const content = fs.readFileSync(lockPath, "utf8");
// pnpm lock format uses YAML with a specific structure
// We'll parse it with a simple regex-based approach for the packages section
// Format: packages: followed by entries like:
// /package-name/version:
// ...
// /@scope/package/version:
// ...
// Match package entries: " /package-name/version:" or " /@scope/package/version:"
// Pattern matches: /package/version: or /@scope/package/version:
const packageRegex = /^\s+"?(\/(?:@[^/]+\/)?[^/]+)\/([^":]+)"?:/gm;
let match;
while ((match = packageRegex.exec(content)) !== null) {
let packageName = match[1];
const version = match[2];
// Remove leading slash
if (packageName.startsWith("/")) {
packageName = packageName.substring(1);
}
add(hits, packageName, version);
}
} catch (err) {
// Silently fail if parsing fails
return hits;
}
return hits;
}
export function collectFromLockFiles(projectRoot) {
const hits = new Map();
// Try npm first
const npmLock = collectFromPackageLock(projectRoot);
for (const [name, versions] of npmLock.entries()) {
if (!hits.has(name)) hits.set(name, new Set());
for (const v of versions) hits.get(name).add(v);
}
// Try Yarn
const yarnLock = collectFromYarnLock(projectRoot);
for (const [name, versions] of yarnLock.entries()) {
if (!hits.has(name)) hits.set(name, new Set());
for (const v of versions) hits.get(name).add(v);
}
// Try pnpm
const pnpmLock = collectFromPnpmLock(projectRoot);
for (const [name, versions] of pnpmLock.entries()) {
if (!hits.has(name)) hits.set(name, new Set());
for (const v of versions) hits.get(name).add(v);
}
return hits;
}
export function readJSONSafe(p) {
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
import fs from "fs";
import path from "path";