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.
Files changed (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. 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;