@blamejs/core 0.7.51 → 0.7.52
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/CHANGELOG.md +2 -0
- package/index.js +2 -0
- package/lib/framework-error.js +9 -0
- package/lib/guard-all.js +1 -0
- package/lib/guard-graphql.js +461 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.52** (2026-05-05) — `b.guardGraphql` — GraphQL request-shape safety primitive (KIND="graphql-request"). Validates user-supplied GraphQL request bundles against the canonical query-shape DoS catalog before the framework hands the query to a schema-aware executor. Threat catalog: query depth bombs (N² query-shape DoS — caps at strict 8 / balanced 12 / permissive 24); alias-bomb breadth DoS (caps at 8/16/32 aliases per selection-set); introspection in production (`__schema` / `__type` schema-leak); batch query DoS (caps at 1/10/50 entries per array); persisted-query enforcement (refuse free-form queries when `persistedQueryPolicy: "require"`); operation-name allowlist drift; variable type confusion (operator declares `variableShapes: { id: "string", limit: "number" }`); oversized query / variable / total bytes; BIDI / null / control / zero-width universal refuse on the query string. Brace-counting query-shape walker handles strings + comments correctly without requiring a full GraphQL parser. Profiles: `strict` (refuse introspection + batch + shape-DoS), `balanced` (audit most, allow batch up to 10), `permissive` (universal-refuse class still refused; rest allow / audit). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Adaptive integration harness gains a KIND="graphql-request" dispatcher reading `ctx.graphqlRequest`. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
12
|
+
|
|
11
13
|
- **0.7.51** (2026-05-05) — `b.guardOauth` — OAuth flow-shape safety primitive (KIND="oauth-flow"). Validates user-supplied OAuth 2.x / OIDC authorization-code-flow parameter bundles before the framework's `b.auth.oauth` client exchanges them. Threat catalog: PKCE missing or non-S256 (RFC 7636 / OAuth 2.1 mandate; `plain` is the downgrade-attack class); state missing (RFC 6749 §10.12 CSRF); redirect_uri not in operator allowlist (exact-match per OAuth 2.1 — no prefix / wildcard / scheme drift); response_type allowlist drift (refuse implicit `token` deprecated in OAuth 2.1, require operator-allowed types); scope-token shape per RFC 6749 §3.3 (refuse non-printable / control / whitespace-other-than-space); issuer missing on callback (RFC 9207 IdP-mix-up defense — set `flow._isCallback = true` to enforce); authorization-code reuse via operator-supplied `seenCodeStore.hasSeen(code)` (RFC 6749 §10.5 replay class); excessive parameter / total bytes; BIDI / null / control / zero-width universal refuse. Profiles: `strict` (PKCE S256, state required, redirect_uri exact-match, RFC 9207 iss required), `balanced` (PKCE any method, state + redirect_uri allowlist required, iss audited), `permissive` (universal-refuse class still refused; rest audit). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Adaptive integration harness gains a KIND="oauth-flow" dispatcher reading `ctx.oauthFlow`. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
|
12
14
|
|
|
13
15
|
- **0.7.50** (2026-05-05) — `b.guardJwt` — JWT identifier-safety primitive (KIND="identifier"). Validates user-supplied JWT compact-serialization strings against the canonical CVE-class refuse list before hand-off to a verifier — `b.guardJwt` never replaces signature verification, it reduces the input space the verifier sees. Threat catalog: shape malformation; `alg=none` (CVE-2015-9235 jsonwebtoken / CVE-2018-0114 java-jwt) — universally refused at every profile; alg-allowlist drift (PQC-first default: ML-DSA / SLH-DSA / EdDSA / ES* / RS* / PS*); `kid` path-traversal (operator `keyResolver` path-injection class — e.g. `kid: "../etc/passwd"` would escape a key-directory `fs.readFile(keyDir + kid)` resolver); `typ` confusion (non-JWT-shape media-type tokens coerced into the slot); oversized header / payload / signature segment defense (decompression bomb + parser DoS); `exp` / `nbf` / `iat` sanity (past `exp` = replay; far-future `nbf` / `iat` = clock-skew / attacker-shaped); required-claims enforcement (default `iss` / `exp` / `iat` at strict); unknown `crit` field refuse (RFC 7515 §4.1.11 — operator MUST refuse crit values it doesn't understand); BIDI / null / control / zero-width universal refuse. **`b.guardJwt.kidSafe(kid)`** is the documented contract for operator `keyResolver` implementations: throws on traversal indicators or control bytes, returns the validated kid on success. Profiles: `strict` (refuse everything), `balanced` (refuse alg=none / kid-traversal / unknown-crit; audit the rest), `permissive` (universal-refuse class still refused). Postures: `hipaa` / `pci-dss` / `soc2` strict overlay; `gdpr` balanced overlay. Auto-registers into `b.guardAll` as a STANDALONE_GUARD.
|
package/index.js
CHANGED
|
@@ -117,6 +117,7 @@ var guardTime = require("./lib/guard-time");
|
|
|
117
117
|
var guardMime = require("./lib/guard-mime");
|
|
118
118
|
var guardJwt = require("./lib/guard-jwt");
|
|
119
119
|
var guardOauth = require("./lib/guard-oauth");
|
|
120
|
+
var guardGraphql = require("./lib/guard-graphql");
|
|
120
121
|
var guardAll = require("./lib/guard-all");
|
|
121
122
|
var ssrfGuard = require("./lib/ssrf-guard");
|
|
122
123
|
var authHeader = require("./lib/auth-header");
|
|
@@ -265,6 +266,7 @@ module.exports = {
|
|
|
265
266
|
guardMime: guardMime,
|
|
266
267
|
guardJwt: guardJwt,
|
|
267
268
|
guardOauth: guardOauth,
|
|
269
|
+
guardGraphql: guardGraphql,
|
|
268
270
|
guardAll: guardAll,
|
|
269
271
|
ssrfGuard: ssrfGuard,
|
|
270
272
|
authHeader: authHeader,
|
package/lib/framework-error.js
CHANGED
|
@@ -306,6 +306,14 @@ var GuardJwtError = defineClass("GuardJwtError", { alwaysPermane
|
|
|
306
306
|
// BIDI / null / control / zero-width universal refuse.
|
|
307
307
|
// alwaysPermanent.
|
|
308
308
|
var GuardOauthError = defineClass("GuardOauthError", { alwaysPermanent: true });
|
|
309
|
+
// GuardGraphqlError covers GraphQL request-shape violations: query
|
|
310
|
+
// depth bombs (N² query-shape DoS), alias-bomb breadth DoS,
|
|
311
|
+
// introspection in production, batch-query DoS, persisted-query
|
|
312
|
+
// enforcement, operation-name allowlist drift, variable type
|
|
313
|
+
// confusion, oversized query / variable / total bytes, BIDI / null /
|
|
314
|
+
// control / zero-width universal refuse on the query string.
|
|
315
|
+
// alwaysPermanent.
|
|
316
|
+
var GuardGraphqlError = defineClass("GuardGraphqlError", { alwaysPermanent: true });
|
|
309
317
|
// DoraError covers DORA Article 17 incident-reporting workflow errors
|
|
310
318
|
// (classification refusal, report-shape validation, ESA-template
|
|
311
319
|
// generation, audit-chain integration). Permanent — these are
|
|
@@ -372,6 +380,7 @@ module.exports = {
|
|
|
372
380
|
GuardMimeError: GuardMimeError,
|
|
373
381
|
GuardJwtError: GuardJwtError,
|
|
374
382
|
GuardOauthError: GuardOauthError,
|
|
383
|
+
GuardGraphqlError: GuardGraphqlError,
|
|
375
384
|
DoraError: DoraError,
|
|
376
385
|
ComplianceError: ComplianceError,
|
|
377
386
|
SmtpPolicyError: SmtpPolicyError,
|
package/lib/guard-all.js
CHANGED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-graphql — GraphQL request-shape safety primitive
|
|
4
|
+
* (b.guardGraphql).
|
|
5
|
+
*
|
|
6
|
+
* Validates user-supplied GraphQL request bundles against the
|
|
7
|
+
* canonical query-shape DoS catalog before the framework hands the
|
|
8
|
+
* query to a schema-aware executor. KIND="graphql-request" — consumes
|
|
9
|
+
* `ctx.graphqlRequest` shape: { query, operationName?, variables? }.
|
|
10
|
+
*
|
|
11
|
+
* Threat catalog:
|
|
12
|
+
* - Query depth bombs — deeply-nested selection sets multiply N²
|
|
13
|
+
* against schema depth, bypassing field-level rate limits.
|
|
14
|
+
* - Query breadth / alias bombs — same field repeated under
|
|
15
|
+
* different aliases (`a:friend b:friend c:friend ...`) bypasses
|
|
16
|
+
* per-field limits.
|
|
17
|
+
* - Variable type confusion — variables passed as the wrong shape
|
|
18
|
+
* (string for ID expecting Int, object for scalar). Many
|
|
19
|
+
* executors coerce silently; the guard refuses non-shape-matching
|
|
20
|
+
* types when the operator declares variable shapes.
|
|
21
|
+
* - Introspection in production — `__schema` / `__type` queries
|
|
22
|
+
* leak schema details; refused unless operator opts in.
|
|
23
|
+
* - Batch query DoS — operators supporting [{},{}] batch arrays
|
|
24
|
+
* get N requests for one HTTP hit; the guard caps batch length.
|
|
25
|
+
* - Persisted-query opt-in — when operatorRequiresPersistedQuery,
|
|
26
|
+
* refuse free-form queries that don't carry a persisted-query
|
|
27
|
+
* hash extension.
|
|
28
|
+
* - Operation-name allowlist — operator may pin operationName to
|
|
29
|
+
* a whitelist of named operations (denylist for ad-hoc queries).
|
|
30
|
+
* - Excessive query / variable / total byte length — parser DoS.
|
|
31
|
+
* - BIDI / null / control / zero-width universal refuse on the
|
|
32
|
+
* query string.
|
|
33
|
+
*
|
|
34
|
+
* var rv = b.guardGraphql.validate(req, { profile: "strict" });
|
|
35
|
+
* var g = b.guardGraphql.gate({ profile: "strict" });
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var codepointClass = require("./codepoint-class");
|
|
39
|
+
var lazyRequire = require("./lazy-require");
|
|
40
|
+
var gateContract = require("./gate-contract");
|
|
41
|
+
var C = require("./constants");
|
|
42
|
+
var numericBounds = require("./numeric-bounds");
|
|
43
|
+
var { GuardGraphqlError } = require("./framework-error");
|
|
44
|
+
|
|
45
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
46
|
+
void observability;
|
|
47
|
+
|
|
48
|
+
var _err = GuardGraphqlError.factory;
|
|
49
|
+
|
|
50
|
+
// ---- Profile presets ----
|
|
51
|
+
|
|
52
|
+
var PROFILES = Object.freeze({
|
|
53
|
+
"strict": {
|
|
54
|
+
bidiPolicy: "reject",
|
|
55
|
+
controlPolicy: "reject",
|
|
56
|
+
nullBytePolicy: "reject",
|
|
57
|
+
zeroWidthPolicy: "reject",
|
|
58
|
+
introspectionPolicy: "reject",
|
|
59
|
+
persistedQueryPolicy: "audit",
|
|
60
|
+
operationNamePolicy: "audit",
|
|
61
|
+
batchPolicy: "reject",
|
|
62
|
+
aliasBombPolicy: "reject",
|
|
63
|
+
depthPolicy: "reject",
|
|
64
|
+
variableShapePolicy: "reject",
|
|
65
|
+
maxDepth: 8, // allow:raw-byte-literal — selection-set depth ceiling
|
|
66
|
+
maxAliasesPerSelection: 8, // allow:raw-byte-literal — alias breadth ceiling
|
|
67
|
+
maxBatchSize: 1, // allow:raw-byte-literal — strict refuses batch
|
|
68
|
+
maxQueryBytes: C.BYTES.kib(8),
|
|
69
|
+
maxVariableBytes: C.BYTES.kib(8),
|
|
70
|
+
maxBytes: C.BYTES.kib(32),
|
|
71
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
72
|
+
},
|
|
73
|
+
"balanced": {
|
|
74
|
+
bidiPolicy: "reject",
|
|
75
|
+
controlPolicy: "reject",
|
|
76
|
+
nullBytePolicy: "reject",
|
|
77
|
+
zeroWidthPolicy: "reject",
|
|
78
|
+
introspectionPolicy: "audit",
|
|
79
|
+
persistedQueryPolicy: "audit",
|
|
80
|
+
operationNamePolicy: "audit",
|
|
81
|
+
batchPolicy: "audit",
|
|
82
|
+
aliasBombPolicy: "audit",
|
|
83
|
+
depthPolicy: "audit",
|
|
84
|
+
variableShapePolicy: "audit",
|
|
85
|
+
maxDepth: 12, // allow:raw-byte-literal — selection-set depth ceiling
|
|
86
|
+
maxAliasesPerSelection: 16, // allow:raw-byte-literal — alias breadth ceiling
|
|
87
|
+
maxBatchSize: 10, // allow:raw-byte-literal — batch size ceiling
|
|
88
|
+
maxQueryBytes: C.BYTES.kib(16),
|
|
89
|
+
maxVariableBytes: C.BYTES.kib(16),
|
|
90
|
+
maxBytes: C.BYTES.kib(64),
|
|
91
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
92
|
+
},
|
|
93
|
+
"permissive": {
|
|
94
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
95
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
96
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
97
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
98
|
+
introspectionPolicy: "allow",
|
|
99
|
+
persistedQueryPolicy: "allow",
|
|
100
|
+
operationNamePolicy: "allow",
|
|
101
|
+
batchPolicy: "allow",
|
|
102
|
+
aliasBombPolicy: "audit",
|
|
103
|
+
depthPolicy: "audit",
|
|
104
|
+
variableShapePolicy: "audit",
|
|
105
|
+
maxDepth: 24, // allow:raw-byte-literal — selection-set depth ceiling
|
|
106
|
+
maxAliasesPerSelection: 32, // allow:raw-byte-literal — alias breadth ceiling
|
|
107
|
+
maxBatchSize: 50, // allow:raw-byte-literal — batch size ceiling
|
|
108
|
+
maxQueryBytes: C.BYTES.kib(64),
|
|
109
|
+
maxVariableBytes: C.BYTES.kib(64),
|
|
110
|
+
maxBytes: C.BYTES.kib(256),
|
|
111
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
116
|
+
mode: "enforce",
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
120
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
121
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
122
|
+
}),
|
|
123
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
124
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
125
|
+
}),
|
|
126
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
127
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
128
|
+
}),
|
|
129
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
130
|
+
forensicSnippetBytes: C.BYTES.bytes(1024),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function _resolveOpts(opts) {
|
|
135
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
136
|
+
profiles: PROFILES,
|
|
137
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
138
|
+
defaults: DEFAULTS,
|
|
139
|
+
errorClass: GuardGraphqlError,
|
|
140
|
+
errCodePrefix: "graphql",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// _measureQueryShape — walks the query string and computes
|
|
145
|
+
// brace-depth + per-selection-set alias counts using simple paren
|
|
146
|
+
// counting. Not a full GraphQL parser (operator runs the schema-
|
|
147
|
+
// aware parser downstream); the heuristic catches DoS shapes
|
|
148
|
+
// without a full lex/parse.
|
|
149
|
+
function _measureQueryShape(query) {
|
|
150
|
+
var maxDepth = 0;
|
|
151
|
+
var maxAliases = 0;
|
|
152
|
+
var depth = 0;
|
|
153
|
+
var inString = false;
|
|
154
|
+
var inComment = false;
|
|
155
|
+
var aliasCounts = [0]; // per-depth alias counter
|
|
156
|
+
for (var i = 0; i < query.length; i += 1) {
|
|
157
|
+
var c = query.charAt(i);
|
|
158
|
+
if (inComment) {
|
|
159
|
+
if (c === "\n") inComment = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (inString) {
|
|
163
|
+
if (c === '"' && query.charAt(i - 1) !== "\\") inString = false;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === '"') { inString = true; continue; }
|
|
167
|
+
if (c === "#") { inComment = true; continue; }
|
|
168
|
+
if (c === "{") {
|
|
169
|
+
depth += 1;
|
|
170
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
171
|
+
aliasCounts.push(0);
|
|
172
|
+
} else if (c === "}") {
|
|
173
|
+
// Capture the current selection-set's alias count before popping
|
|
174
|
+
// — otherwise we lose the per-block max when the block closes.
|
|
175
|
+
var current = aliasCounts[aliasCounts.length - 1] || 0;
|
|
176
|
+
if (current > maxAliases) maxAliases = current;
|
|
177
|
+
depth -= 1;
|
|
178
|
+
aliasCounts.pop();
|
|
179
|
+
if (depth < 0) depth = 0;
|
|
180
|
+
} else if (c === ":") {
|
|
181
|
+
// Alias indicator — `alias: field`. Increment the current depth's
|
|
182
|
+
// counter when the char before `:` looks like an identifier.
|
|
183
|
+
var prev = i > 0 ? query.charAt(i - 1) : "";
|
|
184
|
+
if (/[A-Za-z0-9_]/.test(prev) && depth > 0) {
|
|
185
|
+
aliasCounts[depth] += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Final sweep covers any unclosed selection-sets (operator-supplied
|
|
190
|
+
// syntactically-invalid queries).
|
|
191
|
+
for (var ai = 0; ai < aliasCounts.length; ai += 1) {
|
|
192
|
+
if (aliasCounts[ai] > maxAliases) maxAliases = aliasCounts[ai];
|
|
193
|
+
}
|
|
194
|
+
return { maxDepth: maxDepth, maxAliases: maxAliases };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _detectIssues(req, opts) {
|
|
198
|
+
var issues = [];
|
|
199
|
+
if (!req || typeof req !== "object") {
|
|
200
|
+
return [{ kind: "bad-input", severity: "high",
|
|
201
|
+
ruleId: "graphql.bad-input",
|
|
202
|
+
snippet: "graphql request is not an object" }];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Batch handling.
|
|
206
|
+
if (Array.isArray(req)) {
|
|
207
|
+
if (opts.batchPolicy !== "allow") {
|
|
208
|
+
if (opts.batchPolicy === "reject" || req.length > opts.maxBatchSize) {
|
|
209
|
+
issues.push({
|
|
210
|
+
kind: "batch-size",
|
|
211
|
+
severity: opts.batchPolicy === "reject" ? "high" : "warn",
|
|
212
|
+
ruleId: "graphql.batch-size",
|
|
213
|
+
snippet: "GraphQL batch length " + req.length + " exceeds " +
|
|
214
|
+
"maxBatchSize " + opts.maxBatchSize +
|
|
215
|
+
(opts.batchPolicy === "reject" ?
|
|
216
|
+
" (strict refuses any batch)" : ""),
|
|
217
|
+
});
|
|
218
|
+
if (opts.batchPolicy === "reject") return issues;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Apply per-request validation to each entry.
|
|
222
|
+
for (var bi = 0; bi < req.length; bi += 1) {
|
|
223
|
+
var sub = _detectIssues(req[bi], opts);
|
|
224
|
+
for (var si = 0; si < sub.length; si += 1) {
|
|
225
|
+
issues.push(Object.assign({}, sub[si], {
|
|
226
|
+
snippet: "[batch[" + bi + "]] " + sub[si].snippet,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return issues;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Total-bytes cap.
|
|
234
|
+
try {
|
|
235
|
+
var totalBytes = Buffer.byteLength(JSON.stringify(req), "utf8");
|
|
236
|
+
if (totalBytes > opts.maxBytes) {
|
|
237
|
+
return [{ kind: "request-cap", severity: "high",
|
|
238
|
+
ruleId: "graphql.request-cap",
|
|
239
|
+
snippet: "graphql request " + totalBytes + " bytes " +
|
|
240
|
+
"exceeds maxBytes " + opts.maxBytes }];
|
|
241
|
+
}
|
|
242
|
+
} catch (_e) { /* unstringifiable surfaces below */ }
|
|
243
|
+
|
|
244
|
+
if (typeof req.query !== "string" || req.query.length === 0) {
|
|
245
|
+
issues.push({
|
|
246
|
+
kind: "query-missing", severity: "high",
|
|
247
|
+
ruleId: "graphql.query-missing",
|
|
248
|
+
snippet: "graphql request missing `query` string",
|
|
249
|
+
});
|
|
250
|
+
return issues;
|
|
251
|
+
}
|
|
252
|
+
if (Buffer.byteLength(req.query, "utf8") > opts.maxQueryBytes) {
|
|
253
|
+
issues.push({
|
|
254
|
+
kind: "query-cap", severity: "high",
|
|
255
|
+
ruleId: "graphql.query-cap",
|
|
256
|
+
snippet: "query " + req.query.length + " bytes exceeds " +
|
|
257
|
+
"maxQueryBytes " + opts.maxQueryBytes,
|
|
258
|
+
});
|
|
259
|
+
return issues;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Codepoint-class threats on the query.
|
|
263
|
+
var charThreats = codepointClass.detectCharThreats(req.query, opts, "graphql");
|
|
264
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
265
|
+
|
|
266
|
+
// Variables byte cap.
|
|
267
|
+
if (req.variables !== undefined) {
|
|
268
|
+
try {
|
|
269
|
+
var varBytes = Buffer.byteLength(JSON.stringify(req.variables), "utf8");
|
|
270
|
+
if (varBytes > opts.maxVariableBytes) {
|
|
271
|
+
issues.push({
|
|
272
|
+
kind: "variables-cap", severity: "high",
|
|
273
|
+
ruleId: "graphql.variables-cap",
|
|
274
|
+
snippet: "variables exceed maxVariableBytes " + opts.maxVariableBytes,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch (_e) { /* unstringifiable variables */ }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Introspection.
|
|
281
|
+
if (opts.introspectionPolicy !== "allow") {
|
|
282
|
+
if (req.query.indexOf("__schema") !== -1 ||
|
|
283
|
+
req.query.indexOf("__type") !== -1) {
|
|
284
|
+
issues.push({
|
|
285
|
+
kind: "introspection",
|
|
286
|
+
severity: opts.introspectionPolicy === "reject" ? "high" : "warn",
|
|
287
|
+
ruleId: "graphql.introspection",
|
|
288
|
+
snippet: "query contains `__schema` / `__type` introspection — " +
|
|
289
|
+
"leaks schema details in production",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Persisted-query enforcement.
|
|
295
|
+
if (opts.persistedQueryPolicy === "require") {
|
|
296
|
+
var ext = req.extensions;
|
|
297
|
+
var hasPersisted = ext && ext.persistedQuery &&
|
|
298
|
+
typeof ext.persistedQuery.sha256Hash === "string";
|
|
299
|
+
if (!hasPersisted) {
|
|
300
|
+
issues.push({
|
|
301
|
+
kind: "persisted-query-missing", severity: "high",
|
|
302
|
+
ruleId: "graphql.persisted-query-missing",
|
|
303
|
+
snippet: "persistedQueryPolicy is `require` but request carries " +
|
|
304
|
+
"no extensions.persistedQuery.sha256Hash",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Operation-name allowlist.
|
|
310
|
+
if (Array.isArray(opts.allowedOperations) &&
|
|
311
|
+
opts.operationNamePolicy !== "allow") {
|
|
312
|
+
if (typeof req.operationName !== "string" ||
|
|
313
|
+
opts.allowedOperations.indexOf(req.operationName) === -1) {
|
|
314
|
+
issues.push({
|
|
315
|
+
kind: "operation-not-allowed",
|
|
316
|
+
severity: opts.operationNamePolicy === "reject" ? "high" : "warn",
|
|
317
|
+
ruleId: "graphql.operation-not-allowed",
|
|
318
|
+
snippet: "operationName `" + (req.operationName || "<missing>") +
|
|
319
|
+
"` not in operator allowlist",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Query shape — depth + alias bombs.
|
|
325
|
+
var shape = _measureQueryShape(req.query);
|
|
326
|
+
if (opts.depthPolicy !== "allow" && shape.maxDepth > opts.maxDepth) {
|
|
327
|
+
issues.push({
|
|
328
|
+
kind: "depth-exceeded",
|
|
329
|
+
severity: opts.depthPolicy === "reject" ? "high" : "warn",
|
|
330
|
+
ruleId: "graphql.depth-exceeded",
|
|
331
|
+
snippet: "query depth " + shape.maxDepth + " exceeds maxDepth " +
|
|
332
|
+
opts.maxDepth + " — N² query-shape DoS class",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (opts.aliasBombPolicy !== "allow" &&
|
|
336
|
+
shape.maxAliases > opts.maxAliasesPerSelection) {
|
|
337
|
+
issues.push({
|
|
338
|
+
kind: "alias-bomb",
|
|
339
|
+
severity: opts.aliasBombPolicy === "reject" ? "high" : "warn",
|
|
340
|
+
ruleId: "graphql.alias-bomb",
|
|
341
|
+
snippet: "selection-set alias count " + shape.maxAliases +
|
|
342
|
+
" exceeds maxAliasesPerSelection " +
|
|
343
|
+
opts.maxAliasesPerSelection +
|
|
344
|
+
" — alias-bomb breadth-DoS class",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Variable shape (operator-declared via opts.variableShapes).
|
|
349
|
+
if (opts.variableShapePolicy !== "allow" &&
|
|
350
|
+
opts.variableShapes && typeof opts.variableShapes === "object" &&
|
|
351
|
+
req.variables && typeof req.variables === "object") {
|
|
352
|
+
var keys = Object.keys(opts.variableShapes);
|
|
353
|
+
for (var ki = 0; ki < keys.length; ki += 1) {
|
|
354
|
+
var k = keys[ki];
|
|
355
|
+
var expected = opts.variableShapes[k];
|
|
356
|
+
var actual = req.variables[k];
|
|
357
|
+
if (actual === undefined) continue;
|
|
358
|
+
if (typeof actual !== expected) {
|
|
359
|
+
issues.push({
|
|
360
|
+
kind: "variable-type-confusion",
|
|
361
|
+
severity: opts.variableShapePolicy === "reject" ? "high" : "warn",
|
|
362
|
+
ruleId: "graphql.variable-type-confusion",
|
|
363
|
+
snippet: "variable `" + k + "` is " + typeof actual +
|
|
364
|
+
", expected " + expected,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return issues;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function validate(input, opts) {
|
|
374
|
+
opts = _resolveOpts(opts);
|
|
375
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
376
|
+
["maxBytes", "maxQueryBytes", "maxVariableBytes",
|
|
377
|
+
"maxDepth", "maxAliasesPerSelection", "maxBatchSize"],
|
|
378
|
+
"guardGraphql.validate", GuardGraphqlError, "graphql.bad-opt");
|
|
379
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sanitize(input, opts) {
|
|
383
|
+
opts = _resolveOpts(opts);
|
|
384
|
+
var issues = _detectIssues(input, opts);
|
|
385
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
386
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
387
|
+
throw _err(issues[i].ruleId || "graphql.refused",
|
|
388
|
+
"guardGraphql.sanitize: " + issues[i].snippet);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return input;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function gate(opts) {
|
|
395
|
+
opts = _resolveOpts(opts);
|
|
396
|
+
return gateContract.buildGuardGate(
|
|
397
|
+
opts.name || "guardGraphql:" + (opts.profile || "default"),
|
|
398
|
+
opts,
|
|
399
|
+
async function (ctx) {
|
|
400
|
+
var req = ctx && (ctx.graphqlRequest || ctx.gql);
|
|
401
|
+
if (!req) return { ok: true, action: "serve" };
|
|
402
|
+
var rv = validate(req, opts);
|
|
403
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
404
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
405
|
+
return i.severity === "critical";
|
|
406
|
+
});
|
|
407
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
408
|
+
return i.severity === "high";
|
|
409
|
+
});
|
|
410
|
+
if (!hasCritical && !hasHigh) {
|
|
411
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
412
|
+
}
|
|
413
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
418
|
+
|
|
419
|
+
function compliancePosture(name) {
|
|
420
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
421
|
+
_err, "graphql");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
var _gqlRulePacks = gateContract.makeRulePackLoader(GuardGraphqlError, "graphql");
|
|
425
|
+
var loadRulePack = _gqlRulePacks.load;
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
// ---- guard-* family registry exports ----
|
|
429
|
+
NAME: "graphql",
|
|
430
|
+
KIND: "graphql-request",
|
|
431
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
432
|
+
kind: "graphql-request",
|
|
433
|
+
benignBytes: Buffer.from(JSON.stringify({
|
|
434
|
+
query: "query GetMe { me { id name } }",
|
|
435
|
+
operationName: "GetMe",
|
|
436
|
+
}), "utf8"),
|
|
437
|
+
hostileBytes: Buffer.from(JSON.stringify({
|
|
438
|
+
query: "query Inspect { __schema { types { name } } }",
|
|
439
|
+
operationName: "Inspect",
|
|
440
|
+
}), "utf8"),
|
|
441
|
+
benignGraphqlRequest: {
|
|
442
|
+
query: "query GetMe { me { id name } }",
|
|
443
|
+
operationName: "GetMe",
|
|
444
|
+
},
|
|
445
|
+
hostileGraphqlRequest: {
|
|
446
|
+
query: "query Inspect { __schema { types { name } } }",
|
|
447
|
+
operationName: "Inspect",
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
// ---- primitive surface ----
|
|
451
|
+
validate: validate,
|
|
452
|
+
sanitize: sanitize,
|
|
453
|
+
gate: gate,
|
|
454
|
+
buildProfile: buildProfile,
|
|
455
|
+
compliancePosture: compliancePosture,
|
|
456
|
+
loadRulePack: loadRulePack,
|
|
457
|
+
PROFILES: PROFILES,
|
|
458
|
+
DEFAULTS: DEFAULTS,
|
|
459
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
460
|
+
GuardGraphqlError: GuardGraphqlError,
|
|
461
|
+
};
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:5f6f1fab-e230-4b2c-ad06-d5b9143d8fe2",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-05T22:
|
|
8
|
+
"timestamp": "2026-05-05T22:47:38.526Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.52",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.52",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.52",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.52",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|