Last active
May 13, 2026 12:24
-
-
Save JuanFelix88/ffaa62e8073dc2f70a3917e7b489e522 to your computer and use it in GitHub Desktop.
Check for leaked npm packages @TanStack and related packages.
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 | |
| /** | |
| * @example node verify-npm-libraries.mjs --root localfolder --csv csv-file.csv --only-likely | |
| * | |
| * Plan with libraries affecteds: | |
| * @link https://docs.google.com/spreadsheets/d/157SIcf-VSpb4VF_DlpppsG9GanNk6LorakSQgUEW99c/edit?usp=sharing | |
| */ | |
| import { access, readdir, readFile } from 'node:fs/promises'; | |
| import path from 'node:path'; | |
| import process from 'node:process'; | |
| import { fileURLToPath } from 'node:url'; | |
| const DEFAULT_IGNORED_DIRS = new Set([ | |
| '.git', | |
| '.hg', | |
| '.svn', | |
| 'node_modules', | |
| 'bower_components', | |
| 'dist', | |
| 'build', | |
| 'out', | |
| 'coverage', | |
| '.next', | |
| '.nuxt', | |
| '.svelte-kit', | |
| '.turbo', | |
| '.cache', | |
| '.vite', | |
| 'vendor', | |
| ]); | |
| const DEPENDENCY_SECTIONS = [ | |
| 'dependencies', | |
| 'devDependencies', | |
| 'optionalDependencies', | |
| 'peerDependencies', | |
| ]; | |
| const LOCKFILE_NAMES = new Set([ | |
| 'package-lock.json', | |
| 'npm-shrinkwrap.json', | |
| 'pnpm-lock.yaml', | |
| 'yarn.lock', | |
| ]); | |
| const args = parseArgs(process.argv.slice(2)); | |
| const scriptDir = path.dirname(fileURLToPath(import.meta.url)); | |
| const baseDir = path.resolve(args.root ?? scriptDir); | |
| const csvPath = path.resolve(args.csv ?? path.join(scriptDir, 'pacotes-comprometidos.csv')); | |
| if (args.help) { | |
| printHelp(); | |
| process.exit(0); | |
| } | |
| const progress = createProgress({ enabled: !args.noProgress && !args.json }); | |
| try { | |
| progress.start('Carregando CSV de pacotes comprometidos'); | |
| const compromisedPackages = await loadCompromisedPackages(csvPath); | |
| progress.update('Mapeando package.json e lockfiles'); | |
| const projectFiles = await findProjectFiles(baseDir, progress); | |
| const reports = []; | |
| const allPackages = []; | |
| let packageJsonProcessed = 0; | |
| for (const packageJsonPath of projectFiles.packageJsonPaths) { | |
| packageJsonProcessed += 1; | |
| progress.update( | |
| `Analisando package.json ${packageJsonProcessed}/${projectFiles.packageJsonPaths.length}: ${relativePath(packageJsonPath, baseDir)}`, | |
| ); | |
| const packageJson = await readPackageJson(packageJsonPath); | |
| if (!packageJson) { | |
| continue; | |
| } | |
| const repoPath = await findRepositoryRoot(path.dirname(packageJsonPath), baseDir); | |
| const dependencies = collectDependencies(packageJson, packageJsonPath, repoPath); | |
| allPackages.push(...dependencies); | |
| reports.push(...matchCompromisedDependencies(dependencies, compromisedPackages)); | |
| } | |
| let lockfilesProcessed = 0; | |
| for (const lockfilePath of projectFiles.lockfilePaths) { | |
| lockfilesProcessed += 1; | |
| progress.update( | |
| `Analisando lockfile ${lockfilesProcessed}/${projectFiles.lockfilePaths.length}: ${relativePath(lockfilePath, baseDir)}`, | |
| ); | |
| const repoPath = await findRepositoryRoot(path.dirname(lockfilePath), baseDir); | |
| const directNames = await collectSiblingPackageJsonDependencyNames(lockfilePath); | |
| const dependencies = await collectLockfileDependencies(lockfilePath, repoPath, directNames); | |
| allPackages.push(...dependencies); | |
| reports.push(...matchCompromisedDependencies(dependencies, compromisedPackages)); | |
| } | |
| progress.stop(); | |
| printResult({ | |
| baseDir, | |
| csvPath, | |
| compromisedPackages, | |
| packageJsonPaths: projectFiles.packageJsonPaths, | |
| lockfilePaths: projectFiles.lockfilePaths, | |
| allPackages, | |
| reports, | |
| listPackages: args.listPackages, | |
| json: args.json, | |
| onlyLikely: args.onlyLikely, | |
| }); | |
| } catch (error) { | |
| progress.stop(); | |
| console.error(`Erro: ${error.message}`); | |
| process.exit(1); | |
| } | |
| function parseArgs(argv) { | |
| const parsed = { | |
| root: undefined, | |
| csv: undefined, | |
| help: false, | |
| listPackages: false, | |
| json: false, | |
| onlyLikely: false, | |
| noProgress: false, | |
| }; | |
| for (let index = 0; index < argv.length; index += 1) { | |
| const arg = argv[index]; | |
| if (arg === '--help' || arg === '-h') { | |
| parsed.help = true; | |
| continue; | |
| } | |
| if (arg === '--root') { | |
| parsed.root = readRequiredArg(argv, index, arg); | |
| index += 1; | |
| continue; | |
| } | |
| if (arg.startsWith('--root=')) { | |
| parsed.root = arg.slice('--root='.length); | |
| continue; | |
| } | |
| if (arg === '--csv') { | |
| parsed.csv = readRequiredArg(argv, index, arg); | |
| index += 1; | |
| continue; | |
| } | |
| if (arg.startsWith('--csv=')) { | |
| parsed.csv = arg.slice('--csv='.length); | |
| continue; | |
| } | |
| if (arg === '--list-packages') { | |
| parsed.listPackages = true; | |
| continue; | |
| } | |
| if (arg === '--json') { | |
| parsed.json = true; | |
| continue; | |
| } | |
| if (arg === '--only-likely') { | |
| parsed.onlyLikely = true; | |
| continue; | |
| } | |
| if (arg === '--no-progress') { | |
| parsed.noProgress = true; | |
| continue; | |
| } | |
| throw new Error(`Argumento desconhecido: ${arg}`); | |
| } | |
| return parsed; | |
| } | |
| function readRequiredArg(argv, index, flag) { | |
| const value = argv[index + 1]; | |
| if (!value || value.startsWith('--')) { | |
| throw new Error(`Informe um valor para ${flag}`); | |
| } | |
| return value; | |
| } | |
| function createProgress({ enabled }) { | |
| const frames = [ | |
| '[> ]', | |
| '[>> ]', | |
| '[>>> ]', | |
| '[ >>> ]', | |
| '[ >>>]', | |
| '[ >>]', | |
| '[ >]', | |
| '[ <<]', | |
| '[ <<<]', | |
| '[ <<< ]', | |
| '[<<< ]', | |
| '[<< ]', | |
| ]; | |
| let frameIndex = 0; | |
| let message = ''; | |
| let timer = null; | |
| const canRender = enabled && process.stderr.isTTY; | |
| function render() { | |
| if (!canRender) { | |
| return; | |
| } | |
| const frame = frames[frameIndex % frames.length]; | |
| frameIndex += 1; | |
| process.stderr.write(`\r\x1b[2K${frame} ${message}`); | |
| } | |
| return { | |
| start(initialMessage) { | |
| message = initialMessage; | |
| render(); | |
| if (canRender && !timer) { | |
| timer = setInterval(render, 120); | |
| } | |
| }, | |
| update(nextMessage) { | |
| message = nextMessage; | |
| render(); | |
| }, | |
| stop() { | |
| if (timer) { | |
| clearInterval(timer); | |
| timer = null; | |
| } | |
| if (canRender) { | |
| process.stderr.write('\r\x1b[2K'); | |
| } | |
| }, | |
| }; | |
| } | |
| function printHelp() { | |
| console.log(`Uso: | |
| node verificar-pacotes-comprometidos.mjs [opcoes] | |
| Opcoes: | |
| --root <pasta> Pasta base para procurar package.json. Padrao: pasta do script. | |
| --csv <arquivo> Caminho do CSV de pacotes comprometidos. Padrao: pacotes-comprometidos.csv ao lado do script. | |
| --list-packages Lista todos os pacotes encontrados nos package.json e lockfiles. | |
| --only-likely Oculta matches somente por nome quando a versao nao parece afetada. | |
| --no-progress Desativa a animacao de progresso. | |
| --json Imprime o resultado completo em JSON. | |
| --help Mostra esta ajuda. | |
| Exemplos: | |
| node verificar-pacotes-comprometidos.mjs | |
| node verificar-pacotes-comprometidos.mjs --root Juan | |
| node verificar-pacotes-comprometidos.mjs --root Juan --list-packages | |
| node verificar-pacotes-comprometidos.mjs --root Juan --only-likely | |
| `); | |
| } | |
| async function loadCompromisedPackages(filePath) { | |
| const content = await readFile(filePath, 'utf8'); | |
| const rows = parseCsv(content); | |
| if (rows.length === 0) { | |
| throw new Error(`CSV vazio: ${filePath}`); | |
| } | |
| const header = rows[0].map(normalizeHeader); | |
| const indexes = { | |
| ecosystem: findHeaderIndex(header, ['ecossistema', 'ecosystem']), | |
| namespace: findHeaderIndex(header, ['namespace', 'escopo', 'scope']), | |
| packageName: findHeaderIndex(header, ['nomedopacote', 'nomepacote', 'package', 'packagename', 'name']), | |
| versions: findHeaderIndex(header, ['versoesidentificadas', 'versoes', 'versions', 'version']), | |
| notes: findHeaderIndex(header, ['notas', 'notes', 'observacoes']), | |
| }; | |
| if (indexes.packageName === -1) { | |
| throw new Error('Nao encontrei a coluna com o nome do pacote no CSV.'); | |
| } | |
| const compromised = new Map(); | |
| for (const row of rows.slice(1)) { | |
| const ecosystem = readCell(row, indexes.ecosystem).trim().toLowerCase(); | |
| if (ecosystem && ecosystem !== 'npm') { | |
| continue; | |
| } | |
| const packageName = readCell(row, indexes.packageName).trim(); | |
| const namespace = readCell(row, indexes.namespace).trim(); | |
| const fullName = buildPackageName(namespace, packageName); | |
| if (!fullName) { | |
| continue; | |
| } | |
| const notes = readCell(row, indexes.notes).trim(); | |
| const identifiedVersions = splitVersions(readCell(row, indexes.versions)); | |
| const versions = extractCompromisedVersions(notes, identifiedVersions); | |
| const existing = compromised.get(fullName); | |
| if (existing) { | |
| existing.versions = unique([...existing.versions, ...versions]); | |
| existing.notes = unique([existing.notes, notes].filter(Boolean)).join(' | '); | |
| continue; | |
| } | |
| compromised.set(fullName, { versions, notes }); | |
| } | |
| return compromised; | |
| } | |
| function parseCsv(content) { | |
| const normalizedContent = content.replace(/^\uFEFF/, ''); | |
| const delimiter = detectDelimiter(normalizedContent); | |
| const rows = []; | |
| let row = []; | |
| let cell = ''; | |
| let inQuotes = false; | |
| for (let index = 0; index < normalizedContent.length; index += 1) { | |
| const char = normalizedContent[index]; | |
| const next = normalizedContent[index + 1]; | |
| if (char === '"') { | |
| if (inQuotes && next === '"') { | |
| cell += '"'; | |
| index += 1; | |
| } else { | |
| inQuotes = !inQuotes; | |
| } | |
| continue; | |
| } | |
| if (char === delimiter && !inQuotes) { | |
| row.push(cell); | |
| cell = ''; | |
| continue; | |
| } | |
| if ((char === '\n' || char === '\r') && !inQuotes) { | |
| if (char === '\r' && next === '\n') { | |
| index += 1; | |
| } | |
| row.push(cell); | |
| rows.push(row); | |
| row = []; | |
| cell = ''; | |
| continue; | |
| } | |
| cell += char; | |
| } | |
| if (cell.length > 0 || row.length > 0) { | |
| row.push(cell); | |
| rows.push(row); | |
| } | |
| return rows.filter((currentRow) => currentRow.some((currentCell) => currentCell.trim() !== '')); | |
| } | |
| function detectDelimiter(content) { | |
| const firstLine = content.split(/\r?\n/, 1)[0] ?? ''; | |
| const commaCount = countOccurrences(firstLine, ','); | |
| const semicolonCount = countOccurrences(firstLine, ';'); | |
| return semicolonCount > commaCount ? ';' : ','; | |
| } | |
| function countOccurrences(value, token) { | |
| return [...value].filter((char) => char === token).length; | |
| } | |
| function normalizeHeader(value) { | |
| return value | |
| .normalize('NFD') | |
| .replace(/[\u0300-\u036f]/g, '') | |
| .toLowerCase() | |
| .replace(/[^a-z0-9]/g, ''); | |
| } | |
| function findHeaderIndex(header, candidates) { | |
| return header.findIndex((value) => candidates.includes(value)); | |
| } | |
| function readCell(row, index) { | |
| if (index === -1) { | |
| return ''; | |
| } | |
| return row[index] ?? ''; | |
| } | |
| function buildPackageName(namespace, packageName) { | |
| if (!packageName) { | |
| return ''; | |
| } | |
| if (packageName.startsWith('@') || !namespace || isGlobalNamespace(namespace)) { | |
| return packageName; | |
| } | |
| if (namespace.startsWith('@')) { | |
| return `${namespace}/${packageName}`; | |
| } | |
| return packageName; | |
| } | |
| function isGlobalNamespace(namespace) { | |
| return /^(global|sem namespace|global\/sem namespace|none|n\/a)$/i.test(namespace.trim()); | |
| } | |
| function splitVersions(value) { | |
| return unique( | |
| value | |
| .split(',') | |
| .map((version) => normalizeVersion(version)) | |
| .filter(Boolean), | |
| ); | |
| } | |
| function extractCompromisedVersions(notes, identifiedVersions) { | |
| if (!/comprometid/i.test(removeDiacritics(notes))) { | |
| return identifiedVersions; | |
| } | |
| const versions = []; | |
| const rangePattern = /v?(\d+(?:\.\d+){1,2}(?:[-+][0-9A-Za-z.-]+)?)\s+(?:a|ate|to|-)\s+v?(\d+(?:\.\d+){1,2}(?:[-+][0-9A-Za-z.-]+)?)/gi; | |
| for (const match of notes.matchAll(rangePattern)) { | |
| const start = normalizeVersion(match[1]); | |
| const end = normalizeVersion(match[2]); | |
| const versionsInRange = identifiedVersions.filter( | |
| (version) => compareVersions(version, start) >= 0 && compareVersions(version, end) <= 0, | |
| ); | |
| versions.push(...(versionsInRange.length > 0 ? versionsInRange : [start, end])); | |
| } | |
| const exactPattern = /(?:^|[^\w.-])v?(\d+(?:\.\d+){1,2}(?:[-+][0-9A-Za-z.-]+)?)(?=$|[^\w.-])/g; | |
| for (const match of notes.matchAll(exactPattern)) { | |
| versions.push(normalizeVersion(match[1])); | |
| } | |
| const compromisedVersions = unique(versions.filter(Boolean)); | |
| return compromisedVersions.length > 0 ? compromisedVersions : identifiedVersions; | |
| } | |
| function removeDiacritics(value) { | |
| return value.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); | |
| } | |
| function normalizeVersion(value) { | |
| return value | |
| .trim() | |
| .replace(/^v/i, '') | |
| .replace(/^=+/, '') | |
| .trim(); | |
| } | |
| async function findProjectFiles(root, progress) { | |
| const results = { | |
| packageJsonPaths: [], | |
| lockfilePaths: [], | |
| }; | |
| const state = { | |
| directoryCount: 0, | |
| }; | |
| await walk(root, results, state, progress); | |
| return results; | |
| } | |
| async function walk(currentDir, results, state, progress) { | |
| let entries; | |
| try { | |
| entries = await readdir(currentDir, { withFileTypes: true }); | |
| } catch (error) { | |
| console.warn(`Aviso: nao foi possivel ler ${currentDir}: ${error.message}`); | |
| return; | |
| } | |
| state.directoryCount += 1; | |
| if (state.directoryCount % 25 === 0) { | |
| progress?.update(`Mapeando arquivos... ${state.directoryCount} pastas lidas`); | |
| } | |
| for (const entry of entries) { | |
| const fullPath = path.join(currentDir, entry.name); | |
| if (entry.isDirectory()) { | |
| if (!DEFAULT_IGNORED_DIRS.has(entry.name)) { | |
| await walk(fullPath, results, state, progress); | |
| } | |
| continue; | |
| } | |
| if (entry.isFile() && entry.name === 'package.json') { | |
| results.packageJsonPaths.push(fullPath); | |
| continue; | |
| } | |
| if (entry.isFile() && LOCKFILE_NAMES.has(entry.name)) { | |
| results.lockfilePaths.push(fullPath); | |
| } | |
| } | |
| } | |
| async function readPackageJson(filePath) { | |
| try { | |
| return JSON.parse(await readFile(filePath, 'utf8')); | |
| } catch (error) { | |
| console.warn(`Aviso: package.json invalido em ${filePath}: ${error.message}`); | |
| return null; | |
| } | |
| } | |
| async function findRepositoryRoot(startDir, fallbackRoot) { | |
| let currentDir = startDir; | |
| while (currentDir.startsWith(fallbackRoot)) { | |
| if (await exists(path.join(currentDir, '.git'))) { | |
| return currentDir; | |
| } | |
| const parent = path.dirname(currentDir); | |
| if (parent === currentDir) { | |
| break; | |
| } | |
| currentDir = parent; | |
| } | |
| return inferProjectRoot(startDir, fallbackRoot); | |
| } | |
| async function exists(filePath) { | |
| try { | |
| await access(filePath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function inferProjectRoot(startDir, fallbackRoot) { | |
| const relative = path.relative(fallbackRoot, startDir); | |
| if (!relative || relative.startsWith('..')) { | |
| return startDir; | |
| } | |
| const segments = relative.split(path.sep).filter(Boolean); | |
| if (segments[0]?.toLowerCase() === 'juan' && segments.length > 1) { | |
| return path.join(fallbackRoot, segments[0], segments[1]); | |
| } | |
| return path.join(fallbackRoot, segments[0]); | |
| } | |
| function relativePath(filePath, root) { | |
| return path.relative(root, filePath) || path.basename(filePath); | |
| } | |
| function collectDependencies(packageJson, packageJsonPath, repoPath) { | |
| const dependencies = []; | |
| for (const section of DEPENDENCY_SECTIONS) { | |
| const values = packageJson[section]; | |
| if (!values || typeof values !== 'object' || Array.isArray(values)) { | |
| continue; | |
| } | |
| for (const [name, versionSpec] of Object.entries(values)) { | |
| dependencies.push({ | |
| repoPath, | |
| sourceFilePath: packageJsonPath, | |
| packageJsonPath, | |
| lockfilePath: '', | |
| sourceType: 'package.json', | |
| dependencyType: 'direct', | |
| section, | |
| name, | |
| versionSpec: String(versionSpec), | |
| installedVersion: '', | |
| }); | |
| } | |
| } | |
| return dependencies; | |
| } | |
| async function collectSiblingPackageJsonDependencyNames(lockfilePath) { | |
| const packageJsonPath = path.join(path.dirname(lockfilePath), 'package.json'); | |
| const packageJson = await readPackageJsonIfExists(packageJsonPath); | |
| if (!packageJson) { | |
| return new Set(); | |
| } | |
| return collectPackageJsonDependencyNames(packageJson); | |
| } | |
| async function readPackageJsonIfExists(packageJsonPath) { | |
| if (!(await exists(packageJsonPath))) { | |
| return null; | |
| } | |
| return readPackageJson(packageJsonPath); | |
| } | |
| function collectPackageJsonDependencyNames(packageJson) { | |
| const names = new Set(); | |
| for (const section of DEPENDENCY_SECTIONS) { | |
| const values = packageJson[section]; | |
| if (!values || typeof values !== 'object' || Array.isArray(values)) { | |
| continue; | |
| } | |
| for (const name of Object.keys(values)) { | |
| names.add(name); | |
| } | |
| } | |
| return names; | |
| } | |
| async function collectLockfileDependencies(lockfilePath, repoPath, directNames) { | |
| const fileName = path.basename(lockfilePath); | |
| if (fileName === 'package-lock.json' || fileName === 'npm-shrinkwrap.json') { | |
| return collectPackageLockDependencies(lockfilePath, repoPath, directNames); | |
| } | |
| if (fileName === 'pnpm-lock.yaml') { | |
| return collectPnpmLockDependencies(lockfilePath, repoPath, directNames); | |
| } | |
| if (fileName === 'yarn.lock') { | |
| return collectYarnLockDependencies(lockfilePath, repoPath, directNames); | |
| } | |
| return []; | |
| } | |
| async function collectPackageLockDependencies(lockfilePath, repoPath, directNamesFromPackageJson) { | |
| let lockfile; | |
| try { | |
| lockfile = JSON.parse(await readFile(lockfilePath, 'utf8')); | |
| } catch (error) { | |
| console.warn(`Aviso: lockfile JSON invalido em ${lockfilePath}: ${error.message}`); | |
| return []; | |
| } | |
| const sourceType = path.basename(lockfilePath); | |
| const rootPackage = lockfile.packages?.['']; | |
| const directNames = new Set([ | |
| ...directNamesFromPackageJson, | |
| ...collectPackageJsonDependencyNames(rootPackage ?? {}), | |
| ]); | |
| if (lockfile.packages && typeof lockfile.packages === 'object') { | |
| return Object.entries(lockfile.packages) | |
| .filter(([packagePath]) => packagePath !== '') | |
| .map(([packagePath, packageInfo]) => | |
| createLockfileDependency({ | |
| repoPath, | |
| lockfilePath, | |
| sourceType, | |
| name: packageInfo?.name || extractPackageNameFromNodeModulesPath(packagePath), | |
| installedVersion: packageInfo?.version, | |
| directNames, | |
| section: detectLockfileSection(packageInfo), | |
| }), | |
| ) | |
| .filter(Boolean); | |
| } | |
| if (lockfile.dependencies && typeof lockfile.dependencies === 'object') { | |
| const dependencies = []; | |
| collectNpmLockV1Dependencies(lockfile.dependencies, { | |
| repoPath, | |
| lockfilePath, | |
| sourceType, | |
| directNames, | |
| dependencies, | |
| depth: 0, | |
| }); | |
| return dependencies; | |
| } | |
| return []; | |
| } | |
| function collectNpmLockV1Dependencies(dependenciesByName, context) { | |
| for (const [name, packageInfo] of Object.entries(dependenciesByName)) { | |
| const dependency = createLockfileDependency({ | |
| repoPath: context.repoPath, | |
| lockfilePath: context.lockfilePath, | |
| sourceType: context.sourceType, | |
| name, | |
| installedVersion: packageInfo?.version, | |
| directNames: context.depth === 0 ? new Set([...context.directNames, name]) : context.directNames, | |
| section: detectLockfileSection(packageInfo), | |
| forceDependencyType: context.depth === 0 ? 'direct' : undefined, | |
| }); | |
| if (dependency) { | |
| context.dependencies.push(dependency); | |
| } | |
| if (packageInfo?.dependencies && typeof packageInfo.dependencies === 'object') { | |
| collectNpmLockV1Dependencies(packageInfo.dependencies, { | |
| ...context, | |
| depth: context.depth + 1, | |
| }); | |
| } | |
| } | |
| } | |
| async function collectPnpmLockDependencies(lockfilePath, repoPath, directNamesFromPackageJson) { | |
| const content = await readFile(lockfilePath, 'utf8'); | |
| const directNames = new Set([...directNamesFromPackageJson, ...collectPnpmImporterDependencyNames(content)]); | |
| const dependencies = []; | |
| for (const key of collectPnpmPackageKeys(content)) { | |
| const parsed = parsePnpmPackageKey(key); | |
| if (!parsed) { | |
| continue; | |
| } | |
| const dependency = createLockfileDependency({ | |
| repoPath, | |
| lockfilePath, | |
| sourceType: 'pnpm-lock.yaml', | |
| name: parsed.name, | |
| installedVersion: parsed.version, | |
| directNames, | |
| section: 'packages', | |
| }); | |
| if (dependency) { | |
| dependencies.push(dependency); | |
| } | |
| } | |
| return uniqueDependencies(dependencies); | |
| } | |
| function collectPnpmImporterDependencyNames(content) { | |
| const names = new Set(); | |
| let inImporters = false; | |
| let dependencySectionIndent = -1; | |
| let dependencyNameIndent = -1; | |
| for (const line of content.split(/\r?\n/)) { | |
| if (!line.trim() || line.trimStart().startsWith('#')) { | |
| continue; | |
| } | |
| const indent = countLeadingSpaces(line); | |
| const trimmed = line.trim(); | |
| if (indent === 0) { | |
| inImporters = trimmed === 'importers:'; | |
| dependencySectionIndent = -1; | |
| dependencyNameIndent = -1; | |
| continue; | |
| } | |
| if (!inImporters) { | |
| continue; | |
| } | |
| const sectionMatch = trimmed.match(/^(dependencies|devDependencies|optionalDependencies|peerDependencies):$/); | |
| if (sectionMatch) { | |
| dependencySectionIndent = indent; | |
| dependencyNameIndent = indent + 2; | |
| continue; | |
| } | |
| if (dependencySectionIndent !== -1 && indent <= dependencySectionIndent) { | |
| dependencySectionIndent = -1; | |
| dependencyNameIndent = -1; | |
| continue; | |
| } | |
| if (dependencyNameIndent !== -1 && indent === dependencyNameIndent) { | |
| const key = parseYamlKeyFromLine(trimmed); | |
| if (key) { | |
| names.add(key); | |
| } | |
| } | |
| } | |
| return names; | |
| } | |
| function collectPnpmPackageKeys(content) { | |
| const keys = []; | |
| let inPackages = false; | |
| for (const line of content.split(/\r?\n/)) { | |
| if (!line.trim() || line.trimStart().startsWith('#')) { | |
| continue; | |
| } | |
| const indent = countLeadingSpaces(line); | |
| const trimmed = line.trim(); | |
| if (indent === 0) { | |
| inPackages = trimmed === 'packages:'; | |
| continue; | |
| } | |
| if (!inPackages || indent !== 2) { | |
| continue; | |
| } | |
| const key = parseYamlKeyFromLine(trimmed); | |
| if (key) { | |
| keys.push(key); | |
| } | |
| } | |
| return keys; | |
| } | |
| async function collectYarnLockDependencies(lockfilePath, repoPath, directNames) { | |
| const content = await readFile(lockfilePath, 'utf8'); | |
| const dependencies = []; | |
| let currentNames = []; | |
| for (const line of content.split(/\r?\n/)) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith('#')) { | |
| continue; | |
| } | |
| if (!line.startsWith(' ') && trimmed.endsWith(':')) { | |
| currentNames = splitYarnDescriptors(trimmed.slice(0, -1)) | |
| .map(parseYarnDescriptorName) | |
| .filter(Boolean); | |
| continue; | |
| } | |
| const version = parseYarnVersionLine(trimmed); | |
| if (!version || currentNames.length === 0) { | |
| continue; | |
| } | |
| for (const name of unique(currentNames)) { | |
| const dependency = createLockfileDependency({ | |
| repoPath, | |
| lockfilePath, | |
| sourceType: 'yarn.lock', | |
| name, | |
| installedVersion: version, | |
| directNames, | |
| section: 'lockfile', | |
| }); | |
| if (dependency) { | |
| dependencies.push(dependency); | |
| } | |
| } | |
| } | |
| return uniqueDependencies(dependencies); | |
| } | |
| function createLockfileDependency({ | |
| repoPath, | |
| lockfilePath, | |
| sourceType, | |
| name, | |
| installedVersion, | |
| directNames, | |
| section, | |
| forceDependencyType, | |
| }) { | |
| const version = normalizeVersion(String(installedVersion ?? '')); | |
| if (!name || !version) { | |
| return null; | |
| } | |
| return { | |
| repoPath, | |
| sourceFilePath: lockfilePath, | |
| packageJsonPath: '', | |
| lockfilePath, | |
| sourceType, | |
| dependencyType: forceDependencyType ?? (directNames.has(name) ? 'direct' : 'transitive'), | |
| section, | |
| name, | |
| versionSpec: version, | |
| installedVersion: version, | |
| }; | |
| } | |
| function detectLockfileSection(packageInfo) { | |
| if (!packageInfo || typeof packageInfo !== 'object') { | |
| return 'lockfile'; | |
| } | |
| if (packageInfo.optional) { | |
| return 'optional'; | |
| } | |
| if (packageInfo.dev) { | |
| return 'dev'; | |
| } | |
| return 'prod'; | |
| } | |
| function extractPackageNameFromNodeModulesPath(packagePath) { | |
| const parts = packagePath.replace(/\\/g, '/').split('/').filter(Boolean); | |
| let packageName = ''; | |
| for (let index = 0; index < parts.length; index += 1) { | |
| if (parts[index] !== 'node_modules') { | |
| continue; | |
| } | |
| const next = parts[index + 1]; | |
| if (!next) { | |
| continue; | |
| } | |
| packageName = next.startsWith('@') && parts[index + 2] ? `${next}/${parts[index + 2]}` : next; | |
| } | |
| return packageName; | |
| } | |
| function parsePnpmPackageKey(rawKey) { | |
| let key = unquote(rawKey) | |
| .replace(/^\//, '') | |
| .replace(/\(.+$/, '') | |
| .trim(); | |
| if (!key || key.includes('link:') || key.includes('file:')) { | |
| return null; | |
| } | |
| if (key.includes('/')) { | |
| const slashParts = key.split('/'); | |
| const lastPart = slashParts.at(-1); | |
| if (/^\d/.test(lastPart) && slashParts.length >= 2) { | |
| const name = slashParts[0].startsWith('@') ? `${slashParts[0]}/${slashParts[1]}` : slashParts[0]; | |
| return { name, version: lastPart }; | |
| } | |
| } | |
| const versionSeparator = key.lastIndexOf('@'); | |
| if (versionSeparator <= 0) { | |
| return null; | |
| } | |
| const name = key.slice(0, versionSeparator); | |
| let version = key.slice(versionSeparator + 1); | |
| if (version.startsWith('npm:')) { | |
| const aliasSeparator = version.lastIndexOf('@'); | |
| version = aliasSeparator === -1 ? '' : version.slice(aliasSeparator + 1); | |
| } | |
| version = version.split('_')[0]; | |
| if (!name || !version || !/^\d/.test(version)) { | |
| return null; | |
| } | |
| return { name, version }; | |
| } | |
| function parseYamlKeyFromLine(trimmedLine) { | |
| const match = trimmedLine.match(/^(.+?):(?:\s|$)/); | |
| if (!match) { | |
| return ''; | |
| } | |
| return unquote(match[1].trim()); | |
| } | |
| function splitYarnDescriptors(header) { | |
| const descriptors = []; | |
| let current = ''; | |
| let quote = ''; | |
| for (let index = 0; index < header.length; index += 1) { | |
| const char = header[index]; | |
| if ((char === '"' || char === "'") && header[index - 1] !== '\\') { | |
| quote = quote === char ? '' : char; | |
| current += char; | |
| continue; | |
| } | |
| if (char === ',' && !quote) { | |
| descriptors.push(current.trim()); | |
| current = ''; | |
| continue; | |
| } | |
| current += char; | |
| } | |
| if (current.trim()) { | |
| descriptors.push(current.trim()); | |
| } | |
| return descriptors.map(unquote); | |
| } | |
| function parseYarnDescriptorName(descriptor) { | |
| const value = unquote(descriptor).trim(); | |
| if (!value || value === '__metadata') { | |
| return ''; | |
| } | |
| const npmProtocolIndex = value.indexOf('@npm:'); | |
| const dependencyPart = npmProtocolIndex === -1 ? value : value.slice(0, npmProtocolIndex); | |
| const separator = dependencyPart.startsWith('@') | |
| ? dependencyPart.indexOf('@', dependencyPart.indexOf('/') + 1) | |
| : dependencyPart.indexOf('@'); | |
| return separator === -1 ? dependencyPart : dependencyPart.slice(0, separator); | |
| } | |
| function parseYarnVersionLine(trimmedLine) { | |
| const quotedMatch = trimmedLine.match(/^version\s+"([^"]+)"/); | |
| if (quotedMatch) { | |
| return quotedMatch[1]; | |
| } | |
| const colonMatch = trimmedLine.match(/^version:\s*"?([^"]+?)"?$/); | |
| return colonMatch ? colonMatch[1] : ''; | |
| } | |
| function countLeadingSpaces(value) { | |
| return value.length - value.trimStart().length; | |
| } | |
| function unquote(value) { | |
| const trimmed = value.trim(); | |
| if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) { | |
| return trimmed.slice(1, -1); | |
| } | |
| return trimmed; | |
| } | |
| function uniqueDependencies(dependencies) { | |
| const seen = new Set(); | |
| const result = []; | |
| for (const dependency of dependencies) { | |
| const key = `${dependency.sourceFilePath}\0${dependency.name}\0${dependency.installedVersion}\0${dependency.dependencyType}`; | |
| if (seen.has(key)) { | |
| continue; | |
| } | |
| seen.add(key); | |
| result.push(dependency); | |
| } | |
| return result; | |
| } | |
| function matchCompromisedDependencies(dependencies, compromisedPackages) { | |
| const reports = []; | |
| for (const dependency of dependencies) { | |
| const compromised = compromisedPackages.get(dependency.name); | |
| if (!compromised) { | |
| continue; | |
| } | |
| const match = dependency.installedVersion | |
| ? classifyInstalledVersionMatch(dependency.installedVersion, compromised.versions) | |
| : classifyVersionMatch(dependency.versionSpec, compromised.versions); | |
| reports.push({ | |
| ...dependency, | |
| compromisedVersions: compromised.versions, | |
| notes: compromised.notes, | |
| matchType: match.type, | |
| matchedVersions: match.versions, | |
| }); | |
| } | |
| return reports; | |
| } | |
| function classifyVersionMatch(versionSpec, compromisedVersions) { | |
| const normalizedSpec = normalizeDependencySpec(versionSpec); | |
| if (compromisedVersions.length === 0) { | |
| return { type: 'nome encontrado; CSV sem versao', versions: [] }; | |
| } | |
| if (!normalizedSpec) { | |
| return { type: 'nome encontrado; versao local nao comparavel', versions: [] }; | |
| } | |
| const matchedVersions = compromisedVersions.filter((version) => satisfiesVersionSpec(version, normalizedSpec)); | |
| if (matchedVersions.length > 0) { | |
| return { type: 'versao possivelmente afetada', versions: matchedVersions }; | |
| } | |
| return { type: 'nome encontrado; versao nao parece afetada', versions: [] }; | |
| } | |
| function classifyInstalledVersionMatch(installedVersion, compromisedVersions) { | |
| const normalizedInstalledVersion = normalizeVersion(installedVersion); | |
| if (compromisedVersions.length === 0) { | |
| return { type: 'nome encontrado; CSV sem versao', versions: [] }; | |
| } | |
| if (!normalizedInstalledVersion) { | |
| return { type: 'nome encontrado; versao instalada nao comparavel', versions: [] }; | |
| } | |
| const matchedVersions = compromisedVersions.filter( | |
| (version) => normalizeVersion(version) === normalizedInstalledVersion, | |
| ); | |
| if (matchedVersions.length > 0) { | |
| return { type: 'versao instalada afetada', versions: matchedVersions }; | |
| } | |
| return { type: 'nome encontrado; versao instalada nao afetada', versions: [] }; | |
| } | |
| function normalizeDependencySpec(spec) { | |
| const value = spec.trim(); | |
| if (!value || value === '*' || value.toLowerCase() === 'latest') { | |
| return value; | |
| } | |
| if (/^(workspace:|file:|link:|portal:|git\+|https?:|github:|gitlab:|bitbucket:)/i.test(value)) { | |
| return ''; | |
| } | |
| if (value.startsWith('npm:')) { | |
| const aliasVersion = value.split('@').at(-1); | |
| return aliasVersion && aliasVersion !== value ? aliasVersion : ''; | |
| } | |
| return value.replace(/^v/i, ''); | |
| } | |
| function satisfiesVersionSpec(version, spec) { | |
| const normalizedVersion = normalizeVersion(version); | |
| if (!normalizedVersion || !spec) { | |
| return false; | |
| } | |
| if (spec === '*' || spec.toLowerCase() === 'latest') { | |
| return true; | |
| } | |
| return spec | |
| .split('||') | |
| .some((part) => satisfiesSingleRange(normalizedVersion, part.trim())); | |
| } | |
| function satisfiesSingleRange(version, range) { | |
| if (!range) { | |
| return false; | |
| } | |
| const hyphenMatch = range.match(/^(.+)\s+-\s+(.+)$/); | |
| if (hyphenMatch) { | |
| return compareVersions(version, hyphenMatch[1].trim()) >= 0 && compareVersions(version, hyphenMatch[2].trim()) <= 0; | |
| } | |
| const comparators = range.match(/(?:<=|>=|<|>|=|\^|~)?\s*v?\d+(?:\.\d+|\.x|\.\*)?(?:\.\d+|\.x|\.\*)?(?:[-+][0-9A-Za-z.-]+)?|[xX*]/g); | |
| if (!comparators) { | |
| return normalizeVersion(range) === version; | |
| } | |
| return comparators.every((comparator) => satisfiesComparator(version, comparator.trim())); | |
| } | |
| function satisfiesComparator(version, comparator) { | |
| if (!comparator || comparator === '*' || /^[xX]$/.test(comparator)) { | |
| return true; | |
| } | |
| const operatorMatch = comparator.match(/^(<=|>=|<|>|=|\^|~)?\s*(.+)$/); | |
| const operator = operatorMatch?.[1] ?? ''; | |
| const target = normalizeVersion(operatorMatch?.[2] ?? comparator); | |
| if (/[xX*]/.test(target)) { | |
| return satisfiesWildcard(version, target); | |
| } | |
| if (operator === '^') { | |
| return satisfiesCaret(version, target); | |
| } | |
| if (operator === '~') { | |
| return satisfiesTilde(version, target); | |
| } | |
| const comparison = compareVersions(version, target); | |
| if (operator === '>') { | |
| return comparison > 0; | |
| } | |
| if (operator === '>=') { | |
| return comparison >= 0; | |
| } | |
| if (operator === '<') { | |
| return comparison < 0; | |
| } | |
| if (operator === '<=') { | |
| return comparison <= 0; | |
| } | |
| return comparison === 0; | |
| } | |
| function satisfiesWildcard(version, target) { | |
| const versionParts = parseVersion(version); | |
| const targetParts = target.split('.'); | |
| return targetParts.every((part, index) => { | |
| if (part === '*' || /^x$/i.test(part)) { | |
| return true; | |
| } | |
| return Number(part) === versionParts[index]; | |
| }); | |
| } | |
| function satisfiesCaret(version, target) { | |
| const lower = parseVersion(target); | |
| const upper = [...lower]; | |
| if (lower[0] > 0) { | |
| upper[0] += 1; | |
| upper[1] = 0; | |
| upper[2] = 0; | |
| } else if (lower[1] > 0) { | |
| upper[1] += 1; | |
| upper[2] = 0; | |
| } else { | |
| upper[2] += 1; | |
| } | |
| return compareVersionParts(parseVersion(version), lower) >= 0 && compareVersionParts(parseVersion(version), upper) < 0; | |
| } | |
| function satisfiesTilde(version, target) { | |
| const lower = parseVersion(target); | |
| const upper = [...lower]; | |
| upper[1] += 1; | |
| upper[2] = 0; | |
| return compareVersionParts(parseVersion(version), lower) >= 0 && compareVersionParts(parseVersion(version), upper) < 0; | |
| } | |
| function compareVersions(left, right) { | |
| return compareVersionParts(parseVersion(left), parseVersion(right)); | |
| } | |
| function compareVersionParts(left, right) { | |
| for (let index = 0; index < 3; index += 1) { | |
| if (left[index] > right[index]) { | |
| return 1; | |
| } | |
| if (left[index] < right[index]) { | |
| return -1; | |
| } | |
| } | |
| return 0; | |
| } | |
| function parseVersion(version) { | |
| const [major = '0', minor = '0', patch = '0'] = normalizeVersion(version).split(/[.-]/); | |
| return [major, minor, patch].map((part) => { | |
| const value = Number.parseInt(part, 10); | |
| return Number.isFinite(value) ? value : 0; | |
| }); | |
| } | |
| function printResult({ | |
| baseDir, | |
| csvPath, | |
| compromisedPackages, | |
| packageJsonPaths, | |
| lockfilePaths, | |
| allPackages, | |
| reports, | |
| listPackages, | |
| json, | |
| onlyLikely, | |
| }) { | |
| const visibleReports = onlyLikely ? reports.filter(isLikelyAffectedReport) : reports; | |
| const directDependencyCount = allPackages.filter((dependency) => dependency.sourceType === 'package.json').length; | |
| const lockfileDependencyCount = allPackages.length - directDependencyCount; | |
| if (json) { | |
| console.log( | |
| JSON.stringify( | |
| { | |
| baseDir, | |
| csvPath, | |
| packageJsonCount: packageJsonPaths.length, | |
| lockfileCount: lockfilePaths.length, | |
| dependencyCount: allPackages.length, | |
| directDependencyCount, | |
| lockfileDependencyCount, | |
| compromisedPackageCount: compromisedPackages.size, | |
| affectedRepositoryCount: new Set(visibleReports.map((report) => report.repoPath)).size, | |
| matches: visibleReports, | |
| packages: listPackages ? allPackages : undefined, | |
| }, | |
| null, | |
| 2, | |
| ), | |
| ); | |
| return; | |
| } | |
| console.log(`Pasta analisada: ${baseDir}`); | |
| console.log(`CSV analisado: ${csvPath}`); | |
| console.log(`Pacotes comprometidos no CSV: ${compromisedPackages.size}`); | |
| console.log(`package.json analisados: ${packageJsonPaths.length}`); | |
| console.log(`lockfiles analisados: ${lockfilePaths.length}`); | |
| console.log(`Dependencias diretas encontradas: ${directDependencyCount}`); | |
| console.log(`Dependencias resolvidas em lockfiles: ${lockfileDependencyCount}`); | |
| console.log( | |
| `${onlyLikely ? 'Repositorios com versao possivelmente afetada' : 'Repositorios com pacote citado no CSV'}: ${ | |
| new Set(visibleReports.map((report) => report.repoPath)).size | |
| }`, | |
| ); | |
| console.log(''); | |
| if (listPackages) { | |
| console.log('Pacotes encontrados:'); | |
| for (const dependency of allPackages) { | |
| console.log(`- ${dependency.name}@${dependency.installedVersion || dependency.versionSpec}`); | |
| console.log(` Repositorio: ${dependency.repoPath}`); | |
| console.log(` Origem: ${dependency.sourceType}`); | |
| console.log(` Arquivo: ${dependency.sourceFilePath}`); | |
| console.log(` Tipo: ${dependency.dependencyType}`); | |
| console.log(` Secao: ${dependency.section}`); | |
| } | |
| console.log(''); | |
| } | |
| if (visibleReports.length === 0) { | |
| console.log( | |
| onlyLikely | |
| ? 'Nenhuma versao possivelmente comprometida foi encontrada nos package.json/lockfiles analisados.' | |
| : 'Nenhum pacote comprometido foi encontrado nos package.json/lockfiles analisados.', | |
| ); | |
| return; | |
| } | |
| console.log('Possiveis repositorios comprometidos:'); | |
| for (const report of visibleReports) { | |
| const matchedVersions = report.matchedVersions.length > 0 ? report.matchedVersions.join(', ') : 'sem match exato'; | |
| const compromisedVersions = report.compromisedVersions.length > 0 ? report.compromisedVersions.join(', ') : 'nao informado'; | |
| console.log(`- Repositorio: ${report.repoPath}`); | |
| console.log(` Arquivo: ${report.sourceFilePath}`); | |
| console.log(` Origem: ${report.sourceType}`); | |
| console.log(` Tipo: ${report.dependencyType}`); | |
| console.log(` Pacote: ${report.name}`); | |
| console.log(` Secao: ${report.section}`); | |
| console.log( | |
| report.installedVersion | |
| ? ` Versao instalada/resolvida: ${report.installedVersion}` | |
| : ` Versao declarada no package.json: ${report.versionSpec}`, | |
| ); | |
| console.log(` Versoes comprometidas no CSV: ${compromisedVersions}`); | |
| console.log(` Resultado: ${report.matchType}`); | |
| console.log(` Versoes que bateram: ${matchedVersions}`); | |
| if (report.notes) { | |
| console.log(` Notas do CSV: ${report.notes}`); | |
| } | |
| } | |
| } | |
| function isLikelyAffectedReport(report) { | |
| return ( | |
| report.matchType === 'versao possivelmente afetada' || | |
| report.matchType === 'versao instalada afetada' || | |
| report.matchType === 'nome encontrado; CSV sem versao' || | |
| report.matchType === 'nome encontrado; versao local nao comparavel' || | |
| report.matchType === 'nome encontrado; versao instalada nao comparavel' | |
| ); | |
| } | |
| function unique(values) { | |
| return [...new Set(values)]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment