@sun-asterisk/sunlint 1.3.26 → 1.3.28

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 (69) hide show
  1. package/config/rules/enhanced-rules-registry.json +101 -17
  2. package/config/rules/rules-registry-generated.json +22 -22
  3. package/origin-rules/security-en.md +351 -338
  4. package/package.json +1 -1
  5. package/rules/common/C003_no_vague_abbreviations/analyzer.js +73 -21
  6. package/rules/common/C017_constructor_logic/symbol-based-analyzer.js +206 -2
  7. package/rules/common/C024_no_scatter_hardcoded_constants/symbol-based-analyzer.js +553 -58
  8. package/rules/common/C029_catch_block_logging/analyzer.js +47 -12
  9. package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +35 -15
  10. package/rules/common/C041_no_sensitive_hardcode/symbol-based-analyzer.js +9 -5
  11. package/rules/security/S003_open_redirect_protection/README.md +371 -0
  12. package/rules/security/S003_open_redirect_protection/analyzer.js +135 -0
  13. package/rules/security/S003_open_redirect_protection/config.json +58 -0
  14. package/rules/security/S003_open_redirect_protection/symbol-based-analyzer.js +884 -0
  15. package/rules/security/S004_sensitive_data_logging/analyzer.js +135 -0
  16. package/rules/security/S004_sensitive_data_logging/config.json +62 -0
  17. package/rules/security/S004_sensitive_data_logging/symbol-based-analyzer.js +592 -0
  18. package/rules/security/S005_no_origin_auth/analyzer.js +97 -148
  19. package/rules/security/S005_no_origin_auth/config.json +28 -67
  20. package/rules/security/S005_no_origin_auth/symbol-based-analyzer.js +708 -0
  21. package/rules/security/S006_no_plaintext_recovery_codes/symbol-based-analyzer.js +170 -31
  22. package/rules/security/S010_no_insecure_encryption/analyzer.js +8 -2
  23. package/rules/security/S012_hardcoded_secrets/analyzer.js +149 -0
  24. package/rules/security/S012_hardcoded_secrets/config.json +75 -0
  25. package/rules/security/S012_hardcoded_secrets/symbol-based-analyzer.js +1204 -0
  26. package/rules/security/S013_tls_enforcement/symbol-based-analyzer.js +87 -0
  27. package/rules/security/S017_use_parameterized_queries/analyzer.js +11 -78
  28. package/rules/security/S017_use_parameterized_queries/symbol-based-analyzer.js +1146 -1
  29. package/rules/security/S019_smtp_injection_protection/analyzer.js +120 -0
  30. package/rules/security/S019_smtp_injection_protection/config.json +35 -0
  31. package/rules/security/S019_smtp_injection_protection/symbol-based-analyzer.js +687 -0
  32. package/rules/security/S020_no_eval_dynamic_code/analyzer.js +55 -130
  33. package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +4 -19
  34. package/rules/security/S022_escape_output_context/README.md +254 -0
  35. package/rules/security/S022_escape_output_context/analyzer.js +510 -0
  36. package/rules/security/S022_escape_output_context/config.json +229 -0
  37. package/rules/security/S023_no_json_injection/analyzer.js +15 -0
  38. package/rules/security/S023_no_json_injection/ast-analyzer.js +18 -3
  39. package/rules/security/S023_no_json_injection/config.json +133 -0
  40. package/rules/security/S024_xpath_xxe_protection/regex-based-analyzer.js +41 -0
  41. package/rules/security/S027_no_hardcoded_secrets/analyzer.js +67 -8
  42. package/rules/security/S027_no_hardcoded_secrets/categorized-analyzer.js +29 -6
  43. package/rules/security/S029_csrf_protection/config.json +127 -0
  44. package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +160 -28
  45. package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +81 -19
  46. package/rules/security/S031_secure_session_cookies/analyzer.js +20 -2
  47. package/rules/security/S031_secure_session_cookies/regex-based-analyzer.js +100 -0
  48. package/rules/security/S031_secure_session_cookies/symbol-based-analyzer.js +8 -1
  49. package/rules/security/S032_httponly_session_cookies/analyzer.js +2 -2
  50. package/rules/security/S032_httponly_session_cookies/regex-based-analyzer.js +115 -0
  51. package/rules/security/S032_httponly_session_cookies/symbol-based-analyzer.js +39 -10
  52. package/rules/security/S036_lfi_rfi_protection/analyzer.js +224 -0
  53. package/rules/security/S036_lfi_rfi_protection/config.json +20 -0
  54. package/rules/security/S040_session_fixation_protection/analyzer.js +153 -0
  55. package/rules/security/S040_session_fixation_protection/config.json +20 -0
  56. package/rules/security/S042_require_re_authentication_for_long_lived/README.md +83 -0
  57. package/rules/security/S042_require_re_authentication_for_long_lived/analyzer.js +153 -0
  58. package/rules/security/S042_require_re_authentication_for_long_lived/config.json +41 -0
  59. package/rules/security/S042_require_re_authentication_for_long_lived/symbol-based-analyzer.js +1139 -0
  60. package/rules/security/S043_password_changes_invalidate_all_sessions/README.md +107 -0
  61. package/rules/security/S043_password_changes_invalidate_all_sessions/analyzer.js +153 -0
  62. package/rules/security/S043_password_changes_invalidate_all_sessions/config.json +41 -0
  63. package/rules/security/S043_password_changes_invalidate_all_sessions/symbol-based-analyzer.js +541 -0
  64. package/docs/COMMAND-EXAMPLES.md +0 -390
  65. package/docs/FILE_LIMITS_COMPLETION_REPORT.md +0 -151
  66. package/docs/FOLDER_STRUCTURE.md +0 -59
  67. package/docs/SIMPLIFIED_USAGE_GUIDE.md +0 -208
  68. package/rules/security/S017_use_parameterized_queries/regex-based-analyzer.js +0 -541
  69. package/rules/security/S020_no_eval_dynamic_code/regex-based-analyzer.js +0 -307
@@ -0,0 +1,1139 @@
1
+ /**
2
+ * S042
3
+ * Require re-authentication for long-lived sessions or sensitive actions
4
+ * Objective: Reduce the risk of session hijacking or privilege misuse by forcing
5
+ * re-authentication after long idle periods or before critical actions.
6
+ * 1. Excessive Session Duration: Detects sessions configured for more than 24 hours
7
+ * 2. JWT Without Expiration: Identifies JWT tokens created without expiry times
8
+ * 3. Excessive JWT Expiration: Flags access tokens with expiry > 1 hour
9
+ * 4. Sensitive Actions Without Re-auth: Detects functions like password changes without re-authentication
10
+ * 5. Persistent Sessions Without Re-auth: Flags "Remember Me" features without proper security
11
+ * 6. Missing Idle Timeout: Identifies session configurations without inactivity timeouts
12
+ */
13
+
14
+ const { SyntaxKind } = require('ts-morph');
15
+
16
+ class S042SymbolBasedAnalyzer {
17
+ constructor(semanticEngine = null) {
18
+ this.ruleId = "S042";
19
+ this.ruleName = 'Require re-authentication for long-lived sessions or sensitive actions';
20
+ this.semanticEngine = semanticEngine;
21
+ this.verbose = false;
22
+ this.skipPatterns = [
23
+ /\/node_modules\//,
24
+ /\/tests?\//,
25
+ /\/dist\//,
26
+ /\/build\//,
27
+ /\.spec\.ts$/,
28
+ /\.test\.ts$/
29
+ ];
30
+
31
+ // Sensitive action patterns
32
+ this.sensitiveActions = [
33
+ /password.*change/i,
34
+ /update.*password/i,
35
+ /change.*password/i,
36
+ /reset.*password/i,
37
+ /delete.*account/i,
38
+ /remove.*account/i,
39
+ /payment/i,
40
+ /transfer.*fund/i,
41
+ /withdraw/i,
42
+ /deposit/i,
43
+ /update.*email/i,
44
+ /change.*email/i,
45
+ /two.*factor/i,
46
+ /2fa/i,
47
+ /security.*setting/i,
48
+ /role.*change/i,
49
+ /admin.*action/i
50
+ ];
51
+
52
+ // Session MIDDLEWARE configuration keys (express-session, cookie-session, etc.)
53
+ this.sessionConfigKeys = [
54
+ 'secret',
55
+ 'resave',
56
+ 'saveUninitialized',
57
+ 'cookie',
58
+ 'rolling',
59
+ 'store',
60
+ 'genid',
61
+ 'name',
62
+ 'proxy',
63
+ 'unset'
64
+ ];
65
+
66
+ // JWT configuration patterns
67
+ this.jwtConfigKeys = [
68
+ 'expiresIn',
69
+ 'expiry',
70
+ 'exp',
71
+ 'maxAge',
72
+ 'tokenExpiration'
73
+ ];
74
+
75
+ // Time thresholds (in milliseconds)
76
+ this.MAX_SESSION_AGE = 24 * 60 * 60 * 1000; // 24 hours
77
+ this.MAX_IDLE_TIME = 30 * 60 * 1000; // 30 minutes
78
+ this.MAX_JWT_EXPIRY = 60 * 60 * 1000; // 1 hour for access tokens
79
+ }
80
+
81
+ async initialize(semanticEngine = null) {
82
+ if (semanticEngine) {
83
+ this.semanticEngine = semanticEngine;
84
+ }
85
+ this.verbose = semanticEngine?.verbose || false;
86
+
87
+ if (process.env.SUNLINT_DEBUG) {
88
+ console.log(`🔧 [S042 Symbol-Based] Analyzer initialized, verbose: ${this.verbose}`);
89
+ }
90
+ }
91
+
92
+ async analyzeFileBasic(filePath, options = {}) {
93
+ return await this.analyzeFileWithSymbols(filePath, options);
94
+ }
95
+
96
+ analyzeFileWithSymbols(filePath, options = {}) {
97
+ const violations = [];
98
+ const verbose = options.verbose || this.verbose;
99
+
100
+ if (!this.semanticEngine?.project) {
101
+ if (verbose) {
102
+ console.warn('[S042 Symbol-Based] No semantic engine available, skipping analysis');
103
+ }
104
+ return violations;
105
+ }
106
+
107
+ if (this.shouldIgnoreFile(filePath)) {
108
+ if (verbose) console.log(`[${this.ruleId}] Ignoring ${filePath}`);
109
+ return violations;
110
+ }
111
+
112
+ if (verbose) {
113
+ console.log(`🔍 [S042 Symbol-Based] Starting analysis for ${filePath}`);
114
+ }
115
+
116
+ try {
117
+ const sourceFile = this.semanticEngine.project.getSourceFile(filePath);
118
+ if (!sourceFile) {
119
+ return violations;
120
+ }
121
+
122
+ // Check for session configuration issues
123
+ violations.push(...this.checkSessionConfiguration(sourceFile, verbose));
124
+
125
+ // Check for JWT configuration issues
126
+ violations.push(...this.checkJWTConfiguration(sourceFile, verbose));
127
+
128
+ // Check for sensitive actions without re-authentication
129
+ violations.push(...this.checkSensitiveActions(sourceFile, verbose));
130
+
131
+ // Check for Remember Me implementations
132
+ violations.push(...this.checkRememberMeImplementation(sourceFile, verbose));
133
+
134
+ // Check for idle timeout implementation
135
+ violations.push(...this.checkIdleTimeoutImplementation(sourceFile, verbose));
136
+
137
+ return violations;
138
+ } catch (error) {
139
+ if (verbose) {
140
+ console.warn(`[S042 Symbol-Based] Analysis failed for ${filePath}:`, error.message);
141
+ }
142
+ return violations;
143
+ }
144
+ }
145
+
146
+ checkSessionConfiguration(sourceFile, verbose) {
147
+ const violations = [];
148
+
149
+ // Find all object literals that might be session configuration
150
+ const objectLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression);
151
+
152
+ for (const objLiteral of objectLiterals) {
153
+ // Get context to understand what this object is for
154
+ const context = this.getObjectContext(objLiteral);
155
+
156
+ // Skip if this is clearly not a session middleware config
157
+ if (this.isSessionDataModel(context, objLiteral)) {
158
+ continue;
159
+ }
160
+
161
+ const properties = objLiteral.getProperties();
162
+ const sessionConfig = this.extractSessionConfig(properties, objLiteral, context);
163
+
164
+ // Only process if this is actually a session MIDDLEWARE configuration
165
+ if (sessionConfig.isSessionConfig) {
166
+ // Check for excessively long session duration
167
+ if (sessionConfig.maxAge && sessionConfig.maxAge > this.MAX_SESSION_AGE) {
168
+ const node = sessionConfig.maxAgeNode;
169
+ const startLine = node.getStartLineNumber();
170
+
171
+ violations.push({
172
+ ruleId: this.ruleId,
173
+ ruleName: this.ruleName,
174
+ severity: 'medium',
175
+ message: `Session maxAge is too long (${this.formatDuration(sessionConfig.maxAge)}). Consider limiting to 24 hours or less for security.`,
176
+ line: startLine,
177
+ column: node.getStart() - node.getStartLinePos() + 1,
178
+ filePath: sourceFile.getFilePath(),
179
+ type: 'EXCESSIVE_SESSION_DURATION',
180
+ details: `Long-lived sessions increase the risk of session hijacking. Recommended maximum: ${this.formatDuration(this.MAX_SESSION_AGE)}`
181
+ });
182
+ }
183
+
184
+ // Check if persistent/rememberMe is enabled without limits
185
+ if (sessionConfig.isPersistent && !sessionConfig.hasReauthCheck) {
186
+ const node = sessionConfig.persistentNode;
187
+ const startLine = node.getStartLineNumber();
188
+
189
+ violations.push({
190
+ ruleId: this.ruleId,
191
+ ruleName: this.ruleName,
192
+ severity: 'medium',
193
+ message: `Persistent session (Remember Me) enabled without re-authentication requirements for sensitive actions.`,
194
+ line: startLine,
195
+ column: node.getStart() - node.getStartLinePos() + 1,
196
+ filePath: sourceFile.getFilePath(),
197
+ type: 'PERSISTENT_SESSION_WITHOUT_REAUTH',
198
+ details: 'Persistent sessions should require re-authentication for sensitive operations like password changes or payments.'
199
+ });
200
+ }
201
+
202
+ // Check for missing idle timeout
203
+ if (!sessionConfig.hasIdleTimeout) {
204
+ const node = objLiteral;
205
+ const startLine = node.getStartLineNumber();
206
+
207
+ violations.push({
208
+ ruleId: this.ruleId,
209
+ ruleName: this.ruleName,
210
+ severity: 'medium',
211
+ message: `Session configuration missing idle timeout. Consider adding inactivity-based expiration.`,
212
+ line: startLine,
213
+ column: node.getStart() - node.getStartLinePos() + 1,
214
+ filePath: sourceFile.getFilePath(),
215
+ type: 'MISSING_IDLE_TIMEOUT',
216
+ details: `Recommended idle timeout: ${this.formatDuration(this.MAX_IDLE_TIME)} or less. Use 'rolling: true' or 'idleTimeout' option.`
217
+ });
218
+ }
219
+ }
220
+ }
221
+
222
+ return violations;
223
+ }
224
+
225
+ isSessionDataModel(context, objLiteral) {
226
+ // Check if this is a data model object (not middleware config)
227
+ if (context) {
228
+ // Repository/database operations
229
+ if (context.match(/repository|repo|model|entity|database|db|create|save|update|insert/i)) {
230
+ return true;
231
+ }
232
+ }
233
+
234
+ // Check if object has data model indicators
235
+ const properties = objLiteral.getProperties();
236
+ const propNames = properties
237
+ .filter(p => p.getKind() === SyntaxKind.PropertyAssignment)
238
+ .map(p => p.getName());
239
+
240
+ // Data model indicators
241
+ const dataModelKeys = ['userId', 'token', 'expiresAt', 'createdAt', 'updatedAt', 'id'];
242
+ const hasDataModelKeys = propNames.filter(name =>
243
+ dataModelKeys.some(key => name === key)
244
+ ).length >= 2;
245
+
246
+ return hasDataModelKeys;
247
+ }
248
+
249
+ extractSessionConfig(properties, objLiteral, context) {
250
+ const config = {
251
+ isSessionConfig: false,
252
+ maxAge: null,
253
+ maxAgeNode: null,
254
+ isPersistent: false,
255
+ persistentNode: null,
256
+ hasReauthCheck: false,
257
+ hasIdleTimeout: false
258
+ };
259
+
260
+ let sessionKeyCount = 0;
261
+ let jwtKeyCount = 0;
262
+
263
+ for (const prop of properties) {
264
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
265
+
266
+ const name = prop.getName();
267
+ const initializer = prop.getInitializer();
268
+
269
+ // Count session-specific keys
270
+ if (this.sessionConfigKeys.some(key => name === key)) {
271
+ sessionKeyCount++;
272
+ }
273
+
274
+ // Count JWT-specific keys
275
+ if (this.jwtConfigKeys.some(key => name === key)) {
276
+ jwtKeyCount++;
277
+ }
278
+
279
+ // Check maxAge/expires in cookie object
280
+ if (name === 'cookie' && initializer && initializer.getKind() === SyntaxKind.ObjectLiteralExpression) {
281
+ const cookieProps = initializer.getProperties();
282
+ for (const cookieProp of cookieProps) {
283
+ if (cookieProp.getKind() !== SyntaxKind.PropertyAssignment) continue;
284
+
285
+ const cookiePropName = cookieProp.getName();
286
+ const cookieInitializer = cookieProp.getInitializer();
287
+
288
+ if (cookiePropName === 'maxAge') {
289
+ const value = this.extractNumericValue(cookieInitializer);
290
+ if (value !== null) {
291
+ config.maxAge = value;
292
+ config.maxAgeNode = cookieProp;
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ // Check for persistent/rememberMe (but only if not in a data model context)
299
+ if (name.match(/persistent|rememberMe/i) && name !== 'requiresReauthForSensitiveActions') {
300
+ const value = this.extractBooleanValue(initializer);
301
+ if (value === true) {
302
+ config.isPersistent = true;
303
+ config.persistentNode = prop;
304
+ }
305
+ }
306
+
307
+ // Check for idle timeout or rolling sessions
308
+ if (name.match(/idleTimeout|idle/i)) {
309
+ config.hasIdleTimeout = true;
310
+ }
311
+
312
+ if (name === 'rolling') {
313
+ const value = this.extractBooleanValue(initializer);
314
+ if (value === true) {
315
+ config.hasIdleTimeout = true;
316
+ }
317
+ }
318
+
319
+ // Check for re-authentication flags
320
+ if (name.match(/reauth|reauthenticate|requireAuth|requiresReauthForSensitiveActions/i)) {
321
+ config.hasReauthCheck = true;
322
+ }
323
+ }
324
+
325
+ // Determine if this is a session MIDDLEWARE config based on:
326
+ // 1. Has multiple session-specific keys
327
+ // 2. Context indicates session middleware
328
+ // 3. NOT dominated by JWT keys
329
+ if (sessionKeyCount >= 2 && jwtKeyCount < sessionKeyCount) {
330
+ config.isSessionConfig = true;
331
+ }
332
+
333
+ // Strong indicator: context mentions session middleware
334
+ if (context && context.match(/session\(|express.*session|cookie.*session|getSessionConfig|sessionConfig|sessionOptions|sessionMiddleware/i)) {
335
+ // Only mark as session config if it has at least one session key
336
+ if (sessionKeyCount >= 1) {
337
+ config.isSessionConfig = true;
338
+ }
339
+ }
340
+
341
+ return config;
342
+ }
343
+
344
+ getObjectContext(objLiteral) {
345
+ // Get the parent context to understand what this object is for
346
+ let parent = objLiteral.getParent();
347
+ let depth = 0;
348
+
349
+ while (parent && depth < 5) {
350
+ const kind = parent.getKind();
351
+
352
+ // Check variable declaration
353
+ if (kind === SyntaxKind.VariableDeclaration) {
354
+ const name = parent.getNameNode()?.getText();
355
+ return name;
356
+ }
357
+
358
+ // Check call expression (e.g., session(config), repository.create(data))
359
+ if (kind === SyntaxKind.CallExpression) {
360
+ const expr = parent.getExpression();
361
+ return expr.getText();
362
+ }
363
+
364
+ // Check property assignment
365
+ if (kind === SyntaxKind.PropertyAssignment) {
366
+ return parent.getName();
367
+ }
368
+
369
+ // Check method declaration
370
+ if (kind === SyntaxKind.MethodDeclaration) {
371
+ const name = parent.getNameNode()?.getText();
372
+ return name;
373
+ }
374
+
375
+ // Check return statement
376
+ if (kind === SyntaxKind.ReturnStatement) {
377
+ // Look for the parent function name
378
+ const func = parent.getParent();
379
+ if (func && (func.getKind() === SyntaxKind.MethodDeclaration ||
380
+ func.getKind() === SyntaxKind.FunctionDeclaration)) {
381
+ const funcName = func.getNameNode()?.getText();
382
+ return funcName;
383
+ }
384
+ }
385
+
386
+ parent = parent.getParent();
387
+ depth++;
388
+ }
389
+
390
+ return null;
391
+ }
392
+
393
+ checkJWTConfiguration(sourceFile, verbose) {
394
+ const violations = [];
395
+
396
+ // Find JWT signing/creation patterns
397
+ const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
398
+
399
+ for (const callExpr of callExpressions) {
400
+ const expression = callExpr.getExpression();
401
+ const expressionText = expression.getText();
402
+
403
+ // Check for jwt.sign() or similar
404
+ if (this.isJWTSignCall(expressionText)) {
405
+ const args = callExpr.getArguments();
406
+
407
+ if (args.length === 0) {
408
+ continue; // Skip if no arguments
409
+ }
410
+
411
+ // Determine which argument contains options
412
+ const optionsArg = this.findJWTOptionsArgument(args, expressionText);
413
+
414
+ if (!optionsArg) {
415
+ // No options argument found at all
416
+ const startLine = callExpr.getStartLineNumber();
417
+
418
+ violations.push({
419
+ ruleId: this.ruleId,
420
+ ruleName: this.ruleName,
421
+ severity: 'high',
422
+ message: `JWT token created without expiration configuration. Add expiresIn option.`,
423
+ line: startLine,
424
+ column: callExpr.getStart() - callExpr.getStartLinePos() + 1,
425
+ filePath: sourceFile.getFilePath(),
426
+ type: 'JWT_MISSING_OPTIONS',
427
+ details: 'Example: jwt.sign(payload, secret, { expiresIn: "1h" }) or jwtService.sign(payload, { expiresIn: "1h" })'
428
+ });
429
+ continue;
430
+ }
431
+
432
+ // Check if options argument is an object literal
433
+ if (optionsArg.getKind() === SyntaxKind.ObjectLiteralExpression) {
434
+ const jwtConfig = this.extractJWTConfig(optionsArg);
435
+
436
+ // Check for missing expiration
437
+ if (!jwtConfig.hasExpiration) {
438
+ const startLine = callExpr.getStartLineNumber();
439
+
440
+ violations.push({
441
+ ruleId: this.ruleId,
442
+ ruleName: this.ruleName,
443
+ severity: 'high',
444
+ message: `JWT token created without expiration time. Tokens should have short expiry (e.g., 1 hour).`,
445
+ line: startLine,
446
+ column: callExpr.getStart() - callExpr.getStartLinePos() + 1,
447
+ filePath: sourceFile.getFilePath(),
448
+ type: 'JWT_WITHOUT_EXPIRATION',
449
+ details: 'JWTs without expiration can be used indefinitely if compromised. Use short-lived access tokens with refresh token mechanism.'
450
+ });
451
+ }
452
+
453
+ // Check for excessively long JWT expiration (only for access tokens, not refresh tokens)
454
+ if (jwtConfig.expirationValue && jwtConfig.expirationValue > this.MAX_JWT_EXPIRY) {
455
+ // Check if this might be a refresh token (look for 'refresh' in variable names or comments)
456
+ const isLikelyRefreshToken = this.isLikelyRefreshToken(callExpr);
457
+
458
+ if (!isLikelyRefreshToken) {
459
+ const startLine = jwtConfig.expirationNode.getStartLineNumber();
460
+
461
+ violations.push({
462
+ ruleId: this.ruleId,
463
+ ruleName: this.ruleName,
464
+ severity: 'medium',
465
+ message: `JWT expiration is too long (${this.formatDuration(jwtConfig.expirationValue)}). Access tokens should expire within 1 hour.`,
466
+ line: startLine,
467
+ column: jwtConfig.expirationNode.getStart() - jwtConfig.expirationNode.getStartLinePos() + 1,
468
+ filePath: sourceFile.getFilePath(),
469
+ type: 'EXCESSIVE_JWT_EXPIRATION',
470
+ details: `Use short-lived access tokens (${this.formatDuration(this.MAX_JWT_EXPIRY)} or less) with refresh token rotation.`
471
+ });
472
+ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ return violations;
479
+ }
480
+
481
+ findJWTOptionsArgument(args, expressionText) {
482
+ // For @nestjs/jwt JwtService.sign(payload, options)
483
+ // The options is the second argument (index 1)
484
+ if (expressionText.includes('jwtService') || expressionText.includes('JwtService')) {
485
+ return args[1] || null;
486
+ }
487
+
488
+ // For jsonwebtoken jwt.sign(payload, secret, options)
489
+ // The options is the third argument (index 2)
490
+ if (expressionText.match(/jwt\.sign|JWT\.sign/)) {
491
+ return args[2] || null;
492
+ }
493
+
494
+ // Default: check both positions
495
+ // Try third argument first (jsonwebtoken style)
496
+ if (args[2] && args[2].getKind() === SyntaxKind.ObjectLiteralExpression) {
497
+ return args[2];
498
+ }
499
+
500
+ // Try second argument (NestJS style)
501
+ if (args[1] && args[1].getKind() === SyntaxKind.ObjectLiteralExpression) {
502
+ return args[1];
503
+ }
504
+
505
+ return null;
506
+ }
507
+
508
+ isLikelyRefreshToken(callExpr) {
509
+ // Check parent variable declaration
510
+ const parent = callExpr.getParent();
511
+
512
+ if (parent) {
513
+ // Check variable name
514
+ if (parent.getKind() === SyntaxKind.VariableDeclaration) {
515
+ const varName = parent.getNameNode()?.getText() || '';
516
+ if (varName.toLowerCase().includes('refresh')) {
517
+ return true;
518
+ }
519
+ }
520
+
521
+ // Check property assignment
522
+ if (parent.getKind() === SyntaxKind.PropertyAssignment) {
523
+ const propName = parent.getName();
524
+ if (propName.toLowerCase().includes('refresh')) {
525
+ return true;
526
+ }
527
+ }
528
+ }
529
+
530
+ // Check preceding comments
531
+ const sourceFile = callExpr.getSourceFile();
532
+ const fullText = sourceFile.getFullText();
533
+ const pos = callExpr.getStart();
534
+ const precedingText = fullText.substring(Math.max(0, pos - 200), pos);
535
+
536
+ if (precedingText.toLowerCase().includes('refresh')) {
537
+ return true;
538
+ }
539
+
540
+ return false;
541
+ }
542
+
543
+ extractJWTConfig(optionsNode) {
544
+ const config = {
545
+ hasExpiration: false,
546
+ expirationValue: null,
547
+ expirationNode: null
548
+ };
549
+
550
+ if (optionsNode.getKind() !== SyntaxKind.ObjectLiteralExpression) {
551
+ return config;
552
+ }
553
+
554
+ const properties = optionsNode.getProperties();
555
+
556
+ for (const prop of properties) {
557
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
558
+
559
+ const name = prop.getName();
560
+ const initializer = prop.getInitializer();
561
+
562
+ if (this.jwtConfigKeys.some(key => name === key)) {
563
+ config.hasExpiration = true;
564
+ config.expirationNode = prop;
565
+
566
+ // Try to extract numeric value
567
+ const value = this.extractTimeValue(initializer);
568
+ if (value !== null) {
569
+ config.expirationValue = value;
570
+ }
571
+ }
572
+ }
573
+
574
+ return config;
575
+ }
576
+
577
+ checkSensitiveActions(sourceFile, verbose) {
578
+ const violations = [];
579
+
580
+ // Find function/method declarations
581
+ const functions = [
582
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration),
583
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration),
584
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.ArrowFunction)
585
+ ];
586
+
587
+ for (const func of functions) {
588
+ const funcName = this.getFunctionName(func);
589
+
590
+ if (!funcName) continue;
591
+
592
+ // Check if function name suggests sensitive action
593
+ if (this.isSensitiveAction(funcName)) {
594
+ const hasReauth = this.checkForReauthentication(func);
595
+
596
+ if (!hasReauth) {
597
+ const startLine = func.getStartLineNumber();
598
+
599
+ // Provide more context in the message
600
+ const params = func.getParameters().map(p => p.getName()).join(', ');
601
+ const hasPasswordParam = params.match(/password|auth|verify|confirm/i);
602
+
603
+ let suggestion = 'Sensitive operations like password changes, payments, or account deletion should require recent authentication or password verification.';
604
+
605
+ if (!hasPasswordParam) {
606
+ suggestion += ' Consider adding a currentPassword or twoFactorCode parameter.';
607
+ } else {
608
+ suggestion += ' Ensure the password parameter is verified against the user\'s current password.';
609
+ }
610
+
611
+ violations.push({
612
+ ruleId: this.ruleId,
613
+ ruleName: this.ruleName,
614
+ severity: 'high',
615
+ message: `Sensitive action '${funcName}' does not appear to require re-authentication.`,
616
+ line: startLine,
617
+ column: func.getStart() - func.getStartLinePos() + 1,
618
+ filePath: sourceFile.getFilePath(),
619
+ type: 'SENSITIVE_ACTION_WITHOUT_REAUTH',
620
+ details: suggestion
621
+ });
622
+ }
623
+ }
624
+ }
625
+
626
+ return violations;
627
+ }
628
+
629
+ checkRememberMeImplementation(sourceFile, verbose) {
630
+ const violations = [];
631
+
632
+ const allNodes = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier);
633
+ const processedLines = new Set(); // Avoid duplicate violations on same line
634
+
635
+ for (const node of allNodes) {
636
+ const text = node.getText();
637
+
638
+ if (text.match(/rememberMe|remember_me|persistentLogin/i)) {
639
+ const startLine = node.getStartLineNumber();
640
+
641
+ // Skip if already reported on this line
642
+ if (processedLines.has(startLine)) {
643
+ continue;
644
+ }
645
+
646
+ // Check if there's nearby re-authentication logic
647
+ const parent = this.findRelevantParent(node);
648
+
649
+ if (parent) {
650
+ const hasReauthLogic = this.hasReauthenticationLogic(parent);
651
+
652
+ if (!hasReauthLogic) {
653
+ processedLines.add(startLine);
654
+
655
+ violations.push({
656
+ ruleId: this.ruleId,
657
+ ruleName: this.ruleName,
658
+ severity: 'medium',
659
+ message: `Remember Me feature implemented without apparent re-authentication requirements.`,
660
+ line: startLine,
661
+ column: node.getStart() - node.getStartLinePos() + 1,
662
+ filePath: sourceFile.getFilePath(),
663
+ type: 'REMEMBER_ME_WITHOUT_REAUTH',
664
+ details: 'Remember Me sessions should require re-authentication after a maximum period (e.g., 24 hours) or before sensitive actions.'
665
+ });
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ return violations;
672
+ }
673
+
674
+ checkIdleTimeoutImplementation(sourceFile, verbose) {
675
+ const violations = [];
676
+
677
+ // Look for session middleware or configuration
678
+ const hasSessionConfig = this.hasSessionConfiguration(sourceFile);
679
+ const hasIdleTimeout = this.hasIdleTimeoutLogic(sourceFile);
680
+
681
+ if (hasSessionConfig && !hasIdleTimeout) {
682
+ violations.push({
683
+ ruleId: this.ruleId,
684
+ ruleName: this.ruleName,
685
+ severity: 'medium',
686
+ message: `Session management detected but no idle timeout implementation found.`,
687
+ line: 1,
688
+ column: 1,
689
+ filePath: sourceFile.getFilePath(),
690
+ type: 'MISSING_IDLE_TIMEOUT_LOGIC',
691
+ details: `Implement idle timeout to automatically expire sessions after ${this.formatDuration(this.MAX_IDLE_TIME)} of inactivity.`
692
+ });
693
+ }
694
+
695
+ return violations;
696
+ }
697
+
698
+ // Helper methods
699
+
700
+ isJWTSignCall(expressionText) {
701
+ return expressionText.match(/jwt\.sign|jsonwebtoken\.sign|JWT\.sign|jwtService\.sign|JwtService\.sign/i) !== null;
702
+ }
703
+
704
+ isSensitiveAction(name) {
705
+ return this.sensitiveActions.some(pattern => pattern.test(name));
706
+ }
707
+
708
+ checkForReauthentication(functionNode) {
709
+ // First check: Does the function accept re-authentication parameters?
710
+ if (this.hasReauthenticationParameters(functionNode)) {
711
+ return true;
712
+ }
713
+
714
+ // Second check: Does the function body contain re-authentication logic?
715
+ const body = functionNode.getBody();
716
+ if (!body) return false;
717
+
718
+ const bodyText = body.getText();
719
+
720
+ // Direct re-authentication patterns
721
+ const reauthPatterns = [
722
+ /verifyPassword/i,
723
+ /checkPassword/i,
724
+ /requireAuth/i,
725
+ /reauth/i,
726
+ /verify.*credential/i,
727
+ /confirm.*password/i,
728
+ /validate.*password/i,
729
+ /authenticate.*again/i,
730
+ /require.*2fa/i,
731
+ /verify.*2fa/i,
732
+ /bcrypt\.compare/i,
733
+ /compareSync/i,
734
+ /passwordConfirmation/i,
735
+ /currentPassword/i,
736
+ /twoFactorCode/i
737
+ ];
738
+
739
+ if (reauthPatterns.some(pattern => pattern.test(bodyText))) {
740
+ return true;
741
+ }
742
+
743
+ // Third check: Does it call service methods that likely handle re-authentication?
744
+ if (this.callsReauthenticationService(functionNode)) {
745
+ return true;
746
+ }
747
+
748
+ return false;
749
+ }
750
+
751
+ hasReauthenticationParameters(functionNode) {
752
+ const parameters = functionNode.getParameters();
753
+
754
+ const reauthParamNames = [
755
+ 'currentPassword',
756
+ 'password',
757
+ 'passwordConfirmation',
758
+ 'oldPassword',
759
+ 'verifyPassword',
760
+ 'confirmPassword',
761
+ 'twoFactorCode',
762
+ 'twoFactorToken',
763
+ 'otpCode',
764
+ 'mfaCode',
765
+ 'authCode',
766
+ 'verificationCode'
767
+ ];
768
+
769
+ for (const param of parameters) {
770
+ const paramName = param.getName();
771
+
772
+ // Check if parameter name suggests re-authentication
773
+ if (reauthParamNames.some(name =>
774
+ paramName.toLowerCase().includes(name.toLowerCase())
775
+ )) {
776
+ return true;
777
+ }
778
+
779
+ // Check parameter type annotations for password-related types
780
+ const typeNode = param.getTypeNode();
781
+ if (typeNode) {
782
+ const typeText = typeNode.getText();
783
+ if (typeText.match(/password|auth|verification|2fa|mfa|otp/i)) {
784
+ return true;
785
+ }
786
+ }
787
+ }
788
+
789
+ return false;
790
+ }
791
+
792
+ callsReauthenticationService(functionNode) {
793
+ const body = functionNode.getBody();
794
+ if (!body) return false;
795
+
796
+ // Get all call expressions in the function
797
+ const callExpressions = body.getDescendantsOfKind(SyntaxKind.CallExpression);
798
+
799
+ for (const callExpr of callExpressions) {
800
+ const expression = callExpr.getExpression();
801
+ const callText = expression.getText();
802
+
803
+ // Check if calling a service method that handles re-authentication
804
+ const servicePatterns = [
805
+ /verifyPassword/i,
806
+ /checkPassword/i,
807
+ /validatePassword/i,
808
+ /authenticateUser/i,
809
+ /verifyCredentials/i,
810
+ /verify2FA/i,
811
+ /validateOTP/i,
812
+ /checkAuth/i,
813
+ /requireReauth/i,
814
+ /confirmIdentity/i,
815
+ /\.authenticate\(/i,
816
+ /\.verify\(/i,
817
+ /authService\./i,
818
+ /passwordService\./i,
819
+ /securityService\./i
820
+ ];
821
+
822
+ if (servicePatterns.some(pattern => pattern.test(callText))) {
823
+ return true;
824
+ }
825
+
826
+ // Check if the call passes password-related arguments
827
+ const args = callExpr.getArguments();
828
+ for (const arg of args) {
829
+ const argText = arg.getText();
830
+ if (argText.match(/currentPassword|passwordConfirmation|twoFactorCode|oldPassword/i)) {
831
+ return true;
832
+ }
833
+ }
834
+
835
+ // Try to resolve the called function and check its implementation
836
+ if (this.semanticEngine?.project) {
837
+ const resolvedFunction = this.tryResolveCalledFunction(callExpr);
838
+ if (resolvedFunction) {
839
+ // Recursively check if the called function has re-authentication logic
840
+ const resolvedBody = resolvedFunction.getBody();
841
+ if (resolvedBody) {
842
+ const resolvedBodyText = resolvedBody.getText();
843
+
844
+ const deepReauthPatterns = [
845
+ /bcrypt\.compare/i,
846
+ /compareSync/i,
847
+ /verifyPassword/i,
848
+ /checkPassword/i,
849
+ /verify.*2fa/i,
850
+ /totp\.verify/i,
851
+ /speakeasy\.verify/i,
852
+ /authenticator\.verify/i
853
+ ];
854
+
855
+ if (deepReauthPatterns.some(pattern => pattern.test(resolvedBodyText))) {
856
+ return true;
857
+ }
858
+ }
859
+ }
860
+ }
861
+ }
862
+
863
+ return false;
864
+ }
865
+
866
+ tryResolveCalledFunction(callExpr) {
867
+ try {
868
+ const expression = callExpr.getExpression();
869
+
870
+ // Handle property access expressions (e.g., this.authService.verifyPassword)
871
+ if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
872
+ const nameNode = expression.getNameNode();
873
+ const methodName = nameNode.getText();
874
+
875
+ // Try to find the method definition
876
+ const sourceFile = callExpr.getSourceFile();
877
+
878
+ // Search in the same file
879
+ const methods = sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration);
880
+ for (const method of methods) {
881
+ const name = method.getName();
882
+ if (name === methodName) {
883
+ return method;
884
+ }
885
+ }
886
+
887
+ // Search for functions
888
+ const functions = sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration);
889
+ for (const func of functions) {
890
+ const name = func.getName();
891
+ if (name === methodName) {
892
+ return func;
893
+ }
894
+ }
895
+
896
+ // Try to find in imported files (basic implementation)
897
+ const objectExpr = expression.getExpression();
898
+ if (objectExpr.getKind() === SyntaxKind.PropertyAccessExpression) {
899
+ const serviceName = objectExpr.getNameNode()?.getText();
900
+ if (serviceName) {
901
+ // Look for service injection and try to resolve
902
+ return this.findServiceMethod(sourceFile, serviceName, methodName);
903
+ }
904
+ }
905
+ }
906
+
907
+ // Handle direct function calls
908
+ if (expression.getKind() === SyntaxKind.Identifier) {
909
+ const functionName = expression.getText();
910
+ const sourceFile = callExpr.getSourceFile();
911
+
912
+ // Search for function declaration
913
+ const functions = sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration);
914
+ for (const func of functions) {
915
+ if (func.getName() === functionName) {
916
+ return func;
917
+ }
918
+ }
919
+
920
+ // Search for arrow functions assigned to variables
921
+ const variables = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
922
+ for (const variable of variables) {
923
+ if (variable.getName() === functionName) {
924
+ const initializer = variable.getInitializer();
925
+ if (initializer &&
926
+ (initializer.getKind() === SyntaxKind.ArrowFunction ||
927
+ initializer.getKind() === SyntaxKind.FunctionExpression)) {
928
+ return initializer;
929
+ }
930
+ }
931
+ }
932
+ }
933
+ } catch (error) {
934
+ // Silently fail - resolution is best effort
935
+ }
936
+
937
+ return null;
938
+ }
939
+
940
+ findServiceMethod(sourceFile, serviceName, methodName) {
941
+ try {
942
+ // Look for constructor parameters (dependency injection)
943
+ const classes = sourceFile.getDescendantsOfKind(SyntaxKind.ClassDeclaration);
944
+
945
+ for (const classDecl of classes) {
946
+ const constructor = classDecl.getConstructors()[0];
947
+ if (!constructor) continue;
948
+
949
+ const params = constructor.getParameters();
950
+ for (const param of params) {
951
+ const paramName = param.getName();
952
+
953
+ // Check if this is the service we're looking for
954
+ if (paramName === serviceName || paramName.includes(serviceName)) {
955
+ const typeNode = param.getTypeNode();
956
+ if (!typeNode) continue;
957
+
958
+ const typeName = typeNode.getText();
959
+
960
+ // Try to find the service class in the project
961
+ if (this.semanticEngine?.project) {
962
+ const allSourceFiles = this.semanticEngine.project.getSourceFiles();
963
+
964
+ for (const file of allSourceFiles) {
965
+ const serviceClasses = file.getDescendantsOfKind(SyntaxKind.ClassDeclaration);
966
+
967
+ for (const serviceClass of serviceClasses) {
968
+ const className = serviceClass.getName();
969
+ if (className === typeName || typeName.includes(className)) {
970
+ // Found the service class, now find the method
971
+ const methods = serviceClass.getMethods();
972
+ for (const method of methods) {
973
+ if (method.getName() === methodName) {
974
+ return method;
975
+ }
976
+ }
977
+ }
978
+ }
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+ } catch (error) {
985
+ // Silently fail
986
+ }
987
+
988
+ return null;
989
+ }
990
+
991
+ getFunctionName(func) {
992
+ if (func.getKind() === SyntaxKind.FunctionDeclaration ||
993
+ func.getKind() === SyntaxKind.MethodDeclaration) {
994
+ const nameNode = func.getNameNode();
995
+ return nameNode ? nameNode.getText() : null;
996
+ }
997
+
998
+ // For arrow functions, try to get from parent variable declaration
999
+ const parent = func.getParent();
1000
+ if (parent && parent.getKind() === SyntaxKind.VariableDeclaration) {
1001
+ const nameNode = parent.getNameNode();
1002
+ return nameNode ? nameNode.getText() : null;
1003
+ }
1004
+
1005
+ return null;
1006
+ }
1007
+
1008
+ findRelevantParent(node) {
1009
+ let parent = node.getParent();
1010
+ let depth = 0;
1011
+
1012
+ while (parent && depth < 5) {
1013
+ const kind = parent.getKind();
1014
+ if (kind === SyntaxKind.FunctionDeclaration ||
1015
+ kind === SyntaxKind.MethodDeclaration ||
1016
+ kind === SyntaxKind.ArrowFunction ||
1017
+ kind === SyntaxKind.Block) {
1018
+ return parent;
1019
+ }
1020
+ parent = parent.getParent();
1021
+ depth++;
1022
+ }
1023
+
1024
+ return null;
1025
+ }
1026
+
1027
+ hasReauthenticationLogic(node) {
1028
+ const text = node.getText();
1029
+
1030
+ const reauthKeywords = [
1031
+ 'reauth',
1032
+ 'verifyPassword',
1033
+ 'confirmPassword',
1034
+ 'requireAuth',
1035
+ 'checkPassword',
1036
+ 'validatePassword',
1037
+ '2fa',
1038
+ 'twoFactor',
1039
+ 'requiresReauthForSensitiveActions'
1040
+ ];
1041
+
1042
+ return reauthKeywords.some(keyword =>
1043
+ text.toLowerCase().includes(keyword.toLowerCase())
1044
+ );
1045
+ }
1046
+
1047
+ hasSessionConfiguration(sourceFile) {
1048
+ const text = sourceFile.getText();
1049
+ return /session\(|express-session|cookie-session|getSessionConfig|sessionConfig/i.test(text);
1050
+ }
1051
+
1052
+ hasIdleTimeoutLogic(sourceFile) {
1053
+ const text = sourceFile.getText();
1054
+ return /idle.*timeout|idleTimeout|inactivity|lastActivity|last.*access|rolling.*true|rolling:\s*true/i.test(text);
1055
+ }
1056
+
1057
+ extractNumericValue(node) {
1058
+ if (!node) return null;
1059
+
1060
+ if (node.getKind() === SyntaxKind.NumericLiteral) {
1061
+ return parseInt(node.getText(), 10);
1062
+ }
1063
+
1064
+ // Handle expressions like 1000 * 60 * 60
1065
+ if (node.getKind() === SyntaxKind.BinaryExpression) {
1066
+ try {
1067
+ const text = node.getText();
1068
+ // Simple eval for numeric expressions (be careful in production)
1069
+ const value = Function(`"use strict"; return (${text})`)();
1070
+ return typeof value === 'number' ? value : null;
1071
+ } catch {
1072
+ return null;
1073
+ }
1074
+ }
1075
+
1076
+ return null;
1077
+ }
1078
+
1079
+ extractBooleanValue(node) {
1080
+ if (!node) return null;
1081
+
1082
+ const kind = node.getKind();
1083
+ if (kind === SyntaxKind.TrueKeyword) return true;
1084
+ if (kind === SyntaxKind.FalseKeyword) return false;
1085
+
1086
+ return null;
1087
+ }
1088
+
1089
+ extractTimeValue(node) {
1090
+ if (!node) return null;
1091
+
1092
+ // Handle string literals like "1h", "24h", "30m"
1093
+ if (node.getKind() === SyntaxKind.StringLiteral) {
1094
+ const text = node.getText().replace(/['"]/g, '');
1095
+ return this.parseTimeString(text);
1096
+ }
1097
+
1098
+ // Handle numeric literals (assumed to be milliseconds)
1099
+ return this.extractNumericValue(node);
1100
+ }
1101
+
1102
+ parseTimeString(timeStr) {
1103
+ const match = timeStr.match(/^(\d+)(ms|s|m|h|d|w|y)?$/i);
1104
+ if (!match) return null;
1105
+
1106
+ const value = parseInt(match[1], 10);
1107
+ const unit = (match[2] || 'ms').toLowerCase();
1108
+
1109
+ const multipliers = {
1110
+ 'ms': 1,
1111
+ 's': 1000,
1112
+ 'm': 60 * 1000,
1113
+ 'h': 60 * 60 * 1000,
1114
+ 'd': 24 * 60 * 60 * 1000,
1115
+ 'w': 7 * 24 * 60 * 60 * 1000,
1116
+ 'y': 365 * 24 * 60 * 60 * 1000
1117
+ };
1118
+
1119
+ return value * (multipliers[unit] || 1);
1120
+ }
1121
+
1122
+ formatDuration(ms) {
1123
+ const seconds = ms / 1000;
1124
+ const minutes = seconds / 60;
1125
+ const hours = minutes / 60;
1126
+ const days = hours / 24;
1127
+
1128
+ if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}`;
1129
+ if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}`;
1130
+ if (minutes >= 1) return `${Math.floor(minutes)} minute${Math.floor(minutes) !== 1 ? 's' : ''}`;
1131
+ return `${Math.floor(seconds)} second${Math.floor(seconds) !== 1 ? 's' : ''}`;
1132
+ }
1133
+
1134
+ shouldIgnoreFile(filePath) {
1135
+ return this.skipPatterns.some((pattern) => pattern.test(filePath));
1136
+ }
1137
+ }
1138
+
1139
+ module.exports = S042SymbolBasedAnalyzer;