@n8n/eslint-plugin-community-nodes 0.26.0 → 0.27.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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +7 -1
  3. package/dist/plugin.d.ts +36 -0
  4. package/dist/plugin.d.ts.map +1 -1
  5. package/dist/plugin.js +12 -0
  6. package/dist/plugin.js.map +1 -1
  7. package/dist/rules/credential-password-field.d.ts.map +1 -1
  8. package/dist/rules/credential-password-field.js +3 -31
  9. package/dist/rules/credential-password-field.js.map +1 -1
  10. package/dist/rules/credential-unnecessary-password.d.ts +2 -0
  11. package/dist/rules/credential-unnecessary-password.d.ts.map +1 -0
  12. package/dist/rules/credential-unnecessary-password.js +54 -0
  13. package/dist/rules/credential-unnecessary-password.js.map +1 -0
  14. package/dist/rules/index.d.ts +6 -0
  15. package/dist/rules/index.d.ts.map +1 -1
  16. package/dist/rules/index.js +12 -0
  17. package/dist/rules/index.js.map +1 -1
  18. package/dist/rules/no-dead-files.d.ts +7 -0
  19. package/dist/rules/no-dead-files.d.ts.map +1 -0
  20. package/dist/rules/no-dead-files.js +57 -0
  21. package/dist/rules/no-dead-files.js.map +1 -0
  22. package/dist/rules/no-hardcoded-secrets.d.ts +2 -0
  23. package/dist/rules/no-hardcoded-secrets.d.ts.map +1 -0
  24. package/dist/rules/no-hardcoded-secrets.js +147 -0
  25. package/dist/rules/no-hardcoded-secrets.js.map +1 -0
  26. package/dist/rules/no-silent-error-swallowing.d.ts +2 -0
  27. package/dist/rules/no-silent-error-swallowing.d.ts.map +1 -0
  28. package/dist/rules/no-silent-error-swallowing.js +112 -0
  29. package/dist/rules/no-silent-error-swallowing.js.map +1 -0
  30. package/dist/rules/require-files-array.d.ts +2 -0
  31. package/dist/rules/require-files-array.d.ts.map +1 -0
  32. package/dist/rules/require-files-array.js +41 -0
  33. package/dist/rules/require-files-array.js.map +1 -0
  34. package/dist/rules/require-homepage.d.ts +2 -0
  35. package/dist/rules/require-homepage.d.ts.map +1 -0
  36. package/dist/rules/require-homepage.js +52 -0
  37. package/dist/rules/require-homepage.js.map +1 -0
  38. package/dist/rules/webhook-lifecycle-complete.d.ts.map +1 -1
  39. package/dist/rules/webhook-lifecycle-complete.js +2 -3
  40. package/dist/rules/webhook-lifecycle-complete.js.map +1 -1
  41. package/dist/typecheck.tsbuildinfo +1 -1
  42. package/dist/utils/constants.d.ts +5 -0
  43. package/dist/utils/constants.d.ts.map +1 -0
  44. package/dist/utils/constants.js +15 -0
  45. package/dist/utils/constants.js.map +1 -0
  46. package/dist/utils/credential-fields.d.ts +20 -0
  47. package/dist/utils/credential-fields.d.ts.map +1 -0
  48. package/dist/utils/credential-fields.js +45 -0
  49. package/dist/utils/credential-fields.js.map +1 -0
  50. package/dist/utils/file-utils.d.ts +6 -0
  51. package/dist/utils/file-utils.d.ts.map +1 -1
  52. package/dist/utils/file-utils.js +16 -0
  53. package/dist/utils/file-utils.js.map +1 -1
  54. package/dist/utils/index.d.ts +2 -0
  55. package/dist/utils/index.d.ts.map +1 -1
  56. package/dist/utils/index.js +2 -0
  57. package/dist/utils/index.js.map +1 -1
  58. package/docs/rules/credential-password-field.md +3 -1
  59. package/docs/rules/credential-unnecessary-password.md +46 -0
  60. package/docs/rules/no-dead-files.md +56 -0
  61. package/docs/rules/no-hardcoded-secrets.md +59 -0
  62. package/docs/rules/no-silent-error-swallowing.md +72 -0
  63. package/docs/rules/require-files-array.md +43 -0
  64. package/docs/rules/require-homepage.md +49 -0
  65. package/package.json +2 -2
  66. package/src/plugin.ts +12 -0
  67. package/src/rules/credential-password-field.ts +4 -40
  68. package/src/rules/credential-unnecessary-password.test.ts +105 -0
  69. package/src/rules/credential-unnecessary-password.ts +72 -0
  70. package/src/rules/index.ts +12 -0
  71. package/src/rules/no-dead-files.test.ts +128 -0
  72. package/src/rules/no-dead-files.ts +71 -0
  73. package/src/rules/no-hardcoded-secrets.test.ts +124 -0
  74. package/src/rules/no-hardcoded-secrets.ts +161 -0
  75. package/src/rules/no-silent-error-swallowing.test.ts +245 -0
  76. package/src/rules/no-silent-error-swallowing.ts +127 -0
  77. package/src/rules/require-files-array.test.ts +57 -0
  78. package/src/rules/require-files-array.ts +50 -0
  79. package/src/rules/require-homepage.test.ts +80 -0
  80. package/src/rules/require-homepage.ts +59 -0
  81. package/src/rules/webhook-lifecycle-complete.ts +4 -5
  82. package/src/utils/constants.ts +21 -0
  83. package/src/utils/credential-fields.ts +57 -0
  84. package/src/utils/file-utils.ts +23 -0
  85. package/src/utils/index.ts +2 -0
@@ -6,47 +6,11 @@ import {
6
6
  findClassProperty,
7
7
  findObjectProperty,
8
8
  getStringLiteralValue,
9
- getBooleanLiteralValue,
9
+ isSensitiveName,
10
+ hasPasswordTypeOption,
10
11
  createRule,
11
12
  } from '../utils/index.js';
12
13
 
13
- const SENSITIVE_PATTERNS = [
14
- 'password',
15
- 'secret',
16
- 'token',
17
- 'cert',
18
- 'passphrase',
19
- 'apikey',
20
- 'secretkey',
21
- 'privatekey',
22
- 'authkey',
23
- ];
24
-
25
- const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
26
-
27
- function isSensitiveFieldName(name: string): boolean {
28
- const lowerName = name.toLowerCase();
29
-
30
- if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
31
- return false;
32
- }
33
-
34
- return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
35
- }
36
-
37
- function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
38
- const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
39
-
40
- if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
41
- return false;
42
- }
43
-
44
- const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
45
- const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
46
-
47
- return passwordValue === true;
48
- }
49
-
50
14
  function createPasswordFix(
51
15
  element: TSESTree.ObjectExpression,
52
16
  typeOptionsProperty: TSESTree.Property | null,
@@ -87,7 +51,7 @@ export const CredentialPasswordFieldRule = createRule({
87
51
  meta: {
88
52
  type: 'problem',
89
53
  docs: {
90
- description: 'Ensure credential fields with sensitive names have typeOptions.password = true',
54
+ description: 'Ensure fields with sensitive names have typeOptions.password = true',
91
55
  },
92
56
  messages: {
93
57
  missingPasswordOption:
@@ -120,7 +84,7 @@ export const CredentialPasswordFieldRule = createRule({
120
84
  const nameProperty = findObjectProperty(element, 'name');
121
85
  const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
122
86
 
123
- if (!fieldName || !isSensitiveFieldName(fieldName)) {
87
+ if (!fieldName || !isSensitiveName(fieldName)) {
124
88
  continue;
125
89
  }
126
90
 
@@ -0,0 +1,105 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { CredentialUnnecessaryPasswordRule } from './credential-unnecessary-password.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ function createCredentialCode(properties: string[]): string {
8
+ return `
9
+ import type { ICredentialType, INodeProperties } from 'n8n-workflow';
10
+
11
+ export class TestCredential implements ICredentialType {
12
+ name = 'testApi';
13
+ displayName = 'Test API';
14
+
15
+ properties: INodeProperties[] = [
16
+ ${properties.map((prop) => `\t\t${prop}`).join(',\n')},
17
+ ];
18
+ }`;
19
+ }
20
+
21
+ function createProperty(
22
+ displayName: string,
23
+ name: string,
24
+ options: { password?: boolean } = {},
25
+ ): string {
26
+ const typeOptionsStr =
27
+ options.password !== undefined ? `\n\t\t\ttypeOptions: { password: ${options.password} },` : '';
28
+
29
+ return `{
30
+ displayName: '${displayName}',
31
+ name: '${name}',
32
+ type: 'string',
33
+ default: '',${typeOptionsStr}
34
+ }`;
35
+ }
36
+
37
+ function createRegularClass(): string {
38
+ return `
39
+ export class RegularClass {
40
+ properties = [
41
+ {
42
+ name: 'region',
43
+ type: 'string',
44
+ typeOptions: { password: true },
45
+ },
46
+ ];
47
+ }`;
48
+ }
49
+
50
+ ruleTester.run('credential-unnecessary-password', CredentialUnnecessaryPasswordRule, {
51
+ valid: [
52
+ {
53
+ name: 'non-sensitive field without password option',
54
+ code: createCredentialCode([createProperty('Region', 'region')]),
55
+ },
56
+ {
57
+ name: 'non-sensitive field with password: false',
58
+ code: createCredentialCode([createProperty('Base URL', 'baseUrl', { password: false })]),
59
+ },
60
+ {
61
+ name: 'sensitive field correctly masked',
62
+ code: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
63
+ },
64
+ {
65
+ name: 'masked field with a sensitive marker is left alone even when its name contains a non-sensitive substring',
66
+ code: createCredentialCode([
67
+ // 'androidToken' contains the non-sensitive substring 'id' but is a real secret
68
+ createProperty('Android Token', 'androidToken', { password: true }),
69
+ createProperty('Token URL', 'tokenUrl', { password: true }),
70
+ ]),
71
+ },
72
+ {
73
+ name: 'class does not implement ICredentialType',
74
+ code: createRegularClass(),
75
+ },
76
+ ],
77
+ invalid: [
78
+ {
79
+ name: 'region field should not use password option',
80
+ code: createCredentialCode([createProperty('Region', 'region', { password: true })]),
81
+ errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'region' } }],
82
+ },
83
+ {
84
+ name: 'URL field should not use password option',
85
+ code: createCredentialCode([createProperty('Base URL', 'baseUrl', { password: true })]),
86
+ errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'baseUrl' } }],
87
+ },
88
+ {
89
+ name: 'ID field should not use password option',
90
+ code: createCredentialCode([createProperty('Client ID', 'clientId', { password: true })]),
91
+ errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'clientId' } }],
92
+ },
93
+ {
94
+ name: 'multiple non-sensitive masked fields are each flagged',
95
+ code: createCredentialCode([
96
+ createProperty('Region', 'region', { password: true }),
97
+ createProperty('Host', 'host', { password: true }),
98
+ ]),
99
+ errors: [
100
+ { messageId: 'unnecessaryPasswordOption', data: { fieldName: 'region' } },
101
+ { messageId: 'unnecessaryPasswordOption', data: { fieldName: 'host' } },
102
+ ],
103
+ },
104
+ ],
105
+ });
@@ -0,0 +1,72 @@
1
+ import { TSESTree } from '@typescript-eslint/utils';
2
+
3
+ import {
4
+ isCredentialTypeClass,
5
+ findClassProperty,
6
+ findObjectProperty,
7
+ getStringLiteralValue,
8
+ isSensitiveName,
9
+ hasPasswordTypeOption,
10
+ createRule,
11
+ } from '../utils/index.js';
12
+
13
+ export const CredentialUnnecessaryPasswordRule = createRule({
14
+ name: 'credential-unnecessary-password',
15
+ meta: {
16
+ type: 'suggestion',
17
+ docs: {
18
+ description:
19
+ 'Warn when a credential field with no sensitive name uses typeOptions.password = true',
20
+ },
21
+ messages: {
22
+ unnecessaryPasswordOption:
23
+ "Field '{{ fieldName }}' does not appear to be sensitive but uses 'typeOptions: { password: true }'. Remove it unless the field holds a secret",
24
+ },
25
+ schema: [],
26
+ },
27
+ defaultOptions: [],
28
+ create(context) {
29
+ return {
30
+ ClassDeclaration(node) {
31
+ if (!isCredentialTypeClass(node)) {
32
+ return;
33
+ }
34
+
35
+ const propertiesProperty = findClassProperty(node, 'properties');
36
+ if (
37
+ !propertiesProperty?.value ||
38
+ propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
39
+ ) {
40
+ return;
41
+ }
42
+
43
+ for (const element of propertiesProperty.value.elements) {
44
+ if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
45
+ continue;
46
+ }
47
+
48
+ const nameProperty = findObjectProperty(element, 'name');
49
+ const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
50
+
51
+ if (!fieldName || !hasPasswordTypeOption(element)) {
52
+ continue;
53
+ }
54
+
55
+ // Only flag a masked field when its name carries no sensitive marker at all.
56
+ // We check with `exclusions: []` (raw patterns, no URL/ID/pub exclusions) so a
57
+ // real secret like `androidToken` — non-sensitive under the default exclusions
58
+ // only because it contains the substring `id` — is not wrongly flagged.
59
+ // Reported without a fix: removing masking could still be wrong for an
60
+ // unlisted secret name, so the author confirms.
61
+ if (!isSensitiveName(fieldName, { exclusions: [] })) {
62
+ context.report({
63
+ node: element,
64
+ messageId: 'unnecessaryPasswordOption',
65
+ data: { fieldName },
66
+ });
67
+ }
68
+ }
69
+ },
70
+ };
71
+ },
72
+ });
@@ -6,6 +6,7 @@ import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
6
6
  import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
7
7
  import { CredentialPasswordFieldRule } from './credential-password-field.js';
8
8
  import { CredentialTestRequiredRule } from './credential-test-required.js';
9
+ import { CredentialUnnecessaryPasswordRule } from './credential-unnecessary-password.js';
9
10
  import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
10
11
  import { IconValidationRule } from './icon-validation.js';
11
12
  import { MissingPairedItemRule } from './missing-paired-item.js';
@@ -13,20 +14,25 @@ import { N8nObjectValidationRule } from './n8n-object-validation.js';
13
14
  import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js';
14
15
  import { NoCredentialReuseRule } from './no-credential-reuse.js';
15
16
  import { NoDangerousFunctionsRule } from './no-dangerous-functions.js';
17
+ import { NoDeadFilesRule } from './no-dead-files.js';
16
18
  import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
17
19
  import { NoEmojiInOptionsRule } from './no-emoji-in-options.js';
18
20
  import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js';
21
+ import { NoHardcodedSecretsRule } from './no-hardcoded-secrets.js';
19
22
  import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js';
20
23
  import { NoOverridesFieldRule } from './no-overrides-field.js';
21
24
  import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
22
25
  import { NoRestrictedImportsRule } from './no-restricted-imports.js';
23
26
  import { NoRuntimeDependenciesRule } from './no-runtime-dependencies.js';
27
+ import { NoSilentErrorSwallowingRule } from './no-silent-error-swallowing.js';
24
28
  import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
25
29
  import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
26
30
  import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
27
31
  import { NodeRegistrationCompleteRule } from './node-registration-complete.js';
28
32
  import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
29
33
  import { PackageNameConventionRule } from './package-name-convention.js';
34
+ import { RequireFilesArrayRule } from './require-files-array.js';
35
+ import { RequireHomepageRule } from './require-homepage.js';
30
36
  import { RequireNodeApiErrorRule } from './require-node-api-error.js';
31
37
  import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
32
38
  import { RequireVersionRule } from './require-version.js';
@@ -48,12 +54,16 @@ export const rules = {
48
54
  'node-usable-as-tool': NodeUsableAsToolRule,
49
55
  'package-name-convention': PackageNameConventionRule,
50
56
  'credential-test-required': CredentialTestRequiredRule,
57
+ 'credential-unnecessary-password': CredentialUnnecessaryPasswordRule,
51
58
  'no-credential-reuse': NoCredentialReuseRule,
52
59
  'no-dangerous-functions': NoDangerousFunctionsRule,
60
+ 'no-dead-files': NoDeadFilesRule,
53
61
  'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule,
62
+ 'no-hardcoded-secrets': NoHardcodedSecretsRule,
54
63
  'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule,
55
64
  'no-overrides-field': NoOverridesFieldRule,
56
65
  'no-runtime-dependencies': NoRuntimeDependenciesRule,
66
+ 'no-silent-error-swallowing': NoSilentErrorSwallowingRule,
57
67
  'no-template-placeholders': NoTemplatePlaceholdersRule,
58
68
  'icon-validation': IconValidationRule,
59
69
  'icon-prefer-themed-variants': IconPreferThemedVariantsRule,
@@ -68,6 +78,8 @@ export const rules = {
68
78
  'missing-paired-item': MissingPairedItemRule,
69
79
  'no-builder-hint-leakage': NoBuilderHintLeakageRule,
70
80
  'n8n-object-validation': N8nObjectValidationRule,
81
+ 'require-files-array': RequireFilesArrayRule,
82
+ 'require-homepage': RequireHomepageRule,
71
83
  'require-node-api-error': RequireNodeApiErrorRule,
72
84
  'require-node-description-fields': RequireNodeDescriptionFieldsRule,
73
85
  'require-version': RequireVersionRule,
@@ -0,0 +1,128 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { isDeadFileName, NoDeadFilesRule } from './no-dead-files.js';
5
+ import * as fileUtils from '../utils/file-utils.js';
6
+
7
+ vi.mock('../utils/file-utils.js', async () => {
8
+ const actual = await vi.importActual('../utils/file-utils.js');
9
+ return {
10
+ ...actual,
11
+ findFilesInPackageDirs: vi.fn(),
12
+ };
13
+ });
14
+
15
+ const mockFindFilesInPackageDirs = vi.mocked(fileUtils.findFilesInPackageDirs);
16
+
17
+ const packageJsonPath = '/tmp/package.json';
18
+
19
+ const ruleTester = new RuleTester();
20
+
21
+ function setup(deadFiles: string[]): void {
22
+ mockFindFilesInPackageDirs.mockReturnValue(deadFiles);
23
+ }
24
+
25
+ afterEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ // Default: no dead files on disk.
30
+ setup([]);
31
+
32
+ ruleTester.run('no-dead-files', NoDeadFilesRule, {
33
+ valid: [
34
+ {
35
+ name: 'package with no dead files',
36
+ filename: packageJsonPath,
37
+ code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": ["dist/nodes/Foo/Foo.node.js"] } }',
38
+ },
39
+ {
40
+ name: 'non-package.json file is ignored',
41
+ filename: 'some-config.json',
42
+ code: '{ "name": "n8n-nodes-example" }',
43
+ before() {
44
+ setup(['/tmp/nodes/Foo/Foo.node.ts.bak']);
45
+ },
46
+ },
47
+ ],
48
+ invalid: [
49
+ {
50
+ name: 'single backup file is reported',
51
+ filename: packageJsonPath,
52
+ code: '{ "name": "n8n-nodes-example" }',
53
+ before() {
54
+ setup(['/tmp/nodes/Foo/Foo.node.ts.bak']);
55
+ },
56
+ errors: [
57
+ {
58
+ messageId: 'deadFileFound',
59
+ data: { file: 'nodes/Foo/Foo.node.ts.bak' },
60
+ },
61
+ ],
62
+ },
63
+ {
64
+ name: 'multiple dead files are each reported',
65
+ filename: packageJsonPath,
66
+ code: '{ "name": "n8n-nodes-example" }',
67
+ before() {
68
+ setup([
69
+ '/tmp/nodes/Foo/Foo.node.ts.backup',
70
+ '/tmp/nodes/Foo/test.js',
71
+ '/tmp/nodes/Bar/Bar.node.ts:Zone.Identifier',
72
+ ]);
73
+ },
74
+ errors: [
75
+ { messageId: 'deadFileFound', data: { file: 'nodes/Foo/Foo.node.ts.backup' } },
76
+ { messageId: 'deadFileFound', data: { file: 'nodes/Foo/test.js' } },
77
+ { messageId: 'deadFileFound', data: { file: 'nodes/Bar/Bar.node.ts:Zone.Identifier' } },
78
+ ],
79
+ },
80
+ {
81
+ name: 'stray test.ts file is reported',
82
+ filename: packageJsonPath,
83
+ code: '{ "name": "n8n-nodes-example" }',
84
+ before() {
85
+ setup(['/tmp/nodes/test.ts']);
86
+ },
87
+ errors: [{ messageId: 'deadFileFound', data: { file: 'nodes/test.ts' } }],
88
+ },
89
+ {
90
+ name: 'dead file in credentials directory is reported',
91
+ filename: packageJsonPath,
92
+ code: '{ "name": "n8n-nodes-example" }',
93
+ before() {
94
+ setup(['/tmp/credentials/MyApi.credentials.ts.bak']);
95
+ },
96
+ errors: [
97
+ { messageId: 'deadFileFound', data: { file: 'credentials/MyApi.credentials.ts.bak' } },
98
+ ],
99
+ },
100
+ ],
101
+ });
102
+
103
+ describe('isDeadFileName', () => {
104
+ it.each([
105
+ 'Foo.node.ts.backup',
106
+ 'notes.backup',
107
+ 'Foo.node.ts.bak',
108
+ 'archive.bak',
109
+ 'Zone.Identifier',
110
+ 'Foo.node.ts:Zone.Identifier',
111
+ 'test.js',
112
+ 'test.ts',
113
+ ])('flags %s as a dead file', (fileName) => {
114
+ expect(isDeadFileName(fileName)).toBe(true);
115
+ });
116
+
117
+ it.each([
118
+ 'Foo.node.ts',
119
+ 'Foo.node.js',
120
+ 'index.ts',
121
+ 'test.json',
122
+ 'mytest.js',
123
+ 'backup.ts',
124
+ 'GenericFunctions.ts',
125
+ ])('does not flag %s', (fileName) => {
126
+ expect(isDeadFileName(fileName)).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,71 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+ import * as path from 'node:path';
4
+
5
+ import { createRule, findFilesInPackageDirs } from '../utils/index.js';
6
+
7
+ // Package subdirectories that ship in the published artifact and should stay clean.
8
+ const SCAN_DIRS = ['nodes', 'credentials'];
9
+
10
+ const DEAD_FILE_MATCHERS: Array<(fileName: string) => boolean> = [
11
+ (fileName) => fileName.endsWith('.backup'),
12
+ (fileName) => fileName.endsWith('.bak'),
13
+ // Windows "mark of the web" ADS files, extracted as literal filenames on
14
+ // filesystems without alternate data streams (e.g. `Foo.node.ts:Zone.Identifier`).
15
+ (fileName) => fileName.endsWith('Zone.Identifier'),
16
+ (fileName) => fileName === 'test.js',
17
+ (fileName) => fileName === 'test.ts',
18
+ ];
19
+
20
+ /**
21
+ * Returns true if the given filename looks like a leftover backup, editor, or
22
+ * test-artifact file that should not ship with a community node.
23
+ */
24
+ export function isDeadFileName(fileName: string): boolean {
25
+ return DEAD_FILE_MATCHERS.some((matches) => matches(fileName));
26
+ }
27
+
28
+ export const NoDeadFilesRule = createRule({
29
+ name: 'no-dead-files',
30
+ meta: {
31
+ type: 'problem',
32
+ docs: {
33
+ description:
34
+ 'Disallow leftover backup, editor, and test-artifact files in the `nodes/` and `credentials/` directories of a community node package',
35
+ },
36
+ messages: {
37
+ deadFileFound:
38
+ 'Remove the leftover file "{{ file }}". Backup, editor, and test-artifact files should not be published with a community node.',
39
+ },
40
+ schema: [],
41
+ },
42
+ defaultOptions: [],
43
+ create(context) {
44
+ if (!context.filename.endsWith('package.json')) {
45
+ return {};
46
+ }
47
+
48
+ return {
49
+ ObjectExpression(node: TSESTree.ObjectExpression) {
50
+ // Only inspect the root object of the package.json file, so the scan runs once.
51
+ if (node.parent?.type !== AST_NODE_TYPES.ExpressionStatement) {
52
+ return;
53
+ }
54
+
55
+ const packageDir = path.dirname(context.filename);
56
+
57
+ for (const deadFile of findFilesInPackageDirs(
58
+ context.filename,
59
+ SCAN_DIRS,
60
+ isDeadFileName,
61
+ )) {
62
+ context.report({
63
+ node,
64
+ messageId: 'deadFileFound',
65
+ data: { file: path.relative(packageDir, deadFile) },
66
+ });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ });
@@ -0,0 +1,124 @@
1
+ import { RuleTester } from '@typescript-eslint/rule-tester';
2
+
3
+ import { NoHardcodedSecretsRule } from './no-hardcoded-secrets.js';
4
+
5
+ const ruleTester = new RuleTester();
6
+
7
+ // A 40-char hex string (SHA-1 shaped) — a realistic hardcoded secret value.
8
+ const HEX_SECRET = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
9
+ // A base64/token-shaped value with mixed case, digits and punctuation.
10
+ const TOKEN_SECRET = 'sk-1a2B3c4D5e6F7g8H9i0J-kLmNoP';
11
+
12
+ ruleTester.run('no-hardcoded-secrets', NoHardcodedSecretsRule, {
13
+ valid: [
14
+ // Non-secret variable names are never inspected.
15
+ { name: 'non-secret variable name', code: `const label = '${HEX_SECRET}';` },
16
+ { name: 'unrelated name with long value', code: `const description = '${TOKEN_SECRET}';` },
17
+
18
+ // Secret-named variables with short values are ignored (< 16 chars).
19
+ { name: 'short secret value', code: "const apiKey = 'short';" },
20
+ { name: '15 chars, below minimum', code: "const token = 'abcdef012345678';" },
21
+
22
+ // Secret-named variables holding prose / human-readable strings.
23
+ {
24
+ name: 'secret name with human text',
25
+ code: "const passwordHint = 'Enter your account password here';",
26
+ },
27
+ {
28
+ name: 'secret name with spaces in value',
29
+ code: "const secretNote = 'this is not a secret';",
30
+ },
31
+
32
+ // Plain long camelCase word is not token-shaped (no digits/punctuation).
33
+ { name: 'long word without digits', code: "const tokenName = 'anExtraordinarilyLongName';" },
34
+
35
+ // Dotted / hyphenated / snake_case constants without a digit+letter mix
36
+ // are common (scopes, paths, ids) and must not be treated as secrets.
37
+ { name: 'dotted scope string', code: "const authScope = 'read.write.admin.profile.email';" },
38
+ { name: 'hyphenated slug', code: "const secretName = 'my-very-long-descriptive-slug';" },
39
+ { name: 'snake_case constant', code: "const tokenType = 'refresh_token_grant_type_value';" },
40
+
41
+ // Secret-shaped name but non-secret value: the value gate, not the name,
42
+ // is what decides. `tokenId` no longer benefits from a name exclusion, so
43
+ // this proves a benign value still passes on its own merits.
44
+ {
45
+ name: 'tokenId with human-readable value',
46
+ code: "const tokenId = 'primary-session-reference';",
47
+ },
48
+
49
+ // Obvious placeholders are skipped.
50
+ { name: 'placeholder value', code: "const apiKey = 'your-api-key-goes-here-xxxx';" },
51
+ { name: 'example placeholder', code: `const secret = 'example-secret-${HEX_SECRET}';` },
52
+
53
+ // Not a static string literal.
54
+ { name: 'value from env', code: 'const apiKey = process.env.API_KEY;' },
55
+ // The payload must itself contain `${` to be a real template literal.
56
+ // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
57
+ { name: 'template literal with expression', code: 'const token = `${suffix}`;' },
58
+ { name: 'computed member assignment', code: `obj[key] = '${HEX_SECRET}';` },
59
+ ],
60
+ invalid: [
61
+ {
62
+ name: 'SECURITY: exactly 16 chars is flagged (minimum length)',
63
+ code: "const token = 'abcdef0123456789';",
64
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'token' } }],
65
+ },
66
+ {
67
+ name: 'SECURITY: hex secret in variable',
68
+ code: `const apiKey = '${HEX_SECRET}';`,
69
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'apiKey' } }],
70
+ },
71
+ {
72
+ name: 'SECURITY: token-shaped secret in variable',
73
+ code: `const accessToken = '${TOKEN_SECRET}';`,
74
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'accessToken' } }],
75
+ },
76
+ {
77
+ name: 'SECURITY: secret in object property (identifier key)',
78
+ code: `const config = { clientSecret: '${HEX_SECRET}' };`,
79
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'clientSecret' } }],
80
+ },
81
+ {
82
+ name: 'SECURITY: secret in object property (string key)',
83
+ code: `const config = { 'api_key': '${HEX_SECRET}' };`,
84
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'api_key' } }],
85
+ },
86
+ {
87
+ name: 'SECURITY: secret in class field',
88
+ code: `class C { private password = '${TOKEN_SECRET}'; }`,
89
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'password' } }],
90
+ },
91
+ {
92
+ name: 'SECURITY: secret in member assignment',
93
+ code: `this.authToken = '${HEX_SECRET}';`,
94
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'authToken' } }],
95
+ },
96
+ {
97
+ name: 'SECURITY: secret in identifier assignment',
98
+ code: `secretKey = '${TOKEN_SECRET}';`,
99
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'secretKey' } }],
100
+ },
101
+ {
102
+ name: 'SECURITY: secret in no-expression template literal',
103
+ code: `const apiKey = \`${HEX_SECRET}\`;`,
104
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'apiKey' } }],
105
+ },
106
+ // No name exclusions: a secret-shaped value is flagged even when the name
107
+ // also contains a normally-"non-sensitive" fragment (url/pub/id).
108
+ {
109
+ name: 'SECURITY: secret on name containing "pub"',
110
+ code: `const publicKey = '${HEX_SECRET}';`,
111
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'publicKey' } }],
112
+ },
113
+ {
114
+ name: 'SECURITY: secret on name containing "id"',
115
+ code: `const tokenId = '${HEX_SECRET}';`,
116
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'tokenId' } }],
117
+ },
118
+ {
119
+ name: 'SECURITY: secret on name containing "url"',
120
+ code: `const tokenUrl = '${TOKEN_SECRET}';`,
121
+ errors: [{ messageId: 'hardcodedSecret', data: { name: 'tokenUrl' } }],
122
+ },
123
+ ],
124
+ });