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,120 @@
|
|
|
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-issuer-validation
|
|
9
|
+
*
|
|
10
|
+
* Mandates issuer (iss) claim validation in JWT verify operations.
|
|
11
|
+
* Without issuer validation, tokens from any issuer are accepted.
|
|
12
|
+
*
|
|
13
|
+
* CWE-287: Improper Authentication
|
|
14
|
+
*/
|
|
15
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
16
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
17
|
+
import { isVerifyOperation, getOptionsArgument, hasOption } from '../../utils';
|
|
18
|
+
import type { RequireIssuerValidationOptions } from '../../types';
|
|
19
|
+
|
|
20
|
+
type MessageIds = 'missingIssuerValidation';
|
|
21
|
+
|
|
22
|
+
type RuleOptions = [RequireIssuerValidationOptions?];
|
|
23
|
+
|
|
24
|
+
export const requireIssuerValidation = createRule<RuleOptions, MessageIds>({
|
|
25
|
+
name: 'require-issuer-validation',
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'problem',
|
|
28
|
+
docs: {
|
|
29
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-issuer-validation.md',
|
|
30
|
+
description:
|
|
31
|
+
'Require issuer (iss) claim validation in JWT verify operations',
|
|
32
|
+
cwe: 'CWE-287',
|
|
33
|
+
cvss: 9.8,
|
|
34
|
+
},
|
|
35
|
+
fixable: undefined,
|
|
36
|
+
hasSuggestions: false,
|
|
37
|
+
messages: {
|
|
38
|
+
missingIssuerValidation: formatLLMMessage({
|
|
39
|
+
icon: MessageIcons.SECURITY,
|
|
40
|
+
issueName: 'Missing Issuer Validation',
|
|
41
|
+
cwe: 'CWE-287',
|
|
42
|
+
description:
|
|
43
|
+
'JWT verification without issuer validation accepts tokens from any source',
|
|
44
|
+
severity: 'MEDIUM',
|
|
45
|
+
fix: 'Add issuer option: { issuer: "https://auth.example.com" }',
|
|
46
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
schema: [
|
|
50
|
+
{
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
knownIssuers: {
|
|
54
|
+
type: 'array',
|
|
55
|
+
items: { type: 'string' },
|
|
56
|
+
default: [],
|
|
57
|
+
description: 'Known valid issuers to suggest',
|
|
58
|
+
},
|
|
59
|
+
trustedSanitizers: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
items: { type: 'string' },
|
|
62
|
+
default: [],
|
|
63
|
+
},
|
|
64
|
+
trustedAnnotations: {
|
|
65
|
+
type: 'array',
|
|
66
|
+
items: { type: 'string' },
|
|
67
|
+
default: [],
|
|
68
|
+
},
|
|
69
|
+
strictMode: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
default: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
defaultOptions: [
|
|
79
|
+
{
|
|
80
|
+
knownIssuers: [],
|
|
81
|
+
trustedSanitizers: [],
|
|
82
|
+
trustedAnnotations: [],
|
|
83
|
+
strictMode: false,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
87
|
+
return {
|
|
88
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
89
|
+
if (!isVerifyOperation(node)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (node.arguments.length < 2) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
98
|
+
|
|
99
|
+
// No options at all
|
|
100
|
+
if (!optionsArg) {
|
|
101
|
+
context.report({
|
|
102
|
+
node,
|
|
103
|
+
messageId: 'missingIssuerValidation',
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for issuer option
|
|
109
|
+
if (!hasOption(optionsArg, 'issuer') && !hasOption(optionsArg, 'iss')) {
|
|
110
|
+
context.report({
|
|
111
|
+
node: optionsArg,
|
|
112
|
+
messageId: 'missingIssuerValidation',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export default requireIssuerValidation;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-issuer-validation 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 { requireIssuerValidation } 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-issuer-validation', () => {
|
|
23
|
+
describe('Valid Code', () => {
|
|
24
|
+
ruleTester.run('valid - with issuer', requireIssuerValidation, {
|
|
25
|
+
valid: [
|
|
26
|
+
{ code: `jwt.verify(token, secret, { issuer: 'https://auth.example.com' });` },
|
|
27
|
+
{ code: `jwt.verify(token, secret, { iss: 'auth.example.com' });` },
|
|
28
|
+
{ code: `jwt.verify(token, secret, { issuer: 'auth', algorithms: ['RS256'] });` },
|
|
29
|
+
{ code: `jwt.sign(payload, secret);` }, // sign not checked
|
|
30
|
+
// Only one argument - edge case
|
|
31
|
+
{ code: `jwt.verify(token);` },
|
|
32
|
+
// jwtVerify with issuer
|
|
33
|
+
{ code: `jwtVerify(token, key, { issuer: 'https://auth.example.com' });` },
|
|
34
|
+
],
|
|
35
|
+
invalid: [],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Invalid Code', () => {
|
|
40
|
+
ruleTester.run('invalid - missing issuer', requireIssuerValidation, {
|
|
41
|
+
valid: [],
|
|
42
|
+
invalid: [
|
|
43
|
+
{
|
|
44
|
+
code: `jwt.verify(token, secret);`,
|
|
45
|
+
errors: [{ messageId: 'missingIssuerValidation' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: `jwt.verify(token, secret, {});`,
|
|
49
|
+
errors: [{ messageId: 'missingIssuerValidation' }],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
code: `jwt.verify(token, secret, { algorithms: ['RS256'] });`,
|
|
53
|
+
errors: [{ messageId: 'missingIssuerValidation' }],
|
|
54
|
+
},
|
|
55
|
+
// jwtVerify without issuer
|
|
56
|
+
{
|
|
57
|
+
code: `jwtVerify(token, key);`,
|
|
58
|
+
errors: [{ messageId: 'missingIssuerValidation' }],
|
|
59
|
+
},
|
|
60
|
+
// jwtVerify with empty options
|
|
61
|
+
{
|
|
62
|
+
code: `jwtVerify(token, key, {});`,
|
|
63
|
+
errors: [{ messageId: 'missingIssuerValidation' }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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-max-age
|
|
9
|
+
*
|
|
10
|
+
* Mandates maxAge option in JWT verify operations to enforce token freshness.
|
|
11
|
+
* This prevents the "Back to the Future" replay attack where tokens are
|
|
12
|
+
* captured and replayed months or years later.
|
|
13
|
+
*
|
|
14
|
+
* CWE-294: Authentication Bypass by Capture-replay
|
|
15
|
+
*
|
|
16
|
+
* @see https://securitypattern.com/post/jwt-back-to-the-future
|
|
17
|
+
* @see LightSEC 2025 - "JWT Back to the future: On the (ab)use of JWTs"
|
|
18
|
+
*/
|
|
19
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
20
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
21
|
+
import { isVerifyOperation, getOptionsArgument, hasOption } from '../../utils';
|
|
22
|
+
import type { JwtRuleOptions } from '../../types';
|
|
23
|
+
|
|
24
|
+
type MessageIds = 'missingMaxAge';
|
|
25
|
+
|
|
26
|
+
type RuleOptions = [JwtRuleOptions?];
|
|
27
|
+
|
|
28
|
+
export const requireMaxAge = createRule<RuleOptions, MessageIds>({
|
|
29
|
+
name: 'require-max-age',
|
|
30
|
+
meta: {
|
|
31
|
+
type: 'problem',
|
|
32
|
+
docs: {
|
|
33
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-max-age.md',
|
|
34
|
+
description:
|
|
35
|
+
'Require maxAge option in verify operations to enforce token freshness',
|
|
36
|
+
cwe: 'CWE-294',
|
|
37
|
+
cvss: 5,
|
|
38
|
+
},
|
|
39
|
+
fixable: undefined,
|
|
40
|
+
hasSuggestions: false,
|
|
41
|
+
messages: {
|
|
42
|
+
missingMaxAge: formatLLMMessage({
|
|
43
|
+
icon: MessageIcons.SECURITY,
|
|
44
|
+
issueName: 'Missing Token Age Validation',
|
|
45
|
+
cwe: 'CWE-294',
|
|
46
|
+
description:
|
|
47
|
+
'Without maxAge, tokens can be replayed long after issuance (Back to the Future attack)',
|
|
48
|
+
severity: 'MEDIUM',
|
|
49
|
+
fix: 'Add maxAge option: { maxAge: "1h" } to limit token lifetime',
|
|
50
|
+
documentationLink: 'https://securitypattern.com/post/jwt-back-to-the-future',
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
schema: [
|
|
54
|
+
{
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
trustedSanitizers: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
default: [],
|
|
61
|
+
},
|
|
62
|
+
trustedAnnotations: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: { type: 'string' },
|
|
65
|
+
default: [],
|
|
66
|
+
},
|
|
67
|
+
strictMode: {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
default: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
defaultOptions: [
|
|
77
|
+
{
|
|
78
|
+
trustedSanitizers: [],
|
|
79
|
+
trustedAnnotations: [],
|
|
80
|
+
strictMode: false,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
84
|
+
return {
|
|
85
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
86
|
+
if (!isVerifyOperation(node)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (node.arguments.length < 2) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
95
|
+
|
|
96
|
+
// No options at all
|
|
97
|
+
if (!optionsArg) {
|
|
98
|
+
context.report({
|
|
99
|
+
node,
|
|
100
|
+
messageId: 'missingMaxAge',
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check for maxAge option
|
|
106
|
+
if (!hasOption(optionsArg, 'maxAge') && !hasOption(optionsArg, 'clockTolerance')) {
|
|
107
|
+
context.report({
|
|
108
|
+
node: optionsArg,
|
|
109
|
+
messageId: 'missingMaxAge',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export default requireMaxAge;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-max-age rule
|
|
3
|
+
* Security: LightSEC 2025 - Replay Attack Prevention
|
|
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 { requireMaxAge } 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('require-max-age', () => {
|
|
24
|
+
describe('Valid Code', () => {
|
|
25
|
+
ruleTester.run('valid - with maxAge', requireMaxAge, {
|
|
26
|
+
valid: [
|
|
27
|
+
{ code: `jwt.verify(token, secret, { maxAge: '1h' });` },
|
|
28
|
+
{ code: `jwt.verify(token, secret, { maxAge: 3600 });` },
|
|
29
|
+
{ code: `jwt.verify(token, secret, { clockTolerance: 30 });` },
|
|
30
|
+
{ code: `jwt.sign(payload, secret);` }, // sign not checked
|
|
31
|
+
// Only one argument - edge case
|
|
32
|
+
{ code: `jwt.verify(token);` },
|
|
33
|
+
// jwtVerify with maxAge
|
|
34
|
+
{ code: `jwtVerify(token, key, { maxAge: '24h' });` },
|
|
35
|
+
],
|
|
36
|
+
invalid: [],
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Invalid Code', () => {
|
|
41
|
+
ruleTester.run('invalid - missing maxAge', requireMaxAge, {
|
|
42
|
+
valid: [],
|
|
43
|
+
invalid: [
|
|
44
|
+
{
|
|
45
|
+
code: `jwt.verify(token, secret);`,
|
|
46
|
+
errors: [{ messageId: 'missingMaxAge' }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
code: `jwt.verify(token, secret, {});`,
|
|
50
|
+
errors: [{ messageId: 'missingMaxAge' }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
code: `jwt.verify(token, secret, { algorithms: ['RS256'] });`,
|
|
54
|
+
errors: [{ messageId: 'missingMaxAge' }],
|
|
55
|
+
},
|
|
56
|
+
// jwtVerify without maxAge
|
|
57
|
+
{
|
|
58
|
+
code: `jwtVerify(token, key);`,
|
|
59
|
+
errors: [{ messageId: 'missingMaxAge' }],
|
|
60
|
+
},
|
|
61
|
+
// verifyJWT without maxAge
|
|
62
|
+
{
|
|
63
|
+
code: `verifyJWT(token, key, { algorithms: ['RS256'] });`,
|
|
64
|
+
errors: [{ messageId: 'missingMaxAge' }],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
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
|
+
* Dual-layer coverage tests — closes the remaining uncovered branches.
|
|
9
|
+
*
|
|
10
|
+
* Layer 1: RuleTester fixtures through the real parser.
|
|
11
|
+
* Layer 2: raw unit tests — helpers called as plain functions, plus
|
|
12
|
+
* createWithMockContext (from @interlace/eslint-devkit) invoking
|
|
13
|
+
* rule listeners directly with synthetic AST nodes for
|
|
14
|
+
* parser-unreachable branches.
|
|
15
|
+
*/
|
|
16
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
17
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
18
|
+
import parser from '@typescript-eslint/parser';
|
|
19
|
+
import type { TSESTree } from '@interlace/eslint-devkit';
|
|
20
|
+
import { createWithMockContext } from '@interlace/eslint-devkit';
|
|
21
|
+
import { noAlgorithmConfusion } from '../rules/no-algorithm-confusion';
|
|
22
|
+
import { noAlgorithmNone } from '../rules/no-algorithm-none';
|
|
23
|
+
import { noDecodeWithoutVerify } from '../rules/no-decode-without-verify';
|
|
24
|
+
import { noHardcodedSecret } from '../rules/no-hardcoded-secret';
|
|
25
|
+
import { noSensitivePayload } from '../rules/no-sensitive-payload';
|
|
26
|
+
import { noWeakSecret } from '../rules/no-weak-secret';
|
|
27
|
+
import { extractAlgorithms } from '../utils';
|
|
28
|
+
|
|
29
|
+
RuleTester.afterAll = afterAll;
|
|
30
|
+
RuleTester.it = it;
|
|
31
|
+
RuleTester.itOnly = it.only;
|
|
32
|
+
RuleTester.describe = describe;
|
|
33
|
+
|
|
34
|
+
const ruleTester = new RuleTester({
|
|
35
|
+
languageOptions: {
|
|
36
|
+
parser,
|
|
37
|
+
ecmaVersion: 2022,
|
|
38
|
+
sourceType: 'module',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Layer 1 — no-algorithm-confusion: property-loop guards (spread + computed
|
|
44
|
+
// key before the `algorithms` entry) still land the report on the right node.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('coverage: no-algorithm-confusion property-loop guards', () => {
|
|
47
|
+
ruleTester.run('spread and computed props before algorithms', noAlgorithmConfusion, {
|
|
48
|
+
valid: [],
|
|
49
|
+
invalid: [
|
|
50
|
+
{
|
|
51
|
+
// SpreadElement (prop.type !== 'Property') and computed key
|
|
52
|
+
// (prop.key.type !== 'Identifier') are skipped; the report fires on
|
|
53
|
+
// the `algorithms` property value.
|
|
54
|
+
code: `jwt.verify(token, publicKey, { ...baseOpts, ['audit']: true, algorithms: ['HS256'] });`,
|
|
55
|
+
errors: [{ messageId: 'algorithmConfusion' }],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Layer 1 — no-algorithm-none: the isDecodeCall() detector.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
describe('coverage: no-algorithm-none decode detection', () => {
|
|
65
|
+
ruleTester.run('isDecodeCall branches', noAlgorithmNone, {
|
|
66
|
+
valid: [
|
|
67
|
+
// object name is neither `jwt` nor `jsonwebtoken` -> not a decode call
|
|
68
|
+
{ code: `myParser.decode(data);` },
|
|
69
|
+
// callee object is itself a MemberExpression -> returns false
|
|
70
|
+
{ code: `api.jwt.decode(token);` },
|
|
71
|
+
// computed property (Literal, not Identifier) -> returns false
|
|
72
|
+
{ code: `jwt["decode"](token);` },
|
|
73
|
+
],
|
|
74
|
+
invalid: [
|
|
75
|
+
// jwt.decode() is flagged as equivalent to algorithm:"none"
|
|
76
|
+
{
|
|
77
|
+
code: `jwt.decode(token);`,
|
|
78
|
+
errors: [{ messageId: 'decodeWithoutVerify' }],
|
|
79
|
+
},
|
|
80
|
+
// jsonwebtoken.decode() — second operand of the name check
|
|
81
|
+
{
|
|
82
|
+
code: `jsonwebtoken.decode(token);`,
|
|
83
|
+
errors: [{ messageId: 'decodeWithoutVerify' }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Layer 1 — no-decode-without-verify: safe-annotation suppression (previously
|
|
91
|
+
// hidden behind c8-ignore; a plain leading comment through the real parser
|
|
92
|
+
// exercises hasSafeAnnotation for both the jwt.decode and jwt_decode paths).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
describe('coverage: no-decode-without-verify safe annotations', () => {
|
|
95
|
+
ruleTester.run('trusted annotations suppress the report', noDecodeWithoutVerify, {
|
|
96
|
+
valid: [
|
|
97
|
+
{
|
|
98
|
+
// custom trustedAnnotations option suppresses the jwt.decode report
|
|
99
|
+
code: `// @decoded-header-only
|
|
100
|
+
const header = jwt.decode(token);`,
|
|
101
|
+
options: [{ trustedAnnotations: ['@decoded-header-only'] }],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
// built-in devkit SAFE_ANNOTATIONS ('@verified') suppresses jwt_decode
|
|
105
|
+
code: `/* @verified-separately */
|
|
106
|
+
const payload = jwt_decode(token);`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
invalid: [
|
|
110
|
+
// Unrelated leading comment does NOT suppress — annotation walk runs
|
|
111
|
+
// and falls through to the report.
|
|
112
|
+
{
|
|
113
|
+
code: `// TODO: refactor later
|
|
114
|
+
const payload = jwt.decode(token);`,
|
|
115
|
+
errors: [{ messageId: 'decodeWithoutVerify' }],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
code: `// just a note
|
|
119
|
+
const payload = jwt_decode(token);`,
|
|
120
|
+
errors: [{ messageId: 'jwtDecodeLibrary' }],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Layer 1 — no-hardcoded-secret: resolveConstLiteralValue() through the real
|
|
128
|
+
// scope manager.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
describe('coverage: no-hardcoded-secret const-literal resolution', () => {
|
|
131
|
+
ruleTester.run('single-frame const indirection', noHardcodedSecret, {
|
|
132
|
+
valid: [
|
|
133
|
+
// `let` binding is never resolved (kind !== 'const') -> treated safe
|
|
134
|
+
{ code: `let MUTABLE_SECRET = 'abc'; jwt.sign(payload, MUTABLE_SECRET);` },
|
|
135
|
+
// function parameter: def.type is 'Parameter', not 'Variable' -> safe
|
|
136
|
+
{ code: `function issue(SECRET) { return jwt.sign(payload, SECRET); }` },
|
|
137
|
+
// unresolved global identifier: no def at all -> safe
|
|
138
|
+
{ code: `jwt.sign(payload, GLOBAL_SECRET);` },
|
|
139
|
+
// for-of const has no initializer: `def.node.init ?? null` -> null -> safe
|
|
140
|
+
{ code: `for (const SECRET of secretList) { jwt.sign(payload, SECRET); }` },
|
|
141
|
+
// const resolved to a non-literal initializer (call) -> safe
|
|
142
|
+
{ code: `const KEY = loadKey(); jwt.sign(payload, KEY);` },
|
|
143
|
+
// non-identifier, non-literal secret arg: resolveConstLiteralValue()
|
|
144
|
+
// gets a non-Identifier node via isHardcodedStringOrResolvedConst -> null
|
|
145
|
+
{ code: `jwt.sign(payload, [42]);` },
|
|
146
|
+
],
|
|
147
|
+
invalid: [
|
|
148
|
+
// const-hidden string literal is resolved and flagged
|
|
149
|
+
{
|
|
150
|
+
code: `const SECRET = 'my-hardcoded-secret'; jwt.sign(payload, SECRET);`,
|
|
151
|
+
errors: [{ messageId: 'hardcodedSecret' }],
|
|
152
|
+
},
|
|
153
|
+
// const-hidden expressionless template literal is resolved and flagged
|
|
154
|
+
{
|
|
155
|
+
code: 'const SECRET = `template-secret`; jwt.sign(payload, SECRET);',
|
|
156
|
+
errors: [{ messageId: 'hardcodedSecret' }],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Layer 1 — no-sensitive-payload: property-loop guards.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
describe('coverage: no-sensitive-payload property-loop guards', () => {
|
|
166
|
+
ruleTester.run('spread and non-identifier keys are skipped', noSensitivePayload, {
|
|
167
|
+
valid: [
|
|
168
|
+
// SpreadElement (prop.type !== 'Property') and string-literal key
|
|
169
|
+
// (prop.key.type !== 'Identifier') are both skipped without reporting.
|
|
170
|
+
{
|
|
171
|
+
code: `jwt.sign({ ...claims, 'display-name': name, role: 'admin' }, process.env.JWT_SECRET);`,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
invalid: [],
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Layer 1 — no-weak-secret: template-literal arms + missing-secret guard.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
describe('coverage: no-weak-secret template literals and arg guard', () => {
|
|
182
|
+
ruleTester.run('template secret arms', noWeakSecret, {
|
|
183
|
+
valid: [
|
|
184
|
+
// sign() with a single argument: secret check is skipped entirely
|
|
185
|
+
{ code: `jwt.sign(payload);` },
|
|
186
|
+
// strong 34-char template literal: neither weak pattern nor short
|
|
187
|
+
{ code: 'jwt.sign(payload, `ThisIsAVeryStrongSecretThatIs32Ch+`);' },
|
|
188
|
+
],
|
|
189
|
+
invalid: [
|
|
190
|
+
// known weak pattern in a template literal (left arm true)
|
|
191
|
+
{
|
|
192
|
+
code: 'jwt.sign(payload, `secret`);',
|
|
193
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
194
|
+
},
|
|
195
|
+
// short but not a known pattern (left arm false, right arm true)
|
|
196
|
+
{
|
|
197
|
+
code: 'jwt.sign(payload, `xy9z`);',
|
|
198
|
+
errors: [{ messageId: 'weakSecret' }],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Layer 2 — utils.extractAlgorithms: sparse-array holes and non-string
|
|
206
|
+
// elements inside the algorithms array are skipped.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
describe('coverage (unit): extractAlgorithms skips holes and non-strings', () => {
|
|
209
|
+
it('ignores null elements and non-string literals', () => {
|
|
210
|
+
const optionsNode = {
|
|
211
|
+
type: 'ObjectExpression',
|
|
212
|
+
properties: [
|
|
213
|
+
{
|
|
214
|
+
type: 'Property',
|
|
215
|
+
key: { type: 'Identifier', name: 'algorithms' },
|
|
216
|
+
value: {
|
|
217
|
+
type: 'ArrayExpression',
|
|
218
|
+
elements: [
|
|
219
|
+
null, // sparse-array hole
|
|
220
|
+
{ type: 'Literal', value: 42 }, // non-string literal
|
|
221
|
+
{ type: 'Literal', value: 'RS256' },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
} as unknown as TSESTree.ObjectExpression;
|
|
227
|
+
|
|
228
|
+
expect(extractAlgorithms(optionsNode)).toEqual(['RS256']);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Layer 2 — no-hardcoded-secret: parser-unreachable guards inside
|
|
234
|
+
// resolveConstLiteralValue(). A real ESLint context always exposes
|
|
235
|
+
// sourceCode.getScope, and a scope-manager 'Variable' def always hangs off a
|
|
236
|
+
// VariableDeclaration parent — these defensive branches need a mock context
|
|
237
|
+
// and synthetic scope objects.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
describe('coverage (unit): no-hardcoded-secret defensive scope guards', () => {
|
|
240
|
+
const makeSignCall = (
|
|
241
|
+
secretArg: Record<string, unknown>,
|
|
242
|
+
): TSESTree.CallExpression =>
|
|
243
|
+
({
|
|
244
|
+
type: 'CallExpression',
|
|
245
|
+
callee: {
|
|
246
|
+
type: 'MemberExpression',
|
|
247
|
+
object: { type: 'Identifier', name: 'jwt' },
|
|
248
|
+
property: { type: 'Identifier', name: 'sign' },
|
|
249
|
+
computed: false,
|
|
250
|
+
},
|
|
251
|
+
arguments: [{ type: 'ObjectExpression', properties: [] }, secretArg],
|
|
252
|
+
}) as unknown as TSESTree.CallExpression;
|
|
253
|
+
|
|
254
|
+
it('treats the secret as unresolvable when sourceCode.getScope is absent', () => {
|
|
255
|
+
const { listeners, reports, context } = createWithMockContext(noHardcodedSecret);
|
|
256
|
+
(context.sourceCode as { getScope?: unknown }).getScope = undefined;
|
|
257
|
+
|
|
258
|
+
const secretId = { type: 'Identifier', name: 'SECRET' };
|
|
259
|
+
(listeners.CallExpression as (n: TSESTree.CallExpression) => void)(
|
|
260
|
+
makeSignCall(secretId),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// getScope?.() -> undefined -> ?? null -> early null: identifier cannot
|
|
264
|
+
// be resolved to a literal, so it is treated as a safe key source.
|
|
265
|
+
expect(reports).toHaveLength(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('returns null when the resolved def has no parent declaration', () => {
|
|
269
|
+
const { listeners, reports, context } = createWithMockContext(noHardcodedSecret);
|
|
270
|
+
const secretId = { type: 'Identifier', name: 'SECRET' };
|
|
271
|
+
const scope = {
|
|
272
|
+
variables: [],
|
|
273
|
+
childScopes: [],
|
|
274
|
+
references: [
|
|
275
|
+
{
|
|
276
|
+
identifier: secretId,
|
|
277
|
+
resolved: {
|
|
278
|
+
defs: [{ type: 'Variable', parent: undefined, node: {} }],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
};
|
|
283
|
+
(context.sourceCode as { getScope?: unknown }).getScope = () => scope;
|
|
284
|
+
|
|
285
|
+
(listeners.CallExpression as (n: TSESTree.CallExpression) => void)(
|
|
286
|
+
makeSignCall(secretId),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// decl?.type is undefined -> not a VariableDeclaration -> null -> safe.
|
|
290
|
+
expect(reports).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('returns null when the def parent is not a VariableDeclaration', () => {
|
|
294
|
+
const { listeners, reports, context } = createWithMockContext(noHardcodedSecret);
|
|
295
|
+
const secretId = { type: 'Identifier', name: 'SECRET' };
|
|
296
|
+
const scope = {
|
|
297
|
+
variables: [],
|
|
298
|
+
childScopes: [],
|
|
299
|
+
references: [
|
|
300
|
+
{
|
|
301
|
+
identifier: secretId,
|
|
302
|
+
resolved: {
|
|
303
|
+
defs: [
|
|
304
|
+
{
|
|
305
|
+
type: 'Variable',
|
|
306
|
+
parent: { type: 'ExpressionStatement' },
|
|
307
|
+
node: { init: { type: 'Literal', value: 'x' } },
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
(context.sourceCode as { getScope?: unknown }).getScope = () => scope;
|
|
315
|
+
|
|
316
|
+
(listeners.CallExpression as (n: TSESTree.CallExpression) => void)(
|
|
317
|
+
makeSignCall(secretId),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// parent.type mismatch -> null -> identifier treated as safe key source.
|
|
321
|
+
expect(reports).toHaveLength(0);
|
|
322
|
+
});
|
|
323
|
+
});
|