From 8dae565dc3400faf1507f8787231408cd530ec8e Mon Sep 17 00:00:00 2001 From: Ria Scholz <123465523+riascho@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:38:09 +0200 Subject: [PATCH] feat: Adds community scanner package (#18946) Co-authored-by: Elias Meire --- .../@n8n/scan-community-package/.gitignore | 1 + .../@n8n/scan-community-package/README.md | 7 + .../@n8n/scan-community-package/package.json | 17 ++ .../scan-community-package/scanner/cli.mjs | 36 ++++ .../scanner/eslint-plugin.mjs | 89 +++++++++ .../scanner/scanner.mjs | 183 ++++++++++++++++++ pnpm-lock.yaml | 62 +++--- 7 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 packages/@n8n/scan-community-package/.gitignore create mode 100644 packages/@n8n/scan-community-package/README.md create mode 100644 packages/@n8n/scan-community-package/package.json create mode 100755 packages/@n8n/scan-community-package/scanner/cli.mjs create mode 100644 packages/@n8n/scan-community-package/scanner/eslint-plugin.mjs create mode 100644 packages/@n8n/scan-community-package/scanner/scanner.mjs diff --git a/packages/@n8n/scan-community-package/.gitignore b/packages/@n8n/scan-community-package/.gitignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/packages/@n8n/scan-community-package/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/@n8n/scan-community-package/README.md b/packages/@n8n/scan-community-package/README.md new file mode 100644 index 0000000000..47d297f8d6 --- /dev/null +++ b/packages/@n8n/scan-community-package/README.md @@ -0,0 +1,7 @@ +## n8n community-package static analysis tool + +### How to use this + +``` +$ npx @n8n/scan-community-package n8n-nodes-PACKAGE +``` diff --git a/packages/@n8n/scan-community-package/package.json b/packages/@n8n/scan-community-package/package.json new file mode 100644 index 0000000000..e78d6d669e --- /dev/null +++ b/packages/@n8n/scan-community-package/package.json @@ -0,0 +1,17 @@ +{ + "name": "@n8n/scan-community-package", + "version": "0.1.2", + "description": "Static code analyser for n8n community packages", + "license": "none", + "bin": "scanner/cli.mjs", + "files": [ + "scanner" + ], + "dependencies": { + "eslint": "catalog:", + "fast-glob": "catalog:", + "axios": "catalog:", + "semver": "^7.5.4", + "tmp": "0.2.4" + } +} diff --git a/packages/@n8n/scan-community-package/scanner/cli.mjs b/packages/@n8n/scan-community-package/scanner/cli.mjs new file mode 100755 index 0000000000..0f313a33e5 --- /dev/null +++ b/packages/@n8n/scan-community-package/scanner/cli.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +const args = process.argv.slice(2); +if (args.length < 1) { + console.error( + "Usage: npx @n8n/scan-community-package [@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); +} diff --git a/packages/@n8n/scan-community-package/scanner/eslint-plugin.mjs b/packages/@n8n/scan-community-package/scanner/eslint-plugin.mjs new file mode 100644 index 0000000000..24924c9ea2 --- /dev/null +++ b/packages/@n8n/scan-community-package/scanner/eslint-plugin.mjs @@ -0,0 +1,89 @@ +const restrictedGlobals = [ + "clearInterval", + "clearTimeout", + "global", + "process", + "setInterval", + "setTimeout", +]; + +const allowedModules = [ + "n8n-workflow", + "lodash", + "moment", + "p-limit", + "luxon", + "zod", + "crypto", + "node:crypto" +]; + +const isModuleAllowed = (modulePath) => { + // Allow relative paths + if (modulePath.startsWith("./") || modulePath.startsWith("../")) return true; + + // Extract module name from imports that might contain additional path + const moduleName = modulePath.startsWith("@") + ? modulePath.split("/").slice(0, 2).join("/") + : modulePath.split("/")[0]; + return allowedModules.includes(moduleName); +}; + +/** @type {import('@types/eslint').ESLint.Plugin} */ +const plugin = { + rules: { + "no-restricted-globals": { + create(context) { + return { + Identifier(node) { + if ( + restrictedGlobals.includes(node.name) && + (!node.parent || + node.parent.type !== "MemberExpression" || + node.parent.object === node) + ) { + context.report({ + node, + message: `Use of restricted global '${node.name}' is not allowed`, + }); + } + }, + }; + }, + }, + + "no-restricted-imports": { + create(context) { + return { + ImportDeclaration(node) { + const modulePath = node.source.value; + if (!isModuleAllowed(modulePath)) { + context.report({ + node, + message: `Import of '${modulePath}' is not allowed.`, + }); + } + }, + + CallExpression(node) { + if ( + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === "Literal" + ) { + const modulePath = node.arguments[0].value; + if (!isModuleAllowed(modulePath)) { + context.report({ + node, + message: `Require of '${modulePath}' is not allowed.`, + }); + } + } + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/packages/@n8n/scan-community-package/scanner/scanner.mjs b/packages/@n8n/scan-community-package/scanner/scanner.mjs new file mode 100644 index 0000000000..5008544583 --- /dev/null +++ b/packages/@n8n/scan-community-package/scanner/scanner.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { ESLint } from "eslint"; +import { execSync } 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"; + +import plugin from "./eslint-plugin.mjs"; + +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/"; + +export const resolvePackage = (packageSpec) => { + 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 + execSync(`npm -q pack ${packageName}@${version}`, { cwd: TEMP_DIR }); + const tarballName = fs + .readdirSync(TEMP_DIR) + .find((file) => file.endsWith(".tgz")); + if (!tarballName) { + throw new Error("Tarball not found"); + } + + // Unpack the tarball + const packageDir = path.join(TEMP_DIR, `${packageName}-${version}`); + fs.mkdirSync(packageDir, { recursive: true }); + execSync(`tar -xzf ${tarballName} -C ${packageDir} --strip-components=1`, { + cwd: TEMP_DIR, + }); + fs.unlinkSync(path.join(TEMP_DIR, tarballName)); + + return packageDir; + } catch (error) { + console.error(`\nFailed to download package: ${error.message}`); + throw error; + } +}; + +const analyzePackage = async (packageDir) => { + const { default: eslintPlugin } = await import("./eslint-plugin.mjs"); + const eslint = new ESLint({ + cwd: packageDir, + allowInlineConfig: false, + overrideConfigFile: true, + overrideConfig: defineConfig([ + { + plugins: { + "n8n-community-packages": plugin, + }, + rules: { + "n8n-community-packages/no-restricted-globals": "error", + "n8n-community-packages/no-restricted-imports": "error", + }, + languageOptions: { + parserOptions: { + ecmaVersion: 2022, + sourceType: "commonjs", + }, + }, + }, + ]), + }); + + 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}`, + }; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54f2a2cfe9..e04b833c88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1002,7 +1002,7 @@ importers: version: 4.3.0 '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a)) + version: 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -1029,7 +1029,7 @@ importers: version: 0.3.4(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13) '@langchain/community': specifier: 'catalog:' - version: 0.3.50(7d9026709e640c92cdf2ea22646a0399) + version: 0.3.50(ccee17333f80550b1303d83de2b6f79a) '@langchain/core': specifier: 'catalog:' version: 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) @@ -1146,7 +1146,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.30 - version: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + version: 0.3.30(316b19288832115574731e049dc7676a) lodash: specifier: 'catalog:' version: 4.17.21 @@ -1252,6 +1252,24 @@ importers: specifier: workspace:* version: link:../typescript-config + packages/@n8n/scan-community-package: + dependencies: + axios: + specifier: 'catalog:' + version: 1.8.3 + eslint: + specifier: 'catalog:' + version: 9.29.0(jiti@1.21.7) + fast-glob: + specifier: 'catalog:' + version: 3.2.12 + semver: + specifier: ^7.5.4 + version: 7.7.2 + tmp: + specifier: 0.2.4 + version: 0.2.4 + packages/@n8n/storybook: devDependencies: '@chromatic-com/storybook': @@ -19109,7 +19127,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a))': dependencies: form-data: 4.0.4 node-fetch: 2.7.0(encoding@0.1.13) @@ -19118,7 +19136,7 @@ snapshots: zod: 3.25.67 optionalDependencies: '@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) - langchain: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + langchain: 0.3.30(316b19288832115574731e049dc7676a) transitivePeerDependencies: - encoding @@ -19672,7 +19690,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.50(7d9026709e640c92cdf2ea22646a0399)': + '@langchain/community@0.3.50(ccee17333f80550b1303d83de2b6f79a)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.54.2)(deepmerge@4.3.1)(dotenv@16.6.1)(encoding@0.1.13)(openai@5.12.2(ws@8.18.3)(zod@3.25.67))(zod@3.25.67) '@ibm-cloud/watsonx-ai': 1.1.2 @@ -19684,7 +19702,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.3.2 js-yaml: 4.1.0 - langchain: 0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a) + langchain: 0.3.30(316b19288832115574731e049dc7676a) langsmith: 0.3.55(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) openai: 5.12.2(ws@8.18.3)(zod@3.25.67) uuid: 10.0.0 @@ -19698,7 +19716,7 @@ snapshots: '@aws-sdk/credential-provider-node': 3.808.0 '@azure/storage-blob': 12.26.0 '@browserbasehq/sdk': 2.6.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(encoding@0.1.13)(langchain@0.3.30(316b19288832115574731e049dc7676a)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -20761,7 +20779,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) redoc: 2.4.0(core-js@3.40.0)(encoding@0.1.13)(mobx@6.12.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(styled-components@6.1.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) - semver: 7.6.0 + semver: 7.7.2 simple-websocket: 9.1.0 styled-components: 6.1.8(react-dom@18.2.0(react@18.2.0))(react@18.2.0) yargs: 17.0.1 @@ -23465,7 +23483,7 @@ snapshots: axios-retry@4.5.0(axios@1.11.0(debug@4.4.1)): dependencies: - axios: 1.11.0(debug@4.3.6) + axios: 1.11.0(debug@4.4.1) is-retry-allowed: 2.2.0 axios-retry@4.5.0(axios@1.11.0): @@ -23819,7 +23837,7 @@ snapshots: ioredis: 5.3.2 lodash: 4.17.21 msgpackr: 1.11.2 - semver: 7.6.0 + semver: 7.7.2 uuid: 8.3.2 transitivePeerDependencies: - supports-color @@ -25532,7 +25550,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.16.1 resolve: 1.22.10 transitivePeerDependencies: @@ -25556,7 +25574,7 @@ snapshots: eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2) eslint: 9.29.0(jiti@1.21.7) @@ -25595,7 +25613,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 9.29.0(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 @@ -26550,7 +26568,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 7.0.6 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -26901,7 +26919,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 20.19.10 '@types/tough-cookie': 4.0.5 - axios: 1.11.0(debug@4.3.6) + axios: 1.11.0(debug@4.4.1) camelcase: 6.3.0 debug: 4.4.1(supports-color@8.1.1) dotenv: 16.6.1 @@ -26911,7 +26929,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.11.0(debug@4.4.1)) + retry-axios: 2.6.0(axios@1.11.0) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -27038,7 +27056,7 @@ snapshots: fengari: 0.1.4 fengari-interop: 0.1.3(fengari@0.1.4) ioredis: 5.3.2 - semver: 7.6.0 + semver: 7.7.2 ioredis@5.3.2: dependencies: @@ -28164,7 +28182,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.30(e7c2f10ddf33088da1e6affdf0fc6c0a): + langchain@0.3.30(316b19288832115574731e049dc7676a): dependencies: '@langchain/core': 0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)) '@langchain/openai': 0.6.7(@langchain/core@0.3.68(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(openai@5.12.2(ws@8.18.3)(zod@3.25.67)))(ws@8.18.3) @@ -29955,7 +29973,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -30918,7 +30936,7 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - retry-axios@2.6.0(axios@1.11.0(debug@4.4.1)): + retry-axios@2.6.0(axios@1.11.0): dependencies: axios: 1.11.0(debug@4.3.6) @@ -30945,7 +30963,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color