@atom8n/scan-community-package 0.9.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 ADDED
@@ -0,0 +1,7 @@
1
+ ## n8n community-package static analysis tool
2
+
3
+ ### How to use this
4
+
5
+ ```
6
+ $ npx @n8n/scan-community-package n8n-nodes-PACKAGE
7
+ ```
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@atom8n/scan-community-package",
3
+ "version": "0.9.0",
4
+ "description": "Static code analyser for n8n community packages",
5
+ "license": "none",
6
+ "bin": "scanner/cli.mjs",
7
+ "files": [
8
+ "scanner"
9
+ ],
10
+ "dependencies": {
11
+ "eslint": "catalog:",
12
+ "fast-glob": "catalog:",
13
+ "axios": "catalog:",
14
+ "@atom8n/eslint-plugin-community-nodes": "0.7.0",
15
+ "semver": "^7.5.4",
16
+ "tmp": "0.2.4"
17
+ }
18
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ const args = process.argv.slice(2);
4
+ if (args.length < 1) {
5
+ console.error('Usage: npx @n8n/scan-community-package <package-name>[@version]');
6
+ process.exit(1);
7
+ }
8
+
9
+ import { resolvePackage, analyzePackageByName } from './scanner.mjs';
10
+
11
+ const packageSpec = args[0];
12
+ const { packageName, version } = resolvePackage(packageSpec);
13
+ try {
14
+ const result = await analyzePackageByName(packageName, version);
15
+
16
+ if (result.passed) {
17
+ console.log(`✅ Package ${packageName}@${result.version} has passed all security checks`);
18
+ } else {
19
+ console.log(`❌ Package ${packageName}@${result.version} has failed security checks`);
20
+ console.log(`Reason: ${result.message}`);
21
+
22
+ if (result.details) {
23
+ console.log('\nDetails:');
24
+ console.log(result.details);
25
+ }
26
+ }
27
+ } catch (error) {
28
+ console.error('Analysis failed:', error);
29
+ process.exit(1);
30
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { ESLint } from 'eslint';
6
+ import { spawnSync } 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
+ const { stdout } = process;
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const TEMP_DIR = tmp.dirSync({ unsafeCleanup: true }).name;
17
+ const registry = 'https://registry.npmjs.org/';
18
+
19
+ /**
20
+ * Checks if the given childPath is contained within the parentPath. Resolves
21
+ * the paths before comparing them, so that relative paths are also supported.
22
+ */
23
+ export function isContainedWithin(parentPath, childPath) {
24
+ parentPath = path.resolve(parentPath);
25
+ childPath = path.resolve(childPath);
26
+
27
+ if (parentPath === childPath) {
28
+ return true;
29
+ }
30
+
31
+ return childPath.startsWith(parentPath + path.sep);
32
+ }
33
+
34
+ /**
35
+ * Joins the given paths to the parentPath, ensuring that the resulting path
36
+ * is still contained within the parentPath. If not, it throws an error to
37
+ * prevent path traversal vulnerabilities.
38
+ *
39
+ * @throws {UnexpectedError} If the resulting path is not contained within the parentPath.
40
+ */
41
+ export function safeJoinPath(parentPath, ...paths) {
42
+ const candidate = path.join(parentPath, ...paths);
43
+
44
+ if (!isContainedWithin(parentPath, candidate)) {
45
+ throw new Error(
46
+ `Path traversal detected, refusing to join paths: ${parentPath} and ${JSON.stringify(paths)}`,
47
+ );
48
+ }
49
+
50
+ return candidate;
51
+ }
52
+
53
+ export const resolvePackage = (packageSpec) => {
54
+ // Validate input to prevent command injection
55
+ if (!/^[a-zA-Z0-9@/_.-]+$/.test(packageSpec)) {
56
+ throw new Error('Invalid package specification');
57
+ }
58
+
59
+ let packageName, version;
60
+ if (packageSpec.startsWith('@')) {
61
+ if (packageSpec.includes('@', 1)) {
62
+ // Handle scoped packages with versions
63
+ const lastAtIndex = packageSpec.lastIndexOf('@');
64
+ return {
65
+ packageName: packageSpec.substring(0, lastAtIndex),
66
+ version: packageSpec.substring(lastAtIndex + 1),
67
+ };
68
+ } else {
69
+ // Handle scoped packages without version
70
+ return { packageName: packageSpec, version: null };
71
+ }
72
+ }
73
+ // Handle regular packages
74
+ const parts = packageSpec.split('@');
75
+ return { packageName: parts[0], version: parts[1] || null };
76
+ };
77
+
78
+ const downloadAndExtractPackage = async (packageName, version) => {
79
+ try {
80
+ // Download the tarball using safe arguments
81
+ const npmResult = spawnSync('npm', ['-q', 'pack', `${packageName}@${version}`], {
82
+ cwd: TEMP_DIR,
83
+ stdio: 'pipe',
84
+ shell: process.platform === 'win32',
85
+ });
86
+ if (npmResult.status !== 0) {
87
+ throw new Error(`npm pack failed: ${npmResult.stderr?.toString()}`);
88
+ }
89
+ const tarballName = fs.readdirSync(TEMP_DIR).find((file) => file.endsWith('.tgz'));
90
+ if (!tarballName) {
91
+ throw new Error('Tarball not found');
92
+ }
93
+
94
+ // Unpack the tarball
95
+ const packageDir = safeJoinPath(TEMP_DIR, `${packageName}-${version}`);
96
+ fs.mkdirSync(packageDir, { recursive: true });
97
+ const tarResult = spawnSync(
98
+ 'tar',
99
+ ['-xzf', tarballName, '-C', packageDir, '--strip-components=1'],
100
+ {
101
+ cwd: TEMP_DIR,
102
+ stdio: 'pipe',
103
+ shell: process.platform === 'win32',
104
+ },
105
+ );
106
+ if (tarResult.status !== 0) {
107
+ throw new Error(`tar extraction failed: ${tarResult.stderr?.toString()}`);
108
+ }
109
+ fs.unlinkSync(safeJoinPath(TEMP_DIR, tarballName));
110
+
111
+ return packageDir;
112
+ } catch (error) {
113
+ console.error(`\nFailed to download package: ${error.message}`);
114
+ throw error;
115
+ }
116
+ };
117
+
118
+ const analyzePackage = async (packageDir) => {
119
+ const { n8nCommunityNodesPlugin } = await import('@n8n/eslint-plugin-community-nodes');
120
+ const eslint = new ESLint({
121
+ cwd: packageDir,
122
+ allowInlineConfig: false,
123
+ overrideConfigFile: true,
124
+ overrideConfig: defineConfig(n8nCommunityNodesPlugin.configs.recommended, {
125
+ rules: { 'no-console': 'error' },
126
+ }),
127
+ });
128
+
129
+ try {
130
+ const jsFiles = glob.sync('**/*.js', {
131
+ cwd: packageDir,
132
+ absolute: true,
133
+ ignore: ['node_modules/**'],
134
+ });
135
+
136
+ if (jsFiles.length === 0) {
137
+ return { passed: true, message: 'No JavaScript files found to analyze' };
138
+ }
139
+
140
+ const results = await eslint.lintFiles(jsFiles);
141
+ const violations = results.filter((result) => result.errorCount > 0);
142
+
143
+ if (violations.length > 0) {
144
+ const formatter = await eslint.loadFormatter('stylish');
145
+ const formattedResults = await formatter.format(results);
146
+ return {
147
+ passed: false,
148
+ message: 'ESLint violations found',
149
+ details: formattedResults,
150
+ };
151
+ }
152
+
153
+ return { passed: true };
154
+ } catch (error) {
155
+ console.error(error);
156
+ return {
157
+ passed: false,
158
+ message: `Analysis failed: ${error.message}`,
159
+ error,
160
+ };
161
+ }
162
+ };
163
+
164
+ export const analyzePackageByName = async (packageName, version) => {
165
+ try {
166
+ let exactVersion = version;
167
+
168
+ // If version is a range, get the latest matching version
169
+ if (version && semver.validRange(version) && !semver.valid(version)) {
170
+ const { data } = await axios.get(`${registry}/${packageName}`);
171
+ const versions = Object.keys(data.versions);
172
+ exactVersion = semver.maxSatisfying(versions, version);
173
+
174
+ if (!exactVersion) {
175
+ throw new Error(`No version found matching ${version}`);
176
+ }
177
+ }
178
+
179
+ // If no version specified, get the latest
180
+ if (!exactVersion) {
181
+ const { data } = await axios.get(`${registry}/${packageName}`);
182
+ exactVersion = data['dist-tags'].latest;
183
+ }
184
+
185
+ const label = `${packageName}@${exactVersion}`;
186
+
187
+ stdout.write(`Downloading ${label}...`);
188
+ const packageDir = await downloadAndExtractPackage(packageName, exactVersion);
189
+ if (stdout.TTY) {
190
+ stdout.clearLine(0);
191
+ stdout.cursorTo(0);
192
+ }
193
+ stdout.write(`✅ Downloaded ${label} \n`);
194
+
195
+ stdout.write(`Analyzing ${label}...`);
196
+ const analysisResult = await analyzePackage(packageDir);
197
+ if (stdout.TTY) {
198
+ stdout.clearLine(0);
199
+ stdout.cursorTo(0);
200
+ }
201
+ stdout.write(`✅ Analyzed ${label} \n`);
202
+
203
+ return {
204
+ packageName,
205
+ version: exactVersion,
206
+ ...analysisResult,
207
+ };
208
+ } catch (error) {
209
+ console.error(`Failed to analyze ${packageName}@${version}:`, error);
210
+ return {
211
+ packageName,
212
+ version,
213
+ passed: false,
214
+ message: `Analysis failed: ${error.message}`,
215
+ };
216
+ }
217
+ };