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,263 @@
1
+ import { createFinding } from '../../utils/finding.js';
2
+
3
+ /**
4
+ * CORSWSTester — Tests CORS policy and WebSocket security.
5
+ *
6
+ * CORS Probes:
7
+ * - Access-Control-Allow-Origin: * with credentials
8
+ * - Origin reflection (arbitrary origin echoed back)
9
+ * - Null origin accepted
10
+ * - Credentialed pre-flight misconfiguration
11
+ *
12
+ * WebSocket Probes:
13
+ * - WS upgrade without authentication
14
+ * - WS connection from arbitrary origin
15
+ * - WS message injection
16
+ */
17
+ export class CORSWSTester {
18
+ constructor(logger) {
19
+ this.logger = logger;
20
+
21
+ this.WS_PATHS = [
22
+ '/ws', '/websocket', '/socket', '/api/ws', '/api/websocket',
23
+ '/socket.io/', '/realtime', '/live', '/stream',
24
+ ];
25
+ }
26
+
27
+ /**
28
+ * Test CORS and WebSocket security.
29
+ */
30
+ async test(surfaceInventory) {
31
+ const findings = [];
32
+ const baseUrl = this._getBaseUrl(surfaceInventory);
33
+ if (!baseUrl) return findings;
34
+
35
+ this.logger?.info?.('CORS & WS Tester: starting tests');
36
+
37
+ // 1. Test CORS policy
38
+ const corsFindings = await this._testCORS(baseUrl, surfaceInventory);
39
+ findings.push(...corsFindings);
40
+
41
+ // 2. Test WebSocket security
42
+ const wsFindings = await this._testWebSockets(baseUrl);
43
+ findings.push(...wsFindings);
44
+
45
+ this.logger?.info?.(`CORS & WS Tester: found ${findings.length} issues`);
46
+ return findings;
47
+ }
48
+
49
+ /**
50
+ * Test CORS on discovered endpoints.
51
+ */
52
+ async _testCORS(baseUrl, surfaceInventory) {
53
+ const findings = [];
54
+ const testUrls = [baseUrl + '/'];
55
+
56
+ // Add API endpoints
57
+ const apis = surfaceInventory.apis || [];
58
+ testUrls.push(...apis.slice(0, 5).map(a => a.url || a));
59
+
60
+ // Add common API paths
61
+ const apiPaths = ['/api', '/api/v1', '/api/users', '/graphql'];
62
+ testUrls.push(...apiPaths.map(p => {
63
+ try { return new URL(p, baseUrl).href; } catch { return null; }
64
+ }).filter(Boolean));
65
+
66
+ const tested = new Set();
67
+
68
+ for (const url of testUrls) {
69
+ if (tested.has(url)) continue;
70
+ tested.add(url);
71
+
72
+ // Test 1: Arbitrary origin reflection
73
+ await this._testOriginReflection(url, findings);
74
+
75
+ // Test 2: Null origin
76
+ await this._testNullOrigin(url, findings);
77
+
78
+ // Test 3: Wildcard with credentials
79
+ await this._testWildcardCredentials(url, findings);
80
+ }
81
+
82
+ return findings;
83
+ }
84
+
85
+ async _testOriginReflection(url, findings) {
86
+ try {
87
+ const evilOrigin = 'https://evil-attacker.com';
88
+ const response = await fetch(url, {
89
+ method: 'GET',
90
+ headers: { 'Origin': evilOrigin },
91
+ signal: AbortSignal.timeout(5000),
92
+ });
93
+
94
+ const acao = response.headers.get('access-control-allow-origin') || '';
95
+ const acac = response.headers.get('access-control-allow-credentials') || '';
96
+
97
+ if (acao === evilOrigin) {
98
+ const withCreds = acac.toLowerCase() === 'true';
99
+ findings.push(createFinding({
100
+ module: 'api',
101
+ title: withCreds
102
+ ? 'CORS: Origin Reflection with Credentials'
103
+ : 'CORS: Arbitrary Origin Reflected',
104
+ severity: withCreds ? 'critical' : 'high',
105
+ affected_surface: url,
106
+ description: withCreds
107
+ ? `The server reflects any Origin in Access-Control-Allow-Origin AND sets Allow-Credentials: true. An attacker's website can make credentialed cross-origin requests and read the response — effectively bypassing same-origin policy. This enables full cross-origin data theft.`
108
+ : `The server reflects any Origin in Access-Control-Allow-Origin. While credentials are not allowed, this still permits cross-origin data reading of non-credentialed responses.`,
109
+ reproduction: [
110
+ `1. Send request with Origin: ${evilOrigin}`,
111
+ `2. Response includes Access-Control-Allow-Origin: ${evilOrigin}`,
112
+ withCreds ? `3. Access-Control-Allow-Credentials: true` : '',
113
+ ].filter(Boolean),
114
+ evidence: `ACAO: ${acao}\nACAC: ${acac}`,
115
+ remediation: 'Use a strict allowlist for CORS origins. Never reflect the Origin header. If credentials are needed, specify exact allowed origins. Never combine wildcard (*) with credentials.',
116
+ }));
117
+ }
118
+ } catch {
119
+ // Endpoint not reachable
120
+ }
121
+ }
122
+
123
+ async _testNullOrigin(url, findings) {
124
+ try {
125
+ const response = await fetch(url, {
126
+ method: 'GET',
127
+ headers: { 'Origin': 'null' },
128
+ signal: AbortSignal.timeout(5000),
129
+ });
130
+
131
+ const acao = response.headers.get('access-control-allow-origin') || '';
132
+ if (acao === 'null') {
133
+ findings.push(createFinding({
134
+ module: 'api',
135
+ title: 'CORS: Null Origin Accepted',
136
+ severity: 'high',
137
+ affected_surface: url,
138
+ description: `The server allows requests from Origin: null. An attacker can trigger null origin from sandboxed iframes, data: URLs, or file: protocol — enabling cross-origin attacks without a real origin.`,
139
+ evidence: `Origin: null → ACAO: null`,
140
+ remediation: 'Never allow null origin in CORS configuration. Remove "null" from allowed origins list.',
141
+ }));
142
+ }
143
+ } catch {
144
+ // Not reachable
145
+ }
146
+ }
147
+
148
+ async _testWildcardCredentials(url, findings) {
149
+ try {
150
+ const response = await fetch(url, {
151
+ method: 'GET',
152
+ headers: { 'Origin': 'https://test.com' },
153
+ signal: AbortSignal.timeout(5000),
154
+ });
155
+
156
+ const acao = response.headers.get('access-control-allow-origin') || '';
157
+ const acac = response.headers.get('access-control-allow-credentials') || '';
158
+
159
+ if (acao === '*' && acac.toLowerCase() === 'true') {
160
+ findings.push(createFinding({
161
+ module: 'api',
162
+ title: 'CORS: Wildcard with Credentials',
163
+ severity: 'critical',
164
+ affected_surface: url,
165
+ description: `The server sets Access-Control-Allow-Origin: * with Allow-Credentials: true. While modern browsers block this combination, some older clients or custom HTTP libraries may honor it, allowing cross-origin credential theft.`,
166
+ evidence: `ACAO: *\nACAC: true`,
167
+ remediation: 'Never use wildcard (*) with credentials. Specify exact allowed origins.',
168
+ }));
169
+ }
170
+ } catch {
171
+ // Not reachable
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Test WebSocket security.
177
+ */
178
+ async _testWebSockets(baseUrl) {
179
+ const findings = [];
180
+ const wsBase = baseUrl.replace(/^http/, 'ws');
181
+
182
+ for (const path of this.WS_PATHS) {
183
+ try {
184
+ const wsUrl = new URL(path, wsBase).href;
185
+
186
+ // Test if WS endpoint exists via HTTP upgrade
187
+ const httpUrl = wsUrl.replace(/^ws/, 'http');
188
+ const response = await fetch(httpUrl, {
189
+ method: 'GET',
190
+ headers: {
191
+ 'Upgrade': 'websocket',
192
+ 'Connection': 'Upgrade',
193
+ 'Sec-WebSocket-Version': '13',
194
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
195
+ 'Origin': 'https://evil-attacker.com',
196
+ },
197
+ signal: AbortSignal.timeout(5000),
198
+ });
199
+
200
+ // 101 Switching Protocols = WS upgrade accepted
201
+ if (response.status === 101) {
202
+ findings.push(createFinding({
203
+ module: 'api',
204
+ title: 'WebSocket: Unauthenticated Upgrade from Arbitrary Origin',
205
+ severity: 'high',
206
+ affected_surface: wsUrl,
207
+ description: `WebSocket endpoint at ${path} accepts upgrade requests from arbitrary origins (https://evil-attacker.com) without authentication. An attacker's website can connect to this WebSocket and read/write messages.`,
208
+ reproduction: [
209
+ `1. Send WS upgrade request to ${wsUrl}`,
210
+ `2. Include Origin: https://evil-attacker.com`,
211
+ `3. Server responds with 101 Switching Protocols`,
212
+ ],
213
+ evidence: `Status: 101\nOrigin accepted: evil-attacker.com`,
214
+ remediation: 'Validate Origin header on WebSocket upgrade requests. Require authentication tokens in the initial HTTP upgrade. Implement per-connection authorization.',
215
+ }));
216
+ }
217
+
218
+ // Some servers respond 400/426 indicating WS is supported but needs proper upgrade
219
+ if (response.status === 426 || (response.status === 400 &&
220
+ (response.headers.get('upgrade') || '').toLowerCase().includes('websocket'))) {
221
+ // WS endpoint exists — check if it requires auth
222
+ const authResponse = await fetch(httpUrl, {
223
+ method: 'GET',
224
+ headers: {
225
+ 'Upgrade': 'websocket',
226
+ 'Connection': 'Upgrade',
227
+ 'Sec-WebSocket-Version': '13',
228
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
229
+ },
230
+ signal: AbortSignal.timeout(5000),
231
+ });
232
+
233
+ if (authResponse.status !== 401 && authResponse.status !== 403) {
234
+ findings.push(createFinding({
235
+ module: 'api',
236
+ title: 'WebSocket: Missing Authentication on Upgrade',
237
+ severity: 'medium',
238
+ affected_surface: wsUrl,
239
+ description: `WebSocket endpoint at ${path} does not return 401/403 for unauthenticated upgrade requests. The endpoint may accept anonymous connections.`,
240
+ evidence: `Status without auth: ${authResponse.status} (expected 401 or 403)`,
241
+ remediation: 'Require authentication tokens (JWT, session cookie) on WebSocket upgrade requests. Return 401 for unauthenticated connections.',
242
+ }));
243
+ }
244
+ }
245
+ } catch {
246
+ continue;
247
+ }
248
+ }
249
+
250
+ return findings;
251
+ }
252
+
253
+ _getBaseUrl(surfaceInventory) {
254
+ const pages = surfaceInventory.pages || [];
255
+ if (pages.length === 0) return null;
256
+ try {
257
+ const parsed = new URL(pages[0].url || pages[0]);
258
+ return `${parsed.protocol}//${parsed.host}`;
259
+ } catch { return null; }
260
+ }
261
+ }
262
+
263
+ export default CORSWSTester;
@@ -0,0 +1,287 @@
1
+ import { createFinding } from '../../utils/finding.js';
2
+
3
+ /**
4
+ * GraphQLTester — Tests GraphQL-specific vulnerabilities.
5
+ *
6
+ * Probes:
7
+ * - Introspection exposure (enumerate schema)
8
+ * - Batch query abuse (credential brute-force)
9
+ * - Nested query DoS (exponential execution)
10
+ * - Field suggestion (info disclosure)
11
+ * - Mutation without auth
12
+ * - Missing query depth/complexity limits
13
+ */
14
+ export class GraphQLTester {
15
+ constructor(logger) {
16
+ this.logger = logger;
17
+
18
+ this.GRAPHQL_PATHS = [
19
+ '/graphql', '/api/graphql', '/graphql/v1', '/gql',
20
+ '/query', '/api/query', '/graphql/console', '/graphiql',
21
+ ];
22
+ }
23
+
24
+ /**
25
+ * Test GraphQL endpoints for security issues.
26
+ */
27
+ async test(surfaceInventory) {
28
+ const findings = [];
29
+ const baseUrl = this._getBaseUrl(surfaceInventory);
30
+ if (!baseUrl) return findings;
31
+
32
+ this.logger?.info?.('GraphQL Tester: starting tests');
33
+
34
+ // 1. Discover GraphQL endpoints
35
+ const endpoints = await this._discover(baseUrl);
36
+
37
+ if (endpoints.length === 0) {
38
+ this.logger?.info?.('GraphQL Tester: no GraphQL endpoints found — skipping');
39
+ return findings;
40
+ }
41
+
42
+ this.logger?.info?.(`GraphQL Tester: found ${endpoints.length} GraphQL endpoints`);
43
+
44
+ for (const endpoint of endpoints) {
45
+ // 2. Test introspection
46
+ const introFindings = await this._testIntrospection(endpoint);
47
+ findings.push(...introFindings);
48
+
49
+ // 3. Test batch queries
50
+ const batchFindings = await this._testBatchQueries(endpoint);
51
+ findings.push(...batchFindings);
52
+
53
+ // 4. Test nested query DoS
54
+ const dosFindings = await this._testNestedDoS(endpoint);
55
+ findings.push(...dosFindings);
56
+
57
+ // 5. Test field suggestions
58
+ const suggestionFindings = await this._testFieldSuggestions(endpoint);
59
+ findings.push(...suggestionFindings);
60
+ }
61
+
62
+ this.logger?.info?.(`GraphQL Tester: found ${findings.length} issues`);
63
+ return findings;
64
+ }
65
+
66
+ async _discover(baseUrl) {
67
+ const endpoints = [];
68
+
69
+ for (const path of this.GRAPHQL_PATHS) {
70
+ try {
71
+ const url = new URL(path, baseUrl).href;
72
+
73
+ // Test with a simple introspection field
74
+ const response = await fetch(url, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ query: '{ __typename }' }),
78
+ signal: AbortSignal.timeout(5000),
79
+ });
80
+
81
+ if (response.ok) {
82
+ const text = await response.text();
83
+ if (text.includes('__typename') || text.includes('"data"') || text.includes('"errors"')) {
84
+ endpoints.push(url);
85
+ }
86
+ }
87
+ } catch {
88
+ continue;
89
+ }
90
+ }
91
+
92
+ return endpoints;
93
+ }
94
+
95
+ async _testIntrospection(endpoint) {
96
+ const findings = [];
97
+
98
+ const introspectionQuery = `{
99
+ __schema {
100
+ types { name kind }
101
+ queryType { name }
102
+ mutationType { name }
103
+ }
104
+ }`;
105
+
106
+ try {
107
+ const response = await fetch(endpoint, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ query: introspectionQuery }),
111
+ signal: AbortSignal.timeout(8000),
112
+ });
113
+
114
+ if (response.ok) {
115
+ const result = await response.json();
116
+ if (result.data?.__schema?.types) {
117
+ const types = result.data.__schema.types;
118
+ const customTypes = types.filter(t =>
119
+ !t.name.startsWith('__') && !['String', 'Int', 'Float', 'Boolean', 'ID'].includes(t.name)
120
+ );
121
+
122
+ findings.push(createFinding({
123
+ module: 'api',
124
+ title: 'GraphQL Introspection Exposed',
125
+ severity: 'high',
126
+ affected_surface: endpoint,
127
+ description: `GraphQL introspection is enabled at ${endpoint}, exposing the entire API schema (${types.length} types, ${customTypes.length} custom). An attacker can enumerate all queries, mutations, and data types to map the attack surface.`,
128
+ reproduction: [
129
+ `1. POST to ${endpoint} with __schema introspection query`,
130
+ `2. Full schema is returned`,
131
+ ],
132
+ evidence: `Types: ${types.length} (${customTypes.length} custom)\nCustom types: ${customTypes.slice(0, 10).map(t => t.name).join(', ')}`,
133
+ remediation: 'Disable introspection in production. Only allow it in development environments. Use persisted queries/allowlists to restrict which queries clients can execute.',
134
+ }));
135
+ }
136
+ }
137
+ } catch {
138
+ // Not GraphQL or introspection disabled
139
+ }
140
+
141
+ return findings;
142
+ }
143
+
144
+ async _testBatchQueries(endpoint) {
145
+ const findings = [];
146
+
147
+ // Send multiple queries in a single request (batch abuse)
148
+ const batchPayload = Array.from({ length: 10 }, (_, i) => ({
149
+ query: `{ __typename }`,
150
+ operationName: `batch_${i}`,
151
+ }));
152
+
153
+ try {
154
+ const response = await fetch(endpoint, {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify(batchPayload),
158
+ signal: AbortSignal.timeout(8000),
159
+ });
160
+
161
+ if (response.ok) {
162
+ const result = await response.json();
163
+ if (Array.isArray(result) && result.length >= 5) {
164
+ findings.push(createFinding({
165
+ module: 'api',
166
+ title: 'GraphQL Batch Query Abuse',
167
+ severity: 'high',
168
+ affected_surface: endpoint,
169
+ description: `GraphQL endpoint accepts batch queries (${result.length}/10 processed). An attacker can abuse batching for credential brute-force (batching login mutations), rate limit bypass, or DoS by sending thousands of queries in a single HTTP request.`,
170
+ reproduction: [
171
+ `1. POST array of 10 queries to ${endpoint}`,
172
+ `2. Server processes all ${result.length} queries`,
173
+ ],
174
+ evidence: `Batch size: 10 → ${result.length} processed`,
175
+ remediation: 'Limit batch query count (max 5-10 per request). Implement per-query rate limiting. Count each query in a batch against rate limits individually.',
176
+ }));
177
+ }
178
+ }
179
+ } catch {
180
+ // Batch not supported
181
+ }
182
+
183
+ return findings;
184
+ }
185
+
186
+ async _testNestedDoS(endpoint) {
187
+ const findings = [];
188
+
189
+ // Test with a deeply nested query
190
+ const deepQuery = `{
191
+ __schema {
192
+ types {
193
+ fields {
194
+ type {
195
+ fields {
196
+ type {
197
+ fields {
198
+ type {
199
+ name
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }`;
209
+
210
+ try {
211
+ const startTime = Date.now();
212
+ const response = await fetch(endpoint, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify({ query: deepQuery }),
216
+ signal: AbortSignal.timeout(10000),
217
+ });
218
+ const elapsed = Date.now() - startTime;
219
+
220
+ if (response.ok) {
221
+ const text = await response.text();
222
+
223
+ if (elapsed > 3000 || text.length > 100000) {
224
+ findings.push(createFinding({
225
+ module: 'api',
226
+ title: 'GraphQL Nested Query DoS: No Depth Limit',
227
+ severity: 'high',
228
+ affected_surface: endpoint,
229
+ description: `GraphQL endpoint processed a deeply nested query (7 levels) in ${elapsed}ms, returning ${text.length} bytes. No depth or complexity limit prevents resource exhaustion via recursive queries.`,
230
+ evidence: `Query depth: 7 levels\nResponse time: ${elapsed}ms\nResponse size: ${text.length} bytes`,
231
+ remediation: 'Implement query depth limiting (max 5-10 levels). Add query complexity analysis. Set maximum execution time. Use query cost analysis to reject expensive queries.',
232
+ }));
233
+ }
234
+ }
235
+ } catch {
236
+ // Timeout — which actually confirms DoS risk
237
+ }
238
+
239
+ return findings;
240
+ }
241
+
242
+ async _testFieldSuggestions(endpoint) {
243
+ const findings = [];
244
+
245
+ // Send query with a typo to trigger field suggestions
246
+ const typoQuery = `{ usrs { id naem emial } }`;
247
+
248
+ try {
249
+ const response = await fetch(endpoint, {
250
+ method: 'POST',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({ query: typoQuery }),
253
+ signal: AbortSignal.timeout(5000),
254
+ });
255
+
256
+ if (response.ok || response.status === 400) {
257
+ const text = await response.text();
258
+ if (/did you mean|suggestion|similar/i.test(text)) {
259
+ findings.push(createFinding({
260
+ module: 'api',
261
+ title: 'GraphQL Field Suggestions: Schema Enumeration',
262
+ severity: 'low',
263
+ affected_surface: endpoint,
264
+ description: `GraphQL endpoint returns field suggestions for invalid queries. An attacker can brute-force valid field names by observing "Did you mean..." suggestions.`,
265
+ evidence: `Query with typos returned suggestions`,
266
+ remediation: 'Disable field suggestions in production. This leaks schema information even when introspection is disabled.',
267
+ }));
268
+ }
269
+ }
270
+ } catch {
271
+ // Not applicable
272
+ }
273
+
274
+ return findings;
275
+ }
276
+
277
+ _getBaseUrl(surfaceInventory) {
278
+ const pages = surfaceInventory.pages || [];
279
+ if (pages.length === 0) return null;
280
+ try {
281
+ const parsed = new URL(pages[0].url || pages[0]);
282
+ return `${parsed.protocol}//${parsed.host}`;
283
+ } catch { return null; }
284
+ }
285
+ }
286
+
287
+ export default GraphQLTester;