@n8n/scan-community-package 0.26.0 → 0.28.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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@n8n/scan-community-package",
3
- "version": "0.26.0",
3
+ "version": "0.28.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",
7
7
  "files": [
8
8
  "scanner",
9
- "LICENSE.md",
10
- "LICENSE_EE.md"
9
+ "LICENSE_EE.md",
10
+ "LICENSE.md"
11
11
  ],
12
12
  "dependencies": {
13
13
  "eslint": "9.29.0",
@@ -18,11 +18,11 @@
18
18
  "semver": "7.7.3",
19
19
  "typescript": "6.0.2",
20
20
  "tmp": "0.2.4",
21
- "@n8n/eslint-plugin-community-nodes": "0.24.0"
21
+ "@n8n/eslint-plugin-community-nodes": "0.25.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "vitest": "^4.1.9",
25
- "@n8n/vitest-config": "1.17.0"
25
+ "@n8n/vitest-config": "1.18.0"
26
26
  },
27
27
  "homepage": "https://n8n.io",
28
28
  "author": {
@@ -1,4 +1,4 @@
1
- const NPM_PROVENANCE_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
1
+ export const NPM_PROVENANCE_PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
2
2
  const N8N_COMMUNITY_NODE_PUBLISH_DOCS_URL =
3
3
  'https://docs.n8n.io/integrations/creating-nodes/deploy/submit-community-nodes/';
4
4
 
@@ -11,7 +11,7 @@ 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';
14
+ import { checkPackageProvenance, NPM_PROVENANCE_PREDICATE_TYPE } from './provenance.mjs';
15
15
 
16
16
  const { stdout } = process;
17
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -117,6 +117,110 @@ const downloadAndExtractPackage = async (packageName, version) => {
117
117
  }
118
118
  };
119
119
 
120
+ /**
121
+ * Extracts the source repository and commit a package was built from, out of
122
+ * its npm provenance attestation. Provenance is already mandatory for the
123
+ * scan to proceed, so any package that reaches this point attests exactly
124
+ * which source produced the published artifact.
125
+ *
126
+ * Returns `{ owner, repo, gitCommit }`, or `null` when the attestation is
127
+ * missing, malformed, or points at an unsupported host.
128
+ */
129
+ export const parseSourceRepo = (attestations) => {
130
+ const provenance = attestations?.find((a) => a.predicateType === NPM_PROVENANCE_PREDICATE_TYPE);
131
+ const payload = provenance?.bundle?.dsseEnvelope?.payload;
132
+ if (!payload) return null;
133
+
134
+ let statement;
135
+ try {
136
+ statement = JSON.parse(Buffer.from(payload, 'base64').toString('utf8'));
137
+ } catch {
138
+ return null;
139
+ }
140
+
141
+ const dependency = statement?.predicate?.buildDefinition?.resolvedDependencies?.[0];
142
+ const gitCommit = dependency?.digest?.gitCommit;
143
+ // ponytail: GitHub only — add a host→archive-URL mapping if GitLab-built packages show up
144
+ const match =
145
+ /^git\+https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:@|$)/.exec(
146
+ dependency?.uri ?? '',
147
+ );
148
+ if (!match || !/^[0-9a-f]{40,64}$/i.test(gitCommit ?? '')) return null;
149
+
150
+ return { owner: match[1], repo: match[2], gitCommit };
151
+ };
152
+
153
+ // A source fetch failure fails the scan outright, so bound the requests —
154
+ // a stalled connection must not hang the gate.
155
+ const SOURCE_FETCH_TIMEOUT_MS = 30_000;
156
+
157
+ const fetchSourceInfo = async (packageName, version) => {
158
+ const { data } = await axios.get(`${registry}-/npm/v1/attestations/${packageName}@${version}`, {
159
+ timeout: SOURCE_FETCH_TIMEOUT_MS,
160
+ });
161
+ return parseSourceRepo(data.attestations);
162
+ };
163
+
164
+ /**
165
+ * Finds the directory inside a source checkout whose package.json declares
166
+ * the given package name — handles both single-package repos and monorepos.
167
+ */
168
+ export const findPackageRoot = (sourceDir, packageName) => {
169
+ const packageJsonPaths = glob.sync('**/package.json', {
170
+ cwd: sourceDir,
171
+ absolute: true,
172
+ ignore: ['**/node_modules/**'],
173
+ });
174
+
175
+ for (const packageJsonPath of packageJsonPaths) {
176
+ try {
177
+ if (JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).name === packageName) {
178
+ return path.dirname(packageJsonPath);
179
+ }
180
+ } catch {
181
+ // Unparseable package.json (e.g. a fixture) — keep looking
182
+ }
183
+ }
184
+
185
+ return null;
186
+ };
187
+
188
+ const downloadAndExtractSource = async ({ owner, repo, gitCommit }, packageName) => {
189
+ const url = `https://codeload.github.com/${owner}/${repo}/tar.gz/${gitCommit}`;
190
+ const { data } = await axios.get(url, {
191
+ responseType: 'arraybuffer',
192
+ timeout: SOURCE_FETCH_TIMEOUT_MS,
193
+ });
194
+
195
+ const tarballName = `source-${gitCommit}.tgz`;
196
+ fs.writeFileSync(safeJoinPath(TEMP_DIR, tarballName), Buffer.from(data));
197
+
198
+ const sourceDir = safeJoinPath(TEMP_DIR, `source-${gitCommit}`);
199
+ fs.mkdirSync(sourceDir, { recursive: true });
200
+ const tarResult = spawnSync(
201
+ 'tar',
202
+ ['-xzf', tarballName, '-C', sourceDir, '--strip-components=1'],
203
+ {
204
+ cwd: TEMP_DIR,
205
+ stdio: 'pipe',
206
+ shell: process.platform === 'win32',
207
+ },
208
+ );
209
+ if (tarResult.status !== 0) {
210
+ throw new Error(`tar extraction failed: ${tarResult.stderr?.toString()}`);
211
+ }
212
+ fs.unlinkSync(safeJoinPath(TEMP_DIR, tarballName));
213
+
214
+ return findPackageRoot(sourceDir, packageName);
215
+ };
216
+
217
+ /**
218
+ * What `n8n-node lint` covers at dev time: the shippable node/credential
219
+ * sources plus package.json. Deliberately excludes repo dev files (gulpfile,
220
+ * test configs, committed dist/) that never end up in the published package.
221
+ */
222
+ export const SOURCE_FILE_PATTERNS = ['package.json', '{nodes,credentials}/**/*.{js,ts,json}'];
223
+
120
224
  /**
121
225
  * Builds the flat ESLint config the scanner lints packages with. Exported so
122
226
  * tests can assert the external `eslint-plugin-n8n-nodes-base` plugin and its
@@ -137,18 +241,12 @@ export const buildScanConfig = async () => {
137
241
  // Register the full `eslint-plugin-n8n-nodes-base` plugin and apply its
138
242
  // three rulesets so the scan gate enforces the same rules as
139
243
  // `n8n-node lint` (see node-cli/src/configs/eslint.ts). The off-overrides
140
- // below are kept identical. Scoping differs on purpose: `n8n-node lint`
141
- // runs at dev-time on `nodes/**` / `credentials/**` `.ts` sources, but
142
- // published tarballs ship compiled output under `dist/` (e.g.
143
- // `dist/nodes/Foo/Foo.node.js` + `.d.ts`). We match `nodes`/`credentials`
144
- // dirs at any depth and target `.ts`/`.d.ts` only — the AST-walking rules
145
- // resolve against the type-preserving `.d.ts`. Compiled `.js` is
146
- // deliberately excluded: the description AST is buried in a constructor
147
- // there so the rules no-op, and file-shape rules like
148
- // node-filename-against-convention would false-positive on the `.js`
149
- // extension (that check is meaningful only against `.ts` sources at
150
- // dev-time). Without the `dist/`-aware glob these rules never run at the
151
- // gate at all.
244
+ // below are kept identical. The `.ts` globs only ever match the
245
+ // provenance-attested source checkout the tarball leg lints compiled
246
+ // `.js` and the published package.json only, where these rules would
247
+ // no-op (the description AST is buried in a constructor) or
248
+ // false-positive (the filename-convention rules hard-code a `.ts`
249
+ // suffix that compiled output can never satisfy).
152
250
  { plugins: { 'n8n-nodes-base': n8nNodesPlugin } },
153
251
  {
154
252
  files: ['package.json'],
@@ -193,7 +291,10 @@ export const buildScanConfig = async () => {
193
291
  );
194
292
  };
195
293
 
196
- export const analyzePackage = async (packageDir) => {
294
+ export const analyzePackage = async (
295
+ packageDir,
296
+ filePatterns = ['**/*.js', '**/*.ts', '**/*.json'],
297
+ ) => {
197
298
  const eslint = new ESLint({
198
299
  cwd: packageDir,
199
300
  allowInlineConfig: false,
@@ -206,7 +307,7 @@ export const analyzePackage = async (packageDir) => {
206
307
  // such as `no-overrides-field`, `valid-peer-dependencies`, and
207
308
  // `package-name-convention` only run against `package.json`. Without
208
309
  // it the scanner silently skips every package.json-based rule.
209
- const filesToLint = glob.sync(['**/*.js', '**/*.ts', '**/*.json'], {
310
+ const filesToLint = glob.sync(filePatterns, {
210
311
  cwd: packageDir,
211
312
  absolute: true,
212
313
  ignore: ['node_modules/**', '**/package-lock.json'],
@@ -287,6 +388,46 @@ export const analyzePackageByName = async (packageName, version) => {
287
388
 
288
389
  stdout.write(`✅ Provenance check passed for ${label} \n`);
289
390
 
391
+ // Lint the source the provenance attestation points at: the
392
+ // node/credential rules are written for `.ts` sources and mostly no-op
393
+ // (or false-positive on filenames) against the compiled output shipped
394
+ // in the tarball. An unreachable source is a hard failure — falling
395
+ // back to a tarball-only scan would silently reintroduce that blind
396
+ // spot.
397
+ stdout.write(`Fetching source for ${label}...`);
398
+ let sourceDir = null;
399
+ let sourceInfo = null;
400
+ let sourceError = null;
401
+ try {
402
+ sourceInfo = await fetchSourceInfo(packageName, exactVersion);
403
+ if (sourceInfo) {
404
+ sourceDir = await downloadAndExtractSource(sourceInfo, packageName);
405
+ }
406
+ } catch (error) {
407
+ sourceError = error;
408
+ }
409
+ if (stdout.TTY) {
410
+ stdout.clearLine(0);
411
+ stdout.cursorTo(0);
412
+ }
413
+
414
+ if (!sourceDir) {
415
+ const reason = sourceError?.message ?? 'unsupported or unlocatable source repository';
416
+ stdout.write(`❌ Could not fetch source for ${label} \n`);
417
+
418
+ return {
419
+ packageName,
420
+ version: exactVersion,
421
+ passed: false,
422
+ message: `Could not fetch the source repository recorded in the package's npm provenance (${reason}). The scan lints the attested source, so it must be reachable — publish with provenance from a public GitHub repository.`,
423
+ };
424
+ }
425
+
426
+ const shortCommit = sourceInfo.gitCommit.slice(0, 7);
427
+ stdout.write(
428
+ `✅ Fetched source from github.com/${sourceInfo.owner}/${sourceInfo.repo}@${shortCommit} \n`,
429
+ );
430
+
290
431
  stdout.write(`Downloading ${label}...`);
291
432
  const packageDir = await downloadAndExtractPackage(packageName, exactVersion);
292
433
  if (stdout.TTY) {
@@ -296,7 +437,19 @@ export const analyzePackageByName = async (packageName, version) => {
296
437
  stdout.write(`✅ Downloaded ${label} \n`);
297
438
 
298
439
  stdout.write(`Analyzing ${label}...`);
299
- const analysisResult = await analyzePackage(packageDir);
440
+ // The source checkout gets the full rule set on real `.ts` sources.
441
+ // The shipped artifact must stay scanned too: provenance pins the
442
+ // source commit, not the build output — a build step can emit anything
443
+ // into `dist/`. Scope the tarball leg to compiled `.js` and the
444
+ // published package.json; `.ts`/`.d.ts` declarations are covered better
445
+ // by the source scan and only false-positive on filename rules here.
446
+ const sourceResult = await analyzePackage(sourceDir, SOURCE_FILE_PATTERNS);
447
+ const distResult = await analyzePackage(packageDir, ['**/*.js', 'package.json']);
448
+ const analysisResult = {
449
+ passed: sourceResult.passed && distResult.passed,
450
+ message: [sourceResult, distResult].find((r) => !r.passed)?.message,
451
+ details: [sourceResult.details, distResult.details].filter(Boolean).join('\n') || undefined,
452
+ };
300
453
  if (stdout.TTY) {
301
454
  stdout.clearLine(0);
302
455
  stdout.cursorTo(0);
@@ -3,7 +3,13 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
5
 
6
- import { analyzePackage, buildScanConfig } from './scanner.mjs';
6
+ import {
7
+ analyzePackage,
8
+ buildScanConfig,
9
+ findPackageRoot,
10
+ parseSourceRepo,
11
+ SOURCE_FILE_PATTERNS,
12
+ } from './scanner.mjs';
7
13
 
8
14
  /**
9
15
  * Build a temporary package directory on disk so we can hand it to
@@ -73,6 +79,125 @@ describe('buildScanConfig', () => {
73
79
  });
74
80
  });
75
81
 
82
+ describe('parseSourceRepo', () => {
83
+ const makeAttestation = (predicate) => [
84
+ {
85
+ predicateType: 'https://slsa.dev/provenance/v1',
86
+ bundle: {
87
+ dsseEnvelope: {
88
+ payload: Buffer.from(JSON.stringify({ predicate })).toString('base64'),
89
+ },
90
+ },
91
+ },
92
+ ];
93
+
94
+ const commit = 'a'.repeat(40);
95
+
96
+ it('extracts owner, repo and commit from a GitHub attestation', () => {
97
+ const attestations = makeAttestation({
98
+ buildDefinition: {
99
+ resolvedDependencies: [
100
+ {
101
+ uri: `git+https://github.com/acme/n8n-nodes-foo@refs/heads/main`,
102
+ digest: { gitCommit: commit },
103
+ },
104
+ ],
105
+ },
106
+ });
107
+
108
+ expect(parseSourceRepo(attestations)).toEqual({
109
+ owner: 'acme',
110
+ repo: 'n8n-nodes-foo',
111
+ gitCommit: commit,
112
+ });
113
+ });
114
+
115
+ it('supports repo names containing dots', () => {
116
+ const attestations = makeAttestation({
117
+ buildDefinition: {
118
+ resolvedDependencies: [
119
+ {
120
+ uri: 'git+https://github.com/acme/n8n-nodes-foo.bar@refs/heads/main',
121
+ digest: { gitCommit: commit },
122
+ },
123
+ ],
124
+ },
125
+ });
126
+
127
+ expect(parseSourceRepo(attestations)).toEqual({
128
+ owner: 'acme',
129
+ repo: 'n8n-nodes-foo.bar',
130
+ gitCommit: commit,
131
+ });
132
+ });
133
+
134
+ it('returns null for non-GitHub source hosts', () => {
135
+ const attestations = makeAttestation({
136
+ buildDefinition: {
137
+ resolvedDependencies: [
138
+ {
139
+ uri: `git+https://gitlab.com/acme/n8n-nodes-foo@refs/heads/main`,
140
+ digest: { gitCommit: commit },
141
+ },
142
+ ],
143
+ },
144
+ });
145
+
146
+ expect(parseSourceRepo(attestations)).toBeNull();
147
+ });
148
+
149
+ it('returns null when the commit digest is not a git SHA', () => {
150
+ const attestations = makeAttestation({
151
+ buildDefinition: {
152
+ resolvedDependencies: [
153
+ {
154
+ uri: 'git+https://github.com/acme/n8n-nodes-foo@refs/heads/main',
155
+ digest: { gitCommit: 'not-a-sha' },
156
+ },
157
+ ],
158
+ },
159
+ });
160
+
161
+ expect(parseSourceRepo(attestations)).toBeNull();
162
+ });
163
+
164
+ it('returns null when there is no provenance attestation', () => {
165
+ expect(parseSourceRepo(undefined)).toBeNull();
166
+ expect(parseSourceRepo([])).toBeNull();
167
+ expect(parseSourceRepo([{ predicateType: 'something-else' }])).toBeNull();
168
+ });
169
+ });
170
+
171
+ describe('findPackageRoot', () => {
172
+ let fixtureDir;
173
+
174
+ afterEach(() => {
175
+ if (fixtureDir) {
176
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
177
+ fixtureDir = undefined;
178
+ }
179
+ });
180
+
181
+ it('finds the package directory in a monorepo by package.json name', () => {
182
+ fixtureDir = makeFixturePackage({
183
+ 'package.json': { name: 'monorepo-root', private: true },
184
+ 'packages/foo/package.json': { name: 'n8n-nodes-foo', version: '1.0.0' },
185
+ });
186
+
187
+ expect(findPackageRoot(fixtureDir, 'n8n-nodes-foo')).toBe(
188
+ path.join(fixtureDir, 'packages', 'foo'),
189
+ );
190
+ });
191
+
192
+ it('returns null when no package.json declares the package name', () => {
193
+ fixtureDir = makeFixturePackage({
194
+ 'package.json': { name: 'something-else' },
195
+ });
196
+
197
+ expect(findPackageRoot(fixtureDir, 'n8n-nodes-foo')).toBeNull();
198
+ });
199
+ });
200
+
76
201
  describe('analyzePackage', () => {
77
202
  let fixtureDir;
78
203
 
@@ -92,7 +217,7 @@ describe('analyzePackage', () => {
92
217
  peerDependencies: { 'n8n-workflow': '*' },
93
218
  overrides: { 'change-case': '4.1.2' },
94
219
  },
95
- 'index.js': "module.exports = {};\n",
220
+ 'index.js': 'module.exports = {};\n',
96
221
  });
97
222
 
98
223
  const result = await analyzePackage(fixtureDir);
@@ -113,7 +238,7 @@ describe('analyzePackage', () => {
113
238
  peerDependencies: { 'n8n-workflow': '*' },
114
239
  n8n: { n8nNodesApiVersion: 1, nodes: ['dist/nodes/Foo/Foo.node.js'] },
115
240
  },
116
- 'index.js': "module.exports = {};\n",
241
+ 'index.js': 'module.exports = {};\n',
117
242
  });
118
243
 
119
244
  const result = await analyzePackage(fixtureDir);
@@ -130,7 +255,7 @@ describe('analyzePackage', () => {
130
255
  peerDependencies: { 'n8n-workflow': '*' },
131
256
  scripts: { postinstall: 'node ./malicious.js' },
132
257
  },
133
- 'index.js': "module.exports = {};\n",
258
+ 'index.js': 'module.exports = {};\n',
134
259
  });
135
260
 
136
261
  const result = await analyzePackage(fixtureDir);
@@ -167,7 +292,8 @@ describe('analyzePackage', () => {
167
292
  };
168
293
  }
169
294
  `,
170
- 'dist/nodes/Foo/Foo.node.js': "\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.Foo = void 0;\nclass Foo {}\nexports.Foo = Foo;\n",
295
+ 'dist/nodes/Foo/Foo.node.js':
296
+ '"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });\nexports.Foo = void 0;\nclass Foo {}\nexports.Foo = Foo;\n',
171
297
  });
172
298
 
173
299
  const result = await analyzePackage(fixtureDir);
@@ -175,6 +301,47 @@ describe('analyzePackage', () => {
175
301
  expect(result.passed).toBe(true);
176
302
  });
177
303
 
304
+ // CE-1713: source checkouts are linted with dev-scoped globs — only the
305
+ // shippable `nodes/**` / `credentials/**` sources and package.json, so repo
306
+ // dev files (gulpfile, test configs) can't produce gate-only violations.
307
+ it('ignores repo dev files when linting with source file patterns', async () => {
308
+ fixtureDir = makeFixturePackage({
309
+ 'package.json': {
310
+ name: 'n8n-nodes-fixture',
311
+ version: '1.0.0',
312
+ description: 'A fixture community node package',
313
+ license: 'MIT',
314
+ author: { name: 'Test Author', email: 'test@example.com' },
315
+ keywords: ['n8n-community-node-package'],
316
+ peerDependencies: { 'n8n-workflow': '*' },
317
+ n8n: { n8nNodesApiVersion: 1, nodes: ['dist/nodes/Foo/Foo.node.js'] },
318
+ },
319
+ 'gulpfile.js': "console.log('building');\n",
320
+ });
321
+
322
+ const result = await analyzePackage(fixtureDir, SOURCE_FILE_PATTERNS);
323
+
324
+ expect(result.passed).toBe(true);
325
+ });
326
+
327
+ it('flags violations in node sources when linting with source file patterns', async () => {
328
+ fixtureDir = makeFixturePackage({
329
+ 'package.json': {
330
+ name: 'n8n-nodes-fixture',
331
+ version: '1.0.0',
332
+ keywords: ['n8n-community-node-package'],
333
+ peerDependencies: { 'n8n-workflow': '*' },
334
+ n8n: { n8nNodesApiVersion: 1, nodes: ['dist/nodes/Foo/Foo.node.js'] },
335
+ },
336
+ 'nodes/Foo/Foo.node.ts': "console.log('debug');\nexport class Foo {}\n",
337
+ });
338
+
339
+ const result = await analyzePackage(fixtureDir, SOURCE_FILE_PATTERNS);
340
+
341
+ expect(result.passed).toBe(false);
342
+ expect(result.details).toContain('no-console');
343
+ });
344
+
178
345
  it('returns passed when the package contains no lintable files', async () => {
179
346
  fixtureDir = makeFixturePackage({
180
347
  'README.md': '# empty package\n',