@blamejs/core 0.7.51 → 0.7.61
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 +20 -0
- package/index.js +16 -0
- package/lib/crypto.js +125 -0
- package/lib/framework-error.js +62 -0
- package/lib/guard-all.js +8 -0
- package/lib/guard-auth.js +278 -0
- package/lib/guard-graphql.js +461 -0
- package/lib/guard-image.js +371 -0
- package/lib/guard-jsonpath.js +300 -0
- package/lib/guard-pdf.js +345 -0
- package/lib/guard-regex.js +287 -0
- package/lib/guard-shell.js +319 -0
- package/lib/guard-template.js +279 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/guard-pdf.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-pdf — PDF identifier-safety primitive (b.guardPdf).
|
|
4
|
+
*
|
|
5
|
+
* Validates PDF inputs without vendoring a full parser. Operators
|
|
6
|
+
* bring their own PDF library (pdf-lib, pdfjs-dist, vendored mupdf)
|
|
7
|
+
* and feed structural metadata to the guard for policy enforcement.
|
|
8
|
+
* KIND="metadata" — consumes `ctx.metadata` shape: `{ bytes?,
|
|
9
|
+
* hasJavaScript?, hasOpenAction?, hasEmbeddedFiles?, hasLaunchAction?,
|
|
10
|
+
* isEncrypted?, pageCount?, embeddedFileCount? }`.
|
|
11
|
+
*
|
|
12
|
+
* Threat catalog:
|
|
13
|
+
* - Magic-byte missing or wrong — `%PDF-` header check.
|
|
14
|
+
* - JavaScript action — `/JS` / `/JavaScript` annotation triggers
|
|
15
|
+
* RCE in vulnerable readers (CVE class — Adobe / Foxit / nitro).
|
|
16
|
+
* - OpenAction trigger — `/OpenAction` runs on document open;
|
|
17
|
+
* when paired with JavaScript or LaunchAction it's a drive-by.
|
|
18
|
+
* - LaunchAction — `/Launch` action invokes external program;
|
|
19
|
+
* refused at every profile.
|
|
20
|
+
* - Embedded files — `/EmbeddedFile` may carry executable payloads.
|
|
21
|
+
* - Encrypted PDF refuse — many AV / sandbox tools can't scan;
|
|
22
|
+
* operators may want to refuse encrypted PDFs.
|
|
23
|
+
* - Oversized — bytes / page count / embedded-file count.
|
|
24
|
+
* - Polyglot — buffer carries non-PDF magic-byte signatures
|
|
25
|
+
* (operator-supplied via `polyglotDetected: true`).
|
|
26
|
+
*
|
|
27
|
+
* var rv = b.guardPdf.validate(metadata, { profile: "strict" });
|
|
28
|
+
* var g = b.guardPdf.gate({ profile: "strict" });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
var lazyRequire = require("./lazy-require");
|
|
32
|
+
var gateContract = require("./gate-contract");
|
|
33
|
+
var C = require("./constants");
|
|
34
|
+
var numericBounds = require("./numeric-bounds");
|
|
35
|
+
var { GuardPdfError } = require("./framework-error");
|
|
36
|
+
|
|
37
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
38
|
+
void observability;
|
|
39
|
+
|
|
40
|
+
var _err = GuardPdfError.factory;
|
|
41
|
+
|
|
42
|
+
// PDF magic bytes — `%PDF-` (5 bytes).
|
|
43
|
+
var PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2D];
|
|
44
|
+
|
|
45
|
+
// ---- Profile presets ----
|
|
46
|
+
|
|
47
|
+
var PROFILES = Object.freeze({
|
|
48
|
+
"strict": {
|
|
49
|
+
magicPolicy: "reject",
|
|
50
|
+
javascriptPolicy: "reject",
|
|
51
|
+
openActionPolicy: "reject",
|
|
52
|
+
launchActionPolicy: "reject",
|
|
53
|
+
embeddedFilePolicy: "reject",
|
|
54
|
+
encryptedPolicy: "reject",
|
|
55
|
+
polyglotPolicy: "reject",
|
|
56
|
+
pageCountPolicy: "reject",
|
|
57
|
+
embeddedFileCountPolicy: "reject",
|
|
58
|
+
maxPageCount: 500, // allow:raw-byte-literal — page-count ceiling
|
|
59
|
+
maxEmbeddedFileCount: 0, // allow:raw-byte-literal — strict refuses any embedded file
|
|
60
|
+
maxBytes: C.BYTES.mib(64),
|
|
61
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
62
|
+
},
|
|
63
|
+
"balanced": {
|
|
64
|
+
magicPolicy: "reject",
|
|
65
|
+
javascriptPolicy: "reject", // RCE class — refused at every profile
|
|
66
|
+
openActionPolicy: "audit",
|
|
67
|
+
launchActionPolicy: "reject", // RCE class — refused at every profile
|
|
68
|
+
embeddedFilePolicy: "audit",
|
|
69
|
+
encryptedPolicy: "audit",
|
|
70
|
+
polyglotPolicy: "reject", // polyglot refused at every profile
|
|
71
|
+
pageCountPolicy: "audit",
|
|
72
|
+
embeddedFileCountPolicy: "audit",
|
|
73
|
+
maxPageCount: 5000, // allow:raw-byte-literal — page-count ceiling
|
|
74
|
+
maxEmbeddedFileCount: 10, // allow:raw-byte-literal — embedded file ceiling
|
|
75
|
+
maxBytes: C.BYTES.mib(128),
|
|
76
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
77
|
+
},
|
|
78
|
+
"permissive": {
|
|
79
|
+
magicPolicy: "audit",
|
|
80
|
+
javascriptPolicy: "reject", // RCE class — refused at every profile
|
|
81
|
+
openActionPolicy: "audit",
|
|
82
|
+
launchActionPolicy: "reject", // RCE class — refused at every profile
|
|
83
|
+
embeddedFilePolicy: "audit",
|
|
84
|
+
encryptedPolicy: "allow",
|
|
85
|
+
polyglotPolicy: "reject", // polyglot refused at every profile
|
|
86
|
+
pageCountPolicy: "audit",
|
|
87
|
+
embeddedFileCountPolicy: "audit",
|
|
88
|
+
maxPageCount: 50000, // allow:raw-byte-literal — page-count ceiling
|
|
89
|
+
maxEmbeddedFileCount: 100, // allow:raw-byte-literal — embedded file ceiling
|
|
90
|
+
maxBytes: C.BYTES.mib(512),
|
|
91
|
+
maxRuntimeMs: C.TIME.seconds(5),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
96
|
+
mode: "enforce",
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
100
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
101
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
102
|
+
}),
|
|
103
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
104
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
105
|
+
}),
|
|
106
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
107
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
108
|
+
}),
|
|
109
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
110
|
+
forensicSnippetBytes: C.BYTES.bytes(1024),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function _resolveOpts(opts) {
|
|
115
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
116
|
+
profiles: PROFILES,
|
|
117
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
118
|
+
defaults: DEFAULTS,
|
|
119
|
+
errorClass: GuardPdfError,
|
|
120
|
+
errCodePrefix: "pdf",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _hasPdfMagic(buf) {
|
|
125
|
+
if (!buf || typeof buf.length !== "number" || buf.length < PDF_MAGIC.length) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
for (var i = 0; i < PDF_MAGIC.length; i += 1) {
|
|
129
|
+
if (buf[i] !== PDF_MAGIC[i]) return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _detectIssues(metadata, opts) {
|
|
135
|
+
var issues = [];
|
|
136
|
+
if (!metadata || typeof metadata !== "object") {
|
|
137
|
+
return [{ kind: "bad-input", severity: "high",
|
|
138
|
+
ruleId: "pdf.bad-input",
|
|
139
|
+
snippet: "pdf metadata is not an object" }];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var bytes = metadata.bytes;
|
|
143
|
+
if (bytes && typeof bytes.length === "number" && bytes.length > opts.maxBytes) {
|
|
144
|
+
return [{ kind: "pdf-cap", severity: "high",
|
|
145
|
+
ruleId: "pdf.pdf-cap",
|
|
146
|
+
snippet: "pdf bytes exceed maxBytes " + opts.maxBytes }];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Magic check.
|
|
150
|
+
if (bytes && opts.magicPolicy !== "allow" && !_hasPdfMagic(bytes)) {
|
|
151
|
+
issues.push({
|
|
152
|
+
kind: "magic-missing",
|
|
153
|
+
severity: opts.magicPolicy === "reject" ? "high" : "warn",
|
|
154
|
+
ruleId: "pdf.magic-missing",
|
|
155
|
+
snippet: "buffer does not start with `%PDF-` magic bytes",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Polyglot — operator-supplied flag.
|
|
160
|
+
if (metadata.polyglotDetected === true && opts.polyglotPolicy !== "allow") {
|
|
161
|
+
issues.push({
|
|
162
|
+
kind: "polyglot", severity: "critical",
|
|
163
|
+
ruleId: "pdf.polyglot",
|
|
164
|
+
snippet: "operator metadata flags this PDF as polyglot — refused " +
|
|
165
|
+
"(buffer carries non-PDF magic-byte signatures)",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// JavaScript action — RCE class.
|
|
170
|
+
if (metadata.hasJavaScript === true && opts.javascriptPolicy !== "allow") {
|
|
171
|
+
issues.push({
|
|
172
|
+
kind: "javascript-action", severity: "critical",
|
|
173
|
+
ruleId: "pdf.javascript-action",
|
|
174
|
+
snippet: "PDF carries `/JS` / `/JavaScript` action — RCE class " +
|
|
175
|
+
"in vulnerable readers (Adobe / Foxit / nitro CVEs)",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// LaunchAction — universally refused.
|
|
180
|
+
if (metadata.hasLaunchAction === true &&
|
|
181
|
+
opts.launchActionPolicy !== "allow") {
|
|
182
|
+
issues.push({
|
|
183
|
+
kind: "launch-action", severity: "critical",
|
|
184
|
+
ruleId: "pdf.launch-action",
|
|
185
|
+
snippet: "PDF carries `/Launch` action — invokes external program",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// OpenAction — runs on document open.
|
|
190
|
+
if (metadata.hasOpenAction === true &&
|
|
191
|
+
opts.openActionPolicy !== "allow") {
|
|
192
|
+
issues.push({
|
|
193
|
+
kind: "open-action",
|
|
194
|
+
severity: opts.openActionPolicy === "reject" ? "high" : "warn",
|
|
195
|
+
ruleId: "pdf.open-action",
|
|
196
|
+
snippet: "PDF carries `/OpenAction` — runs on document open " +
|
|
197
|
+
"(drive-by class when paired with JavaScript / Launch)",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Embedded files.
|
|
202
|
+
if (metadata.hasEmbeddedFiles === true &&
|
|
203
|
+
opts.embeddedFilePolicy !== "allow") {
|
|
204
|
+
issues.push({
|
|
205
|
+
kind: "embedded-file",
|
|
206
|
+
severity: opts.embeddedFilePolicy === "reject" ? "high" : "warn",
|
|
207
|
+
ruleId: "pdf.embedded-file",
|
|
208
|
+
snippet: "PDF carries embedded files — may smuggle executable " +
|
|
209
|
+
"payloads",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (typeof metadata.embeddedFileCount === "number" &&
|
|
213
|
+
opts.embeddedFileCountPolicy !== "allow" &&
|
|
214
|
+
metadata.embeddedFileCount > opts.maxEmbeddedFileCount) {
|
|
215
|
+
issues.push({
|
|
216
|
+
kind: "embedded-file-count",
|
|
217
|
+
severity: opts.embeddedFileCountPolicy === "reject" ? "high" : "warn",
|
|
218
|
+
ruleId: "pdf.embedded-file-count",
|
|
219
|
+
snippet: "embedded-file count " + metadata.embeddedFileCount +
|
|
220
|
+
" exceeds maxEmbeddedFileCount " + opts.maxEmbeddedFileCount,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Encrypted.
|
|
225
|
+
if (metadata.isEncrypted === true && opts.encryptedPolicy !== "allow") {
|
|
226
|
+
issues.push({
|
|
227
|
+
kind: "encrypted",
|
|
228
|
+
severity: opts.encryptedPolicy === "reject" ? "high" : "warn",
|
|
229
|
+
ruleId: "pdf.encrypted",
|
|
230
|
+
snippet: "PDF is encrypted — many AV / sandbox tools can't scan " +
|
|
231
|
+
"encrypted documents",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Page count.
|
|
236
|
+
if (typeof metadata.pageCount === "number" &&
|
|
237
|
+
opts.pageCountPolicy !== "allow" &&
|
|
238
|
+
metadata.pageCount > opts.maxPageCount) {
|
|
239
|
+
issues.push({
|
|
240
|
+
kind: "page-count",
|
|
241
|
+
severity: opts.pageCountPolicy === "reject" ? "high" : "warn",
|
|
242
|
+
ruleId: "pdf.page-count",
|
|
243
|
+
snippet: "page count " + metadata.pageCount + " exceeds " +
|
|
244
|
+
"maxPageCount " + opts.maxPageCount,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return issues;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validate(input, opts) {
|
|
252
|
+
opts = _resolveOpts(opts);
|
|
253
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
254
|
+
["maxBytes", "maxPageCount"],
|
|
255
|
+
"guardPdf.validate", GuardPdfError, "pdf.bad-opt");
|
|
256
|
+
// maxEmbeddedFileCount allows 0 (strict refuses all embedded files);
|
|
257
|
+
// skip the positive-finite check.
|
|
258
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sanitize(input, opts) {
|
|
262
|
+
opts = _resolveOpts(opts);
|
|
263
|
+
if (!input || typeof input !== "object") {
|
|
264
|
+
throw _err("pdf.bad-input", "sanitize requires metadata object");
|
|
265
|
+
}
|
|
266
|
+
var issues = _detectIssues(input, opts);
|
|
267
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
268
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
269
|
+
throw _err(issues[i].ruleId || "pdf.refused",
|
|
270
|
+
"guardPdf.sanitize: " + issues[i].snippet);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return input;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function gate(opts) {
|
|
277
|
+
opts = _resolveOpts(opts);
|
|
278
|
+
return gateContract.buildGuardGate(
|
|
279
|
+
opts.name || "guardPdf:" + (opts.profile || "default"),
|
|
280
|
+
opts,
|
|
281
|
+
async function (ctx) {
|
|
282
|
+
var meta = ctx && (ctx.metadata || ctx.pdfMetadata);
|
|
283
|
+
if (!meta) return { ok: true, action: "serve" };
|
|
284
|
+
var rv = validate(meta, opts);
|
|
285
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
286
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
287
|
+
return i.severity === "critical";
|
|
288
|
+
});
|
|
289
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
290
|
+
return i.severity === "high";
|
|
291
|
+
});
|
|
292
|
+
if (!hasCritical && !hasHigh) {
|
|
293
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
294
|
+
}
|
|
295
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
300
|
+
|
|
301
|
+
function compliancePosture(name) {
|
|
302
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
303
|
+
_err, "pdf");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var _pdfRulePacks = gateContract.makeRulePackLoader(GuardPdfError, "pdf");
|
|
307
|
+
var loadRulePack = _pdfRulePacks.load;
|
|
308
|
+
|
|
309
|
+
// Operator helper: confirm bytes carry the PDF magic header.
|
|
310
|
+
function inspectMagic(bytes) {
|
|
311
|
+
return _hasPdfMagic(bytes);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
// ---- guard-* family registry exports ----
|
|
316
|
+
NAME: "pdf",
|
|
317
|
+
KIND: "metadata",
|
|
318
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
319
|
+
kind: "metadata",
|
|
320
|
+
benignBytes: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]), // %PDF-1.7
|
|
321
|
+
hostileBytes: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]),
|
|
322
|
+
benignMetadata: {
|
|
323
|
+
bytes: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]),
|
|
324
|
+
hasJavaScript: false, hasOpenAction: false, hasLaunchAction: false,
|
|
325
|
+
hasEmbeddedFiles: false, isEncrypted: false, pageCount: 1,
|
|
326
|
+
},
|
|
327
|
+
hostileMetadata: {
|
|
328
|
+
bytes: Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37]),
|
|
329
|
+
// Hostile: JavaScript action — universal refuse (RCE class).
|
|
330
|
+
hasJavaScript: true,
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
// ---- primitive surface ----
|
|
334
|
+
validate: validate,
|
|
335
|
+
sanitize: sanitize,
|
|
336
|
+
gate: gate,
|
|
337
|
+
inspectMagic: inspectMagic,
|
|
338
|
+
buildProfile: buildProfile,
|
|
339
|
+
compliancePosture: compliancePosture,
|
|
340
|
+
loadRulePack: loadRulePack,
|
|
341
|
+
PROFILES: PROFILES,
|
|
342
|
+
DEFAULTS: DEFAULTS,
|
|
343
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
344
|
+
GuardPdfError: GuardPdfError,
|
|
345
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-regex — Regex pattern identifier-safety primitive
|
|
4
|
+
* (b.guardRegex).
|
|
5
|
+
*
|
|
6
|
+
* Validates user-supplied regex pattern strings for catastrophic-
|
|
7
|
+
* backtracking (ReDoS) shapes BEFORE compilation. KIND="identifier" —
|
|
8
|
+
* consumes ctx.identifier (or ctx.pattern).
|
|
9
|
+
*
|
|
10
|
+
* Threat catalog:
|
|
11
|
+
* - Nested quantifiers — `(a+)+`, `(a*)+`, `(.+)+`. The classic
|
|
12
|
+
* ReDoS shape. CVE-2024-21538 (cross-spawn) and CVE-2022-25929
|
|
13
|
+
* (chartjs-adapter-luxon) are recent prominent examples.
|
|
14
|
+
* - Quantifier-after-grouped-quantifier — `(a|b)+\w*` style strings.
|
|
15
|
+
* - Alternation overlap with quantifier — `(\d|\d{2})*`.
|
|
16
|
+
* - Bounded quantifiers with very large counts — operator-tunable
|
|
17
|
+
* via maxBoundedRepeat.
|
|
18
|
+
* - Excessive pattern length — defense against parser DoS.
|
|
19
|
+
* - Lookbehind / lookahead with quantifiers inside.
|
|
20
|
+
* - BIDI / null / control / zero-width universal refuse.
|
|
21
|
+
*
|
|
22
|
+
* var rv = b.guardRegex.validate("(a+)+b", { profile: "strict" });
|
|
23
|
+
* var g = b.guardRegex.gate({ profile: "strict" });
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var codepointClass = require("./codepoint-class");
|
|
27
|
+
var lazyRequire = require("./lazy-require");
|
|
28
|
+
var gateContract = require("./gate-contract");
|
|
29
|
+
var C = require("./constants");
|
|
30
|
+
var numericBounds = require("./numeric-bounds");
|
|
31
|
+
var { GuardRegexError } = require("./framework-error");
|
|
32
|
+
|
|
33
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
34
|
+
void observability;
|
|
35
|
+
|
|
36
|
+
var _err = GuardRegexError.factory;
|
|
37
|
+
|
|
38
|
+
// Nested-quantifier detector: `(group)+`-style followed by another
|
|
39
|
+
// quantifier or repetition that operates on the grouped match.
|
|
40
|
+
var NESTED_QUANT_RE = /\([^()]*[*+?][^()]*\)\s*[*+?{]/;
|
|
41
|
+
|
|
42
|
+
// Alternation-with-quantifier — `(a|b|...)+`, `(a|b)*`.
|
|
43
|
+
var ALTERNATION_QUANT_RE = /\([^()]*\|[^()]*\)\s*[*+]/;
|
|
44
|
+
|
|
45
|
+
// Bounded repetition — captures the upper bound when present.
|
|
46
|
+
var BOUNDED_REPEAT_RE = /\{(\d+)(?:,(\d*))?\}/g;
|
|
47
|
+
|
|
48
|
+
// Lookaround with internal quantifier — `(?=.*+)`, `(?!a*)`.
|
|
49
|
+
var LOOKAROUND_QUANT_RE = /\(\?[=!<][^()]*[*+]/;
|
|
50
|
+
|
|
51
|
+
// ---- Profile presets ----
|
|
52
|
+
|
|
53
|
+
var PROFILES = Object.freeze({
|
|
54
|
+
"strict": {
|
|
55
|
+
bidiPolicy: "reject",
|
|
56
|
+
controlPolicy: "reject",
|
|
57
|
+
nullBytePolicy: "reject",
|
|
58
|
+
zeroWidthPolicy: "reject",
|
|
59
|
+
nestedQuantPolicy: "reject",
|
|
60
|
+
alternationQuantPolicy: "reject",
|
|
61
|
+
boundedRepeatPolicy: "reject",
|
|
62
|
+
lookaroundQuantPolicy: "reject",
|
|
63
|
+
maxBoundedRepeat: 100, // allow:raw-byte-literal — bounded repeat ceiling
|
|
64
|
+
maxPatternBytes: C.BYTES.kib(1),
|
|
65
|
+
maxBytes: C.BYTES.kib(1),
|
|
66
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
67
|
+
},
|
|
68
|
+
"balanced": {
|
|
69
|
+
bidiPolicy: "reject",
|
|
70
|
+
controlPolicy: "reject",
|
|
71
|
+
nullBytePolicy: "reject",
|
|
72
|
+
zeroWidthPolicy: "reject",
|
|
73
|
+
nestedQuantPolicy: "reject",
|
|
74
|
+
alternationQuantPolicy: "audit",
|
|
75
|
+
boundedRepeatPolicy: "audit",
|
|
76
|
+
lookaroundQuantPolicy: "audit",
|
|
77
|
+
maxBoundedRepeat: 1000, // allow:raw-byte-literal — bounded repeat ceiling
|
|
78
|
+
maxPatternBytes: C.BYTES.kib(2),
|
|
79
|
+
maxBytes: C.BYTES.kib(2),
|
|
80
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
81
|
+
},
|
|
82
|
+
"permissive": {
|
|
83
|
+
bidiPolicy: "reject", // BIDI refused at every profile
|
|
84
|
+
controlPolicy: "reject", // controls refused at every profile
|
|
85
|
+
nullBytePolicy: "reject", // null refused at every profile
|
|
86
|
+
zeroWidthPolicy: "reject", // zero-width refused at every profile
|
|
87
|
+
nestedQuantPolicy: "reject", // canonical ReDoS class refused at every profile
|
|
88
|
+
alternationQuantPolicy: "allow",
|
|
89
|
+
boundedRepeatPolicy: "audit",
|
|
90
|
+
lookaroundQuantPolicy: "audit",
|
|
91
|
+
maxBoundedRepeat: 10000, // allow:raw-byte-literal — bounded repeat ceiling
|
|
92
|
+
maxPatternBytes: C.BYTES.kib(8),
|
|
93
|
+
maxBytes: C.BYTES.kib(8),
|
|
94
|
+
maxRuntimeMs: C.TIME.seconds(2),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
99
|
+
mode: "enforce",
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
103
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
104
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
105
|
+
}),
|
|
106
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
107
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
108
|
+
}),
|
|
109
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
110
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
111
|
+
}),
|
|
112
|
+
"soc2": Object.assign({}, PROFILES["strict"], {
|
|
113
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function _resolveOpts(opts) {
|
|
118
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
119
|
+
profiles: PROFILES,
|
|
120
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
121
|
+
defaults: DEFAULTS,
|
|
122
|
+
errorClass: GuardRegexError,
|
|
123
|
+
errCodePrefix: "regex",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _detectIssues(input, opts) {
|
|
128
|
+
var issues = [];
|
|
129
|
+
if (typeof input !== "string") {
|
|
130
|
+
return [{ kind: "bad-input", severity: "high",
|
|
131
|
+
ruleId: "regex.bad-input",
|
|
132
|
+
snippet: "regex pattern is not a string" }];
|
|
133
|
+
}
|
|
134
|
+
if (input.length === 0) {
|
|
135
|
+
return [{ kind: "empty", severity: "high",
|
|
136
|
+
ruleId: "regex.empty",
|
|
137
|
+
snippet: "regex pattern is empty" }];
|
|
138
|
+
}
|
|
139
|
+
if (Buffer.byteLength(input, "utf8") > opts.maxPatternBytes) {
|
|
140
|
+
return [{ kind: "pattern-cap", severity: "high",
|
|
141
|
+
ruleId: "regex.pattern-cap",
|
|
142
|
+
snippet: "regex pattern exceeds maxPatternBytes " +
|
|
143
|
+
opts.maxPatternBytes }];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
var charThreats = codepointClass.detectCharThreats(input, opts, "regex");
|
|
147
|
+
for (var ci = 0; ci < charThreats.length; ci += 1) issues.push(charThreats[ci]);
|
|
148
|
+
|
|
149
|
+
if (opts.nestedQuantPolicy !== "allow" && NESTED_QUANT_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
150
|
+
issues.push({
|
|
151
|
+
kind: "nested-quantifier", severity: "critical",
|
|
152
|
+
ruleId: "regex.nested-quantifier",
|
|
153
|
+
snippet: "pattern contains nested-quantifier shape (e.g. " +
|
|
154
|
+
"`(a+)+`) — canonical ReDoS catastrophic-backtracking " +
|
|
155
|
+
"class (CVE-2024-21538 cross-spawn / CVE-2022-25929)",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (opts.alternationQuantPolicy !== "allow" &&
|
|
160
|
+
ALTERNATION_QUANT_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
161
|
+
issues.push({
|
|
162
|
+
kind: "alternation-quantifier",
|
|
163
|
+
severity: opts.alternationQuantPolicy === "reject" ? "high" : "warn",
|
|
164
|
+
ruleId: "regex.alternation-quantifier",
|
|
165
|
+
snippet: "pattern contains alternation-with-quantifier shape " +
|
|
166
|
+
"(e.g. `(a|b)+`) — alternation overlap may amplify " +
|
|
167
|
+
"search paths",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (opts.lookaroundQuantPolicy !== "allow" &&
|
|
172
|
+
LOOKAROUND_QUANT_RE.test(input)) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
173
|
+
issues.push({
|
|
174
|
+
kind: "lookaround-quantifier",
|
|
175
|
+
severity: opts.lookaroundQuantPolicy === "reject" ? "high" : "warn",
|
|
176
|
+
ruleId: "regex.lookaround-quantifier",
|
|
177
|
+
snippet: "pattern contains quantifier inside lookaround " +
|
|
178
|
+
"(`(?=.*+)`) — catastrophic in some engines",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (opts.boundedRepeatPolicy !== "allow") {
|
|
183
|
+
BOUNDED_REPEAT_RE.lastIndex = 0;
|
|
184
|
+
var match;
|
|
185
|
+
while ((match = BOUNDED_REPEAT_RE.exec(input)) !== null) { // allow:regex-no-length-cap — input bounded by maxPatternBytes
|
|
186
|
+
var lower = parseInt(match[1], 10); // allow:raw-byte-literal — base-10 radix
|
|
187
|
+
var upper = match[2] === undefined ? lower :
|
|
188
|
+
match[2] === "" ? Infinity : parseInt(match[2], 10); // allow:raw-byte-literal — base-10 radix
|
|
189
|
+
var ceiling = (upper === Infinity || upper > lower) ? upper : lower;
|
|
190
|
+
if (ceiling > opts.maxBoundedRepeat) {
|
|
191
|
+
issues.push({
|
|
192
|
+
kind: "bounded-repeat-cap",
|
|
193
|
+
severity: opts.boundedRepeatPolicy === "reject" ? "high" : "warn",
|
|
194
|
+
ruleId: "regex.bounded-repeat-cap",
|
|
195
|
+
snippet: "bounded-repeat `" + match[0] + "` upper bound " +
|
|
196
|
+
(ceiling === Infinity ? "unbounded" : ceiling) +
|
|
197
|
+
" exceeds maxBoundedRepeat " + opts.maxBoundedRepeat,
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validate(input, opts) {
|
|
208
|
+
opts = _resolveOpts(opts);
|
|
209
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
210
|
+
["maxBytes", "maxPatternBytes", "maxBoundedRepeat"],
|
|
211
|
+
"guardRegex.validate", GuardRegexError, "regex.bad-opt");
|
|
212
|
+
return gateContract.aggregateIssues(_detectIssues(input, opts));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sanitize(input, opts) {
|
|
216
|
+
opts = _resolveOpts(opts);
|
|
217
|
+
if (typeof input !== "string") {
|
|
218
|
+
throw _err("regex.bad-input", "sanitize requires string input");
|
|
219
|
+
}
|
|
220
|
+
var issues = _detectIssues(input, opts);
|
|
221
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
222
|
+
if (issues[i].severity === "critical" || issues[i].severity === "high") {
|
|
223
|
+
throw _err(issues[i].ruleId || "regex.refused",
|
|
224
|
+
"guardRegex.sanitize: " + issues[i].snippet);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return input;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function gate(opts) {
|
|
231
|
+
opts = _resolveOpts(opts);
|
|
232
|
+
return gateContract.buildGuardGate(
|
|
233
|
+
opts.name || "guardRegex:" + (opts.profile || "default"),
|
|
234
|
+
opts,
|
|
235
|
+
async function (ctx) {
|
|
236
|
+
var pattern = ctx && (ctx.identifier || ctx.pattern);
|
|
237
|
+
if (pattern === undefined || pattern === null) {
|
|
238
|
+
return { ok: true, action: "serve" };
|
|
239
|
+
}
|
|
240
|
+
var rv = validate(pattern, opts);
|
|
241
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
242
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
243
|
+
return i.severity === "critical";
|
|
244
|
+
});
|
|
245
|
+
var hasHigh = rv.issues.some(function (i) {
|
|
246
|
+
return i.severity === "high";
|
|
247
|
+
});
|
|
248
|
+
if (!hasCritical && !hasHigh) {
|
|
249
|
+
return { ok: true, action: "audit-only", issues: rv.issues };
|
|
250
|
+
}
|
|
251
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
256
|
+
|
|
257
|
+
function compliancePosture(name) {
|
|
258
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES,
|
|
259
|
+
_err, "regex");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
var _regexRulePacks = gateContract.makeRulePackLoader(GuardRegexError, "regex");
|
|
263
|
+
var loadRulePack = _regexRulePacks.load;
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
// ---- guard-* family registry exports ----
|
|
267
|
+
NAME: "regex",
|
|
268
|
+
KIND: "identifier",
|
|
269
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
270
|
+
kind: "identifier",
|
|
271
|
+
benignBytes: Buffer.from("^[a-z]+$", "utf8"),
|
|
272
|
+
hostileBytes: Buffer.from("(a+)+b", "utf8"),
|
|
273
|
+
benignIdentifier: "^[a-z]+$",
|
|
274
|
+
hostileIdentifier: "(a+)+b",
|
|
275
|
+
}),
|
|
276
|
+
// ---- primitive surface ----
|
|
277
|
+
validate: validate,
|
|
278
|
+
sanitize: sanitize,
|
|
279
|
+
gate: gate,
|
|
280
|
+
buildProfile: buildProfile,
|
|
281
|
+
compliancePosture: compliancePosture,
|
|
282
|
+
loadRulePack: loadRulePack,
|
|
283
|
+
PROFILES: PROFILES,
|
|
284
|
+
DEFAULTS: DEFAULTS,
|
|
285
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
286
|
+
GuardRegexError: GuardRegexError,
|
|
287
|
+
};
|