@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.
- package/.turbo/turbo-build.log +1 -1
- package/README.md +7 -1
- package/dist/plugin.d.ts +36 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +12 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/credential-password-field.d.ts.map +1 -1
- package/dist/rules/credential-password-field.js +3 -31
- package/dist/rules/credential-password-field.js.map +1 -1
- package/dist/rules/credential-unnecessary-password.d.ts +2 -0
- package/dist/rules/credential-unnecessary-password.d.ts.map +1 -0
- package/dist/rules/credential-unnecessary-password.js +54 -0
- package/dist/rules/credential-unnecessary-password.js.map +1 -0
- package/dist/rules/index.d.ts +6 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +12 -0
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/no-dead-files.d.ts +7 -0
- package/dist/rules/no-dead-files.d.ts.map +1 -0
- package/dist/rules/no-dead-files.js +57 -0
- package/dist/rules/no-dead-files.js.map +1 -0
- package/dist/rules/no-hardcoded-secrets.d.ts +2 -0
- package/dist/rules/no-hardcoded-secrets.d.ts.map +1 -0
- package/dist/rules/no-hardcoded-secrets.js +147 -0
- package/dist/rules/no-hardcoded-secrets.js.map +1 -0
- package/dist/rules/no-silent-error-swallowing.d.ts +2 -0
- package/dist/rules/no-silent-error-swallowing.d.ts.map +1 -0
- package/dist/rules/no-silent-error-swallowing.js +112 -0
- package/dist/rules/no-silent-error-swallowing.js.map +1 -0
- package/dist/rules/require-files-array.d.ts +2 -0
- package/dist/rules/require-files-array.d.ts.map +1 -0
- package/dist/rules/require-files-array.js +41 -0
- package/dist/rules/require-files-array.js.map +1 -0
- package/dist/rules/require-homepage.d.ts +2 -0
- package/dist/rules/require-homepage.d.ts.map +1 -0
- package/dist/rules/require-homepage.js +52 -0
- package/dist/rules/require-homepage.js.map +1 -0
- package/dist/rules/webhook-lifecycle-complete.d.ts.map +1 -1
- package/dist/rules/webhook-lifecycle-complete.js +2 -3
- package/dist/rules/webhook-lifecycle-complete.js.map +1 -1
- package/dist/typecheck.tsbuildinfo +1 -1
- package/dist/utils/constants.d.ts +5 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +15 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/credential-fields.d.ts +20 -0
- package/dist/utils/credential-fields.d.ts.map +1 -0
- package/dist/utils/credential-fields.js +45 -0
- package/dist/utils/credential-fields.js.map +1 -0
- package/dist/utils/file-utils.d.ts +6 -0
- package/dist/utils/file-utils.d.ts.map +1 -1
- package/dist/utils/file-utils.js +16 -0
- package/dist/utils/file-utils.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/docs/rules/credential-password-field.md +3 -1
- package/docs/rules/credential-unnecessary-password.md +46 -0
- package/docs/rules/no-dead-files.md +56 -0
- package/docs/rules/no-hardcoded-secrets.md +59 -0
- package/docs/rules/no-silent-error-swallowing.md +72 -0
- package/docs/rules/require-files-array.md +43 -0
- package/docs/rules/require-homepage.md +49 -0
- package/package.json +2 -2
- package/src/plugin.ts +12 -0
- package/src/rules/credential-password-field.ts +4 -40
- package/src/rules/credential-unnecessary-password.test.ts +105 -0
- package/src/rules/credential-unnecessary-password.ts +72 -0
- package/src/rules/index.ts +12 -0
- package/src/rules/no-dead-files.test.ts +128 -0
- package/src/rules/no-dead-files.ts +71 -0
- package/src/rules/no-hardcoded-secrets.test.ts +124 -0
- package/src/rules/no-hardcoded-secrets.ts +161 -0
- package/src/rules/no-silent-error-swallowing.test.ts +245 -0
- package/src/rules/no-silent-error-swallowing.ts +127 -0
- package/src/rules/require-files-array.test.ts +57 -0
- package/src/rules/require-files-array.ts +50 -0
- package/src/rules/require-homepage.test.ts +80 -0
- package/src/rules/require-homepage.ts +59 -0
- package/src/rules/webhook-lifecycle-complete.ts +4 -5
- package/src/utils/constants.ts +21 -0
- package/src/utils/credential-fields.ts +57 -0
- package/src/utils/file-utils.ts +23 -0
- package/src/utils/index.ts +2 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
import { createRule, getStringLiteralValue, isSensitiveName } from '../utils/index.js';
|
|
4
|
+
|
|
5
|
+
const { AST_NODE_TYPES } = TSESTree;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extra name fragments (beyond the shared sensitive set in `credential-fields`)
|
|
9
|
+
* that suggest a JS identifier holds a credential. Bare `key`/`auth` are broad,
|
|
10
|
+
* but the value heuristic below narrows the match, and they catch separator
|
|
11
|
+
* forms such as `api_key` that the compound fragments miss.
|
|
12
|
+
*/
|
|
13
|
+
const EXTRA_SECRET_NAME_FRAGMENTS = ['key', 'passwd', 'auth'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fragments that mark a value as an obvious placeholder rather than a real
|
|
17
|
+
* secret. These are skipped to keep the (intentionally heuristic) rule quieter.
|
|
18
|
+
*/
|
|
19
|
+
const PLACEHOLDER_FRAGMENTS = [
|
|
20
|
+
'example',
|
|
21
|
+
'placeholder',
|
|
22
|
+
'changeme',
|
|
23
|
+
'your',
|
|
24
|
+
'dummy',
|
|
25
|
+
'xxxx',
|
|
26
|
+
'<',
|
|
27
|
+
'{{',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const MIN_SECRET_LENGTH = 16;
|
|
31
|
+
|
|
32
|
+
function nameLooksLikeSecret(name: string): boolean {
|
|
33
|
+
// `exclusions: []` disables the URL/ID/pub name exclusions on purpose: the
|
|
34
|
+
// value heuristic below is the real gate, so a suspicious value on e.g.
|
|
35
|
+
// `publicKeyUrl` should still be flagged rather than silently skipped. Erring
|
|
36
|
+
// toward a false positive is safer than missing a secret.
|
|
37
|
+
return isSensitiveName(name, { extra: EXTRA_SECRET_NAME_FRAGMENTS, exclusions: [] });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function looksLikePlaceholder(value: string): boolean {
|
|
41
|
+
const lower = value.toLowerCase();
|
|
42
|
+
return PLACEHOLDER_FRAGMENTS.some((fragment) => lower.includes(fragment));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Heuristic: does this string value look like a real credential? Real secrets
|
|
47
|
+
* are long, contiguous tokens with the shape of a hex digest or a base64/token
|
|
48
|
+
* string. The digit+letter/punctuation requirement keeps ordinary long
|
|
49
|
+
* camelCase words from being treated as secrets.
|
|
50
|
+
*/
|
|
51
|
+
function looksLikeSecretValue(value: string): boolean {
|
|
52
|
+
// Require at least MIN_SECRET_LENGTH characters; anything shorter is almost
|
|
53
|
+
// always a non-secret word or identifier rather than a generated credential.
|
|
54
|
+
if (value.length < MIN_SECRET_LENGTH) return false;
|
|
55
|
+
if (/\s/.test(value)) return false;
|
|
56
|
+
if (looksLikePlaceholder(value)) return false;
|
|
57
|
+
|
|
58
|
+
// A long, purely hexadecimal string (e.g. an MD5/SHA/hex API key).
|
|
59
|
+
if (/^[0-9a-f]+$/i.test(value)) return true;
|
|
60
|
+
|
|
61
|
+
// A base64 / base64url / token-shaped string.
|
|
62
|
+
if (/^[A-Za-z0-9+/=_.-]+$/.test(value)) {
|
|
63
|
+
const hasDigit = /[0-9]/.test(value);
|
|
64
|
+
const hasLetter = /[a-z]/i.test(value);
|
|
65
|
+
// Only `+ / =` count as a standalone signal: they are hallmarks of base64
|
|
66
|
+
// but rarely appear in ordinary code strings. `. - _` are excluded here
|
|
67
|
+
// because dotted/hyphenated/snake_case constants (scopes, paths, ids) use
|
|
68
|
+
// them heavily and would otherwise be misflagged.
|
|
69
|
+
const hasStrongBase64Punctuation = /[+/=]/.test(value);
|
|
70
|
+
return (hasDigit && hasLetter) || hasStrongBase64Punctuation;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getStaticStringValue(node: TSESTree.Node | null | undefined): string | null {
|
|
77
|
+
if (!node) return null;
|
|
78
|
+
|
|
79
|
+
const literal = getStringLiteralValue(node);
|
|
80
|
+
if (literal !== null) return literal;
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
node.type === AST_NODE_TYPES.TemplateLiteral &&
|
|
84
|
+
node.expressions.length === 0 &&
|
|
85
|
+
node.quasis.length === 1
|
|
86
|
+
) {
|
|
87
|
+
return node.quasis[0]?.value.cooked ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getKeyName(key: TSESTree.Node): string | null {
|
|
94
|
+
if (key.type === AST_NODE_TYPES.Identifier) return key.name;
|
|
95
|
+
if (key.type === AST_NODE_TYPES.Literal && typeof key.value === 'string') return key.value;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const NoHardcodedSecretsRule = createRule({
|
|
100
|
+
name: 'no-hardcoded-secrets',
|
|
101
|
+
meta: {
|
|
102
|
+
type: 'problem',
|
|
103
|
+
docs: {
|
|
104
|
+
description:
|
|
105
|
+
'Disallow hardcoded secrets (API keys, tokens, passwords) embedded as string literals in source.',
|
|
106
|
+
},
|
|
107
|
+
messages: {
|
|
108
|
+
hardcodedSecret:
|
|
109
|
+
'Possible hardcoded secret assigned to `{{ name }}`. Store secrets in credentials or environment variables instead of embedding them in source. If this is not a real secret, rename the variable or move the value out of source to silence this rule.',
|
|
110
|
+
},
|
|
111
|
+
schema: [],
|
|
112
|
+
},
|
|
113
|
+
defaultOptions: [],
|
|
114
|
+
create(context) {
|
|
115
|
+
const check = (name: string | null, valueNode: TSESTree.Node | null | undefined) => {
|
|
116
|
+
if (!name || !valueNode || !nameLooksLikeSecret(name)) return;
|
|
117
|
+
|
|
118
|
+
const value = getStaticStringValue(valueNode);
|
|
119
|
+
if (value === null || !looksLikeSecretValue(value)) return;
|
|
120
|
+
|
|
121
|
+
context.report({
|
|
122
|
+
node: valueNode,
|
|
123
|
+
messageId: 'hardcodedSecret',
|
|
124
|
+
data: { name },
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
// const apiKey = '...'
|
|
130
|
+
VariableDeclarator(node) {
|
|
131
|
+
if (node.id.type !== AST_NODE_TYPES.Identifier) return;
|
|
132
|
+
check(node.id.name, node.init);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// class field: private token = '...'
|
|
136
|
+
PropertyDefinition(node) {
|
|
137
|
+
if (node.computed) return;
|
|
138
|
+
check(getKeyName(node.key), node.value);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// object literal: { apiKey: '...' } or { 'api_key': '...' }
|
|
142
|
+
Property(node) {
|
|
143
|
+
if (node.computed) return;
|
|
144
|
+
check(getKeyName(node.key), node.value);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// this.secret = '...' / config.token = '...' / secret = '...'
|
|
148
|
+
AssignmentExpression(node) {
|
|
149
|
+
if (node.left.type === AST_NODE_TYPES.Identifier) {
|
|
150
|
+
check(node.left.name, node.right);
|
|
151
|
+
} else if (
|
|
152
|
+
node.left.type === AST_NODE_TYPES.MemberExpression &&
|
|
153
|
+
!node.left.computed &&
|
|
154
|
+
node.left.property.type === AST_NODE_TYPES.Identifier
|
|
155
|
+
) {
|
|
156
|
+
check(node.left.property.name, node.right);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { NoSilentErrorSwallowingRule } from './no-silent-error-swallowing.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
function createTriggerNode(webhookMethods: string): string {
|
|
8
|
+
return `
|
|
9
|
+
import type { INodeType, INodeTypeDescription, IHookFunctions } from 'n8n-workflow';
|
|
10
|
+
|
|
11
|
+
export class TestTrigger implements INodeType {
|
|
12
|
+
description: INodeTypeDescription = {
|
|
13
|
+
displayName: 'Test Trigger',
|
|
14
|
+
name: 'testTrigger',
|
|
15
|
+
group: ['trigger'],
|
|
16
|
+
version: 1,
|
|
17
|
+
description: 'A test trigger',
|
|
18
|
+
defaults: { name: 'Test Trigger' },
|
|
19
|
+
inputs: [],
|
|
20
|
+
outputs: ['main'],
|
|
21
|
+
webhooks: [{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' }],
|
|
22
|
+
properties: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
webhookMethods = ${webhookMethods};
|
|
26
|
+
}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ruleTester.run('no-silent-error-swallowing', NoSilentErrorSwallowingRule, {
|
|
30
|
+
valid: [
|
|
31
|
+
{
|
|
32
|
+
name: 'lifecycle methods with no catch blocks',
|
|
33
|
+
code: createTriggerNode(`{
|
|
34
|
+
default: {
|
|
35
|
+
async checkExists(this: IHookFunctions): Promise<boolean> { return true; },
|
|
36
|
+
async create(this: IHookFunctions): Promise<boolean> { return true; },
|
|
37
|
+
async delete(this: IHookFunctions): Promise<boolean> { return true; },
|
|
38
|
+
},
|
|
39
|
+
}`),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'catch block that logs and returns',
|
|
43
|
+
code: createTriggerNode(`{
|
|
44
|
+
default: {
|
|
45
|
+
async checkExists(this: IHookFunctions): Promise<boolean> {
|
|
46
|
+
try {
|
|
47
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.logger.error('checkExists failed', { error });
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}`),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'catch block that rethrows',
|
|
58
|
+
code: createTriggerNode(`{
|
|
59
|
+
default: {
|
|
60
|
+
async create(this: IHookFunctions): Promise<boolean> {
|
|
61
|
+
try {
|
|
62
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}`),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'catch block that returns a non-boolean expression',
|
|
72
|
+
code: createTriggerNode(`{
|
|
73
|
+
default: {
|
|
74
|
+
async delete(this: IHookFunctions): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return this.recover(error);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}`),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'silent catch inside a non-lifecycle method is ignored',
|
|
86
|
+
code: createTriggerNode(`{
|
|
87
|
+
default: {
|
|
88
|
+
async someHelper(this: IHookFunctions): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}`),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'silent catch inside a nested callback within a lifecycle method is ignored',
|
|
100
|
+
code: createTriggerNode(`{
|
|
101
|
+
default: {
|
|
102
|
+
async create(this: IHookFunctions): Promise<boolean> {
|
|
103
|
+
const results = items.map((item) => {
|
|
104
|
+
try {
|
|
105
|
+
return normalize(item);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return results.length > 0;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}`),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'silent catch in a non-node-type class is ignored',
|
|
117
|
+
code: `
|
|
118
|
+
export class RegularClass {
|
|
119
|
+
webhookMethods = {
|
|
120
|
+
default: {
|
|
121
|
+
async checkExists() {
|
|
122
|
+
try {
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
invalid: [
|
|
134
|
+
{
|
|
135
|
+
name: 'empty catch block in checkExists',
|
|
136
|
+
code: createTriggerNode(`{
|
|
137
|
+
default: {
|
|
138
|
+
async checkExists(this: IHookFunctions): Promise<boolean> {
|
|
139
|
+
try {
|
|
140
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
141
|
+
} catch (error) {}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}`),
|
|
145
|
+
errors: [{ messageId: 'emptyCatch', data: { method: 'checkExists' } }],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'catch block that only returns false in create',
|
|
149
|
+
code: createTriggerNode(`{
|
|
150
|
+
default: {
|
|
151
|
+
async create(this: IHookFunctions): Promise<boolean> {
|
|
152
|
+
try {
|
|
153
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}`),
|
|
160
|
+
errors: [{ messageId: 'silentReturn', data: { method: 'create' } }],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'catch block that only returns true in delete',
|
|
164
|
+
code: createTriggerNode(`{
|
|
165
|
+
default: {
|
|
166
|
+
async delete(this: IHookFunctions): Promise<boolean> {
|
|
167
|
+
try {
|
|
168
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}`),
|
|
175
|
+
errors: [{ messageId: 'silentReturn', data: { method: 'delete' } }],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'catch block with a bare return',
|
|
179
|
+
code: createTriggerNode(`{
|
|
180
|
+
default: {
|
|
181
|
+
async checkExists(this: IHookFunctions): Promise<boolean> {
|
|
182
|
+
try {
|
|
183
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
}`),
|
|
190
|
+
errors: [{ messageId: 'silentReturn', data: { method: 'checkExists' } }],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'silent catch defined via arrow function method',
|
|
194
|
+
code: createTriggerNode(`{
|
|
195
|
+
default: {
|
|
196
|
+
checkExists: async (this: IHookFunctions): Promise<boolean> => {
|
|
197
|
+
try {
|
|
198
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}`),
|
|
205
|
+
errors: [{ messageId: 'silentReturn', data: { method: 'checkExists' } }],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: 'flagged regardless of file name (webhookMethods is the signal)',
|
|
209
|
+
filename: 'GenericFunctions.ts',
|
|
210
|
+
code: createTriggerNode(`{
|
|
211
|
+
default: {
|
|
212
|
+
async create(this: IHookFunctions): Promise<boolean> {
|
|
213
|
+
try {
|
|
214
|
+
return await this.helpers.httpRequest({ url: 'https://example.com' });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}`),
|
|
221
|
+
errors: [{ messageId: 'silentReturn', data: { method: 'create' } }],
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'multiple silent lifecycle methods each flagged',
|
|
225
|
+
code: createTriggerNode(`{
|
|
226
|
+
default: {
|
|
227
|
+
async checkExists(this: IHookFunctions): Promise<boolean> {
|
|
228
|
+
try { return await this.helpers.httpRequest({ url: 'https://example.com' }); } catch (error) {}
|
|
229
|
+
},
|
|
230
|
+
async create(this: IHookFunctions): Promise<boolean> {
|
|
231
|
+
try { return await this.helpers.httpRequest({ url: 'https://example.com' }); } catch (error) { return false; }
|
|
232
|
+
},
|
|
233
|
+
async delete(this: IHookFunctions): Promise<boolean> {
|
|
234
|
+
try { return await this.helpers.httpRequest({ url: 'https://example.com' }); } catch (error) { return true; }
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}`),
|
|
238
|
+
errors: [
|
|
239
|
+
{ messageId: 'emptyCatch', data: { method: 'checkExists' } },
|
|
240
|
+
{ messageId: 'silentReturn', data: { method: 'create' } },
|
|
241
|
+
{ messageId: 'silentReturn', data: { method: 'delete' } },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createRule,
|
|
5
|
+
findClassProperty,
|
|
6
|
+
isNodeTypeClass,
|
|
7
|
+
WEBHOOK_LIFECYCLE_METHODS,
|
|
8
|
+
} from '../utils/index.js';
|
|
9
|
+
|
|
10
|
+
const LIFECYCLE_METHODS: readonly string[] = WEBHOOK_LIFECYCLE_METHODS;
|
|
11
|
+
|
|
12
|
+
/** Returns the static name of a non-computed object property key, or null. */
|
|
13
|
+
function getPropertyName(property: TSESTree.Property): string | null {
|
|
14
|
+
if (property.computed) return null;
|
|
15
|
+
if (property.key.type === AST_NODE_TYPES.Identifier) return property.key.name;
|
|
16
|
+
if (property.key.type === AST_NODE_TYPES.Literal) return String(property.key.value);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isFunctionNode(node: TSESTree.Node): boolean {
|
|
21
|
+
return (
|
|
22
|
+
node.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
23
|
+
node.type === AST_NODE_TYPES.FunctionExpression ||
|
|
24
|
+
node.type === AST_NODE_TYPES.ArrowFunctionExpression
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Walks up to the function that lexically encloses `node`, or null at module scope. */
|
|
29
|
+
function getEnclosingFunction(node: TSESTree.Node): TSESTree.Node | null {
|
|
30
|
+
let current = node.parent;
|
|
31
|
+
while (current) {
|
|
32
|
+
if (isFunctionNode(current)) return current;
|
|
33
|
+
current = current.parent;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A bare `return;` or `return true/false;` with no other work in the block. */
|
|
39
|
+
function isSilentReturn(statement: TSESTree.Statement): boolean {
|
|
40
|
+
if (statement.type !== AST_NODE_TYPES.ReturnStatement) return false;
|
|
41
|
+
const argument = statement.argument;
|
|
42
|
+
if (argument === null) return true;
|
|
43
|
+
return argument.type === AST_NODE_TYPES.Literal && typeof argument.value === 'boolean';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const NoSilentErrorSwallowingRule = createRule({
|
|
47
|
+
name: 'no-silent-error-swallowing',
|
|
48
|
+
meta: {
|
|
49
|
+
type: 'problem',
|
|
50
|
+
docs: {
|
|
51
|
+
description:
|
|
52
|
+
'Disallow webhook lifecycle methods (checkExists, create, delete) from silently swallowing errors in catch blocks',
|
|
53
|
+
},
|
|
54
|
+
messages: {
|
|
55
|
+
emptyCatch:
|
|
56
|
+
'Empty catch block in webhook lifecycle method `{{method}}` silently swallows the error. Log the error or rethrow it so failures surface instead of being hidden.',
|
|
57
|
+
silentReturn:
|
|
58
|
+
'Catch block in webhook lifecycle method `{{method}}` swallows the error and only returns without logging. Log the error (or rethrow) so failures surface instead of being hidden.',
|
|
59
|
+
},
|
|
60
|
+
schema: [],
|
|
61
|
+
},
|
|
62
|
+
defaultOptions: [],
|
|
63
|
+
create(context) {
|
|
64
|
+
// Maps each lifecycle method's function node to its method name. Populated
|
|
65
|
+
// when the enclosing class is visited (before its `catch` clauses), so a
|
|
66
|
+
// `catch` is only flagged when its *nearest* enclosing function is a
|
|
67
|
+
// lifecycle method — catches inside nested callbacks are left alone.
|
|
68
|
+
const lifecycleMethodByFn = new WeakMap<TSESTree.Node, string>();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ClassDeclaration(node) {
|
|
72
|
+
if (!isNodeTypeClass(node)) return;
|
|
73
|
+
|
|
74
|
+
const webhookMethodsProperty = findClassProperty(node, 'webhookMethods');
|
|
75
|
+
if (webhookMethodsProperty?.value?.type !== AST_NODE_TYPES.ObjectExpression) return;
|
|
76
|
+
|
|
77
|
+
for (const groupProperty of webhookMethodsProperty.value.properties) {
|
|
78
|
+
if (groupProperty.type !== AST_NODE_TYPES.Property) continue;
|
|
79
|
+
if (groupProperty.value.type !== AST_NODE_TYPES.ObjectExpression) continue;
|
|
80
|
+
|
|
81
|
+
for (const methodProperty of groupProperty.value.properties) {
|
|
82
|
+
if (methodProperty.type !== AST_NODE_TYPES.Property) continue;
|
|
83
|
+
|
|
84
|
+
const methodName = getPropertyName(methodProperty);
|
|
85
|
+
if (methodName === null || !LIFECYCLE_METHODS.includes(methodName)) continue;
|
|
86
|
+
|
|
87
|
+
const fn = methodProperty.value;
|
|
88
|
+
if (
|
|
89
|
+
fn.type === AST_NODE_TYPES.FunctionExpression ||
|
|
90
|
+
fn.type === AST_NODE_TYPES.ArrowFunctionExpression
|
|
91
|
+
) {
|
|
92
|
+
lifecycleMethodByFn.set(fn, methodName);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
CatchClause(node) {
|
|
99
|
+
const enclosingFn = getEnclosingFunction(node);
|
|
100
|
+
if (enclosingFn === null) return;
|
|
101
|
+
|
|
102
|
+
const methodName = lifecycleMethodByFn.get(enclosingFn);
|
|
103
|
+
if (methodName === undefined) return;
|
|
104
|
+
|
|
105
|
+
const statements = node.body.body;
|
|
106
|
+
|
|
107
|
+
if (statements.length === 0) {
|
|
108
|
+
context.report({
|
|
109
|
+
node: node.body,
|
|
110
|
+
messageId: 'emptyCatch',
|
|
111
|
+
data: { method: methodName },
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const onlyStatement = statements.length === 1 ? statements[0] : undefined;
|
|
117
|
+
if (onlyStatement && isSilentReturn(onlyStatement)) {
|
|
118
|
+
context.report({
|
|
119
|
+
node: onlyStatement,
|
|
120
|
+
messageId: 'silentReturn',
|
|
121
|
+
data: { method: methodName },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { RequireFilesArrayRule } from './require-files-array.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
ruleTester.run('require-files-array', RequireFilesArrayRule, {
|
|
8
|
+
valid: [
|
|
9
|
+
{
|
|
10
|
+
name: 'files array with entries',
|
|
11
|
+
filename: 'package.json',
|
|
12
|
+
code: '{ "name": "n8n-nodes-example", "files": ["dist"] }',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'files array with multiple entries',
|
|
16
|
+
filename: 'package.json',
|
|
17
|
+
code: '{ "files": ["dist", "credentials"] }',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'non-package.json file is ignored',
|
|
21
|
+
filename: 'some-config.json',
|
|
22
|
+
code: '{ "name": "n8n-nodes-example" }',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
invalid: [
|
|
26
|
+
{
|
|
27
|
+
name: 'missing files field entirely',
|
|
28
|
+
filename: 'package.json',
|
|
29
|
+
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
|
30
|
+
errors: [{ messageId: 'filesMissing' }],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'empty package.json object',
|
|
34
|
+
filename: 'package.json',
|
|
35
|
+
code: '{}',
|
|
36
|
+
errors: [{ messageId: 'filesMissing' }],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'files as empty array',
|
|
40
|
+
filename: 'package.json',
|
|
41
|
+
code: '{ "files": [] }',
|
|
42
|
+
errors: [{ messageId: 'filesEmpty' }],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'files as string instead of array',
|
|
46
|
+
filename: 'package.json',
|
|
47
|
+
code: '{ "files": "dist" }',
|
|
48
|
+
errors: [{ messageId: 'filesEmpty' }],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'files as non-array value',
|
|
52
|
+
filename: 'package.json',
|
|
53
|
+
code: '{ "files": null }',
|
|
54
|
+
errors: [{ messageId: 'filesEmpty' }],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
3
|
+
|
|
4
|
+
import { createRule, findJsonProperty, getTopLevelObjectInJson } from '../utils/index.js';
|
|
5
|
+
|
|
6
|
+
export const RequireFilesArrayRule = createRule({
|
|
7
|
+
name: 'require-files-array',
|
|
8
|
+
meta: {
|
|
9
|
+
type: 'problem',
|
|
10
|
+
docs: {
|
|
11
|
+
description: 'Require a non-empty "files" array in package.json',
|
|
12
|
+
},
|
|
13
|
+
messages: {
|
|
14
|
+
filesMissing:
|
|
15
|
+
'package.json must have a "files" field with a non-empty array of files to publish.',
|
|
16
|
+
filesEmpty: 'The "files" field must be a non-empty array.',
|
|
17
|
+
},
|
|
18
|
+
schema: [],
|
|
19
|
+
},
|
|
20
|
+
defaultOptions: [],
|
|
21
|
+
create(context) {
|
|
22
|
+
if (!context.filename.endsWith('package.json')) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
ObjectExpression(node: TSESTree.ObjectExpression) {
|
|
28
|
+
const root = getTopLevelObjectInJson(node);
|
|
29
|
+
if (!root) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filesProp = findJsonProperty(root, 'files');
|
|
34
|
+
if (!filesProp) {
|
|
35
|
+
context.report({ node: root, messageId: 'filesMissing' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const filesValue = filesProp.value;
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
filesValue.type !== AST_NODE_TYPES.ArrayExpression ||
|
|
43
|
+
filesValue.elements.length === 0
|
|
44
|
+
) {
|
|
45
|
+
context.report({ node: filesProp, messageId: 'filesEmpty' });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|