@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 CHANGED
@@ -1,5 +1,7 @@
1
1
  ## n8n community-package static analysis tool
2
2
 
3
+ Checks npm provenance and runs static analysis for n8n community packages.
4
+
3
5
  ### How to use this
4
6
 
5
7
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@n8n/scan-community-package",
3
- "version": "0.17.1",
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.15.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
+ };
@@ -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(n8nCommunityNodesPlugin.configs.recommended, {
125
- rules: { 'no-console': 'error' },
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
- const jsFiles = glob.sync('**/*.js', {
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 (jsFiles.length === 0) {
137
- return { passed: true, message: 'No JavaScript files found to analyze' };
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(jsFiles);
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
+ });