Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
218 lines
6.0 KiB
JavaScript
218 lines
6.0 KiB
JavaScript
#!/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}`,
|
|
};
|
|
}
|
|
};
|