jaku.sh 1.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/LICENSE +52 -0
- package/README.md +636 -0
- package/action.yml +264 -0
- package/bin/jaku +2 -0
- package/package.json +62 -0
- package/src/agents/ai-agent.js +175 -0
- package/src/agents/api-agent.js +95 -0
- package/src/agents/base-agent.js +158 -0
- package/src/agents/crawl-agent.js +175 -0
- package/src/agents/event-bus.js +59 -0
- package/src/agents/findings-ledger.js +410 -0
- package/src/agents/logic-agent.js +144 -0
- package/src/agents/orchestrator.js +323 -0
- package/src/agents/qa-agent.js +149 -0
- package/src/agents/security-agent.js +211 -0
- package/src/cli.js +423 -0
- package/src/core/accessibility-checker.js +171 -0
- package/src/core/ai/ai-endpoint-detector.js +227 -0
- package/src/core/ai/guardrail-prober.js +362 -0
- package/src/core/ai/indirect-injector.js +106 -0
- package/src/core/ai/jailbreak-tester.js +212 -0
- package/src/core/ai/model-dos-tester.js +174 -0
- package/src/core/ai/model-fingerprinter.js +246 -0
- package/src/core/ai/multi-turn-attacker.js +297 -0
- package/src/core/ai/output-analyzer.js +182 -0
- package/src/core/ai/prompt-injector.js +543 -0
- package/src/core/ai/system-prompt-extractor.js +244 -0
- package/src/core/api/api-key-auditor.js +266 -0
- package/src/core/api/auth-flow-tester.js +430 -0
- package/src/core/api/cors-ws-tester.js +263 -0
- package/src/core/api/graphql-tester.js +287 -0
- package/src/core/api/oauth-prober.js +343 -0
- package/src/core/auth-manager.js +902 -0
- package/src/core/broken-flow-detector.js +207 -0
- package/src/core/browser-manager.js +119 -0
- package/src/core/console-monitor.js +111 -0
- package/src/core/crawler.js +430 -0
- package/src/core/csr-waiter.js +410 -0
- package/src/core/form-validator.js +240 -0
- package/src/core/logic/abuse-pattern-scanner.js +291 -0
- package/src/core/logic/access-boundary-tester.js +448 -0
- package/src/core/logic/business-rule-inferrer.js +196 -0
- package/src/core/logic/graphql-auditor.js +298 -0
- package/src/core/logic/parameter-polluter.js +212 -0
- package/src/core/logic/pricing-exploiter.js +299 -0
- package/src/core/logic/race-condition-detector.js +222 -0
- package/src/core/logic/workflow-enforcer.js +284 -0
- package/src/core/performance-checker.js +204 -0
- package/src/core/responsive-checker.js +228 -0
- package/src/core/security/cors-prober.js +150 -0
- package/src/core/security/csrf-prober.js +217 -0
- package/src/core/security/dependency-auditor.js +182 -0
- package/src/core/security/file-upload-tester.js +340 -0
- package/src/core/security/header-analyzer.js +324 -0
- package/src/core/security/infra-scanner.js +391 -0
- package/src/core/security/path-traversal.js +112 -0
- package/src/core/security/prototype-pollution.js +147 -0
- package/src/core/security/secret-detector.js +517 -0
- package/src/core/security/sqli-prober.js +257 -0
- package/src/core/security/tls-checker.js +223 -0
- package/src/core/security/xss-scanner.js +225 -0
- package/src/core/test-generator.js +339 -0
- package/src/core/test-runner.js +398 -0
- package/src/reporting/diff-reporter.js +172 -0
- package/src/reporting/report-generator.js +408 -0
- package/src/reporting/sarif-generator.js +190 -0
- package/src/utils/config.js +57 -0
- package/src/utils/finding.js +67 -0
- package/src/utils/logger.js +50 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Infrastructure Scanner — Scans for infrastructure exposure and misconfigurations.
|
|
5
|
+
* Checks debug endpoints, directory listing, error disclosure, and common misconfigs.
|
|
6
|
+
*/
|
|
7
|
+
export class InfraScanner {
|
|
8
|
+
constructor(logger) {
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
this.findings = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Common admin/debug/sensitive endpoints to probe
|
|
14
|
+
static PROBE_PATHS = [
|
|
15
|
+
{ path: '/admin', desc: 'Admin panel', severity: 'high' },
|
|
16
|
+
{ path: '/administrator', desc: 'Admin panel', severity: 'high' },
|
|
17
|
+
{ path: '/admin/login', desc: 'Admin login', severity: 'medium' },
|
|
18
|
+
{ path: '/wp-admin', desc: 'WordPress admin', severity: 'high' },
|
|
19
|
+
{ path: '/wp-login.php', desc: 'WordPress login', severity: 'medium' },
|
|
20
|
+
{ path: '/debug', desc: 'Debug endpoint', severity: 'high' },
|
|
21
|
+
{ path: '/_debug', desc: 'Debug endpoint', severity: 'high' },
|
|
22
|
+
{ path: '/__debug', desc: 'Debug endpoint', severity: 'high' },
|
|
23
|
+
{ path: '/debug/vars', desc: 'Go debug vars', severity: 'critical' },
|
|
24
|
+
{ path: '/debug/pprof', desc: 'Go profiler', severity: 'critical' },
|
|
25
|
+
{ path: '/status', desc: 'Status page', severity: 'low' },
|
|
26
|
+
{ path: '/health', desc: 'Health check', severity: 'info' },
|
|
27
|
+
{ path: '/healthz', desc: 'Kubernetes health', severity: 'info' },
|
|
28
|
+
{ path: '/readyz', desc: 'Kubernetes readiness', severity: 'info' },
|
|
29
|
+
{ path: '/metrics', desc: 'Prometheus metrics', severity: 'high' },
|
|
30
|
+
{ path: '/api-docs', desc: 'API documentation', severity: 'low' },
|
|
31
|
+
{ path: '/swagger', desc: 'Swagger UI', severity: 'medium' },
|
|
32
|
+
{ path: '/swagger-ui.html', desc: 'Swagger UI', severity: 'medium' },
|
|
33
|
+
{ path: '/swagger.json', desc: 'Swagger spec', severity: 'medium' },
|
|
34
|
+
{ path: '/openapi.json', desc: 'OpenAPI spec', severity: 'medium' },
|
|
35
|
+
{ path: '/graphql', desc: 'GraphQL endpoint', severity: 'low' },
|
|
36
|
+
{ path: '/graphiql', desc: 'GraphQL IDE', severity: 'high' },
|
|
37
|
+
{ path: '/__graphql', desc: 'GraphQL endpoint', severity: 'low' },
|
|
38
|
+
{ path: '/actuator', desc: 'Spring Boot actuator', severity: 'high' },
|
|
39
|
+
{ path: '/actuator/env', desc: 'Spring environment', severity: 'critical' },
|
|
40
|
+
{ path: '/actuator/heapdump', desc: 'Spring heap dump', severity: 'critical' },
|
|
41
|
+
{ path: '/actuator/beans', desc: 'Spring beans', severity: 'high' },
|
|
42
|
+
{ path: '/console', desc: 'Console endpoint', severity: 'high' },
|
|
43
|
+
{ path: '/server-info', desc: 'Server info', severity: 'medium' },
|
|
44
|
+
{ path: '/info', desc: 'Info endpoint', severity: 'low' },
|
|
45
|
+
{ path: '/trace', desc: 'Trace endpoint', severity: 'high' },
|
|
46
|
+
{ path: '/api/v1', desc: 'API v1 root', severity: 'info' },
|
|
47
|
+
{ path: '/robots.txt', desc: 'Robots.txt', severity: 'info' },
|
|
48
|
+
{ path: '/sitemap.xml', desc: 'Sitemap', severity: 'info' },
|
|
49
|
+
{ path: '/crossdomain.xml', desc: 'Flash crossdomain', severity: 'medium' },
|
|
50
|
+
{ path: '/elmah.axd', desc: '.NET error logs', severity: 'high' },
|
|
51
|
+
{ path: '/phpinfo.php', desc: 'PHP info', severity: 'high' },
|
|
52
|
+
{ path: '/test', desc: 'Test page', severity: 'low' },
|
|
53
|
+
{ path: '/backup', desc: 'Backup directory', severity: 'high' },
|
|
54
|
+
{ path: '/dump', desc: 'Data dump', severity: 'critical' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Patterns in error pages that reveal internal details
|
|
58
|
+
static ERROR_DISCLOSURE_PATTERNS = [
|
|
59
|
+
{ regex: /at [\w.]+\([\w/.]+:\d+:\d+\)/i, name: 'Stack trace (Node.js)', severity: 'medium' },
|
|
60
|
+
{ regex: /Traceback \(most recent call/i, name: 'Stack trace (Python)', severity: 'medium' },
|
|
61
|
+
{ regex: /at [\w.]+\.[\w]+\([\w]+\.java:\d+\)/i, name: 'Stack trace (Java)', severity: 'medium' },
|
|
62
|
+
{ regex: /Fatal error:.*in \/[\w/]+\.php on line \d+/i, name: 'PHP fatal error with path', severity: 'high' },
|
|
63
|
+
{ regex: /DOCUMENT_ROOT.*\/[\w/]+/i, name: 'Document root path disclosure', severity: 'medium' },
|
|
64
|
+
{ regex: /\/home\/[\w]+\/|\/var\/www\/|\/usr\/local\//i, name: 'Server path disclosure', severity: 'medium' },
|
|
65
|
+
{ regex: /DB_HOST|DB_PASSWORD|DATABASE_URL/i, name: 'Database config disclosure', severity: 'critical' },
|
|
66
|
+
{ regex: /MongoServerError|mongoose.*Error/i, name: 'MongoDB error disclosure', severity: 'medium' },
|
|
67
|
+
{ regex: /ECONNREFUSED|ETIMEDOUT.*\d+\.\d+\.\d+\.\d+/i, name: 'Internal IP disclosure', severity: 'medium' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run infrastructure scanning.
|
|
72
|
+
*/
|
|
73
|
+
async scan(surfaceInventory) {
|
|
74
|
+
const baseUrl = surfaceInventory.baseUrl;
|
|
75
|
+
|
|
76
|
+
// 1. Probe known sensitive endpoints
|
|
77
|
+
await this._probeEndpoints(baseUrl);
|
|
78
|
+
|
|
79
|
+
// 2. Check for directory listing
|
|
80
|
+
await this._checkDirectoryListing(baseUrl);
|
|
81
|
+
|
|
82
|
+
// 3. Check error page information disclosure
|
|
83
|
+
await this._checkErrorDisclosure(baseUrl);
|
|
84
|
+
|
|
85
|
+
// 4. Check for GraphQL introspection
|
|
86
|
+
await this._checkGraphQLIntrospection(baseUrl);
|
|
87
|
+
|
|
88
|
+
this.logger?.info?.(`Infrastructure scanner found ${this.findings.length} issues`);
|
|
89
|
+
return this.findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Probe known sensitive/admin endpoints.
|
|
94
|
+
*/
|
|
95
|
+
async _probeEndpoints(baseUrl) {
|
|
96
|
+
// Fetch baseline fingerprint to detect SPA catch-all routes
|
|
97
|
+
const baseline = await this._fetchBaselineFingerprint(baseUrl);
|
|
98
|
+
|
|
99
|
+
const results = await Promise.allSettled(
|
|
100
|
+
InfraScanner.PROBE_PATHS.map(async ({ path, desc, severity }) => {
|
|
101
|
+
const url = new URL(path, baseUrl).toString();
|
|
102
|
+
try {
|
|
103
|
+
const resp = await fetch(url, {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
redirect: 'follow',
|
|
106
|
+
signal: AbortSignal.timeout(5000),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (resp.ok && resp.status === 200) {
|
|
110
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
111
|
+
const body = await resp.text();
|
|
112
|
+
|
|
113
|
+
// Skip if response matches the catch-all baseline (SPA serving same page for all routes)
|
|
114
|
+
if (baseline.isCatchAll) {
|
|
115
|
+
const probeHash = this._computeContentHash(body);
|
|
116
|
+
if (probeHash === baseline.catchAllHash) return null;
|
|
117
|
+
// Fuzzy match: if body length is within 5% of baseline and it's HTML, likely same page with minor variations
|
|
118
|
+
const lengthRatio = Math.abs(body.length - baseline.catchAllLength) / Math.max(baseline.catchAllLength, 1);
|
|
119
|
+
if (lengthRatio < 0.05 && contentType.includes('text/html')) return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Skip if it's a generic 200 HTML page (SPA catch-all)
|
|
123
|
+
if (this._isGenericSPAPage(body, path)) return null;
|
|
124
|
+
// Skip very small responses (likely empty)
|
|
125
|
+
if (body.trim().length < 20) return null;
|
|
126
|
+
|
|
127
|
+
return { path, desc, severity, url, contentType, bodyLength: body.length, body };
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Not accessible
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const result of results) {
|
|
137
|
+
if (result.status !== 'fulfilled' || !result.value) continue;
|
|
138
|
+
const { path, desc, severity, url, contentType, bodyLength, body } = result.value;
|
|
139
|
+
|
|
140
|
+
// Determine actual severity based on content
|
|
141
|
+
let actualSeverity = severity;
|
|
142
|
+
if (this._containsSensitiveData(body)) {
|
|
143
|
+
actualSeverity = 'critical';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.findings.push(createFinding({
|
|
147
|
+
module: 'security',
|
|
148
|
+
title: `Exposed Endpoint: ${path} (${desc})`,
|
|
149
|
+
severity: actualSeverity,
|
|
150
|
+
affected_surface: url,
|
|
151
|
+
description: `The endpoint "${path}" (${desc}) is publicly accessible and returned HTTP 200 with ${bodyLength} bytes.\n\nContent-Type: ${contentType}\n\nExposed management, debug, or admin endpoints can leak sensitive information and provide attack vectors.`,
|
|
152
|
+
reproduction: [
|
|
153
|
+
`1. Navigate to ${url}`,
|
|
154
|
+
`2. Endpoint returns HTTP 200 with ${bodyLength} bytes`,
|
|
155
|
+
`3. Content-Type: ${contentType}`,
|
|
156
|
+
],
|
|
157
|
+
evidence: body.substring(0, 500),
|
|
158
|
+
remediation: `Restrict access to "${path}" via authentication, IP whitelisting, or remove it entirely from production. Use environment-based configuration to disable debug endpoints in production.`,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if common directories have directory listing enabled.
|
|
165
|
+
*/
|
|
166
|
+
async _checkDirectoryListing(baseUrl) {
|
|
167
|
+
const dirs = ['/static/', '/assets/', '/uploads/', '/images/', '/files/', '/media/', '/public/'];
|
|
168
|
+
|
|
169
|
+
for (const dir of dirs) {
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(dir, baseUrl).toString();
|
|
172
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
173
|
+
|
|
174
|
+
if (resp.ok) {
|
|
175
|
+
const body = await resp.text();
|
|
176
|
+
// Check for directory listing patterns
|
|
177
|
+
if (body.includes('Index of') || body.includes('Directory listing') ||
|
|
178
|
+
body.includes('<pre>') && (body.includes('Parent Directory') || body.match(/<a href="[^"]+\/">/g)?.length > 3)) {
|
|
179
|
+
this.findings.push(createFinding({
|
|
180
|
+
module: 'security',
|
|
181
|
+
title: `Directory Listing Enabled: ${dir}`,
|
|
182
|
+
severity: 'medium',
|
|
183
|
+
affected_surface: url,
|
|
184
|
+
description: `Directory listing is enabled at "${dir}". This allows anyone to browse the directory contents, potentially revealing sensitive files, backup files, or internal structure.`,
|
|
185
|
+
reproduction: [
|
|
186
|
+
`1. Navigate to ${url}`,
|
|
187
|
+
'2. Observe the directory listing showing file names and sizes',
|
|
188
|
+
],
|
|
189
|
+
remediation: 'Disable directory listing in your web server configuration. Apache: `Options -Indexes`. Nginx: remove `autoindex on`.',
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Not accessible
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check error pages for information disclosure.
|
|
201
|
+
*/
|
|
202
|
+
async _checkErrorDisclosure(baseUrl) {
|
|
203
|
+
// Trigger error pages with various paths
|
|
204
|
+
const errorPaths = [
|
|
205
|
+
'/this-page-definitely-does-not-exist-jaku-test-404',
|
|
206
|
+
'/api/nonexistent-endpoint-jaku-test',
|
|
207
|
+
"/%00", // Null byte
|
|
208
|
+
'/..%2f..%2f..%2fetc/passwd', // Path traversal
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
for (const errorPath of errorPaths) {
|
|
212
|
+
try {
|
|
213
|
+
const url = new URL(errorPath, baseUrl).toString();
|
|
214
|
+
const resp = await fetch(url, {
|
|
215
|
+
signal: AbortSignal.timeout(10000),
|
|
216
|
+
redirect: 'follow',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const body = await resp.text();
|
|
220
|
+
|
|
221
|
+
for (const { regex, name, severity } of InfraScanner.ERROR_DISCLOSURE_PATTERNS) {
|
|
222
|
+
const match = body.match(regex);
|
|
223
|
+
if (match) {
|
|
224
|
+
this.findings.push(createFinding({
|
|
225
|
+
module: 'security',
|
|
226
|
+
title: `Error Information Disclosure: ${name}`,
|
|
227
|
+
severity,
|
|
228
|
+
affected_surface: url,
|
|
229
|
+
description: `The error page reveals internal information: ${name}.\n\nMatched pattern: "${match[0]}"\n\nDetailed error messages help attackers understand the technology stack, internal paths, and potential vulnerabilities.`,
|
|
230
|
+
reproduction: [
|
|
231
|
+
`1. Navigate to ${url}`,
|
|
232
|
+
`2. Error response contains: ${match[0]}`,
|
|
233
|
+
],
|
|
234
|
+
evidence: `Matched: ${match[0]}\n\nFull response excerpt:\n${body.substring(Math.max(0, match.index - 100), match.index + match[0].length + 100)}`,
|
|
235
|
+
remediation: 'Configure custom error pages that do not reveal stack traces, file paths, or technology details. Set NODE_ENV=production or equivalent for your framework.',
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Request failed
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if GraphQL introspection is enabled.
|
|
247
|
+
*/
|
|
248
|
+
async _checkGraphQLIntrospection(baseUrl) {
|
|
249
|
+
const graphqlPaths = ['/graphql', '/api/graphql', '/__graphql', '/graphql/v1'];
|
|
250
|
+
|
|
251
|
+
for (const gqlPath of graphqlPaths) {
|
|
252
|
+
try {
|
|
253
|
+
const url = new URL(gqlPath, baseUrl).toString();
|
|
254
|
+
const resp = await fetch(url, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
query: '{ __schema { types { name } } }',
|
|
259
|
+
}),
|
|
260
|
+
signal: AbortSignal.timeout(5000),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (resp.ok) {
|
|
264
|
+
const data = await resp.json().catch(() => null);
|
|
265
|
+
if (data?.data?.__schema) {
|
|
266
|
+
const typeCount = data.data.__schema.types?.length || 0;
|
|
267
|
+
this.findings.push(createFinding({
|
|
268
|
+
module: 'security',
|
|
269
|
+
title: `GraphQL Introspection Enabled: ${gqlPath}`,
|
|
270
|
+
severity: 'medium',
|
|
271
|
+
affected_surface: url,
|
|
272
|
+
description: `GraphQL introspection is enabled at "${gqlPath}", exposing the entire API schema (${typeCount} types discovered). Attackers can use this to map all queries, mutations, and types to find sensitive operations.`,
|
|
273
|
+
reproduction: [
|
|
274
|
+
`1. Send POST to ${url}`,
|
|
275
|
+
`2. Body: {"query": "{ __schema { types { name } } }"}`,
|
|
276
|
+
`3. Response contains full schema with ${typeCount} types`,
|
|
277
|
+
],
|
|
278
|
+
remediation: 'Disable GraphQL introspection in production. Most GraphQL servers have a configuration option for this.',
|
|
279
|
+
references: ['https://www.apollographql.com/docs/apollo-server/security/introspection/'],
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Not a GraphQL endpoint
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check if a response is a generic SPA catch-all page.
|
|
291
|
+
*/
|
|
292
|
+
_isGenericSPAPage(body, path) {
|
|
293
|
+
// SPAs often serve the same index.html for all routes
|
|
294
|
+
if (!body.includes('<!DOCTYPE html') && !body.includes('<!doctype html')) return false;
|
|
295
|
+
|
|
296
|
+
// Broad set of SPA framework markers
|
|
297
|
+
const spaMarkers = [
|
|
298
|
+
'id="root"', 'id="app"', 'id="__next"', 'id="__nuxt"', 'id="__gatsby"',
|
|
299
|
+
'id="svelte"', 'id="__svelte"', 'data-reactroot', 'ng-app', 'ng-version',
|
|
300
|
+
'data-server-rendered', 'id="q-app"', // Qwik
|
|
301
|
+
'_buildManifest.js', '_ssgManifest.js', // Next.js build artifacts
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
return spaMarkers.some(marker => body.includes(marker));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Check if a response body contains actual sensitive data (not just HTML form labels).
|
|
309
|
+
*/
|
|
310
|
+
_containsSensitiveData(body) {
|
|
311
|
+
// Patterns that indicate real sensitive data exposure (not normal HTML content)
|
|
312
|
+
const sensitivePatterns = [
|
|
313
|
+
/DB_HOST\s*[=:]/i, // Env variable assignment
|
|
314
|
+
/DB_PASSWORD\s*[=:]/i, // Env variable
|
|
315
|
+
/DATABASE_URL\s*[=:]/i, // Env variable
|
|
316
|
+
/["']?password["']?\s*[:=]\s*["'][^"']+["']/i, // Key-value with actual password value
|
|
317
|
+
/["']?secret["']?\s*[:=]\s*["'][^"']+["']/i, // Key-value with actual secret value
|
|
318
|
+
/private.key/i, // Private key file reference
|
|
319
|
+
/-----BEGIN (RSA |EC )?PRIVATE KEY-----/, // Actual private key content
|
|
320
|
+
/access.token\s*[=:]\s*["']?[A-Za-z0-9._\-]{20,}/i, // Actual token value
|
|
321
|
+
/api[_-]?key\s*[=:]\s*["']?[A-Za-z0-9._\-]{16,}/i, // Actual API key value
|
|
322
|
+
/AKIA[0-9A-Z]{16}/, // AWS access key
|
|
323
|
+
];
|
|
324
|
+
return sensitivePatterns.some(p => p.test(body));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Fetch baseline fingerprint to detect SPA catch-all routes.
|
|
329
|
+
* Compares the homepage response with a random nonsense path.
|
|
330
|
+
* If both return the same content, the site uses a catch-all.
|
|
331
|
+
*/
|
|
332
|
+
async _fetchBaselineFingerprint(baseUrl) {
|
|
333
|
+
const result = { isCatchAll: false, catchAllHash: null, catchAllLength: 0 };
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const randomPath = `/jaku-fp-check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
337
|
+
|
|
338
|
+
const [homeResp, randomResp] = await Promise.all([
|
|
339
|
+
fetch(new URL('/', baseUrl).toString(), {
|
|
340
|
+
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(10000),
|
|
341
|
+
}).catch(() => null),
|
|
342
|
+
fetch(new URL(randomPath, baseUrl).toString(), {
|
|
343
|
+
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(10000),
|
|
344
|
+
}).catch(() => null),
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
if (!homeResp?.ok || !randomResp?.ok) return result;
|
|
348
|
+
|
|
349
|
+
const homeBody = await homeResp.text();
|
|
350
|
+
const randomBody = await randomResp.text();
|
|
351
|
+
|
|
352
|
+
const homeHash = this._computeContentHash(homeBody);
|
|
353
|
+
const randomHash = this._computeContentHash(randomBody);
|
|
354
|
+
|
|
355
|
+
if (homeHash === randomHash) {
|
|
356
|
+
result.isCatchAll = true;
|
|
357
|
+
result.catchAllHash = homeHash;
|
|
358
|
+
result.catchAllLength = homeBody.length;
|
|
359
|
+
this.logger?.info?.('Detected SPA catch-all route — baseline fingerprint will filter false positives');
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
// Fingerprinting failed, proceed without baseline
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Compute a simple content hash for comparing page bodies.
|
|
370
|
+
* Strips dynamic tokens (nonces, timestamps, CSRF tokens) for stable comparison.
|
|
371
|
+
*/
|
|
372
|
+
_computeContentHash(body) {
|
|
373
|
+
// Normalize: strip nonces, CSRF tokens, timestamps, and whitespace variations
|
|
374
|
+
const normalized = body
|
|
375
|
+
.replace(/nonce="[^"]*"/g, 'nonce=""')
|
|
376
|
+
.replace(/csrf[_-]?token["']?\s*[:=]\s*["'][^"']*["']/gi, 'csrf_token=""')
|
|
377
|
+
.replace(/\b\d{13,}\b/g, '0') // Unix timestamps (milliseconds)
|
|
378
|
+
.replace(/[a-f0-9]{32,}/gi, 'HASH') // Long hex strings (session IDs, hashes)
|
|
379
|
+
.replace(/\s+/g, ' ')
|
|
380
|
+
.trim();
|
|
381
|
+
|
|
382
|
+
// Simple DJB2 hash — fast and sufficient for content comparison
|
|
383
|
+
let hash = 5381;
|
|
384
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
385
|
+
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
|
386
|
+
}
|
|
387
|
+
return hash;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export default InfraScanner;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PathTraversalScanner — Tests for path traversal and local file inclusion (LFI).
|
|
5
|
+
*
|
|
6
|
+
* Targets:
|
|
7
|
+
* - URL path parameters (/download?file=, /view?path=, /image?src=)
|
|
8
|
+
* - Upload URL parameters
|
|
9
|
+
* - API file-serving endpoints
|
|
10
|
+
*
|
|
11
|
+
* Payloads include:
|
|
12
|
+
* - Unix path traversal: ../../../etc/passwd
|
|
13
|
+
* - Windows path traversal: ..\..\..\windows\win.ini
|
|
14
|
+
* - URL encoding: %2e%2e%2f
|
|
15
|
+
* - Double encoding: %252e%252e%252f
|
|
16
|
+
* - Null byte: ../../../etc/passwd%00.png
|
|
17
|
+
*/
|
|
18
|
+
export class PathTraversalScanner {
|
|
19
|
+
constructor(logger) {
|
|
20
|
+
this.logger = logger;
|
|
21
|
+
|
|
22
|
+
this.FILE_PARAMS = [
|
|
23
|
+
'file', 'path', 'filename', 'filepath', 'dir', 'directory',
|
|
24
|
+
'src', 'source', 'doc', 'document', 'template', 'page',
|
|
25
|
+
'include', 'require', 'show', 'view', 'read', 'load',
|
|
26
|
+
'img', 'image', 'photo', 'asset', 'resource', 'url', 'redirect',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
this.PAYLOADS = [
|
|
30
|
+
// Unix
|
|
31
|
+
{ name: 'Basic Unix traversal', payload: '../../../etc/passwd', marker: /root:.*:0:0/, os: 'unix' },
|
|
32
|
+
{ name: 'Encoded Unix traversal', payload: '..%2F..%2F..%2Fetc%2Fpasswd', marker: /root:.*:0:0/, os: 'unix' },
|
|
33
|
+
{ name: 'Double-encoded traversal', payload: '..%252F..%252F..%252Fetc%252Fpasswd', marker: /root:.*:0:0/, os: 'unix' },
|
|
34
|
+
{ name: 'Null byte bypass', payload: '../../../etc/passwd\x00.png', marker: /root:.*:0:0/, os: 'unix' },
|
|
35
|
+
{ name: '/proc/self/environ exposure', payload: '../../../proc/self/environ', marker: /PATH=|HOME=|USER=/, os: 'unix' },
|
|
36
|
+
{ name: '/etc/hosts exposure', payload: '../../../etc/hosts', marker: /127\.0\.0\.1\s+localhost/, os: 'unix' },
|
|
37
|
+
// Windows
|
|
38
|
+
{ name: 'Windows traversal', payload: '..\\..\\..\\windows\\win.ini', marker: /\[fonts\]/i, os: 'windows' },
|
|
39
|
+
{ name: 'Windows encoded traversal', payload: '..%5C..%5C..%5Cwindows%5Cwin.ini', marker: /\[fonts\]/i, os: 'windows' },
|
|
40
|
+
// Cloud/container paths
|
|
41
|
+
{ name: 'AWS metadata', payload: 'http://169.254.169.254/latest/meta-data/iam/security-credentials', marker: /AccessKeyId|SecretAccessKey/i, os: 'cloud' },
|
|
42
|
+
{ name: 'GCP metadata', payload: 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token', marker: /access_token|expires_in/i, os: 'cloud' },
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async scan(surfaceInventory) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
|
|
49
|
+
for (const page of surfaceInventory.pages) {
|
|
50
|
+
if (!page.url || page.status >= 400) continue;
|
|
51
|
+
|
|
52
|
+
const url = new URL(page.url);
|
|
53
|
+
const paramNames = [...url.searchParams.keys()];
|
|
54
|
+
|
|
55
|
+
// Find file-like parameters in the URL
|
|
56
|
+
const fileParams = paramNames.filter(p =>
|
|
57
|
+
this.FILE_PARAMS.some(fp => p.toLowerCase().includes(fp))
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (fileParams.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
for (const param of fileParams) {
|
|
63
|
+
for (const { name, payload, marker, os } of this.PAYLOADS) {
|
|
64
|
+
try {
|
|
65
|
+
const testUrl = new URL(page.url);
|
|
66
|
+
testUrl.searchParams.set(param, payload);
|
|
67
|
+
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
70
|
+
|
|
71
|
+
const response = await fetch(testUrl.toString(), {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
|
|
77
|
+
if (!response.ok) continue;
|
|
78
|
+
const text = await response.text();
|
|
79
|
+
|
|
80
|
+
if (marker.test(text)) {
|
|
81
|
+
findings.push(createFinding({
|
|
82
|
+
module: 'security',
|
|
83
|
+
title: `Path Traversal / LFI: ${name} via "${param}" parameter`,
|
|
84
|
+
severity: os === 'cloud' ? 'critical' : 'critical',
|
|
85
|
+
affected_surface: page.url,
|
|
86
|
+
description: `The parameter "${param}" at ${page.url} is vulnerable to path traversal. The payload "${payload}" successfully read a ${os === 'cloud' ? 'cloud metadata endpoint' : 'system file'}, allowing an attacker to read arbitrary files from the server's filesystem${os === 'cloud' ? ' and steal cloud credentials' : ', including application source code, configuration files, and credentials'}.`,
|
|
87
|
+
reproduction: [
|
|
88
|
+
`1. Navigate to: ${testUrl.toString()}`,
|
|
89
|
+
`2. Server returns contents of ${os === 'unix' ? '/etc/passwd' : os === 'windows' ? 'windows/win.ini' : 'cloud metadata endpoint'}`,
|
|
90
|
+
'3. Escalate to reading: application config files, .env, database credentials',
|
|
91
|
+
],
|
|
92
|
+
evidence: `Param: ${param}\nPayload: ${payload}\nResponse excerpt: ${text.substring(0, 300)}`,
|
|
93
|
+
remediation: 'Never use user-supplied input directly in file path operations. Use an allowlist of permitted filenames. Resolve and verify the canonical path is within the expected base directory (e.g., require realpath to start with /app/public/). Use chroot jails or container isolation for file serving services.',
|
|
94
|
+
references: [
|
|
95
|
+
'https://owasp.org/www-community/attacks/Path_Traversal',
|
|
96
|
+
'CWE-22',
|
|
97
|
+
'CWE-98',
|
|
98
|
+
],
|
|
99
|
+
}));
|
|
100
|
+
break; // One finding per param
|
|
101
|
+
}
|
|
102
|
+
} catch { /* continue */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.logger?.info?.(`Path Traversal: found ${findings.length} issues`);
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default PathTraversalScanner;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PrototypePollutionScanner — Tests for JavaScript prototype pollution vulnerabilities.
|
|
5
|
+
*
|
|
6
|
+
* Prototype pollution allows attackers to add properties to the global
|
|
7
|
+
* Object.prototype, which are then inherited by all objects, potentially
|
|
8
|
+
* causing RCE, access control bypass, or DoS in Node.js applications.
|
|
9
|
+
*
|
|
10
|
+
* Test vectors:
|
|
11
|
+
* - URL query parameters: ?__proto__[admin]=true
|
|
12
|
+
* - JSON body: {"__proto__":{"admin":true}}
|
|
13
|
+
* - Nested paths: ?constructor[prototype][admin]=true
|
|
14
|
+
* - URL path segments: /__proto__/admin
|
|
15
|
+
*/
|
|
16
|
+
export class PrototypePollutionScanner {
|
|
17
|
+
constructor(logger) {
|
|
18
|
+
this.logger = logger;
|
|
19
|
+
|
|
20
|
+
// Pollution vectors to test
|
|
21
|
+
this.URL_VECTORS = [
|
|
22
|
+
{ param: '__proto__[polluted]', value: 'jaku_pp_test', label: 'Direct __proto__ param' },
|
|
23
|
+
{ param: '__proto__[admin]', value: 'true', label: '__proto__[admin] escalation' },
|
|
24
|
+
{ param: 'constructor[prototype][polluted]', value: 'jaku_pp_test', label: 'Constructor prototype' },
|
|
25
|
+
{ param: 'constructor.prototype.polluted', value: 'jaku_pp_test', label: 'Dot notation constructor' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
this.JSON_VECTORS = [
|
|
29
|
+
{ body: { '__proto__': { 'polluted': 'jaku_pp_test', 'admin': true } }, label: 'JSON __proto__ key' },
|
|
30
|
+
{ body: { 'constructor': { 'prototype': { 'polluted': 'jaku_pp_test' } } }, label: 'JSON constructor.prototype' },
|
|
31
|
+
{ body: [{ '__proto__': { 'polluted': 'jaku_pp_test' } }], label: 'Array __proto__ element' },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async scan(surfaceInventory) {
|
|
36
|
+
const findings = [];
|
|
37
|
+
const tested = new Set();
|
|
38
|
+
|
|
39
|
+
// Test API endpoints and forms
|
|
40
|
+
const targets = surfaceInventory.pages.filter(p => p.status < 400).slice(0, 20);
|
|
41
|
+
|
|
42
|
+
for (const target of targets) {
|
|
43
|
+
if (tested.has(target.url)) continue;
|
|
44
|
+
tested.add(target.url);
|
|
45
|
+
|
|
46
|
+
// URL parameter pollution
|
|
47
|
+
const urlFinding = await this._testURLPollution(target.url);
|
|
48
|
+
if (urlFinding) findings.push(urlFinding);
|
|
49
|
+
|
|
50
|
+
// JSON body pollution (for API endpoints)
|
|
51
|
+
const jsonFinding = await this._testJSONPollution(target.url);
|
|
52
|
+
if (jsonFinding) findings.push(jsonFinding);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.logger?.info?.(`Prototype Pollution: found ${findings.length} issues`);
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async _testURLPollution(url) {
|
|
60
|
+
for (const vector of this.URL_VECTORS) {
|
|
61
|
+
try {
|
|
62
|
+
const testUrl = new URL(url);
|
|
63
|
+
testUrl.searchParams.set(vector.param, vector.value);
|
|
64
|
+
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
67
|
+
|
|
68
|
+
const response = await fetch(testUrl.toString(), {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
|
|
74
|
+
if (!response.ok) continue;
|
|
75
|
+
const text = await response.text();
|
|
76
|
+
|
|
77
|
+
// Heuristic: if the pollution key/value appears in a JSON response, it may indicate reflection
|
|
78
|
+
if (text.includes('jaku_pp_test') || text.includes('"admin":true') || text.includes('"polluted"')) {
|
|
79
|
+
return createFinding({
|
|
80
|
+
module: 'security',
|
|
81
|
+
title: `Prototype Pollution via URL Parameter: ${vector.label}`,
|
|
82
|
+
severity: 'high',
|
|
83
|
+
affected_surface: url,
|
|
84
|
+
description: `The endpoint reflects prototype pollution payloads injected via URL parameters. The vector "${vector.param}=${vector.value}" appears in the response, suggesting the server merges query parameters into objects without sanitizing prototype chain keys. In Node.js applications (lodash.merge, jQuery.extend, etc.), this can lead to global object corruption, access control bypass, or Remote Code Execution.`,
|
|
85
|
+
reproduction: [
|
|
86
|
+
`1. Send GET ${testUrl.toString()}`,
|
|
87
|
+
'2. Server reflects pollution key in response',
|
|
88
|
+
'3. If server uses a vulnerable merge operation, Object.prototype is now polluted',
|
|
89
|
+
],
|
|
90
|
+
evidence: `URL: ${testUrl.toString()}\nResponse contained: ${text.substring(0, 300)}`,
|
|
91
|
+
remediation: 'Sanitize all object keys before using them as property names. Use Object.hasOwnProperty checks. Use Object.create(null) for merge targets. Update lodash >= 4.17.21, jQuery >= 3.4.0. Use flat-param libraries that reject prototype chain keys.',
|
|
92
|
+
references: [
|
|
93
|
+
'https://portswigger.net/web-security/prototype-pollution',
|
|
94
|
+
'CWE-1321',
|
|
95
|
+
'https://snyk.io/vuln/SNYK-JS-LODASH-567746',
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} catch { /* continue */ }
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async _testJSONPollution(url) {
|
|
105
|
+
for (const vector of this.JSON_VECTORS) {
|
|
106
|
+
try {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
109
|
+
|
|
110
|
+
const response = await fetch(url, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(vector.body),
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
});
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
|
|
118
|
+
if (!response.ok && response.status !== 422) continue;
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
|
|
121
|
+
if (text.includes('jaku_pp_test') || (text.includes('admin') && text.includes('true'))) {
|
|
122
|
+
return createFinding({
|
|
123
|
+
module: 'security',
|
|
124
|
+
title: `Prototype Pollution via JSON Body: ${vector.label}`,
|
|
125
|
+
severity: 'critical',
|
|
126
|
+
affected_surface: url,
|
|
127
|
+
description: `The endpoint accepts JSON bodies containing "__proto__" or "constructor.prototype" keys and appears to process them without sanitization. A successful prototype pollution attack on the server can corrupt the Node.js runtime's Object prototype, enabling privilege escalation or code execution.`,
|
|
128
|
+
reproduction: [
|
|
129
|
+
`1. POST to ${url} with body: ${JSON.stringify(vector.body).substring(0, 150)}`,
|
|
130
|
+
'2. Server processes the __proto__ key',
|
|
131
|
+
'3. Check if admin endpoints become accessible: GET /admin',
|
|
132
|
+
],
|
|
133
|
+
evidence: `Body: ${JSON.stringify(vector.body)}\nResponse: ${text.substring(0, 300)}`,
|
|
134
|
+
remediation: 'Never use prototype-unsafe merge functions (lodash.merge pre-4.17.21, $.extend deep, Object.assign with user input as a source). Use a JSON schema validator that rejects __proto__ keys. Filter out __proto__, constructor, and prototype keys from all incoming JSON at the API gateway layer.',
|
|
135
|
+
references: [
|
|
136
|
+
'https://portswigger.net/web-security/prototype-pollution/server-side',
|
|
137
|
+
'CWE-1321',
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch { /* continue */ }
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default PrototypePollutionScanner;
|