@blamejs/core 0.9.28 → 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,379 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardDsn
4
+ * @nav Guards
5
+ * @title Guard DSN
6
+ * @order 460
7
+ *
8
+ * @intro
9
+ * RFC 3464 Delivery Status Notification parser. Reads the
10
+ * `multipart/report; report-type=delivery-status` structure that
11
+ * bounces, delayed-delivery notices, and successful-delivery
12
+ * confirmations carry and surfaces the per-recipient action +
13
+ * enhanced status code so operator-side delivery-failure routing
14
+ * (`b.mail.bounce` retry curve, address-book invalidation, mailing-
15
+ * list cleanup, transactional-mail dead-letter handling) reads a
16
+ * stable shape regardless of MTA wording.
17
+ *
18
+ * ## RFC 3464 structure
19
+ *
20
+ * `multipart/report` per RFC 6522 §3:
21
+ *
22
+ * 1. `text/plain` (or text/html) — human-readable wording
23
+ * ("Your message could not be delivered to alice@example.com");
24
+ * the framework does NOT route on this prose.
25
+ * 2. **`message/delivery-status`** (RFC 3464 §2) — the
26
+ * machine-readable DSN body the framework parses.
27
+ * 3. Optional `message/rfc822` (or `text/rfc822-headers`) —
28
+ * the original message (or its headers) that bounced.
29
+ *
30
+ * ## Required fields the parser extracts
31
+ *
32
+ * **Per-message fields (RFC 3464 §2.2)**:
33
+ * - `Reporting-MTA` — MTA that issued the DSN. Mandatory.
34
+ * - `Original-Envelope-Id` (optional) — DSN-tied envelope id.
35
+ * - `Arrival-Date` (optional) — when the original message
36
+ * arrived at the reporting MTA.
37
+ *
38
+ * **Per-recipient fields (RFC 3464 §2.3)** — repeated, one block
39
+ * per recipient:
40
+ * - `Final-Recipient` — recipient address as the reporting MTA
41
+ * knows it. Mandatory.
42
+ * - `Action` — `failed` / `delayed` / `delivered` / `relayed` /
43
+ * `expanded`. Mandatory.
44
+ * - `Status` — RFC 3463 enhanced status code, format
45
+ * `D.D[D[D]].D[D[D]]` (e.g. `5.1.1` = bad address).
46
+ * - `Original-Recipient` (optional).
47
+ * - `Diagnostic-Code` (optional) — raw MTA error line.
48
+ *
49
+ * ## RFC 3463 status-class semantics
50
+ *
51
+ * The first digit classifies the verdict and drives the framework's
52
+ * downstream routing:
53
+ *
54
+ * - **`2.x.y`** — success (delivered / relayed / expanded). Used
55
+ * by mailing-list `verp` tracking + delivery-receipt auditing.
56
+ * - **`4.x.y`** — persistent transient failure. Operator's
57
+ * `b.outbox` retry curve applies; address stays valid.
58
+ * - **`5.x.y`** — permanent failure. Address-book invalidation
59
+ * trigger; mailing-list cleanup; no further retries.
60
+ *
61
+ * The framework surfaces `statusClass` (`success` / `temporary` /
62
+ * `permanent`) so operator routing reads one shape regardless of
63
+ * the exact subcode.
64
+ *
65
+ * ## Defenses
66
+ *
67
+ * - **Oversize DSN** — bounded body cap (default 256 KiB strict)
68
+ * per the profile; legitimate DSNs are KB-scale, multi-MB DSNs
69
+ * are pathological / DoS-shaped.
70
+ * - **Recipient-count cap** — per-DSN recipient cap (default 256
71
+ * strict). A DSN with thousands of recipients is forged or
72
+ * misconfigured; operator opts permissive for mailing-list
73
+ * blast-bounces.
74
+ * - **Header-line cap** — each field-line capped at 998 bytes
75
+ * per RFC 5322 §2.1.1.
76
+ * - **CRLF + control-char refusal** — header injection defense
77
+ * for fields that propagate to operator's audit log /
78
+ * monitoring dashboard.
79
+ *
80
+ * ## CVE / threat model
81
+ *
82
+ * - **Bounce-flood / backscatter** — operator's MX should refuse
83
+ * mail with envelope-from that doesn't pass SPF before
84
+ * generating a DSN (the existing `b.mail.bounce` primitive does
85
+ * this); this guard parses INBOUND DSNs and gates the parse
86
+ * surface bounds, not the bounce-generation policy.
87
+ * - **DSN header-injection class** (CVE-2026-32178 .NET
88
+ * System.Net.Mail at outbound; the inbound parse path here)
89
+ * — refuses CR/LF/NUL/C0 in header lines.
90
+ * - **CSAF / iSchedule prose tampering** — operator inspecting
91
+ * the prose part for the original recipient runs into the
92
+ * ambiguous wording that DSNs vary across MTAs (Postfix vs
93
+ * Exchange vs SES vs Gmail). The parser surfaces the
94
+ * STRUCTURED fields so operator routing doesn't have to
95
+ * regex MTA-specific prose.
96
+ *
97
+ * @card
98
+ * RFC 3464 DSN parser. Walks message/delivery-status per-message + per-recipient blocks, surfaces Action / Status / Final-Recipient + the RFC 3463 status-class verdict (success / temporary / permanent). Bounded recipient count + body size + header-line length; CRLF / NUL / C0 refusal. Operator delivery-failure routing reads one shape regardless of MTA wording.
99
+ */
100
+
101
+ var C = require("./constants");
102
+ var { defineClass } = require("./framework-error");
103
+
104
+ var GuardDsnError = defineClass("GuardDsnError", { alwaysPermanent: true });
105
+
106
+ var DEFAULT_PROFILE = "strict";
107
+
108
+ var PROFILES = Object.freeze({
109
+ strict: { maxBytes: C.BYTES.kib(256), maxRecipients: 256, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 header line cap; RFC 3464 recipient count
110
+ balanced: { maxBytes: C.BYTES.mib(1), maxRecipients: 1024, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap; mailing-list blast bounces
111
+ permissive: { maxBytes: C.BYTES.mib(4), maxRecipients: 4096, maxHeaderLine: 998 }, // allow:raw-byte-literal — RFC 5322 §2.1.1 line cap; large-blast bounce class
112
+ });
113
+
114
+ var COMPLIANCE_POSTURES = Object.freeze({
115
+ hipaa: "strict",
116
+ "pci-dss": "strict",
117
+ gdpr: "strict",
118
+ soc2: "strict",
119
+ });
120
+
121
+ var KNOWN_ACTIONS = Object.freeze({
122
+ failed: true,
123
+ delayed: true,
124
+ delivered: true,
125
+ relayed: true,
126
+ expanded: true,
127
+ });
128
+
129
+ // RFC 3463 §3.1: status code is digit . digit{1,3} . digit{1,3}.
130
+ var STATUS_RE = /^([245])\.(\d{1,3})\.(\d{1,3})$/; // allow:regex-no-length-cap — anchored + per-component repeat cap
131
+
132
+ /**
133
+ * @primitive b.guardDsn.parse
134
+ * @signature b.guardDsn.parse(deliveryStatusBody, opts?)
135
+ * @since 0.9.37
136
+ * @status stable
137
+ * @related b.safeMime.parse, b.guardEnvelope.check
138
+ *
139
+ * Parse a `message/delivery-status` body (the MIME part body, not
140
+ * the entire RFC 3464 multipart/report — extract that via
141
+ * `b.safeMime.parse` first). Returns `{ perMessage, perRecipients,
142
+ * worstStatusClass, action }`.
143
+ *
144
+ * Throws `GuardDsnError` on oversize body / recipient count /
145
+ * header-line length / malformed status code / required-field
146
+ * missing / control-char in field value.
147
+ *
148
+ * @opts
149
+ * profile: "strict" | "balanced" | "permissive",
150
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
151
+ *
152
+ * @example
153
+ * var mime = b.safeMime.parse(rawBouncedMessage);
154
+ * var deliveryStatusPart = b.safeMime.findFirst(mime, function (p) {
155
+ * return p.leaf && p.leaf.contentType === "message/delivery-status";
156
+ * });
157
+ * var dsn = b.guardDsn.parse(deliveryStatusPart.leaf.body);
158
+ * if (dsn.worstStatusClass === "permanent") {
159
+ * dsn.perRecipients.forEach(function (r) { invalidateAddress(r.finalRecipient); });
160
+ * }
161
+ */
162
+ function parse(deliveryStatusBody, opts) {
163
+ opts = opts || {};
164
+ var caps = _resolveProfile(opts);
165
+ var bytes;
166
+ if (Buffer.isBuffer(deliveryStatusBody)) {
167
+ bytes = deliveryStatusBody;
168
+ } else if (typeof deliveryStatusBody === "string") {
169
+ bytes = Buffer.from(deliveryStatusBody, "utf8");
170
+ } else {
171
+ throw new GuardDsnError("guard-dsn/bad-input",
172
+ "parse: deliveryStatusBody must be a Buffer or string");
173
+ }
174
+ if (bytes.length > caps.maxBytes) {
175
+ throw new GuardDsnError("guard-dsn/oversize-body",
176
+ "parse: body " + bytes.length + " bytes exceeds maxBytes=" + caps.maxBytes);
177
+ }
178
+
179
+ // RFC 3464 §2.1: the delivery-status body is per-message fields,
180
+ // a blank line, then per-recipient field groups separated by
181
+ // blank lines.
182
+ var text = bytes.toString("utf8");
183
+ var blocks = _splitBlocks(text);
184
+ if (blocks.length === 0) {
185
+ throw new GuardDsnError("guard-dsn/empty",
186
+ "parse: delivery-status body has no field blocks");
187
+ }
188
+ var perMessageFields = _parseFieldBlock(blocks[0], caps.maxHeaderLine);
189
+
190
+ // Reporting-MTA is mandatory per RFC 3464 §2.2.2.
191
+ if (!perMessageFields["reporting-mta"]) {
192
+ throw new GuardDsnError("guard-dsn/missing-reporting-mta",
193
+ "parse: required per-message field Reporting-MTA missing (RFC 3464 §2.2.2)");
194
+ }
195
+
196
+ var perRecipients = [];
197
+ for (var i = 1; i < blocks.length; i += 1) {
198
+ if (perRecipients.length >= caps.maxRecipients) {
199
+ throw new GuardDsnError("guard-dsn/too-many-recipients",
200
+ "parse: per-recipient count exceeds maxRecipients=" + caps.maxRecipients);
201
+ }
202
+ var fields = _parseFieldBlock(blocks[i], caps.maxHeaderLine);
203
+ if (Object.keys(fields).length === 0) continue; // empty trailing block
204
+ if (!fields["final-recipient"]) {
205
+ throw new GuardDsnError("guard-dsn/missing-final-recipient",
206
+ "parse: per-recipient block missing Final-Recipient (RFC 3464 §2.3.2)");
207
+ }
208
+ if (!fields["action"]) {
209
+ throw new GuardDsnError("guard-dsn/missing-action",
210
+ "parse: per-recipient block missing Action (RFC 3464 §2.3.3)");
211
+ }
212
+ var action = fields["action"].toLowerCase();
213
+ if (!KNOWN_ACTIONS[action]) {
214
+ throw new GuardDsnError("guard-dsn/bad-action",
215
+ "parse: Action '" + action + "' not in RFC 3464 §2.3.3 vocabulary");
216
+ }
217
+ if (!fields["status"]) {
218
+ throw new GuardDsnError("guard-dsn/missing-status",
219
+ "parse: per-recipient block missing Status (RFC 3464 §2.3.4)");
220
+ }
221
+ var statusMatch = fields["status"].match(STATUS_RE);
222
+ if (!statusMatch) {
223
+ throw new GuardDsnError("guard-dsn/bad-status",
224
+ "parse: Status '" + fields["status"] + "' not RFC 3463 D.D.D form");
225
+ }
226
+ perRecipients.push({
227
+ finalRecipient: _stripRecipientType(fields["final-recipient"]),
228
+ originalRecipient: fields["original-recipient"] ? _stripRecipientType(fields["original-recipient"]) : null,
229
+ action: action,
230
+ status: fields["status"],
231
+ statusClass: _statusClass(statusMatch[1]),
232
+ diagnosticCode: fields["diagnostic-code"] || null,
233
+ remoteMta: fields["remote-mta"] || null,
234
+ lastAttemptDate: fields["last-attempt-date"] || null,
235
+ });
236
+ }
237
+
238
+ if (perRecipients.length === 0) {
239
+ throw new GuardDsnError("guard-dsn/no-recipients",
240
+ "parse: delivery-status has no per-recipient blocks (RFC 3464 §2.1 requires at least one)");
241
+ }
242
+
243
+ // Worst status class across recipients: permanent > temporary > success.
244
+ var worst = "success";
245
+ for (var r = 0; r < perRecipients.length; r += 1) {
246
+ if (perRecipients[r].statusClass === "permanent") { worst = "permanent"; break; }
247
+ if (perRecipients[r].statusClass === "temporary") worst = "temporary";
248
+ }
249
+
250
+ return {
251
+ perMessage: {
252
+ reportingMta: perMessageFields["reporting-mta"],
253
+ originalEnvelopeId: perMessageFields["original-envelope-id"] || null,
254
+ arrivalDate: perMessageFields["arrival-date"] || null,
255
+ receivedFromMta: perMessageFields["received-from-mta"] || null,
256
+ },
257
+ perRecipients: perRecipients,
258
+ worstStatusClass: worst,
259
+ action: worst === "permanent" ? "invalidate" :
260
+ worst === "temporary" ? "retry" :
261
+ "deliver",
262
+ };
263
+ }
264
+
265
+ /**
266
+ * @primitive b.guardDsn.compliancePosture
267
+ * @signature b.guardDsn.compliancePosture(posture)
268
+ * @since 0.9.37
269
+ * @status stable
270
+ *
271
+ * Return the effective profile name for a compliance posture, or
272
+ * `null` for unknown posture names.
273
+ *
274
+ * @example
275
+ * b.guardDsn.compliancePosture("hipaa"); // → "strict"
276
+ */
277
+ function compliancePosture(posture) {
278
+ return COMPLIANCE_POSTURES[posture] || null;
279
+ }
280
+
281
+ function _splitBlocks(text) {
282
+ // RFC 3464 §2.1: blank line separates per-message from
283
+ // per-recipient blocks; blank lines also separate consecutive
284
+ // per-recipient blocks.
285
+ // Normalize CRLF + bare-CR to LF for split.
286
+ var normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // allow:regex-no-length-cap — input length already capped
287
+ return normalized.split(/\n\s*\n/); // allow:regex-no-length-cap — input length already capped
288
+ }
289
+
290
+ function _parseFieldBlock(block, maxHeaderLine) {
291
+ // RFC 5322 §2.2: header field = name ":" value; continuation
292
+ // lines start with whitespace.
293
+ var lines = block.split("\n");
294
+ var fields = Object.create(null);
295
+ var current = null;
296
+ for (var i = 0; i < lines.length; i += 1) {
297
+ var raw = lines[i];
298
+ if (raw.length > maxHeaderLine) {
299
+ throw new GuardDsnError("guard-dsn/oversize-header-line",
300
+ "parse: header line " + raw.length + " bytes exceeds maxHeaderLine=" + maxHeaderLine + " (RFC 5322 §2.1.1)");
301
+ }
302
+ if (raw.length === 0) continue;
303
+ _checkControlChars(raw);
304
+ if (/^[ \t]/.test(raw) && current) { // allow:regex-no-length-cap — single-char check on capped line
305
+ // Continuation.
306
+ fields[current] += " " + raw.replace(/^[ \t]+/, ""); // allow:regex-no-length-cap — trim on capped line // allow:duplicate-regex — leading-WS-trim shape common to RFC 5322 header continuation parsers
307
+ continue;
308
+ }
309
+ var colon = raw.indexOf(":");
310
+ if (colon === -1) {
311
+ throw new GuardDsnError("guard-dsn/malformed-field",
312
+ "parse: line '" + raw + "' missing ':' field-name terminator");
313
+ }
314
+ var name = raw.slice(0, colon).trim().toLowerCase();
315
+ var value = raw.slice(colon + 1).trim();
316
+ if (name.length === 0) {
317
+ throw new GuardDsnError("guard-dsn/malformed-field",
318
+ "parse: empty field name on line '" + raw + "'");
319
+ }
320
+ fields[name] = value;
321
+ current = name;
322
+ }
323
+ return fields;
324
+ }
325
+
326
+ function _checkControlChars(line) {
327
+ // Refuse NUL, C0 controls (except TAB which is valid in
328
+ // continuation), DEL. Bare CR and LF can't appear because we
329
+ // already split on \n; this catches forms that survive the
330
+ // split (e.g. backslash + literal sequence).
331
+ for (var i = 0; i < line.length; i += 1) {
332
+ var c = line.charCodeAt(i);
333
+ if (c === 0x00 || c === 0x7f || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — RFC 5322 control char + TAB allow
334
+ throw new GuardDsnError("guard-dsn/control-char",
335
+ "parse: control char 0x" + c.toString(16) + " in field line refused (header-injection defense)");
336
+ }
337
+ }
338
+ }
339
+
340
+ function _stripRecipientType(value) {
341
+ // RFC 3464 §2.3.2: "rfc822;alice@example.com" — type prefix
342
+ // before semicolon classifies the address. Strip for the common
343
+ // case of rfc822, surface the raw value otherwise.
344
+ var semi = value.indexOf(";");
345
+ if (semi === -1) return value;
346
+ var type = value.slice(0, semi).trim().toLowerCase();
347
+ if (type === "rfc822") return value.slice(semi + 1).trim();
348
+ return value;
349
+ }
350
+
351
+ function _statusClass(firstDigit) {
352
+ if (firstDigit === "2") return "success";
353
+ if (firstDigit === "4") return "temporary";
354
+ if (firstDigit === "5") return "permanent";
355
+ return "unknown";
356
+ }
357
+
358
+ function _resolveProfile(opts) {
359
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
360
+ return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
361
+ }
362
+ var p = opts.profile || DEFAULT_PROFILE;
363
+ if (!PROFILES[p]) {
364
+ throw new GuardDsnError("guard-dsn/bad-profile",
365
+ "guardDsn: unknown profile '" + p + "'");
366
+ }
367
+ return PROFILES[p];
368
+ }
369
+
370
+ module.exports = {
371
+ parse: parse,
372
+ compliancePosture: compliancePosture,
373
+ PROFILES: PROFILES,
374
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
375
+ KNOWN_ACTIONS: KNOWN_ACTIONS,
376
+ GuardDsnError: GuardDsnError,
377
+ NAME: "dsn",
378
+ KIND: "delivery-status",
379
+ };
@@ -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
+ };