@sun-asterisk/sunlint 1.3.26 → 1.3.27
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 +99 -16
- package/package.json +1 -1
- 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/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/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/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/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
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# S003 - Open Redirect Protection
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Quy tắc này phát hiện lỗ hổng Open Redirect - nơi ứng dụng chuyển hướng người dùng đến URL được cung cấp từ đầu vào mà không xác thực. Kẻ tấn công có thể lợi dụng để chuyển hướng nạn nhân đến trang độc hại (phishing, malware) thông qua link có vẻ hợp lệ.
|
|
6
|
+
|
|
7
|
+
## OWASP Classification
|
|
8
|
+
|
|
9
|
+
- **Category**: A03:2021 - Injection
|
|
10
|
+
- **CWE**: CWE-601 - URL Redirection to Untrusted Site ('Open Redirect')
|
|
11
|
+
- **Severity**: Warning
|
|
12
|
+
- **Impact**: Medium (Phishing attacks, credential theft, malware distribution)
|
|
13
|
+
|
|
14
|
+
## Vấn đề
|
|
15
|
+
|
|
16
|
+
Khi ứng dụng chuyển hướng người dùng đến URL từ đầu vào mà không xác thực:
|
|
17
|
+
|
|
18
|
+
1. **Phishing attacks**: Kẻ tấn công có thể tạo link hợp lệ dẫn đến trang giả mạo
|
|
19
|
+
2. **Credential theft**: Người dùng tin tưởng domain gốc và nhập thông tin nhạy cảm
|
|
20
|
+
3. **Malware distribution**: Chuyển hướng đến trang chứa malware
|
|
21
|
+
4. **Bypass security controls**: Vượt qua whitelist/blacklist dựa trên domain
|
|
22
|
+
|
|
23
|
+
## Các trường hợp vi phạm
|
|
24
|
+
|
|
25
|
+
### 1. Redirect trực tiếp từ query parameter
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
// ❌ Vi phạm - Redirect trực tiếp không kiểm tra
|
|
29
|
+
app.get('/redirect', (req, res) => {
|
|
30
|
+
const url = req.query.url;
|
|
31
|
+
res.redirect(url); // Nguy hiểm!
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ❌ Vi phạm - Express redirect
|
|
35
|
+
router.get('/goto', (req, res) => {
|
|
36
|
+
res.redirect(req.query.redirect_url);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ❌ Vi phạm - Location header
|
|
40
|
+
app.get('/forward', (req, res) => {
|
|
41
|
+
res.setHeader('Location', req.query.next);
|
|
42
|
+
res.status(302).send();
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Client-side redirect không validate
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
// ❌ Vi phạm - window.location redirect
|
|
50
|
+
const redirectUrl = new URLSearchParams(window.location.search).get('redirect');
|
|
51
|
+
window.location = redirectUrl;
|
|
52
|
+
|
|
53
|
+
// ❌ Vi phạm - window.location.href
|
|
54
|
+
const next = getQueryParam('next');
|
|
55
|
+
window.location.href = next;
|
|
56
|
+
|
|
57
|
+
// ❌ Vi phạm - window.location.replace
|
|
58
|
+
window.location.replace(req.params.url);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. NestJS redirects
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// ❌ Vi phạm - NestJS @Redirect decorator
|
|
65
|
+
@Controller('redirect')
|
|
66
|
+
export class RedirectController {
|
|
67
|
+
@Get()
|
|
68
|
+
@Redirect()
|
|
69
|
+
redirect(@Query('url') url: string) {
|
|
70
|
+
return { url }; // Nguy hiểm!
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ❌ Vi phạm - NestJS res.redirect
|
|
75
|
+
@Get()
|
|
76
|
+
goto(@Query('target') target: string, @Res() res: Response) {
|
|
77
|
+
res.redirect(target);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 4. Next.js redirects (App Router)
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// ❌ Vi phạm - Next.js redirect
|
|
85
|
+
import { redirect } from 'next/navigation';
|
|
86
|
+
|
|
87
|
+
export default async function Page({ searchParams }) {
|
|
88
|
+
redirect(searchParams.url); // Nguy hiểm!
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ❌ Vi phạm - Next.js permanentRedirect
|
|
92
|
+
import { permanentRedirect } from 'next/navigation';
|
|
93
|
+
|
|
94
|
+
export default function RedirectPage({ searchParams }) {
|
|
95
|
+
permanentRedirect(searchParams.target);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ❌ Vi phạm - Next.js router.push
|
|
99
|
+
'use client';
|
|
100
|
+
export default function ClientRedirect() {
|
|
101
|
+
const router = useRouter();
|
|
102
|
+
const searchParams = useSearchParams();
|
|
103
|
+
|
|
104
|
+
const url = searchParams.get('url');
|
|
105
|
+
router.push(url); // Nguy hiểm!
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 5. Nuxt.js redirects
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// ❌ Vi phạm - Nuxt.js navigateTo
|
|
113
|
+
export default defineComponent({
|
|
114
|
+
setup() {
|
|
115
|
+
const route = useRoute();
|
|
116
|
+
const url = route.query.url;
|
|
117
|
+
navigateTo(url); // Nguy hiểm!
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ❌ Vi phạm - Nuxt.js sendRedirect
|
|
122
|
+
export default defineEventHandler((event) => {
|
|
123
|
+
const { url } = getQuery(event);
|
|
124
|
+
return sendRedirect(event, url);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 6. Spring Boot/Java redirects
|
|
129
|
+
|
|
130
|
+
```java
|
|
131
|
+
// ❌ Vi phạm - Spring RedirectView
|
|
132
|
+
@GetMapping("/redirect")
|
|
133
|
+
public RedirectView redirect(@RequestParam String url) {
|
|
134
|
+
return new RedirectView(url);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ❌ Vi phạm - sendRedirect
|
|
138
|
+
@GetMapping("/forward")
|
|
139
|
+
public void forward(@RequestParam String target, HttpServletResponse response) {
|
|
140
|
+
response.sendRedirect(target);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Giải pháp an toàn
|
|
145
|
+
|
|
146
|
+
### 1. Sử dụng Allow List (Whitelist)
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
// ✅ An toàn - Allow list
|
|
150
|
+
const ALLOWED_DOMAINS = [
|
|
151
|
+
'https://example.com',
|
|
152
|
+
'https://app.example.com'
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
app.get('/redirect', (req, res) => {
|
|
156
|
+
const url = req.query.url;
|
|
157
|
+
if (!ALLOWED_DOMAINS.includes(url)) {
|
|
158
|
+
return res.status(400).send('Invalid redirect URL');
|
|
159
|
+
}
|
|
160
|
+
res.redirect(url);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ✅ An toàn - Domain validation
|
|
164
|
+
function isAllowedUrl(urlString) {
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(urlString);
|
|
167
|
+
const allowedHosts = ['example.com', 'app.example.com'];
|
|
168
|
+
return allowedHosts.includes(url.hostname);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 2. Relative URL only
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
// ✅ An toàn - Chỉ cho phép relative URLs
|
|
179
|
+
function isRelativeUrl(url) {
|
|
180
|
+
return url && url.startsWith('/') && !url.startsWith('//');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
app.get('/redirect', (req, res) => {
|
|
184
|
+
const path = req.query.next;
|
|
185
|
+
if (!isRelativeUrl(path)) {
|
|
186
|
+
return res.status(400).send('Only relative URLs allowed');
|
|
187
|
+
}
|
|
188
|
+
res.redirect(path);
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3. Indirect redirect mapping
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
// ✅ An toàn - Mapping key
|
|
196
|
+
const REDIRECT_MAP = {
|
|
197
|
+
'home': '/',
|
|
198
|
+
'dashboard': '/dashboard',
|
|
199
|
+
'profile': '/user/profile'
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
app.get('/redirect', (req, res) => {
|
|
203
|
+
const key = req.query.destination;
|
|
204
|
+
const url = REDIRECT_MAP[key];
|
|
205
|
+
if (!url) {
|
|
206
|
+
return res.status(400).send('Invalid destination');
|
|
207
|
+
}
|
|
208
|
+
res.redirect(url);
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 4. Framework-specific solutions
|
|
213
|
+
|
|
214
|
+
#### NestJS
|
|
215
|
+
```typescript
|
|
216
|
+
// ✅ An toàn - NestJS với DTO validation
|
|
217
|
+
import { IsIn } from 'class-validator';
|
|
218
|
+
|
|
219
|
+
class RedirectDto {
|
|
220
|
+
@IsIn(['home', 'dashboard', 'profile'])
|
|
221
|
+
destination: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Controller('redirect')
|
|
225
|
+
export class SafeController {
|
|
226
|
+
@Get()
|
|
227
|
+
redirect(@Query() query: RedirectDto) {
|
|
228
|
+
const urlMap = {
|
|
229
|
+
home: '/',
|
|
230
|
+
dashboard: '/dashboard',
|
|
231
|
+
profile: '/profile'
|
|
232
|
+
};
|
|
233
|
+
return { url: urlMap[query.destination] }; // Safe
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ✅ An toàn - NestJS với custom validation
|
|
238
|
+
@Controller('goto')
|
|
239
|
+
export class ValidatedController {
|
|
240
|
+
private readonly allowedUrls = ['https://example.com'];
|
|
241
|
+
|
|
242
|
+
@Get()
|
|
243
|
+
goto(@Query('url') url: string, @Res() res: Response) {
|
|
244
|
+
if (this.allowedUrls.includes(url)) {
|
|
245
|
+
res.redirect(url); // Safe
|
|
246
|
+
} else {
|
|
247
|
+
throw new BadRequestException('Invalid URL');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Next.js (App Router)
|
|
254
|
+
```typescript
|
|
255
|
+
// ✅ An toàn - Next.js với allowlist
|
|
256
|
+
const ALLOWED_PATHS = ['/home', '/dashboard', '/profile'];
|
|
257
|
+
|
|
258
|
+
export default function SafeRedirect({ searchParams }) {
|
|
259
|
+
const path = searchParams.path;
|
|
260
|
+
|
|
261
|
+
if (ALLOWED_PATHS.includes(path)) {
|
|
262
|
+
redirect(path); // Safe
|
|
263
|
+
} else {
|
|
264
|
+
redirect('/'); // Safe default
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ✅ An toàn - Next.js với mapping
|
|
269
|
+
const REDIRECT_MAP = {
|
|
270
|
+
'success': '/success',
|
|
271
|
+
'error': '/error'
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default function MappedRedirect({ searchParams }) {
|
|
275
|
+
const key = searchParams.destination;
|
|
276
|
+
redirect(REDIRECT_MAP[key] || '/'); // Safe
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ✅ An toàn - Next.js router với validation
|
|
280
|
+
'use client';
|
|
281
|
+
export default function SafeClientRedirect() {
|
|
282
|
+
const router = useRouter();
|
|
283
|
+
const searchParams = useSearchParams();
|
|
284
|
+
|
|
285
|
+
const handleRedirect = () => {
|
|
286
|
+
const path = searchParams.get('path');
|
|
287
|
+
const safePaths = ['/home', '/about'];
|
|
288
|
+
|
|
289
|
+
if (safePaths.includes(path)) {
|
|
290
|
+
router.push(path); // Safe
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Nuxt.js
|
|
297
|
+
```typescript
|
|
298
|
+
// ✅ An toàn - Nuxt.js với validation
|
|
299
|
+
const ALLOWED_PATHS = ['/home', '/dashboard'];
|
|
300
|
+
|
|
301
|
+
export default defineEventHandler((event) => {
|
|
302
|
+
const { path } = getQuery(event);
|
|
303
|
+
|
|
304
|
+
if (ALLOWED_PATHS.includes(path)) {
|
|
305
|
+
return navigateTo(path); // Safe
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return navigateTo('/'); // Safe default
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ✅ An toàn - Nuxt.js với mapping
|
|
312
|
+
const ROUTE_MAP = {
|
|
313
|
+
'profile': '/user/profile',
|
|
314
|
+
'settings': '/settings'
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
export default defineComponent({
|
|
318
|
+
setup() {
|
|
319
|
+
const route = useRoute();
|
|
320
|
+
|
|
321
|
+
const redirect = () => {
|
|
322
|
+
const key = route.query.destination;
|
|
323
|
+
const url = ROUTE_MAP[key] || '/';
|
|
324
|
+
navigateTo(url); // Safe
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ✅ An toàn - Nuxt.js với domain validation
|
|
330
|
+
export default defineEventHandler((event) => {
|
|
331
|
+
const { url } = getQuery(event);
|
|
332
|
+
const allowedDomains = ['example.com'];
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const parsed = new URL(url);
|
|
336
|
+
if (allowedDomains.includes(parsed.hostname)) {
|
|
337
|
+
return sendRedirect(event, url); // Safe
|
|
338
|
+
}
|
|
339
|
+
} catch {
|
|
340
|
+
throw createError({ statusCode: 400 });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Phương pháp phát hiện
|
|
346
|
+
|
|
347
|
+
Rule này sử dụng symbol-based analysis để phát hiện:
|
|
348
|
+
|
|
349
|
+
1. **Redirect functions**:
|
|
350
|
+
- Express: `res.redirect()`
|
|
351
|
+
- NestJS: `@Redirect()`, `res.redirect()`
|
|
352
|
+
- Next.js: `redirect()`, `permanentRedirect()`, `router.push()`
|
|
353
|
+
- Nuxt.js: `navigateTo()`, `sendRedirect()`
|
|
354
|
+
- Spring: `response.sendRedirect()`
|
|
355
|
+
- Generic: `window.location`, `setHeader('Location')`
|
|
356
|
+
|
|
357
|
+
2. **User input sources**:
|
|
358
|
+
- `req.query`, `req.params`, `req.body`
|
|
359
|
+
- `@Query()`, `@Param()`, `@Body()` (NestJS)
|
|
360
|
+
- `searchParams`, `useSearchParams()` (Next.js)
|
|
361
|
+
- `useRoute()`, `getQuery()`, `event.query` (Nuxt.js)
|
|
362
|
+
- `URLSearchParams.get()`
|
|
363
|
+
|
|
364
|
+
3. **Dataflow analysis**: Track từ user input đến redirect function
|
|
365
|
+
4. **Validation check**: Kiểm tra có whitelist/validation hay không
|
|
366
|
+
|
|
367
|
+
## Tài liệu tham khảo
|
|
368
|
+
|
|
369
|
+
- [OWASP A03:2021 - Injection](https://owasp.org/Top10/A03_2021-Injection/)
|
|
370
|
+
- [CWE-601: URL Redirection to Untrusted Site](https://cwe.mitre.org/data/definitions/601.html)
|
|
371
|
+
- [OWASP Unvalidated Redirects Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S003 - Open Redirect Protection
|
|
3
|
+
*
|
|
4
|
+
* Main analyzer using symbol-based analysis to detect unvalidated URL redirects
|
|
5
|
+
* from user input.
|
|
6
|
+
*
|
|
7
|
+
* Based on:
|
|
8
|
+
* - OWASP A03:2021 - Injection
|
|
9
|
+
* - CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Command: node cli.js --rule=S003 --input=examples/rule-test-fixtures/rules/S003_open_redirect_protection --engine=heuristic
|
|
13
|
+
|
|
14
|
+
const S003SymbolBasedAnalyzer = require("./symbol-based-analyzer");
|
|
15
|
+
|
|
16
|
+
class S003Analyzer {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.ruleId = "S003";
|
|
19
|
+
this.semanticEngine = options.semanticEngine || null;
|
|
20
|
+
this.verbose = options.verbose || false;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
this.symbolAnalyzer = new S003SymbolBasedAnalyzer(this.semanticEngine);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.warn(`⚠ [S003] Failed to create symbol analyzer: ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async initialize(semanticEngine) {
|
|
30
|
+
this.semanticEngine = semanticEngine;
|
|
31
|
+
if (this.symbolAnalyzer && this.symbolAnalyzer.initialize) {
|
|
32
|
+
await this.symbolAnalyzer.initialize(semanticEngine);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
analyzeSingle(filePath, options = {}) {
|
|
37
|
+
return this.analyze([filePath], "typescript", options);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async analyze(files, language, options = {}) {
|
|
41
|
+
const violations = [];
|
|
42
|
+
for (const filePath of files) {
|
|
43
|
+
try {
|
|
44
|
+
const vs = await this.analyzeFile(filePath, options);
|
|
45
|
+
violations.push(...vs);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.warn(`⚠ [S003] Analysis error for ${filePath}: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return violations;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async analyzeFile(filePath, options = {}) {
|
|
54
|
+
const violationMap = new Map();
|
|
55
|
+
|
|
56
|
+
if (!this.symbolAnalyzer) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Skip test files, build directories, and node_modules
|
|
61
|
+
if (this.shouldSkipFile(filePath)) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
let sourceFile = null;
|
|
67
|
+
if (this.semanticEngine?.project) {
|
|
68
|
+
sourceFile = this.semanticEngine.project.getSourceFile(filePath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!sourceFile) {
|
|
72
|
+
// Create temporary ts-morph source file
|
|
73
|
+
const fs = require("fs");
|
|
74
|
+
const path = require("path");
|
|
75
|
+
const { Project } = require("ts-morph");
|
|
76
|
+
if (!fs.existsSync(filePath)) {
|
|
77
|
+
throw new Error(`File not found: ${filePath}`);
|
|
78
|
+
}
|
|
79
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
80
|
+
const tmp = new Project({
|
|
81
|
+
useInMemoryFileSystem: true,
|
|
82
|
+
compilerOptions: { allowJs: true },
|
|
83
|
+
});
|
|
84
|
+
sourceFile = tmp.createSourceFile(path.basename(filePath), content);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sourceFile) {
|
|
88
|
+
const symbolViolations = await this.symbolAnalyzer.analyze(
|
|
89
|
+
sourceFile,
|
|
90
|
+
filePath
|
|
91
|
+
);
|
|
92
|
+
symbolViolations.forEach((v) => {
|
|
93
|
+
const key = `${v.line}:${v.column}:${v.message}`;
|
|
94
|
+
if (!violationMap.has(key)) violationMap.set(key, v);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.warn(`⚠ [S003] Symbol analysis failed: ${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Array.from(violationMap.values()).map((v) => ({
|
|
102
|
+
...v,
|
|
103
|
+
filePath,
|
|
104
|
+
file: filePath,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
shouldSkipFile(filePath) {
|
|
109
|
+
const skipPatterns = [
|
|
110
|
+
"test/",
|
|
111
|
+
"tests/",
|
|
112
|
+
"__tests__/",
|
|
113
|
+
".test.",
|
|
114
|
+
".spec.",
|
|
115
|
+
"node_modules/",
|
|
116
|
+
"build/",
|
|
117
|
+
"dist/",
|
|
118
|
+
".next/",
|
|
119
|
+
"coverage/",
|
|
120
|
+
"vendor/",
|
|
121
|
+
"mocks/",
|
|
122
|
+
".mock.",
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
return skipPatterns.some((pattern) => filePath.includes(pattern));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
cleanup() {
|
|
129
|
+
if (this.symbolAnalyzer?.cleanup) {
|
|
130
|
+
this.symbolAnalyzer.cleanup();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = S003Analyzer;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ruleId": "S003",
|
|
3
|
+
"name": "Open Redirect Protection",
|
|
4
|
+
"description": "URL redirects must validate against an allow list to prevent open redirect vulnerabilities",
|
|
5
|
+
"category": "security",
|
|
6
|
+
"severity": "warning",
|
|
7
|
+
"languages": ["All languages"],
|
|
8
|
+
"tags": [
|
|
9
|
+
"security",
|
|
10
|
+
"owasp",
|
|
11
|
+
"injection",
|
|
12
|
+
"open-redirect",
|
|
13
|
+
"phishing",
|
|
14
|
+
"url-validation"
|
|
15
|
+
],
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"fixable": false,
|
|
18
|
+
"engine": "heuristic",
|
|
19
|
+
"metadata": {
|
|
20
|
+
"owaspCategory": "A03:2021 - Injection",
|
|
21
|
+
"cweId": "CWE-601",
|
|
22
|
+
"description": "Applications that redirect users to URLs from untrusted input without validation are vulnerable to phishing attacks. Attackers can create legitimate-looking links that redirect victims to malicious sites.",
|
|
23
|
+
"impact": "Medium - Phishing attacks, credential theft, malware distribution",
|
|
24
|
+
"likelihood": "High",
|
|
25
|
+
"remediation": "Use allow list (whitelist) to validate redirect URLs, or only allow relative URLs within the same domain"
|
|
26
|
+
},
|
|
27
|
+
"patterns": {
|
|
28
|
+
"vulnerable": [
|
|
29
|
+
"res.redirect(req.query.*) without validation",
|
|
30
|
+
"res.redirect(req.params.*) without validation",
|
|
31
|
+
"window.location = userInput without validation",
|
|
32
|
+
"response.sendRedirect(request.getParameter(*)) without validation",
|
|
33
|
+
"new RedirectView(userInput) without validation",
|
|
34
|
+
"res.setHeader('Location', req.query.*) without validation"
|
|
35
|
+
],
|
|
36
|
+
"secure": [
|
|
37
|
+
"Validate against allow list (whitelist) of trusted domains",
|
|
38
|
+
"Only allow relative URLs (startsWith('/') && !startsWith('//'))",
|
|
39
|
+
"Use indirect mapping (key -> URL mapping)",
|
|
40
|
+
"Parse and validate URL with new URL() and check hostname",
|
|
41
|
+
"Use framework validation (e.g., @IsIn(['home', 'dashboard']))"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"examples": {
|
|
45
|
+
"violations": [
|
|
46
|
+
"res.redirect(req.query.url);",
|
|
47
|
+
"window.location.href = params.get('redirect');",
|
|
48
|
+
"response.sendRedirect(request.getParameter('next'));",
|
|
49
|
+
"new RedirectView(request.getParameter('url'));"
|
|
50
|
+
],
|
|
51
|
+
"fixes": [
|
|
52
|
+
"const url = req.query.url; if (ALLOWED_URLS.includes(url)) res.redirect(url);",
|
|
53
|
+
"if (isAllowedDomain(url)) window.location.href = url;",
|
|
54
|
+
"const destination = REDIRECT_MAP[req.query.key]; res.redirect(destination);",
|
|
55
|
+
"if (url.startsWith('/') && !url.startsWith('//')) res.redirect(url);"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|