redgun-security 1.4.2 → 2.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redgun-security",
3
- "version": "1.4.2",
3
+ "version": "2.1.0",
4
4
  "description": "Black-box & white-box security auditor for web applications with HackTricks techniques",
5
5
  "type": "module",
6
6
  "main": "scan.js",
package/scan.js CHANGED
@@ -7,6 +7,8 @@ import { scanXxeRemote, scanOauthRemote, scanAccessControlRemote, scanWebCacheDe
7
7
  import { scanSamlRemote, scanLdapRemote, scanMfaBypass, scanWebsocketReplay, scanPasswordReset, scanCsrfRemote, scanDanglingDns, scanCloudRemote } from './src/remote/advanced.js';
8
8
  import { scanSsrfBypassChains, scanJwtRemoteAdvanced, scanGrpc, scanOpenApi, scanWebrtc, scanStoredDomXss, scanSsiRemote, scanXpathRemote, scanTimingRemote } from './src/remote/complete.js';
9
9
  import { scanLlmRemote, scanCssInjectionRemote, scanPostMessageRemote, scanEsiRemote, scanHttp3, scanHpackBomb, scanSmtpRemote, scanDkimReplay } from './src/remote/modern.js';
10
+ import { validateFindings } from './src/core/validator.js';
11
+ import { runBrowserEngine } from './src/remote/browser.js';
10
12
 
11
13
  export async function runRemoteScan(url, spinner, modules = null) {
12
14
  const target = new URL(url);
@@ -14,6 +16,7 @@ export async function runRemoteScan(url, spinner, modules = null) {
14
16
  const origin = target.origin;
15
17
 
16
18
  const allModules = [
19
+ { name: 'Browser Engine (Puppeteer)', value: 'browser', fn: () => runBrowserEngine(origin, spinner) },
17
20
  { name: 'Probe & Fingerprint (httpx)', value: 'probe', fn: () => runProbe(origin, spinner) },
18
21
  { name: 'Crawl & Extract (Katana)', value: 'crawl', fn: () => runCrawler(origin, spinner) },
19
22
  { name: 'HTTP Headers', value: 'headers', fn: () => scanHeaders(origin, spinner) },
@@ -85,6 +88,9 @@ export async function runRemoteScan(url, spinner, modules = null) {
85
88
  // Module failed silently
86
89
  }
87
90
  }
91
+
92
+ spinner.text = '[Validation] Verifying findings...';
93
+ await validateFindings(origin, spinner);
88
94
  }
89
95
 
90
96
  async function scanHeaders(origin, spinner) {
@@ -8,9 +8,19 @@ export function addFinding(severity, module, title, details, fix) {
8
8
  details,
9
9
  fix,
10
10
  timestamp: new Date().toISOString(),
11
+ validated: false,
12
+ confidence: 0,
13
+ exploitability: null,
14
+ validationNote: null,
11
15
  });
12
16
  }
13
17
 
18
+ export function updateFinding(index, updates) {
19
+ if (findings[index]) {
20
+ Object.assign(findings[index], updates);
21
+ }
22
+ }
23
+
14
24
  export function getFindings() {
15
25
  return [...findings];
16
26
  }
@@ -27,6 +37,14 @@ export function getFindingsByModule(module) {
27
37
  return findings.filter((f) => f.module === module);
28
38
  }
29
39
 
40
+ export function removeFalsePositives() {
41
+ for (let i = findings.length - 1; i >= 0; i--) {
42
+ if (findings[i].validated && findings[i].confidence < 30) {
43
+ findings.splice(i, 1);
44
+ }
45
+ }
46
+ }
47
+
30
48
  export function getSeverityCounts() {
31
49
  return {
32
50
  critical: findings.filter((f) => f.severity === 'CRITICAL').length,
@@ -36,3 +54,12 @@ export function getSeverityCounts() {
36
54
  info: findings.filter((f) => f.severity === 'INFO').length,
37
55
  };
38
56
  }
57
+
58
+ export function getValidationStats() {
59
+ const total = findings.length;
60
+ const validated = findings.filter((f) => f.validated).length;
61
+ const confirmed = findings.filter((f) => f.validated && f.exploitability === 'confirmed').length;
62
+ const inconclusive = findings.filter((f) => f.validated && f.exploitability === 'inconclusive').length;
63
+ const rejected = findings.filter((f) => f.validated && f.exploitability === 'rejected').length;
64
+ return { total, validated, confirmed, inconclusive, rejected };
65
+ }
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { getFindings, getSeverityCounts } from '../findings.js';
2
+ import { getFindings, getSeverityCounts, getValidationStats } from '../findings.js';
3
3
  import { calculateScore, getGrade } from '../score.js';
4
4
 
5
5
  const SEVERITY_COLORS = {
@@ -28,6 +28,7 @@ export function printResults() {
28
28
  const score = calculateScore();
29
29
  const grade = getGrade(score);
30
30
  const counts = getSeverityCounts();
31
+ const vstats = getValidationStats();
31
32
 
32
33
  console.log('\n' + chalk.bold('═══════════════════════════════════════════════════════'));
33
34
  console.log(chalk.bold(' SCAN RESULTS'));
@@ -43,6 +44,18 @@ export function printResults() {
43
44
  console.log(` ${SEVERITY_COLORS.INFO(' INFO ')} ${counts.info}`);
44
45
  console.log('');
45
46
 
47
+ if (vstats.validated > 0) {
48
+ console.log(chalk.bold(' VALIDATION RESULTS'));
49
+ console.log(` ${chalk.green('✓ Confirmed')}: ${vstats.confirmed} ` +
50
+ `${chalk.yellow('? Inconclusive')}: ${vstats.inconclusive} ` +
51
+ `${chalk.red('✗ Rejected')}: ${vstats.rejected}`);
52
+ console.log(` ${chalk.gray('False positives eliminated:')} ${vstats.total - findings.length}`);
53
+ if (vstats.rejected > 0) {
54
+ console.log(` ${chalk.red.bold(' ' + vstats.rejected + ' findings downgraded/removed after validation')}`);
55
+ }
56
+ console.log('');
57
+ }
58
+
46
59
  const grouped = {};
47
60
  for (const finding of findings) {
48
61
  if (!grouped[finding.module]) grouped[finding.module] = [];
@@ -53,8 +66,22 @@ export function printResults() {
53
66
  console.log(chalk.bold(`\n ┌─ ${module}`));
54
67
  for (const f of moduleFindings) {
55
68
  const color = SEVERITY_COLORS[f.severity] || chalk.white;
56
- console.log(` │ ${color(`[${f.severity}]`)} ${f.title}`);
69
+ let confidenceBar = '';
70
+ if (f.validated && f.confidence > 0) {
71
+ const barWidth = Math.round(f.confidence / 10);
72
+ const barColor = f.confidence >= 70 ? chalk.green : f.confidence >= 40 ? chalk.yellow : chalk.red;
73
+ confidenceBar = ` ${barColor('█'.repeat(barWidth) + '░'.repeat(10 - barWidth))} ${f.confidence}%`;
74
+ }
75
+ const badge = f.validated
76
+ ? (f.exploitability === 'confirmed' ? chalk.green.bold(' ✓CONFIRMED') :
77
+ f.exploitability === 'rejected' ? chalk.red.bold(' ✗REJECTED') : chalk.yellow.bold(' ?INCONCLUSIVE'))
78
+ : chalk.gray(' UNVERIFIED');
79
+ console.log(` │ ${color(`[${f.severity}]`)} ${f.title}${badge}`);
80
+ if (confidenceBar) console.log(` │ ${confidenceBar}`);
57
81
  if (f.details) console.log(` │ ${chalk.gray(f.details.substring(0, 120))}`);
82
+ if (f.validationNote && f.validated) {
83
+ console.log(` │ ${chalk.magenta('Validation:')} ${f.validationNote.substring(0, 120)}`);
84
+ }
58
85
  if (f.fix) console.log(` │ ${chalk.green('Fix:')} ${f.fix.substring(0, 120)}`);
59
86
  }
60
87
  console.log(' └─');
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
- import { getFindings, getSeverityCounts } from '../findings.js';
3
+ import { getFindings, getSeverityCounts, getValidationStats } from '../findings.js';
4
4
  import { calculateScore, getGrade, getGradeColor } from '../score.js';
5
5
 
6
6
  export function exportHtml(outputDir = './scans') {
@@ -15,6 +15,7 @@ export function exportHtml(outputDir = './scans') {
15
15
  const grade = getGrade(score);
16
16
  const gradeColor = getGradeColor(grade);
17
17
  const counts = getSeverityCounts();
18
+ const vstats = getValidationStats();
18
19
  const findings = getFindings();
19
20
 
20
21
  const grouped = {};
@@ -37,10 +38,20 @@ export function exportHtml(outputDir = './scans') {
37
38
  if (!grouped[sev] || grouped[sev].length === 0) continue;
38
39
  findingsHtml += `<h3 style="color:${severityColors[sev]}">${sev} (${grouped[sev].length})</h3>`;
39
40
  for (const f of grouped[sev]) {
41
+ const vBadge = f.validated
42
+ ? (f.exploitability === 'confirmed' ? '<span style="background:#238636;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.8em">CONFIRMED</span>'
43
+ : f.exploitability === 'rejected' ? '<span style="background:#da3633;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.8em">REJECTED</span>'
44
+ : '<span style="background:#d29922;color:#fff;padding:1px 6px;border-radius:3px;font-size:0.8em">INCONCLUSIVE</span>')
45
+ : '';
46
+ const confidenceBar = f.validated && f.confidence > 0
47
+ ? `<div style="margin:4px 0;background:#21262d;border-radius:4px;height:6px"><div style="width:${f.confidence}%;height:6px;border-radius:4px;background:${f.confidence >= 70 ? '#238636' : f.confidence >= 40 ? '#d29922' : '#da3633'}"></div></div><span style="font-size:0.8em;color:#8b949e">Confidence: ${f.confidence}%</span>`
48
+ : '';
40
49
  findingsHtml += `
41
50
  <div class="finding" style="border-left:4px solid ${severityColors[sev]}">
42
- <strong>[${f.module}]</strong> ${escapeHtml(f.title)}
51
+ <strong>[${f.module}]</strong> ${escapeHtml(f.title)} ${vBadge}
52
+ ${confidenceBar}
43
53
  ${f.details ? `<p class="details">${escapeHtml(f.details)}</p>` : ''}
54
+ ${f.validationNote && f.validated ? `<p class="valnote" style="color:#d2a8ff;font-size:0.9em">Validation: ${escapeHtml(f.validationNote)}</p>` : ''}
44
55
  ${f.fix ? `<p class="fix"><strong>Fix:</strong> ${escapeHtml(f.fix)}</p>` : ''}
45
56
  </div>`;
46
57
  }
@@ -86,6 +97,15 @@ export function exportHtml(outputDir = './scans') {
86
97
  <div class="count-item"><div class="count-num" style="color:#9e9e9e">${counts.info}</div>Info</div>
87
98
  </div>
88
99
  </div>
100
+ ${vstats.validated > 0 ? `
101
+ <div style="background:#161b22;border-radius:12px;padding:1rem;margin:1rem 0;text-align:center">
102
+ <strong style="color:#58a6ff">Validation Results</strong>
103
+ <div class="counts" style="margin-top:0.5rem">
104
+ <div class="count-item"><div class="count-num" style="color:#238636">${vstats.confirmed}</div>Confirmed</div>
105
+ <div class="count-item"><div class="count-num" style="color:#d29922">${vstats.inconclusive}</div>Inconclusive</div>
106
+ <div class="count-item"><div class="count-num" style="color:#da3633">${vstats.rejected}</div>Rejected</div>
107
+ </div>
108
+ </div>` : ''}
89
109
  <h2>Findings (${findings.length} total)</h2>
90
110
  ${findingsHtml}
91
111
  <div class="footer">
@@ -0,0 +1,375 @@
1
+ import { getFindings, updateFinding, removeFalsePositives } from './findings.js';
2
+ import { fetchText } from '../utils/fetch.js';
3
+
4
+ export async function validateFindings(origin, spinner) {
5
+ const findings = getFindings();
6
+ let validated = 0;
7
+ let fpRemoved = 0;
8
+
9
+ for (let i = 0; i < findings.length; i++) {
10
+ const f = findings[i];
11
+ spinner.text = `Validating: ${f.module} [${validated + 1}/${findings.length}]`;
12
+
13
+ try {
14
+ switch (true) {
15
+ case f.title.toLowerCase().includes('sql injection'):
16
+ await validateSqli(origin, f, i);
17
+ break;
18
+ case f.title.toLowerCase().includes('xss'):
19
+ await validateXss(origin, f, i);
20
+ break;
21
+ case f.title.toLowerCase().includes('ssrf'):
22
+ await validateSsrf(origin, f, i);
23
+ break;
24
+ case f.title.toLowerCase().includes('lfi') || f.title.toLowerCase().includes('path traversal'):
25
+ await validateLfi(origin, f, i);
26
+ break;
27
+ case f.title.toLowerCase().includes('jwt') && f.title.toLowerCase().includes('none'):
28
+ await validateJwtNone(origin, f, i);
29
+ break;
30
+ case f.title.toLowerCase().includes('open redirect'):
31
+ await validateOpenRedirect(origin, f, i);
32
+ break;
33
+ case f.title.toLowerCase().includes('cors'):
34
+ await validateCors(origin, f, i);
35
+ break;
36
+ case f.title.toLowerCase().includes('nosql injection'):
37
+ await validateNosql(origin, f, i);
38
+ break;
39
+ case f.title.toLowerCase().includes('command injection'):
40
+ await validateCommandInjection(origin, f, i);
41
+ break;
42
+ case f.title.toLowerCase().includes('no clickjacking') || f.title.toLowerCase().includes('x-frame-options'):
43
+ await validateClickjacking(origin, f, i);
44
+ break;
45
+ case f.title.toLowerCase().includes('missing') && f.module === 'HTTP Headers':
46
+ await validateHeaders(origin, f, i);
47
+ break;
48
+ case f.title.toLowerCase().includes('accessible') && f.module === 'Exposed Files':
49
+ await validateExposedFile(origin, f, i);
50
+ break;
51
+ case f.title.toLowerCase().includes('saml'):
52
+ await validateSaml(origin, f, i);
53
+ break;
54
+ case f.title.toLowerCase().includes('ldap'):
55
+ await validateLdap(origin, f, i);
56
+ break;
57
+ case f.title.toLowerCase().includes('no csrf'):
58
+ await validateCsrf(origin, f, i);
59
+ break;
60
+ case f.title.toLowerCase().includes('http method') && f.title.toLowerCase().includes('trace'):
61
+ await validateTrace(origin, f, i);
62
+ break;
63
+ default:
64
+ await genericValidation(origin, f, i);
65
+ }
66
+ } catch {}
67
+
68
+ validated++;
69
+ }
70
+
71
+ fpRemoved = findings.length - getFindings().length;
72
+ removeFalsePositives();
73
+
74
+ spinner.text = `Validation complete: ${validated}d ${fpRemoved} FP removed`;
75
+ return { validated, falsePositivesRemoved: fpRemoved };
76
+ }
77
+
78
+ async function validateSqli(origin, f, idx) {
79
+ const testUrl = origin + '/?id=1%27%20UNION%20SELECT%20NULL--';
80
+ try {
81
+ const resp = await fetchText(testUrl, {}, 8000);
82
+ if (/UNION|SELECT|syntax|error.*SQL|mysql|postgresql|sqlite|ORA-|Microsoft SQL/i.test(resp.body)) {
83
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 90, validationNote: 'Database error confirmed via UNION SELECT probe' });
84
+ } else {
85
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 45, validationNote: 'No database error on UNION probe — may be parameterized or WAF-blocked' });
86
+ }
87
+ } catch {
88
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'Validation request failed (timeout/connection error)' });
89
+ }
90
+ }
91
+
92
+ async function validateXss(origin, f, idx) {
93
+ const testUrl = origin + '/?q=<script>alert(1)<%2fscript>';
94
+ try {
95
+ const resp = await fetchText(testUrl, {}, 5000);
96
+ if (resp.body.includes('<script>alert(1)')) {
97
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 95, validationNote: 'XSS payload reflected unescaped in response' });
98
+ } else if (resp.body.includes('alert(1)')) {
99
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 80, validationNote: 'alert(1) reflected but script tags may be stripped' });
100
+ } else if (resp.body.includes('&lt;script&gt;') || resp.body.includes('&lt;')) {
101
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 15, validationNote: 'Input properly HTML-encoded — XSS unlikely' });
102
+ } else {
103
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 40, validationNote: 'Payload not reflected — may be parameter-specific' });
104
+ }
105
+ } catch {
106
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'Validation request failed' });
107
+ }
108
+ }
109
+
110
+ async function validateSsrf(origin, f, idx) {
111
+ try {
112
+ const startTime = Date.now();
113
+ await fetchText(origin + '/?url=http://169.254.169.254/latest/meta-data/', {}, 8000);
114
+ const elapsed = Date.now() - startTime;
115
+
116
+ if (elapsed < 1000) {
117
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 85, validationNote: `Metadata endpoint responded quickly (${elapsed}ms) — SSRF likely` });
118
+ } else {
119
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 50, validationNote: `Response took ${elapsed}ms — may be blocked or timing out` });
120
+ }
121
+ } catch {
122
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'SSRF probe timed out or connection refused' });
123
+ }
124
+ }
125
+
126
+ async function validateLfi(origin, f, idx) {
127
+ try {
128
+ const resp = await fetchText(origin + '/?file=../../../etc/passwd', {}, 5000);
129
+ if (resp.body.includes('root:x:0:')) {
130
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 98, validationNote: '/etc/passwd file retrieved successfully' });
131
+ } else if (resp.body.includes('root') && resp.body.includes('/bin/')) {
132
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 85, validationNote: 'File contents contain system paths — LFI likely' });
133
+ } else {
134
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 35, validationNote: 'No passwd data returned — try different encoding bypasses' });
135
+ }
136
+ } catch {
137
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 20, validationNote: 'LFI probe failed' });
138
+ }
139
+ }
140
+
141
+ async function validateJwtNone(origin, f, idx) {
142
+ const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' }));
143
+ const payload = btoa(JSON.stringify({ sub: 'admin', role: 'admin', exp: 9999999999 }));
144
+ const token = `${header}.${payload}.`;
145
+
146
+ try {
147
+ const resp = await fetchText(origin + '/api/me', {
148
+ headers: { Authorization: `Bearer ${token}` },
149
+ }, 5000);
150
+
151
+ if (resp.status === 200) {
152
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 99, validationNote: 'JWT alg=none accepted — complete auth bypass' });
153
+ } else if (resp.status === 401 || resp.status === 403) {
154
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 10, validationNote: 'Server rejected unsigned JWT — "none" attack blocked' });
155
+ } else {
156
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 40, validationNote: `Unexpected response ${resp.status}` });
157
+ }
158
+ } catch {
159
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'JWT validation failed' });
160
+ }
161
+ }
162
+
163
+ async function validateOpenRedirect(origin, f, idx) {
164
+ try {
165
+ const resp = await fetchText(origin + '/?redirect=https://evil.com', { redirect: 'manual' }, 5000);
166
+ const location = resp.headers['location'] || '';
167
+
168
+ if (location.includes('evil.com')) {
169
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 95, validationNote: 'Redirect to external domain confirmed' });
170
+ } else {
171
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 20, validationNote: 'Redirect blocked or sanitized' });
172
+ }
173
+ } catch {
174
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'Redirect validation failed' });
175
+ }
176
+ }
177
+
178
+ async function validateCors(origin, f, idx) {
179
+ try {
180
+ const resp = await fetchText(origin, { headers: { Origin: 'https://evil.com' } });
181
+ const acao = resp.headers['access-control-allow-origin'] || '';
182
+ const acac = resp.headers['access-control-allow-credentials'] || '';
183
+
184
+ if (acao === '*' && acac === 'true') {
185
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 98, validationNote: 'CORS wildcard with credentials — full cross-origin data theft' });
186
+ } else if (acao === 'https://evil.com') {
187
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 90, validationNote: 'CORS reflects arbitrary origin' });
188
+ } else if (acao === 'null') {
189
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 75, validationNote: 'CORS allows null origin' });
190
+ } else {
191
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 15, validationNote: 'CORS does not reflect test origin' });
192
+ }
193
+ } catch {
194
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'CORS validation failed' });
195
+ }
196
+ }
197
+
198
+ async function validateNosql(origin, f, idx) {
199
+ try {
200
+ const resp = await fetchText(origin + '/api/login', {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ username: { $ne: '' }, password: { $ne: '' } }),
204
+ }, 5000);
205
+
206
+ if (resp.status === 200 && /token|session|success|welcome|user/i.test(resp.body)) {
207
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 95, validationNote: 'NoSQL auth bypass confirmed — $ne operator accepted' });
208
+ } else {
209
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 40, validationNote: 'NoSQL auth bypass not directly confirmed' });
210
+ }
211
+ } catch {
212
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'NoSQL validation failed' });
213
+ }
214
+ }
215
+
216
+ async function validateCommandInjection(origin, f, idx) {
217
+ const startTime = Date.now();
218
+ try {
219
+ await fetchText(origin + '/?cmd=sleep+3', {}, 8000);
220
+ const elapsed = Date.now() - startTime;
221
+
222
+ if (elapsed > 2500) {
223
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 85, validationNote: `sleep 3 confirmed — response took ${elapsed}ms` });
224
+ } else {
225
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 35, validationNote: `Response was ${elapsed}ms — sleep did not trigger` });
226
+ }
227
+ } catch {
228
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 20, validationNote: 'Command injection validation failed' });
229
+ }
230
+ }
231
+
232
+ async function validateClickjacking(origin, f, idx) {
233
+ try {
234
+ const resp = await fetchText(origin);
235
+ const xfo = resp.headers['x-frame-options'] || '';
236
+ const csp = resp.headers['content-security-policy'] || '';
237
+
238
+ if (!xfo && !csp.includes('frame-ancestors')) {
239
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 85, validationNote: 'Confirmed — no X-Frame-Options or frame-ancestors CSP' });
240
+ } else {
241
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 5, validationNote: 'Clickjacking protection found on re-check' });
242
+ }
243
+ } catch {
244
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'Could not verify clickjacking' });
245
+ }
246
+ }
247
+
248
+ async function validateHeaders(origin, f, idx) {
249
+ try {
250
+ const resp = await fetchText(origin);
251
+ const headerName = f.title.toLowerCase().match(/missing ([\w-]+)/)?.[1] || '';
252
+
253
+ if (headerName && resp.headers[headerName]) {
254
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 5, validationNote: `${headerName} found on re-check — false positive` });
255
+ } else if (headerName && !resp.headers[headerName]) {
256
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 90, validationNote: `Confirmed — ${headerName} still missing` });
257
+ } else {
258
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 50, validationNote: 'Header presence could not be verified' });
259
+ }
260
+ } catch {
261
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'Header validation failed' });
262
+ }
263
+ }
264
+
265
+ async function validateExposedFile(origin, f, idx) {
266
+ const pathMatch = f.title.match(/Accessible: (.+)/);
267
+ if (!pathMatch) {
268
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 40, validationNote: 'Could not parse path from finding' });
269
+ return;
270
+ }
271
+
272
+ try {
273
+ const resp = await fetchText(origin + pathMatch[1], {}, 5000);
274
+ if (resp.status === 200 && resp.body.length > 10) {
275
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 90, validationNote: `Confirmed — ${pathMatch[1]} still accessible (${resp.body.length}B)` });
276
+ } else {
277
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 10, validationNote: `${pathMatch[1]} no longer accessible on re-check` });
278
+ }
279
+ } catch {
280
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 30, validationNote: 'File access validation failed' });
281
+ }
282
+ }
283
+
284
+ async function validateSaml(origin, f, idx) {
285
+ const acsEndpoints = ['/saml/acs', '/auth/saml/callback', '/sso/acs', '/Shibboleth.sso/SAML2/POST'];
286
+ let confirmed = false;
287
+
288
+ const unsignedResponse = `<?xml version="1.0"?><samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="test" Version="2.0"><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status><saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0"><saml:Subject><saml:NameID>admin@target.com</saml:NameID></saml:Subject></saml:Assertion></samlp:Response>`;
289
+ const encoded = Buffer.from(unsignedResponse).toString('base64');
290
+
291
+ for (const acs of acsEndpoints) {
292
+ try {
293
+ const resp = await fetchText(origin + acs, {
294
+ method: 'POST',
295
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
296
+ body: `SAMLResponse=${encodeURIComponent(encoded)}`,
297
+ }, 5000);
298
+
299
+ if (resp.status === 200 || resp.status === 302) {
300
+ confirmed = true;
301
+ break;
302
+ }
303
+ } catch {}
304
+ }
305
+
306
+ if (confirmed) {
307
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 80, validationNote: 'Unsigned SAML response accepted — XSW attack confirmed' });
308
+ } else {
309
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 40, validationNote: 'SAML ACS did not accept unsigned response' });
310
+ }
311
+ }
312
+
313
+ async function validateLdap(origin, f, idx) {
314
+ try {
315
+ const resp = await fetchText(origin + '/api/login', {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({ username: '*)(uid=*))(|(uid=*', password: 'test' }),
319
+ }, 5000);
320
+
321
+ if (resp.status === 200) {
322
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 90, validationNote: 'LDAP auth bypass confirmed via wildcard injection' });
323
+ } else {
324
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 35, validationNote: 'LDAP auth bypass not confirmed on re-check' });
325
+ }
326
+ } catch {
327
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'LDAP validation failed' });
328
+ }
329
+ }
330
+
331
+ async function validateCsrf(origin, f, idx) {
332
+ try {
333
+ const resp = await fetchText(origin);
334
+ const forms = (resp.body.match(/<form[^>]*method\s*=\s*['"](?:post|put|delete)['"][^>]*>/gi) || []);
335
+ let missingCsrf = 0;
336
+
337
+ for (const form of forms) {
338
+ if (!/csrf|_token|authenticity_token/i.test(form)) missingCsrf++;
339
+ }
340
+
341
+ if (missingCsrf > 0) {
342
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 80, validationNote: `${missingCsrf} forms missing CSRF tokens confirmed` });
343
+ } else {
344
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 10, validationNote: 'CSRF tokens found in all forms on re-check' });
345
+ }
346
+ } catch {
347
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 25, validationNote: 'CSRF validation failed' });
348
+ }
349
+ }
350
+
351
+ async function validateTrace(origin, f, idx) {
352
+ try {
353
+ const resp = await fetchText(origin, { method: 'TRACE' }, 5000);
354
+ if (resp.status === 200 && resp.body.includes('TRACE')) {
355
+ updateFinding(idx, { validated: true, exploitability: 'confirmed', confidence: 95, validationNote: 'TRACE method confirmed enabled — XST attack vector' });
356
+ } else {
357
+ updateFinding(idx, { validated: true, exploitability: 'rejected', confidence: 5, validationNote: 'TRACE method blocked or disabled' });
358
+ }
359
+ } catch {
360
+ updateFinding(idx, { validated: true, exploitability: 'inconclusive', confidence: 20, validationNote: 'TRACE validation failed' });
361
+ }
362
+ }
363
+
364
+ async function genericValidation(origin, f, idx) {
365
+ updateFinding(idx, {
366
+ validated: true,
367
+ exploitability: 'inconclusive',
368
+ confidence: 50,
369
+ validationNote: 'Auto-validation not supported for this finding type — manual verification recommended',
370
+ });
371
+ }
372
+
373
+ function btoa(str) {
374
+ return Buffer.from(str).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
375
+ }
@@ -0,0 +1,263 @@
1
+ import { addFinding } from '../core/findings.js';
2
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ export async function runBrowserEngine(origin, spinner) {
6
+ spinner.text = '[Browser] Launching headless Chromium...';
7
+
8
+ let puppeteer;
9
+ try {
10
+ puppeteer = (await import('puppeteer')).default;
11
+ } catch {
12
+ spinner.warn('Puppeteer not available — browser tests skipped');
13
+ return;
14
+ }
15
+
16
+ let browser;
17
+ let page;
18
+ const results = {
19
+ alerts: [],
20
+ consoleErrors: [],
21
+ networkRequests: [],
22
+ wsConnections: [],
23
+ localStorage: {},
24
+ sessionStorage: {},
25
+ serviceWorkers: false,
26
+ postMessageListens: false,
27
+ screenshotPath: null,
28
+ xssTested: 0,
29
+ xssConfirmed: 0,
30
+ formsFound: 0,
31
+ };
32
+
33
+ try {
34
+ browser = await puppeteer.launch({
35
+ headless: 'new',
36
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process'],
37
+ });
38
+
39
+ page = await browser.newPage();
40
+ await page.setViewport({ width: 1280, height: 800 });
41
+
42
+ page.on('dialog', async (dialog) => {
43
+ results.alerts.push({ message: dialog.message(), type: dialog.type() });
44
+ await dialog.dismiss();
45
+ });
46
+
47
+ page.on('console', (msg) => {
48
+ if (msg.type() === 'error') {
49
+ results.consoleErrors.push(msg.text());
50
+ }
51
+ });
52
+
53
+ page.on('request', (req) => {
54
+ if (req.resourceType() === 'websocket') {
55
+ results.wsConnections.push(req.url());
56
+ }
57
+ results.networkRequests.push({
58
+ url: req.url(),
59
+ method: req.method(),
60
+ type: req.resourceType(),
61
+ });
62
+ });
63
+
64
+ spinner.text = '[Browser] Navigating to target...';
65
+ await page.goto(origin, { waitUntil: 'networkidle2', timeout: 30000 });
66
+
67
+ results.serviceWorkers = await page.evaluate(() => 'serviceWorker' in navigator && Boolean(navigator.serviceWorker.controller));
68
+
69
+ results.postMessageListens = await page.evaluate(() => {
70
+ let hasListener = false;
71
+ window.addEventListener('message', () => { hasListener = true; });
72
+ window.postMessage('__redgun_probe__', '*');
73
+ return new Promise(r => setTimeout(() => r(hasListener), 200));
74
+ });
75
+
76
+ results.localStorage = await page.evaluate(() => {
77
+ const data = {};
78
+ for (let i = 0; i < localStorage.length; i++) {
79
+ const key = localStorage.key(i);
80
+ data[key] = localStorage.getItem(key);
81
+ }
82
+ return data;
83
+ });
84
+
85
+ results.sessionStorage = await page.evaluate(() => {
86
+ const data = {};
87
+ try {
88
+ for (let i = 0; i < sessionStorage.length; i++) {
89
+ const key = sessionStorage.key(i);
90
+ data[key] = sessionStorage.getItem(key);
91
+ }
92
+ } catch {}
93
+ return data;
94
+ });
95
+
96
+ spinner.text = '[Browser] Scanning for forms and inputs...';
97
+ const inputFields = await page.evaluate(() => {
98
+ const fields = [];
99
+ document.querySelectorAll('input, textarea, select').forEach((el) => {
100
+ const name = el.getAttribute('name') || el.getAttribute('id') || el.getAttribute('class') || '';
101
+ const type = el.getAttribute('type') || el.tagName.toLowerCase();
102
+ fields.push({ name: name.substring(0, 60), type });
103
+ });
104
+ return fields;
105
+ });
106
+
107
+ results.formsFound = await page.evaluate(() => document.querySelectorAll('form').length);
108
+
109
+ spinner.text = '[Browser] Testing DOM XSS...';
110
+ const xssPayloads = [
111
+ '<img src=x onerror=alert("REDGUN_XSS") />',
112
+ '<svg onload=alert("REDGUN_XSS") />',
113
+ '" onfocus=alert("REDGUN_XSS") autofocus="',
114
+ '"><img src=x onerror=alert("REDGUN_XSS")>',
115
+ 'javascript:alert("REDGUN_XSS")',
116
+ ];
117
+
118
+ for (const field of inputFields.slice(0, 20)) {
119
+ if (!field.name) continue;
120
+ for (const payload of xssPayloads.slice(0, 3)) {
121
+ try {
122
+ await page.evaluate((name, payload) => {
123
+ const el = document.querySelector(`[name="${name}"], [id="${name}"]`);
124
+ if (el) {
125
+ el.value = payload;
126
+ el.dispatchEvent(new Event('input', { bubbles: true }));
127
+ el.dispatchEvent(new Event('change', { bubbles: true }));
128
+ }
129
+ }, field.name, payload);
130
+ results.xssTested++;
131
+ } catch {}
132
+ }
133
+ }
134
+
135
+ for (const formFields of inputFields.filter(f => f.name)) {
136
+ try {
137
+ await page.evaluate((name) => {
138
+ const input = document.querySelector(`[name="${name}"], [id="${name}"]`);
139
+ const form = input?.closest('form');
140
+ if (form) form.submit();
141
+ }, formFields.name);
142
+ await new Promise(r => setTimeout(r, 300));
143
+
144
+ if (results.alerts.some(a => a.message.includes('REDGUN_XSS'))) {
145
+ results.xssConfirmed++;
146
+ }
147
+ } catch {}
148
+ }
149
+
150
+ const currentPageUrl = page.url();
151
+ if (results.alerts.some(a => a.message.includes('REDGUN_XSS')) && currentPageUrl.includes(origin)) {
152
+ results.xssConfirmed = Math.max(results.xssConfirmed, 1);
153
+ }
154
+
155
+ if (results.alerts.length > 0 || results.xssConfirmed > 0) {
156
+ const screenshotsDir = './scans';
157
+ if (!existsSync(screenshotsDir)) mkdirSync(screenshotsDir, { recursive: true });
158
+ const ts = Date.now();
159
+ results.screenshotPath = join(screenshotsDir, `redgun-xss-${ts}.png`);
160
+ await page.screenshot({ path: results.screenshotPath, fullPage: true });
161
+ }
162
+
163
+ } catch (err) {
164
+ spinner.text = `[Browser] Error: ${err.message}`;
165
+ } finally {
166
+ if (browser) await browser.close();
167
+ }
168
+
169
+ reportFindings(origin, results);
170
+ }
171
+
172
+ function reportFindings(origin, results) {
173
+ if (results.xssConfirmed > 0) {
174
+ addFinding(
175
+ 'CRITICAL',
176
+ 'Browser XSS',
177
+ `${results.xssConfirmed} DOM/Stored XSS confirmed via browser`,
178
+ `${results.xssTested} payloads injected into ${results.formsFound} forms — ${results.xssConfirmed} alert() triggers detected\nScreenshot: ${results.screenshotPath || 'N/A'}`,
179
+ 'Sanitize all user input on both client and server side. Use DOMPurify, React escape, or framework auto-escaping.'
180
+ );
181
+ } else if (results.xssTested > 0) {
182
+ addFinding(
183
+ 'INFO',
184
+ 'Browser XSS',
185
+ `Tested ${results.xssTested} inputs — no XSS confirmed`,
186
+ `${results.formsFound} forms analyzed with 5 XSS payload types`,
187
+ 'DOM/Stored XSS not confirmed — continue manual testing with application-specific payloads'
188
+ );
189
+ }
190
+
191
+ const apiRequests = results.networkRequests.filter(r => r.url.includes('/api/'));
192
+ if (apiRequests.length > 0) {
193
+ const uniqueApis = [...new Set(apiRequests.map(r => r.url).filter(u => u.startsWith(origin)))];
194
+
195
+ if (uniqueApis.length > 5) {
196
+ addFinding(
197
+ 'INFO',
198
+ 'Browser Recon',
199
+ `${uniqueApis.length} API endpoints captured from browser network`,
200
+ `APIs: ${uniqueApis.slice(0, 10).join(', ')}${uniqueApis.length > 10 ? ` +${uniqueApis.length - 10} more` : ''}`,
201
+ 'Review captured API endpoints for auth requirements and sensitive data exposure'
202
+ );
203
+ }
204
+ }
205
+
206
+ if (results.wsConnections.length > 0) {
207
+ addFinding(
208
+ 'MEDIUM',
209
+ 'Browser WebSocket',
210
+ `${results.wsConnections.length} WebSocket connection(s) detected`,
211
+ `WS: ${results.wsConnections.join(', ')}`,
212
+ 'Test WebSocket for CSWSH, missing auth, and message tampering'
213
+ );
214
+ }
215
+
216
+ if (results.serviceWorkers) {
217
+ addFinding(
218
+ 'LOW',
219
+ 'Browser Service Worker',
220
+ 'Service Worker active on page',
221
+ 'SW registered — test for importScripts abuse and fetch listener tampering',
222
+ 'Validate Service Worker scope and origin. Use Subresource Integrity for imported scripts.'
223
+ );
224
+ }
225
+
226
+ if (results.postMessageListens) {
227
+ addFinding(
228
+ 'MEDIUM',
229
+ 'Browser postMessage',
230
+ 'postMessage listener detected (runtime check)',
231
+ 'Page has active message event handler',
232
+ 'Audit postMessage listeners for missing origin validation. Test cross-origin iframe attacks.'
233
+ );
234
+ }
235
+
236
+ const localKeys = Object.keys(results.localStorage || {});
237
+ const sessionKeys = Object.keys(results.sessionStorage || {});
238
+ if (localKeys.length > 0 || sessionKeys.length > 0) {
239
+ const sensitiveStorage = [...localKeys, ...sessionKeys].filter(k =>
240
+ /token|secret|key|password|credential|jwt|auth|session|user/i.test(k)
241
+ );
242
+
243
+ if (sensitiveStorage.length > 0) {
244
+ addFinding(
245
+ 'HIGH',
246
+ 'Browser Storage',
247
+ `${sensitiveStorage.length} sensitive items in browser storage`,
248
+ `Keys: ${sensitiveStorage.join(', ')}`,
249
+ 'Never store tokens, secrets, or credentials in localStorage/sessionStorage. Use httpOnly cookies for session management.'
250
+ );
251
+ }
252
+ }
253
+
254
+ if (results.consoleErrors.length > 5) {
255
+ addFinding(
256
+ 'LOW',
257
+ 'Browser Console',
258
+ `${results.consoleErrors.length} console errors detected`,
259
+ `Sample: ${results.consoleErrors.slice(0, 3).join(' | ')}`,
260
+ 'Console errors may reveal internal paths, CSP violations, or API error messages'
261
+ );
262
+ }
263
+ }