@n8n/eslint-plugin-community-nodes 0.25.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 +51 -15
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +12 -0
- package/dist/plugin.js.map +1 -1
- package/dist/rules/ai-node-package-json.d.ts +1 -1
- package/dist/rules/cred-class-field-icon-missing.d.ts +1 -1
- package/dist/rules/cred-class-oauth2-naming.d.ts +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/icon-validation.d.ts +1 -1
- package/dist/rules/index.d.ts +26 -20
- 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-credential-reuse.d.ts +1 -1
- package/dist/rules/no-dangerous-functions.d.ts +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-deprecated-workflow-functions.d.ts +1 -1
- 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-restricted-imports.d.ts +1 -1
- package/dist/rules/no-restricted-imports.d.ts.map +1 -1
- package/dist/rules/no-restricted-imports.js +14 -5
- package/dist/rules/no-restricted-imports.js.map +1 -1
- 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/node-class-description-icon-missing.d.ts +1 -1
- package/dist/rules/node-connection-type-literal.js.map +1 -1
- package/dist/rules/package-name-convention.d.ts +1 -1
- 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/require-version.d.ts +1 -1
- package/dist/rules/trigger-node-conventions.d.ts +1 -1
- package/dist/rules/valid-author.d.ts +1 -1
- package/dist/rules/valid-credential-references.d.ts +1 -1
- package/dist/rules/valid-description.d.ts +1 -1
- package/dist/rules/valid-peer-dependencies.d.ts +1 -1
- package/dist/rules/webhook-lifecycle-complete.d.ts +1 -1
- 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/ast-utils.js.map +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 +13 -0
- package/dist/utils/file-utils.d.ts.map +1 -1
- package/dist/utils/file-utils.js +44 -6
- 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-restricted-imports.md +9 -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 +5 -4
- 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-restricted-imports.test.ts +63 -0
- package/src/rules/no-restricted-imports.ts +17 -4
- 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 +50 -6
- package/src/utils/index.ts +2 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
|
|
3
|
+
import { NoHardcodedSecretsRule } from './no-hardcoded-secrets.js';
|
|
4
|
+
|
|
5
|
+
const ruleTester = new RuleTester();
|
|
6
|
+
|
|
7
|
+
// A 40-char hex string (SHA-1 shaped) — a realistic hardcoded secret value.
|
|
8
|
+
const HEX_SECRET = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
|
|
9
|
+
// A base64/token-shaped value with mixed case, digits and punctuation.
|
|
10
|
+
const TOKEN_SECRET = 'sk-1a2B3c4D5e6F7g8H9i0J-kLmNoP';
|
|
11
|
+
|
|
12
|
+
ruleTester.run('no-hardcoded-secrets', NoHardcodedSecretsRule, {
|
|
13
|
+
valid: [
|
|
14
|
+
// Non-secret variable names are never inspected.
|
|
15
|
+
{ name: 'non-secret variable name', code: `const label = '${HEX_SECRET}';` },
|
|
16
|
+
{ name: 'unrelated name with long value', code: `const description = '${TOKEN_SECRET}';` },
|
|
17
|
+
|
|
18
|
+
// Secret-named variables with short values are ignored (< 16 chars).
|
|
19
|
+
{ name: 'short secret value', code: "const apiKey = 'short';" },
|
|
20
|
+
{ name: '15 chars, below minimum', code: "const token = 'abcdef012345678';" },
|
|
21
|
+
|
|
22
|
+
// Secret-named variables holding prose / human-readable strings.
|
|
23
|
+
{
|
|
24
|
+
name: 'secret name with human text',
|
|
25
|
+
code: "const passwordHint = 'Enter your account password here';",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'secret name with spaces in value',
|
|
29
|
+
code: "const secretNote = 'this is not a secret';",
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Plain long camelCase word is not token-shaped (no digits/punctuation).
|
|
33
|
+
{ name: 'long word without digits', code: "const tokenName = 'anExtraordinarilyLongName';" },
|
|
34
|
+
|
|
35
|
+
// Dotted / hyphenated / snake_case constants without a digit+letter mix
|
|
36
|
+
// are common (scopes, paths, ids) and must not be treated as secrets.
|
|
37
|
+
{ name: 'dotted scope string', code: "const authScope = 'read.write.admin.profile.email';" },
|
|
38
|
+
{ name: 'hyphenated slug', code: "const secretName = 'my-very-long-descriptive-slug';" },
|
|
39
|
+
{ name: 'snake_case constant', code: "const tokenType = 'refresh_token_grant_type_value';" },
|
|
40
|
+
|
|
41
|
+
// Secret-shaped name but non-secret value: the value gate, not the name,
|
|
42
|
+
// is what decides. `tokenId` no longer benefits from a name exclusion, so
|
|
43
|
+
// this proves a benign value still passes on its own merits.
|
|
44
|
+
{
|
|
45
|
+
name: 'tokenId with human-readable value',
|
|
46
|
+
code: "const tokenId = 'primary-session-reference';",
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// Obvious placeholders are skipped.
|
|
50
|
+
{ name: 'placeholder value', code: "const apiKey = 'your-api-key-goes-here-xxxx';" },
|
|
51
|
+
{ name: 'example placeholder', code: `const secret = 'example-secret-${HEX_SECRET}';` },
|
|
52
|
+
|
|
53
|
+
// Not a static string literal.
|
|
54
|
+
{ name: 'value from env', code: 'const apiKey = process.env.API_KEY;' },
|
|
55
|
+
// The payload must itself contain `${` to be a real template literal.
|
|
56
|
+
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
|
|
57
|
+
{ name: 'template literal with expression', code: 'const token = `${suffix}`;' },
|
|
58
|
+
{ name: 'computed member assignment', code: `obj[key] = '${HEX_SECRET}';` },
|
|
59
|
+
],
|
|
60
|
+
invalid: [
|
|
61
|
+
{
|
|
62
|
+
name: 'SECURITY: exactly 16 chars is flagged (minimum length)',
|
|
63
|
+
code: "const token = 'abcdef0123456789';",
|
|
64
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'token' } }],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'SECURITY: hex secret in variable',
|
|
68
|
+
code: `const apiKey = '${HEX_SECRET}';`,
|
|
69
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'apiKey' } }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'SECURITY: token-shaped secret in variable',
|
|
73
|
+
code: `const accessToken = '${TOKEN_SECRET}';`,
|
|
74
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'accessToken' } }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'SECURITY: secret in object property (identifier key)',
|
|
78
|
+
code: `const config = { clientSecret: '${HEX_SECRET}' };`,
|
|
79
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'clientSecret' } }],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'SECURITY: secret in object property (string key)',
|
|
83
|
+
code: `const config = { 'api_key': '${HEX_SECRET}' };`,
|
|
84
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'api_key' } }],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'SECURITY: secret in class field',
|
|
88
|
+
code: `class C { private password = '${TOKEN_SECRET}'; }`,
|
|
89
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'password' } }],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'SECURITY: secret in member assignment',
|
|
93
|
+
code: `this.authToken = '${HEX_SECRET}';`,
|
|
94
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'authToken' } }],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'SECURITY: secret in identifier assignment',
|
|
98
|
+
code: `secretKey = '${TOKEN_SECRET}';`,
|
|
99
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'secretKey' } }],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'SECURITY: secret in no-expression template literal',
|
|
103
|
+
code: `const apiKey = \`${HEX_SECRET}\`;`,
|
|
104
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'apiKey' } }],
|
|
105
|
+
},
|
|
106
|
+
// No name exclusions: a secret-shaped value is flagged even when the name
|
|
107
|
+
// also contains a normally-"non-sensitive" fragment (url/pub/id).
|
|
108
|
+
{
|
|
109
|
+
name: 'SECURITY: secret on name containing "pub"',
|
|
110
|
+
code: `const publicKey = '${HEX_SECRET}';`,
|
|
111
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'publicKey' } }],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'SECURITY: secret on name containing "id"',
|
|
115
|
+
code: `const tokenId = '${HEX_SECRET}';`,
|
|
116
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'tokenId' } }],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'SECURITY: secret on name containing "url"',
|
|
120
|
+
code: `const tokenUrl = '${TOKEN_SECRET}';`,
|
|
121
|
+
errors: [{ messageId: 'hardcodedSecret', data: { name: 'tokenUrl' } }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { afterAll } from 'vitest';
|
|
2
6
|
|
|
3
7
|
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
|
4
8
|
|
|
9
|
+
// Fixture package whose devDependencies include `vitest` and `@vitest/expect`,
|
|
10
|
+
// and whose runtime `dependencies` include `axios`. Used to verify the rule
|
|
11
|
+
// allows dev-dependency imports but still restricts runtime dependencies.
|
|
12
|
+
// Created synchronously because RuleTester.run reads the `filename` option at
|
|
13
|
+
// module-eval time, before any test hooks fire.
|
|
14
|
+
const fixtureDir = mkdtempSync(join(tmpdir(), 'n8n-restricted-imports-'));
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(fixtureDir, 'package.json'),
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
name: 'n8n-nodes-devdep-fixture',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
devDependencies: { vitest: '^1.0.0', '@vitest/expect': '^1.0.0' },
|
|
21
|
+
dependencies: { axios: '^1.0.0' },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const fixtureTestFile = join(fixtureDir, '__tests__', 'MyNode.test.ts');
|
|
25
|
+
const fixtureRealFile = join(fixtureDir, 'src', 'MyNode.node.ts');
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
rmSync(fixtureDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
5
31
|
const ruleTester = new RuleTester();
|
|
6
32
|
|
|
7
33
|
ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
|
|
@@ -78,6 +104,31 @@ ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
|
|
|
78
104
|
{
|
|
79
105
|
code: 'const workflow = await import(`n8n-workflow`);',
|
|
80
106
|
},
|
|
107
|
+
{
|
|
108
|
+
name: 'devDependency (vitest) import is allowed in test files',
|
|
109
|
+
filename: fixtureTestFile,
|
|
110
|
+
code: 'import { describe } from "vitest";',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'scoped devDependency import is allowed in test files',
|
|
114
|
+
filename: fixtureTestFile,
|
|
115
|
+
code: 'import { expect } from "@vitest/expect";',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'devDependency require is allowed in test files',
|
|
119
|
+
filename: fixtureTestFile,
|
|
120
|
+
code: 'const { it } = require("vitest");',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'devDependency dynamic import is allowed in test files',
|
|
124
|
+
filename: fixtureTestFile,
|
|
125
|
+
code: 'const vitest = await import("vitest");',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'type-only devDependency import is allowed in node source',
|
|
129
|
+
filename: fixtureRealFile,
|
|
130
|
+
code: 'import type { Task } from "vitest";',
|
|
131
|
+
},
|
|
81
132
|
],
|
|
82
133
|
invalid: [
|
|
83
134
|
{
|
|
@@ -157,6 +208,18 @@ const lodash = require("lodash");`,
|
|
|
157
208
|
code: 'const express = await import("express");',
|
|
158
209
|
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'express' } }],
|
|
159
210
|
},
|
|
211
|
+
{
|
|
212
|
+
name: 'runtime dependency (axios) is still restricted in a real node file',
|
|
213
|
+
filename: fixtureRealFile,
|
|
214
|
+
code: 'import axios from "axios";',
|
|
215
|
+
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'runtime dependency (axios) stays restricted even in test files',
|
|
219
|
+
filename: fixtureTestFile,
|
|
220
|
+
code: 'import axios from "axios";',
|
|
221
|
+
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
|
|
222
|
+
},
|
|
160
223
|
{
|
|
161
224
|
code: 'const path = require(`path`);',
|
|
162
225
|
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
isDirectRequireCall,
|
|
4
4
|
isRequireMemberCall,
|
|
5
5
|
createRule,
|
|
6
|
+
findPackageJson,
|
|
7
|
+
readPackageJsonDevDependencies,
|
|
6
8
|
} from '../utils/index.js';
|
|
7
9
|
|
|
8
10
|
const allowedModules = [
|
|
@@ -18,13 +20,20 @@ const allowedModules = [
|
|
|
18
20
|
'@n8n/ai-node-sdk',
|
|
19
21
|
];
|
|
20
22
|
|
|
21
|
-
const isModuleAllowed = (modulePath: string): boolean => {
|
|
23
|
+
const isModuleAllowed = (modulePath: string, devDependencies: Set<string>): boolean => {
|
|
22
24
|
if (modulePath.startsWith('./') || modulePath.startsWith('../')) return true;
|
|
23
25
|
|
|
24
26
|
const moduleName = modulePath.startsWith('@')
|
|
25
27
|
? modulePath.split('/').slice(0, 2).join('/')
|
|
26
28
|
: modulePath.split('/')[0];
|
|
27
29
|
if (!moduleName) return true;
|
|
30
|
+
// Dev dependencies (e.g. `vitest`) are never installed at runtime on n8n
|
|
31
|
+
// Cloud, so they are not subject to this rule — it targets runtime
|
|
32
|
+
// dependencies only. `no-runtime-dependencies` already enforces that the
|
|
33
|
+
// package's `dependencies` field is empty, so any external package an
|
|
34
|
+
// author uses must be a devDependency (bundled at build) or a
|
|
35
|
+
// peerDependency (provided by the instance).
|
|
36
|
+
if (devDependencies.has(moduleName)) return true;
|
|
28
37
|
return allowedModules.includes(moduleName);
|
|
29
38
|
};
|
|
30
39
|
|
|
@@ -47,10 +56,14 @@ export const NoRestrictedImportsRule = createRule({
|
|
|
47
56
|
},
|
|
48
57
|
defaultOptions: [],
|
|
49
58
|
create(context) {
|
|
59
|
+
const devDependencies = readPackageJsonDevDependencies(
|
|
60
|
+
findPackageJson(context.physicalFilename),
|
|
61
|
+
);
|
|
62
|
+
|
|
50
63
|
return {
|
|
51
64
|
ImportDeclaration(node) {
|
|
52
65
|
const modulePath = getModulePath(node.source);
|
|
53
|
-
if (modulePath && !isModuleAllowed(modulePath)) {
|
|
66
|
+
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
|
|
54
67
|
context.report({
|
|
55
68
|
node,
|
|
56
69
|
messageId: 'restrictedImport',
|
|
@@ -63,7 +76,7 @@ export const NoRestrictedImportsRule = createRule({
|
|
|
63
76
|
|
|
64
77
|
ImportExpression(node) {
|
|
65
78
|
const modulePath = getModulePath(node.source);
|
|
66
|
-
if (modulePath && !isModuleAllowed(modulePath)) {
|
|
79
|
+
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
|
|
67
80
|
context.report({
|
|
68
81
|
node,
|
|
69
82
|
messageId: 'restrictedDynamicImport',
|
|
@@ -77,7 +90,7 @@ export const NoRestrictedImportsRule = createRule({
|
|
|
77
90
|
CallExpression(node) {
|
|
78
91
|
if (isDirectRequireCall(node) || isRequireMemberCall(node)) {
|
|
79
92
|
const modulePath = getModulePath(node.arguments[0] ?? null);
|
|
80
|
-
if (modulePath && !isModuleAllowed(modulePath)) {
|
|
93
|
+
if (modulePath && !isModuleAllowed(modulePath, devDependencies)) {
|
|
81
94
|
context.report({
|
|
82
95
|
node,
|
|
83
96
|
messageId: 'restrictedRequire',
|
|
@@ -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
|
+
});
|