eslint-plugin-jwt-security 2.3.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/AGENTS.md +102 -0
- package/CHANGELOG.md +161 -0
- package/LICENSE +23 -0
- package/README.md +158 -0
- package/dist/AGENTS.md +102 -0
- package/dist/CHANGELOG.md +161 -0
- package/dist/LICENSE +23 -0
- package/dist/README.md +158 -0
- package/dist/package.json +125 -0
- package/dist/src/index.d.ts +101 -0
- package/dist/src/index.js +171 -0
- package/dist/src/oxlint.d.ts +20 -0
- package/dist/src/oxlint.js +21 -0
- package/dist/src/rules/no-algorithm-confusion/index.d.ts +24 -0
- package/dist/src/rules/no-algorithm-confusion/index.js +165 -0
- package/dist/src/rules/no-algorithm-none/index.d.ts +24 -0
- package/dist/src/rules/no-algorithm-none/index.js +222 -0
- package/dist/src/rules/no-decode-without-verify/index.d.ts +23 -0
- package/dist/src/rules/no-decode-without-verify/index.js +131 -0
- package/dist/src/rules/no-hardcoded-secret/index.d.ts +23 -0
- package/dist/src/rules/no-hardcoded-secret/index.js +189 -0
- package/dist/src/rules/no-sensitive-payload/index.d.ts +20 -0
- package/dist/src/rules/no-sensitive-payload/index.js +103 -0
- package/dist/src/rules/no-timestamp-manipulation/index.d.ts +23 -0
- package/dist/src/rules/no-timestamp-manipulation/index.js +97 -0
- package/dist/src/rules/no-weak-secret/index.d.ts +23 -0
- package/dist/src/rules/no-weak-secret/index.js +165 -0
- package/dist/src/rules/require-algorithm-whitelist/index.d.ts +24 -0
- package/dist/src/rules/require-algorithm-whitelist/index.js +113 -0
- package/dist/src/rules/require-audience-validation/index.d.ts +20 -0
- package/dist/src/rules/require-audience-validation/index.js +100 -0
- package/dist/src/rules/require-expiration/index.d.ts +23 -0
- package/dist/src/rules/require-expiration/index.js +136 -0
- package/dist/src/rules/require-issued-at/index.d.ts +23 -0
- package/dist/src/rules/require-issued-at/index.js +113 -0
- package/dist/src/rules/require-issuer-validation/index.d.ts +20 -0
- package/dist/src/rules/require-issuer-validation/index.js +100 -0
- package/dist/src/rules/require-max-age/index.d.ts +24 -0
- package/dist/src/rules/require-max-age/index.js +93 -0
- package/dist/src/types/index.d.ts +82 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/utils/index.d.ts +98 -0
- package/dist/src/utils/index.js +238 -0
- package/package.json +125 -0
- package/src/__compatibility__/jwt-interface.spec.ts +192 -0
- package/src/index.test.ts +47 -0
- package/src/index.ts +209 -0
- package/src/oxlint.test.ts +61 -0
- package/src/oxlint.ts +23 -0
- package/src/rules/no-algorithm-confusion/index.ts +208 -0
- package/src/rules/no-algorithm-confusion/no-algorithm-confusion.test.ts +106 -0
- package/src/rules/no-algorithm-none/index.ts +270 -0
- package/src/rules/no-algorithm-none/no-algorithm-none.test.ts +146 -0
- package/src/rules/no-decode-without-verify/index.ts +162 -0
- package/src/rules/no-decode-without-verify/no-decode-without-verify.test.ts +76 -0
- package/src/rules/no-hardcoded-secret/index.ts +230 -0
- package/src/rules/no-hardcoded-secret/no-hardcoded-secret.test.ts +97 -0
- package/src/rules/no-sensitive-payload/index.ts +123 -0
- package/src/rules/no-sensitive-payload/no-sensitive-payload.test.ts +157 -0
- package/src/rules/no-timestamp-manipulation/index.ts +118 -0
- package/src/rules/no-timestamp-manipulation/no-timestamp-manipulation.test.ts +54 -0
- package/src/rules/no-weak-secret/index.ts +204 -0
- package/src/rules/no-weak-secret/no-weak-secret.test.ts +114 -0
- package/src/rules/require-algorithm-whitelist/index.ts +145 -0
- package/src/rules/require-algorithm-whitelist/require-algorithm-whitelist.test.ts +72 -0
- package/src/rules/require-audience-validation/index.ts +120 -0
- package/src/rules/require-audience-validation/require-audience-validation.test.ts +68 -0
- package/src/rules/require-expiration/index.ts +170 -0
- package/src/rules/require-expiration/require-expiration.test.ts +135 -0
- package/src/rules/require-issued-at/index.ts +143 -0
- package/src/rules/require-issued-at/require-issued-at.test.ts +64 -0
- package/src/rules/require-issuer-validation/index.ts +120 -0
- package/src/rules/require-issuer-validation/require-issuer-validation.test.ts +68 -0
- package/src/rules/require-max-age/index.ts +117 -0
- package/src/rules/require-max-age/require-max-age.test.ts +69 -0
- package/src/tests/coverage-dual-layer.test.ts +323 -0
- package/src/types/index.ts +107 -0
- package/src/utils/index.ts +285 -0
- package/src/utils/utils.test.ts +324 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: no-timestamp-manipulation
|
|
9
|
+
*
|
|
10
|
+
* Detects patterns that disable automatic timestamp (iat) generation.
|
|
11
|
+
* This enables the "Back to the Future" replay attack (LightSEC 2025).
|
|
12
|
+
*
|
|
13
|
+
* CWE-294: Authentication Bypass by Capture-replay
|
|
14
|
+
*
|
|
15
|
+
* @see https://securitypattern.com/post/jwt-back-to-the-future
|
|
16
|
+
*/
|
|
17
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
18
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
19
|
+
import { isSignOperation, getOptionsArgument, hasOption, getOptionValue } from '../../utils';
|
|
20
|
+
import type { JwtRuleOptions } from '../../types';
|
|
21
|
+
|
|
22
|
+
type MessageIds = 'timestampDisabled' | 'noTimestampTrue';
|
|
23
|
+
|
|
24
|
+
type RuleOptions = [JwtRuleOptions?];
|
|
25
|
+
|
|
26
|
+
export const noTimestampManipulation = createRule<RuleOptions, MessageIds>({
|
|
27
|
+
name: 'no-timestamp-manipulation',
|
|
28
|
+
meta: {
|
|
29
|
+
type: 'problem',
|
|
30
|
+
docs: {
|
|
31
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/no-timestamp-manipulation.md',
|
|
32
|
+
description:
|
|
33
|
+
'Prevent disabling automatic timestamp generation which enables replay attacks',
|
|
34
|
+
cwe: 'CWE-294',
|
|
35
|
+
cvss: 7.5,
|
|
36
|
+
},
|
|
37
|
+
fixable: undefined,
|
|
38
|
+
hasSuggestions: false,
|
|
39
|
+
messages: {
|
|
40
|
+
timestampDisabled: formatLLMMessage({
|
|
41
|
+
icon: MessageIcons.SECURITY,
|
|
42
|
+
issueName: 'Timestamp Generation Disabled',
|
|
43
|
+
cwe: 'CWE-294',
|
|
44
|
+
description:
|
|
45
|
+
'noTimestamp:true disables iat claim, enabling replay attacks',
|
|
46
|
+
severity: 'HIGH',
|
|
47
|
+
fix: 'Remove noTimestamp option or set to false',
|
|
48
|
+
documentationLink: 'https://securitypattern.com/post/jwt-back-to-the-future',
|
|
49
|
+
}),
|
|
50
|
+
noTimestampTrue: formatLLMMessage({
|
|
51
|
+
icon: MessageIcons.SECURITY,
|
|
52
|
+
issueName: 'Back to the Future Attack Vector',
|
|
53
|
+
cwe: 'CWE-294',
|
|
54
|
+
description:
|
|
55
|
+
'Disabling timestamps allows attackers to forge tokens valid in the future',
|
|
56
|
+
severity: 'HIGH',
|
|
57
|
+
fix: 'Always include iat claim for freshness validation',
|
|
58
|
+
documentationLink: 'https://securitypattern.com/post/jwt-back-to-the-future',
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
schema: [
|
|
62
|
+
{
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
trustedSanitizers: {
|
|
66
|
+
type: 'array',
|
|
67
|
+
items: { type: 'string' },
|
|
68
|
+
default: [],
|
|
69
|
+
},
|
|
70
|
+
trustedAnnotations: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
items: { type: 'string' },
|
|
73
|
+
default: [],
|
|
74
|
+
},
|
|
75
|
+
strictMode: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
default: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
defaultOptions: [
|
|
85
|
+
{
|
|
86
|
+
trustedSanitizers: [],
|
|
87
|
+
trustedAnnotations: [],
|
|
88
|
+
strictMode: false,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
92
|
+
return {
|
|
93
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
94
|
+
if (!isSignOperation(node)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
99
|
+
if (!optionsArg) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check for noTimestamp: true
|
|
104
|
+
if (hasOption(optionsArg, 'noTimestamp')) {
|
|
105
|
+
const value = getOptionValue(optionsArg, 'noTimestamp');
|
|
106
|
+
if (value?.type === 'Literal' && value.value === true) {
|
|
107
|
+
context.report({
|
|
108
|
+
node: value,
|
|
109
|
+
messageId: 'noTimestampTrue',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export default noTimestampManipulation;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for no-timestamp-manipulation rule
|
|
3
|
+
* Security: LightSEC 2025 - "Back to the Future" Attack
|
|
4
|
+
*/
|
|
5
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
6
|
+
import { describe, it, afterAll } from 'vitest';
|
|
7
|
+
import parser from '@typescript-eslint/parser';
|
|
8
|
+
import { noTimestampManipulation } from './index';
|
|
9
|
+
|
|
10
|
+
RuleTester.afterAll = afterAll;
|
|
11
|
+
RuleTester.it = it;
|
|
12
|
+
RuleTester.itOnly = it.only;
|
|
13
|
+
RuleTester.describe = describe;
|
|
14
|
+
|
|
15
|
+
const ruleTester = new RuleTester({
|
|
16
|
+
languageOptions: {
|
|
17
|
+
parser,
|
|
18
|
+
ecmaVersion: 2022,
|
|
19
|
+
sourceType: 'module',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('no-timestamp-manipulation', () => {
|
|
24
|
+
describe('Valid Code', () => {
|
|
25
|
+
ruleTester.run('valid - default timestamps', noTimestampManipulation, {
|
|
26
|
+
valid: [
|
|
27
|
+
'const x = 42;',
|
|
28
|
+
'const flag = true;',
|
|
29
|
+
{ code: `jwt.sign(payload, secret);` },
|
|
30
|
+
{ code: `jwt.sign(payload, secret, {});` },
|
|
31
|
+
{ code: `jwt.sign(payload, secret, { expiresIn: '1h' });` },
|
|
32
|
+
{ code: `jwt.sign(payload, secret, { noTimestamp: false });` },
|
|
33
|
+
{ code: `jwt.verify(token, secret);` }, // verify not checked
|
|
34
|
+
],
|
|
35
|
+
invalid: [],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Invalid Code', () => {
|
|
40
|
+
ruleTester.run('invalid - noTimestamp true', noTimestampManipulation, {
|
|
41
|
+
valid: [],
|
|
42
|
+
invalid: [
|
|
43
|
+
{
|
|
44
|
+
code: `jwt.sign(payload, secret, { noTimestamp: true });`,
|
|
45
|
+
errors: [{ messageId: 'noTimestampTrue' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: `jwt.sign(payload, secret, { algorithm: 'RS256', noTimestamp: true });`,
|
|
49
|
+
errors: [{ messageId: 'noTimestampTrue' }],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: no-weak-secret
|
|
9
|
+
*
|
|
10
|
+
* Detects weak secrets used for HMAC-based JWT signing.
|
|
11
|
+
* Weak secrets can be brute-forced, allowing attackers to forge tokens.
|
|
12
|
+
*
|
|
13
|
+
* CWE-326: Inadequate Encryption Strength
|
|
14
|
+
*
|
|
15
|
+
* @see https://tools.ietf.org/html/rfc8725
|
|
16
|
+
*/
|
|
17
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
18
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
19
|
+
import {
|
|
20
|
+
isSignOperation,
|
|
21
|
+
isVerifyOperation,
|
|
22
|
+
isEnvVariable,
|
|
23
|
+
} from '../../utils';
|
|
24
|
+
import type { NoWeakSecretOptions } from '../../types';
|
|
25
|
+
|
|
26
|
+
type MessageIds =
|
|
27
|
+
| 'weakSecret'
|
|
28
|
+
| 'shortSecret'
|
|
29
|
+
| 'useStrongSecret';
|
|
30
|
+
|
|
31
|
+
type RuleOptions = [NoWeakSecretOptions?];
|
|
32
|
+
|
|
33
|
+
// Common weak secret patterns
|
|
34
|
+
const WEAK_SECRET_PATTERNS = [
|
|
35
|
+
/^secret$/i,
|
|
36
|
+
/^password$/i,
|
|
37
|
+
/^123456/,
|
|
38
|
+
/^test/i,
|
|
39
|
+
/^demo/i,
|
|
40
|
+
/^example/i,
|
|
41
|
+
/^changeme/i,
|
|
42
|
+
/^default/i,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const noWeakSecret = createRule<RuleOptions, MessageIds>({
|
|
46
|
+
name: 'no-weak-secret',
|
|
47
|
+
meta: {
|
|
48
|
+
type: 'problem',
|
|
49
|
+
docs: {
|
|
50
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/no-weak-secret.md',
|
|
51
|
+
description:
|
|
52
|
+
'Require strong secrets (256+ bits) for HMAC-based JWT signing',
|
|
53
|
+
cwe: 'CWE-326',
|
|
54
|
+
cvss: 5.9,
|
|
55
|
+
},
|
|
56
|
+
fixable: undefined,
|
|
57
|
+
hasSuggestions: false,
|
|
58
|
+
messages: {
|
|
59
|
+
weakSecret: formatLLMMessage({
|
|
60
|
+
icon: MessageIcons.SECURITY,
|
|
61
|
+
issueName: 'Weak JWT Secret',
|
|
62
|
+
cwe: 'CWE-326',
|
|
63
|
+
description:
|
|
64
|
+
'JWT secret is too weak and can be brute-forced',
|
|
65
|
+
severity: 'HIGH',
|
|
66
|
+
fix: 'Use crypto.randomBytes(32).toString("hex") for secrets',
|
|
67
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
68
|
+
}),
|
|
69
|
+
shortSecret: formatLLMMessage({
|
|
70
|
+
icon: MessageIcons.SECURITY,
|
|
71
|
+
issueName: 'Short JWT Secret',
|
|
72
|
+
cwe: 'CWE-326',
|
|
73
|
+
description:
|
|
74
|
+
'JWT secret must be at least 32 characters (256 bits) for HS256',
|
|
75
|
+
severity: 'HIGH',
|
|
76
|
+
fix: 'Generate a secret with: crypto.randomBytes(32).toString("hex")',
|
|
77
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
78
|
+
}),
|
|
79
|
+
useStrongSecret: formatLLMMessage({
|
|
80
|
+
icon: MessageIcons.INFO,
|
|
81
|
+
issueName: 'Use Strong Secret',
|
|
82
|
+
description: 'Replace with cryptographically strong secret',
|
|
83
|
+
severity: 'LOW',
|
|
84
|
+
fix: 'Use process.env.JWT_SECRET with a 256-bit random value',
|
|
85
|
+
documentationLink: 'https://nodejs.org/api/crypto.html',
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
schema: [
|
|
89
|
+
{
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
minSecretLength: {
|
|
93
|
+
type: 'integer',
|
|
94
|
+
default: 32,
|
|
95
|
+
minimum: 16,
|
|
96
|
+
description: 'Minimum secret length in characters',
|
|
97
|
+
},
|
|
98
|
+
trustedSanitizers: {
|
|
99
|
+
type: 'array',
|
|
100
|
+
items: { type: 'string' },
|
|
101
|
+
default: [],
|
|
102
|
+
},
|
|
103
|
+
trustedAnnotations: {
|
|
104
|
+
type: 'array',
|
|
105
|
+
items: { type: 'string' },
|
|
106
|
+
default: [],
|
|
107
|
+
},
|
|
108
|
+
strictMode: {
|
|
109
|
+
type: 'boolean',
|
|
110
|
+
default: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
additionalProperties: false,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
defaultOptions: [
|
|
118
|
+
{
|
|
119
|
+
minSecretLength: 32,
|
|
120
|
+
trustedSanitizers: [],
|
|
121
|
+
trustedAnnotations: [],
|
|
122
|
+
strictMode: false,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
126
|
+
const options = context.options[0] ?? {};
|
|
127
|
+
const { minSecretLength = 32 } = options;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a value matches known weak patterns
|
|
131
|
+
*/
|
|
132
|
+
const isKnownWeakPattern = (value: string): boolean => {
|
|
133
|
+
return WEAK_SECRET_PATTERNS.some((pattern) => pattern.test(value));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check the secret argument for weakness
|
|
138
|
+
*/
|
|
139
|
+
const checkSecret = (
|
|
140
|
+
secretNode: TSESTree.Node
|
|
141
|
+
): void => {
|
|
142
|
+
// Environment variables are considered safe (configuration)
|
|
143
|
+
if (isEnvVariable(secretNode)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check string literals
|
|
148
|
+
if (
|
|
149
|
+
secretNode.type === 'Literal' &&
|
|
150
|
+
typeof secretNode.value === 'string'
|
|
151
|
+
) {
|
|
152
|
+
const secretValue = secretNode.value;
|
|
153
|
+
|
|
154
|
+
// Check for known weak patterns
|
|
155
|
+
if (isKnownWeakPattern(secretValue)) {
|
|
156
|
+
context.report({
|
|
157
|
+
node: secretNode,
|
|
158
|
+
messageId: 'weakSecret',
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check for short secrets
|
|
164
|
+
if (secretValue.length < minSecretLength) {
|
|
165
|
+
context.report({
|
|
166
|
+
node: secretNode,
|
|
167
|
+
messageId: 'shortSecret',
|
|
168
|
+
data: {
|
|
169
|
+
length: String(secretValue.length),
|
|
170
|
+
minLength: String(minSecretLength),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Template literals with obvious weak values
|
|
177
|
+
if (secretNode.type === 'TemplateLiteral' && secretNode.quasis.length === 1) {
|
|
178
|
+
const rawValue = secretNode.quasis[0].value.raw;
|
|
179
|
+
if (isKnownWeakPattern(rawValue) || rawValue.length < minSecretLength) {
|
|
180
|
+
context.report({
|
|
181
|
+
node: secretNode,
|
|
182
|
+
messageId: 'weakSecret',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
190
|
+
// Check both sign and verify operations
|
|
191
|
+
if (!isSignOperation(node) && !isVerifyOperation(node)) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Secret is usually the second argument
|
|
196
|
+
if (node.arguments.length >= 2) {
|
|
197
|
+
checkSecret(node.arguments[1]);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export default noWeakSecret;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for no-weak-secret rule
|
|
3
|
+
* Security: CWE-326 (Encryption Strength)
|
|
4
|
+
*/
|
|
5
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
6
|
+
import { describe, it, afterAll } from 'vitest';
|
|
7
|
+
import parser from '@typescript-eslint/parser';
|
|
8
|
+
import { noWeakSecret } from './index';
|
|
9
|
+
|
|
10
|
+
RuleTester.afterAll = afterAll;
|
|
11
|
+
RuleTester.it = it;
|
|
12
|
+
RuleTester.itOnly = it.only;
|
|
13
|
+
RuleTester.describe = describe;
|
|
14
|
+
|
|
15
|
+
const ruleTester = new RuleTester({
|
|
16
|
+
languageOptions: {
|
|
17
|
+
parser,
|
|
18
|
+
ecmaVersion: 2022,
|
|
19
|
+
sourceType: 'module',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// 32+ character strong secret for testing
|
|
24
|
+
const STRONG_SECRET = 'ThisIsAVeryStrongSecretThatIs32Ch+';
|
|
25
|
+
|
|
26
|
+
describe('no-weak-secret', () => {
|
|
27
|
+
describe('Valid Code - Strong Secrets', () => {
|
|
28
|
+
ruleTester.run('valid - strong secrets', noWeakSecret, {
|
|
29
|
+
valid: [
|
|
30
|
+
// Environment variable
|
|
31
|
+
{ code: `jwt.sign(payload, process.env.JWT_SECRET);` },
|
|
32
|
+
// Long secret (32+ chars)
|
|
33
|
+
{ code: `jwt.sign(payload, '${STRONG_SECRET}');` },
|
|
34
|
+
// Non-literal (function call)
|
|
35
|
+
{ code: `jwt.sign(payload, getSecret());` },
|
|
36
|
+
// Variable reference
|
|
37
|
+
{ code: `jwt.sign(payload, secret);` },
|
|
38
|
+
// crypto.randomBytes
|
|
39
|
+
{ code: `jwt.sign(payload, crypto.randomBytes(32).toString('hex'));` },
|
|
40
|
+
// Verify with env
|
|
41
|
+
{ code: `jwt.verify(token, process.env.JWT_SECRET);` },
|
|
42
|
+
],
|
|
43
|
+
invalid: [],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Invalid Code - Weak Secrets', () => {
|
|
48
|
+
ruleTester.run('invalid - weak secrets', noWeakSecret, {
|
|
49
|
+
valid: [],
|
|
50
|
+
invalid: [
|
|
51
|
+
// Known weak pattern: "secret"
|
|
52
|
+
{
|
|
53
|
+
code: `jwt.sign(payload, 'secret');`,
|
|
54
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
55
|
+
},
|
|
56
|
+
// Known weak pattern: "password"
|
|
57
|
+
{
|
|
58
|
+
code: `jwt.sign(payload, 'password');`,
|
|
59
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
60
|
+
},
|
|
61
|
+
// Short secret
|
|
62
|
+
{
|
|
63
|
+
code: `jwt.sign(payload, 'short');`,
|
|
64
|
+
errors: [{ messageId: 'shortSecret' }],
|
|
65
|
+
},
|
|
66
|
+
// Just under minimum (31 chars)
|
|
67
|
+
{
|
|
68
|
+
code: `jwt.sign(payload, 'abcdefghijklmnopqrstuvwxyz12345');`,
|
|
69
|
+
errors: [{ messageId: 'shortSecret' }],
|
|
70
|
+
},
|
|
71
|
+
// Known weak: test
|
|
72
|
+
{
|
|
73
|
+
code: `jwt.sign(payload, 'testkey');`,
|
|
74
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
75
|
+
},
|
|
76
|
+
// Known weak: demo
|
|
77
|
+
{
|
|
78
|
+
code: `jwt.verify(token, 'demo123');`,
|
|
79
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
80
|
+
},
|
|
81
|
+
// Template literal with weak value
|
|
82
|
+
{
|
|
83
|
+
code: 'jwt.sign(payload, `secret`);',
|
|
84
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
85
|
+
},
|
|
86
|
+
// Known weak: changeme
|
|
87
|
+
{
|
|
88
|
+
code: `jwt.sign(payload, 'changeme');`,
|
|
89
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('Configuration Options', () => {
|
|
96
|
+
ruleTester.run('config - custom min length', noWeakSecret, {
|
|
97
|
+
valid: [
|
|
98
|
+
// 16 char secret with minSecretLength: 16
|
|
99
|
+
{
|
|
100
|
+
code: `jwt.sign(payload, 'sixteen_chars!!!');`,
|
|
101
|
+
options: [{ minSecretLength: 16 }],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
invalid: [
|
|
105
|
+
// 15 chars with minSecretLength: 16
|
|
106
|
+
{
|
|
107
|
+
code: `jwt.sign(payload, 'fifteen_chars!!');`,
|
|
108
|
+
options: [{ minSecretLength: 16 }],
|
|
109
|
+
errors: [{ messageId: 'shortSecret' }],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: require-algorithm-whitelist
|
|
9
|
+
*
|
|
10
|
+
* Enforces explicit algorithm specification in JWT verify operations.
|
|
11
|
+
* Without explicit algorithms, the token's header algorithm is trusted,
|
|
12
|
+
* enabling algorithm substitution attacks.
|
|
13
|
+
*
|
|
14
|
+
* CWE-757: Selection of Less-Secure Algorithm During Negotiation
|
|
15
|
+
*
|
|
16
|
+
* @see https://tools.ietf.org/html/rfc8725
|
|
17
|
+
*/
|
|
18
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
19
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
20
|
+
import {
|
|
21
|
+
isVerifyOperation,
|
|
22
|
+
getOptionsArgument,
|
|
23
|
+
hasOption,
|
|
24
|
+
} from '../../utils';
|
|
25
|
+
import type { RequireAlgorithmWhitelistOptions } from '../../types';
|
|
26
|
+
|
|
27
|
+
type MessageIds =
|
|
28
|
+
| 'missingAlgorithmWhitelist'
|
|
29
|
+
| 'addAlgorithmWhitelist';
|
|
30
|
+
|
|
31
|
+
type RuleOptions = [RequireAlgorithmWhitelistOptions?];
|
|
32
|
+
|
|
33
|
+
export const requireAlgorithmWhitelist = createRule<RuleOptions, MessageIds>({
|
|
34
|
+
name: 'require-algorithm-whitelist',
|
|
35
|
+
meta: {
|
|
36
|
+
type: 'problem',
|
|
37
|
+
docs: {
|
|
38
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-algorithm-whitelist.md',
|
|
39
|
+
description:
|
|
40
|
+
'Require explicit algorithm specification in JWT verify operations',
|
|
41
|
+
cwe: 'CWE-757',
|
|
42
|
+
cvss: 7.5,
|
|
43
|
+
},
|
|
44
|
+
fixable: undefined,
|
|
45
|
+
hasSuggestions: false,
|
|
46
|
+
messages: {
|
|
47
|
+
missingAlgorithmWhitelist: formatLLMMessage({
|
|
48
|
+
icon: MessageIcons.SECURITY,
|
|
49
|
+
issueName: 'Missing Algorithm Whitelist',
|
|
50
|
+
cwe: 'CWE-757',
|
|
51
|
+
description:
|
|
52
|
+
'JWT verification without explicit algorithms trusts the token header',
|
|
53
|
+
severity: 'HIGH',
|
|
54
|
+
fix: 'Add algorithms: ["RS256"] or algorithms: ["ES256"] to options',
|
|
55
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
56
|
+
}),
|
|
57
|
+
addAlgorithmWhitelist: formatLLMMessage({
|
|
58
|
+
icon: MessageIcons.INFO,
|
|
59
|
+
issueName: 'Add Algorithm Whitelist',
|
|
60
|
+
description: 'Specify allowed algorithms explicitly',
|
|
61
|
+
severity: 'LOW',
|
|
62
|
+
fix: 'jwt.verify(token, key, { algorithms: ["RS256"] })',
|
|
63
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
schema: [
|
|
67
|
+
{
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
recommendedAlgorithms: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
items: { type: 'string' },
|
|
73
|
+
default: ['RS256', 'ES256'],
|
|
74
|
+
description: 'Algorithms to suggest in auto-fix',
|
|
75
|
+
},
|
|
76
|
+
trustedSanitizers: {
|
|
77
|
+
type: 'array',
|
|
78
|
+
items: { type: 'string' },
|
|
79
|
+
default: [],
|
|
80
|
+
},
|
|
81
|
+
trustedAnnotations: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
items: { type: 'string' },
|
|
84
|
+
default: [],
|
|
85
|
+
},
|
|
86
|
+
strictMode: {
|
|
87
|
+
type: 'boolean',
|
|
88
|
+
default: false,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
defaultOptions: [
|
|
96
|
+
{
|
|
97
|
+
recommendedAlgorithms: ['RS256', 'ES256'],
|
|
98
|
+
trustedSanitizers: [],
|
|
99
|
+
trustedAnnotations: [],
|
|
100
|
+
strictMode: false,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
107
|
+
// Only check verify operations
|
|
108
|
+
if (!isVerifyOperation(node)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Need at least token and key arguments
|
|
113
|
+
if (node.arguments.length < 2) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
118
|
+
|
|
119
|
+
// No options at all - definitely missing algorithms
|
|
120
|
+
if (!optionsArg) {
|
|
121
|
+
context.report({
|
|
122
|
+
node,
|
|
123
|
+
messageId: 'missingAlgorithmWhitelist',
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Options exist but no algorithms property
|
|
129
|
+
const hasAlgorithms =
|
|
130
|
+
hasOption(optionsArg, 'algorithms') ||
|
|
131
|
+
hasOption(optionsArg, 'algorithm') ||
|
|
132
|
+
hasOption(optionsArg, 'alg');
|
|
133
|
+
|
|
134
|
+
if (!hasAlgorithms) {
|
|
135
|
+
context.report({
|
|
136
|
+
node: optionsArg,
|
|
137
|
+
messageId: 'missingAlgorithmWhitelist',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export default requireAlgorithmWhitelist;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-algorithm-whitelist rule
|
|
3
|
+
*/
|
|
4
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
5
|
+
import { describe, it, afterAll } from 'vitest';
|
|
6
|
+
import parser from '@typescript-eslint/parser';
|
|
7
|
+
import { requireAlgorithmWhitelist } from './index';
|
|
8
|
+
|
|
9
|
+
RuleTester.afterAll = afterAll;
|
|
10
|
+
RuleTester.it = it;
|
|
11
|
+
RuleTester.itOnly = it.only;
|
|
12
|
+
RuleTester.describe = describe;
|
|
13
|
+
|
|
14
|
+
const ruleTester = new RuleTester({
|
|
15
|
+
languageOptions: {
|
|
16
|
+
parser,
|
|
17
|
+
ecmaVersion: 2022,
|
|
18
|
+
sourceType: 'module',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('require-algorithm-whitelist', () => {
|
|
23
|
+
describe('Valid Code', () => {
|
|
24
|
+
ruleTester.run('valid - with algorithms', requireAlgorithmWhitelist, {
|
|
25
|
+
valid: [
|
|
26
|
+
{ code: `jwt.verify(token, secret, { algorithms: ['RS256'] });` },
|
|
27
|
+
{ code: `jwt.verify(token, secret, { algorithms: ['RS256', 'ES256'] });` },
|
|
28
|
+
{ code: `jwt.verify(token, secret, { algorithm: 'RS256' });` },
|
|
29
|
+
{ code: `jwt.verify(token, secret, { alg: 'RS256' });` },
|
|
30
|
+
{ code: `jwt.sign(payload, secret);` }, // sign not checked
|
|
31
|
+
// Only one argument - edge case
|
|
32
|
+
{ code: `jwt.verify(token);` },
|
|
33
|
+
// jwtVerify with algorithms
|
|
34
|
+
{ code: `jwtVerify(token, key, { algorithms: ['RS256'] });` },
|
|
35
|
+
],
|
|
36
|
+
invalid: [],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Invalid Code', () => {
|
|
41
|
+
ruleTester.run('invalid - no algorithms', requireAlgorithmWhitelist, {
|
|
42
|
+
valid: [],
|
|
43
|
+
invalid: [
|
|
44
|
+
{
|
|
45
|
+
code: `jwt.verify(token, secret);`,
|
|
46
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: `jwt.verify(token, secret, {});`,
|
|
50
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
code: `jwt.verify(token, secret, { complete: true });`,
|
|
54
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
code: `jwt.verify(token, secret, { issuer: 'auth.example.com' });`,
|
|
58
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
code: `jwtVerify(token, key);`,
|
|
62
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
63
|
+
},
|
|
64
|
+
// verifyJWT without algorithms
|
|
65
|
+
{
|
|
66
|
+
code: `verifyJWT(token, key, { issuer: 'auth.example.com' });`,
|
|
67
|
+
errors: [{ messageId: 'missingAlgorithmWhitelist' }],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|