@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,127 @@
1
+ {
2
+ "rule": {
3
+ "id": "S029",
4
+ "name": "CSRF Protection Required",
5
+ "description": "Require CSRF (Cross-Site Request Forgery) protection for state-changing operations. All POST, PUT, DELETE, and PATCH endpoints must implement CSRF token validation to prevent unauthorized requests from malicious sites.",
6
+ "category": "security",
7
+ "severity": "error",
8
+ "languages": ["typescript", "javascript"],
9
+ "frameworks": ["express", "nestjs", "node"],
10
+ "version": "1.0.0",
11
+ "status": "stable",
12
+ "tags": ["security", "csrf", "xsrf", "authentication", "owasp"],
13
+ "references": [
14
+ "https://owasp.org/www-community/attacks/csrf",
15
+ "https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html",
16
+ "https://portswigger.net/web-security/csrf",
17
+ "https://cwe.mitre.org/data/definitions/352.html"
18
+ ]
19
+ },
20
+ "configuration": {
21
+ "checkStateChangingMethods": ["POST", "PUT", "DELETE", "PATCH"],
22
+ "csrfProtectionPatterns": [
23
+ "csurf()",
24
+ "csrfProtection",
25
+ "verifyCsrfToken",
26
+ "checkCsrf",
27
+ "csrf-token",
28
+ "_csrf",
29
+ "req.csrfToken",
30
+ "csrf.verify",
31
+ "validateCSRF",
32
+ "x-csrf-token",
33
+ "x-xsrf-token"
34
+ ],
35
+ "globalMiddlewarePatterns": [
36
+ "app.use(csurf())",
37
+ "app.use(csrfProtection)",
38
+ "router.use(csurf())"
39
+ ],
40
+ "routeHandlerPatterns": [
41
+ "app.post",
42
+ "app.put",
43
+ "app.delete",
44
+ "app.patch",
45
+ "router.post",
46
+ "router.put",
47
+ "router.delete",
48
+ "router.patch"
49
+ ],
50
+ "excludePatterns": [
51
+ "test",
52
+ "spec",
53
+ "mock",
54
+ "__tests__"
55
+ ]
56
+ },
57
+ "examples": {
58
+ "violations": [
59
+ {
60
+ "description": "POST endpoint without CSRF protection",
61
+ "code": "app.post('/api/transfer', (req, res) => {\n transferMoney(req.body.amount);\n});"
62
+ },
63
+ {
64
+ "description": "DELETE endpoint without CSRF middleware",
65
+ "code": "router.delete('/api/user/:id', (req, res) => {\n deleteUser(req.params.id);\n});"
66
+ },
67
+ {
68
+ "description": "PUT endpoint without token validation",
69
+ "code": "app.put('/api/profile', (req, res) => {\n updateProfile(req.body);\n});"
70
+ }
71
+ ],
72
+ "fixes": [
73
+ {
74
+ "description": "Apply CSRF protection globally",
75
+ "code": "const csrf = require('csurf');\nconst csrfProtection = csrf({ cookie: true });\napp.use(csrfProtection);\n\napp.post('/api/transfer', (req, res) => {\n // CSRF token is automatically validated\n transferMoney(req.body.amount);\n});"
76
+ },
77
+ {
78
+ "description": "Apply CSRF protection per route",
79
+ "code": "const csrfProtection = csrf({ cookie: true });\n\napp.post('/api/transfer', csrfProtection, (req, res) => {\n transferMoney(req.body.amount);\n});"
80
+ },
81
+ {
82
+ "description": "Manual CSRF token validation",
83
+ "code": "app.post('/api/transfer', (req, res) => {\n const token = req.body._csrf || req.headers['x-csrf-token'];\n if (!validateCsrfToken(token, req.session)) {\n return res.status(403).send('Invalid CSRF token');\n }\n transferMoney(req.body.amount);\n});"
84
+ }
85
+ ]
86
+ },
87
+ "testing": {
88
+ "testCases": [
89
+ {
90
+ "name": "post_without_csrf",
91
+ "type": "violation",
92
+ "description": "POST endpoint without CSRF protection"
93
+ },
94
+ {
95
+ "name": "put_without_csrf",
96
+ "type": "violation",
97
+ "description": "PUT endpoint without CSRF protection"
98
+ },
99
+ {
100
+ "name": "delete_without_csrf",
101
+ "type": "violation",
102
+ "description": "DELETE endpoint without CSRF protection"
103
+ },
104
+ {
105
+ "name": "global_csrf_protection",
106
+ "type": "clean",
107
+ "description": "Global CSRF middleware applied"
108
+ },
109
+ {
110
+ "name": "route_specific_csrf",
111
+ "type": "clean",
112
+ "description": "Route-specific CSRF middleware"
113
+ }
114
+ ]
115
+ },
116
+ "performance": {
117
+ "complexity": "O(n)",
118
+ "description": "Linear complexity based on number of route handlers in the source code"
119
+ },
120
+ "owaspMapping": {
121
+ "category": "A01:2021 – Broken Access Control",
122
+ "subcategories": [
123
+ "A04:2021 – Insecure Design"
124
+ ],
125
+ "description": "Validates that all state-changing endpoints have proper CSRF protection to prevent unauthorized cross-site requests"
126
+ }
127
+ }
@@ -77,7 +77,7 @@ class S030RegexBasedAnalyzer {
77
77
  /config(?:s|uration)?/g,
78
78
  /settings?/g,
79
79
  /secrets?/g,
80
- /keys?/g,
80
+ /\bkeys?\b/g, // Word boundary to avoid matching 'keyValidation', 'apiKey', etc.
81
81
  /backup(?:s)?/g,
82
82
  /database(?:s)?/g,
83
83
  /\\.aws/g,
@@ -133,8 +133,10 @@ class S030RegexBasedAnalyzer {
133
133
  /\\.types\\.ts$/,
134
134
  /\\.interface\\.ts$/,
135
135
  /\\.constants?\\.ts$/,
136
- /\\.spec\\.ts$/,
137
- /\\.test\\.ts$/,
136
+ /\\.spec\\.(ts|tsx|js|jsx)$/,
137
+ /\\.test\\.(ts|tsx|js|jsx)$/,
138
+ /__tests__\\//, // Test directories
139
+ /__mocks__\\//, // Mock directories
138
140
  /\\.min\\.js$/,
139
141
  /\\.bundle\\.js$/,
140
142
  /\\.model\\.ts$/,
@@ -302,19 +304,42 @@ class S030RegexBasedAnalyzer {
302
304
  const matches = [...line.matchAll(sensitivePattern)];
303
305
 
304
306
  for (const match of matches) {
307
+ const matchedText = match[0];
308
+ const matchIndex = match.index;
309
+
310
+ if (process.env.SUNLINT_DEBUG && matchedText.match(/^keys?$/i)) {
311
+ const beforeMatch = line.substring(Math.max(0, matchIndex - 20), matchIndex);
312
+ const afterMatch = line.substring(matchIndex + matchedText.length, matchIndex + matchedText.length + 20);
313
+ console.log(
314
+ `🔧 [S030-Regex] Found 'key' at line ${lineNumber}, col ${matchIndex}: "${line.substring(Math.max(0, matchIndex - 10), matchIndex + matchedText.length + 10)}"`
315
+ );
316
+ console.log(`🔧 [S030-Regex] Before: "${beforeMatch}"`);
317
+ console.log(`🔧 [S030-Regex] After: "${afterMatch}"`);
318
+ }
319
+
320
+ // Skip false positives
321
+ if (this.isFalsePositiveContext(line, matchedText, matchIndex)) {
322
+ if (process.env.SUNLINT_DEBUG && matchedText.match(/^keys?$/i)) {
323
+ console.log(
324
+ `🔧 [S030-Regex] ✓ Skipping 'key' as false positive at line ${lineNumber}`
325
+ );
326
+ }
327
+ continue;
328
+ }
329
+
305
330
  // Check if it's in a serving context (not just a string mention)
306
331
  if (this.isInServingContext(line)) {
307
332
  violations.push({
308
333
  ruleId: this.ruleId,
309
- message: `Potential exposure of sensitive file/directory '${match[0]}' - ensure proper access controls`,
334
+ message: `Potential exposure of sensitive file/directory '${matchedText}' - ensure proper access controls`,
310
335
  severity: "warning",
311
336
  line: lineNumber,
312
- column: match.index + 1,
337
+ column: matchIndex + 1,
313
338
  });
314
339
 
315
340
  if (process.env.SUNLINT_DEBUG) {
316
341
  console.log(
317
- `🔧 [S030-Regex] Found sensitive file exposure at line ${lineNumber}: ${match[0]}`
342
+ `🔧 [S030-Regex] Found sensitive file exposure at line ${lineNumber}: ${matchedText}`
318
343
  );
319
344
  }
320
345
  }
@@ -322,6 +347,104 @@ class S030RegexBasedAnalyzer {
322
347
  }
323
348
  }
324
349
 
350
+ isFalsePositiveContext(line, matchedText, matchIndex) {
351
+ // Get context around the match (extended for minified code)
352
+ const beforeMatch = line.substring(Math.max(0, matchIndex - 20), matchIndex);
353
+ const afterMatch = line.substring(matchIndex + matchedText.length, matchIndex + matchedText.length + 20);
354
+
355
+ // Only check 'key' and 'keys' - other sensitive keywords are valid
356
+ if (!matchedText.match(/^keys?$/i)) {
357
+ return false; // Let other keywords be checked normally
358
+ }
359
+
360
+ // Skip if 'key' is in a comment or string literal (English text)
361
+ // Check for common English phrases with 'key'
362
+ const extendedBefore = line.substring(Math.max(0, matchIndex - 50), matchIndex);
363
+ const extendedAfter = line.substring(matchIndex + matchedText.length, matchIndex + matchedText.length + 50);
364
+ const context = extendedBefore + matchedText + extendedAfter;
365
+
366
+ // Skip common English phrases and technical terms where 'key' is not a security issue
367
+ const keyPhrases = [
368
+ // English phrases
369
+ /\breturn\s+(this|the)\s+keys?\b/i,
370
+ /\b(this|the|a)\s+keys?\b/i,
371
+ /\bkeys?\s+(to|for|of|in)\b/i,
372
+ /\bwithout\s+(a|the)\s+keys?\b/i,
373
+ /\blease.*keys?\b/i, // lease key (physical car key)
374
+ /\bcar\s+keys?\b/i,
375
+ /\bvehicle\s+keys?\b/i,
376
+ // Technical/programming terms
377
+ /\bkeys?\s+event\b/i, // key event, keyboard event
378
+ /\bkeys?\s+(press|down|up|code)\b/i, // key press, key down, key up, key code
379
+ /\bevent\.keys?\b/i, // event.key
380
+ /\bkeyboard\b/i,
381
+ // Keyboard key name comparisons
382
+ /\bkeys?\s*(===|!==|==|!=)\s*['\"]?(Enter|Escape|Backspace|Delete|Tab|Space|Arrow|Shift|Control|Alt|Meta)/i,
383
+ /['\"]?(Enter|Escape|Backspace|Delete|Tab|Space|Arrow|Shift|Control|Alt|Meta)['\"]?\s*(===|!==|==|!=)\s*\bkeys?\b/i,
384
+ ];
385
+
386
+ if (keyPhrases.some(pattern => pattern.test(context))) {
387
+ return true;
388
+ }
389
+
390
+ // Skip TypeScript index signatures: [key: string], [key: number], etc.
391
+ if (beforeMatch.match(/\[\s*$/) && afterMatch.match(/^\s*:\s*\w+\s*\]/)) {
392
+ return true;
393
+ }
394
+
395
+ // Skip TypeScript/JavaScript property names: "key:", "key:" (with/without spaces)
396
+ if (afterMatch.match(/^\s*:/)) {
397
+ return true;
398
+ }
399
+
400
+ // Skip JSX attributes: key={...}, key="..."
401
+ if (afterMatch.match(/^\s*=/)) {
402
+ return true;
403
+ }
404
+
405
+ // Skip object/variable property access: obj.key, obj.keys, obj[key], arr[key], keys.reduce(), key.toString()
406
+ if (beforeMatch.match(/\.\s*$/) || beforeMatch.match(/\[\s*$/) || afterMatch.match(/^\s*\./)) {
407
+ return true;
408
+ }
409
+
410
+ // Skip destructuring: { key }, { key: value }
411
+ if (beforeMatch.match(/[{,]\s*$/) && afterMatch.match(/^\s*[,}:]/)) {
412
+ return true;
413
+ }
414
+
415
+ // Skip array map/forEach callbacks: .map((item, key) => ...), .forEach((value, key) => ...)
416
+ if (beforeMatch.match(/[,(]\s*\w+\s*,\s*$/)) {
417
+ return true;
418
+ }
419
+
420
+ // Skip variable declarations: const key = ..., let key = ..., var key = ...
421
+ if (beforeMatch.match(/(const|let|var)\s+$/)) {
422
+ return true;
423
+ }
424
+
425
+ // Skip function parameters: function(key), (key) =>, function foo(key)
426
+ if (beforeMatch.match(/[,(]\s*$/) && afterMatch.match(/^\s*[,)]/)) {
427
+ return true;
428
+ }
429
+
430
+ // Skip logical operators: sortKey || key, key && value
431
+ if (beforeMatch.match(/(\|\||&&)\s*$/) || afterMatch.match(/^\s*(\|\||&&)/)) {
432
+ return true;
433
+ }
434
+
435
+ // Skip import/export: import { key }, export { key }
436
+ if (beforeMatch.match(/(import|export)\s*{[^}]*$/)) {
437
+ return true;
438
+ }
439
+
440
+ // Skip type definitions: key?: string, key: string
441
+ if (afterMatch.match(/^\s*\?\s*:/) || afterMatch.match(/^\s*:\s*(string|number|boolean|any)/i)) {
442
+ return true;
443
+ }
444
+
445
+ return false;
446
+ }
447
+
325
448
  checkDangerousConfigurations(line, lineNumber, violations) {
326
449
  for (const [configName, pattern] of Object.entries(this.dangerousConfigs)) {
327
450
  const matches = [...line.matchAll(pattern)];
@@ -448,33 +571,42 @@ class S030RegexBasedAnalyzer {
448
571
  "private",
449
572
  ];
450
573
 
451
- return sensitiveKeywords.some(
452
- (keyword) =>
453
- normalizedPath.includes(keyword) ||
454
- normalizedPath.startsWith("./" + keyword) ||
455
- normalizedPath.endsWith("/" + keyword)
456
- );
574
+ return sensitiveKeywords.some((keyword) => {
575
+ // Create regex pattern to match:
576
+ // 1. Exact match (entire path is the sensitive keyword)
577
+ // 2. Preceded by path separator (/, ., -)
578
+ // 3. Followed by path separator (/, ., -)
579
+ // This prevents false positives like "settingStartDate" matching "setting"
580
+ const pattern = new RegExp(
581
+ `(^|[/.\-])${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([/.\-]|$)`,
582
+ 'i'
583
+ );
584
+
585
+ return pattern.test(normalizedPath);
586
+ });
457
587
  }
458
588
 
459
589
  isInServingContext(line) {
460
- const servingKeywords = [
461
- "static",
462
- "serve",
463
- "use",
464
- "mount",
465
- "register",
466
- "get",
467
- "post",
468
- "put",
469
- "delete",
470
- "patch",
471
- "sendFile",
472
- "express",
473
- "app",
474
- "router",
590
+ // More restrictive patterns - check for actual file serving contexts
591
+ const servingPatterns = [
592
+ /express\.static\s*\(/i,
593
+ /\.use\s*\(\s*['"`]/i, // app.use('/path', ...)
594
+ /\.serve/i, // serveStatic, serve-index
595
+ /sendFile\s*\(/i,
596
+ /ServeStatic/i,
597
+ /serve-index/i,
598
+ /koa-static/i,
599
+ /fastify.*static/i,
600
+ /\.register\s*\(\s*require/i,
601
+ /\.mount\s*\(/i,
602
+ /\.(get|post|put|delete|patch)\s*\(\s*['"`][^'"`]*\//i, // Route with path
603
+ /@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]/i, // NestJS decorator
604
+ /root\s*:\s*['"`]/i, // {root: 'path'}
605
+ /path\s*:\s*['"`]/i, // {path: 'dir'}
606
+ /dir\s*:\s*['"`]/i, // {dir: 'path'}
475
607
  ];
476
608
 
477
- return servingKeywords.some((keyword) => line.includes(keyword));
609
+ return servingPatterns.some((pattern) => pattern.test(line));
478
610
  }
479
611
 
480
612
  cleanup() {}
@@ -77,8 +77,10 @@ class S030SymbolBasedAnalyzer {
77
77
  /\.types\.ts$/,
78
78
  /\.interface\.ts$/,
79
79
  /\.constants?\.ts$/,
80
- /\.spec\.ts$/,
81
- /\.test\.ts$/,
80
+ /\.spec\.(ts|tsx|js|jsx)$/,
81
+ /\.test\.(ts|tsx|js|jsx)$/,
82
+ /__tests__\//, // Test directories
83
+ /__mocks__\//, // Mock directories
82
84
  /\.model\.ts$/,
83
85
  /\.entity\.ts$/,
84
86
  /\.dto\.ts$/,
@@ -335,6 +337,13 @@ class S030SymbolBasedAnalyzer {
335
337
  ) {
336
338
  const path = initializer.getLiteralValue();
337
339
 
340
+ // Skip application routes (UI paths that are not file system paths)
341
+ // Application routes typically start with / and don't have file extensions
342
+ // Examples: /dashboard/settings, /api/users, /admin/config
343
+ if (this.isApplicationRoute(path)) {
344
+ return;
345
+ }
346
+
338
347
  if (this.isSensitivePath(path)) {
339
348
  const startLine = variable.getStartLineNumber();
340
349
  violations.push({
@@ -348,6 +357,29 @@ class S030SymbolBasedAnalyzer {
348
357
  }
349
358
  }
350
359
 
360
+ isApplicationRoute(path) {
361
+ if (!path || typeof path !== "string") return false;
362
+
363
+ // Application routes start with / and typically don't have file extensions
364
+ // and don't start with dotfiles or contain actual file indicators
365
+ if (path.startsWith("/")) {
366
+ // Check if it looks like a file path vs app route
367
+ const hasFileExtension = /\.[a-z0-9]+$/i.test(path);
368
+ const hasDotfile = /\/\.[a-z]/i.test(path) || path.startsWith("/.");
369
+ const hasFileIndicator = /\.(env|git|ssh|yml|yaml|json|xml|sql|log)($|\/)/i.test(path);
370
+
371
+ // If it has file indicators, it's a file path, not an app route
372
+ if (hasFileExtension || hasDotfile || hasFileIndicator) {
373
+ return false;
374
+ }
375
+
376
+ // Otherwise, it's likely an application route
377
+ return true;
378
+ }
379
+
380
+ return false;
381
+ }
382
+
351
383
  analyzeConfigurationObjects(objLiteral, violations, filePath) {
352
384
  const properties = objLiteral.getProperties();
353
385
 
@@ -397,19 +429,49 @@ class S030SymbolBasedAnalyzer {
397
429
  isSensitivePath(path) {
398
430
  if (!path || typeof path !== "string") return false;
399
431
 
432
+ // Skip application routes (URL paths that are not file references)
433
+ if (this.isApplicationRoute(path)) {
434
+ return false;
435
+ }
436
+
400
437
  const normalizedPath = path.toLowerCase();
401
438
 
402
439
  return this.sensitiveFiles.some((sensitive) => {
403
- // Check if path contains sensitive file/directory patterns
404
- return (
405
- normalizedPath.includes(sensitive.toLowerCase()) ||
406
- normalizedPath.startsWith("/" + sensitive.toLowerCase()) ||
407
- normalizedPath.startsWith("./" + sensitive.toLowerCase()) ||
408
- normalizedPath.endsWith("/" + sensitive.toLowerCase())
440
+ const sensitiveLower = sensitive.toLowerCase();
441
+
442
+ // Create regex pattern to match:
443
+ // 1. Exact match (entire path is the sensitive keyword)
444
+ // 2. Preceded by path separator (/, ., -)
445
+ // 3. Followed by path separator (/, ., -)
446
+ // This prevents false positives like "settingStartDate" matching "setting"
447
+ const pattern = new RegExp(
448
+ `(^|[/.\-])${sensitiveLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([/.\-]|$)`,
449
+ 'i'
409
450
  );
451
+
452
+ return pattern.test(normalizedPath);
410
453
  });
411
454
  }
412
455
 
456
+ isApplicationRoute(path) {
457
+ if (!path || typeof path !== "string") return false;
458
+
459
+ // If path starts with / and doesn't have file extensions or dotfiles, likely an app route
460
+ if (path.startsWith("/")) {
461
+ const hasFileExtension = /\.[a-z0-9]+$/i.test(path);
462
+ const hasDotfile = /\/\.[a-z]/i.test(path) || path.startsWith("/.");
463
+ const hasFileIndicator = /\.(env|git|ssh|yml|yaml|json|xml|sql|log)($|\/)/i.test(path);
464
+
465
+ if (hasFileExtension || hasDotfile || hasFileIndicator) {
466
+ return false; // It's a file reference
467
+ }
468
+
469
+ return true; // It's an application route
470
+ }
471
+
472
+ return false;
473
+ }
474
+
413
475
  isTruthyValue(valueNode) {
414
476
  const { SyntaxKind } = require("ts-morph");
415
477
 
@@ -500,28 +562,28 @@ class S030SymbolBasedAnalyzer {
500
562
  const httpMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
501
563
 
502
564
  if (httpMethods.includes(funcName)) {
503
- // Check function body for sensitive file operations
565
+ // Check function body for actual file serving operations (not just string mentions)
504
566
  const body = func.getBody();
505
567
  if (body) {
506
568
  const bodyText = body.getText();
507
569
 
508
- // Check for sensitive file paths in the function body
509
- const sensitivePatterns = [
510
- /['"`][^'"`]*\.env[^'"`]*['"`]/g,
511
- /['"`][^'"`]*\.git[^'"`]*['"`]/g,
512
- /['"`][^'"`]*config[^'"`]*['"`]/g,
513
- /['"`][^'"`]*backup[^'"`]*['"`]/g,
514
- /['"`][^'"`]*secret[^'"`]*['"`]/g,
515
- /['"`][^'"`]*\.ssh[^'"`]*['"`]/g,
570
+ // Only detect actual file operations that expose sensitive files
571
+ // (not just variable names or env vars containing "secret", "config", etc.)
572
+ const fileOperationPatterns = [
573
+ // File serving operations with sensitive paths
574
+ /(?:sendFile|readFile|writeFile|readFileSync|writeFileSync)\s*\(\s*['"`][^'"`]*(?:\.env|\.git|\.ssh|config\/|backup\/|secrets\/)[^'"`]*['"`]/g,
575
+ /(?:express\.static|serveStatic|staticFiles)\s*\(\s*['"`][^'"`]*(?:\.env|\.git|\.ssh|config|backup|secrets)[^'"`]*['"`]/g,
576
+ // Direct file path exposure in response
577
+ /(?:res|response)\.send(?:File)?\s*\(\s*['"`][^'"`]*(?:\.env|\.git|\.ssh)[^'"`]*['"`]/g,
516
578
  ];
517
579
 
518
- for (const pattern of sensitivePatterns) {
580
+ for (const pattern of fileOperationPatterns) {
519
581
  const matches = [...bodyText.matchAll(pattern)];
520
582
  if (matches.length > 0) {
521
583
  const startLine = func.getStartLineNumber();
522
584
  violations.push({
523
585
  ruleId: this.ruleId,
524
- message: `NextJS API route '${funcName}' contains sensitive file references - ensure proper access controls`,
586
+ message: `NextJS API route '${funcName}' contains file operations on sensitive paths - ensure proper access controls`,
525
587
  severity: "error",
526
588
  line: startLine,
527
589
  column: 1,
@@ -163,7 +163,9 @@ class S031Analyzer {
163
163
 
164
164
  // Add to violation map with deduplication
165
165
  symbolViolations.forEach((violation) => {
166
- const key = `${violation.line}:${violation.column}:${violation.message}`;
166
+ // Create a cookie-specific key to allow multiple violations for same cookie at different locations
167
+ const cookieName = this.extractCookieName(violation.message) || "";
168
+ const key = `cookie:${cookieName}:line:${violation.line}:secure`;
167
169
  if (!violationMap.has(key)) {
168
170
  violationMap.set(key, violation);
169
171
  }
@@ -194,7 +196,9 @@ class S031Analyzer {
194
196
 
195
197
  // Add to violation map with deduplication
196
198
  regexViolations.forEach((violation) => {
197
- const key = `${violation.line}:${violation.column}:${violation.message}`;
199
+ // Create a cookie-specific key to allow multiple violations for same cookie at different locations
200
+ const cookieName = this.extractCookieName(violation.message) || "";
201
+ const key = `cookie:${cookieName}:line:${violation.line}:secure`;
198
202
  if (!violationMap.has(key)) {
199
203
  violationMap.set(key, violation);
200
204
  }
@@ -228,6 +232,20 @@ class S031Analyzer {
228
232
  return finalViolations;
229
233
  }
230
234
 
235
+ /**
236
+ * Extract cookie name from violation message for better deduplication
237
+ */
238
+ extractCookieName(message) {
239
+ try {
240
+ const match = message.match(
241
+ /Session cookie "([^"]+)"|Session cookie from "([^"]+)"/
242
+ );
243
+ return match ? match[1] || match[2] : "";
244
+ } catch (error) {
245
+ return "";
246
+ }
247
+ }
248
+
231
249
  /**
232
250
  * Clean up resources
233
251