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.
Files changed (79) hide show
  1. package/AGENTS.md +102 -0
  2. package/CHANGELOG.md +161 -0
  3. package/LICENSE +23 -0
  4. package/README.md +158 -0
  5. package/dist/AGENTS.md +102 -0
  6. package/dist/CHANGELOG.md +161 -0
  7. package/dist/LICENSE +23 -0
  8. package/dist/README.md +158 -0
  9. package/dist/package.json +125 -0
  10. package/dist/src/index.d.ts +101 -0
  11. package/dist/src/index.js +171 -0
  12. package/dist/src/oxlint.d.ts +20 -0
  13. package/dist/src/oxlint.js +21 -0
  14. package/dist/src/rules/no-algorithm-confusion/index.d.ts +24 -0
  15. package/dist/src/rules/no-algorithm-confusion/index.js +165 -0
  16. package/dist/src/rules/no-algorithm-none/index.d.ts +24 -0
  17. package/dist/src/rules/no-algorithm-none/index.js +222 -0
  18. package/dist/src/rules/no-decode-without-verify/index.d.ts +23 -0
  19. package/dist/src/rules/no-decode-without-verify/index.js +131 -0
  20. package/dist/src/rules/no-hardcoded-secret/index.d.ts +23 -0
  21. package/dist/src/rules/no-hardcoded-secret/index.js +189 -0
  22. package/dist/src/rules/no-sensitive-payload/index.d.ts +20 -0
  23. package/dist/src/rules/no-sensitive-payload/index.js +103 -0
  24. package/dist/src/rules/no-timestamp-manipulation/index.d.ts +23 -0
  25. package/dist/src/rules/no-timestamp-manipulation/index.js +97 -0
  26. package/dist/src/rules/no-weak-secret/index.d.ts +23 -0
  27. package/dist/src/rules/no-weak-secret/index.js +165 -0
  28. package/dist/src/rules/require-algorithm-whitelist/index.d.ts +24 -0
  29. package/dist/src/rules/require-algorithm-whitelist/index.js +113 -0
  30. package/dist/src/rules/require-audience-validation/index.d.ts +20 -0
  31. package/dist/src/rules/require-audience-validation/index.js +100 -0
  32. package/dist/src/rules/require-expiration/index.d.ts +23 -0
  33. package/dist/src/rules/require-expiration/index.js +136 -0
  34. package/dist/src/rules/require-issued-at/index.d.ts +23 -0
  35. package/dist/src/rules/require-issued-at/index.js +113 -0
  36. package/dist/src/rules/require-issuer-validation/index.d.ts +20 -0
  37. package/dist/src/rules/require-issuer-validation/index.js +100 -0
  38. package/dist/src/rules/require-max-age/index.d.ts +24 -0
  39. package/dist/src/rules/require-max-age/index.js +93 -0
  40. package/dist/src/types/index.d.ts +82 -0
  41. package/dist/src/types/index.js +7 -0
  42. package/dist/src/utils/index.d.ts +98 -0
  43. package/dist/src/utils/index.js +238 -0
  44. package/package.json +125 -0
  45. package/src/__compatibility__/jwt-interface.spec.ts +192 -0
  46. package/src/index.test.ts +47 -0
  47. package/src/index.ts +209 -0
  48. package/src/oxlint.test.ts +61 -0
  49. package/src/oxlint.ts +23 -0
  50. package/src/rules/no-algorithm-confusion/index.ts +208 -0
  51. package/src/rules/no-algorithm-confusion/no-algorithm-confusion.test.ts +106 -0
  52. package/src/rules/no-algorithm-none/index.ts +270 -0
  53. package/src/rules/no-algorithm-none/no-algorithm-none.test.ts +146 -0
  54. package/src/rules/no-decode-without-verify/index.ts +162 -0
  55. package/src/rules/no-decode-without-verify/no-decode-without-verify.test.ts +76 -0
  56. package/src/rules/no-hardcoded-secret/index.ts +230 -0
  57. package/src/rules/no-hardcoded-secret/no-hardcoded-secret.test.ts +97 -0
  58. package/src/rules/no-sensitive-payload/index.ts +123 -0
  59. package/src/rules/no-sensitive-payload/no-sensitive-payload.test.ts +157 -0
  60. package/src/rules/no-timestamp-manipulation/index.ts +118 -0
  61. package/src/rules/no-timestamp-manipulation/no-timestamp-manipulation.test.ts +54 -0
  62. package/src/rules/no-weak-secret/index.ts +204 -0
  63. package/src/rules/no-weak-secret/no-weak-secret.test.ts +114 -0
  64. package/src/rules/require-algorithm-whitelist/index.ts +145 -0
  65. package/src/rules/require-algorithm-whitelist/require-algorithm-whitelist.test.ts +72 -0
  66. package/src/rules/require-audience-validation/index.ts +120 -0
  67. package/src/rules/require-audience-validation/require-audience-validation.test.ts +68 -0
  68. package/src/rules/require-expiration/index.ts +170 -0
  69. package/src/rules/require-expiration/require-expiration.test.ts +135 -0
  70. package/src/rules/require-issued-at/index.ts +143 -0
  71. package/src/rules/require-issued-at/require-issued-at.test.ts +64 -0
  72. package/src/rules/require-issuer-validation/index.ts +120 -0
  73. package/src/rules/require-issuer-validation/require-issuer-validation.test.ts +68 -0
  74. package/src/rules/require-max-age/index.ts +117 -0
  75. package/src/rules/require-max-age/require-max-age.test.ts +69 -0
  76. package/src/tests/coverage-dual-layer.test.ts +323 -0
  77. package/src/types/index.ts +107 -0
  78. package/src/utils/index.ts +285 -0
  79. 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
+ });