#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import { ESLint } from 'eslint'; import { spawnSync } from 'child_process'; import tmp from 'tmp'; import semver from 'semver'; import axios from 'axios'; import glob from 'fast-glob'; import { fileURLToPath } from 'url'; import { defineConfig } from 'eslint/config'; const { stdout } = process; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEMP_DIR = tmp.dirSync({ unsafeCleanup: true }).name; const registry = 'https://registry.npmjs.org/'; /** * Checks if the given childPath is contained within the parentPath. Resolves * the paths before comparing them, so that relative paths are also supported. */ export function isContainedWithin(parentPath, childPath) { parentPath = path.resolve(parentPath); childPath = path.resolve(childPath); if (parentPath === childPath) { return true; } return childPath.startsWith(parentPath + path.sep); } /** * Joins the given paths to the parentPath, ensuring that the resulting path * is still contained within the parentPath. If not, it throws an error to * prevent path traversal vulnerabilities. * * @throws {UnexpectedError} If the resulting path is not contained within the parentPath. */ export function safeJoinPath(parentPath, ...paths) { const candidate = path.join(parentPath, ...paths); if (!isContainedWithin(parentPath, candidate)) { throw new Error( `Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`, ); } return candidate; } export const resolvePackage = (packageSpec) => { // Validate input to prevent command injection if (!/^[a-zA-Z0-9@/_.-]+$/.test(packageSpec)) { throw new Error('Invalid package specification'); } let packageName, version; if (packageSpec.startsWith('@')) { if (packageSpec.includes('@', 1)) { // Handle scoped packages with versions const lastAtIndex = packageSpec.lastIndexOf('@'); return { packageName: packageSpec.substring(0, lastAtIndex), version: packageSpec.substring(lastAtIndex + 1), }; } else { // Handle scoped packages without version return { packageName: packageSpec, version: null }; } } // Handle regular packages const parts = packageSpec.split('@'); return { packageName: parts[0], version: parts[1] || null }; }; const downloadAndExtractPackage = async (packageName, version) => { try { // Download the tarball using safe arguments const npmResult = spawnSync('npm', ['-q', 'pack', `${packageName}@${version}`], { cwd: TEMP_DIR, stdio: 'pipe', shell: process.platform === 'win32', }); if (npmResult.status !== 0) { throw new Error(`npm pack failed: ${npmResult.stderr?.toString()}`); } const tarballName = fs.readdirSync(TEMP_DIR).find((file) => file.endsWith('.tgz')); if (!tarballName) { throw new Error('Tarball not found'); } // Unpack the tarball const packageDir = safeJoinPath(TEMP_DIR, `${packageName}-${version}`); fs.mkdirSync(packageDir, { recursive: true }); const tarResult = spawnSync( 'tar', ['-xzf', tarballName, '-C', packageDir, '--strip-components=1'], { cwd: TEMP_DIR, stdio: 'pipe', shell: process.platform === 'win32', }, ); if (tarResult.status !== 0) { throw new Error(`tar extraction failed: ${tarResult.stderr?.toString()}`); } fs.unlinkSync(safeJoinPath(TEMP_DIR, tarballName)); return packageDir; } catch (error) { console.error(`\nFailed to download package: ${error.message}`); throw error; } }; const analyzePackage = async (packageDir) => { const { n8nCommunityNodesPlugin } = await import('@n8n/eslint-plugin-community-nodes'); const eslint = new ESLint({ cwd: packageDir, allowInlineConfig: false, overrideConfigFile: true, overrideConfig: defineConfig(n8nCommunityNodesPlugin.configs.recommended, { rules: { 'no-console': 'error' }, }), }); try { const jsFiles = glob.sync('**/*.js', { cwd: packageDir, absolute: true, ignore: ['node_modules/**'], }); if (jsFiles.length === 0) { return { passed: true, message: 'No JavaScript files found to analyze' }; } const results = await eslint.lintFiles(jsFiles); const violations = results.filter((result) => result.errorCount > 0); if (violations.length > 0) { const formatter = await eslint.loadFormatter('stylish'); const formattedResults = await formatter.format(results); return { passed: false, message: 'ESLint violations found', details: formattedResults, }; } return { passed: true }; } catch (error) { console.error(error); return { passed: false, message: `Analysis failed: ${error.message}`, error, }; } }; export const analyzePackageByName = async (packageName, version) => { try { let exactVersion = version; // If version is a range, get the latest matching version if (version && semver.validRange(version) && !semver.valid(version)) { const { data } = await axios.get(`${registry}/${packageName}`); const versions = Object.keys(data.versions); exactVersion = semver.maxSatisfying(versions, version); if (!exactVersion) { throw new Error(`No version found matching ${version}`); } } // If no version specified, get the latest if (!exactVersion) { const { data } = await axios.get(`${registry}/${packageName}`); exactVersion = data['dist-tags'].latest; } const label = `${packageName}@${exactVersion}`; stdout.write(`Downloading ${label}...`); const packageDir = await downloadAndExtractPackage(packageName, exactVersion); if (stdout.TTY) { stdout.clearLine(0); stdout.cursorTo(0); } stdout.write(`✅ Downloaded ${label} \n`); stdout.write(`Analyzing ${label}...`); const analysisResult = await analyzePackage(packageDir); if (stdout.TTY) { stdout.clearLine(0); stdout.cursorTo(0); } stdout.write(`✅ Analyzed ${label} \n`); return { packageName, version: exactVersion, ...analysisResult, }; } catch (error) { console.error(`Failed to analyze ${packageName}@${version}:`, error); return { packageName, version, passed: false, message: `Analysis failed: ${error.message}`, }; } };