@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,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.trace
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Trace
|
|
6
|
+
* @order 85
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Distributed tracing through every agent boundary. Composes the
|
|
10
|
+
* existing `b.tracing` (W3C trace context) so operators get a full
|
|
11
|
+
* request waterfall across the agent stack without wiring spans
|
|
12
|
+
* per-handler.
|
|
13
|
+
*
|
|
14
|
+
* The substrate at v0.9.29 ships the integration surface:
|
|
15
|
+
*
|
|
16
|
+
* - `startSpan(name, opts)` — wrap an agent method call in a span
|
|
17
|
+
* - `injectIntoEnvelope(envelope, currentSpan)` — inject W3C
|
|
18
|
+
* `traceparent` + `tracestate` into queue / event-bus / sub-
|
|
19
|
+
* agent envelopes so the consumer can continue the trace
|
|
20
|
+
* - `extractFromEnvelope(envelope)` — parse the envelope's
|
|
21
|
+
* trace context (refused via `b.guardTraceContext` if
|
|
22
|
+
* malformed)
|
|
23
|
+
* - `recordResult(span, result, error?)` — close span with
|
|
24
|
+
* success / error status
|
|
25
|
+
* - `shouldSample(method)` — sampling decision (global +
|
|
26
|
+
* per-method override)
|
|
27
|
+
*
|
|
28
|
+
* Span shape (per method call):
|
|
29
|
+
*
|
|
30
|
+
* name: "<agent-kind>.<method>" // e.g. "mail.agent.search"
|
|
31
|
+
* attributes:
|
|
32
|
+
* agent.method: method name
|
|
33
|
+
* agent.dispatch_mode: "local" | "queue" | "auto"
|
|
34
|
+
* agent.tenant_id: from v0.9.26 tenant scope (if present)
|
|
35
|
+
* agent.posture: JSON-array of v0.9.28 posture set
|
|
36
|
+
* agent.shard: from v0.9.21 shard routing
|
|
37
|
+
* agent.result_status: "success" | "error" | "not_implemented"
|
|
38
|
+
* agent.elapsed_ms: integer
|
|
39
|
+
*
|
|
40
|
+
* ```js
|
|
41
|
+
* var trace = b.agent.trace.create({
|
|
42
|
+
* tracing: b.tracing.create({ instrumentationName: "mail-agent" }),
|
|
43
|
+
* sampleRate: 1.0,
|
|
44
|
+
* perMethod: { fetch: 0.1, search: 0.5, send: 1.0 },
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* var span = trace.startSpan("mail.agent.fetch", { actor, method: "fetch" });
|
|
48
|
+
* try {
|
|
49
|
+
* var result = await agent.fetch(args);
|
|
50
|
+
* trace.recordResult(span, result);
|
|
51
|
+
* } catch (e) {
|
|
52
|
+
* trace.recordResult(span, null, e);
|
|
53
|
+
* throw e;
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @card
|
|
58
|
+
* Distributed tracing through every agent boundary. W3C trace
|
|
59
|
+
* context injection at queue / event-bus / sub-agent envelopes;
|
|
60
|
+
* per-method sampling; integrated with existing b.tracing.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
var lazyRequire = require("./lazy-require");
|
|
64
|
+
var { defineClass } = require("./framework-error");
|
|
65
|
+
var guardTraceContext = require("./guard-trace-context");
|
|
66
|
+
|
|
67
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
68
|
+
|
|
69
|
+
var AgentTraceError = defineClass("AgentTraceError", { alwaysPermanent: true });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @primitive b.agent.trace.create
|
|
73
|
+
* @signature b.agent.trace.create(opts)
|
|
74
|
+
* @since 0.9.29
|
|
75
|
+
* @status stable
|
|
76
|
+
* @related b.tracing.create, b.agent.orchestrator.create
|
|
77
|
+
*
|
|
78
|
+
* Create the trace facade. Composes operator-supplied `b.tracing`
|
|
79
|
+
* instance (or stub if absent — spans become no-ops).
|
|
80
|
+
*
|
|
81
|
+
* @opts
|
|
82
|
+
* tracing: b.tracing instance, // required for live spans
|
|
83
|
+
* audit: b.audit namespace, // optional
|
|
84
|
+
* sampleRate: number in [0..1], // default 1.0
|
|
85
|
+
* perMethod: { <method>: number }, // override per-method
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* var trace = b.agent.trace.create({ tracing: myTracing, sampleRate: 0.5 });
|
|
89
|
+
* var span = trace.startSpan("mail.agent.fetch", { actor });
|
|
90
|
+
*/
|
|
91
|
+
function create(opts) {
|
|
92
|
+
opts = opts || {};
|
|
93
|
+
if (!opts.tracing || typeof opts.tracing !== "object") {
|
|
94
|
+
throw new AgentTraceError("agent-trace/bad-tracing",
|
|
95
|
+
"create: opts.tracing is required (b.tracing.create() result)");
|
|
96
|
+
}
|
|
97
|
+
var sampleRate = typeof opts.sampleRate === "number" ? opts.sampleRate : 1.0;
|
|
98
|
+
if (!isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
|
|
99
|
+
throw new AgentTraceError("agent-trace/bad-sample-rate",
|
|
100
|
+
"create: sampleRate must be in [0, 1]");
|
|
101
|
+
}
|
|
102
|
+
var perMethod = opts.perMethod || {};
|
|
103
|
+
var auditImpl = opts.audit || audit();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
startSpan: function (name, sopts) { return _startSpan(opts.tracing, name, sopts || {}); },
|
|
107
|
+
injectIntoEnvelope: function (envelope, span) { return _injectIntoEnvelope(opts.tracing, envelope, span); },
|
|
108
|
+
extractFromEnvelope: function (envelope) { return _extractFromEnvelope(envelope); },
|
|
109
|
+
recordResult: function (span, result, error) { return _recordResult(span, result, error); },
|
|
110
|
+
shouldSample: function (method) { return _shouldSample(sampleRate, perMethod, method); },
|
|
111
|
+
formatAttributes: function (info) { return _formatAttributes(info); },
|
|
112
|
+
AgentTraceError: AgentTraceError,
|
|
113
|
+
_ctx: { sampleRate: sampleRate, perMethod: perMethod, audit: auditImpl },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _startSpan(tracing, name, sopts) {
|
|
118
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
119
|
+
throw new AgentTraceError("agent-trace/bad-span-name",
|
|
120
|
+
"startSpan: name required");
|
|
121
|
+
}
|
|
122
|
+
// Compose b.tracing's manual-lifetime span — sets the span as active
|
|
123
|
+
// on the registry stack so tracing.contextHeaders() / currentSpan()
|
|
124
|
+
// see it, then exposes end() so the agent boundary controls
|
|
125
|
+
// lifetime across publish → consume.
|
|
126
|
+
if (typeof tracing.manualSpan === "function") {
|
|
127
|
+
return tracing.manualSpan(name, sopts);
|
|
128
|
+
}
|
|
129
|
+
// Operator passed a non-b.tracing object (operator-supplied OTel
|
|
130
|
+
// tracer directly) — try its native startSpan. Refuse if neither.
|
|
131
|
+
if (typeof tracing.startSpan === "function") {
|
|
132
|
+
return tracing.startSpan(name, sopts);
|
|
133
|
+
}
|
|
134
|
+
throw new AgentTraceError("agent-trace/bad-tracing",
|
|
135
|
+
"startSpan: opts.tracing must expose manualSpan() (b.tracing.create()) " +
|
|
136
|
+
"or startSpan() (raw OTel tracer); neither found");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _injectIntoEnvelope(tracing, envelope, span) {
|
|
140
|
+
if (!envelope || typeof envelope !== "object") {
|
|
141
|
+
throw new AgentTraceError("agent-trace/bad-envelope",
|
|
142
|
+
"injectIntoEnvelope: envelope required");
|
|
143
|
+
}
|
|
144
|
+
// tracing.contextHeaders() returns { traceparent, tracestate? } when
|
|
145
|
+
// a span is active. We pass through whatever's current.
|
|
146
|
+
var headers = (typeof tracing.contextHeaders === "function") ? tracing.contextHeaders() : null;
|
|
147
|
+
if (!headers || typeof headers.traceparent !== "string") return envelope;
|
|
148
|
+
envelope._trace = {
|
|
149
|
+
traceparent: headers.traceparent,
|
|
150
|
+
tracestate: headers.tracestate || "",
|
|
151
|
+
};
|
|
152
|
+
return envelope;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _extractFromEnvelope(envelope) {
|
|
156
|
+
if (!envelope || typeof envelope !== "object" || !envelope._trace) return null;
|
|
157
|
+
// Validate via guardTraceContext — refuses malformed traceparent
|
|
158
|
+
// strings before the consumer side picks them up as a parent span.
|
|
159
|
+
try {
|
|
160
|
+
guardTraceContext.validate(envelope._trace);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
throw new AgentTraceError("agent-trace/bad-envelope-trace",
|
|
163
|
+
"extractFromEnvelope: " + ((e && e.message) || String(e)));
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
traceparent: envelope._trace.traceparent,
|
|
167
|
+
tracestate: envelope._trace.tracestate || "",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _recordResult(span, result, error) {
|
|
172
|
+
if (!span || typeof span !== "object") return;
|
|
173
|
+
if (error) {
|
|
174
|
+
if (typeof span.recordException === "function") {
|
|
175
|
+
try { span.recordException(error); } catch (_e) { /* best-effort */ }
|
|
176
|
+
}
|
|
177
|
+
if (typeof span.setStatus === "function") {
|
|
178
|
+
try { span.setStatus({ code: 2, message: error.message || String(error) }); }
|
|
179
|
+
catch (_e) { /* best-effort */ }
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof span.setStatus === "function") {
|
|
182
|
+
try { span.setStatus({ code: 1 }); } catch (_e) { /* best-effort */ }
|
|
183
|
+
}
|
|
184
|
+
if (typeof span.end === "function") {
|
|
185
|
+
try { span.end(); } catch (_e) { /* best-effort */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _shouldSample(globalRate, perMethod, method) {
|
|
190
|
+
if (typeof method === "string" && Object.prototype.hasOwnProperty.call(perMethod, method)) {
|
|
191
|
+
var r = perMethod[method];
|
|
192
|
+
if (typeof r === "number" && isFinite(r) && r >= 0 && r <= 1) {
|
|
193
|
+
return Math.random() < r; // allow:math-random-noncrypto — sampling is statistical, not security-sensitive
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return Math.random() < globalRate; // allow:math-random-noncrypto — sampling is statistical, not security-sensitive
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _formatAttributes(info) {
|
|
200
|
+
if (!info || typeof info !== "object") return {};
|
|
201
|
+
var attrs = {};
|
|
202
|
+
if (info.method) attrs["agent.method"] = info.method;
|
|
203
|
+
if (info.dispatchMode) attrs["agent.dispatch_mode"] = info.dispatchMode;
|
|
204
|
+
if (info.tenantId) attrs["agent.tenant_id"] = info.tenantId;
|
|
205
|
+
if (Array.isArray(info.postureSet)) attrs["agent.posture"] = JSON.stringify(info.postureSet);
|
|
206
|
+
if (typeof info.shard === "number") attrs["agent.shard"] = info.shard;
|
|
207
|
+
if (info.resultStatus) attrs["agent.result_status"] = info.resultStatus;
|
|
208
|
+
if (typeof info.elapsedMs === "number") attrs["agent.elapsed_ms"] = info.elapsedMs;
|
|
209
|
+
return attrs;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
create: create,
|
|
214
|
+
AgentTraceError: AgentTraceError,
|
|
215
|
+
guards: {
|
|
216
|
+
context: guardTraceContext,
|
|
217
|
+
},
|
|
218
|
+
};
|
package/lib/guard-all.js
CHANGED
package/lib/guard-dsn.js
ADDED
|
@@ -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
|
+
};
|