mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 19:15:01 +02:00
feat(core): Add credential-unnecessary-password community node lint rule (#34791)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d5b5d8ead5
commit
95da84912f
|
|
@ -50,8 +50,9 @@ export default [
|
|||
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | |
|
||||
| [cred-class-oauth2-naming](docs/rules/cred-class-oauth2-naming.md) | OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` | ✅ ☑️ | | 🔧 | | |
|
||||
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
|
||||
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
|
||||
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
|
||||
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |
|
||||
| [credential-unnecessary-password](docs/rules/credential-unnecessary-password.md) | Warn when a credential field with no sensitive name uses typeOptions.password = true | | ✅ ☑️ | | | |
|
||||
| [icon-prefer-themed-variants](docs/rules/icon-prefer-themed-variants.md) | Encourage node and credential icons to provide light/dark variants instead of a single icon file | | ✅ ☑️ | | | |
|
||||
| [icon-validation](docs/rules/icon-validation.md) | Validate node and credential icon files exist, use the file: protocol, and that light/dark icons are different | ✅ ☑️ | | | 💡 | |
|
||||
| [missing-paired-item](docs/rules/missing-paired-item.md) | Require pairedItem on INodeExecutionData objects in execute() methods to preserve item linking. | ✅ ☑️ | | | | |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Ensure credential fields with sensitive names have typeOptions.password = true (`@n8n/community-nodes/credential-password-field`)
|
||||
# Ensure fields with sensitive names have typeOptions.password = true (`@n8n/community-nodes/credential-password-field`)
|
||||
|
||||
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
Ensures that credential fields with names like "password", "secret", "token", or "key" are properly masked in the UI by having `typeOptions.password = true`.
|
||||
|
||||
The inverse case — non-sensitive fields that should **not** be masked — is covered by the separate [`credential-unnecessary-password`](credential-unnecessary-password.md) rule.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# Warn when a credential field with no sensitive name uses typeOptions.password = true (`@n8n/community-nodes/credential-unnecessary-password`)
|
||||
|
||||
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
This is the inverse of [`credential-password-field`](credential-password-field.md). A field that clearly does not hold a secret — URLs, IDs, regions, and similar — should not be masked with `typeOptions.password = true`, since masking a non-secret value only hurts usability.
|
||||
|
||||
The check only flags a masked field when its name carries **no** sensitive marker at all (`password`, `secret`, `token`, `cert`, `key`-like words, …). A name that contains a sensitive marker is left alone even if it also looks like a URL or ID — for example `androidToken` is a real secret despite containing the substring `id`, so it is never flagged.
|
||||
|
||||
It is reported as a **warning without an autofix**: the sensitivity check is a name-based heuristic, so removing masking could be wrong for a secret whose name isn't in the recognised list. The author must confirm before removing it.
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Incorrect
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
typeOptions: { password: true }, // A URL is not a secret
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Correct
|
||||
|
||||
```typescript
|
||||
export class MyApiCredential implements ICredentialType {
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Base URL',
|
||||
name: 'baseUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
|
@ -24,6 +24,7 @@ const configs = {
|
|||
'@n8n/community-nodes/no-restricted-globals': 'error',
|
||||
'@n8n/community-nodes/no-restricted-imports': 'error',
|
||||
'@n8n/community-nodes/credential-password-field': 'error',
|
||||
'@n8n/community-nodes/credential-unnecessary-password': 'warn',
|
||||
'@n8n/community-nodes/n8n-object-validation': 'error',
|
||||
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/community-nodes/no-emoji-in-options': 'error',
|
||||
|
|
@ -67,6 +68,7 @@ const configs = {
|
|||
rules: {
|
||||
'@n8n/community-nodes/ai-node-package-json': 'error',
|
||||
'@n8n/community-nodes/credential-password-field': 'error',
|
||||
'@n8n/community-nodes/credential-unnecessary-password': 'warn',
|
||||
'@n8n/community-nodes/n8n-object-validation': 'error',
|
||||
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
|
||||
'@n8n/community-nodes/no-emoji-in-options': 'error',
|
||||
|
|
|
|||
|
|
@ -6,47 +6,11 @@ import {
|
|||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
getBooleanLiteralValue,
|
||||
isSensitiveFieldName,
|
||||
hasPasswordTypeOption,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'cert',
|
||||
'passphrase',
|
||||
'apikey',
|
||||
'secretkey',
|
||||
'privatekey',
|
||||
'authkey',
|
||||
];
|
||||
|
||||
const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
|
||||
|
||||
function isSensitiveFieldName(name: string): boolean {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
|
||||
|
||||
return passwordValue === true;
|
||||
}
|
||||
|
||||
function createPasswordFix(
|
||||
element: TSESTree.ObjectExpression,
|
||||
typeOptionsProperty: TSESTree.Property | null,
|
||||
|
|
@ -87,7 +51,7 @@ export const CredentialPasswordFieldRule = createRule({
|
|||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure credential fields with sensitive names have typeOptions.password = true',
|
||||
description: 'Ensure fields with sensitive names have typeOptions.password = true',
|
||||
},
|
||||
messages: {
|
||||
missingPasswordOption:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialUnnecessaryPasswordRule } from './credential-unnecessary-password.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createCredentialCode(properties: string[]): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
${properties.map((prop) => `\t\t${prop}`).join(',\n')},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createProperty(
|
||||
displayName: string,
|
||||
name: string,
|
||||
options: { password?: boolean } = {},
|
||||
): string {
|
||||
const typeOptionsStr =
|
||||
options.password !== undefined ? `\n\t\t\ttypeOptions: { password: ${options.password} },` : '';
|
||||
|
||||
return `{
|
||||
displayName: '${displayName}',
|
||||
name: '${name}',
|
||||
type: 'string',
|
||||
default: '',${typeOptionsStr}
|
||||
}`;
|
||||
}
|
||||
|
||||
function createRegularClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
properties = [
|
||||
{
|
||||
name: 'region',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-unnecessary-password', CredentialUnnecessaryPasswordRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'non-sensitive field without password option',
|
||||
code: createCredentialCode([createProperty('Region', 'region')]),
|
||||
},
|
||||
{
|
||||
name: 'non-sensitive field with password: false',
|
||||
code: createCredentialCode([createProperty('Base URL', 'baseUrl', { password: false })]),
|
||||
},
|
||||
{
|
||||
name: 'sensitive field correctly masked',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'masked field with a sensitive marker is left alone even when its name contains a non-sensitive substring',
|
||||
code: createCredentialCode([
|
||||
// 'androidToken' contains the non-sensitive substring 'id' but is a real secret
|
||||
createProperty('Android Token', 'androidToken', { password: true }),
|
||||
createProperty('Token URL', 'tokenUrl', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'class does not implement ICredentialType',
|
||||
code: createRegularClass(),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'region field should not use password option',
|
||||
code: createCredentialCode([createProperty('Region', 'region', { password: true })]),
|
||||
errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'region' } }],
|
||||
},
|
||||
{
|
||||
name: 'URL field should not use password option',
|
||||
code: createCredentialCode([createProperty('Base URL', 'baseUrl', { password: true })]),
|
||||
errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'baseUrl' } }],
|
||||
},
|
||||
{
|
||||
name: 'ID field should not use password option',
|
||||
code: createCredentialCode([createProperty('Client ID', 'clientId', { password: true })]),
|
||||
errors: [{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'clientId' } }],
|
||||
},
|
||||
{
|
||||
name: 'multiple non-sensitive masked fields are each flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Region', 'region', { password: true }),
|
||||
createProperty('Host', 'host', { password: true }),
|
||||
]),
|
||||
errors: [
|
||||
{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'region' } },
|
||||
{ messageId: 'unnecessaryPasswordOption', data: { fieldName: 'host' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
containsSensitivePattern,
|
||||
hasPasswordTypeOption,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const CredentialUnnecessaryPasswordRule = createRule({
|
||||
name: 'credential-unnecessary-password',
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Warn when a credential field with no sensitive name uses typeOptions.password = true',
|
||||
},
|
||||
messages: {
|
||||
unnecessaryPasswordOption:
|
||||
"Field '{{ fieldName }}' does not appear to be sensitive but uses 'typeOptions: { password: true }'. Remove it unless the field holds a secret",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findClassProperty(node, 'properties');
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of propertiesProperty.value.elements) {
|
||||
if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameProperty = findObjectProperty(element, 'name');
|
||||
const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
|
||||
|
||||
if (!fieldName || !hasPasswordTypeOption(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only flag a masked field when its name carries no sensitive marker at all.
|
||||
// Requiring the *absence* of any sensitive pattern (rather than reusing
|
||||
// isSensitiveFieldName) avoids flagging a real secret like `androidToken`,
|
||||
// which is classified non-sensitive only because it contains the substring
|
||||
// `id`. Reported without a fix: removing masking could still be wrong for an
|
||||
// unlisted secret name, so the author confirms.
|
||||
if (!containsSensitivePattern(fieldName)) {
|
||||
context.report({
|
||||
node: element,
|
||||
messageId: 'unnecessaryPasswordOption',
|
||||
data: { fieldName },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
|
|||
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
import { CredentialUnnecessaryPasswordRule } from './credential-unnecessary-password.js';
|
||||
import { IconPreferThemedVariantsRule } from './icon-prefer-themed-variants.js';
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
import { MissingPairedItemRule } from './missing-paired-item.js';
|
||||
|
|
@ -49,6 +50,7 @@ export const rules = {
|
|||
'node-usable-as-tool': NodeUsableAsToolRule,
|
||||
'package-name-convention': PackageNameConventionRule,
|
||||
'credential-test-required': CredentialTestRequiredRule,
|
||||
'credential-unnecessary-password': CredentialUnnecessaryPasswordRule,
|
||||
'no-credential-reuse': NoCredentialReuseRule,
|
||||
'no-dangerous-functions': NoDangerousFunctionsRule,
|
||||
'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
|
||||
import { findObjectProperty, getBooleanLiteralValue } from './ast-utils.js';
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'cert',
|
||||
'passphrase',
|
||||
'apikey',
|
||||
'secretkey',
|
||||
'privatekey',
|
||||
'authkey',
|
||||
];
|
||||
|
||||
const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
|
||||
|
||||
/** Whether the field name contains any marker that suggests it may hold a secret. */
|
||||
export function containsSensitivePattern(name: string): boolean {
|
||||
const lowerName = name.toLowerCase();
|
||||
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the field name should be treated as sensitive (and therefore masked).
|
||||
* A name that contains a non-sensitive marker (URL, ID, public) is never treated
|
||||
* as sensitive, even if it also contains a sensitive one.
|
||||
*/
|
||||
export function isSensitiveFieldName(name: string): boolean {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return containsSensitivePattern(name);
|
||||
}
|
||||
|
||||
/** Whether the credential field element sets `typeOptions: { password: true }`. */
|
||||
export function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
|
||||
|
||||
return passwordValue === true;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './ast-utils.js';
|
||||
export * from './credential-fields.js';
|
||||
export * from './constants.js';
|
||||
export * from './file-utils.js';
|
||||
export * from './rule-creator.js';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user