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.
- package/README.md +72 -20
- package/bin/redgun.js +1 -1
- package/package.json +7 -1
- package/scan.js +22 -0
- package/src/core/reporter/console.js +1 -1
- package/src/core/reporter/html.js +1 -1
- package/src/local/access-control.js +64 -0
- package/src/local/ato.js +72 -0
- package/src/local/business-logic.js +67 -0
- package/src/local/cicd.js +69 -0
- package/src/local/cloud.js +73 -0
- package/src/local/csrf.js +75 -0
- package/src/local/index.js +32 -8
- package/src/local/ldap.js +67 -0
- package/src/local/mobile.js +71 -0
- package/src/local/oauth.js +68 -0
- package/src/local/saml.js +72 -0
- package/src/local/web3.js +73 -0
- package/src/local/xxe.js +74 -0
- package/src/remote/advanced.js +238 -0
- package/src/remote/crawler.js +234 -0
- package/src/remote/portswigger.js +235 -0
- package/src/remote/probe.js +217 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { addFinding } from '../core/findings.js';
|
|
2
|
+
import { fetchText, fetchWithTimeout } from '../utils/fetch.js';
|
|
3
|
+
|
|
4
|
+
export async function scanSamlRemote(origin, spinner) {
|
|
5
|
+
spinner.text = 'Testing SAML/SSO endpoints...';
|
|
6
|
+
const samlPaths = ['/saml', '/auth/saml', '/sso', '/auth/sso', '/Shibboleth.sso', '/adfs/ls', '/saml/SSO', '/saml2', '/websso/SAML2/Metadata'];
|
|
7
|
+
|
|
8
|
+
for (const path of samlPaths) {
|
|
9
|
+
try {
|
|
10
|
+
const resp = await fetchText(`${origin}${path}`, {}, 5000);
|
|
11
|
+
if (resp.status === 200 || resp.status === 302) {
|
|
12
|
+
if (path.includes('Metadata') && resp.status === 200) {
|
|
13
|
+
addFinding('MEDIUM', 'SAML/SSO', `SAML metadata exposed: ${path}`, `${origin}${path} returns SAML metadata XML`, 'Review metadata for certificate info and supported bindings. Ensure entity IDs are correct.');
|
|
14
|
+
} else {
|
|
15
|
+
addFinding('INFO', 'SAML/SSO', `SAML endpoint found: ${path}`, `Status: ${resp.status}`, 'Test for XML Signature Wrapping (XSW1-XSW8), signature stripping, and comment injection in NameID.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const xswPayload = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="test" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">
|
|
23
|
+
<saml:Issuer>evil-idp</saml:Issuer>
|
|
24
|
+
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
|
|
25
|
+
<saml:Assertion ID="evil-assertion" Version="2.0" IssueInstant="2024-01-01T00:00:00Z">
|
|
26
|
+
<saml:Issuer>evil-idp</saml:Issuer>
|
|
27
|
+
<saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">admin@target.com</saml:NameID></saml:Subject>
|
|
28
|
+
<saml:Conditions NotBefore="2024-01-01T00:00:00Z" NotOnOrAfter="2030-01-01T00:00:00Z"/>
|
|
29
|
+
<saml:AuthnStatement AuthnInstant="2024-01-01T00:00:00Z">
|
|
30
|
+
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext>
|
|
31
|
+
</saml:AuthnStatement>
|
|
32
|
+
</saml:Assertion>
|
|
33
|
+
</samlp:Response>`;
|
|
34
|
+
|
|
35
|
+
const encodedXsw = Buffer.from(xswPayload).toString('base64');
|
|
36
|
+
const samlConsumes = ['/saml/acs', '/auth/saml/callback', '/sso/acs', '/Shibboleth.sso/SAML2/POST'];
|
|
37
|
+
|
|
38
|
+
for (const consumer of samlConsumes) {
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetchText(`${origin}${consumer}`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
43
|
+
body: `SAMLResponse=${encodeURIComponent(encodedXsw)}`,
|
|
44
|
+
}, 5000);
|
|
45
|
+
|
|
46
|
+
if (resp.status === 200 || resp.status === 302) {
|
|
47
|
+
if (!resp.body.includes('error') && !resp.body.includes('invalid signature') && !resp.body.includes('Unauthorized')) {
|
|
48
|
+
addFinding('HIGH', 'SAML/SSO', `SAML POST ACS at ${consumer} accepts unsigned assertion`, `Unsigned SAML response returned ${resp.status} without error`, 'Always validate SAML signatures. Check for XML Signature Wrapping (XSW) vulnerabilities.');
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function scanLdapRemote(origin, spinner) {
|
|
58
|
+
spinner.text = 'Testing LDAP injection...';
|
|
59
|
+
const params = ['user', 'username', 'login', 'uid', 'email', 'search', 'query', 'filter', 'cn', 'name'];
|
|
60
|
+
const payloads = ['*)(uid=*))(|(uid=*', '*', 'admin*', '*)(|(uid=*', 'admin)(|(uid=*'];
|
|
61
|
+
|
|
62
|
+
for (const param of params) {
|
|
63
|
+
for (const payload of payloads.slice(0, 3)) {
|
|
64
|
+
try {
|
|
65
|
+
const resp = await fetchText(`${origin}/?${param}=${encodeURIComponent(payload)}`, {}, 5000);
|
|
66
|
+
if (resp.status === 200 && (resp.body.toLowerCase().includes('admin') || resp.body.length > 10000)) {
|
|
67
|
+
addFinding('CRITICAL', 'LDAP Injection', `LDAP injection via ?${param}=`, `Payload "${payload}" returned sensitive data`, 'Escape LDAP special characters. Use parameterized queries.');
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetchText(`${origin}/api/login`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ username: '*)(uid=*))(|(uid=*', password: 'anything' }),
|
|
79
|
+
}, 5000);
|
|
80
|
+
|
|
81
|
+
if (resp.status === 200) {
|
|
82
|
+
addFinding('CRITICAL', 'LDAP Injection', 'LDAP injection auth bypass at /api/login', 'Wildcard LDAP payload returned success', 'Validate and escape all inputs in LDAP filters. Never concatenate user input into LDAP queries.');
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function scanMfaBypass(origin, spinner) {
|
|
88
|
+
spinner.text = 'Testing MFA/OTP bypass vectors...';
|
|
89
|
+
|
|
90
|
+
const postMfaPaths = ['/dashboard', '/account', '/settings', '/profile', '/home', '/user', '/mfa/setup', '/mfa/disable', '/2fa', '/api/me', '/api/user'];
|
|
91
|
+
|
|
92
|
+
for (const path of postMfaPaths) {
|
|
93
|
+
try {
|
|
94
|
+
const resp = await fetchText(`${origin}${path}`, {}, 5000);
|
|
95
|
+
if (resp.status === 200 && !resp.body.toLowerCase().includes('login') && !resp.body.toLowerCase().includes('2fa') && !resp.body.toLowerCase().includes('mfa')) {
|
|
96
|
+
addFinding('HIGH', 'MFA Bypass', `Post-login path accessible without MFA: ${path}`, `${origin}${path} returns 200 without MFA challenge`, 'Enforce MFA at middleware level, not per-route. Gate all authenticated endpoints behind MFA check.');
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const otpEndpoints = ['/api/verify-otp', '/api/mfa/verify', '/api/2fa/verify', '/api/auth/totp', '/api/otp'];
|
|
103
|
+
for (const ep of otpEndpoints) {
|
|
104
|
+
for (let i = 0; i < 3; i++) {
|
|
105
|
+
try {
|
|
106
|
+
const resp = await fetchText(`${origin}${ep}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ code: String(Math.floor(Math.random() * 1000000)).padStart(6, '0') }),
|
|
110
|
+
}, 3000);
|
|
111
|
+
|
|
112
|
+
if (resp.status !== 429 && resp.status !== 400 && resp.status !== 401) {
|
|
113
|
+
addFinding('MEDIUM', 'MFA Bypass', `OTP endpoint ${ep} may lack rate limiting`, `No rate limit response after 3 attempts`, 'Implement rate limiting on OTP verification. After 5 failed attempts, lock and require re-login.');
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function scanWebsocketReplay(origin, spinner) {
|
|
122
|
+
spinner.text = 'Testing WebSocket vulnerabilities (enhanced)...';
|
|
123
|
+
const wsOrigin = origin.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
124
|
+
|
|
125
|
+
addFinding('INFO', 'WebSocket (enhanced)', 'WebSocket replay/tampering check', `Check ${wsOrigin} for: CSWSH (Cross-Site WebSocket Hijacking), missing Origin validation, unauthenticated message handling, and message replay attacks`, 'Validate WebSocket Origin header. Require auth token in connect params. Implement message sequence numbers to prevent replay. Use per-message deflate compression.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function scanPasswordReset(origin, spinner) {
|
|
129
|
+
spinner.text = 'Testing password reset security...';
|
|
130
|
+
|
|
131
|
+
let hasTargetParam = false;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const resp = await fetchText(`${origin}/account/password/reset?email=test@example.com`, {}, 5000);
|
|
135
|
+
if (resp.status === 200) {
|
|
136
|
+
if (resp.body.includes('sent') || resp.body.includes('email') || resp.body.includes('check')) {
|
|
137
|
+
addFinding('INFO', 'Password Reset', 'Email enumeration via reset endpoint', 'Check if response differs for existing vs non-existing emails', 'Use generic messages: "If that email exists, we sent a reset link."');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
const hostTest = ['evil.com', 'localhost', '127.0.0.1'];
|
|
143
|
+
for (const host of hostTest) {
|
|
144
|
+
try {
|
|
145
|
+
const resp = await fetchText(`${origin}/account/password/reset?email=target@victim.com`, {
|
|
146
|
+
headers: { Host: host, 'X-Forwarded-Host': host },
|
|
147
|
+
}, 5000);
|
|
148
|
+
|
|
149
|
+
if (resp.body.includes(host)) {
|
|
150
|
+
hasTargetParam = true;
|
|
151
|
+
addFinding('HIGH', 'Password Reset', `Host header injection in password reset (${host})`, 'Reset link URL reflects attacker-controlled Host header', 'Generate reset URLs from a configured base URL, not the request Host header.');
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!hasTargetParam) {
|
|
158
|
+
try {
|
|
159
|
+
const token = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
160
|
+
const resp = await fetchText(`${origin}/account/password/reset/verify?token=${token}`, {}, 5000);
|
|
161
|
+
if (resp.status === 200) {
|
|
162
|
+
addFinding('LOW', 'Password Reset', 'Reset token endpoint accessible', `Token: ${token.substring(0, 8)}...`, 'Ensure reset tokens are cryptographically random (32+ bytes) and expire quickly (< 15 min).');
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function scanCsrfRemote(origin, spinner) {
|
|
169
|
+
spinner.text = 'CSRF token analysis (remote)...';
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const resp = await fetchText(origin);
|
|
173
|
+
const body = resp.body;
|
|
174
|
+
|
|
175
|
+
const csrfPatterns = [
|
|
176
|
+
/csrf[_-]?token|_token|<input[^>]*name=['"](?:csrf|_token|authenticity_token|__RequestVerificationToken)['"]/gi,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
let foundToken = false;
|
|
180
|
+
for (const pattern of csrfPatterns) {
|
|
181
|
+
if (pattern.test(body)) foundToken = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!foundToken) {
|
|
185
|
+
const forms = body.match(/<form[^>]*method[^>]*(?:post|put|delete|patch)/gi);
|
|
186
|
+
if (forms) {
|
|
187
|
+
addFinding('HIGH', 'CSRF (remote)', 'Forms with POST/PUT/DELETE may lack CSRF protection', `${forms.length} state-changing forms found without CSRF tokens`, 'Add CSRF tokens to all state-changing forms. Use SameSite cookies.');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cookieTokens = body.match(/cookie\s*.*token|document\.cookie.*csrf/i);
|
|
192
|
+
if (cookieTokens) {
|
|
193
|
+
addFinding('MEDIUM', 'CSRF (remote)', 'CSRF token from cookies detected (vulnerable to cookie jar overflow)', 'CSRF token is read from cookies — not safe for SPA CSRF protection', 'Use custom header (X-CSRF-Token) with token from meta tag, not cookie. Implement Double Submit Cookie only with SameSite=Strict.');
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function scanDanglingDns(hostname, spinner) {
|
|
199
|
+
spinner.text = 'Checking for dangling DNS subdomain takeover...';
|
|
200
|
+
|
|
201
|
+
const services = [
|
|
202
|
+
{ pattern: /\.cloudfront\.net/i, name: 'AWS CloudFront', indicator: 'The request could not be satisfied' },
|
|
203
|
+
{ pattern: /\.s3\.amazonaws\.com/i, name: 'AWS S3', indicator: 'NoSuchBucket' },
|
|
204
|
+
{ pattern: /\.azurewebsites\.net/i, name: 'Azure Web Apps', indicator: '404 Web Site not found' },
|
|
205
|
+
{ pattern: /\.herokuapp\.com/i, name: 'Heroku', indicator: 'No such app' },
|
|
206
|
+
{ pattern: /\.github\.io/i, name: 'GitHub Pages', indicator: 'There isnt a GitHub Pages site here' },
|
|
207
|
+
{ pattern: /\.netlify\.app|\.netlify\.com/i, name: 'Netlify', indicator: 'Not Found' },
|
|
208
|
+
{ pattern: /\.vercel\.app/i, name: 'Vercel', indicator: 'DEPLOYMENT_NOT_FOUND' },
|
|
209
|
+
{ pattern: /\.firebaseapp\.com/i, name: 'Firebase Hosting', indicator: 'Site Not Found' },
|
|
210
|
+
{ pattern: /\.surge\.sh/i, name: 'Surge.sh', indicator: 'project not found' },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
addFinding('INFO', 'Subdomain Takeover', 'Dangling CNAME check', 'Check DNS records for dangling CNAMEs to cloud services (CloudFront, S3, Heroku, GitHub Pages, Netlify, Vercel)', 'Remove DNS records pointing to decommissioned cloud resources to prevent subdomain takeover');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function scanCloudRemote(origin, spinner) {
|
|
217
|
+
spinner.text = 'Testing cloud metadata access via SSRF...';
|
|
218
|
+
|
|
219
|
+
const metadataUrls = [
|
|
220
|
+
'http://169.254.169.254/latest/meta-data/',
|
|
221
|
+
'http://metadata.google.internal/computeMetadata/v1/',
|
|
222
|
+
'http://169.254.169.254/metadata/instance?api-version=2021-02-01',
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
const ssrfParams = ['url', 'link', 'src', 'callback', 'webhook', 'fetch', 'proxy', 'redirect'];
|
|
226
|
+
|
|
227
|
+
for (const url of metadataUrls.slice(0, 1)) {
|
|
228
|
+
for (const param of ssrfParams) {
|
|
229
|
+
try {
|
|
230
|
+
const resp = await fetchText(`${origin}/?${param}=${encodeURIComponent(url)}`, {}, 5000);
|
|
231
|
+
if (resp.body.includes('instance-id') || resp.body.includes('ami-id') || resp.body.includes('local-hostname')) {
|
|
232
|
+
addFinding('CRITICAL', 'Cloud Metadata', `Cloud metadata accessible via ?${param}= SSRF`, 'AWS IMDSv1 instance metadata exposed', 'Upgrade to IMDSv2 (uses session tokens). Block 169.254.169.254 at network level.');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { addFinding } from '../core/findings.js';
|
|
2
|
+
import { fetchText } from '../utils/fetch.js';
|
|
3
|
+
|
|
4
|
+
export async function runCrawler(origin, spinner) {
|
|
5
|
+
spinner.text = '[Katana] Crawling target for endpoints...';
|
|
6
|
+
|
|
7
|
+
const discovered = {
|
|
8
|
+
urls: new Set(),
|
|
9
|
+
jsFiles: new Set(),
|
|
10
|
+
forms: [],
|
|
11
|
+
params: new Set(),
|
|
12
|
+
apiEndpoints: new Set(),
|
|
13
|
+
emails: new Set(),
|
|
14
|
+
subdomains: new Set(),
|
|
15
|
+
secrets: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const resp = await fetchText(origin);
|
|
20
|
+
const body = resp.body;
|
|
21
|
+
|
|
22
|
+
extractUrls(body, origin, discovered);
|
|
23
|
+
extractJsFiles(body, origin, discovered);
|
|
24
|
+
extractForms(body, discovered);
|
|
25
|
+
extractParams(body, discovered);
|
|
26
|
+
extractEmails(body, discovered);
|
|
27
|
+
|
|
28
|
+
for (const jsUrl of [...discovered.jsFiles].slice(0, 20)) {
|
|
29
|
+
try {
|
|
30
|
+
spinner.text = `[Katana] Parsing JS: ${jsUrl.substring(0, 60)}...`;
|
|
31
|
+
const jsResp = await fetchText(jsUrl, {}, 8000);
|
|
32
|
+
extractEndpointsFromJs(jsResp.body, origin, discovered);
|
|
33
|
+
extractSecretsFromJs(jsResp.body, jsUrl, discovered);
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
if (discovered.apiEndpoints.size > 0) {
|
|
39
|
+
addFinding(
|
|
40
|
+
'INFO',
|
|
41
|
+
'Crawler (Katana)',
|
|
42
|
+
`Discovered ${discovered.apiEndpoints.size} API endpoints from JS`,
|
|
43
|
+
`Endpoints: ${[...discovered.apiEndpoints].slice(0, 10).join(', ')}${discovered.apiEndpoints.size > 10 ? '...' : ''}`,
|
|
44
|
+
'Review discovered endpoints for authentication and authorization requirements'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (discovered.forms.length > 0) {
|
|
49
|
+
addFinding(
|
|
50
|
+
'INFO',
|
|
51
|
+
'Crawler (Katana)',
|
|
52
|
+
`Discovered ${discovered.forms.length} forms`,
|
|
53
|
+
`Actions: ${discovered.forms.map(f => f.action).slice(0, 5).join(', ')}`,
|
|
54
|
+
'Test discovered forms for injection vulnerabilities'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
for (const form of discovered.forms) {
|
|
58
|
+
if (form.method === 'GET' && form.hasSensitiveFields) {
|
|
59
|
+
addFinding(
|
|
60
|
+
'MEDIUM',
|
|
61
|
+
'Crawler (Katana)',
|
|
62
|
+
'Sensitive form uses GET method',
|
|
63
|
+
`Form action: ${form.action} - sends sensitive data in URL`,
|
|
64
|
+
'Use POST method for forms that submit sensitive data (passwords, tokens, etc.)'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!form.hasCsrf && form.method === 'POST') {
|
|
68
|
+
addFinding(
|
|
69
|
+
'MEDIUM',
|
|
70
|
+
'Crawler (Katana)',
|
|
71
|
+
'POST form without CSRF token',
|
|
72
|
+
`Form action: ${form.action}`,
|
|
73
|
+
'Add CSRF token to all state-changing forms'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (discovered.params.size > 0) {
|
|
80
|
+
addFinding(
|
|
81
|
+
'INFO',
|
|
82
|
+
'Crawler (Katana)',
|
|
83
|
+
`Discovered ${discovered.params.size} unique parameters`,
|
|
84
|
+
`Parameters: ${[...discovered.params].slice(0, 20).join(', ')}`,
|
|
85
|
+
'Fuzz discovered parameters for injection vulnerabilities'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (discovered.secrets.length > 0) {
|
|
90
|
+
for (const secret of discovered.secrets.slice(0, 5)) {
|
|
91
|
+
addFinding(
|
|
92
|
+
'HIGH',
|
|
93
|
+
'Crawler (Katana)',
|
|
94
|
+
`Secret found in JS bundle: ${secret.type}`,
|
|
95
|
+
`File: ${secret.file}\nValue: ${secret.value.substring(0, 20)}...`,
|
|
96
|
+
'Remove secrets from client-side JavaScript. Use server-side proxying for API calls.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (discovered.emails.size > 0) {
|
|
102
|
+
addFinding(
|
|
103
|
+
'LOW',
|
|
104
|
+
'Crawler (Katana)',
|
|
105
|
+
`${discovered.emails.size} email addresses discovered`,
|
|
106
|
+
`Emails: ${[...discovered.emails].slice(0, 5).join(', ')}`,
|
|
107
|
+
'Email addresses can be used for phishing and social engineering'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return discovered;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function extractUrls(html, origin, discovered) {
|
|
115
|
+
const urlPatterns = [
|
|
116
|
+
/href\s*=\s*['"]([^'"#]+)['"]/gi,
|
|
117
|
+
/src\s*=\s*['"]([^'"]+)['"]/gi,
|
|
118
|
+
/action\s*=\s*['"]([^'"]+)['"]/gi,
|
|
119
|
+
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
|
|
120
|
+
/window\.location\s*=\s*['"]([^'"]+)['"]/gi,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const pattern of urlPatterns) {
|
|
124
|
+
let match;
|
|
125
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
126
|
+
let url = match[1];
|
|
127
|
+
if (url.startsWith('/')) url = origin + url;
|
|
128
|
+
else if (!url.startsWith('http')) continue;
|
|
129
|
+
if (url.startsWith(origin)) discovered.urls.add(url);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function extractJsFiles(html, origin, discovered) {
|
|
135
|
+
const jsPattern = /src\s*=\s*['"]([^'"]*\.(?:js|mjs|chunk\.js|bundle\.js)[^'"]*)['"]/gi;
|
|
136
|
+
let match;
|
|
137
|
+
while ((match = jsPattern.exec(html)) !== null) {
|
|
138
|
+
let url = match[1];
|
|
139
|
+
if (url.startsWith('/')) url = origin + url;
|
|
140
|
+
else if (url.startsWith('./')) url = origin + url.substring(1);
|
|
141
|
+
else if (!url.startsWith('http')) url = origin + '/' + url;
|
|
142
|
+
discovered.jsFiles.add(url);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractForms(html, discovered) {
|
|
147
|
+
const formPattern = /<form[^>]*>([\s\S]*?)<\/form>/gi;
|
|
148
|
+
let match;
|
|
149
|
+
while ((match = formPattern.exec(html)) !== null) {
|
|
150
|
+
const formHtml = match[0];
|
|
151
|
+
const action = formHtml.match(/action\s*=\s*['"]([^'"]*)['"]/i)?.[1] || '';
|
|
152
|
+
const method = (formHtml.match(/method\s*=\s*['"]([^'"]*)['"]/i)?.[1] || 'GET').toUpperCase();
|
|
153
|
+
const hasCsrf = /csrf|_token|authenticity_token|__RequestVerificationToken/i.test(formHtml);
|
|
154
|
+
const hasSensitiveFields = /type\s*=\s*['"]password['"]|name\s*=\s*['"](?:password|secret|token|card|ssn|credit)/i.test(formHtml);
|
|
155
|
+
|
|
156
|
+
const inputPattern = /name\s*=\s*['"]([^'"]+)['"]/gi;
|
|
157
|
+
let inputMatch;
|
|
158
|
+
while ((inputMatch = inputPattern.exec(formHtml)) !== null) {
|
|
159
|
+
discovered.params.add(inputMatch[1]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
discovered.forms.push({ action, method, hasCsrf, hasSensitiveFields });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractParams(html, discovered) {
|
|
167
|
+
const paramPatterns = [
|
|
168
|
+
/[?&]([a-zA-Z_][a-zA-Z0-9_]*)=/g,
|
|
169
|
+
/name\s*=\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (const pattern of paramPatterns) {
|
|
173
|
+
let match;
|
|
174
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
175
|
+
discovered.params.add(match[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractEmails(html, discovered) {
|
|
181
|
+
const emailPattern = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
182
|
+
let match;
|
|
183
|
+
while ((match = emailPattern.exec(html)) !== null) {
|
|
184
|
+
if (!match[0].includes('example.com') && !match[0].includes('test.com')) {
|
|
185
|
+
discovered.emails.add(match[0]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractEndpointsFromJs(jsContent, origin, discovered) {
|
|
191
|
+
const endpointPatterns = [
|
|
192
|
+
/['"`](\/api\/[^'"`\s{]+)['"`]/g,
|
|
193
|
+
/['"`](\/v[0-9]+\/[^'"`\s{]+)['"`]/g,
|
|
194
|
+
/['"`](\/(?:users?|admin|auth|login|register|graphql|webhook|upload|download|export|import|config|settings|profile|account|payment|order|cart|search|notify|message)[^'"`\s{]*)['"`]/g,
|
|
195
|
+
/(?:fetch|axios|http|request|ajax)\s*\(\s*['"`]([^'"`]+)['"`]/g,
|
|
196
|
+
/(?:url|endpoint|path|route|href)\s*[:=]\s*['"`](\/[^'"`]+)['"`]/g,
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
for (const pattern of endpointPatterns) {
|
|
200
|
+
let match;
|
|
201
|
+
while ((match = pattern.exec(jsContent)) !== null) {
|
|
202
|
+
const endpoint = match[1];
|
|
203
|
+
if (endpoint.length > 2 && endpoint.length < 200 && !endpoint.includes('${')) {
|
|
204
|
+
discovered.apiEndpoints.add(endpoint);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const paramPattern = /['"`]\?([a-zA-Z_]+)=|['"`]&([a-zA-Z_]+)=/g;
|
|
210
|
+
let paramMatch;
|
|
211
|
+
while ((paramMatch = paramPattern.exec(jsContent)) !== null) {
|
|
212
|
+
discovered.params.add(paramMatch[1] || paramMatch[2]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractSecretsFromJs(jsContent, fileUrl, discovered) {
|
|
217
|
+
const secretPatterns = [
|
|
218
|
+
{ pattern: /AKIA[0-9A-Z]{16}/g, type: 'AWS Access Key' },
|
|
219
|
+
{ pattern: /sk_live_[0-9a-zA-Z]{24,}/g, type: 'Stripe Secret Key' },
|
|
220
|
+
{ pattern: /ghp_[A-Za-z0-9_]{36,}/g, type: 'GitHub Token' },
|
|
221
|
+
{ pattern: /sk-[a-zA-Z0-9]{48}/g, type: 'OpenAI Key' },
|
|
222
|
+
{ pattern: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, type: 'Slack Token' },
|
|
223
|
+
{ pattern: /AIza[0-9A-Za-z\-_]{35}/g, type: 'Google API Key' },
|
|
224
|
+
{ pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, type: 'Private Key' },
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
for (const { pattern, type } of secretPatterns) {
|
|
228
|
+
let match;
|
|
229
|
+
while ((match = pattern.exec(jsContent)) !== null) {
|
|
230
|
+
discovered.secrets.push({ type, value: match[0], file: fileUrl });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|