Last active
September 16, 2025 19:57
-
-
Save b0tting/8f808aa7d140b102924579540450d242 to your computer and use it in GitHub Desktop.
Given a list of compromised NPM packages, check to see which have an updated or retracted version, or which are still compromised when pulling the most recent. Run with "node .\checkPackages.js .\packages.txt".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const https = require('https'); | |
| // Cache latest version lookups per package to ensure only one fetch per unique package | |
| /** @type {Map<string, Promise<string>>} */ | |
| const latestVersionCache = new Map(); | |
| /** Add a version entry for a package into the map */ | |
| function addVersion(packageToVersions, name, version) { | |
| if (!packageToVersions.has(name)) packageToVersions.set(name, new Set()); | |
| packageToVersions.get(name).add(version); | |
| } | |
| /** Process a single comma-separated segment and update the map; returns updated currentPackage */ | |
| function processSegment(segment, currentPackage, packageToVersions, isSemver) { | |
| // Case: "[email protected]" or "@scope/[email protected]" | |
| const atPos = segment.lastIndexOf('@'); | |
| const hasExplicitVersionWithAt = atPos > 0 && atPos < segment.length - 1 && isSemver(segment.slice(atPos + 1).trim()); | |
| if (hasExplicitVersionWithAt) { | |
| const name = segment.slice(0, atPos).trim(); | |
| const version = segment.slice(atPos + 1).trim(); | |
| addVersion(packageToVersions, name, version); | |
| return name; | |
| } | |
| // Case: shorthand "@1.2.3" meaning version only (must have currentPackage) | |
| if (segment.startsWith('@')) { | |
| const maybeVersion = segment.slice(1).trim(); | |
| if (currentPackage && isSemver(maybeVersion)) { | |
| addVersion(packageToVersions, currentPackage, maybeVersion); | |
| return currentPackage; | |
| } | |
| } | |
| // Case: "name 1.2.3" or "@scope/name 1.2.3" | |
| const spaceIdx = segment.lastIndexOf(' '); | |
| if (spaceIdx > 0) { | |
| const left = segment.slice(0, spaceIdx).trim(); | |
| const right = segment.slice(spaceIdx + 1).trim(); | |
| if (isSemver(right)) { | |
| addVersion(packageToVersions, left, right); | |
| return left; | |
| } | |
| } | |
| // Case: segment is just a semver (e.g. "1.2.3") using the last seen package | |
| if (isSemver(segment) && currentPackage) { | |
| addVersion(packageToVersions, currentPackage, segment); | |
| return currentPackage; | |
| } | |
| // Case: segment is a bare package name | |
| if (segment) { | |
| if (!packageToVersions.has(segment)) packageToVersions.set(segment, new Set()); | |
| return segment; | |
| } | |
| return currentPackage; | |
| } | |
| /** | |
| * Parse the packages.txt file into a map of packageName -> Set of compromised versions | |
| * Supported input patterns per line (commas separate entries): | |
| * - [email protected] | |
| * - @scope/[email protected] | |
| * - [email protected], 1.2.4 | |
| * - @scope/[email protected], @1.2.4 (shorthand for same package) | |
| * - @scope/name 1.2.3 (space separator) | |
| */ | |
| function parsePackagesFile(filePath) { | |
| const content = fs.readFileSync(filePath, 'utf8'); | |
| const lines = content.split(/\r?\n/); | |
| /** @type {Map<string, Set<string>>} */ | |
| const packageToVersions = new Map(); | |
| const isSemver = (s) => /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(s); | |
| for (const rawLine of lines) { | |
| const line = rawLine.trim(); | |
| if (!line || line.startsWith('#')) continue; | |
| let currentPackage = null; | |
| // Split on commas to support multiple versions or entries per line | |
| const segments = line.split(',').map(s => s.trim()).filter(Boolean); | |
| for (const segment of segments) { | |
| if (!segment) continue; | |
| currentPackage = processSegment(segment, currentPackage, packageToVersions, isSemver); | |
| } | |
| } | |
| return packageToVersions; | |
| } | |
| function fetchJson(url) { | |
| return new Promise((resolve, reject) => { | |
| https | |
| .get(url, { headers: { 'Accept': 'application/vnd.npm.install-v1+json, application/json', 'User-Agent': 'npmPackageScanner/1.0 (+node https)'} }, (res) => { | |
| let data = ''; | |
| res.setEncoding('utf8'); | |
| res.on('data', (chunk) => (data += chunk)); | |
| res.on('end', () => { | |
| if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { | |
| try { | |
| resolve(JSON.parse(data)); | |
| } catch (err) { | |
| reject(new Error(`Failed to parse JSON from ${url}: ${err.message}`)); | |
| } | |
| } else { | |
| reject(new Error(`HTTP ${res.statusCode} for ${url}: ${data.slice(0, 200)}`)); | |
| } | |
| }); | |
| }) | |
| .on('error', (err) => reject(err)); | |
| }); | |
| } | |
| function compareSemver(a, b) { | |
| const parse = (s) => { | |
| const [core, pre] = s.split('-'); | |
| const [ma, mi, pa] = core.split('.').map((n) => parseInt(n, 10)); | |
| return { ma, mi, pa, pre: pre || '' }; | |
| }; | |
| const A = parse(a); | |
| const B = parse(b); | |
| if (A.ma !== B.ma) return A.ma - B.ma; | |
| if (A.mi !== B.mi) return A.mi - B.mi; | |
| if (A.pa !== B.pa) return A.pa - B.pa; | |
| // Pre-release: absence > presence (e.g., 1.0.0 > 1.0.0-beta) | |
| if (A.pre === B.pre) return 0; | |
| if (!A.pre) return 1; | |
| if (!B.pre) return -1; | |
| return A.pre.localeCompare(B.pre); | |
| } | |
| async function getLatestVersionFromRegistry(pkgName) { | |
| if (latestVersionCache.has(pkgName)) { | |
| return latestVersionCache.get(pkgName); | |
| } | |
| const promise = (async () => { | |
| const encoded = encodeURIComponent(pkgName); | |
| const url = `https://registry.npmjs.org/${encoded}`; | |
| const json = await fetchJson(url); | |
| if (json && json['dist-tags'] && json['dist-tags'].latest) { | |
| return json['dist-tags'].latest; | |
| } | |
| // Fallback: pick max of versions keys | |
| if (json && json.versions && typeof json.versions === 'object') { | |
| const versions = Object.keys(json.versions); | |
| if (versions.length > 0) { | |
| versions.sort(compareSemver); | |
| return versions[versions.length - 1]; | |
| } | |
| } | |
| throw new Error(`No versions found for ${pkgName}`); | |
| })(); | |
| latestVersionCache.set(pkgName, promise); | |
| return promise; | |
| } | |
| async function main() { | |
| const argPath = process.argv[2]; | |
| if (!argPath) { | |
| console.error('Usage: node checkPackages.js <path-to-packages.txt>'); | |
| process.exit(1); | |
| } | |
| const filePath = path.resolve(process.cwd(), argPath); | |
| if (!fs.existsSync(filePath)) { | |
| console.error(`File not found: ${filePath}`); | |
| process.exit(1); | |
| } | |
| const packageToVersions = parsePackagesFile(filePath); | |
| const entries = Array.from(packageToVersions.entries()); | |
| if (entries.length === 0) { | |
| const raw = fs.readFileSync(filePath, 'utf8'); | |
| console.error('No packages parsed from packages.txt. Please check formatting.'); | |
| console.error(`packages.txt path: ${filePath}`); | |
| console.error(`packages.txt size: ${raw.length} chars`); | |
| const preview = raw.split(/\r?\n/).slice(0, 5).join('\n'); | |
| console.error('First lines preview:\n' + preview); | |
| return; | |
| } | |
| // Limit concurrency to avoid overwhelming the registry | |
| const concurrency = 8; | |
| let index = 0; | |
| const results = []; | |
| async function worker() { | |
| while (true) { | |
| let i; | |
| if (index >= entries.length) return; | |
| i = index++; | |
| const [pkg, compromisedSet] = entries[i]; | |
| try { | |
| const latest = await getLatestVersionFromRegistry(pkg); | |
| const stillCompromised = compromisedSet.has(latest); | |
| results[i] = { pkg, compromisedSet, latest, stillCompromised, error: null }; | |
| } catch (err) { | |
| results[i] = { pkg, compromisedSet, latest: null, stillCompromised: false, error: String(err.message || err) }; | |
| } | |
| } | |
| } | |
| const workers = Array.from({ length: Math.min(concurrency, entries.length) }, () => worker()); | |
| await Promise.all(workers); | |
| let compromisedCount = 0; | |
| let notCompromisedCount = 0; | |
| let errorCount = 0; | |
| for (const r of results) { | |
| if (!r) continue; | |
| const versions = Array.from(r.compromisedSet || []).sort(compareSemver).join(', '); | |
| if (r.error) { | |
| console.log(`${r.pkg}@${versions} -> ERROR: ${r.error}`); | |
| errorCount++; | |
| } else if (r.stillCompromised) { | |
| console.log(`${r.pkg}@${versions} -> still compromised`); | |
| compromisedCount++; | |
| } else { | |
| console.log(`${r.pkg}@${versions} -> latest ${r.latest}`); | |
| notCompromisedCount++; | |
| } | |
| } | |
| console.log(`\nScanned ${results.filter(Boolean).length} packages: ${compromisedCount} still compromised, ${notCompromisedCount} not compromised, ${errorCount} errors.`); | |
| } | |
| // Given a file with a list of packages and versions, this script will check if requesting the packages from the npm registry | |
| // gives you either a higher version (meaning there was an update) or a lower version (meaning the compromised version is detracted) | |
| // If neither, the package is still compromised. Version information is retrieved by from the npm registry - see for example | |
| // https://registry.npmjs.org/angulartics2 | |
| // | |
| // App was mostly vibecoded with Cursor :) | |
| // | |
| // This works great with the list from https://socket.dev/blog/ongoing-supply-chain-attack-targets-crowdstrike-npm-packages | |
| // but I started with other examples from aikido.dev which had a comma separated list of versions per package. Also works. | |
| main().catch((err) => { | |
| console.error(err); | |
| process.exit(1); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment