@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.
@@ -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
+ };