@blamejs/core 0.9.28 → 0.9.39
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 +886 -875
- package/index.js +20 -1
- package/lib/agent-snapshot.js +346 -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-list-unsubscribe.js +337 -0
- package/lib/guard-smtp-command.js +484 -0
- package/lib/guard-snapshot-envelope.js +168 -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
package/lib/mail-helo.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.helo
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail HELO
|
|
6
|
+
* @order 550
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* RFC 5321 §4.1.1.1 HELO / EHLO hostname validation primitive +
|
|
10
|
+
* forward-confirmed-reverse-DNS (FCrDNS, RFC 8601 §2.7.6) verifier.
|
|
11
|
+
* Composes `b.network.dns.resolver` (v0.9.31) for the rDNS + forward
|
|
12
|
+
* lookup pair; pairs with `b.guardSmtpCommand` (v0.9.32) which gates
|
|
13
|
+
* the command-line SHAPE — this primitive evaluates the SEMANTIC
|
|
14
|
+
* identity claim.
|
|
15
|
+
*
|
|
16
|
+
* The MX listener (v0.9.36) calls `b.mail.helo.evaluate({ ip,
|
|
17
|
+
* claimedName, resolver })` at the EHLO boundary and feeds the
|
|
18
|
+
* verdict into the per-connection policy decision (reject /
|
|
19
|
+
* greylist-anyway / score-tag for downstream SpamAssassin / accept).
|
|
20
|
+
*
|
|
21
|
+
* ## Shape gate (RFC 5321 §4.1.1.1 + §4.1.2)
|
|
22
|
+
*
|
|
23
|
+
* - **Domain form**: LDH labels per RFC 5321 §2.3.5, FQDN with at
|
|
24
|
+
* least one `.` (operator can demand multi-label via profile).
|
|
25
|
+
* Bare hostname (no dots) refused under `strict`; localhost-class
|
|
26
|
+
* claims (`localhost`, `localdomain`) always refused regardless
|
|
27
|
+
* of profile.
|
|
28
|
+
* - **Address-literal**: `[1.2.3.4]` IPv4 or `[IPv6:2001:db8::1]`
|
|
29
|
+
* IPv6 per RFC 5321 §4.1.3 — accepted when matches the connection
|
|
30
|
+
* IP, refused otherwise (RFC 5321 §4.1.1.1 implies the literal
|
|
31
|
+
* should be the actual host).
|
|
32
|
+
* - **Empty / too-long**: refused under all profiles.
|
|
33
|
+
*
|
|
34
|
+
* ## FCrDNS check (RFC 8601 §2.7.6 / RFC 1912 §2.1)
|
|
35
|
+
*
|
|
36
|
+
* With `resolver` provided, `evaluate()` issues:
|
|
37
|
+
*
|
|
38
|
+
* 1. PTR for `<connection-ip>.in-addr.arpa` (IPv4) or `.ip6.arpa`
|
|
39
|
+
* (IPv6) — reverse name.
|
|
40
|
+
* 2. A / AAAA for each PTR result — forward name.
|
|
41
|
+
* 3. Match: at least one forward IP must equal the connection IP
|
|
42
|
+
* (the FCrDNS contract).
|
|
43
|
+
*
|
|
44
|
+
* The returned verdict carries the rDNS name(s) + the per-name
|
|
45
|
+
* forward-match outcome so operator audit pipelines see exactly why
|
|
46
|
+
* FCrDNS passed or failed.
|
|
47
|
+
*
|
|
48
|
+
* ## "Generic rDNS" heuristic (operator-configurable)
|
|
49
|
+
*
|
|
50
|
+
* Many spam sources have FCrDNS-valid rDNS that's CLEARLY a consumer
|
|
51
|
+
* ISP dynamic pool (`pool-xx-xx.dialup.example.com`,
|
|
52
|
+
* `dsl-1234.foo.example.net`, etc.). Operators opt-in via
|
|
53
|
+
* `{ genericRdnsPatterns: [<regex>...] }` and the verdict flags
|
|
54
|
+
* genericRdns: true. Pre-shipped pattern list lives in
|
|
55
|
+
* `b.mail.helo.GENERIC_RDNS_PATTERNS` for the common
|
|
56
|
+
* consumer-ISP shapes; operator extends per-deployment.
|
|
57
|
+
*
|
|
58
|
+
* ## Verdict shape
|
|
59
|
+
*
|
|
60
|
+
* ```js
|
|
61
|
+
* {
|
|
62
|
+
* action: "accept" | "reject-shape" | "soft-fail-fcrdns" |
|
|
63
|
+
* "match-self-refused" | "literal-mismatch",
|
|
64
|
+
* shape: "domain" | "address-literal-v4" |
|
|
65
|
+
* "address-literal-v6" | "bare-host" | "invalid",
|
|
66
|
+
* fcrdns: {
|
|
67
|
+
* checked: boolean,
|
|
68
|
+
* passed: boolean,
|
|
69
|
+
* rdnsNames: string[],
|
|
70
|
+
* forwardIps: string[],
|
|
71
|
+
* matchedIp: string | null,
|
|
72
|
+
* } | null,
|
|
73
|
+
* genericRdns: boolean,
|
|
74
|
+
* reason: string,
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* ## CVE / threat model
|
|
79
|
+
*
|
|
80
|
+
* - **HELO spoofing** — RFC 5321 §4.1.1.1 doesn't require HELO
|
|
81
|
+
* accuracy, but a peer claiming `our-mx-cluster.example.com`
|
|
82
|
+
* when its FCrDNS resolves elsewhere is suspect. Operator's
|
|
83
|
+
* `selfNames` list blocks the self-claim spoof.
|
|
84
|
+
* - **Botnet residential-IP class** — generic-rDNS detection +
|
|
85
|
+
* RBL composition catches consumer-ISP dynamic-pool sources
|
|
86
|
+
* before they reach the DATA phase.
|
|
87
|
+
* - **DNS poisoning of PTR** — composed via `b.network.dns.resolver`,
|
|
88
|
+
* so PTR queries inherit the resolver's `safeDns` caps, AD-bit
|
|
89
|
+
* surface, and CVE coverage (CVE-2008-1447 / 2022-3204 /
|
|
90
|
+
* 2023-50387 / 50868 / 2024-1737).
|
|
91
|
+
*
|
|
92
|
+
* ## When NOT to enforce FCrDNS strict
|
|
93
|
+
*
|
|
94
|
+
* IPv6 PTR records are spotty across consumer ISPs; FCrDNS-strict
|
|
95
|
+
* on IPv6 traffic over-rejects. Operator opts to
|
|
96
|
+
* `{ fcrdnsRequiredFor: ["v4"] }` under `balanced` profile when
|
|
97
|
+
* they need to accept v6 senders without PTR records (common with
|
|
98
|
+
* legitimate cloud / VPS providers that don't auto-publish rDNS).
|
|
99
|
+
*
|
|
100
|
+
* @card
|
|
101
|
+
* RFC 5321 §4.1.1.1 HELO/EHLO validation + RFC 8601 §2.7.6 FCrDNS check. Composes b.network.dns.resolver for PTR + forward lookups. Verdict carries shape + FCrDNS pass/fail + generic-rDNS flag for MX listener policy. Operator-configurable selfNames, genericRdnsPatterns, fcrdnsRequiredFor.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
var { defineClass } = require("./framework-error");
|
|
105
|
+
var lazyRequire = require("./lazy-require");
|
|
106
|
+
var ipUtils = require("./ip-utils");
|
|
107
|
+
|
|
108
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
109
|
+
|
|
110
|
+
var MailHeloError = defineClass("MailHeloError", { alwaysPermanent: true });
|
|
111
|
+
|
|
112
|
+
// RFC 5321 §2.3.5 LDH label: alphanumeric + hyphen (not leading or
|
|
113
|
+
// trailing); §2.3.5 domain: dot-joined LDH labels; total ≤ 255 octets
|
|
114
|
+
// per RFC 1035 §2.3.4.
|
|
115
|
+
var LDH_LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/; // allow:regex-no-length-cap — anchored + per-label repeat cap matches RFC 5321 §2.3.5
|
|
116
|
+
var ADDR_LIT_V4_RE = /^\[((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})\]$/; // allow:regex-no-length-cap — anchored + per-octet repeat cap
|
|
117
|
+
var ADDR_LIT_V6_RE = /^\[IPv6:([0-9a-fA-F:.]+)\]$/; // allow:regex-no-length-cap — IPv6 textual bounded by overall maxBytes
|
|
118
|
+
|
|
119
|
+
var DEFAULT_MAX_BYTES = 255; // allow:raw-byte-literal — RFC 1035 §2.3.4 cap
|
|
120
|
+
var DEFAULT_PROFILE = "strict";
|
|
121
|
+
|
|
122
|
+
var PROFILES = Object.freeze({
|
|
123
|
+
// Strict: FQDN (≥1 dot), no localhost, no bare-host, FCrDNS strict
|
|
124
|
+
// for IPv4 + IPv6.
|
|
125
|
+
strict: {
|
|
126
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
127
|
+
requireFqdn: true,
|
|
128
|
+
refuseBareHost: true,
|
|
129
|
+
fcrdnsRequiredFor: ["v4", "v6"],
|
|
130
|
+
},
|
|
131
|
+
// Balanced: FQDN required, FCrDNS strict for v4 only (consumer IPv6
|
|
132
|
+
// PTR records are spotty).
|
|
133
|
+
balanced: {
|
|
134
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
135
|
+
requireFqdn: true,
|
|
136
|
+
refuseBareHost: true,
|
|
137
|
+
fcrdnsRequiredFor: ["v4"],
|
|
138
|
+
},
|
|
139
|
+
// Permissive: shape only; no FCrDNS gate.
|
|
140
|
+
permissive: {
|
|
141
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
142
|
+
requireFqdn: false,
|
|
143
|
+
refuseBareHost: false,
|
|
144
|
+
fcrdnsRequiredFor: [],
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
149
|
+
hipaa: "strict",
|
|
150
|
+
"pci-dss": "strict",
|
|
151
|
+
gdpr: "strict",
|
|
152
|
+
soc2: "strict",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Operator-extensible default list of generic-rDNS patterns the
|
|
156
|
+
// framework ships. Each is a RegExp — case-insensitive — designed
|
|
157
|
+
// to catch the obvious consumer-ISP dynamic-pool naming shapes.
|
|
158
|
+
// The framework's value-add is the gate primitive, not curating a
|
|
159
|
+
// world-class generic-rDNS list; operators extend per deployment.
|
|
160
|
+
var GENERIC_RDNS_PATTERNS = Object.freeze([
|
|
161
|
+
/dynamic/i, // allow:regex-no-length-cap — case-insensitive partial; input length already capped
|
|
162
|
+
/\bdial-?up\b/i, // allow:regex-no-length-cap
|
|
163
|
+
/\bdsl\b/i, // allow:regex-no-length-cap
|
|
164
|
+
/\bcable\b/i, // allow:regex-no-length-cap
|
|
165
|
+
/\bpool[-_]/i, // allow:regex-no-length-cap
|
|
166
|
+
/\bppp[0-9]/i, // allow:regex-no-length-cap
|
|
167
|
+
/\bbroadband\b/i, // allow:regex-no-length-cap
|
|
168
|
+
/\b[0-9]{1,3}-[0-9]{1,3}-[0-9]{1,3}-[0-9]{1,3}\b/, // allow:regex-no-length-cap — IPv4-in-name shape (no anchor on purpose; runs over capped input)
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// Localhost-class claims always refused regardless of profile.
|
|
172
|
+
var LOCALHOST_REFUSED = Object.freeze({
|
|
173
|
+
"localhost": true,
|
|
174
|
+
"localhost.localdomain": true,
|
|
175
|
+
"localdomain": true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @primitive b.mail.helo.evaluate
|
|
180
|
+
* @signature b.mail.helo.evaluate(ctx, opts?)
|
|
181
|
+
* @since 0.9.35
|
|
182
|
+
* @status stable
|
|
183
|
+
* @related b.guardSmtpCommand.validate, b.network.dns.resolver.create
|
|
184
|
+
*
|
|
185
|
+
* Evaluate a peer's HELO / EHLO identity claim. Returns a verdict
|
|
186
|
+
* shape the MX listener consumes to drive accept / reject /
|
|
187
|
+
* score-tag policy.
|
|
188
|
+
*
|
|
189
|
+
* @opts
|
|
190
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
191
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
192
|
+
* selfNames: string[], // operator's MX hostnames; claim of these by a peer refused
|
|
193
|
+
* genericRdnsPatterns: RegExp[], // additional patterns layered onto built-ins
|
|
194
|
+
* fcrdnsRequiredFor: ("v4" | "v6")[], // overrides profile's FCrDNS list
|
|
195
|
+
* audit: b.audit namespace,
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* var resolver = b.network.dns.resolver.create();
|
|
199
|
+
* var v = await b.mail.helo.evaluate({
|
|
200
|
+
* ip: "203.0.113.42",
|
|
201
|
+
* claimedName: "mail.example.com",
|
|
202
|
+
* resolver: resolver,
|
|
203
|
+
* }, { profile: "strict" });
|
|
204
|
+
* if (v.action === "reject-shape") return reply(550, v.reason);
|
|
205
|
+
*/
|
|
206
|
+
async function evaluate(ctx, opts) {
|
|
207
|
+
opts = opts || {};
|
|
208
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
209
|
+
if (!PROFILES[profile]) {
|
|
210
|
+
throw new MailHeloError("mail-helo/bad-profile",
|
|
211
|
+
"evaluate: unknown profile '" + profile + "'");
|
|
212
|
+
}
|
|
213
|
+
var caps = PROFILES[profile];
|
|
214
|
+
var fcrdnsRequiredFor = Array.isArray(opts.fcrdnsRequiredFor) ? opts.fcrdnsRequiredFor : caps.fcrdnsRequiredFor;
|
|
215
|
+
var selfNames = (opts.selfNames || []).map(function (n) { return String(n).toLowerCase(); });
|
|
216
|
+
var auditImpl = opts.audit || audit();
|
|
217
|
+
|
|
218
|
+
if (!ctx || typeof ctx !== "object") {
|
|
219
|
+
throw new MailHeloError("mail-helo/bad-input",
|
|
220
|
+
"evaluate: ctx must be a plain object");
|
|
221
|
+
}
|
|
222
|
+
if (typeof ctx.claimedName !== "string" || ctx.claimedName.length === 0) {
|
|
223
|
+
throw new MailHeloError("mail-helo/bad-input",
|
|
224
|
+
"evaluate: ctx.claimedName must be a non-empty string");
|
|
225
|
+
}
|
|
226
|
+
if (typeof ctx.ip !== "string" || ctx.ip.length === 0) {
|
|
227
|
+
throw new MailHeloError("mail-helo/bad-input",
|
|
228
|
+
"evaluate: ctx.ip must be a non-empty string");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
var claimed = ctx.claimedName.trim();
|
|
232
|
+
if (Buffer.byteLength(claimed, "utf8") > caps.maxBytes) {
|
|
233
|
+
return _emit(auditImpl, "reject-shape", {
|
|
234
|
+
shape: "invalid",
|
|
235
|
+
reason: "claimedName exceeds " + caps.maxBytes + " bytes (RFC 1035 §2.3.4)",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Classify shape: address-literal vs domain vs invalid.
|
|
240
|
+
var v4Lit = claimed.match(ADDR_LIT_V4_RE);
|
|
241
|
+
var v6Lit = claimed.match(ADDR_LIT_V6_RE);
|
|
242
|
+
if (v4Lit) {
|
|
243
|
+
if (v4Lit[1] !== ctx.ip) {
|
|
244
|
+
return _emit(auditImpl, "literal-mismatch", {
|
|
245
|
+
shape: "address-literal-v4",
|
|
246
|
+
reason: "address-literal '" + v4Lit[1] + "' does not match connection IP '" + ctx.ip + "' (RFC 5321 §4.1.1.1)",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return _emit(auditImpl, "accept", {
|
|
250
|
+
shape: "address-literal-v4",
|
|
251
|
+
reason: "address-literal matches connection IP",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (v6Lit) {
|
|
255
|
+
// Both sides expanded to canonical form for compare.
|
|
256
|
+
var claimedHex = ipUtils.expandIpv6Hex(v6Lit[1]);
|
|
257
|
+
var ipHex = ipUtils.expandIpv6Hex(ctx.ip);
|
|
258
|
+
if (!claimedHex || !ipHex || claimedHex !== ipHex) {
|
|
259
|
+
return _emit(auditImpl, "literal-mismatch", {
|
|
260
|
+
shape: "address-literal-v6",
|
|
261
|
+
reason: "IPv6 address-literal '" + v6Lit[1] + "' does not match connection IP '" + ctx.ip + "' (RFC 5321 §4.1.3)",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return _emit(auditImpl, "accept", {
|
|
265
|
+
shape: "address-literal-v6",
|
|
266
|
+
reason: "IPv6 address-literal matches connection IP",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Not an address-literal; must be a domain.
|
|
271
|
+
var lower = claimed.toLowerCase();
|
|
272
|
+
if (LOCALHOST_REFUSED[lower]) {
|
|
273
|
+
return _emit(auditImpl, "reject-shape", {
|
|
274
|
+
shape: "invalid",
|
|
275
|
+
reason: "localhost-class claim '" + lower + "' refused (RFC 6761 §6.3 reserved name)",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (selfNames.indexOf(lower) !== -1) {
|
|
279
|
+
return _emit(auditImpl, "match-self-refused", {
|
|
280
|
+
shape: "domain",
|
|
281
|
+
reason: "peer claims our own MX hostname '" + lower + "' (HELO-self spoofing)",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
var labels = claimed.split(".");
|
|
286
|
+
var isFqdn = labels.length >= 2 && labels.every(function (l) { return l.length > 0; });
|
|
287
|
+
if (!isFqdn && caps.requireFqdn) {
|
|
288
|
+
return _emit(auditImpl, "reject-shape", {
|
|
289
|
+
shape: "bare-host",
|
|
290
|
+
reason: "claimedName not FQDN (no '.'); RFC 5321 §4.1.1.1 requires primary host name",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (labels.length === 1 && caps.refuseBareHost) {
|
|
294
|
+
return _emit(auditImpl, "reject-shape", {
|
|
295
|
+
shape: "bare-host",
|
|
296
|
+
reason: "bare host '" + claimed + "' refused; FQDN required",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
for (var i = 0; i < labels.length; i += 1) {
|
|
300
|
+
var l = labels[i];
|
|
301
|
+
if (l.length === 0) {
|
|
302
|
+
return _emit(auditImpl, "reject-shape", {
|
|
303
|
+
shape: "invalid",
|
|
304
|
+
reason: "empty label (consecutive dots)",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (!LDH_LABEL_RE.test(l)) { // allow:regex-no-length-cap — claimed already capped at maxBytes; label length-bounded by LDH_LABEL_RE's repeat cap
|
|
308
|
+
return _emit(auditImpl, "reject-shape", {
|
|
309
|
+
shape: "invalid",
|
|
310
|
+
reason: "label '" + l + "' not LDH-shaped (RFC 5321 §2.3.5)",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Shape OK — run FCrDNS if resolver provided and profile requires.
|
|
316
|
+
var ipKind = ipUtils.expandIpv6Hex(ctx.ip) ? "v6" : "v4";
|
|
317
|
+
var needFcrdns = ctx.resolver && fcrdnsRequiredFor.indexOf(ipKind) !== -1;
|
|
318
|
+
if (!needFcrdns) {
|
|
319
|
+
var generic = _checkGenericRdns(opts.genericRdnsPatterns, [claimed]);
|
|
320
|
+
var rv = {
|
|
321
|
+
action: "accept",
|
|
322
|
+
shape: "domain",
|
|
323
|
+
fcrdns: null,
|
|
324
|
+
genericRdns: generic,
|
|
325
|
+
reason: "shape passed; FCrDNS skipped (no resolver or " + ipKind + " not in fcrdnsRequiredFor)",
|
|
326
|
+
};
|
|
327
|
+
_emitAudit(auditImpl, "mail.helo.accept", rv);
|
|
328
|
+
return rv;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
var fcrdnsResult = await _runFcrdns(ctx.ip, ctx.resolver);
|
|
332
|
+
var rv2 = {
|
|
333
|
+
action: fcrdnsResult.passed ? "accept" : "soft-fail-fcrdns",
|
|
334
|
+
shape: "domain",
|
|
335
|
+
fcrdns: fcrdnsResult,
|
|
336
|
+
genericRdns: _checkGenericRdns(opts.genericRdnsPatterns, fcrdnsResult.rdnsNames.concat([claimed])),
|
|
337
|
+
reason: fcrdnsResult.passed
|
|
338
|
+
? "FCrDNS verified (rDNS → forward → match connection IP)"
|
|
339
|
+
: "FCrDNS failed (rDNS resolved but forward did not match connection IP)",
|
|
340
|
+
};
|
|
341
|
+
_emitAudit(auditImpl, fcrdnsResult.passed ? "mail.helo.accept" : "mail.helo.fcrdns_failed", rv2);
|
|
342
|
+
return rv2;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function _runFcrdns(ip, resolver) {
|
|
346
|
+
var result = {
|
|
347
|
+
checked: true,
|
|
348
|
+
passed: false,
|
|
349
|
+
rdnsNames: [],
|
|
350
|
+
forwardIps: [],
|
|
351
|
+
matchedIp: null,
|
|
352
|
+
};
|
|
353
|
+
var rev = _reverseName(ip);
|
|
354
|
+
if (!rev) {
|
|
355
|
+
return result; // unparseable IP — caller already rejected
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
var ptr = await resolver.queryPtr(rev);
|
|
359
|
+
if (ptr && ptr.rrs) {
|
|
360
|
+
result.rdnsNames = ptr.rrs.map(function (r) { return r.decoded; }).filter(Boolean);
|
|
361
|
+
}
|
|
362
|
+
} catch (_e) { /* NXDOMAIN or upstream — leave empty so passed stays false */ }
|
|
363
|
+
|
|
364
|
+
for (var i = 0; i < result.rdnsNames.length; i += 1) {
|
|
365
|
+
var name = result.rdnsNames[i];
|
|
366
|
+
try {
|
|
367
|
+
var isV6 = ipUtils.expandIpv6Hex(ip) !== null;
|
|
368
|
+
var fwd = isV6 ? await resolver.queryAaaa(name) : await resolver.queryA(name);
|
|
369
|
+
if (fwd && fwd.rrs) {
|
|
370
|
+
for (var j = 0; j < fwd.rrs.length; j += 1) {
|
|
371
|
+
var fip = fwd.rrs[j].decoded;
|
|
372
|
+
if (fip) {
|
|
373
|
+
result.forwardIps.push(fip);
|
|
374
|
+
if (_ipEqual(fip, ip)) {
|
|
375
|
+
result.passed = true;
|
|
376
|
+
result.matchedIp = fip;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
} catch (_e) { /* per-name fwd failure is non-fatal; check the next */ }
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function _reverseName(ip) {
|
|
387
|
+
if (typeof ip !== "string") return null;
|
|
388
|
+
// Reuse the RFC 5782 reverse-name construction on mail-rbl —
|
|
389
|
+
// appended to in-addr.arpa or ip6.arpa for the PTR query name.
|
|
390
|
+
if (ipUtils.isIPv4Shape(ip)) {
|
|
391
|
+
return ip.split(".").reverse().join(".") + ".in-addr.arpa";
|
|
392
|
+
}
|
|
393
|
+
var hex = ipUtils.expandIpv6Hex(ip);
|
|
394
|
+
if (!hex) return null;
|
|
395
|
+
var rev = "";
|
|
396
|
+
for (var i = hex.length - 1; i >= 0; i -= 1) {
|
|
397
|
+
rev += hex[i];
|
|
398
|
+
if (i > 0) rev += ".";
|
|
399
|
+
}
|
|
400
|
+
return rev + ".ip6.arpa";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _ipEqual(a, b) {
|
|
404
|
+
if (a === b) return true;
|
|
405
|
+
var ha = ipUtils.expandIpv6Hex(a);
|
|
406
|
+
var hb = ipUtils.expandIpv6Hex(b);
|
|
407
|
+
if (ha && hb) return ha === hb;
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function _checkGenericRdns(extraPatterns, names) {
|
|
412
|
+
var patterns = GENERIC_RDNS_PATTERNS.slice();
|
|
413
|
+
if (Array.isArray(extraPatterns)) {
|
|
414
|
+
for (var p = 0; p < extraPatterns.length; p += 1) {
|
|
415
|
+
if (extraPatterns[p] instanceof RegExp) patterns.push(extraPatterns[p]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (var n = 0; n < names.length; n += 1) {
|
|
419
|
+
if (typeof names[n] !== "string") continue;
|
|
420
|
+
for (var q = 0; q < patterns.length; q += 1) {
|
|
421
|
+
if (patterns[q].test(names[n])) return true;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function _emit(auditImpl, action, partial) {
|
|
428
|
+
var rv = Object.assign({
|
|
429
|
+
action: action,
|
|
430
|
+
fcrdns: null,
|
|
431
|
+
genericRdns: false,
|
|
432
|
+
}, partial);
|
|
433
|
+
_emitAudit(auditImpl, "mail.helo." + action, rv);
|
|
434
|
+
return rv;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function _emitAudit(auditImpl, action, metadata) {
|
|
438
|
+
try {
|
|
439
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
440
|
+
auditImpl.safeEmit({
|
|
441
|
+
action: action,
|
|
442
|
+
outcome: "success",
|
|
443
|
+
metadata: metadata,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
} catch (_e) { /* drop-silent — audit emit failure must not block MX accept loop */ }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @primitive b.mail.helo.compliancePosture
|
|
451
|
+
* @signature b.mail.helo.compliancePosture(posture)
|
|
452
|
+
* @since 0.9.35
|
|
453
|
+
* @status stable
|
|
454
|
+
*
|
|
455
|
+
* Return the effective profile name for a compliance posture, or
|
|
456
|
+
* `null` for unknown posture names.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* b.mail.helo.compliancePosture("hipaa"); // → "strict"
|
|
460
|
+
*/
|
|
461
|
+
function compliancePosture(posture) {
|
|
462
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
module.exports = {
|
|
466
|
+
evaluate: evaluate,
|
|
467
|
+
compliancePosture: compliancePosture,
|
|
468
|
+
PROFILES: PROFILES,
|
|
469
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
470
|
+
GENERIC_RDNS_PATTERNS: GENERIC_RDNS_PATTERNS,
|
|
471
|
+
MailHeloError: MailHeloError,
|
|
472
|
+
_reverseName: _reverseName,
|
|
473
|
+
};
|