@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,884 @@
1
+ /**
2
+ * S003 - Open Redirect Protection (Symbol-based Analyzer)
3
+ *
4
+ * Detects unvalidated URL redirects from user input that can lead to
5
+ * phishing attacks and malware distribution.
6
+ *
7
+ * Based on:
8
+ * - OWASP A03:2021 - Injection
9
+ * - CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
10
+ */
11
+
12
+ const { SyntaxKind } = require("ts-morph");
13
+
14
+ class S003SymbolBasedAnalyzer {
15
+ constructor(semanticEngine = null) {
16
+ this.ruleId = "S003";
17
+ this.semanticEngine = semanticEngine;
18
+
19
+ // Redirect function patterns (server-side)
20
+ this.redirectFunctions = [
21
+ "redirect", // Express: res.redirect(), NestJS: @Redirect()
22
+ "sendredirect", // Java/Spring: response.sendRedirect()
23
+ "redirectview", // Spring: new RedirectView()
24
+ "setheader", // Generic: res.setHeader('Location', ...)
25
+ "@redirect", // NestJS decorator: @Redirect(url)
26
+ "permanentredirect", // Next.js: permanentRedirect()
27
+ "navigateto", // Nuxt.js: navigateTo()
28
+ "sendredirect", // Nuxt.js: sendRedirect()
29
+ ];
30
+
31
+ // Client-side redirect patterns
32
+ this.clientRedirectPatterns = [
33
+ "window.location",
34
+ "location.href",
35
+ "location.replace",
36
+ "location.assign",
37
+ "router.push", // Next.js/Nuxt.js router
38
+ "router.replace", // Next.js/Nuxt.js router
39
+ "navigateto", // Nuxt.js composable
40
+ "userouter", // Next.js/Nuxt.js hook
41
+ ];
42
+
43
+ // User input source patterns
44
+ this.userInputSources = [
45
+ "req.query",
46
+ "req.params",
47
+ "req.body",
48
+ "request.getparameter",
49
+ "request.getquerystring",
50
+ "urlsearchparams",
51
+ "searchparams.get",
52
+ "params.get",
53
+ "query.get",
54
+ "@requestparam", // Spring annotation
55
+ "@queryparam", // JAX-RS annotation
56
+ "@query", // NestJS decorator: @Query()
57
+ "@param", // NestJS decorator: @Param()
58
+ "@body", // NestJS decorator: @Body()
59
+ "usesearchparams", // Next.js hook
60
+ "useparams", // Next.js/React Router hook
61
+ "usequeryparams", // Next.js query params
62
+ "useroute", // Nuxt.js composable
63
+ "event.query", // Nuxt.js h3 event
64
+ "event.context.params", // Nuxt.js context
65
+ ];
66
+
67
+ // Validation/safe patterns that indicate proper validation
68
+ this.validationPatterns = [
69
+ "allowed", // ALLOWED_URLS, allowedDomains
70
+ "whitelist", // WHITELIST, whitelistedUrls
71
+ "allowlist", // allowList
72
+ "safe", // SAFE_URLS, safeRedirects
73
+ "includes", // array.includes(url)
74
+ "new url", // URL parsing for validation
75
+ "startswith('/')", // Relative URL check
76
+ "startswith(\"/\")", // Relative URL check (double quotes)
77
+ "isvalid", // isValidUrl(), isValidDomain()
78
+ "validate", // validateUrl(), validateRedirect()
79
+ "check", // checkUrl(), checkDomain()
80
+ "istrust", // isTrustedUrl()
81
+ "trusted", // TRUSTED_URLS
82
+ "verify", // verifyUrl()
83
+ ];
84
+
85
+ // Framework-specific safe patterns
86
+ this.safeFrameworkPatterns = [
87
+ "redirectmap", // Indirect mapping
88
+ "redirect_map",
89
+ "urlmap",
90
+ "destinationmap",
91
+ ];
92
+
93
+ // Safe URL construction patterns (not user-controllable)
94
+ this.safeUrlPatterns = [
95
+ "process.env.", // Environment variables
96
+ "env.", // Short env reference
97
+ ];
98
+
99
+ // Hardcoded path patterns (relative URLs are generally safe)
100
+ this.hardcodedPathPatterns = [
101
+ "'/", // Single quote relative path
102
+ '"//', // Double quote relative path (but not protocol)
103
+ "`/", // Template literal relative path
104
+ ];
105
+ }
106
+
107
+ async initialize(semanticEngine) {
108
+ this.semanticEngine = semanticEngine;
109
+ }
110
+
111
+ async analyze(sourceFile, filePath) {
112
+ const violations = [];
113
+ const reportedLines = new Set();
114
+
115
+ try {
116
+ // Build dataflow map: variable name -> user input source
117
+ const taintedVariables = this.buildTaintedVariablesMap(sourceFile);
118
+
119
+
120
+ // Check server-side redirect functions
121
+ this.checkServerRedirects(
122
+ sourceFile,
123
+ filePath,
124
+ violations,
125
+ reportedLines,
126
+ taintedVariables
127
+ );
128
+
129
+ // Check client-side redirects
130
+ this.checkClientRedirects(
131
+ sourceFile,
132
+ filePath,
133
+ violations,
134
+ reportedLines,
135
+ taintedVariables
136
+ );
137
+
138
+ // Check Location header assignments
139
+ this.checkLocationHeaders(
140
+ sourceFile,
141
+ filePath,
142
+ violations,
143
+ reportedLines,
144
+ taintedVariables
145
+ );
146
+ } catch (error) {
147
+ console.warn(`⚠ [S003] Analysis error in ${filePath}: ${error.message}`);
148
+ }
149
+
150
+ return violations;
151
+ }
152
+
153
+ /**
154
+ * Build a map of variables that are tainted with user input
155
+ * Returns: Map<variableName, userInputSource>
156
+ */
157
+ buildTaintedVariablesMap(sourceFile) {
158
+ const taintedVars = new Map();
159
+
160
+ // Find all variable declarations
161
+ const variableDeclarations = sourceFile.getDescendantsOfKind(
162
+ SyntaxKind.VariableDeclaration
163
+ );
164
+
165
+ for (const varDecl of variableDeclarations) {
166
+ const varName = varDecl.getName();
167
+ const initializer = varDecl.getInitializer();
168
+
169
+ if (!initializer) continue;
170
+
171
+ const initText = initializer.getText().toLowerCase();
172
+ const initOriginal = initializer.getText();
173
+
174
+ // Check if initializer is from user input (including chained calls and template literals)
175
+ const isUserInput = this.isUserInput(initText) ||
176
+ this.isUserInputCall(initText) ||
177
+ this.isChainedUserInputCall(initText) ||
178
+ this.containsUserInputInTemplate(initText);
179
+
180
+ // But exclude if it's a safe URL construction
181
+ if (isUserInput && !this.isSafeUrlConstruction(initOriginal)) {
182
+ taintedVars.set(varName.toLowerCase(), initText);
183
+ }
184
+ }
185
+
186
+ // Also track binary expressions (assignments)
187
+ const binaryExprs = sourceFile.getDescendantsOfKind(
188
+ SyntaxKind.BinaryExpression
189
+ );
190
+
191
+ for (const binaryExpr of binaryExprs) {
192
+ const operator = binaryExpr.getOperatorToken().getText();
193
+ if (operator !== "=") continue;
194
+
195
+ const left = binaryExpr.getLeft();
196
+ const right = binaryExpr.getRight();
197
+
198
+ const leftText = left.getText();
199
+ const rightText = right.getText().toLowerCase();
200
+ const rightOriginal = right.getText();
201
+
202
+ // Check if right side is user input or tainted variable
203
+ const isUserInput = this.isUserInput(rightText) ||
204
+ this.isUserInputCall(rightText) ||
205
+ this.isChainedUserInputCall(rightText) ||
206
+ this.containsUserInputInTemplate(rightText);
207
+
208
+ // But exclude if it's a safe URL construction
209
+ if (isUserInput && !this.isSafeUrlConstruction(rightOriginal)) {
210
+ taintedVars.set(leftText.toLowerCase(), rightText);
211
+ } else if (taintedVars.has(rightText)) {
212
+ // Propagate taint
213
+ taintedVars.set(leftText.toLowerCase(), taintedVars.get(rightText));
214
+ }
215
+ }
216
+
217
+ return taintedVars;
218
+ }
219
+
220
+ /**
221
+ * Check if text contains chained user input calls
222
+ * e.g., new URLSearchParams().get(), params.get()
223
+ */
224
+ isChainedUserInputCall(text) {
225
+ const chainedPatterns = [
226
+ "urlsearchparams",
227
+ "searchparams",
228
+ "params.get",
229
+ "query.get",
230
+ ];
231
+
232
+ return chainedPatterns.some(pattern => text.includes(pattern));
233
+ }
234
+
235
+ /**
236
+ * Check if template literal contains user input variable
237
+ * Now checks if variables inside template are from known user input sources
238
+ */
239
+ containsUserInputInTemplate(text, taintedVariables = new Map()) {
240
+ // Check if it's a template literal with ${...}
241
+ if (!text.includes('${') || !text.includes('}')) {
242
+ return false;
243
+ }
244
+
245
+ // Extract variable names from template literals
246
+ const varMatches = text.match(/\$\{([^}]+)\}/g);
247
+ if (!varMatches) {
248
+ return false;
249
+ }
250
+
251
+ // Check if any variable looks like user input
252
+ for (const varMatch of varMatches) {
253
+ const varContent = varMatch.slice(2, -1).toLowerCase(); // Remove ${ and }
254
+ const varName = varContent.split('.')[0].trim(); // Get variable name without properties
255
+
256
+ // Check if it's a known user input source
257
+ if (this.isUserInput(varContent) || this.isUserInputCall(varContent)) {
258
+ return true;
259
+ }
260
+
261
+ // Check if variable is in tainted variables map
262
+ if (taintedVariables.has(varName)) {
263
+ return true;
264
+ }
265
+
266
+ // Check for suspicious variable names that commonly hold user input
267
+ const suspiciousNames = ['redirect', 'url', 'next', 'return', 'callback', 'target', 'dest', 'destination'];
268
+ if (suspiciousNames.some(name => varName.includes(name))) {
269
+ // But exclude if it's clearly a hardcoded endpoint
270
+ const hasHardcodedEndpoint = /\/(register|login|account|auth|logout|home|dashboard|en|ja)/.test(text.toLowerCase());
271
+ if (!hasHardcodedEndpoint) {
272
+ return true;
273
+ }
274
+ }
275
+ }
276
+
277
+ // Conservative: if we can't determine, assume not tainted
278
+ // This reduces false positives
279
+ return false;
280
+ }
281
+
282
+ /**
283
+ * Check if text is a user input function call
284
+ * e.g., params.get(), getQueryParam(), new URLSearchParams().get()
285
+ */
286
+ isUserInputCall(text) {
287
+ const userInputCallPatterns = [
288
+ "getqueryParam",
289
+ ".get(",
290
+ "geturlparameter",
291
+ "getparameter(",
292
+ "getquerystring(",
293
+ ];
294
+
295
+ return userInputCallPatterns.some(pattern => text.includes(pattern.toLowerCase()));
296
+ }
297
+
298
+ /**
299
+ * Check if URL construction appears to be safe (not user-controllable)
300
+ * Returns true if the URL is constructed from:
301
+ * - Environment variables (process.env.*)
302
+ * - Hardcoded relative paths
303
+ * - Template literals with mostly hardcoded content
304
+ */
305
+ isSafeUrlConstruction(urlText) {
306
+ const lowerText = urlText.toLowerCase();
307
+
308
+ // Check for environment variables with hardcoded paths
309
+ if (this.safeUrlPatterns.some(pattern => lowerText.includes(pattern))) {
310
+ // If it contains env var + any path segments, check if paths are hardcoded
311
+ // e.g., `${process.env.BASE_URL}/en/account/login`
312
+ if (urlText.includes('/')) {
313
+ // Check for specific hardcoded endpoint patterns
314
+ const hasHardcodedEndpoint = /\/(register|login|account|auth|logout|home|dashboard|en|ja|profile|settings)/.test(lowerText);
315
+ if (hasHardcodedEndpoint) {
316
+ return true;
317
+ }
318
+
319
+ // Count template vars vs path segments
320
+ const templateVars = (urlText.match(/\$\{[^}]+\}/g) || []).length;
321
+ const pathSegments = urlText.split('/').filter(s => s.length > 0).length;
322
+
323
+ // If we have hardcoded path segments (more segments than template vars)
324
+ if (pathSegments > templateVars) {
325
+ return true;
326
+ }
327
+ }
328
+ }
329
+
330
+ // Check if it's a pure relative path construction
331
+ // e.g., `/register?fc_id=${fanclubId}` where only query param is dynamic
332
+ if (urlText.startsWith("'/") || urlText.startsWith('"/') || urlText.startsWith("`/")) {
333
+ // Extract the base path before query string
334
+ const pathMatch = urlText.match(/^['"`](\/[^?$}]+)/);
335
+ if (pathMatch) {
336
+ const basePath = pathMatch[1];
337
+ // If base path is hardcoded (no template vars), it's relatively safe
338
+ // Only dynamic parts should be query params or fragments
339
+ if (!basePath.includes('${')) {
340
+ return true;
341
+ }
342
+ }
343
+ }
344
+
345
+ // Check if it's a ternary with only hardcoded alternatives
346
+ // e.g., fanclubId ? '/register?fc_id=${fanclubId}' : '/register'
347
+ // This is safe because both branches have hardcoded base paths
348
+ if (urlText.includes('?') && urlText.includes(':')) {
349
+ // Count how many env vars or hardcoded paths
350
+ let safePatternCount = 0;
351
+ if (this.safeUrlPatterns.some(p => lowerText.includes(p))) safePatternCount++;
352
+ if (this.hardcodedPathPatterns.some(p => urlText.includes(p))) safePatternCount++;
353
+
354
+ // If multiple safe patterns, likely a safe ternary
355
+ if (safePatternCount >= 2) {
356
+ return true;
357
+ }
358
+ }
359
+
360
+ return false;
361
+ }
362
+
363
+ /**
364
+ * Check server-side redirect functions (res.redirect, response.sendRedirect, etc.)
365
+ */
366
+ checkServerRedirects(sourceFile, filePath, violations, reportedLines, taintedVariables) {
367
+ const callExprs = sourceFile.getDescendantsOfKind(
368
+ SyntaxKind.CallExpression
369
+ );
370
+
371
+ for (const callExpr of callExprs) {
372
+ const expression = callExpr.getExpression();
373
+ const expressionText = expression.getText().toLowerCase();
374
+
375
+ // Check if this is a redirect function call
376
+ if (!this.isRedirectFunction(expressionText)) {
377
+ continue;
378
+ }
379
+
380
+ // Get the redirect URL argument early for checks
381
+ const args = callExpr.getArguments();
382
+ if (args.length === 0) {
383
+ continue;
384
+ }
385
+
386
+ // Skip if this is a function definition (e.g., function redirect() or @Get() redirect())
387
+ // For decorated methods like "@Get() redirect(...)", the call expression is actually the function name
388
+ // Check if the expression is an identifier without property access (not res.redirect but just redirect)
389
+ const hasPropertyAccess = expressionText.includes('.');
390
+ const isStandaloneRedirect = expressionText === 'redirect' ||
391
+ expressionText === 'permanentredirect' ||
392
+ expressionText === 'navigateto';
393
+
394
+ // If it's a standalone redirect call and first arg is a decorator, skip (likely function def)
395
+ if (isStandaloneRedirect && !hasPropertyAccess) {
396
+ const firstArg = args[0];
397
+ const firstArgText = firstArg.getText();
398
+ // Check if first arg looks like a decorator parameter (@Query('url'))
399
+ if (firstArgText.includes('@')) {
400
+ continue;
401
+ }
402
+ }
403
+
404
+ const line = callExpr.getStartLineNumber();
405
+ if (reportedLines.has(line)) {
406
+ continue;
407
+ }
408
+
409
+ const urlArg = args[0];
410
+ const urlArgText = urlArg.getText();
411
+ const urlArgTextLower = urlArgText.toLowerCase();
412
+
413
+ // Skip object-based navigation (e.g., navigateTo({ path: ... }))
414
+ // These are routing helpers, not direct redirects
415
+ if (this.isObjectBasedNavigation(urlArg)) {
416
+ continue;
417
+ }
418
+
419
+ // Check if URL comes from user input (direct or via tainted variable)
420
+ const isTainted = this.isUserInput(urlArgTextLower) ||
421
+ this.isUserInputCall(urlArgTextLower) ||
422
+ taintedVariables.has(urlArgTextLower);
423
+
424
+
425
+ if (!isTainted) {
426
+ continue;
427
+ }
428
+
429
+ // Check if there's validation in the surrounding context
430
+ const hasValidation = this.hasValidationInContext(callExpr);
431
+ if (hasValidation) {
432
+ continue; // Safe - has validation
433
+ }
434
+
435
+ // VIOLATION: Redirect with user input without validation
436
+ violations.push({
437
+ ruleId: this.ruleId,
438
+ severity: "warning",
439
+ message: `Open redirect vulnerability: '${expression.getText()}(${urlArgText})' uses user input without validation - validate against allow list or use relative URLs only`,
440
+ line: line,
441
+ column: callExpr.getStart() - callExpr.getStartLinePos() + 1,
442
+ filePath: filePath,
443
+ file: filePath,
444
+ });
445
+
446
+ reportedLines.add(line);
447
+ }
448
+
449
+ // Also check for new RedirectView() constructor calls
450
+ const newExprs = sourceFile.getDescendantsOfKind(
451
+ SyntaxKind.NewExpression
452
+ );
453
+
454
+ for (const newExpr of newExprs) {
455
+ const expression = newExpr.getExpression();
456
+ const expressionText = expression.getText().toLowerCase();
457
+
458
+ // Check if this is a RedirectView constructor
459
+ if (!expressionText.includes("redirectview")) {
460
+ continue;
461
+ }
462
+
463
+ const line = newExpr.getStartLineNumber();
464
+ if (reportedLines.has(line)) {
465
+ continue;
466
+ }
467
+
468
+ const args = newExpr.getArguments();
469
+ if (!args || args.length === 0) {
470
+ continue;
471
+ }
472
+
473
+ const urlArg = args[0];
474
+ const urlArgText = urlArg.getText();
475
+ const urlArgTextLower = urlArgText.toLowerCase();
476
+
477
+ // Check if URL comes from user input
478
+ const isTainted = this.isUserInput(urlArgTextLower) ||
479
+ this.isUserInputCall(urlArgTextLower) ||
480
+ taintedVariables.has(urlArgTextLower);
481
+
482
+ if (!isTainted) {
483
+ continue;
484
+ }
485
+
486
+ const hasValidation = this.hasValidationInContext(newExpr);
487
+ if (hasValidation) {
488
+ continue;
489
+ }
490
+
491
+ violations.push({
492
+ ruleId: this.ruleId,
493
+ severity: "warning",
494
+ message: `Open redirect vulnerability: 'new ${expression.getText()}(${urlArgText})' uses user input without validation`,
495
+ line: line,
496
+ column: newExpr.getStart() - newExpr.getStartLinePos() + 1,
497
+ filePath: filePath,
498
+ file: filePath,
499
+ });
500
+
501
+ reportedLines.add(line);
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Check client-side redirects (window.location, location.href, etc.)
507
+ */
508
+ checkClientRedirects(sourceFile, filePath, violations, reportedLines, taintedVariables) {
509
+ // Check binary expressions (assignments)
510
+ const binaryExprs = sourceFile.getDescendantsOfKind(
511
+ SyntaxKind.BinaryExpression
512
+ );
513
+
514
+ for (const binaryExpr of binaryExprs) {
515
+ const operator = binaryExpr.getOperatorToken().getText();
516
+ if (operator !== "=") {
517
+ continue;
518
+ }
519
+
520
+ const left = binaryExpr.getLeft();
521
+ const right = binaryExpr.getRight();
522
+ const leftText = left.getText().toLowerCase();
523
+ const rightText = right.getText().toLowerCase();
524
+
525
+ // Check if left side is a location redirect
526
+ if (!this.isClientRedirect(leftText)) {
527
+ continue;
528
+ }
529
+
530
+ const line = binaryExpr.getStartLineNumber();
531
+ if (reportedLines.has(line)) {
532
+ continue;
533
+ }
534
+
535
+ // Check if right side is user input (direct or via tainted variable)
536
+ // Also check for template literals containing tainted variables
537
+ const rightOriginal = right.getText();
538
+ const isTainted = this.isUserInput(rightText) ||
539
+ this.isUserInputCall(rightText) ||
540
+ this.isChainedUserInputCall(rightText) ||
541
+ this.containsUserInputInTemplate(rightOriginal) ||
542
+ taintedVariables.has(rightText);
543
+
544
+ if (!isTainted) {
545
+ continue;
546
+ }
547
+
548
+ // Check if URL construction is safe (env vars + hardcoded paths)
549
+ if (this.isSafeUrlConstruction(rightOriginal)) {
550
+ continue;
551
+ }
552
+
553
+ // Check for validation
554
+ const hasValidation = this.hasValidationInContext(binaryExpr);
555
+ if (hasValidation) {
556
+ continue;
557
+ }
558
+
559
+ // VIOLATION: Client-side redirect with user input
560
+ violations.push({
561
+ ruleId: this.ruleId,
562
+ severity: "warning",
563
+ message: `Open redirect vulnerability: '${left.getText()} = ${right.getText()}' uses user input without validation - validate URL before redirecting`,
564
+ line: line,
565
+ column: binaryExpr.getStart() - binaryExpr.getStartLinePos() + 1,
566
+ filePath: filePath,
567
+ file: filePath,
568
+ });
569
+
570
+ reportedLines.add(line);
571
+ }
572
+
573
+ // Also check method calls (location.replace, location.assign)
574
+ const callExprs = sourceFile.getDescendantsOfKind(
575
+ SyntaxKind.CallExpression
576
+ );
577
+
578
+ for (const callExpr of callExprs) {
579
+ const expression = callExpr.getExpression();
580
+ const expressionText = expression.getText().toLowerCase();
581
+
582
+ if (!this.isClientRedirect(expressionText)) {
583
+ continue;
584
+ }
585
+
586
+ const line = callExpr.getStartLineNumber();
587
+ if (reportedLines.has(line)) {
588
+ continue;
589
+ }
590
+
591
+ const args = callExpr.getArguments();
592
+ if (args.length === 0) {
593
+ continue;
594
+ }
595
+
596
+ const urlArg = args[0];
597
+ const urlArgText = urlArg.getText().toLowerCase();
598
+ const urlArgOriginal = urlArg.getText();
599
+
600
+ // Skip object-based navigation (e.g., navigateTo({ path: ... }))
601
+ // These are routing helpers, not direct redirects
602
+ if (this.isObjectBasedNavigation(urlArg)) {
603
+ continue;
604
+ }
605
+
606
+ // Check if argument is tainted
607
+ const isTainted = this.isUserInput(urlArgText) ||
608
+ this.isUserInputCall(urlArgText) ||
609
+ taintedVariables.has(urlArgText);
610
+
611
+ if (!isTainted) {
612
+ continue;
613
+ }
614
+
615
+ // Check if URL construction is safe
616
+ if (this.isSafeUrlConstruction(urlArgOriginal)) {
617
+ continue;
618
+ }
619
+
620
+ const hasValidation = this.hasValidationInContext(callExpr);
621
+ if (hasValidation) {
622
+ continue;
623
+ }
624
+
625
+ violations.push({
626
+ ruleId: this.ruleId,
627
+ severity: "warning",
628
+ message: `Open redirect vulnerability: '${expression.getText()}(${urlArg.getText()})' uses user input without validation`,
629
+ line: line,
630
+ column: callExpr.getStart() - callExpr.getStartLinePos() + 1,
631
+ filePath: filePath,
632
+ file: filePath,
633
+ });
634
+
635
+ reportedLines.add(line);
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Check if argument is an object-based navigation (React Router style)
641
+ * e.g., navigateTo({ path: '/home', ... }) instead of navigateTo('/home')
642
+ */
643
+ isObjectBasedNavigation(arg) {
644
+ // Check if argument is an object literal
645
+ const kind = arg.getKind();
646
+ if (kind === SyntaxKind.ObjectLiteralExpression) {
647
+ // It's an object literal - this is likely a routing config object
648
+ // Not a direct URL redirect
649
+ return true;
650
+ }
651
+ return false;
652
+ }
653
+
654
+ /**
655
+ * Check Location header assignments (res.setHeader('Location', url))
656
+ */
657
+ checkLocationHeaders(sourceFile, filePath, violations, reportedLines, taintedVariables) {
658
+ const callExprs = sourceFile.getDescendantsOfKind(
659
+ SyntaxKind.CallExpression
660
+ );
661
+
662
+ for (const callExpr of callExprs) {
663
+ const expression = callExpr.getExpression();
664
+ const expressionText = expression.getText().toLowerCase();
665
+
666
+ // Check if this is setHeader call
667
+ if (!expressionText.includes("setheader")) {
668
+ continue;
669
+ }
670
+
671
+ const args = callExpr.getArguments();
672
+ if (args.length < 2) {
673
+ continue;
674
+ }
675
+
676
+ // Check if first argument is 'Location'
677
+ const firstArg = args[0].getText();
678
+ const firstArgLower = firstArg.toLowerCase();
679
+ if (
680
+ !firstArgLower.includes("location") &&
681
+ !firstArgLower.includes("'location'") &&
682
+ !firstArgLower.includes('"location"')
683
+ ) {
684
+ continue;
685
+ }
686
+
687
+ const line = callExpr.getStartLineNumber();
688
+ if (reportedLines.has(line)) {
689
+ continue;
690
+ }
691
+
692
+ // Check if second argument (URL) is from user input (direct or via tainted variable)
693
+ const urlArg = args[1];
694
+ const urlArgText = urlArg.getText().toLowerCase();
695
+
696
+ const isTainted = this.isUserInput(urlArgText) ||
697
+ this.isUserInputCall(urlArgText) ||
698
+ taintedVariables.has(urlArgText);
699
+
700
+ if (!isTainted) {
701
+ continue;
702
+ }
703
+
704
+ const hasValidation = this.hasValidationInContext(callExpr);
705
+ if (hasValidation) {
706
+ continue;
707
+ }
708
+
709
+ violations.push({
710
+ ruleId: this.ruleId,
711
+ severity: "warning",
712
+ message: `Open redirect vulnerability: setHeader('Location', ${urlArg.getText()}) uses user input without validation`,
713
+ line: line,
714
+ column: callExpr.getStart() - callExpr.getStartLinePos() + 1,
715
+ filePath: filePath,
716
+ file: filePath,
717
+ });
718
+
719
+ reportedLines.add(line);
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Helper: Check if expression text is a redirect function
725
+ * Excludes validation functions that contain redirect in their name
726
+ */
727
+ isRedirectFunction(text) {
728
+ // Exclude validation functions
729
+ const validationFunctionPatterns = [
730
+ 'isvalid',
731
+ 'validate',
732
+ 'check',
733
+ 'verify',
734
+ 'istrust',
735
+ ];
736
+
737
+ // If text contains validation patterns, it's not a redirect function
738
+ if (validationFunctionPatterns.some(pattern => text.includes(pattern))) {
739
+ return false;
740
+ }
741
+
742
+ // Exclude service/repository method calls (backend patterns)
743
+ // e.g., this.service.getRedirectById(), userRepository.findRedirect()
744
+ const servicePatterns = [
745
+ '.service.',
746
+ '.repository.',
747
+ 'service.',
748
+ 'repository.',
749
+ 'getredirect',
750
+ 'findredirect',
751
+ 'fetchredirect',
752
+ 'loadredirect',
753
+ ];
754
+
755
+ if (servicePatterns.some(pattern => text.includes(pattern))) {
756
+ return false;
757
+ }
758
+
759
+ return this.redirectFunctions.some((func) => text.includes(func));
760
+ }
761
+
762
+ /**
763
+ * Helper: Check if expression text is a client-side redirect
764
+ */
765
+ isClientRedirect(text) {
766
+ return this.clientRedirectPatterns.some((pattern) =>
767
+ text.includes(pattern)
768
+ );
769
+ }
770
+
771
+ /**
772
+ * Helper: Check if text represents user input
773
+ */
774
+ isUserInput(text) {
775
+ return this.userInputSources.some((source) => text.includes(source));
776
+ }
777
+
778
+ /**
779
+ * Helper: Check if there's validation in the surrounding context
780
+ * Looks for validation patterns in parent blocks (if statements, functions)
781
+ */
782
+ hasValidationInContext(node) {
783
+ // First check: if statement condition (most direct validation)
784
+ const parentIf = this.findParentIfStatement(node);
785
+ if (parentIf) {
786
+ const condition = parentIf.getExpression();
787
+ const conditionText = condition.getText().toLowerCase();
788
+
789
+ // Check for array.includes(), allowlist checks, validation functions etc.
790
+ if (
791
+ conditionText.includes("includes") ||
792
+ conditionText.includes("allowed") ||
793
+ conditionText.includes("whitelist") ||
794
+ conditionText.includes("startswith('/')") ||
795
+ conditionText.includes('startswith("/")') ||
796
+ conditionText.includes("isvalid") ||
797
+ conditionText.includes("validate") ||
798
+ conditionText.includes("check") ||
799
+ conditionText.includes("verify") ||
800
+ conditionText.includes("istrust") ||
801
+ conditionText.includes("safe")
802
+ ) {
803
+ return true;
804
+ }
805
+ }
806
+
807
+ // Second check: Get surrounding code context (parent statements/function)
808
+ const parentFunction = this.findParentFunction(node);
809
+ if (!parentFunction) {
810
+ return false;
811
+ }
812
+
813
+ const functionText = parentFunction.getText().toLowerCase();
814
+
815
+ // Check for validation patterns
816
+ const hasValidationPattern = this.validationPatterns.some((pattern) =>
817
+ functionText.includes(pattern)
818
+ );
819
+
820
+ if (hasValidationPattern) {
821
+ return true;
822
+ }
823
+
824
+ // Check for safe framework patterns (mapping)
825
+ const hasSafePattern = this.safeFrameworkPatterns.some((pattern) =>
826
+ functionText.includes(pattern)
827
+ );
828
+
829
+ if (hasSafePattern) {
830
+ return true;
831
+ }
832
+
833
+ return false;
834
+ }
835
+
836
+ /**
837
+ * Helper: Find parent function
838
+ */
839
+ findParentFunction(node) {
840
+ let current = node.getParent();
841
+ let depth = 0;
842
+
843
+ while (current && depth < 15) {
844
+ const kind = current.getKind();
845
+ if (
846
+ kind === SyntaxKind.FunctionDeclaration ||
847
+ kind === SyntaxKind.FunctionExpression ||
848
+ kind === SyntaxKind.ArrowFunction ||
849
+ kind === SyntaxKind.MethodDeclaration
850
+ ) {
851
+ return current;
852
+ }
853
+ current = current.getParent();
854
+ depth++;
855
+ }
856
+
857
+ return null;
858
+ }
859
+
860
+ /**
861
+ * Helper: Find parent if statement
862
+ */
863
+ findParentIfStatement(node) {
864
+ let current = node.getParent();
865
+ let depth = 0;
866
+
867
+ while (current && depth < 10) {
868
+ const kind = current.getKind();
869
+ if (kind === SyntaxKind.IfStatement) {
870
+ return current;
871
+ }
872
+ current = current.getParent();
873
+ depth++;
874
+ }
875
+
876
+ return null;
877
+ }
878
+
879
+ cleanup() {
880
+ // Cleanup if needed
881
+ }
882
+ }
883
+
884
+ module.exports = S003SymbolBasedAnalyzer;