feat: Adds community scanner package (#18946)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Ria Scholz
2025-08-29 12:38:09 +02:00
committed by GitHub
parent cec1255ffc
commit 8dae565dc3
7 changed files with 373 additions and 22 deletions

View File

@@ -0,0 +1 @@
node_modules

View File

@@ -0,0 +1,7 @@
## n8n community-package static analysis tool
### How to use this
```
$ npx @n8n/scan-community-package n8n-nodes-PACKAGE
```

View File

@@ -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"
}
}

View File

@@ -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 <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);
}

View File

@@ -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;

View File

@@ -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}`,
};
}
};

62
pnpm-lock.yaml generated
View File

@@ -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