graphql-sentinel 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 +21 -0
- package/README.md +530 -0
- package/dist/cli.cjs +1834 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1811 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +1822 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +176 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +1768 -0
- package/dist/index.js.map +1 -0
- package/package.json +95 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,1834 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli.ts
|
|
27
|
+
var import_commander3 = require("commander");
|
|
28
|
+
|
|
29
|
+
// src/cli/scan.ts
|
|
30
|
+
var import_commander = require("commander");
|
|
31
|
+
|
|
32
|
+
// src/scanner/checks/introspection.ts
|
|
33
|
+
var introspectionCheck = {
|
|
34
|
+
name: "introspection",
|
|
35
|
+
severity: "medium",
|
|
36
|
+
async run(endpoint, headers) {
|
|
37
|
+
const query = "{ __schema { types { name } } }";
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(endpoint, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
...headers
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({ query })
|
|
46
|
+
});
|
|
47
|
+
const body = await response.json();
|
|
48
|
+
const hasSchema = body?.data?.__schema?.types?.length > 0;
|
|
49
|
+
return {
|
|
50
|
+
check: "introspection",
|
|
51
|
+
severity: "medium",
|
|
52
|
+
passed: !hasSchema,
|
|
53
|
+
title: "Introspection Enabled",
|
|
54
|
+
description: hasSchema ? "GraphQL introspection is enabled, exposing the full API schema to attackers." : "GraphQL introspection is properly disabled.",
|
|
55
|
+
remediation: "Disable introspection in production to prevent schema exposure.",
|
|
56
|
+
details: {
|
|
57
|
+
introspectionEnabled: hasSchema,
|
|
58
|
+
typesFound: hasSchema ? body.data.__schema.types.length : 0
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
check: "introspection",
|
|
64
|
+
severity: "medium",
|
|
65
|
+
passed: true,
|
|
66
|
+
title: "Introspection Enabled",
|
|
67
|
+
description: "Could not perform introspection query (likely disabled or endpoint unreachable).",
|
|
68
|
+
remediation: "Disable introspection in production to prevent schema exposure.",
|
|
69
|
+
details: { error: String(error) }
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/scanner/checks/depth-limit.ts
|
|
76
|
+
var depthLimitCheck = {
|
|
77
|
+
name: "depth-limit",
|
|
78
|
+
severity: "high",
|
|
79
|
+
async run(endpoint, headers) {
|
|
80
|
+
const depth = 20;
|
|
81
|
+
let current = "__typename";
|
|
82
|
+
for (let i = depth; i >= 0; i--) {
|
|
83
|
+
current = `d${i}: __type(name: "Query") { ${current} }`;
|
|
84
|
+
}
|
|
85
|
+
const query = `{ ${current} }`;
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(endpoint, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
...headers
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({ query })
|
|
94
|
+
});
|
|
95
|
+
const body = await response.json();
|
|
96
|
+
const hasErrors = body?.errors?.length > 0;
|
|
97
|
+
const depthError = body?.errors?.some(
|
|
98
|
+
(e) => e.message.toLowerCase().includes("depth") || e.message.toLowerCase().includes("too complex") || e.message.toLowerCase().includes("max") || e.message.toLowerCase().includes("limit")
|
|
99
|
+
);
|
|
100
|
+
if (depthError) {
|
|
101
|
+
return {
|
|
102
|
+
check: "depth-limit",
|
|
103
|
+
severity: "high",
|
|
104
|
+
passed: true,
|
|
105
|
+
title: "No Query Depth Limit",
|
|
106
|
+
description: "Server properly enforces query depth limits.",
|
|
107
|
+
remediation: "Enforce query depth limits to prevent deeply nested query attacks.",
|
|
108
|
+
details: { depthTested: depth, blocked: true }
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const noDepthLimit = !hasErrors || !depthError;
|
|
112
|
+
return {
|
|
113
|
+
check: "depth-limit",
|
|
114
|
+
severity: "high",
|
|
115
|
+
passed: !noDepthLimit,
|
|
116
|
+
title: "No Query Depth Limit",
|
|
117
|
+
description: noDepthLimit ? "Server does not enforce query depth limits, enabling denial-of-service via deeply nested queries." : "Server properly enforces query depth limits.",
|
|
118
|
+
remediation: "Enforce query depth limits to prevent deeply nested query attacks.",
|
|
119
|
+
details: { depthTested: depth, blocked: !noDepthLimit }
|
|
120
|
+
};
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
check: "depth-limit",
|
|
124
|
+
severity: "high",
|
|
125
|
+
passed: true,
|
|
126
|
+
title: "No Query Depth Limit",
|
|
127
|
+
description: "Could not test query depth (endpoint unreachable or request failed).",
|
|
128
|
+
remediation: "Enforce query depth limits to prevent deeply nested query attacks.",
|
|
129
|
+
details: { error: String(error) }
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// src/scanner/checks/batch-attack.ts
|
|
136
|
+
var batchAttackCheck = {
|
|
137
|
+
name: "batch-attack",
|
|
138
|
+
severity: "medium",
|
|
139
|
+
async run(endpoint, headers) {
|
|
140
|
+
const singleQuery = { query: "{ __typename }" };
|
|
141
|
+
const batchPayload = Array.from({ length: 10 }, () => ({ ...singleQuery }));
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(endpoint, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
...headers
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify(batchPayload)
|
|
150
|
+
});
|
|
151
|
+
const body = await response.json();
|
|
152
|
+
const isBatchResponse = Array.isArray(body) && body.length === 10;
|
|
153
|
+
return {
|
|
154
|
+
check: "batch-attack",
|
|
155
|
+
severity: "medium",
|
|
156
|
+
passed: !isBatchResponse,
|
|
157
|
+
title: "Batch Queries Allowed",
|
|
158
|
+
description: isBatchResponse ? "Server accepts batched queries, enabling amplification attacks." : "Server does not accept batched queries.",
|
|
159
|
+
remediation: "Disable or limit batch query support to prevent query amplification attacks.",
|
|
160
|
+
details: {
|
|
161
|
+
batchSize: 10,
|
|
162
|
+
batchAccepted: isBatchResponse,
|
|
163
|
+
responseType: Array.isArray(body) ? "array" : typeof body
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return {
|
|
168
|
+
check: "batch-attack",
|
|
169
|
+
severity: "medium",
|
|
170
|
+
passed: true,
|
|
171
|
+
title: "Batch Queries Allowed",
|
|
172
|
+
description: "Could not test batch queries (endpoint unreachable or request failed).",
|
|
173
|
+
remediation: "Disable or limit batch query support to prevent query amplification attacks.",
|
|
174
|
+
details: { error: String(error) }
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/scanner/checks/field-suggestion.ts
|
|
181
|
+
var fieldSuggestionCheck = {
|
|
182
|
+
name: "field-suggestion",
|
|
183
|
+
severity: "low",
|
|
184
|
+
async run(endpoint, headers) {
|
|
185
|
+
const query = "{ __schemax }";
|
|
186
|
+
try {
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
...headers
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify({ query })
|
|
194
|
+
});
|
|
195
|
+
const body = await response.json();
|
|
196
|
+
const errorMessages = (body?.errors || []).map((e) => e.message).join(" ");
|
|
197
|
+
const hasSuggestions = errorMessages.toLowerCase().includes("did you mean") || errorMessages.toLowerCase().includes("do you mean");
|
|
198
|
+
return {
|
|
199
|
+
check: "field-suggestion",
|
|
200
|
+
severity: "low",
|
|
201
|
+
passed: !hasSuggestions,
|
|
202
|
+
title: "Field Suggestions Exposed",
|
|
203
|
+
description: hasSuggestions ? "Server exposes field suggestions in error messages, aiding schema discovery." : "Server does not expose field suggestions in error messages.",
|
|
204
|
+
remediation: "Disable field suggestions in production to prevent schema enumeration via error messages.",
|
|
205
|
+
details: {
|
|
206
|
+
suggestionsExposed: hasSuggestions,
|
|
207
|
+
errorMessages: errorMessages.substring(0, 500)
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return {
|
|
212
|
+
check: "field-suggestion",
|
|
213
|
+
severity: "low",
|
|
214
|
+
passed: true,
|
|
215
|
+
title: "Field Suggestions Exposed",
|
|
216
|
+
description: "Could not test field suggestions (endpoint unreachable or request failed).",
|
|
217
|
+
remediation: "Disable field suggestions in production to prevent schema enumeration via error messages.",
|
|
218
|
+
details: { error: String(error) }
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/scanner/checks/alias-overloading.ts
|
|
225
|
+
var aliasOverloadingCheck = {
|
|
226
|
+
name: "alias-overloading",
|
|
227
|
+
severity: "medium",
|
|
228
|
+
async run(endpoint, headers) {
|
|
229
|
+
const aliasCount = 100;
|
|
230
|
+
const aliases = Array.from({ length: aliasCount }, (_, i) => `a${i}: __typename`).join(" ");
|
|
231
|
+
const query = `{ ${aliases} }`;
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(endpoint, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: {
|
|
236
|
+
"Content-Type": "application/json",
|
|
237
|
+
...headers
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify({ query })
|
|
240
|
+
});
|
|
241
|
+
const body = await response.json();
|
|
242
|
+
const hasData2 = body?.data !== void 0 && body?.data !== null;
|
|
243
|
+
const aliasKeys = hasData2 ? Object.keys(body.data) : [];
|
|
244
|
+
const allAliasesResolved = aliasKeys.length >= aliasCount;
|
|
245
|
+
const hasAliasError = body?.errors?.some(
|
|
246
|
+
(e) => e.message.toLowerCase().includes("alias") || e.message.toLowerCase().includes("too many") || e.message.toLowerCase().includes("limit")
|
|
247
|
+
);
|
|
248
|
+
const passed = hasAliasError || !allAliasesResolved;
|
|
249
|
+
return {
|
|
250
|
+
check: "alias-overloading",
|
|
251
|
+
severity: "medium",
|
|
252
|
+
passed,
|
|
253
|
+
title: "Alias Overloading Possible",
|
|
254
|
+
description: passed ? "Server properly limits the number of aliases in a query." : `Server accepted ${aliasCount} aliases without restriction, enabling alias-based DoS attacks.`,
|
|
255
|
+
remediation: "Implement alias limits to prevent denial-of-service via alias overloading.",
|
|
256
|
+
details: {
|
|
257
|
+
aliasesTested: aliasCount,
|
|
258
|
+
aliasesAccepted: aliasKeys.length,
|
|
259
|
+
blocked: passed
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
check: "alias-overloading",
|
|
265
|
+
severity: "medium",
|
|
266
|
+
passed: true,
|
|
267
|
+
title: "Alias Overloading Possible",
|
|
268
|
+
description: "Could not test alias overloading (endpoint unreachable or request failed).",
|
|
269
|
+
remediation: "Implement alias limits to prevent denial-of-service via alias overloading.",
|
|
270
|
+
details: { error: String(error) }
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// src/scanner/checks/csrf.ts
|
|
277
|
+
var csrfCheck = {
|
|
278
|
+
name: "csrf",
|
|
279
|
+
severity: "high",
|
|
280
|
+
async run(endpoint, headers) {
|
|
281
|
+
const query = encodeURIComponent("{ __typename }");
|
|
282
|
+
const url = `${endpoint}?query=${query}`;
|
|
283
|
+
try {
|
|
284
|
+
const response = await fetch(url, {
|
|
285
|
+
method: "GET",
|
|
286
|
+
headers: {
|
|
287
|
+
...headers
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
const body = await response.json();
|
|
291
|
+
const hasData2 = body?.data?.__typename !== void 0;
|
|
292
|
+
return {
|
|
293
|
+
check: "csrf",
|
|
294
|
+
severity: "high",
|
|
295
|
+
passed: !hasData2,
|
|
296
|
+
title: "GET Mutations Allowed (CSRF Risk)",
|
|
297
|
+
description: hasData2 ? "Server accepts GraphQL queries via GET requests, which can be exploited for CSRF attacks on mutations." : "Server does not accept GraphQL queries via GET requests.",
|
|
298
|
+
remediation: "Disable GET method for GraphQL queries, or at minimum restrict GET to only allow queries (not mutations).",
|
|
299
|
+
details: {
|
|
300
|
+
getRequestAccepted: hasData2,
|
|
301
|
+
statusCode: response.status
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
} catch (error) {
|
|
305
|
+
return {
|
|
306
|
+
check: "csrf",
|
|
307
|
+
severity: "high",
|
|
308
|
+
passed: true,
|
|
309
|
+
title: "GET Mutations Allowed (CSRF Risk)",
|
|
310
|
+
description: "Could not test CSRF via GET request (endpoint unreachable or request failed).",
|
|
311
|
+
remediation: "Disable GET method for GraphQL queries, or at minimum restrict GET to only allow queries (not mutations).",
|
|
312
|
+
details: { error: String(error) }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// src/scanner/checks/auth-bypass.ts
|
|
319
|
+
var INTROSPECTION_QUERY = `{
|
|
320
|
+
__schema {
|
|
321
|
+
queryType { name }
|
|
322
|
+
types {
|
|
323
|
+
name
|
|
324
|
+
kind
|
|
325
|
+
fields {
|
|
326
|
+
name
|
|
327
|
+
type { name kind ofType { name kind } }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}`;
|
|
332
|
+
function findFirstQueryField(schemaData) {
|
|
333
|
+
const types = schemaData?.__schema?.types;
|
|
334
|
+
if (!Array.isArray(types)) return null;
|
|
335
|
+
const queryTypeName = schemaData?.__schema?.queryType?.name || "Query";
|
|
336
|
+
const queryType = types.find((t) => t.name === queryTypeName);
|
|
337
|
+
if (!queryType?.fields || !Array.isArray(queryType.fields)) return null;
|
|
338
|
+
for (const field of queryType.fields) {
|
|
339
|
+
if (field.name.startsWith("__")) continue;
|
|
340
|
+
return field.name;
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
async function sendQuery(endpoint, query, headers) {
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetch(endpoint, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: {
|
|
349
|
+
"Content-Type": "application/json",
|
|
350
|
+
...headers
|
|
351
|
+
},
|
|
352
|
+
body: JSON.stringify({ query })
|
|
353
|
+
});
|
|
354
|
+
const body = await response.json();
|
|
355
|
+
return { status: response.status, body };
|
|
356
|
+
} catch {
|
|
357
|
+
return { status: 0, body: null };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function isAuthError(body, status) {
|
|
361
|
+
if (status === 401 || status === 403) return true;
|
|
362
|
+
if (!body) return false;
|
|
363
|
+
const errors = body?.errors;
|
|
364
|
+
if (!Array.isArray(errors)) return false;
|
|
365
|
+
return errors.some((e) => {
|
|
366
|
+
const msg = (e.message || "").toLowerCase();
|
|
367
|
+
const code = (e.extensions?.code || "").toLowerCase();
|
|
368
|
+
return msg.includes("unauthorized") || msg.includes("unauthenticated") || msg.includes("not authenticated") || msg.includes("authentication required") || msg.includes("access denied") || msg.includes("forbidden") || msg.includes("must be logged in") || msg.includes("not allowed") || code.includes("unauthenticated") || code.includes("unauthorized") || code.includes("forbidden");
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function hasData(body) {
|
|
372
|
+
if (!body) return false;
|
|
373
|
+
const data = body?.data;
|
|
374
|
+
if (data === null || data === void 0) return false;
|
|
375
|
+
if (typeof data === "object") {
|
|
376
|
+
return Object.values(data).some((v) => v !== null);
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
var authBypassCheck = {
|
|
381
|
+
name: "auth-bypass",
|
|
382
|
+
severity: "high",
|
|
383
|
+
async run(endpoint, headers) {
|
|
384
|
+
try {
|
|
385
|
+
const introResult = await sendQuery(endpoint, INTROSPECTION_QUERY, headers);
|
|
386
|
+
let testField = null;
|
|
387
|
+
if (introResult.body && hasData(introResult.body)) {
|
|
388
|
+
testField = findFirstQueryField(introResult.body.data);
|
|
389
|
+
}
|
|
390
|
+
const testQuery = testField ? `{ ${testField} }` : "{ __typename }";
|
|
391
|
+
const noAuthResult = await sendQuery(endpoint, testQuery);
|
|
392
|
+
const noAuthBlocked = isAuthError(noAuthResult.body, noAuthResult.status);
|
|
393
|
+
const noAuthHasData = hasData(noAuthResult.body);
|
|
394
|
+
const emptyAuthResult = await sendQuery(endpoint, testQuery, {
|
|
395
|
+
Authorization: ""
|
|
396
|
+
});
|
|
397
|
+
const emptyAuthBlocked = isAuthError(emptyAuthResult.body, emptyAuthResult.status);
|
|
398
|
+
const emptyAuthHasData = hasData(emptyAuthResult.body);
|
|
399
|
+
const invalidTokenResult = await sendQuery(endpoint, testQuery, {
|
|
400
|
+
Authorization: "Bearer invalid_token_sentinel_test"
|
|
401
|
+
});
|
|
402
|
+
const invalidTokenBlocked = isAuthError(invalidTokenResult.body, invalidTokenResult.status);
|
|
403
|
+
const invalidTokenHasData = hasData(invalidTokenResult.body);
|
|
404
|
+
if (headers && (headers["Authorization"] || headers["authorization"])) {
|
|
405
|
+
await sendQuery(endpoint, testQuery, headers);
|
|
406
|
+
}
|
|
407
|
+
const allBlocked = noAuthBlocked && emptyAuthBlocked && invalidTokenBlocked;
|
|
408
|
+
const anyDataLeaked = noAuthHasData || emptyAuthHasData || invalidTokenHasData;
|
|
409
|
+
const isPublicApi = !headers && noAuthHasData && !noAuthBlocked;
|
|
410
|
+
if (isPublicApi && !headers) {
|
|
411
|
+
return {
|
|
412
|
+
check: "auth-bypass",
|
|
413
|
+
severity: "info",
|
|
414
|
+
passed: true,
|
|
415
|
+
title: "Authorization Bypass Detection",
|
|
416
|
+
description: "API appears to be publicly accessible without authentication. Verify this is intentional.",
|
|
417
|
+
remediation: "If this API should require authentication, implement proper auth middleware.",
|
|
418
|
+
details: {
|
|
419
|
+
publicApi: true,
|
|
420
|
+
noAuthBlocked,
|
|
421
|
+
emptyAuthBlocked,
|
|
422
|
+
invalidTokenBlocked,
|
|
423
|
+
testQuery
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (allBlocked) {
|
|
428
|
+
return {
|
|
429
|
+
check: "auth-bypass",
|
|
430
|
+
severity: "high",
|
|
431
|
+
passed: true,
|
|
432
|
+
title: "Authorization Bypass Detection",
|
|
433
|
+
description: "All unauthorized requests were properly rejected. Authorization checks appear to be in place.",
|
|
434
|
+
remediation: "Continue enforcing authorization checks on all fields and mutations.",
|
|
435
|
+
details: {
|
|
436
|
+
noAuthBlocked,
|
|
437
|
+
emptyAuthBlocked,
|
|
438
|
+
invalidTokenBlocked,
|
|
439
|
+
testQuery
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (anyDataLeaked) {
|
|
444
|
+
const bypasses = [];
|
|
445
|
+
if (noAuthHasData) bypasses.push("no-auth-header");
|
|
446
|
+
if (emptyAuthHasData) bypasses.push("empty-auth-header");
|
|
447
|
+
if (invalidTokenHasData) bypasses.push("invalid-bearer-token");
|
|
448
|
+
return {
|
|
449
|
+
check: "auth-bypass",
|
|
450
|
+
severity: "high",
|
|
451
|
+
passed: false,
|
|
452
|
+
title: "Authorization Bypass Detection",
|
|
453
|
+
description: `Data was returned without valid authorization via: ${bypasses.join(", ")}. The API may have missing or improperly configured authorization.`,
|
|
454
|
+
remediation: "Ensure all queries require proper authentication. Validate authorization tokens on every request and verify field-level authorization is enforced.",
|
|
455
|
+
details: {
|
|
456
|
+
noAuthBlocked,
|
|
457
|
+
noAuthHasData,
|
|
458
|
+
emptyAuthBlocked,
|
|
459
|
+
emptyAuthHasData,
|
|
460
|
+
invalidTokenBlocked,
|
|
461
|
+
invalidTokenHasData,
|
|
462
|
+
bypasses,
|
|
463
|
+
testQuery
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
check: "auth-bypass",
|
|
469
|
+
severity: "high",
|
|
470
|
+
passed: true,
|
|
471
|
+
title: "Authorization Bypass Detection",
|
|
472
|
+
description: "Unauthorized requests did not return data. Authorization appears to be configured.",
|
|
473
|
+
remediation: "Continue enforcing authorization checks on all fields and mutations.",
|
|
474
|
+
details: {
|
|
475
|
+
noAuthBlocked,
|
|
476
|
+
emptyAuthBlocked,
|
|
477
|
+
invalidTokenBlocked,
|
|
478
|
+
testQuery
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return {
|
|
483
|
+
check: "auth-bypass",
|
|
484
|
+
severity: "high",
|
|
485
|
+
passed: true,
|
|
486
|
+
title: "Authorization Bypass Detection",
|
|
487
|
+
description: "Could not perform authorization bypass check (endpoint unreachable or request failed).",
|
|
488
|
+
remediation: "Ensure all queries require proper authentication and retry the scan.",
|
|
489
|
+
details: { error: String(error) }
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// src/scanner/checks/index.ts
|
|
496
|
+
var allChecks = [
|
|
497
|
+
introspectionCheck,
|
|
498
|
+
depthLimitCheck,
|
|
499
|
+
batchAttackCheck,
|
|
500
|
+
fieldSuggestionCheck,
|
|
501
|
+
aliasOverloadingCheck,
|
|
502
|
+
csrfCheck,
|
|
503
|
+
authBypassCheck
|
|
504
|
+
];
|
|
505
|
+
function getChecks(names) {
|
|
506
|
+
if (!names || names.length === 0) {
|
|
507
|
+
return allChecks;
|
|
508
|
+
}
|
|
509
|
+
return allChecks.filter((check) => names.includes(check.name));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/scanner/runner.ts
|
|
513
|
+
async function runScan(config) {
|
|
514
|
+
const { endpoint, headers, checks: checkNames, timeout = 1e4 } = config;
|
|
515
|
+
const checks = getChecks(checkNames);
|
|
516
|
+
const startTime = Date.now();
|
|
517
|
+
const results = await Promise.all(
|
|
518
|
+
checks.map(async (check) => {
|
|
519
|
+
try {
|
|
520
|
+
const result = await Promise.race([
|
|
521
|
+
check.run(endpoint, headers),
|
|
522
|
+
new Promise(
|
|
523
|
+
(_, reject) => setTimeout(() => reject(new Error(`Check '${check.name}' timed out`)), timeout)
|
|
524
|
+
)
|
|
525
|
+
]);
|
|
526
|
+
return result;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return {
|
|
529
|
+
check: check.name,
|
|
530
|
+
severity: check.severity,
|
|
531
|
+
passed: true,
|
|
532
|
+
title: `Check ${check.name}`,
|
|
533
|
+
description: `Check failed to execute: ${String(error)}`,
|
|
534
|
+
remediation: "Retry the scan or check endpoint availability.",
|
|
535
|
+
details: { error: String(error) }
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
);
|
|
540
|
+
const duration = Date.now() - startTime;
|
|
541
|
+
const bySeverity = {
|
|
542
|
+
critical: 0,
|
|
543
|
+
high: 0,
|
|
544
|
+
medium: 0,
|
|
545
|
+
low: 0,
|
|
546
|
+
info: 0
|
|
547
|
+
};
|
|
548
|
+
const failed = results.filter((r) => !r.passed);
|
|
549
|
+
for (const result of failed) {
|
|
550
|
+
bySeverity[result.severity]++;
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
target: endpoint,
|
|
554
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
555
|
+
duration,
|
|
556
|
+
results,
|
|
557
|
+
summary: {
|
|
558
|
+
total: results.length,
|
|
559
|
+
passed: results.filter((r) => r.passed).length,
|
|
560
|
+
failed: failed.length,
|
|
561
|
+
bySeverity
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/reporter/json.ts
|
|
567
|
+
function generateJsonReport(report) {
|
|
568
|
+
return JSON.stringify(report, null, 2);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/reporter/terminal.ts
|
|
572
|
+
var COLORS = {
|
|
573
|
+
reset: "\x1B[0m",
|
|
574
|
+
bold: "\x1B[1m",
|
|
575
|
+
dim: "\x1B[2m",
|
|
576
|
+
red: "\x1B[31m",
|
|
577
|
+
green: "\x1B[32m",
|
|
578
|
+
yellow: "\x1B[33m",
|
|
579
|
+
blue: "\x1B[34m",
|
|
580
|
+
magenta: "\x1B[35m",
|
|
581
|
+
cyan: "\x1B[36m",
|
|
582
|
+
white: "\x1B[37m",
|
|
583
|
+
bgRed: "\x1B[41m",
|
|
584
|
+
bgGreen: "\x1B[42m",
|
|
585
|
+
bgYellow: "\x1B[43m",
|
|
586
|
+
bgBlue: "\x1B[44m",
|
|
587
|
+
bgMagenta: "\x1B[45m"
|
|
588
|
+
};
|
|
589
|
+
function severityColor(severity) {
|
|
590
|
+
switch (severity) {
|
|
591
|
+
case "critical":
|
|
592
|
+
return COLORS.bgRed + COLORS.white;
|
|
593
|
+
case "high":
|
|
594
|
+
return COLORS.red;
|
|
595
|
+
case "medium":
|
|
596
|
+
return COLORS.yellow;
|
|
597
|
+
case "low":
|
|
598
|
+
return COLORS.blue;
|
|
599
|
+
case "info":
|
|
600
|
+
return COLORS.dim;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function severityBadge(severity) {
|
|
604
|
+
const color = severityColor(severity);
|
|
605
|
+
return `${color}[${severity.toUpperCase()}]${COLORS.reset}`;
|
|
606
|
+
}
|
|
607
|
+
function statusIcon(passed) {
|
|
608
|
+
return passed ? `${COLORS.green}PASS${COLORS.reset}` : `${COLORS.red}FAIL${COLORS.reset}`;
|
|
609
|
+
}
|
|
610
|
+
function formatResult(result) {
|
|
611
|
+
const lines = [];
|
|
612
|
+
lines.push(
|
|
613
|
+
` ${statusIcon(result.passed)} ${severityBadge(result.severity)} ${COLORS.bold}${result.title}${COLORS.reset}`
|
|
614
|
+
);
|
|
615
|
+
lines.push(` ${COLORS.dim}${result.description}${COLORS.reset}`);
|
|
616
|
+
if (!result.passed) {
|
|
617
|
+
lines.push(` ${COLORS.cyan}Remediation: ${result.remediation}${COLORS.reset}`);
|
|
618
|
+
}
|
|
619
|
+
return lines.join("\n");
|
|
620
|
+
}
|
|
621
|
+
function generateTerminalReport(report) {
|
|
622
|
+
const lines = [];
|
|
623
|
+
lines.push("");
|
|
624
|
+
lines.push(
|
|
625
|
+
`${COLORS.bold}${COLORS.magenta}=== GraphQL Sentinel Security Scan ===${COLORS.reset}`
|
|
626
|
+
);
|
|
627
|
+
lines.push(`${COLORS.dim}Target: ${report.target}${COLORS.reset}`);
|
|
628
|
+
lines.push(`${COLORS.dim}Timestamp: ${report.timestamp}${COLORS.reset}`);
|
|
629
|
+
lines.push(`${COLORS.dim}Duration: ${report.duration}ms${COLORS.reset}`);
|
|
630
|
+
lines.push("");
|
|
631
|
+
lines.push(`${COLORS.bold}Results:${COLORS.reset}`);
|
|
632
|
+
lines.push("");
|
|
633
|
+
for (const result of report.results) {
|
|
634
|
+
lines.push(formatResult(result));
|
|
635
|
+
lines.push("");
|
|
636
|
+
}
|
|
637
|
+
lines.push(`${COLORS.bold}${COLORS.magenta}--- Summary ---${COLORS.reset}`);
|
|
638
|
+
lines.push(` Total checks: ${report.summary.total}`);
|
|
639
|
+
lines.push(` ${COLORS.green}Passed: ${report.summary.passed}${COLORS.reset}`);
|
|
640
|
+
lines.push(` ${COLORS.red}Failed: ${report.summary.failed}${COLORS.reset}`);
|
|
641
|
+
if (report.summary.failed > 0) {
|
|
642
|
+
lines.push("");
|
|
643
|
+
lines.push(` ${COLORS.bold}Failures by severity:${COLORS.reset}`);
|
|
644
|
+
for (const [severity, count] of Object.entries(report.summary.bySeverity)) {
|
|
645
|
+
if (count > 0) {
|
|
646
|
+
lines.push(` ${severityBadge(severity)} ${count}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
lines.push("");
|
|
651
|
+
return lines.join("\n");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// src/reporter/html.ts
|
|
655
|
+
function severityColor2(severity) {
|
|
656
|
+
switch (severity) {
|
|
657
|
+
case "critical":
|
|
658
|
+
return "#dc2626";
|
|
659
|
+
case "high":
|
|
660
|
+
return "#ea580c";
|
|
661
|
+
case "medium":
|
|
662
|
+
return "#ca8a04";
|
|
663
|
+
case "low":
|
|
664
|
+
return "#2563eb";
|
|
665
|
+
case "info":
|
|
666
|
+
return "#6b7280";
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function escapeHtml(text) {
|
|
670
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
671
|
+
}
|
|
672
|
+
function renderResult(result, index) {
|
|
673
|
+
const color = severityColor2(result.severity);
|
|
674
|
+
const statusClass = result.passed ? "pass" : "fail";
|
|
675
|
+
return `
|
|
676
|
+
<div class="result ${statusClass}">
|
|
677
|
+
<div class="result-header" onclick="toggleDetails('details-${index}')">
|
|
678
|
+
<span class="status-icon">${result.passed ? "✔" : "✘"}</span>
|
|
679
|
+
<span class="severity-badge" style="background-color: ${color}">${result.severity.toUpperCase()}</span>
|
|
680
|
+
<span class="result-title">${escapeHtml(result.title)}</span>
|
|
681
|
+
</div>
|
|
682
|
+
<div class="result-body">
|
|
683
|
+
<p class="description">${escapeHtml(result.description)}</p>
|
|
684
|
+
${!result.passed ? `<details id="details-${index}">
|
|
685
|
+
<summary>Remediation</summary>
|
|
686
|
+
<p class="remediation">${escapeHtml(result.remediation)}</p>
|
|
687
|
+
</details>` : ""}
|
|
688
|
+
</div>
|
|
689
|
+
</div>`;
|
|
690
|
+
}
|
|
691
|
+
function generateHtmlReport(report) {
|
|
692
|
+
const results = report.results.map((r, i) => renderResult(r, i)).join("\n");
|
|
693
|
+
return `<!DOCTYPE html>
|
|
694
|
+
<html lang="en">
|
|
695
|
+
<head>
|
|
696
|
+
<meta charset="UTF-8">
|
|
697
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
698
|
+
<title>GraphQL Sentinel Security Report</title>
|
|
699
|
+
<style>
|
|
700
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
701
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; }
|
|
702
|
+
.container { max-width: 800px; margin: 0 auto; }
|
|
703
|
+
h1 { color: #a78bfa; margin-bottom: 0.5rem; font-size: 1.5rem; }
|
|
704
|
+
.meta { color: #94a3b8; font-size: 0.875rem; margin-bottom: 2rem; }
|
|
705
|
+
.meta span { margin-right: 1.5rem; }
|
|
706
|
+
.result { background: #1e293b; border-radius: 8px; margin-bottom: 0.75rem; border-left: 4px solid #475569; overflow: hidden; }
|
|
707
|
+
.result.fail { border-left-color: #ef4444; }
|
|
708
|
+
.result.pass { border-left-color: #22c55e; }
|
|
709
|
+
.result-header { display: flex; align-items: center; padding: 0.75rem 1rem; cursor: pointer; gap: 0.75rem; }
|
|
710
|
+
.result-header:hover { background: #334155; }
|
|
711
|
+
.status-icon { font-size: 1.1rem; min-width: 1.5rem; text-align: center; }
|
|
712
|
+
.pass .status-icon { color: #22c55e; }
|
|
713
|
+
.fail .status-icon { color: #ef4444; }
|
|
714
|
+
.severity-badge { color: white; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; }
|
|
715
|
+
.result-title { font-weight: 600; }
|
|
716
|
+
.result-body { padding: 0 1rem 0.75rem 3.25rem; }
|
|
717
|
+
.description { color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.5rem; }
|
|
718
|
+
details { margin-top: 0.5rem; }
|
|
719
|
+
summary { color: #60a5fa; cursor: pointer; font-size: 0.875rem; }
|
|
720
|
+
summary:hover { color: #93c5fd; }
|
|
721
|
+
.remediation { color: #86efac; font-size: 0.875rem; margin-top: 0.5rem; padding: 0.5rem; background: #1a2e1a; border-radius: 4px; }
|
|
722
|
+
.summary-box { background: #1e293b; border-radius: 8px; padding: 1.5rem; margin-top: 1.5rem; }
|
|
723
|
+
.summary-box h2 { color: #a78bfa; margin-bottom: 1rem; font-size: 1.1rem; }
|
|
724
|
+
.summary-stats { display: flex; gap: 2rem; flex-wrap: wrap; }
|
|
725
|
+
.stat { text-align: center; }
|
|
726
|
+
.stat-value { font-size: 1.5rem; font-weight: 700; }
|
|
727
|
+
.stat-label { font-size: 0.75rem; color: #94a3b8; }
|
|
728
|
+
.stat-pass .stat-value { color: #22c55e; }
|
|
729
|
+
.stat-fail .stat-value { color: #ef4444; }
|
|
730
|
+
.severity-counts { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; }
|
|
731
|
+
.severity-count { padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.8rem; color: white; }
|
|
732
|
+
footer { text-align: center; color: #64748b; margin-top: 2rem; font-size: 0.75rem; }
|
|
733
|
+
</style>
|
|
734
|
+
</head>
|
|
735
|
+
<body>
|
|
736
|
+
<div class="container">
|
|
737
|
+
<h1>GraphQL Sentinel Security Report</h1>
|
|
738
|
+
<div class="meta">
|
|
739
|
+
<span>Target: ${escapeHtml(report.target)}</span>
|
|
740
|
+
<span>Date: ${escapeHtml(report.timestamp)}</span>
|
|
741
|
+
<span>Duration: ${report.duration}ms</span>
|
|
742
|
+
</div>
|
|
743
|
+
|
|
744
|
+
${results}
|
|
745
|
+
|
|
746
|
+
<div class="summary-box">
|
|
747
|
+
<h2>Summary</h2>
|
|
748
|
+
<div class="summary-stats">
|
|
749
|
+
<div class="stat">
|
|
750
|
+
<div class="stat-value">${report.summary.total}</div>
|
|
751
|
+
<div class="stat-label">Total Checks</div>
|
|
752
|
+
</div>
|
|
753
|
+
<div class="stat stat-pass">
|
|
754
|
+
<div class="stat-value">${report.summary.passed}</div>
|
|
755
|
+
<div class="stat-label">Passed</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div class="stat stat-fail">
|
|
758
|
+
<div class="stat-value">${report.summary.failed}</div>
|
|
759
|
+
<div class="stat-label">Failed</div>
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
${report.summary.failed > 0 ? `<div class="severity-counts">
|
|
763
|
+
${Object.entries(report.summary.bySeverity).filter(([_, count]) => count > 0).map(
|
|
764
|
+
([severity, count]) => `<span class="severity-count" style="background-color: ${severityColor2(severity)}">${severity}: ${count}</span>`
|
|
765
|
+
).join("\n ")}
|
|
766
|
+
</div>` : ""}
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
<footer>Generated by GraphQL Sentinel v0.1.0</footer>
|
|
770
|
+
</div>
|
|
771
|
+
<script>
|
|
772
|
+
function toggleDetails(id) {
|
|
773
|
+
const el = document.getElementById(id);
|
|
774
|
+
if (el) { el.open = !el.open; }
|
|
775
|
+
}
|
|
776
|
+
</script>
|
|
777
|
+
</body>
|
|
778
|
+
</html>`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/reporter/sarif.ts
|
|
782
|
+
function mapSeverityToSarif(severity) {
|
|
783
|
+
switch (severity) {
|
|
784
|
+
case "critical":
|
|
785
|
+
case "high":
|
|
786
|
+
return "error";
|
|
787
|
+
case "medium":
|
|
788
|
+
return "warning";
|
|
789
|
+
case "low":
|
|
790
|
+
case "info":
|
|
791
|
+
return "note";
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function generateSarifReport(report) {
|
|
795
|
+
const sarif = {
|
|
796
|
+
version: "2.1.0",
|
|
797
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
798
|
+
runs: [
|
|
799
|
+
{
|
|
800
|
+
tool: {
|
|
801
|
+
driver: {
|
|
802
|
+
name: "graphql-sentinel",
|
|
803
|
+
version: "0.1.0",
|
|
804
|
+
informationUri: "https://github.com/mstuart/graphql-sentinel",
|
|
805
|
+
rules: report.results.map((r) => ({
|
|
806
|
+
id: r.check,
|
|
807
|
+
name: r.title,
|
|
808
|
+
shortDescription: { text: r.title },
|
|
809
|
+
fullDescription: { text: r.description },
|
|
810
|
+
helpUri: "https://github.com/mstuart/graphql-sentinel",
|
|
811
|
+
defaultConfiguration: {
|
|
812
|
+
level: mapSeverityToSarif(r.severity)
|
|
813
|
+
},
|
|
814
|
+
properties: {
|
|
815
|
+
tags: ["security", "graphql"]
|
|
816
|
+
}
|
|
817
|
+
}))
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
results: report.results.filter((r) => !r.passed).map((r) => ({
|
|
821
|
+
ruleId: r.check,
|
|
822
|
+
level: mapSeverityToSarif(r.severity),
|
|
823
|
+
message: { text: `${r.title}: ${r.description}` },
|
|
824
|
+
locations: [
|
|
825
|
+
{
|
|
826
|
+
physicalLocation: {
|
|
827
|
+
artifactLocation: { uri: report.target }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
],
|
|
831
|
+
...r.remediation ? {
|
|
832
|
+
fixes: [
|
|
833
|
+
{
|
|
834
|
+
description: { text: r.remediation }
|
|
835
|
+
}
|
|
836
|
+
]
|
|
837
|
+
} : {}
|
|
838
|
+
}))
|
|
839
|
+
}
|
|
840
|
+
]
|
|
841
|
+
};
|
|
842
|
+
return JSON.stringify(sarif, null, 2);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/reporter/dashboard.ts
|
|
846
|
+
function escapeHtml2(text) {
|
|
847
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
848
|
+
}
|
|
849
|
+
var SEVERITY_WEIGHTS = {
|
|
850
|
+
critical: 25,
|
|
851
|
+
high: 20,
|
|
852
|
+
medium: 10,
|
|
853
|
+
low: 5,
|
|
854
|
+
info: 1
|
|
855
|
+
};
|
|
856
|
+
var SEVERITY_COLORS = {
|
|
857
|
+
critical: "#dc2626",
|
|
858
|
+
high: "#ea580c",
|
|
859
|
+
medium: "#ca8a04",
|
|
860
|
+
low: "#2563eb",
|
|
861
|
+
info: "#6b7280"
|
|
862
|
+
};
|
|
863
|
+
var CATEGORY_MAP = {
|
|
864
|
+
introspection: "Information Disclosure",
|
|
865
|
+
"field-suggestion": "Information Disclosure",
|
|
866
|
+
"depth-limit": "Denial of Service",
|
|
867
|
+
"batch-attack": "Denial of Service",
|
|
868
|
+
"alias-overloading": "Denial of Service",
|
|
869
|
+
csrf: "Authorization",
|
|
870
|
+
"auth-bypass": "Authorization"
|
|
871
|
+
};
|
|
872
|
+
function calculatePostureScore(results) {
|
|
873
|
+
if (results.length === 0) return 100;
|
|
874
|
+
let totalWeight = 0;
|
|
875
|
+
let failedWeight = 0;
|
|
876
|
+
for (const result of results) {
|
|
877
|
+
const weight = SEVERITY_WEIGHTS[result.severity];
|
|
878
|
+
totalWeight += weight;
|
|
879
|
+
if (!result.passed) {
|
|
880
|
+
failedWeight += weight;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (totalWeight === 0) return 100;
|
|
884
|
+
const score = Math.round((totalWeight - failedWeight) / totalWeight * 100);
|
|
885
|
+
return Math.max(0, Math.min(100, score));
|
|
886
|
+
}
|
|
887
|
+
function getScoreColor(score) {
|
|
888
|
+
if (score >= 80) return "#22c55e";
|
|
889
|
+
if (score >= 60) return "#ca8a04";
|
|
890
|
+
if (score >= 40) return "#ea580c";
|
|
891
|
+
return "#dc2626";
|
|
892
|
+
}
|
|
893
|
+
function getScoreLabel(score) {
|
|
894
|
+
if (score >= 90) return "Excellent";
|
|
895
|
+
if (score >= 80) return "Good";
|
|
896
|
+
if (score >= 60) return "Fair";
|
|
897
|
+
if (score >= 40) return "Poor";
|
|
898
|
+
return "Critical";
|
|
899
|
+
}
|
|
900
|
+
function generateExecutiveSummary(report, score) {
|
|
901
|
+
const { summary } = report;
|
|
902
|
+
const criticalHigh = (summary.bySeverity.critical || 0) + (summary.bySeverity.high || 0);
|
|
903
|
+
if (score >= 90) {
|
|
904
|
+
return `The GraphQL endpoint at ${escapeHtml2(report.target)} demonstrates strong security posture with a score of ${score}/100. All ${summary.total} security checks were evaluated, with ${summary.passed} passing. No critical remediation is required at this time.`;
|
|
905
|
+
}
|
|
906
|
+
if (score >= 60) {
|
|
907
|
+
return `The GraphQL endpoint at ${escapeHtml2(report.target)} has a moderate security posture with a score of ${score}/100. Out of ${summary.total} checks, ${summary.failed} issues were identified. ${criticalHigh > 0 ? `${criticalHigh} high-severity issue(s) require immediate attention.` : "Issues found are of moderate severity and should be addressed in the near term."}`;
|
|
908
|
+
}
|
|
909
|
+
return `The GraphQL endpoint at ${escapeHtml2(report.target)} requires immediate security attention with a score of ${score}/100. ${summary.failed} out of ${summary.total} checks failed, including ${criticalHigh} high or critical severity issue(s). Immediate remediation is strongly recommended to protect against known attack vectors.`;
|
|
910
|
+
}
|
|
911
|
+
function generateCategoryBreakdown(results) {
|
|
912
|
+
const categories = {};
|
|
913
|
+
for (const result of results) {
|
|
914
|
+
const category = CATEGORY_MAP[result.check] || "Other";
|
|
915
|
+
if (!categories[category]) {
|
|
916
|
+
categories[category] = [];
|
|
917
|
+
}
|
|
918
|
+
categories[category].push(result);
|
|
919
|
+
}
|
|
920
|
+
return categories;
|
|
921
|
+
}
|
|
922
|
+
function generateTimelineSvg(reports) {
|
|
923
|
+
if (reports.length < 2) return "";
|
|
924
|
+
const width = 600;
|
|
925
|
+
const height = 200;
|
|
926
|
+
const padding = 40;
|
|
927
|
+
const chartWidth = width - padding * 2;
|
|
928
|
+
const chartHeight = height - padding * 2;
|
|
929
|
+
const scores = reports.map((r) => calculatePostureScore(r.results));
|
|
930
|
+
const maxScore = 100;
|
|
931
|
+
const minScore = 0;
|
|
932
|
+
const points = scores.map((score, i) => {
|
|
933
|
+
const x = padding + i / (scores.length - 1) * chartWidth;
|
|
934
|
+
const y = padding + chartHeight - (score - minScore) / (maxScore - minScore) * chartHeight;
|
|
935
|
+
return { x, y, score };
|
|
936
|
+
});
|
|
937
|
+
const pathData = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
|
938
|
+
const labels = reports.map((r, i) => {
|
|
939
|
+
const x = padding + i / (reports.length - 1) * chartWidth;
|
|
940
|
+
const date = new Date(r.timestamp);
|
|
941
|
+
const label = `${date.getMonth() + 1}/${date.getDate()}`;
|
|
942
|
+
return `<text x="${x}" y="${height - 5}" text-anchor="middle" fill="#94a3b8" font-size="10">${label}</text>`;
|
|
943
|
+
});
|
|
944
|
+
const dots = points.map(
|
|
945
|
+
(p) => `<circle cx="${p.x}" cy="${p.y}" r="4" fill="${getScoreColor(p.score)}" />
|
|
946
|
+
<text x="${p.x}" y="${p.y - 10}" text-anchor="middle" fill="#e2e8f0" font-size="11">${p.score}</text>`
|
|
947
|
+
);
|
|
948
|
+
const gridLines = [0, 25, 50, 75, 100].map((v) => {
|
|
949
|
+
const y = padding + chartHeight - v / 100 * chartHeight;
|
|
950
|
+
return `<line x1="${padding}" y1="${y}" x2="${width - padding}" y2="${y}" stroke="#334155" stroke-width="0.5" />
|
|
951
|
+
<text x="${padding - 5}" y="${y + 4}" text-anchor="end" fill="#64748b" font-size="10">${v}</text>`;
|
|
952
|
+
});
|
|
953
|
+
return `
|
|
954
|
+
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${width}px;height:auto;">
|
|
955
|
+
<rect width="${width}" height="${height}" fill="#0f172a" rx="8" />
|
|
956
|
+
${gridLines.join("\n")}
|
|
957
|
+
<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="2" />
|
|
958
|
+
${dots.join("\n")}
|
|
959
|
+
${labels.join("\n")}
|
|
960
|
+
</svg>`;
|
|
961
|
+
}
|
|
962
|
+
function renderCheckDetail(result, index) {
|
|
963
|
+
const color = SEVERITY_COLORS[result.severity];
|
|
964
|
+
const statusClass = result.passed ? "pass" : "fail";
|
|
965
|
+
const category = CATEGORY_MAP[result.check] || "Other";
|
|
966
|
+
return `
|
|
967
|
+
<div class="check-card ${statusClass}">
|
|
968
|
+
<div class="check-header" onclick="toggleCheck(${index})">
|
|
969
|
+
<span class="check-status">${result.passed ? "✔" : "✘"}</span>
|
|
970
|
+
<span class="sev-badge" style="background:${color}">${result.severity.toUpperCase()}</span>
|
|
971
|
+
<span class="check-name">${escapeHtml2(result.title)}</span>
|
|
972
|
+
<span class="check-category">${escapeHtml2(category)}</span>
|
|
973
|
+
<span class="check-chevron" id="chevron-${index}">▶</span>
|
|
974
|
+
</div>
|
|
975
|
+
<div class="check-details" id="check-${index}" style="display:none;">
|
|
976
|
+
<p class="check-desc">${escapeHtml2(result.description)}</p>
|
|
977
|
+
${!result.passed ? `<div class="remediation-box"><strong>Remediation:</strong> ${escapeHtml2(result.remediation)}</div>` : ""}
|
|
978
|
+
</div>
|
|
979
|
+
</div>`;
|
|
980
|
+
}
|
|
981
|
+
function generateDashboard(reports, config) {
|
|
982
|
+
if (reports.length === 0) {
|
|
983
|
+
return "<html><body>No reports provided</body></html>";
|
|
984
|
+
}
|
|
985
|
+
const latestReport = reports[reports.length - 1];
|
|
986
|
+
const score = calculatePostureScore(latestReport.results);
|
|
987
|
+
const scoreColor = getScoreColor(score);
|
|
988
|
+
const scoreLabel = getScoreLabel(score);
|
|
989
|
+
const executiveSummary = generateExecutiveSummary(latestReport, score);
|
|
990
|
+
const categories = generateCategoryBreakdown(latestReport.results);
|
|
991
|
+
const timelineSvg = generateTimelineSvg(reports);
|
|
992
|
+
const title = config?.title || "GraphQL Sentinel Security Dashboard";
|
|
993
|
+
const checkCards = latestReport.results.map((r, i) => renderCheckDetail(r, i)).join("\n");
|
|
994
|
+
const categoryCards = Object.entries(categories).map(([cat, results]) => {
|
|
995
|
+
const catPassed = results.filter((r) => r.passed).length;
|
|
996
|
+
const catFailed = results.filter((r) => !r.passed).length;
|
|
997
|
+
const catColor = catFailed > 0 ? "#ef4444" : "#22c55e";
|
|
998
|
+
return `
|
|
999
|
+
<div class="cat-card">
|
|
1000
|
+
<div class="cat-header">
|
|
1001
|
+
<span class="cat-icon" style="color:${catColor}">${catFailed > 0 ? "⚠" : "✔"}</span>
|
|
1002
|
+
<span class="cat-name">${escapeHtml2(cat)}</span>
|
|
1003
|
+
</div>
|
|
1004
|
+
<div class="cat-stats">
|
|
1005
|
+
<span class="cat-passed">${catPassed} passed</span>
|
|
1006
|
+
<span class="cat-failed">${catFailed} failed</span>
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>`;
|
|
1009
|
+
}).join("\n");
|
|
1010
|
+
const severityCounts = Object.entries(latestReport.summary.bySeverity).filter(([_, count]) => count > 0).map(
|
|
1011
|
+
([sev, count]) => `<span class="sev-count" style="background:${SEVERITY_COLORS[sev]}">${sev}: ${count}</span>`
|
|
1012
|
+
).join("\n");
|
|
1013
|
+
return `<!DOCTYPE html>
|
|
1014
|
+
<html lang="en">
|
|
1015
|
+
<head>
|
|
1016
|
+
<meta charset="UTF-8">
|
|
1017
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1018
|
+
<title>${escapeHtml2(title)}</title>
|
|
1019
|
+
<style>
|
|
1020
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
1021
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:1.5rem;min-height:100vh}
|
|
1022
|
+
.dashboard{max-width:1000px;margin:0 auto}
|
|
1023
|
+
h1{color:#a78bfa;font-size:1.4rem;margin-bottom:.25rem}
|
|
1024
|
+
.subtitle{color:#64748b;font-size:.8rem;margin-bottom:1.5rem}
|
|
1025
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;margin-bottom:1.5rem}
|
|
1026
|
+
.card{background:#1e293b;border-radius:8px;padding:1.25rem}
|
|
1027
|
+
.score-card{text-align:center;position:relative}
|
|
1028
|
+
.score-ring{position:relative;width:120px;height:120px;margin:0 auto .75rem}
|
|
1029
|
+
.score-ring svg{transform:rotate(-90deg)}
|
|
1030
|
+
.score-value{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:2rem;font-weight:700}
|
|
1031
|
+
.score-label{font-size:.85rem;color:#94a3b8;margin-bottom:.5rem}
|
|
1032
|
+
.summary-card p{color:#94a3b8;font-size:.85rem;line-height:1.6}
|
|
1033
|
+
.stats-row{display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:1rem}
|
|
1034
|
+
.stat{text-align:center}
|
|
1035
|
+
.stat-val{font-size:1.3rem;font-weight:700}
|
|
1036
|
+
.stat-lbl{font-size:.7rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px}
|
|
1037
|
+
.stat-pass .stat-val{color:#22c55e}
|
|
1038
|
+
.stat-fail .stat-val{color:#ef4444}
|
|
1039
|
+
.severity-row{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
|
|
1040
|
+
.sev-count{padding:.15rem .6rem;border-radius:4px;font-size:.7rem;color:#fff;font-weight:600;text-transform:uppercase}
|
|
1041
|
+
.section-title{color:#a78bfa;font-size:1rem;font-weight:600;margin-bottom:.75rem;padding-bottom:.5rem;border-bottom:1px solid #334155}
|
|
1042
|
+
.timeline-card{margin-bottom:1.5rem}
|
|
1043
|
+
.cat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
1044
|
+
.cat-card{background:#1e293b;border-radius:8px;padding:1rem}
|
|
1045
|
+
.cat-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}
|
|
1046
|
+
.cat-icon{font-size:1.1rem}
|
|
1047
|
+
.cat-name{font-weight:600;font-size:.85rem}
|
|
1048
|
+
.cat-stats{display:flex;gap:1rem;font-size:.75rem}
|
|
1049
|
+
.cat-passed{color:#22c55e}
|
|
1050
|
+
.cat-failed{color:#ef4444}
|
|
1051
|
+
.checks-section{margin-bottom:1.5rem}
|
|
1052
|
+
.check-card{background:#1e293b;border-radius:8px;margin-bottom:.5rem;overflow:hidden;border-left:4px solid #475569}
|
|
1053
|
+
.check-card.fail{border-left-color:#ef4444}
|
|
1054
|
+
.check-card.pass{border-left-color:#22c55e}
|
|
1055
|
+
.check-header{display:flex;align-items:center;padding:.75rem 1rem;cursor:pointer;gap:.75rem;user-select:none}
|
|
1056
|
+
.check-header:hover{background:#334155}
|
|
1057
|
+
.check-status{font-size:1rem;min-width:1.25rem;text-align:center}
|
|
1058
|
+
.pass .check-status{color:#22c55e}
|
|
1059
|
+
.fail .check-status{color:#ef4444}
|
|
1060
|
+
.sev-badge{color:#fff;padding:.1rem .45rem;border-radius:3px;font-size:.65rem;font-weight:600;text-transform:uppercase}
|
|
1061
|
+
.check-name{font-weight:600;font-size:.85rem;flex:1}
|
|
1062
|
+
.check-category{font-size:.7rem;color:#64748b;background:#0f172a;padding:.15rem .5rem;border-radius:3px}
|
|
1063
|
+
.check-chevron{color:#64748b;font-size:.7rem;transition:transform .2s}
|
|
1064
|
+
.check-chevron.open{transform:rotate(90deg)}
|
|
1065
|
+
.check-details{padding:.75rem 1rem .75rem 3.5rem;border-top:1px solid #334155}
|
|
1066
|
+
.check-desc{color:#94a3b8;font-size:.8rem;line-height:1.5;margin-bottom:.5rem}
|
|
1067
|
+
.remediation-box{background:#1a2e1a;color:#86efac;font-size:.8rem;padding:.5rem .75rem;border-radius:4px;line-height:1.5}
|
|
1068
|
+
footer{text-align:center;color:#475569;font-size:.7rem;margin-top:2rem;padding-top:1rem;border-top:1px solid #1e293b}
|
|
1069
|
+
</style>
|
|
1070
|
+
</head>
|
|
1071
|
+
<body>
|
|
1072
|
+
<div class="dashboard">
|
|
1073
|
+
<h1>${escapeHtml2(title)}</h1>
|
|
1074
|
+
<div class="subtitle">Target: ${escapeHtml2(latestReport.target)} | ${escapeHtml2(latestReport.timestamp)} | ${latestReport.duration}ms</div>
|
|
1075
|
+
|
|
1076
|
+
<div class="grid">
|
|
1077
|
+
<div class="card score-card">
|
|
1078
|
+
<div class="section-title">Security Posture</div>
|
|
1079
|
+
<div class="score-ring">
|
|
1080
|
+
<svg viewBox="0 0 120 120">
|
|
1081
|
+
<circle cx="60" cy="60" r="52" fill="none" stroke="#334155" stroke-width="8"/>
|
|
1082
|
+
<circle cx="60" cy="60" r="52" fill="none" stroke="${scoreColor}" stroke-width="8" stroke-dasharray="${score / 100 * 327} 327" stroke-linecap="round"/>
|
|
1083
|
+
</svg>
|
|
1084
|
+
<div class="score-value" style="color:${scoreColor}">${score}</div>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="score-label">${scoreLabel}</div>
|
|
1087
|
+
<div class="stats-row">
|
|
1088
|
+
<div class="stat"><div class="stat-val">${latestReport.summary.total}</div><div class="stat-lbl">Total</div></div>
|
|
1089
|
+
<div class="stat stat-pass"><div class="stat-val">${latestReport.summary.passed}</div><div class="stat-lbl">Passed</div></div>
|
|
1090
|
+
<div class="stat stat-fail"><div class="stat-val">${latestReport.summary.failed}</div><div class="stat-lbl">Failed</div></div>
|
|
1091
|
+
</div>
|
|
1092
|
+
${severityCounts ? `<div class="severity-row">${severityCounts}</div>` : ""}
|
|
1093
|
+
</div>
|
|
1094
|
+
|
|
1095
|
+
<div class="card summary-card">
|
|
1096
|
+
<div class="section-title">Executive Summary</div>
|
|
1097
|
+
<p>${executiveSummary}</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
|
|
1101
|
+
${timelineSvg ? `
|
|
1102
|
+
<div class="card timeline-card">
|
|
1103
|
+
<div class="section-title">Vulnerability Timeline</div>
|
|
1104
|
+
${timelineSvg}
|
|
1105
|
+
</div>` : ""}
|
|
1106
|
+
|
|
1107
|
+
<div class="section-title">Category Breakdown</div>
|
|
1108
|
+
<div class="cat-grid">
|
|
1109
|
+
${categoryCards}
|
|
1110
|
+
</div>
|
|
1111
|
+
|
|
1112
|
+
<div class="checks-section">
|
|
1113
|
+
<div class="section-title">Check Details</div>
|
|
1114
|
+
${checkCards}
|
|
1115
|
+
</div>
|
|
1116
|
+
|
|
1117
|
+
<footer>Generated by GraphQL Sentinel v${"1.0.0"}</footer>
|
|
1118
|
+
</div>
|
|
1119
|
+
|
|
1120
|
+
<script>
|
|
1121
|
+
function toggleCheck(idx){
|
|
1122
|
+
var el=document.getElementById('check-'+idx);
|
|
1123
|
+
var ch=document.getElementById('chevron-'+idx);
|
|
1124
|
+
if(el.style.display==='none'){el.style.display='block';ch.classList.add('open');}
|
|
1125
|
+
else{el.style.display='none';ch.classList.remove('open');}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Persist scan results in localStorage for timeline tracking
|
|
1129
|
+
(function(){
|
|
1130
|
+
try{
|
|
1131
|
+
var key='graphql-sentinel-history';
|
|
1132
|
+
var current=${JSON.stringify({
|
|
1133
|
+
target: latestReport.target,
|
|
1134
|
+
timestamp: latestReport.timestamp,
|
|
1135
|
+
score,
|
|
1136
|
+
passed: latestReport.summary.passed,
|
|
1137
|
+
failed: latestReport.summary.failed,
|
|
1138
|
+
total: latestReport.summary.total
|
|
1139
|
+
})};
|
|
1140
|
+
var history=JSON.parse(localStorage.getItem(key)||'[]');
|
|
1141
|
+
// Avoid duplicates by timestamp
|
|
1142
|
+
if(!history.some(function(h){return h.timestamp===current.timestamp;})){
|
|
1143
|
+
history.push(current);
|
|
1144
|
+
// Keep last 50 entries
|
|
1145
|
+
if(history.length>50)history=history.slice(-50);
|
|
1146
|
+
localStorage.setItem(key,JSON.stringify(history));
|
|
1147
|
+
}
|
|
1148
|
+
}catch(e){/* ignore storage errors */}
|
|
1149
|
+
})();
|
|
1150
|
+
</script>
|
|
1151
|
+
</body>
|
|
1152
|
+
</html>`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// src/reporter/index.ts
|
|
1156
|
+
function generateReport(report, format) {
|
|
1157
|
+
switch (format) {
|
|
1158
|
+
case "json":
|
|
1159
|
+
return generateJsonReport(report);
|
|
1160
|
+
case "terminal":
|
|
1161
|
+
return generateTerminalReport(report);
|
|
1162
|
+
case "html":
|
|
1163
|
+
return generateHtmlReport(report);
|
|
1164
|
+
case "sarif":
|
|
1165
|
+
return generateSarifReport(report);
|
|
1166
|
+
case "dashboard":
|
|
1167
|
+
return generateDashboard([report]);
|
|
1168
|
+
default:
|
|
1169
|
+
throw new Error(`Unknown report format: ${format}`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/cli/scan.ts
|
|
1174
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
1175
|
+
function createScanCommand() {
|
|
1176
|
+
return new import_commander.Command("scan").description("Scan a GraphQL endpoint for security vulnerabilities").argument("<url>", "GraphQL endpoint URL to scan").option("-f, --format <format>", "Output format (terminal, json, html)", "terminal").option("-o, --output <file>", "Write report to file instead of stdout").option(
|
|
1177
|
+
"-H, --header <header...>",
|
|
1178
|
+
'Custom headers (format: "Key: Value")'
|
|
1179
|
+
).option("-c, --checks <checks>", "Comma-separated list of checks to run").option("-t, --timeout <ms>", "Timeout per check in milliseconds", "10000").action(async (url, options) => {
|
|
1180
|
+
const headers = {};
|
|
1181
|
+
if (options.header) {
|
|
1182
|
+
for (const h of options.header) {
|
|
1183
|
+
const colonIdx = h.indexOf(":");
|
|
1184
|
+
if (colonIdx > 0) {
|
|
1185
|
+
const key = h.substring(0, colonIdx).trim();
|
|
1186
|
+
const value = h.substring(colonIdx + 1).trim();
|
|
1187
|
+
headers[key] = value;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const checks = options.checks ? options.checks.split(",").map((c) => c.trim()) : void 0;
|
|
1192
|
+
const format = options.format;
|
|
1193
|
+
if (!["terminal", "json", "html", "sarif", "dashboard"].includes(format)) {
|
|
1194
|
+
console.error(`Invalid format "${format}". Use: terminal, json, html, sarif, dashboard`);
|
|
1195
|
+
process.exit(1);
|
|
1196
|
+
}
|
|
1197
|
+
if (format === "terminal") {
|
|
1198
|
+
console.log(`
|
|
1199
|
+
Scanning ${url}...
|
|
1200
|
+
`);
|
|
1201
|
+
}
|
|
1202
|
+
try {
|
|
1203
|
+
const report = await runScan({
|
|
1204
|
+
endpoint: url,
|
|
1205
|
+
headers: Object.keys(headers).length > 0 ? headers : void 0,
|
|
1206
|
+
checks,
|
|
1207
|
+
timeout: parseInt(options.timeout, 10)
|
|
1208
|
+
});
|
|
1209
|
+
const output = generateReport(report, format);
|
|
1210
|
+
if (options.output) {
|
|
1211
|
+
import_node_fs.default.writeFileSync(options.output, output, "utf-8");
|
|
1212
|
+
console.log(`Report written to ${options.output}`);
|
|
1213
|
+
} else {
|
|
1214
|
+
console.log(output);
|
|
1215
|
+
}
|
|
1216
|
+
const hasCriticalFailures = report.results.some(
|
|
1217
|
+
(r) => !r.passed && (r.severity === "critical" || r.severity === "high")
|
|
1218
|
+
);
|
|
1219
|
+
if (hasCriticalFailures) {
|
|
1220
|
+
process.exit(1);
|
|
1221
|
+
}
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
console.error(`Scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1224
|
+
process.exit(2);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/cli/proxy.ts
|
|
1230
|
+
var import_commander2 = require("commander");
|
|
1231
|
+
|
|
1232
|
+
// src/proxy/server.ts
|
|
1233
|
+
var import_node_http = __toESM(require("http"), 1);
|
|
1234
|
+
var import_graphql6 = require("graphql");
|
|
1235
|
+
|
|
1236
|
+
// src/shield/depth-limiter.ts
|
|
1237
|
+
var import_graphql = require("graphql");
|
|
1238
|
+
function createDepthLimitRule(maxDepth = 10) {
|
|
1239
|
+
return function DepthLimitRule(context) {
|
|
1240
|
+
return {
|
|
1241
|
+
Document: {
|
|
1242
|
+
enter(node) {
|
|
1243
|
+
const depth = measureDepth(node);
|
|
1244
|
+
if (depth > maxDepth) {
|
|
1245
|
+
context.reportError(
|
|
1246
|
+
new import_graphql.GraphQLError(
|
|
1247
|
+
`Query depth of ${depth} exceeds maximum allowed depth of ${maxDepth}.`
|
|
1248
|
+
)
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function measureDepth(node, currentDepth = 0) {
|
|
1257
|
+
if (!node || typeof node !== "object") {
|
|
1258
|
+
return currentDepth;
|
|
1259
|
+
}
|
|
1260
|
+
const selectionSet = node.selectionSet;
|
|
1261
|
+
if (selectionSet && Array.isArray(selectionSet.selections)) {
|
|
1262
|
+
let maxChildDepth = currentDepth + 1;
|
|
1263
|
+
for (const selection of selectionSet.selections) {
|
|
1264
|
+
const childDepth = measureDepth(selection, currentDepth + 1);
|
|
1265
|
+
if (childDepth > maxChildDepth) {
|
|
1266
|
+
maxChildDepth = childDepth;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return maxChildDepth;
|
|
1270
|
+
}
|
|
1271
|
+
const definitions = node.definitions;
|
|
1272
|
+
if (Array.isArray(definitions)) {
|
|
1273
|
+
let maxDefDepth = 0;
|
|
1274
|
+
for (const def of definitions) {
|
|
1275
|
+
const defDepth = measureDepth(def, 0);
|
|
1276
|
+
if (defDepth > maxDefDepth) {
|
|
1277
|
+
maxDefDepth = defDepth;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return maxDefDepth;
|
|
1281
|
+
}
|
|
1282
|
+
return currentDepth;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/shield/complexity-analyzer.ts
|
|
1286
|
+
var import_graphql2 = require("graphql");
|
|
1287
|
+
function createComplexityRule(config = {}) {
|
|
1288
|
+
const {
|
|
1289
|
+
maxComplexity = 1e3,
|
|
1290
|
+
defaultFieldCost = 1,
|
|
1291
|
+
listFieldMultiplier = 10
|
|
1292
|
+
} = config;
|
|
1293
|
+
return function ComplexityRule(context) {
|
|
1294
|
+
let complexity = 0;
|
|
1295
|
+
const multiplierStack = [1];
|
|
1296
|
+
return {
|
|
1297
|
+
Field: {
|
|
1298
|
+
enter(_node) {
|
|
1299
|
+
const currentMultiplier = multiplierStack[multiplierStack.length - 1] || 1;
|
|
1300
|
+
complexity += defaultFieldCost * currentMultiplier;
|
|
1301
|
+
const fieldDef = context.getFieldDef();
|
|
1302
|
+
if (fieldDef) {
|
|
1303
|
+
const type = fieldDef.type;
|
|
1304
|
+
const typeName = type.toString();
|
|
1305
|
+
if (typeName.startsWith("[")) {
|
|
1306
|
+
multiplierStack.push(currentMultiplier * listFieldMultiplier);
|
|
1307
|
+
} else {
|
|
1308
|
+
multiplierStack.push(currentMultiplier);
|
|
1309
|
+
}
|
|
1310
|
+
} else {
|
|
1311
|
+
multiplierStack.push(currentMultiplier);
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1314
|
+
leave() {
|
|
1315
|
+
multiplierStack.pop();
|
|
1316
|
+
}
|
|
1317
|
+
},
|
|
1318
|
+
Document: {
|
|
1319
|
+
leave() {
|
|
1320
|
+
if (complexity > maxComplexity) {
|
|
1321
|
+
context.reportError(
|
|
1322
|
+
new import_graphql2.GraphQLError(
|
|
1323
|
+
`Query complexity of ${complexity} exceeds maximum allowed complexity of ${maxComplexity}.`
|
|
1324
|
+
)
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/shield/alias-limiter.ts
|
|
1334
|
+
var import_graphql3 = require("graphql");
|
|
1335
|
+
function createAliasLimitRule(maxAliases = 15) {
|
|
1336
|
+
return function AliasLimitRule(context) {
|
|
1337
|
+
let aliasCount = 0;
|
|
1338
|
+
return {
|
|
1339
|
+
Field: {
|
|
1340
|
+
enter(node) {
|
|
1341
|
+
if (node.alias) {
|
|
1342
|
+
aliasCount++;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
Document: {
|
|
1347
|
+
leave() {
|
|
1348
|
+
if (aliasCount > maxAliases) {
|
|
1349
|
+
context.reportError(
|
|
1350
|
+
new import_graphql3.GraphQLError(
|
|
1351
|
+
`Query contains ${aliasCount} aliases, exceeding the maximum of ${maxAliases}.`
|
|
1352
|
+
)
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/shield/introspection-control.ts
|
|
1362
|
+
var import_graphql4 = require("graphql");
|
|
1363
|
+
function createIntrospectionControlRule() {
|
|
1364
|
+
return function IntrospectionControlRule(context) {
|
|
1365
|
+
return {
|
|
1366
|
+
Field(node) {
|
|
1367
|
+
const fieldName = node.name.value;
|
|
1368
|
+
if (fieldName === "__schema" || fieldName === "__type") {
|
|
1369
|
+
context.reportError(
|
|
1370
|
+
new import_graphql4.GraphQLError(
|
|
1371
|
+
`Introspection query is not allowed. Field "${fieldName}" is disabled.`
|
|
1372
|
+
)
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/shield/rate-limiter.ts
|
|
1381
|
+
function createRateLimiter(config) {
|
|
1382
|
+
const { window, max } = config;
|
|
1383
|
+
const clients = /* @__PURE__ */ new Map();
|
|
1384
|
+
const cleanupInterval = setInterval(() => {
|
|
1385
|
+
const now = Date.now();
|
|
1386
|
+
for (const [key, record] of clients.entries()) {
|
|
1387
|
+
record.entries = record.entries.filter((e) => now - e.timestamp < window);
|
|
1388
|
+
if (record.entries.length === 0) {
|
|
1389
|
+
clients.delete(key);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}, window);
|
|
1393
|
+
if (cleanupInterval.unref) {
|
|
1394
|
+
cleanupInterval.unref();
|
|
1395
|
+
}
|
|
1396
|
+
return {
|
|
1397
|
+
check(key, cost = 1) {
|
|
1398
|
+
const now = Date.now();
|
|
1399
|
+
if (!clients.has(key)) {
|
|
1400
|
+
clients.set(key, { entries: [] });
|
|
1401
|
+
}
|
|
1402
|
+
const record = clients.get(key);
|
|
1403
|
+
record.entries = record.entries.filter((e) => now - e.timestamp < window);
|
|
1404
|
+
const currentCost = record.entries.reduce((sum, e) => sum + e.cost, 0);
|
|
1405
|
+
if (currentCost + cost > max) {
|
|
1406
|
+
return {
|
|
1407
|
+
allowed: false,
|
|
1408
|
+
remaining: Math.max(0, max - currentCost)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
record.entries.push({ timestamp: now, cost });
|
|
1412
|
+
return {
|
|
1413
|
+
allowed: true,
|
|
1414
|
+
remaining: max - currentCost - cost
|
|
1415
|
+
};
|
|
1416
|
+
},
|
|
1417
|
+
reset(key) {
|
|
1418
|
+
if (key) {
|
|
1419
|
+
clients.delete(key);
|
|
1420
|
+
} else {
|
|
1421
|
+
clients.clear();
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
destroy() {
|
|
1425
|
+
clearInterval(cleanupInterval);
|
|
1426
|
+
clients.clear();
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/shield/field-auth.ts
|
|
1432
|
+
var import_graphql5 = require("graphql");
|
|
1433
|
+
function createFieldAuthRule(config) {
|
|
1434
|
+
return function FieldAuthRule(context) {
|
|
1435
|
+
const typeStack = [];
|
|
1436
|
+
return {
|
|
1437
|
+
OperationDefinition: {
|
|
1438
|
+
enter() {
|
|
1439
|
+
const queryType = context.getSchema().getQueryType();
|
|
1440
|
+
typeStack.push(queryType?.name || "Query");
|
|
1441
|
+
},
|
|
1442
|
+
leave() {
|
|
1443
|
+
typeStack.pop();
|
|
1444
|
+
}
|
|
1445
|
+
},
|
|
1446
|
+
Field: {
|
|
1447
|
+
enter(node) {
|
|
1448
|
+
const fieldName = node.name.value;
|
|
1449
|
+
const parentType = typeStack[typeStack.length - 1] || "Query";
|
|
1450
|
+
const ruleKey = `${parentType}.${fieldName}`;
|
|
1451
|
+
const rule = config.rules[ruleKey];
|
|
1452
|
+
if (!rule) {
|
|
1453
|
+
const wildcardField = config.rules[`*.${fieldName}`];
|
|
1454
|
+
const wildcardType = config.rules[`${parentType}.*`];
|
|
1455
|
+
const effectiveRule = wildcardField || wildcardType;
|
|
1456
|
+
if (effectiveRule) {
|
|
1457
|
+
checkRule(effectiveRule, ruleKey, context, config);
|
|
1458
|
+
}
|
|
1459
|
+
} else {
|
|
1460
|
+
checkRule(rule, ruleKey, context, config);
|
|
1461
|
+
}
|
|
1462
|
+
const fieldDef = context.getFieldDef();
|
|
1463
|
+
if (fieldDef) {
|
|
1464
|
+
const namedType = getNamedType(fieldDef.type);
|
|
1465
|
+
if (namedType && "name" in namedType) {
|
|
1466
|
+
typeStack.push(namedType.name);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
},
|
|
1470
|
+
leave() {
|
|
1471
|
+
const fieldDef = context.getFieldDef();
|
|
1472
|
+
if (fieldDef) {
|
|
1473
|
+
const namedType = getNamedType(fieldDef.type);
|
|
1474
|
+
if (namedType && "name" in namedType) {
|
|
1475
|
+
typeStack.pop();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
function checkRule(rule, ruleKey, context, config) {
|
|
1484
|
+
const userContext = config.extractContext ? config.extractContext(context._contextValue) : null;
|
|
1485
|
+
if (rule.requireAuth) {
|
|
1486
|
+
if (!userContext || !userContext.authenticated) {
|
|
1487
|
+
context.reportError(
|
|
1488
|
+
new import_graphql5.GraphQLError(
|
|
1489
|
+
`Access denied: field "${ruleKey}" requires authentication.`
|
|
1490
|
+
)
|
|
1491
|
+
);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (rule.roles && rule.roles.length > 0) {
|
|
1496
|
+
if (!userContext) {
|
|
1497
|
+
context.reportError(
|
|
1498
|
+
new import_graphql5.GraphQLError(
|
|
1499
|
+
`Access denied: field "${ruleKey}" requires one of roles: ${rule.roles.join(", ")}.`
|
|
1500
|
+
)
|
|
1501
|
+
);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const hasRole = rule.roles.some((r) => userContext.roles.includes(r));
|
|
1505
|
+
if (!hasRole) {
|
|
1506
|
+
context.reportError(
|
|
1507
|
+
new import_graphql5.GraphQLError(
|
|
1508
|
+
`Access denied: field "${ruleKey}" requires one of roles: ${rule.roles.join(", ")}.`
|
|
1509
|
+
)
|
|
1510
|
+
);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (rule.permissions && rule.permissions.length > 0) {
|
|
1515
|
+
if (!userContext) {
|
|
1516
|
+
context.reportError(
|
|
1517
|
+
new import_graphql5.GraphQLError(
|
|
1518
|
+
`Access denied: field "${ruleKey}" requires one of permissions: ${rule.permissions.join(", ")}.`
|
|
1519
|
+
)
|
|
1520
|
+
);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const hasPermission = rule.permissions.some(
|
|
1524
|
+
(p) => userContext.permissions.includes(p)
|
|
1525
|
+
);
|
|
1526
|
+
if (!hasPermission) {
|
|
1527
|
+
context.reportError(
|
|
1528
|
+
new import_graphql5.GraphQLError(
|
|
1529
|
+
`Access denied: field "${ruleKey}" requires one of permissions: ${rule.permissions.join(", ")}.`
|
|
1530
|
+
)
|
|
1531
|
+
);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
function getNamedType(type) {
|
|
1537
|
+
if (!type) return null;
|
|
1538
|
+
if (type.ofType) return getNamedType(type.ofType);
|
|
1539
|
+
return type;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// src/shield/index.ts
|
|
1543
|
+
function createShield(config) {
|
|
1544
|
+
const validationRules = [];
|
|
1545
|
+
if (config.maxDepth !== void 0) {
|
|
1546
|
+
validationRules.push(createDepthLimitRule(config.maxDepth));
|
|
1547
|
+
}
|
|
1548
|
+
if (config.maxComplexity !== void 0) {
|
|
1549
|
+
validationRules.push(
|
|
1550
|
+
createComplexityRule({
|
|
1551
|
+
maxComplexity: config.maxComplexity
|
|
1552
|
+
})
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
if (config.maxAliases !== void 0) {
|
|
1556
|
+
validationRules.push(createAliasLimitRule(config.maxAliases));
|
|
1557
|
+
}
|
|
1558
|
+
if (config.disableIntrospection) {
|
|
1559
|
+
validationRules.push(createIntrospectionControlRule());
|
|
1560
|
+
}
|
|
1561
|
+
if (config.fieldAuth) {
|
|
1562
|
+
validationRules.push(createFieldAuthRule(config.fieldAuth));
|
|
1563
|
+
}
|
|
1564
|
+
let rateLimiter;
|
|
1565
|
+
if (config.rateLimit) {
|
|
1566
|
+
rateLimiter = createRateLimiter(config.rateLimit);
|
|
1567
|
+
}
|
|
1568
|
+
return { validationRules, rateLimiter };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// src/proxy/server.ts
|
|
1572
|
+
function readBody(req) {
|
|
1573
|
+
return new Promise((resolve, reject) => {
|
|
1574
|
+
let body = "";
|
|
1575
|
+
req.on("data", (chunk) => body += chunk);
|
|
1576
|
+
req.on("end", () => resolve(body));
|
|
1577
|
+
req.on("error", reject);
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
function setCorsHeaders(res) {
|
|
1581
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1582
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
1583
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
1584
|
+
}
|
|
1585
|
+
function createProxyServer(config) {
|
|
1586
|
+
const shield = createShield(config.shield);
|
|
1587
|
+
const enableCors = config.cors !== false;
|
|
1588
|
+
const server = import_node_http.default.createServer(async (req, res) => {
|
|
1589
|
+
if (enableCors) {
|
|
1590
|
+
setCorsHeaders(res);
|
|
1591
|
+
}
|
|
1592
|
+
if (req.method === "OPTIONS") {
|
|
1593
|
+
res.writeHead(204);
|
|
1594
|
+
res.end();
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
if (req.method !== "POST") {
|
|
1598
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
1599
|
+
res.end(JSON.stringify({ errors: [{ message: "Only POST method is allowed" }] }));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
try {
|
|
1603
|
+
const body = await readBody(req);
|
|
1604
|
+
let parsed;
|
|
1605
|
+
try {
|
|
1606
|
+
parsed = JSON.parse(body);
|
|
1607
|
+
} catch {
|
|
1608
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1609
|
+
res.end(JSON.stringify({ errors: [{ message: "Invalid JSON in request body" }] }));
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (!parsed.query || typeof parsed.query !== "string") {
|
|
1613
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1614
|
+
res.end(JSON.stringify({ errors: [{ message: "Missing or invalid query field" }] }));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
let document;
|
|
1618
|
+
try {
|
|
1619
|
+
document = (0, import_graphql6.parse)(parsed.query);
|
|
1620
|
+
} catch (parseError) {
|
|
1621
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1622
|
+
res.end(
|
|
1623
|
+
JSON.stringify({
|
|
1624
|
+
errors: [
|
|
1625
|
+
{
|
|
1626
|
+
message: `GraphQL parse error: ${parseError instanceof Error ? parseError.message : String(parseError)}`
|
|
1627
|
+
}
|
|
1628
|
+
]
|
|
1629
|
+
})
|
|
1630
|
+
);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const validationErrors = validateWithRules(document, shield.validationRules);
|
|
1634
|
+
if (validationErrors.length > 0) {
|
|
1635
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1636
|
+
res.end(
|
|
1637
|
+
JSON.stringify({
|
|
1638
|
+
errors: validationErrors.map((e) => ({
|
|
1639
|
+
message: e.message,
|
|
1640
|
+
extensions: { code: "GRAPHQL_SENTINEL_BLOCKED" }
|
|
1641
|
+
}))
|
|
1642
|
+
})
|
|
1643
|
+
);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (shield.rateLimiter) {
|
|
1647
|
+
const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "unknown";
|
|
1648
|
+
const result = shield.rateLimiter.check(clientIp);
|
|
1649
|
+
if (!result.allowed) {
|
|
1650
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
1651
|
+
res.end(
|
|
1652
|
+
JSON.stringify({
|
|
1653
|
+
errors: [
|
|
1654
|
+
{
|
|
1655
|
+
message: "Rate limit exceeded",
|
|
1656
|
+
extensions: { code: "RATE_LIMITED", remaining: result.remaining }
|
|
1657
|
+
}
|
|
1658
|
+
]
|
|
1659
|
+
})
|
|
1660
|
+
);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
const forwardHeaders = {
|
|
1665
|
+
"Content-Type": "application/json",
|
|
1666
|
+
...config.headers
|
|
1667
|
+
};
|
|
1668
|
+
if (req.headers["authorization"]) {
|
|
1669
|
+
forwardHeaders["Authorization"] = req.headers["authorization"];
|
|
1670
|
+
}
|
|
1671
|
+
const upstreamResponse = await fetch(config.target, {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
headers: forwardHeaders,
|
|
1674
|
+
body: JSON.stringify({
|
|
1675
|
+
query: parsed.query,
|
|
1676
|
+
variables: parsed.variables,
|
|
1677
|
+
operationName: parsed.operationName
|
|
1678
|
+
})
|
|
1679
|
+
});
|
|
1680
|
+
const upstreamBody = await upstreamResponse.text();
|
|
1681
|
+
const contentType = upstreamResponse.headers.get("content-type");
|
|
1682
|
+
if (contentType) {
|
|
1683
|
+
res.setHeader("Content-Type", contentType);
|
|
1684
|
+
} else {
|
|
1685
|
+
res.setHeader("Content-Type", "application/json");
|
|
1686
|
+
}
|
|
1687
|
+
res.writeHead(upstreamResponse.status);
|
|
1688
|
+
res.end(upstreamBody);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1691
|
+
res.end(
|
|
1692
|
+
JSON.stringify({
|
|
1693
|
+
errors: [
|
|
1694
|
+
{
|
|
1695
|
+
message: `Proxy error: ${error instanceof Error ? error.message : String(error)}`
|
|
1696
|
+
}
|
|
1697
|
+
]
|
|
1698
|
+
})
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
return server;
|
|
1703
|
+
}
|
|
1704
|
+
function validateWithRules(document, rules) {
|
|
1705
|
+
const errors = [];
|
|
1706
|
+
const mockContext = {
|
|
1707
|
+
reportError(error) {
|
|
1708
|
+
errors.push(error);
|
|
1709
|
+
},
|
|
1710
|
+
getSchema() {
|
|
1711
|
+
return {
|
|
1712
|
+
getQueryType() {
|
|
1713
|
+
return { name: "Query" };
|
|
1714
|
+
},
|
|
1715
|
+
getMutationType() {
|
|
1716
|
+
return null;
|
|
1717
|
+
},
|
|
1718
|
+
getSubscriptionType() {
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
},
|
|
1723
|
+
getFieldDef() {
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
for (const rule of rules) {
|
|
1728
|
+
const visitor = rule(mockContext);
|
|
1729
|
+
visitNode(document, visitor);
|
|
1730
|
+
}
|
|
1731
|
+
return errors;
|
|
1732
|
+
}
|
|
1733
|
+
function visitNode(node, visitor) {
|
|
1734
|
+
if (!node || typeof node !== "object") return;
|
|
1735
|
+
const kind = node.kind;
|
|
1736
|
+
if (!kind) return;
|
|
1737
|
+
const kindVisitor = visitor[kind];
|
|
1738
|
+
if (kindVisitor) {
|
|
1739
|
+
if (typeof kindVisitor === "function") {
|
|
1740
|
+
kindVisitor(node);
|
|
1741
|
+
} else if (kindVisitor.enter) {
|
|
1742
|
+
kindVisitor.enter(node);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
for (const key of Object.keys(node)) {
|
|
1746
|
+
const value = node[key];
|
|
1747
|
+
if (Array.isArray(value)) {
|
|
1748
|
+
for (const child of value) {
|
|
1749
|
+
if (child && typeof child === "object" && child.kind) {
|
|
1750
|
+
visitNode(child, visitor);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
} else if (value && typeof value === "object" && value.kind) {
|
|
1754
|
+
visitNode(value, visitor);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
if (kindVisitor) {
|
|
1758
|
+
if (kindVisitor.leave) {
|
|
1759
|
+
kindVisitor.leave(node);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
async function startProxy(config) {
|
|
1764
|
+
const server = createProxyServer(config);
|
|
1765
|
+
return new Promise((resolve) => {
|
|
1766
|
+
server.listen(config.port, () => {
|
|
1767
|
+
console.log(`GraphQL Sentinel proxy running on port ${config.port}`);
|
|
1768
|
+
console.log(`Forwarding to ${config.target}`);
|
|
1769
|
+
resolve(server);
|
|
1770
|
+
});
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/cli/proxy.ts
|
|
1775
|
+
function createProxyCommand() {
|
|
1776
|
+
return new import_commander2.Command("proxy").description("Start a security proxy in front of a GraphQL endpoint").argument("<target>", "Upstream GraphQL endpoint URL").option("-p, --port <port>", "Proxy listening port", "4000").option("--max-depth <depth>", "Maximum query depth").option("--max-complexity <complexity>", "Maximum query complexity").option("--max-aliases <aliases>", "Maximum number of aliases").option("--disable-introspection", "Block introspection queries").option("--rate-limit-window <ms>", "Rate limit window in milliseconds").option("--rate-limit-max <max>", "Maximum requests per window").option(
|
|
1777
|
+
"-H, --header <header...>",
|
|
1778
|
+
'Headers to forward to upstream (format: "Key: Value")'
|
|
1779
|
+
).option("--no-cors", "Disable CORS headers").action(
|
|
1780
|
+
async (target, options) => {
|
|
1781
|
+
const shieldConfig = {};
|
|
1782
|
+
if (options.maxDepth) {
|
|
1783
|
+
shieldConfig.maxDepth = parseInt(options.maxDepth, 10);
|
|
1784
|
+
}
|
|
1785
|
+
if (options.maxComplexity) {
|
|
1786
|
+
shieldConfig.maxComplexity = parseInt(options.maxComplexity, 10);
|
|
1787
|
+
}
|
|
1788
|
+
if (options.maxAliases) {
|
|
1789
|
+
shieldConfig.maxAliases = parseInt(options.maxAliases, 10);
|
|
1790
|
+
}
|
|
1791
|
+
if (options.disableIntrospection) {
|
|
1792
|
+
shieldConfig.disableIntrospection = true;
|
|
1793
|
+
}
|
|
1794
|
+
if (options.rateLimitWindow && options.rateLimitMax) {
|
|
1795
|
+
shieldConfig.rateLimit = {
|
|
1796
|
+
window: parseInt(options.rateLimitWindow, 10),
|
|
1797
|
+
max: parseInt(options.rateLimitMax, 10)
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
const headers = {};
|
|
1801
|
+
if (options.header) {
|
|
1802
|
+
for (const h of options.header) {
|
|
1803
|
+
const colonIdx = h.indexOf(":");
|
|
1804
|
+
if (colonIdx > 0) {
|
|
1805
|
+
const key = h.substring(0, colonIdx).trim();
|
|
1806
|
+
const value = h.substring(colonIdx + 1).trim();
|
|
1807
|
+
headers[key] = value;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
await startProxy({
|
|
1813
|
+
target,
|
|
1814
|
+
port: parseInt(options.port, 10),
|
|
1815
|
+
shield: shieldConfig,
|
|
1816
|
+
headers: Object.keys(headers).length > 0 ? headers : void 0,
|
|
1817
|
+
cors: options.cors
|
|
1818
|
+
});
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
console.error(
|
|
1821
|
+
`Failed to start proxy: ${error instanceof Error ? error.message : String(error)}`
|
|
1822
|
+
);
|
|
1823
|
+
process.exit(1);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/cli.ts
|
|
1830
|
+
var program = new import_commander3.Command().name("graphql-sentinel").description("GraphQL security scanner and runtime shield").version("1.0.0");
|
|
1831
|
+
program.addCommand(createScanCommand());
|
|
1832
|
+
program.addCommand(createProxyCommand());
|
|
1833
|
+
program.parse();
|
|
1834
|
+
//# sourceMappingURL=cli.cjs.map
|