@n8n/eslint-plugin-community-nodes 0.20.0 → 0.21.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$colon$unchecked.log +1 -1
- package/README.md +9 -1
- package/dist/plugin.d.ts +48 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +16 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/cred-filename-against-convention.d.ts +2 -0
- package/dist/rules/cred-filename-against-convention.d.ts.map +1 -0
- package/dist/rules/cred-filename-against-convention.js +42 -0
- package/dist/rules/cred-filename-against-convention.js.map +1 -0
- package/dist/rules/icon-prefer-themed-variants.d.ts +2 -0
- package/dist/rules/icon-prefer-themed-variants.d.ts.map +1 -0
- package/dist/rules/icon-prefer-themed-variants.js +53 -0
- package/dist/rules/icon-prefer-themed-variants.js.map +1 -0
- package/dist/rules/icon-validation.d.ts +1 -1
- package/dist/rules/icon-validation.d.ts.map +1 -1
- package/dist/rules/icon-validation.js +4 -25
- package/dist/rules/icon-validation.js.map +1 -1
- package/dist/rules/index.d.ts +9 -1
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +16 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/no-dangerous-functions.d.ts +2 -0
- package/dist/rules/no-dangerous-functions.d.ts.map +1 -0
- package/dist/rules/no-dangerous-functions.js +121 -0
- package/dist/rules/no-dangerous-functions.js.map +1 -0
- package/dist/rules/no-emoji-in-options.d.ts +2 -0
- package/dist/rules/no-emoji-in-options.d.ts.map +1 -0
- package/dist/rules/no-emoji-in-options.js +86 -0
- package/dist/rules/no-emoji-in-options.js.map +1 -0
- package/dist/rules/node-filename-against-convention.d.ts +2 -0
- package/dist/rules/node-filename-against-convention.d.ts.map +1 -0
- package/dist/rules/node-filename-against-convention.js +61 -0
- package/dist/rules/node-filename-against-convention.js.map +1 -0
- package/dist/rules/node-registration-complete.d.ts +2 -0
- package/dist/rules/node-registration-complete.d.ts.map +1 -0
- package/dist/rules/node-registration-complete.js +60 -0
- package/dist/rules/node-registration-complete.js.map +1 -0
- package/dist/rules/require-version.d.ts +2 -0
- package/dist/rules/require-version.d.ts.map +1 -0
- package/dist/rules/require-version.js +50 -0
- package/dist/rules/require-version.js.map +1 -0
- package/dist/rules/valid-author.d.ts +2 -0
- package/dist/rules/valid-author.d.ts.map +1 -0
- package/dist/rules/valid-author.js +89 -0
- package/dist/rules/valid-author.js.map +1 -0
- package/dist/typecheck.tsbuildinfo +1 -0
- package/dist/utils/file-utils.d.ts +7 -2
- package/dist/utils/file-utils.d.ts.map +1 -1
- package/dist/utils/file-utils.js +47 -10
- package/dist/utils/file-utils.js.map +1 -1
- package/docs/rules/cred-filename-against-convention.md +42 -0
- package/docs/rules/icon-prefer-themed-variants.md +71 -0
- package/docs/rules/icon-validation.md +5 -3
- package/docs/rules/no-dangerous-functions.md +41 -0
- package/docs/rules/no-emoji-in-options.md +60 -0
- package/docs/rules/node-filename-against-convention.md +50 -0
- package/docs/rules/node-registration-complete.md +46 -0
- package/docs/rules/require-version.md +51 -0
- package/docs/rules/valid-author.md +60 -0
- package/package.json +4 -4
- package/src/plugin.ts +16 -0
- package/src/rules/cred-filename-against-convention.test.ts +72 -0
- package/src/rules/cred-filename-against-convention.ts +48 -0
- package/src/rules/icon-prefer-themed-variants.test.ts +128 -0
- package/src/rules/icon-prefer-themed-variants.ts +70 -0
- package/src/rules/icon-validation.test.ts +10 -0
- package/src/rules/icon-validation.ts +4 -28
- package/src/rules/index.ts +16 -0
- package/src/rules/no-dangerous-functions.test.ts +83 -0
- package/src/rules/no-dangerous-functions.ts +155 -0
- package/src/rules/no-emoji-in-options.test.ts +157 -0
- package/src/rules/no-emoji-in-options.ts +105 -0
- package/src/rules/node-filename-against-convention.test.ts +115 -0
- package/src/rules/node-filename-against-convention.ts +76 -0
- package/src/rules/node-registration-complete.test.ts +87 -0
- package/src/rules/node-registration-complete.ts +79 -0
- package/src/rules/require-version.test.ts +90 -0
- package/src/rules/require-version.ts +62 -0
- package/src/rules/valid-author.test.ts +108 -0
- package/src/rules/valid-author.ts +100 -0
- package/src/utils/file-utils.ts +58 -11
- package/tsconfig.build.tsbuildinfo +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { isCredentialTypeClass, isFileType, createRule } from '../utils/index.js';
|
|
4
|
+
|
|
5
|
+
export const CredFilenameAgainstConventionRule = createRule({
|
|
6
|
+
name: 'cred-filename-against-convention',
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Credential filename must match the credential class name',
|
|
11
|
+
},
|
|
12
|
+
messages: {
|
|
13
|
+
renameFile:
|
|
14
|
+
'Credential filename must match the class name "{{className}}". Rename file to "{{expected}}".',
|
|
15
|
+
},
|
|
16
|
+
schema: [],
|
|
17
|
+
},
|
|
18
|
+
defaultOptions: [],
|
|
19
|
+
create(context) {
|
|
20
|
+
if (!isFileType(context.filename, '.credentials.ts')) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
ClassDeclaration(node) {
|
|
26
|
+
if (!isCredentialTypeClass(node)) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const classNameNode = node.id;
|
|
31
|
+
if (!classNameNode) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const className = classNameNode.name;
|
|
36
|
+
const actualBaseName = path.basename(context.filename, '.credentials.ts');
|
|
37
|
+
|
|
38
|
+
if (actualBaseName !== className) {
|
|
39
|
+
context.report({
|
|
40
|
+
node: classNameNode,
|
|
41
|
+
messageId: 'renameFile',
|
|
42
|
+
data: { className, expected: `${className}.credentials.ts` },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
const nodeFilePath = '/tmp/TestNode.node.ts';
|
|
8
|
+
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
|
|
9
|
+
|
|
10
|
+
function createNodeCode(icon?: string | { light: string; dark: string }): string {
|
|
11
|
+
let iconProperty = '';
|
|
12
|
+
if (icon) {
|
|
13
|
+
if (typeof icon === 'string') {
|
|
14
|
+
iconProperty = `icon: '${icon}',`;
|
|
15
|
+
} else {
|
|
16
|
+
iconProperty = `icon: {
|
|
17
|
+
light: '${icon.light}',
|
|
18
|
+
dark: '${icon.dark}'
|
|
19
|
+
},`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `
|
|
24
|
+
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
25
|
+
|
|
26
|
+
export class TestNode implements INodeType {
|
|
27
|
+
description: INodeTypeDescription = {
|
|
28
|
+
displayName: 'Test Node',
|
|
29
|
+
name: 'testNode',
|
|
30
|
+
${iconProperty}
|
|
31
|
+
group: ['input'],
|
|
32
|
+
version: 1,
|
|
33
|
+
description: 'A test node',
|
|
34
|
+
defaults: {
|
|
35
|
+
name: 'Test Node',
|
|
36
|
+
},
|
|
37
|
+
inputs: ['main'],
|
|
38
|
+
outputs: ['main'],
|
|
39
|
+
properties: [],
|
|
40
|
+
};
|
|
41
|
+
}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
|
|
45
|
+
let iconProperty = '';
|
|
46
|
+
if (icon) {
|
|
47
|
+
if (typeof icon === 'string') {
|
|
48
|
+
iconProperty = `icon = '${icon}';`;
|
|
49
|
+
} else {
|
|
50
|
+
iconProperty = `icon = {
|
|
51
|
+
light: '${icon.light}',
|
|
52
|
+
dark: '${icon.dark}'
|
|
53
|
+
};`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `
|
|
58
|
+
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
|
59
|
+
|
|
60
|
+
export class TestCredential implements ICredentialType {
|
|
61
|
+
name = 'testApi';
|
|
62
|
+
displayName = 'Test API';
|
|
63
|
+
${iconProperty}
|
|
64
|
+
properties: INodeProperties[] = [];
|
|
65
|
+
}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createNonNodeClass(icon: string): string {
|
|
69
|
+
return `
|
|
70
|
+
export class NotANode {
|
|
71
|
+
icon = '${icon}';
|
|
72
|
+
}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ruleTester.run('icon-prefer-themed-variants', IconPreferThemedVariantsRule, {
|
|
76
|
+
valid: [
|
|
77
|
+
{
|
|
78
|
+
name: 'non-node class ignored',
|
|
79
|
+
filename: nodeFilePath,
|
|
80
|
+
code: createNonNodeClass('file:icon.svg'),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'non-node file ignored',
|
|
84
|
+
filename: '/tmp/regular-file.ts',
|
|
85
|
+
code: createNodeCode('file:icon.svg'),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'node with no icon property ignored',
|
|
89
|
+
filename: nodeFilePath,
|
|
90
|
+
code: createNodeCode(),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'node with themed light/dark icons',
|
|
94
|
+
filename: nodeFilePath,
|
|
95
|
+
code: createNodeCode({
|
|
96
|
+
light: 'file:icons/icon.light.svg',
|
|
97
|
+
dark: 'file:icons/icon.dark.svg',
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'credential with no icon property ignored',
|
|
102
|
+
filename: credentialFilePath,
|
|
103
|
+
code: createCredentialCode(),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'credential with themed light/dark icons',
|
|
107
|
+
filename: credentialFilePath,
|
|
108
|
+
code: createCredentialCode({
|
|
109
|
+
light: 'file:icons/icon.light.svg',
|
|
110
|
+
dark: 'file:icons/icon.dark.svg',
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
invalid: [
|
|
115
|
+
{
|
|
116
|
+
name: 'node with single string icon',
|
|
117
|
+
filename: nodeFilePath,
|
|
118
|
+
code: createNodeCode('file:icons/icon.svg'),
|
|
119
|
+
errors: [{ messageId: 'missingThemedVariants' }],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'credential with single string icon',
|
|
123
|
+
filename: credentialFilePath,
|
|
124
|
+
code: createCredentialCode('file:icons/icon.svg'),
|
|
125
|
+
errors: [{ messageId: 'missingThemedVariants' }],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isNodeTypeClass,
|
|
5
|
+
isCredentialTypeClass,
|
|
6
|
+
findClassProperty,
|
|
7
|
+
findObjectProperty,
|
|
8
|
+
isFileType,
|
|
9
|
+
createRule,
|
|
10
|
+
} from '../utils/index.js';
|
|
11
|
+
|
|
12
|
+
const messages = {
|
|
13
|
+
missingThemedVariants:
|
|
14
|
+
'Icon is defined as a single file. Provide both light and dark variants using the `{ light, dark }` form so the icon renders well on both themes.',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export const IconPreferThemedVariantsRule = createRule({
|
|
18
|
+
name: 'icon-prefer-themed-variants',
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'suggestion',
|
|
21
|
+
docs: {
|
|
22
|
+
description:
|
|
23
|
+
'Encourage node and credential icons to provide light/dark variants instead of a single icon file',
|
|
24
|
+
},
|
|
25
|
+
messages,
|
|
26
|
+
schema: [],
|
|
27
|
+
},
|
|
28
|
+
defaultOptions: [],
|
|
29
|
+
create(context) {
|
|
30
|
+
if (
|
|
31
|
+
!isFileType(context.filename, '.node.ts') &&
|
|
32
|
+
!isFileType(context.filename, '.credentials.ts')
|
|
33
|
+
) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const checkIconValue = (iconValue: TSESTree.Node) => {
|
|
38
|
+
if (
|
|
39
|
+
iconValue.type === TSESTree.AST_NODE_TYPES.Literal &&
|
|
40
|
+
typeof iconValue.value === 'string'
|
|
41
|
+
) {
|
|
42
|
+
context.report({
|
|
43
|
+
node: iconValue,
|
|
44
|
+
messageId: 'missingThemedVariants',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ClassDeclaration(node) {
|
|
51
|
+
if (isNodeTypeClass(node)) {
|
|
52
|
+
const descriptionProperty = findClassProperty(node, 'description');
|
|
53
|
+
if (descriptionProperty?.value?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const iconProperty = findObjectProperty(descriptionProperty.value, 'icon');
|
|
58
|
+
if (iconProperty) {
|
|
59
|
+
checkIconValue(iconProperty.value);
|
|
60
|
+
}
|
|
61
|
+
} else if (isCredentialTypeClass(node)) {
|
|
62
|
+
const iconProperty = findClassProperty(node, 'icon');
|
|
63
|
+
if (iconProperty?.value) {
|
|
64
|
+
checkIconValue(iconProperty.value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -146,6 +146,11 @@ ruleTester.run('icon-validation', IconValidationRule, {
|
|
|
146
146
|
filename: nodeFilePath,
|
|
147
147
|
code: createNodeCode('file:icons/TestNode.svg', true),
|
|
148
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: 'node with valid PNG string icon in description',
|
|
151
|
+
filename: nodeFilePath,
|
|
152
|
+
code: createNodeCode('file:icons/NotSvg.png', true),
|
|
153
|
+
},
|
|
149
154
|
{
|
|
150
155
|
name: 'node with valid light/dark icons in description',
|
|
151
156
|
filename: nodeFilePath,
|
|
@@ -162,6 +167,11 @@ ruleTester.run('icon-validation', IconValidationRule, {
|
|
|
162
167
|
filename: credentialFilePath,
|
|
163
168
|
code: createCredentialCode('file:icons/TestNode.svg'),
|
|
164
169
|
},
|
|
170
|
+
{
|
|
171
|
+
name: 'credential with valid PNG string icon',
|
|
172
|
+
filename: credentialFilePath,
|
|
173
|
+
code: createCredentialCode('file:icons/NotSvg.png'),
|
|
174
|
+
},
|
|
165
175
|
{
|
|
166
176
|
name: 'credential with valid light/dark icons',
|
|
167
177
|
filename: credentialFilePath,
|
|
@@ -9,20 +9,18 @@ import {
|
|
|
9
9
|
findObjectProperty,
|
|
10
10
|
getStringLiteralValue,
|
|
11
11
|
validateIconPath,
|
|
12
|
-
|
|
12
|
+
findSimilarIconFiles,
|
|
13
13
|
isFileType,
|
|
14
14
|
createRule,
|
|
15
15
|
} from '../utils/index.js';
|
|
16
16
|
|
|
17
17
|
const messages = {
|
|
18
18
|
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
|
|
19
|
-
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
|
|
20
19
|
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
|
|
21
20
|
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
|
|
22
21
|
missingIcon: 'Node/Credential class must have an icon property defined',
|
|
23
22
|
addPlaceholder: 'Add icon property with placeholder',
|
|
24
23
|
addFileProtocol: "Add 'file:' protocol to icon path",
|
|
25
|
-
changeExtension: "Change icon extension to '.svg'",
|
|
26
24
|
similarIcon: "Use existing icon '{{ suggestedName }}'",
|
|
27
25
|
} as const;
|
|
28
26
|
|
|
@@ -32,7 +30,7 @@ export const IconValidationRule = createRule({
|
|
|
32
30
|
type: 'problem',
|
|
33
31
|
docs: {
|
|
34
32
|
description:
|
|
35
|
-
'Validate node and credential icon files exist,
|
|
33
|
+
'Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different',
|
|
36
34
|
},
|
|
37
35
|
messages,
|
|
38
36
|
schema: [],
|
|
@@ -80,34 +78,12 @@ export const IconValidationRule = createRule({
|
|
|
80
78
|
return false;
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
if (!validation.isSvg) {
|
|
84
|
-
const relativePath = iconPath.replace(/^file:/, '');
|
|
85
|
-
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
|
86
|
-
|
|
87
|
-
const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
|
|
88
|
-
const svgPath = `${pathWithoutExt}.svg`;
|
|
89
|
-
suggestions.push({
|
|
90
|
-
messageId: 'changeExtension',
|
|
91
|
-
fix(fixer) {
|
|
92
|
-
return fixer.replaceText(node, `"file:${svgPath}"`);
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
context.report({
|
|
97
|
-
node,
|
|
98
|
-
messageId: 'iconNotSvg',
|
|
99
|
-
data: { iconPath: relativePath },
|
|
100
|
-
suggest: suggestions,
|
|
101
|
-
});
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
81
|
if (!validation.exists) {
|
|
106
82
|
const relativePath = iconPath.replace(/^file:/, '');
|
|
107
83
|
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
|
108
84
|
|
|
109
|
-
// Find similar
|
|
110
|
-
const similarFiles =
|
|
85
|
+
// Find similar icon files in the same directory
|
|
86
|
+
const similarFiles = findSimilarIconFiles(relativePath, currentDir);
|
|
111
87
|
for (const similarFile of similarFiles) {
|
|
112
88
|
suggestions.push({
|
|
113
89
|
messageId: 'similarIcon',
|
package/src/rules/index.ts
CHANGED
|
@@ -5,15 +5,19 @@ import { CredClassFieldIconMissingRule } from './cred-class-field-icon-missing.j
|
|
|
5
5
|
import { CredClassNameFieldConventionsRule } from './cred-class-name-field-conventions.js';
|
|
6
6
|
import { CredClassNameSuffixRule } from './cred-class-name-suffix.js';
|
|
7
7
|
import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
|
|
8
|
+
import { CredFilenameAgainstConventionRule } from './cred-filename-against-convention.js';
|
|
8
9
|
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
|
9
10
|
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
|
10
11
|
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
|
12
|
+
import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
|
|
11
13
|
import { IconValidationRule } from './icon-validation.js';
|
|
12
14
|
import { MissingPairedItemRule } from './missing-paired-item.js';
|
|
13
15
|
import { N8nObjectValidationRule } from './n8n-object-validation.js';
|
|
14
16
|
import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js';
|
|
15
17
|
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
|
18
|
+
import { NoDangerousFunctionsRule } from './no-dangerous-functions.js';
|
|
16
19
|
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
|
20
|
+
import { NoEmojiInOptionsRule } from './no-emoji-in-options.js';
|
|
17
21
|
import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js';
|
|
18
22
|
import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js';
|
|
19
23
|
import { NoOverridesFieldRule } from './no-overrides-field.js';
|
|
@@ -23,7 +27,9 @@ import { NoRuntimeDependenciesRule } from './no-runtime-dependencies.js';
|
|
|
23
27
|
import { NoTemplatePlaceholdersRule } from './no-template-placeholders.js';
|
|
24
28
|
import { NodeClassDescriptionIconMissingRule } from './node-class-description-icon-missing.js';
|
|
25
29
|
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
|
|
30
|
+
import { NodeFilenameAgainstConventionRule } from './node-filename-against-convention.js';
|
|
26
31
|
import { NodeOperationErrorItemIndexRule } from './node-operation-error-itemindex.js';
|
|
32
|
+
import { NodeRegistrationCompleteRule } from './node-registration-complete.js';
|
|
27
33
|
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
|
28
34
|
import { OptionsSortedAlphabeticallyRule } from './options-sorted-alphabetically.js';
|
|
29
35
|
import { PackageNameConventionRule } from './package-name-convention.js';
|
|
@@ -31,7 +37,9 @@ import { RequireCommunityNodeKeywordRule } from './require-community-node-keywor
|
|
|
31
37
|
import { RequireContinueOnFailRule } from './require-continue-on-fail.js';
|
|
32
38
|
import { RequireNodeApiErrorRule } from './require-node-api-error.js';
|
|
33
39
|
import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js';
|
|
40
|
+
import { RequireVersionRule } from './require-version.js';
|
|
34
41
|
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
|
42
|
+
import { ValidAuthorRule } from './valid-author.js';
|
|
35
43
|
import { ValidCredentialReferencesRule } from './valid-credential-references.js';
|
|
36
44
|
import { ValidDescriptionRule } from './valid-description.js';
|
|
37
45
|
import { ValidPeerDependenciesRule } from './valid-peer-dependencies.js';
|
|
@@ -43,17 +51,20 @@ export const rules = {
|
|
|
43
51
|
'no-restricted-imports': NoRestrictedImportsRule,
|
|
44
52
|
'credential-password-field': CredentialPasswordFieldRule,
|
|
45
53
|
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
|
|
54
|
+
'no-emoji-in-options': NoEmojiInOptionsRule,
|
|
46
55
|
'node-usable-as-tool': NodeUsableAsToolRule,
|
|
47
56
|
'options-sorted-alphabetically': OptionsSortedAlphabeticallyRule,
|
|
48
57
|
'package-name-convention': PackageNameConventionRule,
|
|
49
58
|
'credential-test-required': CredentialTestRequiredRule,
|
|
50
59
|
'no-credential-reuse': NoCredentialReuseRule,
|
|
60
|
+
'no-dangerous-functions': NoDangerousFunctionsRule,
|
|
51
61
|
'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule,
|
|
52
62
|
'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule,
|
|
53
63
|
'no-overrides-field': NoOverridesFieldRule,
|
|
54
64
|
'no-runtime-dependencies': NoRuntimeDependenciesRule,
|
|
55
65
|
'no-template-placeholders': NoTemplatePlaceholdersRule,
|
|
56
66
|
'icon-validation': IconValidationRule,
|
|
67
|
+
'icon-prefer-themed-variants': IconPreferThemedVariantsRule,
|
|
57
68
|
'resource-operation-pattern': ResourceOperationPatternRule,
|
|
58
69
|
'credential-documentation-url': CredentialDocumentationUrlRule,
|
|
59
70
|
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
|
|
@@ -61,8 +72,11 @@ export const rules = {
|
|
|
61
72
|
'cred-class-name-field-conventions': CredClassNameFieldConventionsRule,
|
|
62
73
|
'cred-class-name-suffix': CredClassNameSuffixRule,
|
|
63
74
|
'cred-class-oauth2-naming': CredClassOAuth2NamingRule,
|
|
75
|
+
'cred-filename-against-convention': CredFilenameAgainstConventionRule,
|
|
64
76
|
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
|
|
77
|
+
'node-filename-against-convention': NodeFilenameAgainstConventionRule,
|
|
65
78
|
'node-operation-error-itemindex': NodeOperationErrorItemIndexRule,
|
|
79
|
+
'node-registration-complete': NodeRegistrationCompleteRule,
|
|
66
80
|
'missing-paired-item': MissingPairedItemRule,
|
|
67
81
|
'no-builder-hint-leakage': NoBuilderHintLeakageRule,
|
|
68
82
|
'n8n-object-validation': N8nObjectValidationRule,
|
|
@@ -70,6 +84,8 @@ export const rules = {
|
|
|
70
84
|
'require-continue-on-fail': RequireContinueOnFailRule,
|
|
71
85
|
'require-node-api-error': RequireNodeApiErrorRule,
|
|
72
86
|
'require-node-description-fields': RequireNodeDescriptionFieldsRule,
|
|
87
|
+
'require-version': RequireVersionRule,
|
|
88
|
+
'valid-author': ValidAuthorRule,
|
|
73
89
|
'valid-credential-references': ValidCredentialReferencesRule,
|
|
74
90
|
'valid-description': ValidDescriptionRule,
|
|
75
91
|
'valid-peer-dependencies': ValidPeerDependenciesRule,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { NoDangerousFunctionsRule } from './no-dangerous-functions.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('no-dangerous-functions', NoDangerousFunctionsRule, {
|
|
8
|
+
valid: [
|
|
9
|
+
// `exec`/`spawn` not originating from `child_process` must not be flagged.
|
|
10
|
+
{ name: 'regex exec', code: 'const match = /foo/.exec(input);' },
|
|
11
|
+
{ name: 'regex exec via variable', code: 'regex.exec(input);' },
|
|
12
|
+
{ name: 'unrelated exec member', code: 'db.exec("SELECT 1");' },
|
|
13
|
+
{ name: 'unrelated spawn member', code: 'queue.spawn(job);' },
|
|
14
|
+
{ name: 'locally declared exec', code: 'function exec() {} exec();' },
|
|
15
|
+
// Importing without calling is fine.
|
|
16
|
+
{ name: 'import without call', code: "import { exec } from 'child_process';" },
|
|
17
|
+
// Non-dangerous members of the namespace import are fine.
|
|
18
|
+
{
|
|
19
|
+
name: 'non-dangerous namespace member',
|
|
20
|
+
code: "import * as cp from 'child_process'; const p = cp.execPath;",
|
|
21
|
+
},
|
|
22
|
+
// `eval`/`Function` as identifier references (not calls) are fine.
|
|
23
|
+
{ name: 'eval reference only', code: 'const f = eval;' },
|
|
24
|
+
{ name: 'Function reference only', code: 'const F = Function;' },
|
|
25
|
+
// Non-child_process module is irrelevant.
|
|
26
|
+
{
|
|
27
|
+
name: 'spawn from unrelated module',
|
|
28
|
+
code: "import { spawn } from 'some-lib'; spawn('x');",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
invalid: [
|
|
32
|
+
{
|
|
33
|
+
name: 'SECURITY: eval call',
|
|
34
|
+
code: "eval('1 + 1');",
|
|
35
|
+
errors: [{ messageId: 'noEval' }],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'SECURITY: Function constructor with new',
|
|
39
|
+
code: "const fn = new Function('return process');",
|
|
40
|
+
errors: [{ messageId: 'noFunctionConstructor' }],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'SECURITY: Function constructor without new',
|
|
44
|
+
code: "const fn = Function('return 1');",
|
|
45
|
+
errors: [{ messageId: 'noFunctionConstructor' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'SECURITY: exec from child_process',
|
|
49
|
+
code: "import { exec } from 'child_process'; exec('ls');",
|
|
50
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'exec' } }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'SECURITY: aliased exec from node:child_process',
|
|
54
|
+
code: "import { exec as run } from 'node:child_process'; run('ls');",
|
|
55
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'exec' } }],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'SECURITY: spawn from child_process',
|
|
59
|
+
code: "import { spawn } from 'child_process'; spawn('ls', ['-la']);",
|
|
60
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'spawn' } }],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'SECURITY: namespace execSync',
|
|
64
|
+
code: "import * as cp from 'child_process'; cp.execSync('ls');",
|
|
65
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'execSync' } }],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'SECURITY: default import spawnSync',
|
|
69
|
+
code: "import childProcess from 'node:child_process'; childProcess.spawnSync('ls');",
|
|
70
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'spawnSync' } }],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'SECURITY: destructured require execFile',
|
|
74
|
+
code: "const { execFile } = require('child_process'); execFile('ls');",
|
|
75
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'execFile' } }],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'SECURITY: namespace require fork',
|
|
79
|
+
code: "const cp = require('node:child_process'); cp.fork('./worker.js');",
|
|
80
|
+
errors: [{ messageId: 'noChildProcess', data: { name: 'fork' } }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createRule,
|
|
5
|
+
getModulePath,
|
|
6
|
+
isDirectRequireCall,
|
|
7
|
+
isRequireMemberCall,
|
|
8
|
+
} from '../utils/index.js';
|
|
9
|
+
|
|
10
|
+
const { AST_NODE_TYPES } = TSESTree;
|
|
11
|
+
|
|
12
|
+
const CHILD_PROCESS_MODULES = new Set(['child_process', 'node:child_process']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `child_process` functions that spawn OS processes and are therefore
|
|
16
|
+
* vulnerable to command injection when fed untrusted input.
|
|
17
|
+
*/
|
|
18
|
+
const DANGEROUS_CHILD_PROCESS_FUNCTIONS = new Set([
|
|
19
|
+
'exec',
|
|
20
|
+
'execSync',
|
|
21
|
+
'execFile',
|
|
22
|
+
'execFileSync',
|
|
23
|
+
'spawn',
|
|
24
|
+
'spawnSync',
|
|
25
|
+
'fork',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const isChildProcessModule = (node: TSESTree.Node | null): boolean => {
|
|
29
|
+
const modulePath = getModulePath(node);
|
|
30
|
+
return modulePath !== null && CHILD_PROCESS_MODULES.has(modulePath);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const NoDangerousFunctionsRule = createRule({
|
|
34
|
+
name: 'no-dangerous-functions',
|
|
35
|
+
meta: {
|
|
36
|
+
type: 'problem',
|
|
37
|
+
docs: {
|
|
38
|
+
description:
|
|
39
|
+
'Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes.',
|
|
40
|
+
},
|
|
41
|
+
messages: {
|
|
42
|
+
noEval:
|
|
43
|
+
'Use of `eval` is not allowed. It executes arbitrary code and is a common source of remote code execution vulnerabilities.',
|
|
44
|
+
noFunctionConstructor:
|
|
45
|
+
'Use of the `Function` constructor is not allowed. Like `eval`, it executes arbitrary code from strings.',
|
|
46
|
+
noChildProcess:
|
|
47
|
+
'Use of `{{ name }}` from `child_process` is not allowed. Spawning OS processes is not permitted in community nodes and can lead to command injection.',
|
|
48
|
+
},
|
|
49
|
+
schema: [],
|
|
50
|
+
},
|
|
51
|
+
defaultOptions: [],
|
|
52
|
+
create(context) {
|
|
53
|
+
// Local names bound to dangerous named imports, e.g. `import { exec as run }` -> `run`.
|
|
54
|
+
const dangerousLocalNames = new Map<string, string>();
|
|
55
|
+
// Local names bound to the whole module, e.g. `import * as cp` or `const cp = require(...)`.
|
|
56
|
+
const namespaceNames = new Set<string>();
|
|
57
|
+
|
|
58
|
+
const recordDestructuredModule = (pattern: TSESTree.ObjectPattern) => {
|
|
59
|
+
for (const property of pattern.properties) {
|
|
60
|
+
if (
|
|
61
|
+
property.type !== AST_NODE_TYPES.Property ||
|
|
62
|
+
property.key.type !== AST_NODE_TYPES.Identifier ||
|
|
63
|
+
!DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(property.key.name)
|
|
64
|
+
) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (property.value.type === AST_NODE_TYPES.Identifier) {
|
|
69
|
+
dangerousLocalNames.set(property.value.name, property.key.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ImportDeclaration(node) {
|
|
76
|
+
if (!CHILD_PROCESS_MODULES.has(node.source.value)) return;
|
|
77
|
+
|
|
78
|
+
for (const specifier of node.specifiers) {
|
|
79
|
+
if (
|
|
80
|
+
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
|
|
81
|
+
specifier.imported.type === AST_NODE_TYPES.Identifier &&
|
|
82
|
+
DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(specifier.imported.name)
|
|
83
|
+
) {
|
|
84
|
+
dangerousLocalNames.set(specifier.local.name, specifier.imported.name);
|
|
85
|
+
} else if (
|
|
86
|
+
specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier ||
|
|
87
|
+
specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier
|
|
88
|
+
) {
|
|
89
|
+
namespaceNames.add(specifier.local.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
VariableDeclarator(node) {
|
|
95
|
+
if (
|
|
96
|
+
node.init?.type !== AST_NODE_TYPES.CallExpression ||
|
|
97
|
+
!(isDirectRequireCall(node.init) || isRequireMemberCall(node.init)) ||
|
|
98
|
+
!isChildProcessModule(node.init.arguments[0] ?? null)
|
|
99
|
+
) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (node.id.type === AST_NODE_TYPES.ObjectPattern) {
|
|
104
|
+
recordDestructuredModule(node.id);
|
|
105
|
+
} else if (node.id.type === AST_NODE_TYPES.Identifier) {
|
|
106
|
+
namespaceNames.add(node.id.name);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
NewExpression(node) {
|
|
111
|
+
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === 'Function') {
|
|
112
|
+
context.report({ node, messageId: 'noFunctionConstructor' });
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
CallExpression(node) {
|
|
117
|
+
const { callee } = node;
|
|
118
|
+
|
|
119
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
120
|
+
if (callee.name === 'eval') {
|
|
121
|
+
context.report({ node, messageId: 'noEval' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (callee.name === 'Function') {
|
|
126
|
+
context.report({ node, messageId: 'noFunctionConstructor' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const originalName = dangerousLocalNames.get(callee.name);
|
|
131
|
+
if (originalName) {
|
|
132
|
+
context.report({ node, messageId: 'noChildProcess', data: { name: originalName } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
140
|
+
!callee.computed &&
|
|
141
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
142
|
+
namespaceNames.has(callee.object.name) &&
|
|
143
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
144
|
+
DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(callee.property.name)
|
|
145
|
+
) {
|
|
146
|
+
context.report({
|
|
147
|
+
node,
|
|
148
|
+
messageId: 'noChildProcess',
|
|
149
|
+
data: { name: callee.property.name },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
});
|