@blamejs/core 0.9.24 → 0.9.38
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 +885 -871
- package/index.js +32 -1
- package/lib/agent-audit.js +45 -0
- package/lib/agent-event-bus.js +336 -0
- package/lib/agent-idempotency.js +2 -8
- package/lib/agent-orchestrator.js +2 -8
- package/lib/agent-posture-chain.js +208 -0
- package/lib/agent-saga.js +191 -0
- package/lib/agent-snapshot.js +346 -0
- package/lib/agent-stream.js +2 -8
- package/lib/agent-tenant.js +308 -0
- package/lib/agent-trace.js +218 -0
- package/lib/guard-all.js +1 -0
- package/lib/guard-dsn.js +379 -0
- package/lib/guard-envelope.js +294 -0
- package/lib/guard-event-bus-payload.js +217 -0
- package/lib/guard-event-bus-topic.js +150 -0
- package/lib/guard-posture-chain.js +201 -0
- package/lib/guard-saga-config.js +157 -0
- package/lib/guard-smtp-command.js +484 -0
- package/lib/guard-snapshot-envelope.js +168 -0
- package/lib/guard-tenant-id.js +138 -0
- package/lib/guard-trace-context.js +172 -0
- package/lib/ip-utils.js +102 -0
- package/lib/mail-auth.js +4 -35
- package/lib/mail-greylist.js +448 -0
- package/lib/mail-helo.js +473 -0
- package/lib/mail-rbl.js +392 -0
- package/lib/mail.js +2 -1
- package/lib/network-dns-resolver.js +500 -0
- package/lib/network.js +1 -0
- package/lib/redis-client.js +2 -1
- package/lib/safe-dns.js +665 -0
- package/lib/tracing.js +36 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEnvelope
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Envelope
|
|
6
|
+
* @order 455
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 7489 §3.1 DMARC Identifier Alignment validator. Gates the
|
|
10
|
+
* envelope-vs-header domain relationship at the MX listener's
|
|
11
|
+
* end-of-DATA boundary so a sender that passes SPF / DKIM under
|
|
12
|
+
* one domain but spoofs the user-visible `From:` header under
|
|
13
|
+
* another is refused before the message reaches the mail-store.
|
|
14
|
+
*
|
|
15
|
+
* ## What aligns with what
|
|
16
|
+
*
|
|
17
|
+
* DMARC's central identifier is **RFC 5322 `From:` domain** — the
|
|
18
|
+
* user-visible header field. Alignment requires at least one of:
|
|
19
|
+
*
|
|
20
|
+
* - **SPF alignment** — `RFC5321.MailFrom` domain (envelope-from)
|
|
21
|
+
* passed SPF (RFC 7208) AND matches the From-header domain.
|
|
22
|
+
* - **DKIM alignment** — at least one DKIM signature with `d=<X>`
|
|
23
|
+
* verified (RFC 6376) AND `<X>` matches the From-header domain.
|
|
24
|
+
*
|
|
25
|
+
* Match semantics (RFC 7489 §3.1.1 / §3.1.2):
|
|
26
|
+
*
|
|
27
|
+
* - **Strict (`s`)** — exact FQDN match. `From: alice@example.com`
|
|
28
|
+
* requires the authenticated identifier to be exactly
|
|
29
|
+
* `example.com`.
|
|
30
|
+
* - **Relaxed (`r`)** — organizational-domain match (via Public
|
|
31
|
+
* Suffix List). `From: alice@mail.example.com` aligns with
|
|
32
|
+
* SPF `bounces.example.com` because both share organizational
|
|
33
|
+
* domain `example.com`. Relaxed is the spec default per
|
|
34
|
+
* RFC 7489 §6.2.
|
|
35
|
+
*
|
|
36
|
+
* ## Why this primitive vs. b.mail.auth.dmarc.evaluate
|
|
37
|
+
*
|
|
38
|
+
* `b.mail.auth.dmarc.evaluate` (existing) is the FULL DMARC policy
|
|
39
|
+
* evaluation: parse DMARC TXT record, evaluate pct sampling,
|
|
40
|
+
* compute final disposition (none / quarantine / reject), produce
|
|
41
|
+
* the aggregate-report tuple. It composes the alignment check
|
|
42
|
+
* internally.
|
|
43
|
+
*
|
|
44
|
+
* `b.guardEnvelope.check` exposes JUST the alignment primitive so:
|
|
45
|
+
*
|
|
46
|
+
* - The v0.9.36 MX listener can short-circuit on alignment fail
|
|
47
|
+
* before even running the upstream DMARC TXT lookup.
|
|
48
|
+
* - Operator middleware composing a custom anti-spoofing policy
|
|
49
|
+
* can reuse the alignment primitive without dragging in the
|
|
50
|
+
* full DMARC machinery (TXT parse, aggregate reporting, …).
|
|
51
|
+
* - Tests against alignment edge cases don't have to mock the
|
|
52
|
+
* full DMARC pipeline.
|
|
53
|
+
*
|
|
54
|
+
* Both primitives produce the same alignment verdict for the same
|
|
55
|
+
* input — `b.guardEnvelope` is the focused gate; `b.mail.auth.dmarc`
|
|
56
|
+
* is the orchestrator.
|
|
57
|
+
*
|
|
58
|
+
* ## Verdict shape
|
|
59
|
+
*
|
|
60
|
+
* ```js
|
|
61
|
+
* {
|
|
62
|
+
* spf: { aligned: bool, mode: "strict"|"relaxed", domain: string, fromDomain: string },
|
|
63
|
+
* dkim: [{ aligned: bool, mode, signingDomain, fromDomain }, …],
|
|
64
|
+
* aligned: bool, // at least one of SPF/DKIM aligned
|
|
65
|
+
* action: "accept" | "refuse"
|
|
66
|
+
* }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* When operator's profile is `strict` and neither SPF nor DKIM
|
|
70
|
+
* aligns, action = `"refuse"`. Under `permissive`, action is
|
|
71
|
+
* always `"accept"` (the primitive computes alignment but doesn't
|
|
72
|
+
* gate on it — operator decides downstream from the verdict).
|
|
73
|
+
*
|
|
74
|
+
* ## CVE / threat model
|
|
75
|
+
*
|
|
76
|
+
* - **Display-name spoofing class** — `From: "Bank Of Foo" <a@evil.com>`
|
|
77
|
+
* where SPF passes for `evil.com` and DKIM signs `evil.com`: this
|
|
78
|
+
* primitive ALIGNS (both `evil.com`), so the spoof passes DMARC.
|
|
79
|
+
* Defense lives upstream in `b.guardEmail` (display-name vs
|
|
80
|
+
* domain mismatch detection).
|
|
81
|
+
* - **Envelope-vs-header spoofing** (the class this PRIMITIVE
|
|
82
|
+
* defends): `MAIL FROM:<service@aws-bounces.com>` SPF passes for
|
|
83
|
+
* aws-bounces.com, but `From: payments@your-bank.example` —
|
|
84
|
+
* misalignment refused under strict.
|
|
85
|
+
* - **Same-org-different-subdomain attack** under strict: legitimate
|
|
86
|
+
* mail from `bounces.example.com` to alignment-strict `example.com`
|
|
87
|
+
* is REFUSED — operator opts to relaxed for cross-subdomain mail.
|
|
88
|
+
* - **Public-suffix confusion** — relaxed mode uses
|
|
89
|
+
* `b.publicSuffix.organizationalDomain` which composes the
|
|
90
|
+
* vendored PSL; an attacker can't claim `co.uk` as their org
|
|
91
|
+
* domain because PSL classifies it as a public suffix.
|
|
92
|
+
*
|
|
93
|
+
* @card
|
|
94
|
+
* RFC 7489 §3.1 DMARC Identifier Alignment validator. Strict / relaxed match between RFC 5322 From-header domain and SPF MailFrom + DKIM d= identifiers. Composes b.publicSuffix.organizationalDomain for relaxed mode. Refuses envelope-vs-header spoofs at the MX boundary before mail-store touch.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
var { defineClass } = require("./framework-error");
|
|
98
|
+
var lazyRequire = require("./lazy-require");
|
|
99
|
+
var publicSuffix = require("./public-suffix");
|
|
100
|
+
|
|
101
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
102
|
+
|
|
103
|
+
var GuardEnvelopeError = defineClass("GuardEnvelopeError", { alwaysPermanent: true });
|
|
104
|
+
|
|
105
|
+
var DEFAULT_PROFILE = "strict";
|
|
106
|
+
|
|
107
|
+
var PROFILES = Object.freeze({
|
|
108
|
+
// Strict: gate refuses on alignment fail. Default for HIPAA / PCI /
|
|
109
|
+
// GDPR / SOC2 / banking / regulated mail.
|
|
110
|
+
strict: { gateOnFailure: true, defaultMode: "relaxed" },
|
|
111
|
+
// Balanced: gate refuses on alignment fail but defaults to relaxed
|
|
112
|
+
// mode (RFC 7489 §6.2 default). For most operator deployments.
|
|
113
|
+
balanced: { gateOnFailure: true, defaultMode: "relaxed" },
|
|
114
|
+
// Permissive: compute alignment but always accept; operator
|
|
115
|
+
// pipelines downstream consume the verdict for score-tagging.
|
|
116
|
+
permissive: { gateOnFailure: false, defaultMode: "relaxed" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
120
|
+
hipaa: "strict",
|
|
121
|
+
"pci-dss": "strict",
|
|
122
|
+
gdpr: "strict",
|
|
123
|
+
soc2: "strict",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @primitive b.guardEnvelope.check
|
|
128
|
+
* @signature b.guardEnvelope.check(ctx, opts?)
|
|
129
|
+
* @since 0.9.36
|
|
130
|
+
* @status stable
|
|
131
|
+
* @related b.publicSuffix.organizationalDomain, b.guardEmail.validateMessage
|
|
132
|
+
*
|
|
133
|
+
* Evaluate DMARC Identifier Alignment between the user-visible
|
|
134
|
+
* `From:` header domain and the authenticated identifiers (SPF
|
|
135
|
+
* MailFrom + DKIM d=). Returns the alignment verdict.
|
|
136
|
+
*
|
|
137
|
+
* @opts
|
|
138
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
139
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
140
|
+
* spfMode: "strict" | "relaxed", // per-call override (RFC 7489 §6.2)
|
|
141
|
+
* dkimMode: "strict" | "relaxed", // per-call override
|
|
142
|
+
* audit: b.audit namespace,
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* var v = b.guardEnvelope.check({
|
|
146
|
+
* fromHeaderDomain: "example.com",
|
|
147
|
+
* spfResult: { result: "pass", domain: "bounces.example.com" },
|
|
148
|
+
* dkimResults: [{ result: "pass", signingDomain: "example.com" }],
|
|
149
|
+
* });
|
|
150
|
+
* if (v.action === "refuse") return reply(550, "5.7.1 DMARC alignment fail");
|
|
151
|
+
*/
|
|
152
|
+
function check(ctx, opts) {
|
|
153
|
+
opts = opts || {};
|
|
154
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
155
|
+
if (!PROFILES[profile]) {
|
|
156
|
+
throw new GuardEnvelopeError("guard-envelope/bad-profile",
|
|
157
|
+
"check: unknown profile '" + profile + "'");
|
|
158
|
+
}
|
|
159
|
+
var caps = PROFILES[profile];
|
|
160
|
+
var spfMode = opts.spfMode || caps.defaultMode;
|
|
161
|
+
var dkimMode = opts.dkimMode || caps.defaultMode;
|
|
162
|
+
if (spfMode !== "strict" && spfMode !== "relaxed") {
|
|
163
|
+
throw new GuardEnvelopeError("guard-envelope/bad-mode",
|
|
164
|
+
"check: spfMode must be 'strict' or 'relaxed'");
|
|
165
|
+
}
|
|
166
|
+
if (dkimMode !== "strict" && dkimMode !== "relaxed") {
|
|
167
|
+
throw new GuardEnvelopeError("guard-envelope/bad-mode",
|
|
168
|
+
"check: dkimMode must be 'strict' or 'relaxed'");
|
|
169
|
+
}
|
|
170
|
+
var auditImpl = opts.audit || audit();
|
|
171
|
+
|
|
172
|
+
if (!ctx || typeof ctx !== "object") {
|
|
173
|
+
throw new GuardEnvelopeError("guard-envelope/bad-input",
|
|
174
|
+
"check: ctx must be a plain object");
|
|
175
|
+
}
|
|
176
|
+
if (typeof ctx.fromHeaderDomain !== "string" || ctx.fromHeaderDomain.length === 0) {
|
|
177
|
+
throw new GuardEnvelopeError("guard-envelope/bad-input",
|
|
178
|
+
"check: ctx.fromHeaderDomain must be a non-empty string");
|
|
179
|
+
}
|
|
180
|
+
var fromDomain = ctx.fromHeaderDomain.toLowerCase();
|
|
181
|
+
|
|
182
|
+
// SPF alignment.
|
|
183
|
+
var spfVerdict = _spfVerdict(ctx.spfResult, fromDomain, spfMode);
|
|
184
|
+
|
|
185
|
+
// DKIM alignment — one entry per signature.
|
|
186
|
+
var dkimResults = Array.isArray(ctx.dkimResults) ? ctx.dkimResults : [];
|
|
187
|
+
var dkimVerdicts = dkimResults.map(function (r) {
|
|
188
|
+
return _dkimVerdict(r, fromDomain, dkimMode);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
var anyAligned = spfVerdict.aligned || dkimVerdicts.some(function (d) { return d.aligned; });
|
|
192
|
+
var action = anyAligned || !caps.gateOnFailure ? "accept" : "refuse";
|
|
193
|
+
|
|
194
|
+
_emitAudit(auditImpl, anyAligned ? "guard.envelope.aligned" : "guard.envelope.misaligned", {
|
|
195
|
+
fromDomain: fromDomain,
|
|
196
|
+
spfAligned: spfVerdict.aligned,
|
|
197
|
+
dkimAligned: dkimVerdicts.some(function (d) { return d.aligned; }),
|
|
198
|
+
profile: profile,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
spf: spfVerdict,
|
|
203
|
+
dkim: dkimVerdicts,
|
|
204
|
+
aligned: anyAligned,
|
|
205
|
+
action: action,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @primitive b.guardEnvelope.compliancePosture
|
|
211
|
+
* @signature b.guardEnvelope.compliancePosture(posture)
|
|
212
|
+
* @since 0.9.36
|
|
213
|
+
* @status stable
|
|
214
|
+
*
|
|
215
|
+
* Return the effective profile name for a compliance posture, or
|
|
216
|
+
* `null` for unknown posture names.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* b.guardEnvelope.compliancePosture("hipaa"); // → "strict"
|
|
220
|
+
*/
|
|
221
|
+
function compliancePosture(posture) {
|
|
222
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _spfVerdict(spfResult, fromDomain, mode) {
|
|
226
|
+
var verdict = {
|
|
227
|
+
aligned: false,
|
|
228
|
+
mode: mode,
|
|
229
|
+
domain: null,
|
|
230
|
+
fromDomain: fromDomain,
|
|
231
|
+
spfPass: false,
|
|
232
|
+
};
|
|
233
|
+
if (!spfResult || typeof spfResult !== "object") return verdict;
|
|
234
|
+
verdict.spfPass = spfResult.result === "pass";
|
|
235
|
+
if (typeof spfResult.domain !== "string" || spfResult.domain.length === 0) return verdict;
|
|
236
|
+
verdict.domain = spfResult.domain.toLowerCase();
|
|
237
|
+
if (!verdict.spfPass) return verdict;
|
|
238
|
+
verdict.aligned = _domainAligned(verdict.domain, fromDomain, mode);
|
|
239
|
+
return verdict;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _dkimVerdict(dkimResult, fromDomain, mode) {
|
|
243
|
+
var verdict = {
|
|
244
|
+
aligned: false,
|
|
245
|
+
mode: mode,
|
|
246
|
+
signingDomain: null,
|
|
247
|
+
fromDomain: fromDomain,
|
|
248
|
+
dkimPass: false,
|
|
249
|
+
};
|
|
250
|
+
if (!dkimResult || typeof dkimResult !== "object") return verdict;
|
|
251
|
+
verdict.dkimPass = dkimResult.result === "pass";
|
|
252
|
+
if (typeof dkimResult.signingDomain !== "string" || dkimResult.signingDomain.length === 0) return verdict;
|
|
253
|
+
verdict.signingDomain = dkimResult.signingDomain.toLowerCase();
|
|
254
|
+
if (!verdict.dkimPass) return verdict;
|
|
255
|
+
verdict.aligned = _domainAligned(verdict.signingDomain, fromDomain, mode);
|
|
256
|
+
return verdict;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _domainAligned(authDomain, fromDomain, mode) {
|
|
260
|
+
if (mode === "strict") {
|
|
261
|
+
return authDomain === fromDomain;
|
|
262
|
+
}
|
|
263
|
+
// Relaxed — organizational-domain match via PSL.
|
|
264
|
+
var orgAuth, orgFrom;
|
|
265
|
+
try {
|
|
266
|
+
orgAuth = publicSuffix.organizationalDomain(authDomain);
|
|
267
|
+
orgFrom = publicSuffix.organizationalDomain(fromDomain);
|
|
268
|
+
} catch (_e) { return false; }
|
|
269
|
+
if (!orgAuth || !orgFrom) return false;
|
|
270
|
+
return orgAuth === orgFrom;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _emitAudit(auditImpl, action, metadata) {
|
|
274
|
+
try {
|
|
275
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
276
|
+
auditImpl.safeEmit({
|
|
277
|
+
action: action,
|
|
278
|
+
outcome: "success",
|
|
279
|
+
metadata: metadata,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
} catch (_e) { /* drop-silent — audit failure must not block accept loop */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
check: check,
|
|
287
|
+
compliancePosture: compliancePosture,
|
|
288
|
+
PROFILES: PROFILES,
|
|
289
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
290
|
+
GuardEnvelopeError: GuardEnvelopeError,
|
|
291
|
+
NAME: "envelope",
|
|
292
|
+
KIND: "envelope-alignment",
|
|
293
|
+
_domainAligned: _domainAligned,
|
|
294
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEventBusPayload
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Event Bus Payload
|
|
6
|
+
* @order 439
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Payload-shape validator for `b.agent.eventBus` events. Bus owners
|
|
10
|
+
* declare a schema per topic (flat key→type map); operators
|
|
11
|
+
* publishing events validate against the schema before pubsub
|
|
12
|
+
* dispatch; subscribers re-validate at delivery in case the payload
|
|
13
|
+
* was tampered in-flight.
|
|
14
|
+
*
|
|
15
|
+
* Per-topic schema is a flat object:
|
|
16
|
+
*
|
|
17
|
+
* ```js
|
|
18
|
+
* bus.registerTopic("mail.scan.malware-detected", {
|
|
19
|
+
* schema: {
|
|
20
|
+
* source: "string",
|
|
21
|
+
* confidence: "number",
|
|
22
|
+
* detectedAt: "isoDateTime",
|
|
23
|
+
* sampleId: "string",
|
|
24
|
+
* },
|
|
25
|
+
* ...
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Types: `string` / `number` / `boolean` / `integer` / `isoDateTime`
|
|
30
|
+
* / `array` / `object`. Optional fields suffix-marked with `?`
|
|
31
|
+
* (e.g. `"reason?": "string"`).
|
|
32
|
+
*
|
|
33
|
+
* Payload byte cap (default 64 KiB) — events are metadata, NOT bulk
|
|
34
|
+
* data; publishers move bulk data through `b.objectStore` /
|
|
35
|
+
* `b.mailStore` and reference IDs in events.
|
|
36
|
+
*
|
|
37
|
+
* @card
|
|
38
|
+
* Validates `b.agent.eventBus` event payloads against per-topic
|
|
39
|
+
* schemas. Bounded byte cap, flat-shape type checks.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
var { defineClass } = require("./framework-error");
|
|
43
|
+
|
|
44
|
+
var GuardEventBusPayloadError = defineClass("GuardEventBusPayloadError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
var DEFAULT_PROFILE = "strict";
|
|
47
|
+
|
|
48
|
+
var PROFILES = Object.freeze({
|
|
49
|
+
strict: { maxBytes: 65536 }, // allow:raw-byte-literal — 64 KiB metadata cap
|
|
50
|
+
balanced: { maxBytes: 262144 }, // allow:raw-byte-literal — 256 KiB
|
|
51
|
+
permissive: { maxBytes: 1048576 }, // allow:raw-byte-literal — 1 MiB
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
55
|
+
hipaa: "strict",
|
|
56
|
+
"pci-dss": "strict",
|
|
57
|
+
gdpr: "strict",
|
|
58
|
+
soc2: "strict",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
var ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/; // allow:regex-no-length-cap — value length-bounded by maxBytes payload cap
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @primitive b.guardEventBusPayload.validate
|
|
65
|
+
* @signature b.guardEventBusPayload.validate(payload, schema, opts?)
|
|
66
|
+
* @since 0.9.25
|
|
67
|
+
* @status stable
|
|
68
|
+
* @related b.agent.eventBus.create
|
|
69
|
+
*
|
|
70
|
+
* Validate an event payload against its declared schema. Returns
|
|
71
|
+
* the payload on success; throws on type mismatch / missing required
|
|
72
|
+
* field / oversize.
|
|
73
|
+
*
|
|
74
|
+
* @opts
|
|
75
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
76
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* b.guardEventBusPayload.validate(
|
|
80
|
+
* { source: "1.2.3.4", confidence: 0.95 },
|
|
81
|
+
* { source: "string", confidence: "number" }
|
|
82
|
+
* );
|
|
83
|
+
*/
|
|
84
|
+
function validate(payload, schema, opts) {
|
|
85
|
+
opts = opts || {};
|
|
86
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
87
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
88
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-input",
|
|
89
|
+
"guardEventBusPayload.validate: payload must be a plain object");
|
|
90
|
+
}
|
|
91
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
92
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-schema",
|
|
93
|
+
"guardEventBusPayload.validate: schema must be a plain object");
|
|
94
|
+
}
|
|
95
|
+
var serialized;
|
|
96
|
+
try { serialized = JSON.stringify(payload); }
|
|
97
|
+
catch (e) {
|
|
98
|
+
throw new GuardEventBusPayloadError("event-bus-payload/unserializable",
|
|
99
|
+
"guardEventBusPayload.validate: payload not JSON-serializable: " +
|
|
100
|
+
(e && e.message ? e.message : String(e)));
|
|
101
|
+
}
|
|
102
|
+
if (Buffer.byteLength(serialized, "utf8") > profile.maxBytes) {
|
|
103
|
+
throw new GuardEventBusPayloadError("event-bus-payload/oversize",
|
|
104
|
+
"guardEventBusPayload.validate: " + Buffer.byteLength(serialized, "utf8") +
|
|
105
|
+
" bytes exceeds maxBytes=" + profile.maxBytes +
|
|
106
|
+
" (events are metadata; reference bulk data via objectStore IDs)");
|
|
107
|
+
}
|
|
108
|
+
// Walk schema; check each field's type + presence.
|
|
109
|
+
var schemaKeys = Object.keys(schema);
|
|
110
|
+
for (var i = 0; i < schemaKeys.length; i += 1) {
|
|
111
|
+
var key = schemaKeys[i];
|
|
112
|
+
var optional = key.charAt(key.length - 1) === "?";
|
|
113
|
+
var fieldName = optional ? key.slice(0, -1) : key;
|
|
114
|
+
var expectedType = schema[key];
|
|
115
|
+
var actual = payload[fieldName];
|
|
116
|
+
if (typeof actual === "undefined" || actual === null) {
|
|
117
|
+
if (!optional) {
|
|
118
|
+
throw new GuardEventBusPayloadError("event-bus-payload/missing-field",
|
|
119
|
+
"guardEventBusPayload.validate: required field '" + fieldName + "' missing");
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
_checkType(actual, expectedType, fieldName);
|
|
124
|
+
}
|
|
125
|
+
// Reject unknown keys — schema must be exhaustive.
|
|
126
|
+
var payloadKeys = Object.keys(payload);
|
|
127
|
+
for (var p = 0; p < payloadKeys.length; p += 1) {
|
|
128
|
+
var pk = payloadKeys[p];
|
|
129
|
+
if (!Object.prototype.hasOwnProperty.call(schema, pk) &&
|
|
130
|
+
!Object.prototype.hasOwnProperty.call(schema, pk + "?")) {
|
|
131
|
+
throw new GuardEventBusPayloadError("event-bus-payload/unknown-field",
|
|
132
|
+
"guardEventBusPayload.validate: unknown field '" + pk + "' not in schema");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return payload;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @primitive b.guardEventBusPayload.compliancePosture
|
|
140
|
+
* @signature b.guardEventBusPayload.compliancePosture(posture)
|
|
141
|
+
* @since 0.9.25
|
|
142
|
+
* @status stable
|
|
143
|
+
*
|
|
144
|
+
* Return the effective profile for a given compliance posture name.
|
|
145
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
146
|
+
* here instead of silently falling through to the default profile.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* b.guardEventBusPayload.compliancePosture("hipaa"); // → "strict"
|
|
150
|
+
*/
|
|
151
|
+
function compliancePosture(posture) {
|
|
152
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _checkType(value, type, fieldName) {
|
|
156
|
+
if (type === "string" && typeof value !== "string") {
|
|
157
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
158
|
+
"field '" + fieldName + "' expected string, got " + typeof value);
|
|
159
|
+
}
|
|
160
|
+
if (type === "number" && (typeof value !== "number" || !isFinite(value))) {
|
|
161
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
162
|
+
"field '" + fieldName + "' expected finite number, got " +
|
|
163
|
+
(typeof value === "number" ? "non-finite number" : typeof value));
|
|
164
|
+
}
|
|
165
|
+
if (type === "boolean" && typeof value !== "boolean") {
|
|
166
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
167
|
+
"field '" + fieldName + "' expected boolean, got " + typeof value);
|
|
168
|
+
}
|
|
169
|
+
if (type === "integer" && !Number.isInteger(value)) {
|
|
170
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
171
|
+
"field '" + fieldName + "' expected integer");
|
|
172
|
+
}
|
|
173
|
+
if (type === "isoDateTime") {
|
|
174
|
+
// Length-bound the value before regex test so a hostile input can't
|
|
175
|
+
// burn regex-engine CPU. RFC 3339 ISO-8601 dateTime is bounded by
|
|
176
|
+
// ~40 chars even with fractional seconds + numeric offset; cap at 64
|
|
177
|
+
// for safety. The payload-level maxBytes cap also bounds the field.
|
|
178
|
+
if (typeof value !== "string" || value.length > 64 || !ISO_DATETIME_RE.test(value)) { // allow:raw-byte-literal — ISO-8601 dateTime max length
|
|
179
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
180
|
+
"field '" + fieldName + "' expected ISO-8601 dateTime string");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (type === "array" && !Array.isArray(value)) {
|
|
184
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
185
|
+
"field '" + fieldName + "' expected array");
|
|
186
|
+
}
|
|
187
|
+
if (type === "object") {
|
|
188
|
+
// Plain object check: rule out null first (typeof null === "object"
|
|
189
|
+
// pre-ES6 quirk), then array, then any non-object type.
|
|
190
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
191
|
+
throw new GuardEventBusPayloadError("event-bus-payload/type-mismatch",
|
|
192
|
+
"field '" + fieldName + "' expected plain object");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _resolveProfile(opts) {
|
|
198
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
199
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
200
|
+
}
|
|
201
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
202
|
+
if (!PROFILES[p]) {
|
|
203
|
+
throw new GuardEventBusPayloadError("event-bus-payload/bad-profile",
|
|
204
|
+
"guardEventBusPayload: unknown profile '" + p + "'");
|
|
205
|
+
}
|
|
206
|
+
return p;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
validate: validate,
|
|
211
|
+
compliancePosture: compliancePosture,
|
|
212
|
+
PROFILES: PROFILES,
|
|
213
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
214
|
+
GuardEventBusPayloadError: GuardEventBusPayloadError,
|
|
215
|
+
NAME: "eventBusPayload",
|
|
216
|
+
KIND: "event-bus-payload",
|
|
217
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardEventBusTopic
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Event Bus Topic
|
|
6
|
+
* @order 438
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Topic name validator for `b.agent.eventBus.registerTopic` /
|
|
10
|
+
* `publish` / `subscribe`. Refuses:
|
|
11
|
+
*
|
|
12
|
+
* - dot-count < 3 (operators must use `<domain>.<source>.<event>`
|
|
13
|
+
* shape so topic names are greppable + namespace-prefixed —
|
|
14
|
+
* `mail.scan.malware-detected` not `malware`)
|
|
15
|
+
* - non-ASCII (NFC + ASCII-only — operator-greppable across
|
|
16
|
+
* audit logs + JMAP wire + cross-process)
|
|
17
|
+
* - oversized (default 128 bytes — events are metadata, not bulk
|
|
18
|
+
* data; long names defeat the greppability rationale)
|
|
19
|
+
* - reserved `framework.*` prefix from operator code
|
|
20
|
+
* - path-traversal shapes (`..` / `/` / `\` / NUL / C0)
|
|
21
|
+
*
|
|
22
|
+
* @card
|
|
23
|
+
* Validates `b.agent.eventBus` topic names. Dot-count, ASCII,
|
|
24
|
+
* reserved-prefix, path-traversal refusal.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var { defineClass } = require("./framework-error");
|
|
28
|
+
|
|
29
|
+
var GuardEventBusTopicError = defineClass("GuardEventBusTopicError", { alwaysPermanent: true });
|
|
30
|
+
|
|
31
|
+
var DEFAULT_PROFILE = "strict";
|
|
32
|
+
|
|
33
|
+
var PROFILES = Object.freeze({
|
|
34
|
+
strict: { maxBytes: 128, minDots: 2 }, // allow:raw-byte-literal
|
|
35
|
+
balanced: { maxBytes: 256, minDots: 2 }, // allow:raw-byte-literal
|
|
36
|
+
permissive: { maxBytes: 512, minDots: 1 }, // allow:raw-byte-literal
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
40
|
+
hipaa: "strict",
|
|
41
|
+
"pci-dss": "strict",
|
|
42
|
+
gdpr: "strict",
|
|
43
|
+
soc2: "strict",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
var RESERVED_PREFIXES = Object.freeze(["framework.", "FRAMEWORK."]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @primitive b.guardEventBusTopic.validate
|
|
50
|
+
* @signature b.guardEventBusTopic.validate(name, opts?)
|
|
51
|
+
* @since 0.9.25
|
|
52
|
+
* @status stable
|
|
53
|
+
* @related b.agent.eventBus.create
|
|
54
|
+
*
|
|
55
|
+
* Validate an event-bus topic name. Returns the name on success;
|
|
56
|
+
* throws `GuardEventBusTopicError` on refusal.
|
|
57
|
+
*
|
|
58
|
+
* @opts
|
|
59
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
60
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* b.guardEventBusTopic.validate("mail.scan.malware-detected");
|
|
64
|
+
*/
|
|
65
|
+
function validate(name, opts) {
|
|
66
|
+
opts = opts || {};
|
|
67
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
68
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
69
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-input",
|
|
70
|
+
"guardEventBusTopic.validate: name must be a non-empty string");
|
|
71
|
+
}
|
|
72
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxBytes) {
|
|
73
|
+
throw new GuardEventBusTopicError("event-bus-topic/oversize",
|
|
74
|
+
"guardEventBusTopic.validate: name exceeds maxBytes=" + profile.maxBytes);
|
|
75
|
+
}
|
|
76
|
+
// Dot-count check — `<domain>.<source>.<event>` shape.
|
|
77
|
+
var dots = 0;
|
|
78
|
+
for (var d = 0; d < name.length; d += 1) if (name.charCodeAt(d) === 0x2E) dots += 1; // allow:raw-byte-literal — '.' codepoint
|
|
79
|
+
if (dots < profile.minDots) {
|
|
80
|
+
throw new GuardEventBusTopicError("event-bus-topic/insufficient-dots",
|
|
81
|
+
"guardEventBusTopic.validate: name '" + name + "' has " + dots +
|
|
82
|
+
" dots; minimum " + profile.minDots + " required (use <domain>.<source>.<event> shape)");
|
|
83
|
+
}
|
|
84
|
+
// Reserved prefix refusal.
|
|
85
|
+
for (var r = 0; r < RESERVED_PREFIXES.length; r += 1) {
|
|
86
|
+
if (name.indexOf(RESERVED_PREFIXES[r]) === 0) {
|
|
87
|
+
throw new GuardEventBusTopicError("event-bus-topic/reserved-prefix",
|
|
88
|
+
"guardEventBusTopic.validate: name '" + name + "' uses reserved prefix '" +
|
|
89
|
+
RESERVED_PREFIXES[r] + "'");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Path-traversal refusal.
|
|
93
|
+
if (name.indexOf("..") >= 0) {
|
|
94
|
+
throw new GuardEventBusTopicError("event-bus-topic/path-traversal",
|
|
95
|
+
"guardEventBusTopic.validate: name contains '..'");
|
|
96
|
+
}
|
|
97
|
+
// C0 / DEL / slash / non-ASCII refusal.
|
|
98
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
99
|
+
var c = name.charCodeAt(i);
|
|
100
|
+
if (c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
|
|
101
|
+
throw new GuardEventBusTopicError("event-bus-topic/non-ascii",
|
|
102
|
+
"guardEventBusTopic.validate: name contains non-ASCII codepoint at offset " + i);
|
|
103
|
+
}
|
|
104
|
+
if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0/DEL/slash/backslash
|
|
105
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-char",
|
|
106
|
+
"guardEventBusTopic.validate: forbidden char 0x" + c.toString(16) + " at offset " + i);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @primitive b.guardEventBusTopic.compliancePosture
|
|
114
|
+
* @signature b.guardEventBusTopic.compliancePosture(posture)
|
|
115
|
+
* @since 0.9.25
|
|
116
|
+
* @status stable
|
|
117
|
+
*
|
|
118
|
+
* Return the effective profile for a given compliance posture name.
|
|
119
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
120
|
+
* here instead of silently falling through to the default profile.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* b.guardEventBusTopic.compliancePosture("hipaa"); // → "strict"
|
|
124
|
+
*/
|
|
125
|
+
function compliancePosture(posture) {
|
|
126
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _resolveProfile(opts) {
|
|
130
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
131
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
132
|
+
}
|
|
133
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
134
|
+
if (!PROFILES[p]) {
|
|
135
|
+
throw new GuardEventBusTopicError("event-bus-topic/bad-profile",
|
|
136
|
+
"guardEventBusTopic: unknown profile '" + p + "'");
|
|
137
|
+
}
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
validate: validate,
|
|
143
|
+
compliancePosture: compliancePosture,
|
|
144
|
+
PROFILES: PROFILES,
|
|
145
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
146
|
+
RESERVED_PREFIXES: RESERVED_PREFIXES,
|
|
147
|
+
GuardEventBusTopicError: GuardEventBusTopicError,
|
|
148
|
+
NAME: "eventBusTopic",
|
|
149
|
+
KIND: "event-bus-topic",
|
|
150
|
+
};
|