UNPKG

check-compromised-npm-packages

Version:

Scan your project for compromised npm packages

231 lines (198 loc) 7 kB
/** * 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";