@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 +7 -0
- package/package.json +18 -0
- package/scanner/cli.mjs +30 -0
- package/scanner/scanner.mjs +217 -0
package/README.md
ADDED
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
|
+
}
|
package/scanner/cli.mjs
ADDED
|
@@ -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
|
+
};
|