@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,448 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.greylist
4
+ * @nav Mail
5
+ * @title Mail Greylist
6
+ * @order 545
7
+ *
8
+ * @intro
9
+ * RFC 6647 email greylisting primitive. Defers first-seen senders
10
+ * with a 4yz SMTP tempfail so transient connections (spam, snowshoe
11
+ * campaigns, single-attempt botnets) drop and legitimate MTAs (which
12
+ * retry per RFC 5321 §4.5.4) re-connect after the operator-configured
13
+ * minimum delay and pass through.
14
+ *
15
+ * ## Triplet fingerprint (RFC 6647 §4.4)
16
+ *
17
+ * Each greylist entry is keyed by the operator-recommended triple:
18
+ *
19
+ * - **Connection IP** (CIDR-normalized — default `/24` for IPv4,
20
+ * `/64` for IPv6 so adjacent retry hosts from the same MTA
21
+ * cluster share a single entry).
22
+ * - **RFC 5321 MailFrom** (envelope-from; lowercased; bounces
23
+ * carry `<>` and key as the literal empty string).
24
+ * - **First RFC 5321 RcptTo** — RFC 6647 §4.4 notes legitimate
25
+ * MTAs don't reorder recipients on retry, so keying on the
26
+ * first RcptTo is sufficient.
27
+ *
28
+ * The triplet is hashed via `b.crypto.namespaceHash("mail.greylist",
29
+ * ip-cidr + "\\0" + mailfrom + "\\0" + first-rcpt)` so the on-disk
30
+ * key is unlinkable to the PII triplet (privacy + GDPR Art. 5(1)(c)
31
+ * data minimization).
32
+ *
33
+ * ## Window + TTL (RFC 6647 §4.5)
34
+ *
35
+ * - **`minDelayMs`** — minimum delay between first-seen and
36
+ * accept-on-retry (default 5 minutes; RFC 6647 §4.5 recommends
37
+ * "from one minute to 24 hours"). Retries inside the window get
38
+ * another 4yz tempfail; the existing fingerprint stays in place.
39
+ * - **`whitelistTtlMs`** — duration to remember a passed-greylist
40
+ * entry (default 36 days; RFC 6647 §4.5 recommends ≥1 week). On
41
+ * expiry the entry is gc'd and the next first-seen attempt is
42
+ * deferred again.
43
+ * - **`maxEntries`** — operator-configurable upper bound on the
44
+ * active-fingerprint store (default 1M). Bounds memory for the
45
+ * in-memory backend; the dbStore backend is bounded by DB row
46
+ * count.
47
+ *
48
+ * ## Backend abstraction
49
+ *
50
+ * `b.mail.greylist.create({ store: <store>, ... })` accepts any
51
+ * `{ get(key) → entry|null, put(key, entry, ttlMs), delete(key),
52
+ * gc(olderThanMs) → count }`-shaped backend. In-memory default
53
+ * ships for single-process MX deployments; the operator wires a
54
+ * sqlite-backed adapter or external DB for multi-process MX
55
+ * fleets (a retry landing on a different process needs to see the
56
+ * fingerprint planted by the first attempt).
57
+ *
58
+ * ## Verdict
59
+ *
60
+ * `instance.check({ ip, mailFrom, rcptTo })` → `{ action, reason,
61
+ * firstSeenAt?, ttlExpiresAt? }`:
62
+ *
63
+ * - **`"defer"`** — first-seen or retry-too-soon. Operator returns
64
+ * SMTP `451 4.7.1 <reason>` (RFC 6647 §4.5 + RFC 5321 §4.2.5).
65
+ * - **`"accept"`** — within the post-acceptance whitelist window;
66
+ * operator continues the SMTP transaction.
67
+ * - **`"accept-first-pass"`** — retry after `minDelayMs` elapsed
68
+ * on a previously-deferred fingerprint; operator continues AND
69
+ * the framework marks the fingerprint as whitelisted for the
70
+ * full TTL window.
71
+ *
72
+ * ## CVE / threat model
73
+ *
74
+ * - **Snowshoe + single-attempt bot flood** — the defining defense:
75
+ * transient sources don't retry, so they never reach the message
76
+ * body. Pre-DKIM / pre-content-scan defense — cheap rejection.
77
+ * - **Fingerprint-store poisoning** — operator-supplied IPs +
78
+ * mailfrom strings are hashed (no raw PII on disk) and bounded
79
+ * (`maxEntries`); a hostile peer that tries to inflate the store
80
+ * hits the cap and the framework rotates oldest-first.
81
+ * - **CIDR-aggregation bypass** — operators with retry-aware MTA
82
+ * clusters (Gmail, Outlook, AWS SES) need /24 IPv4 and /64 IPv6
83
+ * so the cluster's retry from a different host in the same
84
+ * subnet passes; the defaults match real-world MTA behavior.
85
+ *
86
+ * ## When NOT to greylist
87
+ *
88
+ * - Listserv submissions (operator opts the listserv source out
89
+ * via `allowedSources` per RFC 6647 §6.2).
90
+ * - First-time newsletter sign-up confirmations (a single first
91
+ * attempt would defer the confirmation email; operator opts
92
+ * submission relay paths out).
93
+ * - High-priority transactional sources the operator has direct
94
+ * relationship with (banking, healthcare 2FA, etc.).
95
+ *
96
+ * @card
97
+ * RFC 6647 SMTP greylisting. Triplet fingerprint (IP CIDR + MailFrom + first RcptTo), namespace-hashed, configurable minDelayMs + whitelistTtlMs windows. Defers first-seen senders with SMTP 451 4.7.1; legitimate retries pass and stay whitelisted. Pluggable backend.
98
+ */
99
+
100
+ var C = require("./constants");
101
+ var { defineClass } = require("./framework-error");
102
+ var bCrypto = require("./crypto");
103
+ var lazyRequire = require("./lazy-require");
104
+ var ipUtils = require("./ip-utils");
105
+
106
+ var audit = lazyRequire(function () { return require("./audit"); });
107
+
108
+ var MailGreylistError = defineClass("MailGreylistError", { alwaysPermanent: true });
109
+
110
+ var DEFAULT_MIN_DELAY_MS = C.TIME.minutes(5);
111
+ var DEFAULT_WHITELIST_TTL = C.TIME.days(36);
112
+ var DEFAULT_MAX_ENTRIES = 1000000; // allow:raw-byte-literal — entry-count cap, not bytes
113
+ var DEFAULT_IPV4_PREFIX = 24; // allow:raw-byte-literal — RFC 6647 §4.4 IP-clustering granularity
114
+ var DEFAULT_IPV6_PREFIX = 64; // allow:raw-byte-literal — RFC 6647 §4.4 IPv6 IP-clustering granularity
115
+ var DEFAULT_PROFILE = "strict";
116
+
117
+ var PROFILES = Object.freeze({
118
+ // Strict: low delay, modest whitelist TTL. Catches snowshoe but
119
+ // retries from legitimate MTAs (which back off 5-15 min on tempfail)
120
+ // pass quickly.
121
+ strict: { minDelayMs: C.TIME.minutes(5), whitelistTtlMs: C.TIME.days(36), ipv4Prefix: 24, ipv6Prefix: 64 }, // allow:raw-byte-literal — RFC 6647 §4.4 prefixes
122
+ // Balanced: minimum 1 min delay, shorter TTL for higher churn.
123
+ balanced: { minDelayMs: C.TIME.minutes(1), whitelistTtlMs: C.TIME.days(7), ipv4Prefix: 24, ipv6Prefix: 64 }, // allow:raw-byte-literal — RFC 6647 §4.4 prefixes
124
+ // Permissive: 30s delay, 30-day TTL. For operators that want
125
+ // greylisting present but minimally visible.
126
+ permissive: { minDelayMs: C.TIME.seconds(30), whitelistTtlMs: C.TIME.days(30), ipv4Prefix: 32, ipv6Prefix: 128 }, // allow:raw-byte-literal — RFC 6647 §4.4 prefixes
127
+ });
128
+
129
+ var COMPLIANCE_POSTURES = Object.freeze({
130
+ hipaa: "strict",
131
+ "pci-dss": "strict",
132
+ gdpr: "strict",
133
+ soc2: "strict",
134
+ });
135
+
136
+ var IPV4_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
137
+ var IPV6_RE = /^[0-9a-fA-F:]+$/; // allow:regex-no-length-cap — length-checked separately
138
+
139
+ /**
140
+ * @primitive b.mail.greylist.create
141
+ * @signature b.mail.greylist.create(opts?)
142
+ * @since 0.9.34
143
+ * @status stable
144
+ * @related b.mail.rbl.create
145
+ *
146
+ * Build a greylist instance. Returns an object with `.check(ctx) →
147
+ * Promise<verdict>` and `.gc({ olderThanMs }) → Promise<{ removed }>`.
148
+ *
149
+ * @opts
150
+ * profile: "strict" | "balanced" | "permissive",
151
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
152
+ * store: { get, put, delete, gc } — pluggable backend
153
+ * minDelayMs: number — overrides profile minimum-delay window
154
+ * whitelistTtlMs: number — overrides profile post-acceptance TTL
155
+ * maxEntries: number — in-memory backend's entry cap
156
+ * allowedSources: Array<string> — IPs / CIDRs that skip greylisting
157
+ * audit: b.audit namespace
158
+ *
159
+ * @example
160
+ * var gl = b.mail.greylist.create({ profile: "strict" });
161
+ * var v = await gl.check({
162
+ * ip: "203.0.113.42",
163
+ * mailFrom: "sender@example.com",
164
+ * rcptTo: "alice@operator.example",
165
+ * });
166
+ * if (v.action === "defer") return reply(451, "4.7.1 " + v.reason);
167
+ */
168
+ function create(opts) {
169
+ opts = opts || {};
170
+ var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
171
+ if (!PROFILES[profile]) {
172
+ throw new MailGreylistError("mail-greylist/bad-profile",
173
+ "create: unknown profile '" + profile + "'");
174
+ }
175
+ var caps = PROFILES[profile];
176
+ var minDelayMs = typeof opts.minDelayMs === "number" ? opts.minDelayMs : caps.minDelayMs;
177
+ var whitelistTtlMs = typeof opts.whitelistTtlMs === "number" ? opts.whitelistTtlMs : caps.whitelistTtlMs;
178
+ var maxEntries = typeof opts.maxEntries === "number" ? opts.maxEntries : DEFAULT_MAX_ENTRIES;
179
+ var ipv4Prefix = caps.ipv4Prefix;
180
+ var ipv6Prefix = caps.ipv6Prefix;
181
+ var auditImpl = opts.audit || audit();
182
+ var allowedSources = Array.isArray(opts.allowedSources) ? opts.allowedSources.slice() : [];
183
+
184
+ if (!isFinite(minDelayMs) || minDelayMs < 0) {
185
+ throw new MailGreylistError("mail-greylist/bad-input",
186
+ "create: minDelayMs must be a non-negative finite number");
187
+ }
188
+ if (!isFinite(whitelistTtlMs) || whitelistTtlMs <= 0) {
189
+ throw new MailGreylistError("mail-greylist/bad-input",
190
+ "create: whitelistTtlMs must be a positive finite number");
191
+ }
192
+ if (!isFinite(maxEntries) || maxEntries <= 0) {
193
+ throw new MailGreylistError("mail-greylist/bad-input",
194
+ "create: maxEntries must be a positive finite number");
195
+ }
196
+
197
+ var store = opts.store || _memoryStore(maxEntries);
198
+
199
+ async function check(ctx) {
200
+ if (!ctx || typeof ctx !== "object") {
201
+ throw new MailGreylistError("mail-greylist/bad-input",
202
+ "check: ctx must be a plain object");
203
+ }
204
+ if (typeof ctx.ip !== "string" || ctx.ip.length === 0) {
205
+ throw new MailGreylistError("mail-greylist/bad-input",
206
+ "check: ctx.ip must be a non-empty string");
207
+ }
208
+ if (typeof ctx.mailFrom !== "string") {
209
+ throw new MailGreylistError("mail-greylist/bad-input",
210
+ "check: ctx.mailFrom must be a string (use '' for bounce sender)");
211
+ }
212
+ if (typeof ctx.rcptTo !== "string" || ctx.rcptTo.length === 0) {
213
+ throw new MailGreylistError("mail-greylist/bad-input",
214
+ "check: ctx.rcptTo must be a non-empty string");
215
+ }
216
+
217
+ // Allowed-source bypass — operator-supplied IPs / CIDRs that
218
+ // skip greylisting (RFC 6647 §6.2). Listservs, transactional
219
+ // partners, etc.
220
+ if (_isAllowed(ctx.ip, allowedSources)) {
221
+ _emitAudit(auditImpl, "mail.greylist.bypassed", {
222
+ reason: "allowed-source", ip: ctx.ip,
223
+ });
224
+ return { action: "accept", reason: "allowed-source" };
225
+ }
226
+
227
+ var cidr = _cidrKey(ctx.ip, ipv4Prefix, ipv6Prefix);
228
+ var fingerprint = _hashFingerprint(cidr, ctx.mailFrom.toLowerCase(), ctx.rcptTo.toLowerCase());
229
+
230
+ var now = typeof ctx.now === "number" ? ctx.now : Date.now();
231
+ var existing = await store.get(fingerprint);
232
+
233
+ if (!existing) {
234
+ // First-seen: defer + persist fingerprint with firstSeenAt.
235
+ await store.put(fingerprint, {
236
+ firstSeenAt: now,
237
+ whitelistedAt: null,
238
+ kind: "deferred",
239
+ }, minDelayMs + whitelistTtlMs); // total lifetime so the entry survives the delay window
240
+ _emitAudit(auditImpl, "mail.greylist.deferred", {
241
+ firstSeen: true, cidr: cidr,
242
+ });
243
+ return { action: "defer", reason: "first-seen", firstSeenAt: now };
244
+ }
245
+
246
+ if (existing.kind === "whitelisted") {
247
+ if (now > existing.ttlExpiresAt) {
248
+ // TTL elapsed without recent traffic — RFC 6647 §4.5
249
+ // recommends gc'ing and re-greylisting on the next attempt.
250
+ // We do it on read so a low-traffic deployment doesn't carry
251
+ // stale rows.
252
+ await store.delete(fingerprint);
253
+ await store.put(fingerprint, {
254
+ firstSeenAt: now,
255
+ whitelistedAt: null,
256
+ kind: "deferred",
257
+ }, minDelayMs + whitelistTtlMs);
258
+ _emitAudit(auditImpl, "mail.greylist.deferred", {
259
+ firstSeen: false, expired: true, cidr: cidr,
260
+ });
261
+ return { action: "defer", reason: "whitelist-expired-resnap", firstSeenAt: now };
262
+ }
263
+ _emitAudit(auditImpl, "mail.greylist.accepted", { cidr: cidr });
264
+ return {
265
+ action: "accept",
266
+ reason: "whitelisted",
267
+ ttlExpiresAt: existing.ttlExpiresAt,
268
+ };
269
+ }
270
+
271
+ // Deferred entry — check if retry-after delay elapsed.
272
+ if (now - existing.firstSeenAt < minDelayMs) {
273
+ _emitAudit(auditImpl, "mail.greylist.deferred", {
274
+ firstSeen: false, retryTooSoon: true, cidr: cidr,
275
+ });
276
+ return {
277
+ action: "defer",
278
+ reason: "retry-too-soon",
279
+ firstSeenAt: existing.firstSeenAt,
280
+ };
281
+ }
282
+
283
+ // First-pass: retry-after-delay accepted. Mark whitelisted.
284
+ var ttlExpiresAt = now + whitelistTtlMs;
285
+ await store.put(fingerprint, {
286
+ firstSeenAt: existing.firstSeenAt,
287
+ whitelistedAt: now,
288
+ ttlExpiresAt: ttlExpiresAt,
289
+ kind: "whitelisted",
290
+ }, whitelistTtlMs);
291
+ _emitAudit(auditImpl, "mail.greylist.first_pass", {
292
+ delayMs: now - existing.firstSeenAt, cidr: cidr,
293
+ });
294
+ return {
295
+ action: "accept-first-pass",
296
+ reason: "retry-after-delay",
297
+ firstSeenAt: existing.firstSeenAt,
298
+ ttlExpiresAt: ttlExpiresAt,
299
+ };
300
+ }
301
+
302
+ async function gc(gcOpts) {
303
+ gcOpts = gcOpts || {};
304
+ var olderThanMs = typeof gcOpts.olderThanMs === "number" ? gcOpts.olderThanMs : whitelistTtlMs;
305
+ var removed = await store.gc(olderThanMs);
306
+ _emitAudit(auditImpl, "mail.greylist.gc", { removed: removed });
307
+ return { removed: removed };
308
+ }
309
+
310
+ return {
311
+ check: check,
312
+ gc: gc,
313
+ profile: profile,
314
+ minDelayMs: minDelayMs,
315
+ whitelistTtlMs: whitelistTtlMs,
316
+ MailGreylistError: MailGreylistError,
317
+ };
318
+ }
319
+
320
+ /**
321
+ * @primitive b.mail.greylist.compliancePosture
322
+ * @signature b.mail.greylist.compliancePosture(posture)
323
+ * @since 0.9.34
324
+ * @status stable
325
+ *
326
+ * Return the effective profile name for a compliance posture, or
327
+ * `null` for unknown posture names.
328
+ *
329
+ * @example
330
+ * b.mail.greylist.compliancePosture("hipaa"); // → "strict"
331
+ */
332
+ function compliancePosture(posture) {
333
+ return COMPLIANCE_POSTURES[posture] || null;
334
+ }
335
+
336
+ function _hashFingerprint(cidr, mailFrom, rcptTo) {
337
+ // Namespace-hash so the on-disk key is unlinkable to the PII
338
+ // triplet — operator dumps of the greylist table don't leak
339
+ // sender / recipient pairs. The framework's hash primitive
340
+ // (sha3-512 inside namespaceHash) is bound to the "mail.greylist"
341
+ // namespace so a hash never collides with another consumer's hash
342
+ // of the same plaintext.
343
+ return bCrypto.namespaceHash("mail.greylist",
344
+ cidr + "\u0000" + mailFrom + "\u0000" + rcptTo);
345
+ }
346
+
347
+ function _cidrKey(ip, ipv4Prefix, ipv6Prefix) {
348
+ if (IPV4_RE.test(ip)) {
349
+ var octets = ip.split(".").map(function (s) { return parseInt(s, 10); });
350
+ var prefix = Math.min(32, Math.max(0, ipv4Prefix)); // allow:raw-byte-literal — IPv4 address bit width
351
+ // Apply prefix: zero out the host bits.
352
+ var int = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]; // allow:raw-byte-literal — IPv4 byte shifts
353
+ var mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; // allow:raw-byte-literal — IPv4 mask construction
354
+ var masked = (int & mask) >>> 0;
355
+ return [
356
+ (masked >>> 24) & 0xff, // allow:raw-byte-literal — IPv4 byte extraction
357
+ (masked >>> 16) & 0xff,
358
+ (masked >>> 8) & 0xff,
359
+ masked & 0xff,
360
+ ].join(".") + "/" + prefix;
361
+ }
362
+ if (IPV6_RE.test(ip)) {
363
+ // Expand IPv6, then mask. Reuse the expansion approach from
364
+ // mail-rbl by inlining since this is a different prefix shape.
365
+ var expanded = ipUtils.expandIpv6Hex(ip);
366
+ if (!expanded) {
367
+ throw new MailGreylistError("mail-greylist/bad-input",
368
+ "IP '" + ip + "' is not a parseable IPv6 address");
369
+ }
370
+ // expanded is 32 hex chars; mask to ipv6Prefix bits.
371
+ var prefixBits = Math.min(128, Math.max(0, ipv6Prefix)); // allow:raw-byte-literal — IPv6 address bit width
372
+ var keepNibbles = Math.floor(prefixBits / 4); // allow:raw-byte-literal — bits-per-hex-nibble
373
+ var keptHex = expanded.slice(0, keepNibbles);
374
+ return keptHex + "*/" + prefixBits;
375
+ }
376
+ throw new MailGreylistError("mail-greylist/bad-input",
377
+ "IP '" + ip + "' is not a parseable IPv4 or IPv6 address");
378
+ }
379
+
380
+ function _isAllowed(ip, allowedSources) {
381
+ // Allowed-source matching — exact IP only for v0.9.34. CIDR
382
+ // matching deferred to the operator's own b.middleware.ipAllowlist
383
+ // when fine-grained subnet rules are needed; this primitive's
384
+ // value-add is the triplet-greylist contract.
385
+ return allowedSources.indexOf(ip) !== -1;
386
+ }
387
+
388
+ function _emitAudit(auditImpl, action, metadata) {
389
+ try {
390
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
391
+ auditImpl.safeEmit({ action: action, outcome: "success", metadata: metadata });
392
+ }
393
+ } catch (_e) { /* drop-silent — audit failure must not block accept loop */ }
394
+ }
395
+
396
+ function _memoryStore(maxEntries) {
397
+ var data = new Map();
398
+ var insertionOrder = [];
399
+ return {
400
+ get: async function (key) {
401
+ var entry = data.get(key);
402
+ return entry ? entry.value : null;
403
+ },
404
+ put: async function (key, value, ttlMs) {
405
+ if (!data.has(key)) {
406
+ if (data.size >= maxEntries) {
407
+ // Evict oldest.
408
+ var oldest = insertionOrder.shift();
409
+ if (oldest) data.delete(oldest);
410
+ }
411
+ insertionOrder.push(key);
412
+ }
413
+ data.set(key, { value: value, putAt: Date.now(), ttlMs: ttlMs });
414
+ },
415
+ delete: async function (key) {
416
+ data.delete(key);
417
+ var i = insertionOrder.indexOf(key);
418
+ if (i !== -1) insertionOrder.splice(i, 1);
419
+ },
420
+ gc: async function (olderThanMs) {
421
+ var now = Date.now();
422
+ var removed = 0;
423
+ data.forEach(function (entry, key) {
424
+ if (now - entry.putAt > olderThanMs) {
425
+ data.delete(key);
426
+ var i = insertionOrder.indexOf(key);
427
+ if (i !== -1) insertionOrder.splice(i, 1);
428
+ removed += 1;
429
+ }
430
+ });
431
+ return removed;
432
+ },
433
+ };
434
+ }
435
+
436
+ module.exports = {
437
+ create: create,
438
+ compliancePosture: compliancePosture,
439
+ PROFILES: PROFILES,
440
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
441
+ MailGreylistError: MailGreylistError,
442
+ _hashFingerprint: _hashFingerprint,
443
+ _cidrKey: _cidrKey,
444
+ _DEFAULT_MIN_DELAY_MS: DEFAULT_MIN_DELAY_MS,
445
+ _DEFAULT_WHITELIST_TTL: DEFAULT_WHITELIST_TTL,
446
+ _DEFAULT_IPV4_PREFIX: DEFAULT_IPV4_PREFIX,
447
+ _DEFAULT_IPV6_PREFIX: DEFAULT_IPV6_PREFIX,
448
+ };