@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.
- package/config/rules/enhanced-rules-registry.json +101 -17
- package/config/rules/rules-registry-generated.json +22 -22
- package/origin-rules/security-en.md +351 -338
- package/package.json +1 -1
- package/rules/common/C003_no_vague_abbreviations/analyzer.js +73 -21
- package/rules/common/C017_constructor_logic/symbol-based-analyzer.js +206 -2
- package/rules/common/C024_no_scatter_hardcoded_constants/symbol-based-analyzer.js +553 -58
- package/rules/common/C029_catch_block_logging/analyzer.js +47 -12
- package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +35 -15
- package/rules/common/C041_no_sensitive_hardcode/symbol-based-analyzer.js +9 -5
- package/rules/security/S003_open_redirect_protection/README.md +371 -0
- package/rules/security/S003_open_redirect_protection/analyzer.js +135 -0
- package/rules/security/S003_open_redirect_protection/config.json +58 -0
- package/rules/security/S003_open_redirect_protection/symbol-based-analyzer.js +884 -0
- package/rules/security/S004_sensitive_data_logging/analyzer.js +135 -0
- package/rules/security/S004_sensitive_data_logging/config.json +62 -0
- package/rules/security/S004_sensitive_data_logging/symbol-based-analyzer.js +592 -0
- package/rules/security/S005_no_origin_auth/analyzer.js +97 -148
- package/rules/security/S005_no_origin_auth/config.json +28 -67
- package/rules/security/S005_no_origin_auth/symbol-based-analyzer.js +708 -0
- package/rules/security/S006_no_plaintext_recovery_codes/symbol-based-analyzer.js +170 -31
- package/rules/security/S010_no_insecure_encryption/analyzer.js +8 -2
- package/rules/security/S012_hardcoded_secrets/analyzer.js +149 -0
- package/rules/security/S012_hardcoded_secrets/config.json +75 -0
- package/rules/security/S012_hardcoded_secrets/symbol-based-analyzer.js +1204 -0
- package/rules/security/S013_tls_enforcement/symbol-based-analyzer.js +87 -0
- package/rules/security/S017_use_parameterized_queries/analyzer.js +11 -78
- package/rules/security/S017_use_parameterized_queries/symbol-based-analyzer.js +1146 -1
- package/rules/security/S019_smtp_injection_protection/analyzer.js +120 -0
- package/rules/security/S019_smtp_injection_protection/config.json +35 -0
- package/rules/security/S019_smtp_injection_protection/symbol-based-analyzer.js +687 -0
- package/rules/security/S020_no_eval_dynamic_code/analyzer.js +55 -130
- package/rules/security/S020_no_eval_dynamic_code/symbol-based-analyzer.js +4 -19
- package/rules/security/S022_escape_output_context/README.md +254 -0
- package/rules/security/S022_escape_output_context/analyzer.js +510 -0
- package/rules/security/S022_escape_output_context/config.json +229 -0
- package/rules/security/S023_no_json_injection/analyzer.js +15 -0
- package/rules/security/S023_no_json_injection/ast-analyzer.js +18 -3
- package/rules/security/S023_no_json_injection/config.json +133 -0
- package/rules/security/S024_xpath_xxe_protection/regex-based-analyzer.js +41 -0
- package/rules/security/S027_no_hardcoded_secrets/analyzer.js +67 -8
- package/rules/security/S027_no_hardcoded_secrets/categorized-analyzer.js +29 -6
- package/rules/security/S029_csrf_protection/config.json +127 -0
- package/rules/security/S030_directory_browsing_protection/regex-based-analyzer.js +160 -28
- package/rules/security/S030_directory_browsing_protection/symbol-based-analyzer.js +81 -19
- package/rules/security/S031_secure_session_cookies/analyzer.js +20 -2
- package/rules/security/S031_secure_session_cookies/regex-based-analyzer.js +100 -0
- package/rules/security/S031_secure_session_cookies/symbol-based-analyzer.js +8 -1
- package/rules/security/S032_httponly_session_cookies/analyzer.js +2 -2
- package/rules/security/S032_httponly_session_cookies/regex-based-analyzer.js +115 -0
- package/rules/security/S032_httponly_session_cookies/symbol-based-analyzer.js +39 -10
- package/rules/security/S036_lfi_rfi_protection/analyzer.js +224 -0
- package/rules/security/S036_lfi_rfi_protection/config.json +20 -0
- package/rules/security/S040_session_fixation_protection/analyzer.js +153 -0
- package/rules/security/S040_session_fixation_protection/config.json +20 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/README.md +83 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/analyzer.js +153 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/config.json +41 -0
- package/rules/security/S042_require_re_authentication_for_long_lived/symbol-based-analyzer.js +1139 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/README.md +107 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/analyzer.js +153 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/config.json +41 -0
- package/rules/security/S043_password_changes_invalidate_all_sessions/symbol-based-analyzer.js +541 -0
- package/docs/COMMAND-EXAMPLES.md +0 -390
- package/docs/FILE_LIMITS_COMPLETION_REPORT.md +0 -151
- package/docs/FOLDER_STRUCTURE.md +0 -59
- package/docs/SIMPLIFIED_USAGE_GUIDE.md +0 -208
- package/rules/security/S017_use_parameterized_queries/regex-based-analyzer.js +0 -541
- 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
|
-
/
|
|
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 '${
|
|
334
|
+
message: `Potential exposure of sensitive file/directory '${matchedText}' - ensure proper access controls`,
|
|
310
335
|
severity: "warning",
|
|
311
336
|
line: lineNumber,
|
|
312
|
-
column:
|
|
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}: ${
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
"
|
|
473
|
-
"
|
|
474
|
-
"
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
/['"`][^'"`]*config[^'"`]*['"`]/g,
|
|
513
|
-
/['"`][^'"`]*backup[^'"`]*['"`]/g,
|
|
514
|
-
|
|
515
|
-
/['"`][^'"`]
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|