@n8n/eslint-plugin-community-nodes 0.2.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/.turbo/turbo-build.log +4 -0
- package/LICENSE.md +88 -0
- package/LICENSE_EE.md +27 -0
- package/dist/plugin.d.ts +61 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +51 -0
- package/dist/plugin.js.map +1 -0
- package/dist/rules/credential-password-field.d.ts +3 -0
- package/dist/rules/credential-password-field.d.ts.map +1 -0
- package/dist/rules/credential-password-field.js +97 -0
- package/dist/rules/credential-password-field.js.map +1 -0
- package/dist/rules/credential-test-required.d.ts +3 -0
- package/dist/rules/credential-test-required.d.ts.map +1 -0
- package/dist/rules/credential-test-required.js +79 -0
- package/dist/rules/credential-test-required.js.map +1 -0
- package/dist/rules/icon-validation.d.ts +3 -0
- package/dist/rules/icon-validation.d.ts.map +1 -0
- package/dist/rules/icon-validation.js +131 -0
- package/dist/rules/icon-validation.js.map +1 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +23 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/no-credential-reuse.d.ts +3 -0
- package/dist/rules/no-credential-reuse.d.ts.map +1 -0
- package/dist/rules/no-credential-reuse.js +62 -0
- package/dist/rules/no-credential-reuse.js.map +1 -0
- package/dist/rules/no-deprecated-workflow-functions.d.ts +3 -0
- package/dist/rules/no-deprecated-workflow-functions.d.ts.map +1 -0
- package/dist/rules/no-deprecated-workflow-functions.js +144 -0
- package/dist/rules/no-deprecated-workflow-functions.js.map +1 -0
- package/dist/rules/no-restricted-globals.d.ts +3 -0
- package/dist/rules/no-restricted-globals.d.ts.map +1 -0
- package/dist/rules/no-restricted-globals.js +58 -0
- package/dist/rules/no-restricted-globals.js.map +1 -0
- package/dist/rules/no-restricted-imports.d.ts +3 -0
- package/dist/rules/no-restricted-imports.d.ts.map +1 -0
- package/dist/rules/no-restricted-imports.js +80 -0
- package/dist/rules/no-restricted-imports.js.map +1 -0
- package/dist/rules/node-usable-as-tool.d.ts +3 -0
- package/dist/rules/node-usable-as-tool.d.ts.map +1 -0
- package/dist/rules/node-usable-as-tool.js +57 -0
- package/dist/rules/node-usable-as-tool.js.map +1 -0
- package/dist/rules/package-name-convention.d.ts +3 -0
- package/dist/rules/package-name-convention.d.ts.map +1 -0
- package/dist/rules/package-name-convention.js +52 -0
- package/dist/rules/package-name-convention.js.map +1 -0
- package/dist/rules/resource-operation-pattern.d.ts +3 -0
- package/dist/rules/resource-operation-pattern.d.ts.map +1 -0
- package/dist/rules/resource-operation-pattern.js +77 -0
- package/dist/rules/resource-operation-pattern.js.map +1 -0
- package/dist/utils/ast-utils.d.ts +25 -0
- package/dist/utils/ast-utils.d.ts.map +1 -0
- package/dist/utils/ast-utils.js +117 -0
- package/dist/utils/ast-utils.js.map +1 -0
- package/dist/utils/file-utils.d.ts +12 -0
- package/dist/utils/file-utils.d.ts.map +1 -0
- package/dist/utils/file-utils.js +154 -0
- package/dist/utils/file-utils.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +46 -0
- package/src/plugin.ts +55 -0
- package/src/rules/credential-password-field.test.ts +231 -0
- package/src/rules/credential-password-field.ts +123 -0
- package/src/rules/credential-test-required.test.ts +147 -0
- package/src/rules/credential-test-required.ts +99 -0
- package/src/rules/icon-validation.test.ts +196 -0
- package/src/rules/icon-validation.ts +158 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/no-credential-reuse.test.ts +226 -0
- package/src/rules/no-credential-reuse.ts +81 -0
- package/src/rules/no-deprecated-workflow-functions.test.ts +117 -0
- package/src/rules/no-deprecated-workflow-functions.ts +166 -0
- package/src/rules/no-restricted-globals.test.ts +135 -0
- package/src/rules/no-restricted-globals.ts +71 -0
- package/src/rules/no-restricted-imports.test.ts +181 -0
- package/src/rules/no-restricted-imports.ts +86 -0
- package/src/rules/node-usable-as-tool.test.ts +80 -0
- package/src/rules/node-usable-as-tool.ts +70 -0
- package/src/rules/package-name-convention.test.ts +112 -0
- package/src/rules/package-name-convention.ts +63 -0
- package/src/rules/resource-operation-pattern.test.ts +216 -0
- package/src/rules/resource-operation-pattern.ts +97 -0
- package/src/utils/ast-utils.ts +179 -0
- package/src/utils/file-utils.ts +204 -0
- package/src/utils/index.ts +2 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +4 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { IconValidationRule } from './icon-validation.js';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const ruleTester = new RuleTester();
|
|
7
|
+
|
|
8
|
+
vi.mock('node:fs', () => ({
|
|
9
|
+
existsSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
13
|
+
|
|
14
|
+
function setupMockFileSystem() {
|
|
15
|
+
mockExistsSync.mockImplementation((path: fs.PathLike) => {
|
|
16
|
+
const pathStr = path.toString();
|
|
17
|
+
return (
|
|
18
|
+
pathStr.includes('TestNode.svg') ||
|
|
19
|
+
pathStr.includes('ValidIcon.svg') ||
|
|
20
|
+
pathStr.includes('ValidIcon.dark.svg') ||
|
|
21
|
+
pathStr.includes('SameIcon.svg') ||
|
|
22
|
+
pathStr.includes('NotSvg.png')
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setupMockFileSystem();
|
|
28
|
+
|
|
29
|
+
const nodeFilePath = '/tmp/TestNode.node.ts';
|
|
30
|
+
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
|
|
31
|
+
|
|
32
|
+
function createNodeCode(
|
|
33
|
+
icon?: string | { light: string; dark: string },
|
|
34
|
+
includeTypeImport: boolean = false,
|
|
35
|
+
): string {
|
|
36
|
+
const typeImport = includeTypeImport
|
|
37
|
+
? `import type { INodeType, INodeTypeDescription } from 'n8n-workflow';`
|
|
38
|
+
: `import type { INodeType } from 'n8n-workflow';`;
|
|
39
|
+
|
|
40
|
+
const typeAnnotation = includeTypeImport ? `: INodeTypeDescription` : '';
|
|
41
|
+
|
|
42
|
+
let iconProperty = '';
|
|
43
|
+
if (icon) {
|
|
44
|
+
if (typeof icon === 'string') {
|
|
45
|
+
iconProperty = `icon: '${icon}',`;
|
|
46
|
+
} else {
|
|
47
|
+
iconProperty = `icon: {
|
|
48
|
+
light: '${icon.light}',
|
|
49
|
+
dark: '${icon.dark}'
|
|
50
|
+
},`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `
|
|
55
|
+
${typeImport}
|
|
56
|
+
|
|
57
|
+
export class TestNode implements INodeType {
|
|
58
|
+
description${typeAnnotation} = {
|
|
59
|
+
displayName: 'Test Node',
|
|
60
|
+
name: 'testNode',
|
|
61
|
+
${iconProperty}
|
|
62
|
+
group: ['input'],
|
|
63
|
+
version: 1,
|
|
64
|
+
description: 'A test node',
|
|
65
|
+
defaults: {
|
|
66
|
+
name: 'Test Node',
|
|
67
|
+
},
|
|
68
|
+
inputs: ['main'],
|
|
69
|
+
outputs: ['main'],
|
|
70
|
+
properties: [],
|
|
71
|
+
};
|
|
72
|
+
}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
|
|
76
|
+
let iconProperty = '';
|
|
77
|
+
if (icon) {
|
|
78
|
+
if (typeof icon === 'string') {
|
|
79
|
+
iconProperty = `icon = '${icon}';`;
|
|
80
|
+
} else {
|
|
81
|
+
iconProperty = `icon = {
|
|
82
|
+
light: '${icon.light}',
|
|
83
|
+
dark: '${icon.dark}'
|
|
84
|
+
};`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `
|
|
89
|
+
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
|
90
|
+
|
|
91
|
+
export class TestCredential implements ICredentialType {
|
|
92
|
+
name = 'testApi';
|
|
93
|
+
displayName = 'Test API';
|
|
94
|
+
${iconProperty}
|
|
95
|
+
properties: INodeProperties[] = [];
|
|
96
|
+
}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Helper function to create non-node class
|
|
100
|
+
function createNonNodeClass(icon: string): string {
|
|
101
|
+
return `
|
|
102
|
+
export class NotANode {
|
|
103
|
+
icon = '${icon}';
|
|
104
|
+
}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ruleTester.run('icon-validation', IconValidationRule, {
|
|
108
|
+
valid: [
|
|
109
|
+
{
|
|
110
|
+
name: 'non-node class ignored',
|
|
111
|
+
filename: nodeFilePath,
|
|
112
|
+
code: createNonNodeClass('file:nonexistent.png'),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'non-node file ignored',
|
|
116
|
+
filename: '/tmp/regular-file.ts',
|
|
117
|
+
code: createNodeCode('file:nonexistent.svg'),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'node with valid string icon in description',
|
|
121
|
+
filename: nodeFilePath,
|
|
122
|
+
code: createNodeCode('file:icons/TestNode.svg', true),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'node with valid light/dark icons in description',
|
|
126
|
+
filename: nodeFilePath,
|
|
127
|
+
code: createNodeCode(
|
|
128
|
+
{
|
|
129
|
+
light: 'file:icons/ValidIcon.svg',
|
|
130
|
+
dark: 'file:icons/ValidIcon.dark.svg',
|
|
131
|
+
},
|
|
132
|
+
true,
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'credential with valid string icon',
|
|
137
|
+
filename: credentialFilePath,
|
|
138
|
+
code: createCredentialCode('file:icons/TestNode.svg'),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'credential with valid light/dark icons',
|
|
142
|
+
filename: credentialFilePath,
|
|
143
|
+
code: createCredentialCode({
|
|
144
|
+
light: 'file:icons/ValidIcon.svg',
|
|
145
|
+
dark: 'file:icons/ValidIcon.dark.svg',
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
invalid: [
|
|
150
|
+
{
|
|
151
|
+
name: 'node missing icon property in description',
|
|
152
|
+
filename: nodeFilePath,
|
|
153
|
+
code: createNodeCode(undefined, true),
|
|
154
|
+
errors: [{ messageId: 'missingIcon' }],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'icon file does not exist in description',
|
|
158
|
+
filename: nodeFilePath,
|
|
159
|
+
code: createNodeCode('file:icons/NonExistent.svg', true),
|
|
160
|
+
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'light and dark icons are the same file in description',
|
|
164
|
+
filename: nodeFilePath,
|
|
165
|
+
code: createNodeCode(
|
|
166
|
+
{
|
|
167
|
+
light: 'file:icons/SameIcon.svg',
|
|
168
|
+
dark: 'file:icons/SameIcon.svg',
|
|
169
|
+
},
|
|
170
|
+
true,
|
|
171
|
+
),
|
|
172
|
+
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'credential missing icon property',
|
|
176
|
+
filename: credentialFilePath,
|
|
177
|
+
code: createCredentialCode(),
|
|
178
|
+
errors: [{ messageId: 'missingIcon' }],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'credential icon file does not exist',
|
|
182
|
+
filename: credentialFilePath,
|
|
183
|
+
code: createCredentialCode('file:icons/NonExistent.svg'),
|
|
184
|
+
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'credential light and dark icons are the same file',
|
|
188
|
+
filename: credentialFilePath,
|
|
189
|
+
code: createCredentialCode({
|
|
190
|
+
light: 'file:icons/SameIcon.svg',
|
|
191
|
+
dark: 'file:icons/SameIcon.svg',
|
|
192
|
+
}),
|
|
193
|
+
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
isNodeTypeClass,
|
|
5
|
+
isCredentialTypeClass,
|
|
6
|
+
findClassProperty,
|
|
7
|
+
findObjectProperty,
|
|
8
|
+
getStringLiteralValue,
|
|
9
|
+
validateIconPath,
|
|
10
|
+
isFileType,
|
|
11
|
+
} from '../utils/index.js';
|
|
12
|
+
|
|
13
|
+
export const IconValidationRule = ESLintUtils.RuleCreator.withoutDocs({
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'problem',
|
|
16
|
+
docs: {
|
|
17
|
+
description:
|
|
18
|
+
'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
|
|
19
|
+
},
|
|
20
|
+
messages: {
|
|
21
|
+
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
|
|
22
|
+
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
|
|
23
|
+
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
|
|
24
|
+
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
|
|
25
|
+
missingIcon: 'Node/Credential class must have an icon property defined',
|
|
26
|
+
},
|
|
27
|
+
schema: [],
|
|
28
|
+
},
|
|
29
|
+
defaultOptions: [],
|
|
30
|
+
create(context) {
|
|
31
|
+
if (
|
|
32
|
+
!isFileType(context.filename, '.node.ts') &&
|
|
33
|
+
!isFileType(context.filename, '.credentials.ts')
|
|
34
|
+
) {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const validateIcon = (iconPath: string | null, node: TSESTree.Node): boolean => {
|
|
39
|
+
if (!iconPath) {
|
|
40
|
+
context.report({
|
|
41
|
+
node,
|
|
42
|
+
messageId: 'invalidIconPath',
|
|
43
|
+
data: { iconPath: iconPath || '' },
|
|
44
|
+
});
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const currentDir = dirname(context.filename);
|
|
49
|
+
const validation = validateIconPath(iconPath, currentDir);
|
|
50
|
+
|
|
51
|
+
if (!validation.isFile) {
|
|
52
|
+
context.report({
|
|
53
|
+
node,
|
|
54
|
+
messageId: 'invalidIconPath',
|
|
55
|
+
data: { iconPath },
|
|
56
|
+
});
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!validation.isSvg) {
|
|
61
|
+
const relativePath = iconPath.replace(/^file:/, '');
|
|
62
|
+
context.report({
|
|
63
|
+
node,
|
|
64
|
+
messageId: 'iconNotSvg',
|
|
65
|
+
data: { iconPath: relativePath },
|
|
66
|
+
});
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!validation.exists) {
|
|
71
|
+
const relativePath = iconPath.replace(/^file:/, '');
|
|
72
|
+
context.report({
|
|
73
|
+
node,
|
|
74
|
+
messageId: 'iconFileNotFound',
|
|
75
|
+
data: { iconPath: relativePath },
|
|
76
|
+
});
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const validateIconValue = (iconValue: TSESTree.Node) => {
|
|
84
|
+
if (iconValue.type === 'Literal') {
|
|
85
|
+
const iconPath = getStringLiteralValue(iconValue);
|
|
86
|
+
validateIcon(iconPath, iconValue);
|
|
87
|
+
} else if (iconValue.type === 'ObjectExpression') {
|
|
88
|
+
const lightProperty = findObjectProperty(iconValue, 'light');
|
|
89
|
+
const darkProperty = findObjectProperty(iconValue, 'dark');
|
|
90
|
+
|
|
91
|
+
const lightPath = lightProperty ? getStringLiteralValue(lightProperty.value) : null;
|
|
92
|
+
const darkPath = darkProperty ? getStringLiteralValue(darkProperty.value) : null;
|
|
93
|
+
|
|
94
|
+
if (lightProperty) {
|
|
95
|
+
validateIcon(lightPath, lightProperty.value);
|
|
96
|
+
}
|
|
97
|
+
if (darkProperty) {
|
|
98
|
+
validateIcon(darkPath, darkProperty.value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (lightPath && darkPath && lightPath === darkPath && lightProperty) {
|
|
102
|
+
context.report({
|
|
103
|
+
node: lightProperty.value,
|
|
104
|
+
messageId: 'lightDarkSame',
|
|
105
|
+
data: { iconPath: lightPath.replace(/^file:/, '') },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ClassDeclaration(node) {
|
|
113
|
+
const isNodeClass = isNodeTypeClass(node);
|
|
114
|
+
const isCredentialClass = isCredentialTypeClass(node);
|
|
115
|
+
|
|
116
|
+
if (!isNodeClass && !isCredentialClass) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isNodeClass) {
|
|
121
|
+
const descriptionProperty = findClassProperty(node, 'description');
|
|
122
|
+
if (
|
|
123
|
+
!descriptionProperty?.value ||
|
|
124
|
+
descriptionProperty.value.type !== 'ObjectExpression'
|
|
125
|
+
) {
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
messageId: 'missingIcon',
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const iconProperty = findObjectProperty(descriptionProperty.value, 'icon');
|
|
134
|
+
if (!iconProperty) {
|
|
135
|
+
context.report({
|
|
136
|
+
node,
|
|
137
|
+
messageId: 'missingIcon',
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
validateIconValue(iconProperty.value);
|
|
143
|
+
} else if (isCredentialClass) {
|
|
144
|
+
const iconProperty = findClassProperty(node, 'icon');
|
|
145
|
+
if (!iconProperty?.value) {
|
|
146
|
+
context.report({
|
|
147
|
+
node,
|
|
148
|
+
messageId: 'missingIcon',
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
validateIconValue(iconProperty.value);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
|
3
|
+
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
|
4
|
+
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
|
5
|
+
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
|
6
|
+
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
|
7
|
+
import { PackageNameConventionRule } from './package-name-convention.js';
|
|
8
|
+
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
|
9
|
+
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
|
10
|
+
import { IconValidationRule } from './icon-validation.js';
|
|
11
|
+
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
|
12
|
+
|
|
13
|
+
export const rules = {
|
|
14
|
+
'no-restricted-globals': NoRestrictedGlobalsRule,
|
|
15
|
+
'no-restricted-imports': NoRestrictedImportsRule,
|
|
16
|
+
'credential-password-field': CredentialPasswordFieldRule,
|
|
17
|
+
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
|
|
18
|
+
'node-usable-as-tool': NodeUsableAsToolRule,
|
|
19
|
+
'package-name-convention': PackageNameConventionRule,
|
|
20
|
+
'credential-test-required': CredentialTestRequiredRule,
|
|
21
|
+
'no-credential-reuse': NoCredentialReuseRule,
|
|
22
|
+
'icon-validation': IconValidationRule,
|
|
23
|
+
'resource-operation-pattern': ResourceOperationPatternRule,
|
|
24
|
+
} satisfies Record<string, AnyRuleModule>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const ruleTester = new RuleTester();
|
|
7
|
+
|
|
8
|
+
// Mock fs functions
|
|
9
|
+
vi.mock('node:fs', () => ({
|
|
10
|
+
readFileSync: vi.fn(),
|
|
11
|
+
existsSync: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockReadFileSync = vi.mocked(fs.readFileSync);
|
|
15
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
16
|
+
|
|
17
|
+
const nodeFilePath = '/tmp/TestNode.node.ts';
|
|
18
|
+
|
|
19
|
+
function createCredentialClass(name: string, displayName: string): string {
|
|
20
|
+
return `
|
|
21
|
+
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
|
22
|
+
|
|
23
|
+
export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType {
|
|
24
|
+
name = '${name}';
|
|
25
|
+
displayName = '${displayName}';
|
|
26
|
+
properties: INodeProperties[] = [];
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createNodeCode(
|
|
32
|
+
credentials: (string | { name: string; required?: boolean })[] = [],
|
|
33
|
+
): string {
|
|
34
|
+
const credentialsArray =
|
|
35
|
+
credentials.length > 0
|
|
36
|
+
? credentials
|
|
37
|
+
.map((cred) => {
|
|
38
|
+
if (typeof cred === 'string') {
|
|
39
|
+
return `'${cred}'`;
|
|
40
|
+
} else {
|
|
41
|
+
const required =
|
|
42
|
+
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
|
|
43
|
+
return `{\n\t\t\t\tname: '${cred.name}'${required},\n\t\t\t}`;
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.join(',\n\t\t\t')
|
|
47
|
+
: '';
|
|
48
|
+
|
|
49
|
+
const credentialsProperty =
|
|
50
|
+
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
|
|
51
|
+
|
|
52
|
+
return `
|
|
53
|
+
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
54
|
+
|
|
55
|
+
export class TestNode implements INodeType {
|
|
56
|
+
description: INodeTypeDescription = {
|
|
57
|
+
displayName: 'Test Node',
|
|
58
|
+
name: 'testNode',
|
|
59
|
+
group: ['output'],
|
|
60
|
+
version: 1,
|
|
61
|
+
inputs: ['main'],
|
|
62
|
+
outputs: ['main'],
|
|
63
|
+
${credentialsProperty}
|
|
64
|
+
properties: [],
|
|
65
|
+
};
|
|
66
|
+
}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Helper function to create non-node class
|
|
70
|
+
function createNonNodeClass(): string {
|
|
71
|
+
return `
|
|
72
|
+
export class RegularClass {
|
|
73
|
+
credentials = [
|
|
74
|
+
{ name: 'ExternalApi', required: true }
|
|
75
|
+
];
|
|
76
|
+
}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Helper function to create non-INodeType class
|
|
80
|
+
function createNonINodeTypeClass(): string {
|
|
81
|
+
return `
|
|
82
|
+
export class NotANode {
|
|
83
|
+
description = {
|
|
84
|
+
displayName: 'Not A Node',
|
|
85
|
+
credentials: [
|
|
86
|
+
{ name: 'ExternalApi', required: true }
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setupMockFileSystem() {
|
|
93
|
+
const packageJson = {
|
|
94
|
+
name: 'test-package',
|
|
95
|
+
n8n: {
|
|
96
|
+
credentials: [
|
|
97
|
+
'dist/credentials/MyApi.credentials.js',
|
|
98
|
+
'dist/credentials/AnotherApi.credentials.js',
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const myApiCredential = createCredentialClass('myApiCredential', 'My API');
|
|
104
|
+
const anotherApiCredential = createCredentialClass('anotherApiCredential', 'Another API');
|
|
105
|
+
|
|
106
|
+
mockExistsSync.mockImplementation((path: fs.PathLike) => {
|
|
107
|
+
const pathStr = path.toString();
|
|
108
|
+
return (
|
|
109
|
+
pathStr.includes('package.json') ||
|
|
110
|
+
pathStr.includes('MyApi.credentials.ts') ||
|
|
111
|
+
pathStr.includes('AnotherApi.credentials.ts')
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
mockReadFileSync.mockImplementation((path: any): string => {
|
|
116
|
+
const pathStr = path.toString();
|
|
117
|
+
if (pathStr.includes('package.json')) {
|
|
118
|
+
return JSON.stringify(packageJson, null, 2);
|
|
119
|
+
}
|
|
120
|
+
if (pathStr.includes('MyApi.credentials.ts')) {
|
|
121
|
+
return myApiCredential;
|
|
122
|
+
}
|
|
123
|
+
if (pathStr.includes('AnotherApi.credentials.ts')) {
|
|
124
|
+
return anotherApiCredential;
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`File not found: ${pathStr}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setupMockFileSystem();
|
|
131
|
+
|
|
132
|
+
ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
|
133
|
+
valid: [
|
|
134
|
+
{
|
|
135
|
+
name: 'node using allowed credential (object form) from same package',
|
|
136
|
+
filename: nodeFilePath,
|
|
137
|
+
code: createNodeCode([{ name: 'myApiCredential', required: true }]),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'node using allowed credential (string form) from same package',
|
|
141
|
+
filename: nodeFilePath,
|
|
142
|
+
code: createNodeCode(['myApiCredential']),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'node using multiple allowed credentials (mixed forms)',
|
|
146
|
+
filename: nodeFilePath,
|
|
147
|
+
code: createNodeCode(['myApiCredential', { name: 'anotherApiCredential', required: false }]),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'node without credentials array',
|
|
151
|
+
filename: nodeFilePath,
|
|
152
|
+
code: createNodeCode(),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'non-node file ignored',
|
|
156
|
+
filename: '/tmp/regular-file.ts',
|
|
157
|
+
code: createNonNodeClass(),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'non-INodeType class ignored',
|
|
161
|
+
filename: nodeFilePath,
|
|
162
|
+
code: createNonINodeTypeClass(),
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
invalid: [
|
|
166
|
+
{
|
|
167
|
+
name: 'SECURITY: node using credential not in package (object form)',
|
|
168
|
+
filename: nodeFilePath,
|
|
169
|
+
code: createNodeCode([{ name: 'ExternalApi', required: true }]),
|
|
170
|
+
errors: [
|
|
171
|
+
{
|
|
172
|
+
messageId: 'credentialNotInPackage',
|
|
173
|
+
data: { credentialName: 'ExternalApi' },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'SECURITY: node using credential not in package (string form)',
|
|
179
|
+
filename: nodeFilePath,
|
|
180
|
+
code: createNodeCode(['ExternalApi']),
|
|
181
|
+
errors: [
|
|
182
|
+
{
|
|
183
|
+
messageId: 'credentialNotInPackage',
|
|
184
|
+
data: { credentialName: 'ExternalApi' },
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'SECURITY: node using mix of allowed and disallowed credentials (mixed forms)',
|
|
190
|
+
filename: nodeFilePath,
|
|
191
|
+
code: createNodeCode([
|
|
192
|
+
'myApiCredential',
|
|
193
|
+
{ name: 'ExternalApi', required: true },
|
|
194
|
+
'AnotherExternalApi',
|
|
195
|
+
]),
|
|
196
|
+
errors: [
|
|
197
|
+
{
|
|
198
|
+
messageId: 'credentialNotInPackage',
|
|
199
|
+
data: { credentialName: 'ExternalApi' },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
messageId: 'credentialNotInPackage',
|
|
203
|
+
data: { credentialName: 'AnotherExternalApi' },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'node using multiple disallowed credentials',
|
|
209
|
+
filename: nodeFilePath,
|
|
210
|
+
code: createNodeCode([
|
|
211
|
+
{ name: 'ExternalApi1', required: true },
|
|
212
|
+
{ name: 'ExternalApi2', required: false },
|
|
213
|
+
]),
|
|
214
|
+
errors: [
|
|
215
|
+
{
|
|
216
|
+
messageId: 'credentialNotInPackage',
|
|
217
|
+
data: { credentialName: 'ExternalApi1' },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
messageId: 'credentialNotInPackage',
|
|
221
|
+
data: { credentialName: 'ExternalApi2' },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ESLintUtils } from '@typescript-eslint/utils';
|
|
2
|
+
import {
|
|
3
|
+
isNodeTypeClass,
|
|
4
|
+
findClassProperty,
|
|
5
|
+
findArrayLiteralProperty,
|
|
6
|
+
extractCredentialNameFromArray,
|
|
7
|
+
findPackageJson,
|
|
8
|
+
readPackageJsonCredentials,
|
|
9
|
+
isFileType,
|
|
10
|
+
} from '../utils/index.js';
|
|
11
|
+
|
|
12
|
+
export const NoCredentialReuseRule = ESLintUtils.RuleCreator.withoutDocs({
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
'Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package',
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
credentialNotInPackage:
|
|
21
|
+
'SECURITY: Node references credential "{{ credentialName }}" which is not defined in this package. This creates a security risk as it attempts to reuse credentials from other packages. Nodes can only use credentials from the same package as listed in package.json n8n.credentials field.',
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
defaultOptions: [],
|
|
26
|
+
create(context) {
|
|
27
|
+
if (!isFileType(context.filename, '.node.ts')) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let packageCredentials: Set<string> | null = null;
|
|
32
|
+
|
|
33
|
+
const loadPackageCredentials = (): Set<string> => {
|
|
34
|
+
if (packageCredentials !== null) {
|
|
35
|
+
return packageCredentials;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const packageJsonPath = findPackageJson(context.filename);
|
|
39
|
+
if (!packageJsonPath) {
|
|
40
|
+
packageCredentials = new Set();
|
|
41
|
+
return packageCredentials;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
packageCredentials = readPackageJsonCredentials(packageJsonPath);
|
|
45
|
+
return packageCredentials;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
ClassDeclaration(node) {
|
|
50
|
+
if (!isNodeTypeClass(node)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const descriptionProperty = findClassProperty(node, 'description');
|
|
55
|
+
if (!descriptionProperty?.value || descriptionProperty.value.type !== 'ObjectExpression') {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
|
|
60
|
+
if (!credentialsArray) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const allowedCredentials = loadPackageCredentials();
|
|
65
|
+
|
|
66
|
+
credentialsArray.elements.forEach((element) => {
|
|
67
|
+
const credentialInfo = extractCredentialNameFromArray(element);
|
|
68
|
+
if (credentialInfo && !allowedCredentials.has(credentialInfo.name)) {
|
|
69
|
+
context.report({
|
|
70
|
+
node: credentialInfo.node,
|
|
71
|
+
messageId: 'credentialNotInPackage',
|
|
72
|
+
data: {
|
|
73
|
+
credentialName: credentialInfo.name,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
});
|