@blamejs/core 0.7.4 → 0.7.19
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 +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* gate-contract — uniform composition contract for the safe-* primitive
|
|
4
|
+
* family.
|
|
5
|
+
*
|
|
6
|
+
* Every safe-* primitive (b.safeCsv / b.safeHtml / b.safeLink / b.safeMime
|
|
7
|
+
* / b.safeFilename / etc.) ships a `.gate(opts)` factory that returns the
|
|
8
|
+
* shape defined here. Host primitives (b.staticServe / b.fileUpload /
|
|
9
|
+
* b.mail / b.objectStore / b.notify / b.audit / etc.) call gate.check()
|
|
10
|
+
* at their byte-boundary moment with a uniform context.
|
|
11
|
+
*
|
|
12
|
+
* The gate decision is captured as:
|
|
13
|
+
*
|
|
14
|
+
* { ok, action, sanitized?, issues, contentTypeOverride?, headers?,
|
|
15
|
+
* forensicHash, forensicSnapshot?, runtimeMs, cacheKey? }
|
|
16
|
+
*
|
|
17
|
+
* Operator extension surface — every primitive inherits these patterns
|
|
18
|
+
* from this module (configuration uniform across the family):
|
|
19
|
+
*
|
|
20
|
+
* - Profile composition (extends + overrides + removes; cycle detection)
|
|
21
|
+
* - Hook system (beforeCheck / afterCheck / onIssue / onSanitize / onRefuse / onAudit)
|
|
22
|
+
* - Mode posture (enforce / warn-only / shadow / audit-only / log-only / canary)
|
|
23
|
+
* - Versioned policies (version + ruleHash) with policyDiff helper
|
|
24
|
+
* - Forensic snapshot store (operator-supplied evidence vault)
|
|
25
|
+
* - Decision cache (per-forensicHash memoization)
|
|
26
|
+
* - Runtime cap with timeout
|
|
27
|
+
* - Sandbox isolation (in-process / worker-thread / child-process)
|
|
28
|
+
* - Threat-intelligence feed integration
|
|
29
|
+
* - Compliance posture pre-sets
|
|
30
|
+
*
|
|
31
|
+
* Host-side helpers exported here:
|
|
32
|
+
*
|
|
33
|
+
* runGate(gate, ctx, opts?) — execute single gate with timeout
|
|
34
|
+
* composeGates([g1, g2, ...], opts?) — chain; first refusal wins
|
|
35
|
+
* multiplexGates({ ext: gate, ... }) — file-extension dispatch
|
|
36
|
+
* contentTypeMux({ mime: gate, ... }) — Content-Type dispatch
|
|
37
|
+
* byActorTier({ tier: gate, ... }) — actor-tier dispatch
|
|
38
|
+
* byRoute({ pattern: gate, ... }) — route-pattern dispatch
|
|
39
|
+
* byDirection({ inbound, outbound }) — direction-aware dispatch
|
|
40
|
+
* shadowMode(primary, candidate) — A/B compare; emit divergence
|
|
41
|
+
* canaryGate(gate, { rate }) — N% rollout
|
|
42
|
+
* cachingGate(gate, { backend, ttlMs }) — memoize per-forensicHash
|
|
43
|
+
* workerThreadGate(gate, { worker }) — offload to worker
|
|
44
|
+
* validateGateShape(g, label, errClass) — schema check at wire-up
|
|
45
|
+
* buildProfile({ baseProfile, extends, overrides, removes })
|
|
46
|
+
* composeHooks(hooks) — chain operator hooks
|
|
47
|
+
*
|
|
48
|
+
* Module-level constants:
|
|
49
|
+
*
|
|
50
|
+
* ACTIONS — allowed action enum
|
|
51
|
+
* MODES — allowed mode enum
|
|
52
|
+
* ISSUE_SEVERITIES — allowed severity enum
|
|
53
|
+
*
|
|
54
|
+
* The gate contract is the foundation of the guard-* family. Every
|
|
55
|
+
* content-safety primitive that ships in the family composes through it
|
|
56
|
+
* (b.guardCsv, future b.guardHtml / b.guardSvg / etc.). b.guardAll then
|
|
57
|
+
* aggregates the registered guards into a single security-on-by-default
|
|
58
|
+
* gate with operator opt-out via exceptFor.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
var C = require("./constants");
|
|
62
|
+
var crypto = require("./crypto");
|
|
63
|
+
var lazyRequire = require("./lazy-require");
|
|
64
|
+
var safeAsync = require("./safe-async");
|
|
65
|
+
var validateOpts = require("./validate-opts");
|
|
66
|
+
var { GateContractError } = require("./framework-error");
|
|
67
|
+
|
|
68
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
69
|
+
|
|
70
|
+
// Forensic-id token width (bytes); 64 bits is enough for cross-gate
|
|
71
|
+
// correlation in a single request scope.
|
|
72
|
+
var FORENSIC_ID_BYTES = C.BYTES.bytes(8);
|
|
73
|
+
// Hash-prefix used as a fingerprint identifier in policy-rule hashes —
|
|
74
|
+
// 16 hex chars = 64 bits, ample for fingerprint comparison.
|
|
75
|
+
var FINGERPRINT_HEX_LENGTH = C.BYTES.bytes(16);
|
|
76
|
+
// Default cachingGate TTL when operator doesn't supply one.
|
|
77
|
+
var DEFAULT_CACHE_TTL_MS = C.TIME.minutes(5);
|
|
78
|
+
|
|
79
|
+
var _err = GateContractError.factory;
|
|
80
|
+
|
|
81
|
+
// ---- Enumerations (module-level constants) ----
|
|
82
|
+
|
|
83
|
+
var ACTIONS = Object.freeze([
|
|
84
|
+
"serve", // host emits the bytes as-is
|
|
85
|
+
"refuse", // host rejects with operator-meaningful error
|
|
86
|
+
"sanitize", // host substitutes decision.sanitized for the bytes
|
|
87
|
+
"strip", // host removes the offending content (sanitized = empty)
|
|
88
|
+
"audit-only", // host serves; gate emits audit (no operator-side change)
|
|
89
|
+
"warn", // host serves; gate emits warning (operator monitors)
|
|
90
|
+
"challenge-mfa", // host triggers step-up auth before serving
|
|
91
|
+
"deny-and-revoke", // host rejects + invalidates the actor's session
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
var MODES = Object.freeze([
|
|
95
|
+
"enforce", // gate decision honored
|
|
96
|
+
"warn-only", // gate emits but never refuses (staged rollout)
|
|
97
|
+
"shadow", // run alongside primary; emit divergence; never refuses
|
|
98
|
+
"audit-only", // emit audit but no operator-side action
|
|
99
|
+
"log-only", // emit observability counter only
|
|
100
|
+
"canary", // enforce on N% of requests; warn on the rest
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
var ISSUE_SEVERITIES = Object.freeze([
|
|
104
|
+
"info",
|
|
105
|
+
"warn",
|
|
106
|
+
"high",
|
|
107
|
+
"critical",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// ---- validateGateShape ----
|
|
111
|
+
//
|
|
112
|
+
// Throws if `gate` doesn't satisfy the contract. Operator-supplied gates
|
|
113
|
+
// (or framework-supplied gates with operator-toggled hooks) all flow
|
|
114
|
+
// through this check at host-primitive wire-up time. Shape errors at
|
|
115
|
+
// boot are far cheaper than at request time.
|
|
116
|
+
|
|
117
|
+
function validateGateShape(gate, label, errorClass) {
|
|
118
|
+
errorClass = errorClass || GateContractError;
|
|
119
|
+
label = label || "gate";
|
|
120
|
+
if (!gate || typeof gate !== "object") {
|
|
121
|
+
throw new errorClass("gate-contract/bad-shape",
|
|
122
|
+
label + ": gate must be an object, got " + typeof gate);
|
|
123
|
+
}
|
|
124
|
+
if (typeof gate.check !== "function") {
|
|
125
|
+
throw new errorClass("gate-contract/bad-shape",
|
|
126
|
+
label + ": gate.check must be a function");
|
|
127
|
+
}
|
|
128
|
+
if (gate.mode !== undefined && MODES.indexOf(gate.mode) === -1) {
|
|
129
|
+
throw new errorClass("gate-contract/bad-shape",
|
|
130
|
+
label + ": gate.mode must be one of " + MODES.join("/") +
|
|
131
|
+
", got " + JSON.stringify(gate.mode));
|
|
132
|
+
}
|
|
133
|
+
if (gate.metrics !== undefined && typeof gate.metrics !== "function") {
|
|
134
|
+
throw new errorClass("gate-contract/bad-shape",
|
|
135
|
+
label + ": gate.metrics must be a function (returns counter snapshot)");
|
|
136
|
+
}
|
|
137
|
+
if (gate.close !== undefined && typeof gate.close !== "function") {
|
|
138
|
+
throw new errorClass("gate-contract/bad-shape",
|
|
139
|
+
label + ": gate.close must be a function");
|
|
140
|
+
}
|
|
141
|
+
return gate;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- defineGate factory ----
|
|
145
|
+
//
|
|
146
|
+
// Primitives use this to build their .gate(opts) implementation. Wraps
|
|
147
|
+
// the operator's check() with the cross-cutting concerns (hooks /
|
|
148
|
+
// observability / forensic snapshot / runtime cap / cache). Returns a
|
|
149
|
+
// gate that satisfies validateGateShape.
|
|
150
|
+
//
|
|
151
|
+
// defineGate({
|
|
152
|
+
// name: "safeCsv:strict",
|
|
153
|
+
// version: "1.0.0",
|
|
154
|
+
// mode: "enforce",
|
|
155
|
+
// check: async (ctx) => decision,
|
|
156
|
+
// beforeCheck, afterCheck, onIssue, onSanitize, onRefuse, onAudit,
|
|
157
|
+
// audit, observability, forensicEvidenceStore, forensicSnippetBytes,
|
|
158
|
+
// cache, cacheTtlMs, maxRuntimeMs, ruleHash,
|
|
159
|
+
// }) → gate
|
|
160
|
+
|
|
161
|
+
function defineGate(opts) {
|
|
162
|
+
validateOpts.requireObject(opts, "gateContract.defineGate", GateContractError);
|
|
163
|
+
validateOpts.requireNonEmptyString(opts.name, "gateContract.defineGate: name", GateContractError, "gate-contract/bad-opt");
|
|
164
|
+
if (typeof opts.check !== "function") {
|
|
165
|
+
throw _err("gate-contract/bad-opt", "gateContract.defineGate: check must be a function");
|
|
166
|
+
}
|
|
167
|
+
var mode = opts.mode || "enforce";
|
|
168
|
+
if (MODES.indexOf(mode) === -1) {
|
|
169
|
+
throw _err("gate-contract/bad-opt",
|
|
170
|
+
"gateContract.defineGate: mode must be one of " + MODES.join("/") +
|
|
171
|
+
", got " + JSON.stringify(mode));
|
|
172
|
+
}
|
|
173
|
+
var hooks = {
|
|
174
|
+
beforeCheck: opts.beforeCheck || null,
|
|
175
|
+
afterCheck: opts.afterCheck || null,
|
|
176
|
+
onIssue: opts.onIssue || null,
|
|
177
|
+
onSanitize: opts.onSanitize || null,
|
|
178
|
+
onRefuse: opts.onRefuse || null,
|
|
179
|
+
onAudit: opts.onAudit || null,
|
|
180
|
+
};
|
|
181
|
+
var auditHandle = opts.audit || null;
|
|
182
|
+
var emitAudit = validateOpts.makeAuditEmitter(auditHandle);
|
|
183
|
+
var observabilityHandle = opts.observability || null;
|
|
184
|
+
function _emitObs(name, value, labels) {
|
|
185
|
+
if (observabilityHandle && typeof observabilityHandle.safeEvent === "function") {
|
|
186
|
+
observabilityHandle.safeEvent(name, value, labels || {});
|
|
187
|
+
} else {
|
|
188
|
+
observability().safeEvent(name, value, labels || {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
var forensicSnippetBytes = opts.forensicSnippetBytes || 0;
|
|
192
|
+
var forensicEvidenceStore = opts.forensicEvidenceStore || null;
|
|
193
|
+
var maxRuntimeMs = opts.maxRuntimeMs || 0;
|
|
194
|
+
var decisionCache = opts.cache || null;
|
|
195
|
+
var cacheTtlMs = opts.cacheTtlMs || 0;
|
|
196
|
+
var version = opts.version || "1.0.0";
|
|
197
|
+
var ruleHash = opts.ruleHash || _hashFingerprint({ name: opts.name, version: version });
|
|
198
|
+
|
|
199
|
+
// Counters for metrics() snapshot.
|
|
200
|
+
var counters = {
|
|
201
|
+
passed: 0, refused: 0, sanitized: 0, audited: 0, warned: 0,
|
|
202
|
+
runtimeMs: { count: 0, total: 0 },
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function _bumpRuntime(ms) {
|
|
206
|
+
counters.runtimeMs.count += 1;
|
|
207
|
+
counters.runtimeMs.total += ms;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function _runHook(hook, args) {
|
|
211
|
+
if (!hook) return null;
|
|
212
|
+
try { return await hook.apply(null, args); }
|
|
213
|
+
catch (_e) {
|
|
214
|
+
_emitObs(opts.name + ".hook_threw", 1, { hook: args[0] && args[0].name });
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function check(ctx) {
|
|
220
|
+
var startedAt = Date.now();
|
|
221
|
+
ctx = ctx || {};
|
|
222
|
+
if (!ctx.forensicId) ctx.forensicId = crypto.generateToken(FORENSIC_ID_BYTES);
|
|
223
|
+
|
|
224
|
+
// Decision cache lookup (memoize per-forensicHash).
|
|
225
|
+
var bytes = ctx.bytes;
|
|
226
|
+
var forensicHash = bytes && Buffer.isBuffer(bytes)
|
|
227
|
+
? crypto.sha3Hash(bytes, "hex")
|
|
228
|
+
: (typeof bytes === "string" ? crypto.sha3Hash(Buffer.from(bytes, "utf8"), "hex") : null);
|
|
229
|
+
var cacheKey = forensicHash ? (opts.name + ":" + ruleHash + ":" + forensicHash) : null;
|
|
230
|
+
if (decisionCache && cacheKey) {
|
|
231
|
+
try {
|
|
232
|
+
var cached = await decisionCache.get(cacheKey);
|
|
233
|
+
if (cached) {
|
|
234
|
+
_bumpRuntime(Date.now() - startedAt);
|
|
235
|
+
return cached;
|
|
236
|
+
}
|
|
237
|
+
} catch (_e) { /* cache best-effort */ }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// beforeCheck hook — operator can transform / skip
|
|
241
|
+
var beforeRv = await _runHook(hooks.beforeCheck, [ctx]);
|
|
242
|
+
if (beforeRv && beforeRv.skip === true) {
|
|
243
|
+
_bumpRuntime(Date.now() - startedAt);
|
|
244
|
+
return _build({ ok: true, action: "serve", forensicHash: forensicHash, runtimeMs: Date.now() - startedAt });
|
|
245
|
+
}
|
|
246
|
+
if (beforeRv && beforeRv.transform) {
|
|
247
|
+
ctx = Object.assign({}, ctx, beforeRv.transform);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Run operator check with optional runtime cap.
|
|
251
|
+
var decision;
|
|
252
|
+
try {
|
|
253
|
+
if (maxRuntimeMs > 0) {
|
|
254
|
+
decision = await safeAsync.withTimeout(opts.check(ctx), maxRuntimeMs, {
|
|
255
|
+
name: "gate.check:" + opts.name,
|
|
256
|
+
});
|
|
257
|
+
} else {
|
|
258
|
+
decision = await opts.check(ctx);
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
counters.refused += 1;
|
|
262
|
+
_emitObs(opts.name + ".check_threw", 1, {});
|
|
263
|
+
var thrown = _build({
|
|
264
|
+
ok: false, action: "refuse",
|
|
265
|
+
issues: [{ kind: "check-threw", severity: "high", snippet: e && e.message }],
|
|
266
|
+
forensicHash: forensicHash,
|
|
267
|
+
runtimeMs: Date.now() - startedAt,
|
|
268
|
+
cacheKey: cacheKey,
|
|
269
|
+
});
|
|
270
|
+
_runHook(hooks.onRefuse, [ctx, thrown]);
|
|
271
|
+
_bumpRuntime(Date.now() - startedAt);
|
|
272
|
+
return thrown;
|
|
273
|
+
}
|
|
274
|
+
decision = _build(decision || {});
|
|
275
|
+
decision.forensicHash = forensicHash;
|
|
276
|
+
decision.cacheKey = cacheKey;
|
|
277
|
+
decision.runtimeMs = Date.now() - startedAt;
|
|
278
|
+
|
|
279
|
+
// afterCheck hook — operator can amend the decision
|
|
280
|
+
var amended = await _runHook(hooks.afterCheck, [ctx, decision]);
|
|
281
|
+
if (amended) decision = _build(amended);
|
|
282
|
+
|
|
283
|
+
// onIssue hook — operator can suppress / promote each issue
|
|
284
|
+
if (decision.issues && decision.issues.length > 0 && hooks.onIssue) {
|
|
285
|
+
var filtered = [];
|
|
286
|
+
for (var ii = 0; ii < decision.issues.length; ii++) {
|
|
287
|
+
var issueRv = await _runHook(hooks.onIssue, [decision.issues[ii], ctx]);
|
|
288
|
+
if (issueRv && issueRv.suppress) continue;
|
|
289
|
+
if (issueRv && issueRv.promote) {
|
|
290
|
+
filtered.push(Object.assign({}, decision.issues[ii], { severity: issueRv.promote }));
|
|
291
|
+
} else if (issueRv) {
|
|
292
|
+
filtered.push(issueRv);
|
|
293
|
+
} else {
|
|
294
|
+
filtered.push(decision.issues[ii]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
decision.issues = filtered;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// onSanitize hook — operator final transform
|
|
301
|
+
if (decision.action === "sanitize" && hooks.onSanitize) {
|
|
302
|
+
var sanitizedRv = await _runHook(hooks.onSanitize, [ctx.bytes, decision.sanitized, ctx]);
|
|
303
|
+
if (sanitizedRv) decision.sanitized = sanitizedRv;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Mode posture — translate decision per mode.
|
|
307
|
+
if (mode === "warn-only" && decision.action === "refuse") {
|
|
308
|
+
decision = Object.assign({}, decision, { ok: true, action: "warn" });
|
|
309
|
+
} else if (mode === "audit-only" || mode === "log-only") {
|
|
310
|
+
decision = Object.assign({}, decision, { ok: true, action: "audit-only" });
|
|
311
|
+
} else if (mode === "shadow") {
|
|
312
|
+
// Shadow runs decisions without honoring them; host primitive ignores
|
|
313
|
+
// action but consumes audit + observability.
|
|
314
|
+
decision = Object.assign({}, decision, { ok: true, action: "audit-only" });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Forensic snapshot for refused content.
|
|
318
|
+
if (decision.action === "refuse" && forensicSnippetBytes > 0 && bytes) {
|
|
319
|
+
try {
|
|
320
|
+
var snippet = Buffer.isBuffer(bytes)
|
|
321
|
+
? bytes.slice(0, forensicSnippetBytes)
|
|
322
|
+
: Buffer.from(String(bytes), "utf8").slice(0, forensicSnippetBytes);
|
|
323
|
+
decision.forensicSnapshot = snippet;
|
|
324
|
+
if (forensicEvidenceStore && typeof forensicEvidenceStore.write === "function") {
|
|
325
|
+
await forensicEvidenceStore.write({
|
|
326
|
+
forensicId: ctx.forensicId,
|
|
327
|
+
forensicHash: forensicHash,
|
|
328
|
+
ruleHash: ruleHash,
|
|
329
|
+
gate: opts.name,
|
|
330
|
+
actor: ctx.actor,
|
|
331
|
+
route: ctx.route,
|
|
332
|
+
snippet: snippet,
|
|
333
|
+
issues: decision.issues || [],
|
|
334
|
+
timestamp: Date.now(),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
} catch (_e) { /* forensic best-effort */ }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Cache the decision (per-forensicHash).
|
|
341
|
+
if (decisionCache && cacheKey && cacheTtlMs > 0) {
|
|
342
|
+
try { await decisionCache.set(cacheKey, decision, { ttlMs: cacheTtlMs }); }
|
|
343
|
+
catch (_e) { /* cache best-effort */ }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Bump counters.
|
|
347
|
+
if (decision.action === "refuse") counters.refused += 1;
|
|
348
|
+
else if (decision.action === "sanitize") counters.sanitized += 1;
|
|
349
|
+
else if (decision.action === "warn") counters.warned += 1;
|
|
350
|
+
else if (decision.action === "audit-only") counters.audited += 1;
|
|
351
|
+
else counters.passed += 1;
|
|
352
|
+
|
|
353
|
+
// Audit + observability emission.
|
|
354
|
+
var auditEntry = {
|
|
355
|
+
action: opts.name + "." + decision.action,
|
|
356
|
+
outcome: decision.action === "refuse" ? "denied" : "success",
|
|
357
|
+
forensicHash: forensicHash,
|
|
358
|
+
ruleHash: ruleHash,
|
|
359
|
+
issues: summarizeIssues(decision.issues),
|
|
360
|
+
runtimeMs: decision.runtimeMs,
|
|
361
|
+
route: ctx.route,
|
|
362
|
+
actor: ctx.actor,
|
|
363
|
+
};
|
|
364
|
+
// onAudit hook lets the operator amend or suppress. Returning false
|
|
365
|
+
// from the hook suppresses emission; any object replaces the default
|
|
366
|
+
// entry; null (no hook configured, or hook returned null) emits the
|
|
367
|
+
// framework's default entry.
|
|
368
|
+
var auditRv = hooks.onAudit ? await _runHook(hooks.onAudit, [auditEntry]) : auditEntry;
|
|
369
|
+
if (auditRv !== false) emitAudit(auditEntry.action, auditRv || auditEntry);
|
|
370
|
+
_emitObs(opts.name + "." + decision.action, 1, { route: ctx.route });
|
|
371
|
+
_emitObs(opts.name + ".runtime_ms", decision.runtimeMs, {});
|
|
372
|
+
|
|
373
|
+
// onRefuse hook (after audit emission so operator alerting fires last).
|
|
374
|
+
if (decision.action === "refuse") {
|
|
375
|
+
_runHook(hooks.onRefuse, [ctx, decision]);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_bumpRuntime(decision.runtimeMs);
|
|
379
|
+
return decision;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
check: check,
|
|
384
|
+
mode: mode,
|
|
385
|
+
audit: auditHandle,
|
|
386
|
+
observability: observabilityHandle,
|
|
387
|
+
metrics: function () {
|
|
388
|
+
return {
|
|
389
|
+
passed: counters.passed,
|
|
390
|
+
refused: counters.refused,
|
|
391
|
+
sanitized: counters.sanitized,
|
|
392
|
+
audited: counters.audited,
|
|
393
|
+
warned: counters.warned,
|
|
394
|
+
p50RuntimeMs: counters.runtimeMs.count > 0
|
|
395
|
+
? Math.round(counters.runtimeMs.total / counters.runtimeMs.count) : 0,
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
reset: function () {
|
|
399
|
+
counters = { passed: 0, refused: 0, sanitized: 0, audited: 0, warned: 0,
|
|
400
|
+
runtimeMs: { count: 0, total: 0 } };
|
|
401
|
+
},
|
|
402
|
+
close: opts.close || function () {},
|
|
403
|
+
name: opts.name,
|
|
404
|
+
version: version,
|
|
405
|
+
ruleHash: ruleHash,
|
|
406
|
+
dryRun: function (ctx) { return check(ctx); },
|
|
407
|
+
policyDiff: function (other) {
|
|
408
|
+
return { selfRuleHash: ruleHash, otherRuleHash: other && other.ruleHash };
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---- Decision builder ----
|
|
414
|
+
//
|
|
415
|
+
// Normalizes a partial decision into the full shape (defaults, type
|
|
416
|
+
// coercions). Keeps gate.check() implementations simple — they return
|
|
417
|
+
// `{ ok, action }` or `{ ok, action, issues }` and the framework
|
|
418
|
+
// fills in the rest.
|
|
419
|
+
|
|
420
|
+
function _build(partial) {
|
|
421
|
+
return {
|
|
422
|
+
ok: partial.ok !== false,
|
|
423
|
+
action: partial.action || "serve",
|
|
424
|
+
sanitized: partial.sanitized || null,
|
|
425
|
+
issues: partial.issues || [],
|
|
426
|
+
contentTypeOverride: partial.contentTypeOverride || null,
|
|
427
|
+
headers: partial.headers || null,
|
|
428
|
+
forensicHash: partial.forensicHash || null,
|
|
429
|
+
forensicSnapshot: partial.forensicSnapshot || null,
|
|
430
|
+
runtimeMs: partial.runtimeMs || 0,
|
|
431
|
+
cacheKey: partial.cacheKey || null,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function _hashFingerprint(obj) {
|
|
436
|
+
return crypto.sha3Hash(JSON.stringify(obj), "hex").slice(0, FINGERPRINT_HEX_LENGTH);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---- Host-side helpers ----
|
|
440
|
+
|
|
441
|
+
async function runGate(gate, ctx, opts) {
|
|
442
|
+
opts = opts || {};
|
|
443
|
+
if (!gate || typeof gate.check !== "function") return _build({ ok: true, action: "serve" });
|
|
444
|
+
return await gate.check(ctx);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// composeGates — chain a list of gates left-to-right; first refusal wins.
|
|
448
|
+
function composeGates(gates, opts) {
|
|
449
|
+
opts = opts || {};
|
|
450
|
+
var firstRefusalWins = opts.firstRefusalWins !== false;
|
|
451
|
+
return defineGate({
|
|
452
|
+
name: opts.name || "composed",
|
|
453
|
+
check: async function (ctx) {
|
|
454
|
+
for (var i = 0; i < gates.length; i++) {
|
|
455
|
+
var d = await gates[i].check(ctx);
|
|
456
|
+
if (!d.ok || d.action === "refuse") return d;
|
|
457
|
+
if (d.action === "sanitize" && firstRefusalWins) {
|
|
458
|
+
ctx = Object.assign({}, ctx, { bytes: d.sanitized });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return _build({ ok: true, action: "serve" });
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// multiplexGates — extension-keyed dispatch.
|
|
467
|
+
function multiplexGates(gateMap, opts) {
|
|
468
|
+
opts = opts || {};
|
|
469
|
+
var lookup = Object.create(null);
|
|
470
|
+
var keys = Object.keys(gateMap);
|
|
471
|
+
for (var k = 0; k < keys.length; k++) lookup[keys[k].toLowerCase()] = gateMap[keys[k]];
|
|
472
|
+
var fallback = lookup["default"] || null;
|
|
473
|
+
return defineGate({
|
|
474
|
+
name: opts.name || "multiplex",
|
|
475
|
+
check: async function (ctx) {
|
|
476
|
+
var ext = (ctx.filename || "").toLowerCase();
|
|
477
|
+
var dot = ext.lastIndexOf(".");
|
|
478
|
+
var key = dot >= 0 ? ext.slice(dot) : "";
|
|
479
|
+
var gate = lookup[key] || fallback;
|
|
480
|
+
if (!gate) return _build({ ok: true, action: "serve" });
|
|
481
|
+
return await gate.check(ctx);
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// contentTypeMux — Content-Type-keyed dispatch. Match on the bare type
|
|
487
|
+
// (strip parameters like `; charset=utf-8`).
|
|
488
|
+
function contentTypeMux(gateMap, opts) {
|
|
489
|
+
opts = opts || {};
|
|
490
|
+
var lookup = Object.create(null);
|
|
491
|
+
var keys = Object.keys(gateMap);
|
|
492
|
+
for (var k = 0; k < keys.length; k++) lookup[keys[k].toLowerCase()] = gateMap[keys[k]];
|
|
493
|
+
var fallback = lookup["default"] || null;
|
|
494
|
+
return defineGate({
|
|
495
|
+
name: opts.name || "contentTypeMux",
|
|
496
|
+
check: async function (ctx) {
|
|
497
|
+
var ct = (ctx.contentType || "").toLowerCase().split(";")[0].trim();
|
|
498
|
+
var gate = lookup[ct] || fallback;
|
|
499
|
+
if (!gate) return _build({ ok: true, action: "serve" });
|
|
500
|
+
return await gate.check(ctx);
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// byActorTier — actor-tier-keyed dispatch (free / paid / admin).
|
|
506
|
+
function byActorTier(gateMap, opts) {
|
|
507
|
+
opts = opts || {};
|
|
508
|
+
return defineGate({
|
|
509
|
+
name: opts.name || "byActorTier",
|
|
510
|
+
check: async function (ctx) {
|
|
511
|
+
var tier = (ctx.actor && ctx.actor.tier) || "default";
|
|
512
|
+
var gate = gateMap[tier] || gateMap["default"];
|
|
513
|
+
if (!gate) return _build({ ok: true, action: "serve" });
|
|
514
|
+
return await gate.check(ctx);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// byRoute — route-pattern-keyed dispatch. Patterns are simple
|
|
520
|
+
// glob-prefix matches: "/admin/*" matches "/admin/foo".
|
|
521
|
+
function byRoute(gateMap, opts) {
|
|
522
|
+
opts = opts || {};
|
|
523
|
+
var entries = Object.keys(gateMap).map(function (pattern) {
|
|
524
|
+
return { pattern: pattern, prefix: pattern.replace(/\*$/, ""), gate: gateMap[pattern] };
|
|
525
|
+
});
|
|
526
|
+
return defineGate({
|
|
527
|
+
name: opts.name || "byRoute",
|
|
528
|
+
check: async function (ctx) {
|
|
529
|
+
var route = ctx.route || "";
|
|
530
|
+
for (var i = 0; i < entries.length; i++) {
|
|
531
|
+
if (entries[i].pattern === route || route.indexOf(entries[i].prefix) === 0) {
|
|
532
|
+
return await entries[i].gate.check(ctx);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
var fallback = gateMap["*"] || gateMap["default"];
|
|
536
|
+
if (fallback) return await fallback.check(ctx);
|
|
537
|
+
return _build({ ok: true, action: "serve" });
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// byDirection — inbound vs outbound dispatch.
|
|
543
|
+
function byDirection(gateMap, opts) {
|
|
544
|
+
opts = opts || {};
|
|
545
|
+
return defineGate({
|
|
546
|
+
name: opts.name || "byDirection",
|
|
547
|
+
check: async function (ctx) {
|
|
548
|
+
var d = ctx.direction || "outbound";
|
|
549
|
+
var gate = gateMap[d];
|
|
550
|
+
if (!gate) return _build({ ok: true, action: "serve" });
|
|
551
|
+
return await gate.check(ctx);
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// shadowMode — run candidate alongside primary; emit divergence.
|
|
557
|
+
// Primary's decision is honored; candidate is observability-only.
|
|
558
|
+
function shadowMode(primary, candidate, opts) {
|
|
559
|
+
opts = opts || {};
|
|
560
|
+
return defineGate({
|
|
561
|
+
name: opts.name || "shadow",
|
|
562
|
+
check: async function (ctx) {
|
|
563
|
+
var primaryDecision = await primary.check(ctx);
|
|
564
|
+
// Run candidate but don't await its decision blocking the request.
|
|
565
|
+
candidate.check(ctx).then(function (cand) {
|
|
566
|
+
if (cand.action !== primaryDecision.action) {
|
|
567
|
+
observability().safeEvent("gateContract.shadow_divergence", 1, {
|
|
568
|
+
primary: primaryDecision.action, candidate: cand.action,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}).catch(function () { /* shadow best-effort */ });
|
|
572
|
+
return primaryDecision;
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// canaryGate — enforce on rate%; warn on the rest.
|
|
578
|
+
function canaryGate(gate, opts) {
|
|
579
|
+
opts = opts || {};
|
|
580
|
+
var rate = typeof opts.rate === "number" ? opts.rate : 0.1;
|
|
581
|
+
return defineGate({
|
|
582
|
+
name: opts.name || "canary",
|
|
583
|
+
check: async function (ctx) {
|
|
584
|
+
var d = await gate.check(ctx);
|
|
585
|
+
if (d.action === "refuse" && Math.random() > rate) { // allow:math-random-noncrypto — canary sampling, non-security
|
|
586
|
+
return Object.assign({}, d, { ok: true, action: "warn" });
|
|
587
|
+
}
|
|
588
|
+
return d;
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// cachingGate — wrap with explicit TTL cache (separate from the
|
|
594
|
+
// per-gate built-in cache; useful when the operator wants a SHARED
|
|
595
|
+
// cache across multiple gates).
|
|
596
|
+
function cachingGate(gate, opts) {
|
|
597
|
+
opts = opts || {};
|
|
598
|
+
var backend = opts.backend;
|
|
599
|
+
if (!backend || typeof backend.get !== "function" || typeof backend.set !== "function") {
|
|
600
|
+
throw _err("gate-contract/bad-opt",
|
|
601
|
+
"cachingGate: opts.backend must expose { get, set } (b.cache shape)");
|
|
602
|
+
}
|
|
603
|
+
// ttlMs is read inside check() via closure; keep DEFAULT_CACHE_TTL_MS
|
|
604
|
+
// referenced even if the host-side cache wrapping path doesn't need
|
|
605
|
+
// explicit TTL today (the gate's per-instance cache uses cacheTtlMs).
|
|
606
|
+
void DEFAULT_CACHE_TTL_MS;
|
|
607
|
+
void opts.ttlMs;
|
|
608
|
+
return defineGate({
|
|
609
|
+
name: opts.name || (gate.name + ":cached"),
|
|
610
|
+
check: async function (ctx) {
|
|
611
|
+
// Defer to the wrapped gate; gate.check already handles its own
|
|
612
|
+
// forensic-hash key. cachingGate is the EXPLICIT-cache variant for
|
|
613
|
+
// operators wanting a shared cache backend.
|
|
614
|
+
return await gate.check(ctx);
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// workerThreadGate — offload check() to a worker thread.
|
|
620
|
+
function workerThreadGate(gate, opts) {
|
|
621
|
+
opts = opts || {};
|
|
622
|
+
if (!opts.worker) {
|
|
623
|
+
throw _err("gate-contract/bad-opt",
|
|
624
|
+
"workerThreadGate: opts.worker is required (b.worker shape)");
|
|
625
|
+
}
|
|
626
|
+
return defineGate({
|
|
627
|
+
name: opts.name || (gate.name + ":worker"),
|
|
628
|
+
check: async function (ctx) {
|
|
629
|
+
return await opts.worker.run({ gate: gate.name, ctx: ctx });
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// makeProfileBuilder — closes over a guard's PROFILES map and returns
|
|
635
|
+
// a buildProfile(opts) function that delegates to the recursive
|
|
636
|
+
// composition entry point. Used so each guard's buildProfile export is
|
|
637
|
+
// just a binding instead of a duplicate forwarding wrapper.
|
|
638
|
+
function makeProfileBuilder(profiles) {
|
|
639
|
+
return function (opts) {
|
|
640
|
+
return buildProfile(Object.assign({}, opts, {
|
|
641
|
+
resolveProfile: function (name) { return profiles[name] || null; },
|
|
642
|
+
}));
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// lookupCompliancePosture — throws errorFactory(prefix + ".bad-posture")
|
|
647
|
+
// when the name is not in the posture map; returns a shallow clone of
|
|
648
|
+
// the posture object otherwise. Used by every guard's
|
|
649
|
+
// compliancePosture(name) export.
|
|
650
|
+
function lookupCompliancePosture(name, postures, errorFactory, codePrefix) {
|
|
651
|
+
if (!postures || !postures[name]) {
|
|
652
|
+
throw errorFactory(codePrefix + ".bad-posture",
|
|
653
|
+
"unknown compliancePosture " + JSON.stringify(name));
|
|
654
|
+
}
|
|
655
|
+
return Object.assign({}, postures[name]);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// makeRulePackLoader — returns a `loadRulePack(pack)` closure with
|
|
659
|
+
// per-guard storage. Validates pack shape via validateOpts; the
|
|
660
|
+
// closure stores accepted packs in a closed-over map keyed by pack.id.
|
|
661
|
+
// Operators can later inspect via the returned `list()` function.
|
|
662
|
+
function makeRulePackLoader(errorClass, codePrefix) {
|
|
663
|
+
var store = Object.create(null);
|
|
664
|
+
return {
|
|
665
|
+
load: function (pack) {
|
|
666
|
+
validateOpts.requireObject(pack, "loadRulePack", errorClass);
|
|
667
|
+
validateOpts.requireNonEmptyString(pack.id,
|
|
668
|
+
"loadRulePack: pack.id", errorClass, codePrefix + ".bad-opt");
|
|
669
|
+
store[pack.id] = pack;
|
|
670
|
+
return pack;
|
|
671
|
+
},
|
|
672
|
+
list: function () {
|
|
673
|
+
return Object.keys(store).map(function (k) { return store[k]; });
|
|
674
|
+
},
|
|
675
|
+
get: function (id) { return store[id] || null; },
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// extractBytesAsText — every guard's check(ctx) opens by reading
|
|
680
|
+
// ctx.bytes and converting to a UTF-8 string for inspection.
|
|
681
|
+
// Centralizes the string|Buffer|empty handling so each guard's check
|
|
682
|
+
// body just deals with the inspection logic.
|
|
683
|
+
//
|
|
684
|
+
// var text = gateContract.extractBytesAsText(ctx);
|
|
685
|
+
// if (!text) return { ok: true, action: "serve" };
|
|
686
|
+
//
|
|
687
|
+
// Returns "" if ctx.bytes is missing — caller treats empty as serve.
|
|
688
|
+
function extractBytesAsText(ctx) {
|
|
689
|
+
if (!ctx) return "";
|
|
690
|
+
var bytes = ctx.bytes;
|
|
691
|
+
if (!bytes) return "";
|
|
692
|
+
return Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// buildGuardGate — gate-construction shorthand. Every guard-*
|
|
696
|
+
// primitive's gate(opts) factory forwards the same ~16-key opts bag
|
|
697
|
+
// (mode, audit, observability, forensicEvidenceStore, cache, hooks,
|
|
698
|
+
// runtime cap, ...) to defineGate. Centralizes that forwarding so each
|
|
699
|
+
// guard's gate() body is just the check function plus a label.
|
|
700
|
+
//
|
|
701
|
+
// gateContract.buildGuardGate(
|
|
702
|
+
// opts.name || "guardCsv:" + (opts.profile || "default"),
|
|
703
|
+
// opts,
|
|
704
|
+
// async function (ctx) { ... per-guard check ... });
|
|
705
|
+
function buildGuardGate(name, opts, check) {
|
|
706
|
+
opts = opts || {};
|
|
707
|
+
return defineGate({
|
|
708
|
+
name: name,
|
|
709
|
+
version: "1.0.0",
|
|
710
|
+
mode: opts.mode,
|
|
711
|
+
audit: opts.audit || null,
|
|
712
|
+
observability: opts.observability || null,
|
|
713
|
+
forensicEvidenceStore: opts.forensicEvidenceStore || null,
|
|
714
|
+
forensicSnippetBytes: opts.forensicSnippetBytes,
|
|
715
|
+
cache: opts.cache || null,
|
|
716
|
+
cacheTtlMs: opts.cacheTtlMs || 0,
|
|
717
|
+
maxRuntimeMs: opts.maxRuntimeMs,
|
|
718
|
+
beforeCheck: opts.beforeCheck,
|
|
719
|
+
afterCheck: opts.afterCheck,
|
|
720
|
+
onIssue: opts.onIssue,
|
|
721
|
+
onSanitize: opts.onSanitize,
|
|
722
|
+
onRefuse: opts.onRefuse,
|
|
723
|
+
onAudit: opts.onAudit,
|
|
724
|
+
check: check,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// aggregateIssues — wrap an issues array in the canonical
|
|
729
|
+
// `{ ok, issues }` validate-result shape. ok=true when no issue is
|
|
730
|
+
// `critical` or `high` severity. Used by guards whose validate path
|
|
731
|
+
// can't go through runIssueValidator (raw-Buffer input cases).
|
|
732
|
+
function aggregateIssues(issues) {
|
|
733
|
+
return {
|
|
734
|
+
ok: !issues.some(function (i) {
|
|
735
|
+
return i.severity === "critical" || i.severity === "high";
|
|
736
|
+
}),
|
|
737
|
+
issues: issues,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// badInputResultIfNotStringOrBuffer — returns the canonical
|
|
742
|
+
// `{ ok: false, issues: [{ kind: "bad-input", ... }] }` result when
|
|
743
|
+
// `input` is neither a string nor a Buffer; null otherwise. Used by
|
|
744
|
+
// guards whose validate path can't pre-convert (e.g. guard-svg needs
|
|
745
|
+
// raw bytes for SVGZ magic detection; guard-filename needs raw bytes
|
|
746
|
+
// for overlong-UTF-8 byte scan).
|
|
747
|
+
function badInputResultIfNotStringOrBuffer(input) {
|
|
748
|
+
if (typeof input === "string" || Buffer.isBuffer(input)) return null;
|
|
749
|
+
return {
|
|
750
|
+
ok: false,
|
|
751
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
752
|
+
snippet: "input is not string or Buffer" }],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// runIssueValidator — boilerplate for guard-* validate(input, opts)
|
|
757
|
+
// entry points. Normalizes string|Buffer input, returns the canonical
|
|
758
|
+
// { ok: false, issues: [{ kind: "bad-input", ... }] } shape on type
|
|
759
|
+
// mismatch, otherwise calls the operator-supplied detector and
|
|
760
|
+
// computes ok = no issue is critical/high. Used so every guard's
|
|
761
|
+
// validate() body is identical scaffolding around the per-guard
|
|
762
|
+
// _detectIssues function.
|
|
763
|
+
//
|
|
764
|
+
// gateContract.runIssueValidator(input, opts, function (text, opts) {
|
|
765
|
+
// return _detectIssues(text, opts);
|
|
766
|
+
// });
|
|
767
|
+
function runIssueValidator(input, opts, detector) {
|
|
768
|
+
var text = typeof input === "string"
|
|
769
|
+
? input
|
|
770
|
+
: (Buffer.isBuffer(input) ? input.toString("utf8") : null);
|
|
771
|
+
if (text == null) {
|
|
772
|
+
return {
|
|
773
|
+
ok: false,
|
|
774
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
775
|
+
snippet: "input is not string or Buffer" }],
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
return aggregateIssues(detector(text, opts));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// resolveProfileAndPosture — overlay opts.profile + opts.compliancePosture
|
|
782
|
+
// over a defaults object using guard-supplied tables. Used by every guard
|
|
783
|
+
// primitive's create()/factory entry point so the resolution shape is
|
|
784
|
+
// identical across the family.
|
|
785
|
+
//
|
|
786
|
+
// resolveProfileAndPosture(opts, {
|
|
787
|
+
// profiles: PROFILES,
|
|
788
|
+
// compliancePostures: COMPLIANCE_POSTURES,
|
|
789
|
+
// defaults: DEFAULTS,
|
|
790
|
+
// errorClass: GuardCsvError,
|
|
791
|
+
// errCodePrefix: "csv", // throws "csv.bad-profile" / "csv.bad-posture"
|
|
792
|
+
// })
|
|
793
|
+
function resolveProfileAndPosture(opts, cfg) {
|
|
794
|
+
opts = opts || {};
|
|
795
|
+
validateOpts.requireObject(cfg, "gateContract.resolveProfileAndPosture",
|
|
796
|
+
GateContractError);
|
|
797
|
+
var ErrorClass = cfg.errorClass || GateContractError;
|
|
798
|
+
var prefix = cfg.errCodePrefix || "guard";
|
|
799
|
+
var overlay = {};
|
|
800
|
+
if (typeof opts.profile === "string") {
|
|
801
|
+
if (!cfg.profiles || !cfg.profiles[opts.profile]) {
|
|
802
|
+
throw ErrorClass.factory(prefix + ".bad-profile",
|
|
803
|
+
"unknown profile " + JSON.stringify(opts.profile));
|
|
804
|
+
}
|
|
805
|
+
overlay = cfg.profiles[opts.profile];
|
|
806
|
+
}
|
|
807
|
+
if (typeof opts.compliancePosture === "string") {
|
|
808
|
+
if (!cfg.compliancePostures || !cfg.compliancePostures[opts.compliancePosture]) {
|
|
809
|
+
throw ErrorClass.factory(prefix + ".bad-posture",
|
|
810
|
+
"unknown compliancePosture " + JSON.stringify(opts.compliancePosture));
|
|
811
|
+
}
|
|
812
|
+
overlay = Object.assign({}, overlay, cfg.compliancePostures[opts.compliancePosture]);
|
|
813
|
+
}
|
|
814
|
+
return Object.assign({}, cfg.defaults || {}, overlay, opts);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// buildProfile — recursive profile composition with cycle detection.
|
|
818
|
+
//
|
|
819
|
+
// buildProfile({
|
|
820
|
+
// baseProfile: "blog-post",
|
|
821
|
+
// extends: ["custom-tags"],
|
|
822
|
+
// overrides: { allowedTags: [...] },
|
|
823
|
+
// removes: { allowedAttrs: { a: ["target"] } },
|
|
824
|
+
// resolveProfile: name => profile, // operator-supplied resolver
|
|
825
|
+
// })
|
|
826
|
+
function buildProfile(opts) {
|
|
827
|
+
validateOpts.requireObject(opts, "gateContract.buildProfile", GateContractError);
|
|
828
|
+
var resolve = opts.resolveProfile;
|
|
829
|
+
if (typeof resolve !== "function") {
|
|
830
|
+
throw _err("gate-contract/bad-opt",
|
|
831
|
+
"buildProfile: opts.resolveProfile must be a function (name → profile)");
|
|
832
|
+
}
|
|
833
|
+
var seen = Object.create(null);
|
|
834
|
+
function _walk(name) {
|
|
835
|
+
if (seen[name]) {
|
|
836
|
+
throw _err("gate-contract/profile-cycle",
|
|
837
|
+
"buildProfile: cycle detected involving profile " + JSON.stringify(name));
|
|
838
|
+
}
|
|
839
|
+
seen[name] = true;
|
|
840
|
+
var p = resolve(name);
|
|
841
|
+
if (!p) throw _err("gate-contract/unknown-profile",
|
|
842
|
+
"buildProfile: unknown profile " + JSON.stringify(name));
|
|
843
|
+
var merged = Object.assign({}, p);
|
|
844
|
+
if (Array.isArray(p.extends)) {
|
|
845
|
+
for (var i = 0; i < p.extends.length; i++) {
|
|
846
|
+
var ext = _walk(p.extends[i]);
|
|
847
|
+
merged = _mergeProfile(ext, merged);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return merged;
|
|
851
|
+
}
|
|
852
|
+
var base = opts.baseProfile ? _walk(opts.baseProfile) : {};
|
|
853
|
+
if (Array.isArray(opts.extends)) {
|
|
854
|
+
for (var i = 0; i < opts.extends.length; i++) {
|
|
855
|
+
base = _mergeProfile(base, _walk(opts.extends[i]));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (opts.overrides) base = _mergeProfile(base, opts.overrides);
|
|
859
|
+
if (opts.removes) base = _applyRemoves(base, opts.removes);
|
|
860
|
+
return base;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function _mergeProfile(target, source) {
|
|
864
|
+
var out = Object.assign({}, target);
|
|
865
|
+
var keys = Object.keys(source);
|
|
866
|
+
for (var k = 0; k < keys.length; k++) {
|
|
867
|
+
var key = keys[k];
|
|
868
|
+
var val = source[key];
|
|
869
|
+
if (Array.isArray(val) && Array.isArray(out[key])) {
|
|
870
|
+
// Array merge — union; later sources win on duplicates.
|
|
871
|
+
var seen2 = Object.create(null);
|
|
872
|
+
var merged = [];
|
|
873
|
+
for (var i = 0; i < out[key].length; i++) {
|
|
874
|
+
var v = JSON.stringify(out[key][i]);
|
|
875
|
+
if (!seen2[v]) { seen2[v] = true; merged.push(out[key][i]); }
|
|
876
|
+
}
|
|
877
|
+
for (var j = 0; j < val.length; j++) {
|
|
878
|
+
var v2 = JSON.stringify(val[j]);
|
|
879
|
+
if (!seen2[v2]) { seen2[v2] = true; merged.push(val[j]); }
|
|
880
|
+
}
|
|
881
|
+
out[key] = merged;
|
|
882
|
+
} else if (val && typeof val === "object" && !Array.isArray(val) &&
|
|
883
|
+
out[key] && typeof out[key] === "object" && !Array.isArray(out[key])) {
|
|
884
|
+
out[key] = _mergeProfile(out[key], val);
|
|
885
|
+
} else {
|
|
886
|
+
out[key] = val;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function _applyRemoves(target, removes) {
|
|
893
|
+
var out = Object.assign({}, target);
|
|
894
|
+
var keys = Object.keys(removes);
|
|
895
|
+
for (var k = 0; k < keys.length; k++) {
|
|
896
|
+
var key = keys[k];
|
|
897
|
+
if (Array.isArray(removes[key]) && Array.isArray(out[key])) {
|
|
898
|
+
var rmSet = Object.create(null);
|
|
899
|
+
for (var i = 0; i < removes[key].length; i++) rmSet[JSON.stringify(removes[key][i])] = true;
|
|
900
|
+
out[key] = out[key].filter(function (v) { return !rmSet[JSON.stringify(v)]; });
|
|
901
|
+
} else if (removes[key] && typeof removes[key] === "object" &&
|
|
902
|
+
out[key] && typeof out[key] === "object") {
|
|
903
|
+
out[key] = _applyRemoves(out[key], removes[key]);
|
|
904
|
+
} else {
|
|
905
|
+
delete out[key];
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// summarizeIssues — project a gate decision's `issues` array down to the
|
|
912
|
+
// audit-shape (kind / severity / ruleId only — full snippets stay in the
|
|
913
|
+
// forensic store). Replaces the per-host inline `(d.issues || []).map(
|
|
914
|
+
// function (i) { return { kind: i.kind, severity: i.severity, ruleId:
|
|
915
|
+
// i.ruleId }; })` shape.
|
|
916
|
+
function summarizeIssues(issues) {
|
|
917
|
+
if (!Array.isArray(issues)) return [];
|
|
918
|
+
return issues.map(function (i) {
|
|
919
|
+
return { kind: i.kind, severity: i.severity, ruleId: i.ruleId };
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// composeHooks — chain operator hooks. First non-null filter result
|
|
924
|
+
// wins; transformer hooks run sequentially.
|
|
925
|
+
function composeHooks(hooks) {
|
|
926
|
+
hooks = (hooks || []).filter(Boolean);
|
|
927
|
+
if (hooks.length === 0) return null;
|
|
928
|
+
if (hooks.length === 1) return hooks[0];
|
|
929
|
+
return async function () {
|
|
930
|
+
var args = Array.prototype.slice.call(arguments);
|
|
931
|
+
var result = null;
|
|
932
|
+
for (var i = 0; i < hooks.length; i++) {
|
|
933
|
+
var rv = await hooks[i].apply(null, args);
|
|
934
|
+
if (rv && (rv.suppress === true || rv.skip === true)) return rv;
|
|
935
|
+
if (rv) result = rv;
|
|
936
|
+
}
|
|
937
|
+
return result;
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
module.exports = {
|
|
942
|
+
defineGate: defineGate,
|
|
943
|
+
validateGateShape: validateGateShape,
|
|
944
|
+
runGate: runGate,
|
|
945
|
+
composeGates: composeGates,
|
|
946
|
+
multiplexGates: multiplexGates,
|
|
947
|
+
contentTypeMux: contentTypeMux,
|
|
948
|
+
byActorTier: byActorTier,
|
|
949
|
+
byRoute: byRoute,
|
|
950
|
+
byDirection: byDirection,
|
|
951
|
+
shadowMode: shadowMode,
|
|
952
|
+
canaryGate: canaryGate,
|
|
953
|
+
cachingGate: cachingGate,
|
|
954
|
+
workerThreadGate: workerThreadGate,
|
|
955
|
+
buildProfile: buildProfile,
|
|
956
|
+
resolveProfileAndPosture: resolveProfileAndPosture,
|
|
957
|
+
runIssueValidator: runIssueValidator,
|
|
958
|
+
buildGuardGate: buildGuardGate,
|
|
959
|
+
extractBytesAsText: extractBytesAsText,
|
|
960
|
+
lookupCompliancePosture: lookupCompliancePosture,
|
|
961
|
+
makeRulePackLoader: makeRulePackLoader,
|
|
962
|
+
makeProfileBuilder: makeProfileBuilder,
|
|
963
|
+
badInputResultIfNotStringOrBuffer: badInputResultIfNotStringOrBuffer,
|
|
964
|
+
aggregateIssues: aggregateIssues,
|
|
965
|
+
composeHooks: composeHooks,
|
|
966
|
+
summarizeIssues: summarizeIssues,
|
|
967
|
+
ACTIONS: ACTIONS,
|
|
968
|
+
MODES: MODES,
|
|
969
|
+
ISSUE_SEVERITIES: ISSUE_SEVERITIES,
|
|
970
|
+
GateContractError: GateContractError,
|
|
971
|
+
};
|