first commit
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
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
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: npx @n8n/scan-community-package <package-name>[@version]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import { resolvePackage, analyzePackageByName } from './scanner.mjs';
|
||||
|
||||
const packageSpec = args[0];
|
||||
const { packageName, version } = resolvePackage(packageSpec);
|
||||
try {
|
||||
const result = await analyzePackageByName(packageName, version);
|
||||
|
||||
if (result.passed) {
|
||||
console.log(`✅ Package ${packageName}@${result.version} has passed all security checks`);
|
||||
} else {
|
||||
console.log(`❌ Package ${packageName}@${result.version} has failed security checks`);
|
||||
console.log(`Reason: ${result.message}`);
|
||||
|
||||
if (result.details) {
|
||||
console.log('\nDetails:');
|
||||
console.log(result.details);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/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}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user