@n8n/scan-community-package 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +20 -0
- package/scanner/eslint-plugin.mjs +87 -0
- package/scanner/scan-community-package.mjs +221 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@n8n/scan-community-package",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Static code analyser for n8n community packages",
|
|
5
|
+
"license": "none",
|
|
6
|
+
"packageManager": "pnpm@10.2.1",
|
|
7
|
+
"bin": "scanner/scan-community-package.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"scanner"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"eslint": "^9.22.0",
|
|
13
|
+
"fast-glob": "^3.3.3",
|
|
14
|
+
"express": "^4.21.2",
|
|
15
|
+
"body-parser": "^1.20.3",
|
|
16
|
+
"axios": "^1.8.4",
|
|
17
|
+
"semver": "^7.7.1",
|
|
18
|
+
"tmp": "^0.2.3"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const restrictedGlobals = [
|
|
2
|
+
"clearInterval",
|
|
3
|
+
"clearTimeout",
|
|
4
|
+
"global",
|
|
5
|
+
"process",
|
|
6
|
+
"setInterval",
|
|
7
|
+
"setTimeout",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const allowedModules = [
|
|
11
|
+
"n8n-workflow",
|
|
12
|
+
"lodash",
|
|
13
|
+
"moment",
|
|
14
|
+
"p-limit",
|
|
15
|
+
"luxon",
|
|
16
|
+
"zod",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const isModuleAllowed = (modulePath) => {
|
|
20
|
+
// Allow relative paths
|
|
21
|
+
if (modulePath.startsWith("./") || modulePath.startsWith("../")) return true;
|
|
22
|
+
|
|
23
|
+
// Extract module name from imports that might contain additional path
|
|
24
|
+
const moduleName = modulePath.startsWith("@")
|
|
25
|
+
? modulePath.split("/").slice(0, 2).join("/")
|
|
26
|
+
: modulePath.split("/")[0];
|
|
27
|
+
return allowedModules.includes(moduleName);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** @type {import('@types/eslint').ESLint.Plugin} */
|
|
31
|
+
const plugin = {
|
|
32
|
+
rules: {
|
|
33
|
+
"no-restricted-globals": {
|
|
34
|
+
create(context) {
|
|
35
|
+
return {
|
|
36
|
+
Identifier(node) {
|
|
37
|
+
if (
|
|
38
|
+
restrictedGlobals.includes(node.name) &&
|
|
39
|
+
(!node.parent ||
|
|
40
|
+
node.parent.type !== "MemberExpression" ||
|
|
41
|
+
node.parent.object === node)
|
|
42
|
+
) {
|
|
43
|
+
context.report({
|
|
44
|
+
node,
|
|
45
|
+
message: `Use of restricted global '${node.name}' is not allowed`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
"no-restricted-imports": {
|
|
54
|
+
create(context) {
|
|
55
|
+
return {
|
|
56
|
+
ImportDeclaration(node) {
|
|
57
|
+
const modulePath = node.source.value;
|
|
58
|
+
if (!isModuleAllowed(modulePath)) {
|
|
59
|
+
context.report({
|
|
60
|
+
node,
|
|
61
|
+
message: `Import of '${modulePath}' is not allowed.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
CallExpression(node) {
|
|
67
|
+
if (
|
|
68
|
+
node.callee.name === "require" &&
|
|
69
|
+
node.arguments.length > 0 &&
|
|
70
|
+
node.arguments[0].type === "Literal"
|
|
71
|
+
) {
|
|
72
|
+
const modulePath = node.arguments[0].value;
|
|
73
|
+
if (!isModuleAllowed(modulePath)) {
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
message: `Require of '${modulePath}' is not allowed.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default plugin;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { ESLint } from "eslint";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import tmp from "tmp";
|
|
8
|
+
import semver from "semver";
|
|
9
|
+
import axios from "axios";
|
|
10
|
+
import glob from "fast-glob";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { defineConfig } from "eslint/config";
|
|
13
|
+
|
|
14
|
+
import plugin from "./eslint-plugin.mjs";
|
|
15
|
+
|
|
16
|
+
const { stdout } = process;
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const TEMP_DIR = tmp.dirSync({ unsafeCleanup: true }).name;
|
|
19
|
+
const registry = "https://registry.npmjs.org/";
|
|
20
|
+
|
|
21
|
+
async function downloadAndExtractPackage(packageName, version) {
|
|
22
|
+
try {
|
|
23
|
+
// Download the tarball
|
|
24
|
+
execSync(`npm -q pack ${packageName}@${version}`, { cwd: TEMP_DIR });
|
|
25
|
+
const tarballName = fs
|
|
26
|
+
.readdirSync(TEMP_DIR)
|
|
27
|
+
.find((file) => file.endsWith(".tgz"));
|
|
28
|
+
if (!tarballName) {
|
|
29
|
+
throw new Error("Tarball not found");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Unpack the tarball
|
|
33
|
+
const packageDir = path.join(TEMP_DIR, `${packageName}-${version}`);
|
|
34
|
+
fs.mkdirSync(packageDir, { recursive: true });
|
|
35
|
+
execSync(`tar -xzf ${tarballName} -C ${packageDir} --strip-components=1`, {
|
|
36
|
+
cwd: TEMP_DIR,
|
|
37
|
+
});
|
|
38
|
+
fs.unlinkSync(path.join(TEMP_DIR, tarballName));
|
|
39
|
+
|
|
40
|
+
return packageDir;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`\nFailed to download package: ${error.message}`);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function analyzePackage(packageDir) {
|
|
48
|
+
const { default: eslintPlugin } = await import("./eslint-plugin.mjs");
|
|
49
|
+
const eslint = new ESLint({
|
|
50
|
+
cwd: packageDir,
|
|
51
|
+
allowInlineConfig: false,
|
|
52
|
+
overrideConfigFile: true,
|
|
53
|
+
overrideConfig: defineConfig([
|
|
54
|
+
{
|
|
55
|
+
plugins: {
|
|
56
|
+
"n8n-community-packages": plugin,
|
|
57
|
+
},
|
|
58
|
+
rules: {
|
|
59
|
+
"n8n-community-packages/no-restricted-globals": "error",
|
|
60
|
+
"n8n-community-packages/no-restricted-imports": "error",
|
|
61
|
+
},
|
|
62
|
+
languageOptions: {
|
|
63
|
+
parserOptions: {
|
|
64
|
+
ecmaVersion: 2022,
|
|
65
|
+
sourceType: "commonjs",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
]),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const jsFiles = glob.sync("**/*.js", {
|
|
74
|
+
cwd: packageDir,
|
|
75
|
+
absolute: true,
|
|
76
|
+
ignore: ["node_modules/**"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (jsFiles.length === 0) {
|
|
80
|
+
return { passed: true, message: "No JavaScript files found to analyze" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const results = await eslint.lintFiles(jsFiles);
|
|
84
|
+
const violations = results.filter((result) => result.errorCount > 0);
|
|
85
|
+
|
|
86
|
+
if (violations.length > 0) {
|
|
87
|
+
const formatter = await eslint.loadFormatter("stylish");
|
|
88
|
+
const formattedResults = await formatter.format(results);
|
|
89
|
+
return {
|
|
90
|
+
passed: false,
|
|
91
|
+
message: "ESLint violations found",
|
|
92
|
+
details: formattedResults,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { passed: true };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(error);
|
|
99
|
+
return {
|
|
100
|
+
passed: false,
|
|
101
|
+
message: `Analysis failed: ${error.message}`,
|
|
102
|
+
error,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function analyzePackageByName(packageName, version) {
|
|
108
|
+
try {
|
|
109
|
+
let exactVersion = version;
|
|
110
|
+
|
|
111
|
+
// If version is a range, get the latest matching version
|
|
112
|
+
if (version && semver.validRange(version) && !semver.valid(version)) {
|
|
113
|
+
const { data } = await axios.get(`${registry}/${packageName}`);
|
|
114
|
+
const versions = Object.keys(data.versions);
|
|
115
|
+
exactVersion = semver.maxSatisfying(versions, version);
|
|
116
|
+
|
|
117
|
+
if (!exactVersion) {
|
|
118
|
+
throw new Error(`No version found matching ${version}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// If no version specified, get the latest
|
|
123
|
+
if (!exactVersion) {
|
|
124
|
+
const { data } = await axios.get(`${registry}/${packageName}`);
|
|
125
|
+
exactVersion = data["dist-tags"].latest;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const label = `${packageName}@${exactVersion}`;
|
|
129
|
+
|
|
130
|
+
stdout.write(`Downloading ${label}...`);
|
|
131
|
+
const packageDir = await downloadAndExtractPackage(
|
|
132
|
+
packageName,
|
|
133
|
+
exactVersion,
|
|
134
|
+
);
|
|
135
|
+
stdout.clearLine(0);
|
|
136
|
+
stdout.cursorTo(0);
|
|
137
|
+
stdout.write(`✅ Downloaded ${label} \n`);
|
|
138
|
+
|
|
139
|
+
stdout.write(`Analyzing ${label}...`);
|
|
140
|
+
const analysisResult = await analyzePackage(packageDir);
|
|
141
|
+
stdout.clearLine(0);
|
|
142
|
+
stdout.cursorTo(0);
|
|
143
|
+
stdout.write(`✅ Analyzed ${label} \n`);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
packageName,
|
|
147
|
+
version: exactVersion,
|
|
148
|
+
...analysisResult,
|
|
149
|
+
};
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(`Failed to analyze ${packageName}@${version}:`, error);
|
|
152
|
+
return {
|
|
153
|
+
packageName,
|
|
154
|
+
version,
|
|
155
|
+
passed: false,
|
|
156
|
+
message: `Analysis failed: ${error.message}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolvePackage = (packageSpec) => {
|
|
162
|
+
let packageName, version;
|
|
163
|
+
if (packageSpec.startsWith("@")) {
|
|
164
|
+
if (packageSpec.includes("@", 1)) {
|
|
165
|
+
// Handle scoped packages with versions
|
|
166
|
+
const lastAtIndex = packageSpec.lastIndexOf("@");
|
|
167
|
+
return {
|
|
168
|
+
packageName: packageSpec.substring(0, lastAtIndex),
|
|
169
|
+
version: packageSpec.substring(lastAtIndex + 1),
|
|
170
|
+
};
|
|
171
|
+
} else {
|
|
172
|
+
// Handle scoped packages without version
|
|
173
|
+
return { packageName: packageSpec, version: null };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Handle regular packages
|
|
177
|
+
const parts = packageSpec.split("@");
|
|
178
|
+
return { packageName: parts[0], version: parts[1] || null };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
const args = process.argv.slice(2);
|
|
183
|
+
|
|
184
|
+
if (args.length < 1) {
|
|
185
|
+
console.error(
|
|
186
|
+
"Usage: node scan-community-package.mjs <package-name>[@version]",
|
|
187
|
+
);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const packageSpec = args[0];
|
|
192
|
+
const { packageName, version } = resolvePackage(packageSpec);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const result = await analyzePackageByName(packageName, version);
|
|
196
|
+
|
|
197
|
+
if (result.passed) {
|
|
198
|
+
console.log(
|
|
199
|
+
`✅ Package ${packageName}@${result.version} has passed all security checks`,
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(
|
|
203
|
+
`❌ Package ${packageName}@${result.version} has failed security checks`,
|
|
204
|
+
);
|
|
205
|
+
console.log(`Reason: ${result.message}`);
|
|
206
|
+
|
|
207
|
+
if (result.details) {
|
|
208
|
+
console.log("\nDetails:");
|
|
209
|
+
console.log(result.details);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error("Analysis failed:", error);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// In ESM, check if the current file is being executed directly
|
|
219
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
220
|
+
void main();
|
|
221
|
+
}
|