@n8n/scan-community-package 0.17.1 → 0.18.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 +2 -0
- package/package.json +11 -2
- package/scanner/provenance.mjs +31 -0
- package/scanner/scanner.mjs +53 -9
- package/scanner/scanner.test.mjs +98 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@n8n/scan-community-package",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Static code analyser for n8n community packages",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"bin": "scanner/cli.mjs",
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
"eslint": "9.29.0",
|
|
14
14
|
"fast-glob": "3.2.12",
|
|
15
15
|
"axios": "1.16.0",
|
|
16
|
+
"@typescript-eslint/parser": "^8.35.0",
|
|
16
17
|
"semver": "^7.5.4",
|
|
17
18
|
"tmp": "0.2.4",
|
|
18
|
-
"@n8n/eslint-plugin-community-nodes": "0.
|
|
19
|
+
"@n8n/eslint-plugin-community-nodes": "0.16.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"vitest": "^4.1.1",
|
|
23
|
+
"@n8n/vitest-config": "1.10.0"
|
|
19
24
|
},
|
|
20
25
|
"homepage": "https://n8n.io",
|
|
21
26
|
"author": {
|
|
@@ -25,5 +30,9 @@
|
|
|
25
30
|
"repository": {
|
|
26
31
|
"type": "git",
|
|
27
32
|
"url": "git+https://github.com/n8n-io/n8n.git"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:dev": "vitest"
|
|
28
37
|
}
|
|
29
38
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const NPM_PROVENANCE_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
|
|
2
|
+
const N8N_COMMUNITY_NODE_PUBLISH_DOCS_URL =
|
|
3
|
+
'https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/';
|
|
4
|
+
|
|
5
|
+
export const checkPackageProvenance = (packageMetadata, version) => {
|
|
6
|
+
const packageVersion = packageMetadata.versions?.[version];
|
|
7
|
+
const provenance = packageVersion?.dist?.attestations?.provenance;
|
|
8
|
+
|
|
9
|
+
if (!packageVersion) {
|
|
10
|
+
return {
|
|
11
|
+
passed: false,
|
|
12
|
+
message: `No package metadata found for version ${version}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!provenance) {
|
|
17
|
+
return {
|
|
18
|
+
passed: false,
|
|
19
|
+
message: `Package was not published with npm provenance. Learn how to publish community nodes with provenance: ${N8N_COMMUNITY_NODE_PUBLISH_DOCS_URL}`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (provenance.predicateType !== NPM_PROVENANCE_PREDICATE_TYPE) {
|
|
24
|
+
return {
|
|
25
|
+
passed: false,
|
|
26
|
+
message: `Unsupported npm provenance predicate type: ${provenance.predicateType ?? 'unknown'}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { passed: true };
|
|
31
|
+
};
|
package/scanner/scanner.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import glob from 'fast-glob';
|
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
import { defineConfig } from 'eslint/config';
|
|
13
13
|
|
|
14
|
+
import { checkPackageProvenance } from './provenance.mjs';
|
|
15
|
+
|
|
14
16
|
const { stdout } = process;
|
|
15
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
18
|
const TEMP_DIR = tmp.dirSync({ unsafeCleanup: true }).name;
|
|
@@ -115,29 +117,47 @@ const downloadAndExtractPackage = async (packageName, version) => {
|
|
|
115
117
|
}
|
|
116
118
|
};
|
|
117
119
|
|
|
118
|
-
const analyzePackage = async (packageDir) => {
|
|
120
|
+
export const analyzePackage = async (packageDir) => {
|
|
119
121
|
const { n8nCommunityNodesPlugin } = await import('@n8n/eslint-plugin-community-nodes');
|
|
122
|
+
const tsParser = await import('@typescript-eslint/parser');
|
|
123
|
+
|
|
120
124
|
const eslint = new ESLint({
|
|
121
125
|
cwd: packageDir,
|
|
122
126
|
allowInlineConfig: false,
|
|
123
127
|
overrideConfigFile: true,
|
|
124
|
-
overrideConfig: defineConfig(
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
overrideConfig: defineConfig(
|
|
129
|
+
n8nCommunityNodesPlugin.configs.recommended,
|
|
130
|
+
{
|
|
131
|
+
rules: { 'no-console': 'error' },
|
|
132
|
+
},
|
|
133
|
+
// JSON files (notably `package.json`) are not parseable by ESLint's
|
|
134
|
+
// default JS parser, so register the TypeScript parser for them. The
|
|
135
|
+
// community-nodes rules that gate on `package.json` walk a TSESTree
|
|
136
|
+
// `ObjectExpression` AST, which `@typescript-eslint/parser` produces
|
|
137
|
+
// when given a top-level JSON object literal.
|
|
138
|
+
{
|
|
139
|
+
files: ['**/*.json'],
|
|
140
|
+
languageOptions: { parser: tsParser.default ?? tsParser },
|
|
141
|
+
},
|
|
142
|
+
),
|
|
127
143
|
});
|
|
128
144
|
|
|
129
145
|
try {
|
|
130
|
-
|
|
146
|
+
// Lint both JS and JSON files. JSON inclusion is required because rules
|
|
147
|
+
// such as `no-overrides-field`, `valid-peer-dependencies`, and
|
|
148
|
+
// `package-name-convention` only run against `package.json`. Without
|
|
149
|
+
// it the scanner silently skips every package.json-based rule.
|
|
150
|
+
const filesToLint = glob.sync(['**/*.js', '**/*.json'], {
|
|
131
151
|
cwd: packageDir,
|
|
132
152
|
absolute: true,
|
|
133
|
-
ignore: ['node_modules/**'],
|
|
153
|
+
ignore: ['node_modules/**', '**/package-lock.json'],
|
|
134
154
|
});
|
|
135
155
|
|
|
136
|
-
if (
|
|
137
|
-
return { passed: true, message: 'No
|
|
156
|
+
if (filesToLint.length === 0) {
|
|
157
|
+
return { passed: true, message: 'No files found to analyze' };
|
|
138
158
|
}
|
|
139
159
|
|
|
140
|
-
const results = await eslint.lintFiles(
|
|
160
|
+
const results = await eslint.lintFiles(filesToLint);
|
|
141
161
|
const violations = results.filter((result) => result.errorCount > 0);
|
|
142
162
|
|
|
143
163
|
if (violations.length > 0) {
|
|
@@ -164,10 +184,12 @@ const analyzePackage = async (packageDir) => {
|
|
|
164
184
|
export const analyzePackageByName = async (packageName, version) => {
|
|
165
185
|
try {
|
|
166
186
|
let exactVersion = version;
|
|
187
|
+
let packageMetadata;
|
|
167
188
|
|
|
168
189
|
// If version is a range, get the latest matching version
|
|
169
190
|
if (version && semver.validRange(version) && !semver.valid(version)) {
|
|
170
191
|
const { data } = await axios.get(`${registry}/${packageName}`);
|
|
192
|
+
packageMetadata = data;
|
|
171
193
|
const versions = Object.keys(data.versions);
|
|
172
194
|
exactVersion = semver.maxSatisfying(versions, version);
|
|
173
195
|
|
|
@@ -179,11 +201,33 @@ export const analyzePackageByName = async (packageName, version) => {
|
|
|
179
201
|
// If no version specified, get the latest
|
|
180
202
|
if (!exactVersion) {
|
|
181
203
|
const { data } = await axios.get(`${registry}/${packageName}`);
|
|
204
|
+
packageMetadata = data;
|
|
182
205
|
exactVersion = data['dist-tags'].latest;
|
|
183
206
|
}
|
|
184
207
|
|
|
208
|
+
packageMetadata ??= (await axios.get(`${registry}/${packageName}`)).data;
|
|
209
|
+
exactVersion = packageMetadata['dist-tags']?.[exactVersion] ?? exactVersion;
|
|
185
210
|
const label = `${packageName}@${exactVersion}`;
|
|
186
211
|
|
|
212
|
+
stdout.write(`Checking provenance for ${label}...`);
|
|
213
|
+
const provenanceResult = checkPackageProvenance(packageMetadata, exactVersion);
|
|
214
|
+
if (stdout.TTY) {
|
|
215
|
+
stdout.clearLine(0);
|
|
216
|
+
stdout.cursorTo(0);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!provenanceResult.passed) {
|
|
220
|
+
stdout.write(`❌ Provenance check failed for ${label} \n`);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
packageName,
|
|
224
|
+
version: exactVersion,
|
|
225
|
+
...provenanceResult,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
stdout.write(`✅ Provenance check passed for ${label} \n`);
|
|
230
|
+
|
|
187
231
|
stdout.write(`Downloading ${label}...`);
|
|
188
232
|
const packageDir = await downloadAndExtractPackage(packageName, exactVersion);
|
|
189
233
|
if (stdout.TTY) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { analyzePackage } from './scanner.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a temporary package directory on disk so we can hand it to
|
|
10
|
+
* `analyzePackage` exactly the way the scanner would after extracting a
|
|
11
|
+
* tarball from npm.
|
|
12
|
+
*/
|
|
13
|
+
function makeFixturePackage(files) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-fixture-'));
|
|
15
|
+
for (const [relativePath, contents] of Object.entries(files)) {
|
|
16
|
+
const fullPath = path.join(dir, relativePath);
|
|
17
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(
|
|
19
|
+
fullPath,
|
|
20
|
+
typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('analyzePackage', () => {
|
|
27
|
+
let fixtureDir;
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (fixtureDir) {
|
|
31
|
+
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
32
|
+
fixtureDir = undefined;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('lints package.json and flags forbidden `overrides` field (regression for CE-1023)', async () => {
|
|
37
|
+
fixtureDir = makeFixturePackage({
|
|
38
|
+
'package.json': {
|
|
39
|
+
name: 'n8n-nodes-fixture',
|
|
40
|
+
version: '1.0.0',
|
|
41
|
+
keywords: ['n8n-community-node-package'],
|
|
42
|
+
peerDependencies: { 'n8n-workflow': '*' },
|
|
43
|
+
overrides: { 'change-case': '4.1.2' },
|
|
44
|
+
},
|
|
45
|
+
'index.js': "module.exports = {};\n",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = await analyzePackage(fixtureDir);
|
|
49
|
+
|
|
50
|
+
expect(result.passed).toBe(false);
|
|
51
|
+
expect(result.details).toContain('no-overrides-field');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('passes a clean package that does not violate any error-level rules', async () => {
|
|
55
|
+
fixtureDir = makeFixturePackage({
|
|
56
|
+
'package.json': {
|
|
57
|
+
name: 'n8n-nodes-fixture',
|
|
58
|
+
version: '1.0.0',
|
|
59
|
+
keywords: ['n8n-community-node-package'],
|
|
60
|
+
peerDependencies: { 'n8n-workflow': '*' },
|
|
61
|
+
},
|
|
62
|
+
'index.js': "module.exports = {};\n",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const result = await analyzePackage(fixtureDir);
|
|
66
|
+
|
|
67
|
+
expect(result.passed).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('flags forbidden lifecycle scripts in package.json', async () => {
|
|
71
|
+
fixtureDir = makeFixturePackage({
|
|
72
|
+
'package.json': {
|
|
73
|
+
name: 'n8n-nodes-fixture',
|
|
74
|
+
version: '1.0.0',
|
|
75
|
+
keywords: ['n8n-community-node-package'],
|
|
76
|
+
peerDependencies: { 'n8n-workflow': '*' },
|
|
77
|
+
scripts: { postinstall: 'node ./malicious.js' },
|
|
78
|
+
},
|
|
79
|
+
'index.js': "module.exports = {};\n",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = await analyzePackage(fixtureDir);
|
|
83
|
+
|
|
84
|
+
expect(result.passed).toBe(false);
|
|
85
|
+
expect(result.details).toContain('no-forbidden-lifecycle-scripts');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns passed when the package contains no lintable files', async () => {
|
|
89
|
+
fixtureDir = makeFixturePackage({
|
|
90
|
+
'README.md': '# empty package\n',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = await analyzePackage(fixtureDir);
|
|
94
|
+
|
|
95
|
+
expect(result.passed).toBe(true);
|
|
96
|
+
expect(result.message).toBe('No files found to analyze');
|
|
97
|
+
});
|
|
98
|
+
});
|