redgun-security 1.4.1 → 2.0.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 +1 -1
- package/scan.js +4 -0
- package/src/core/findings.js +27 -0
- package/src/core/reporter/console.js +29 -2
- package/src/core/reporter/html.js +22 -2
- package/src/core/validator.js +375 -0
- package/src/utils/fetch.js +17 -0
package/package.json
CHANGED
package/scan.js
CHANGED
|
@@ -7,6 +7,7 @@ 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';
|
|
10
11
|
|
|
11
12
|
export async function runRemoteScan(url, spinner, modules = null) {
|
|
12
13
|
const target = new URL(url);
|
|
@@ -85,6 +86,9 @@ export async function runRemoteScan(url, spinner, modules = null) {
|
|
|
85
86
|
// Module failed silently
|
|
86
87
|
}
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
spinner.text = '[Validation] Verifying findings...';
|
|
91
|
+
await validateFindings(origin, spinner);
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
async function scanHeaders(origin, spinner) {
|
package/src/core/findings.js
CHANGED
|
@@ -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
|
-
|
|
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('<script>') || resp.body.includes('<')) {
|
|
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
|
+
}
|
package/src/utils/fetch.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import https from 'https';
|
|
2
2
|
import http from 'http';
|
|
3
3
|
|
|
4
|
+
const RATE_LIMIT = 5;
|
|
5
|
+
let lastRequestTime = 0;
|
|
6
|
+
|
|
7
|
+
async function waitForSlot() {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const minInterval = 1000 / RATE_LIMIT;
|
|
10
|
+
const timeSinceLast = now - lastRequestTime;
|
|
11
|
+
|
|
12
|
+
if (timeSinceLast < minInterval) {
|
|
13
|
+
await new Promise((r) => setTimeout(r, minInterval - timeSinceLast));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
lastRequestTime = Date.now();
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
export async function fetchWithTimeout(url, options = {}, timeout = 10000) {
|
|
20
|
+
await waitForSlot();
|
|
21
|
+
|
|
5
22
|
const controller = new AbortController();
|
|
6
23
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
7
24
|
|