@blamejs/core 0.9.49 → 0.10.2

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -0,0 +1,502 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.scan
4
+ * @nav Mail
5
+ * @title Mail Scan
6
+ * @order 555
7
+ *
8
+ * @intro
9
+ * Anti-virus / content-scan facade for inbound + outbound mail.
10
+ * Operators wire `b.mail.scan.create({...})` once at boot, then call
11
+ * `.scan(messageBytes, opts)` from the MX listener (v0.9.45),
12
+ * submission listener (v0.9.47), or any custom pipeline that needs a
13
+ * verdict before delivering / forwarding a message.
14
+ *
15
+ * ## Backends
16
+ *
17
+ * Two transport shapes are supported out of the box:
18
+ *
19
+ * - **ICAP** (`protocol: "icap"` — default) — RFC 3507 Internet
20
+ * Content Adaptation Protocol. The framework speaks to a c-icap
21
+ * (or commercial) daemon over TCP, sends a REQMOD or RESPMOD
22
+ * request with the message body encapsulated, and parses the
23
+ * response through `b.safeIcap.parse`. The de-facto standard for
24
+ * Sophos / Symantec / Trend Micro / McAfee ICAP integrations.
25
+ *
26
+ * - **ClamAV INSTREAM** (`protocol: "clamav-instream"`) — the
27
+ * native ClamAV daemon protocol (no ICAP layer). Operator points
28
+ * at a clamd instance; the framework sends `zINSTREAM\0`, framed
29
+ * 4-byte-length-prefix chunks, then a zero-length terminator, and
30
+ * parses the line-shaped `<id>: stream: OK` / `<id>: stream:
31
+ * <virus> FOUND` response.
32
+ * See https://docs.clamav.net/manual/Usage/Configuration.html#instream
33
+ *
34
+ * ## Composition
35
+ *
36
+ * - **`b.safeIcap`** owns the ICAP wire-protocol bounded parser
37
+ * (CRLF discipline, status-allowlist, body cap). Every ICAP byte
38
+ * routes through it before any field is trusted.
39
+ * - **`b.guardArchive`** is composed when `opts.archiveEntries` is
40
+ * supplied — the scanner refuses an archive with hostile entry
41
+ * metadata BEFORE shipping bytes to the AV daemon, so a zip-bomb
42
+ * can't reach the scanner's parser. Recursion-depth cap is the
43
+ * guard's profile-default.
44
+ * - **`b.audit`** receives every request / verdict / error /
45
+ * timeout via `audit.safeEmit` (the audit failure is drop-silent
46
+ * per the hot-path rule).
47
+ *
48
+ * ## Threat model
49
+ *
50
+ * - **ICAP-response-injection (raw bytes → header injection)**:
51
+ * defended by `b.safeIcap` — bare-CR / bare-LF / NUL refused;
52
+ * status-code allowlist; bounded header / body / count caps.
53
+ * - **Parser-bomb on Encapsulated res-body** (hostile daemon ships
54
+ * arbitrary body length): defended by profile-tunable
55
+ * `maxBodyBytes` cap on the safeIcap parse path.
56
+ * - **DoS via slow daemon**: per-request wall-clock timeout (default
57
+ * 30s strict / 60s balanced / 120s permissive). After the timeout
58
+ * the scan resolves with `{ verdict: "error" }` and the listener
59
+ * fails the message-handling step (operator's choice: tempfail /
60
+ * reject / accept-with-tag).
61
+ * - **Archive-bomb / zip-slip pre-AV**: defended by optional
62
+ * `b.guardArchive.validateEntries` composition when the operator
63
+ * enumerates entries before the AV scan.
64
+ *
65
+ * ## Why not "vendor an AV signature engine"?
66
+ *
67
+ * AV signature databases are operator state, not framework state.
68
+ * ClamAV's signature set changes hourly; commercial scanners refresh
69
+ * their state through their own update channel. The framework's job
70
+ * is the wire-protocol parser + the operator-facing facade — the AV
71
+ * intelligence belongs to whatever daemon the operator deploys.
72
+ *
73
+ * @card
74
+ * ICAP (RFC 3507) + ClamAV-INSTREAM AV-scan facade. Composes
75
+ * b.safeIcap for wire-bytes hardening, b.guardArchive for pre-scan
76
+ * archive-metadata refusal, b.audit for verdict emission. Two
77
+ * built-in backends; operator points at their existing daemon.
78
+ */
79
+
80
+ var net = require("node:net");
81
+ var safeBuffer = require("./safe-buffer");
82
+ var C = require("./constants");
83
+ var { defineClass } = require("./framework-error");
84
+ var lazyRequire = require("./lazy-require");
85
+ var validateOpts = require("./validate-opts");
86
+ var numericBounds = require("./numeric-bounds");
87
+ var safeIcap = require("./safe-icap");
88
+
89
+ var audit = lazyRequire(function () { return require("./audit"); });
90
+ var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
91
+
92
+ var MailScanError = defineClass("MailScanError", { alwaysPermanent: true });
93
+
94
+ var DEFAULT_PROFILE = "strict";
95
+ var DEFAULT_PROTOCOL = "icap";
96
+ var DEFAULT_ICAP_SERVICE = "srv_clamav";
97
+
98
+ // allow:raw-byte-literal — ClamAV INSTREAM 4-byte length prefix.
99
+ var CLAMAV_LENGTH_PREFIX_BYTES = 4;
100
+
101
+ // allow:raw-byte-literal — ClamAV INSTREAM chunk size for streaming.
102
+ var CLAMAV_CHUNK_BYTES = 65536;
103
+
104
+ var PROFILES = Object.freeze({
105
+ strict: { timeoutMs: C.TIME.seconds(30), maxMessageBytes: C.BYTES.mib(25), maxResponseBytes: C.BYTES.mib(50) }, // allow:raw-byte-literal — operator-facing default mailbox cap
106
+ balanced: { timeoutMs: C.TIME.seconds(60), maxMessageBytes: C.BYTES.mib(50), maxResponseBytes: C.BYTES.mib(100) }, // allow:raw-byte-literal — operator-facing default mailbox cap
107
+ permissive: { timeoutMs: C.TIME.seconds(120), maxMessageBytes: C.BYTES.mib(150), maxResponseBytes: C.BYTES.mib(300) }, // allow:raw-byte-literal — operator-facing default mailbox cap
108
+ });
109
+
110
+ var COMPLIANCE_POSTURES = Object.freeze({
111
+ hipaa: "strict",
112
+ "pci-dss": "strict",
113
+ gdpr: "strict",
114
+ soc2: "strict",
115
+ });
116
+
117
+ var ALLOWED_PROTOCOLS = Object.freeze({
118
+ "icap": true,
119
+ "clamav-instream": true,
120
+ });
121
+
122
+ /**
123
+ * @primitive b.mail.scan.create
124
+ * @signature b.mail.scan.create(opts)
125
+ * @since 0.9.81
126
+ * @status stable
127
+ * @related b.safeIcap.parse, b.guardArchive.validateEntries
128
+ *
129
+ * Build a mail-scan handle. Returns `{ scan(messageBytes, opts),
130
+ * profile, protocol, MailScanError }` where `.scan` resolves to
131
+ * `{ verdict, icapResponse?, threats?, durationMs }`:
132
+ *
133
+ * - `verdict`: `"clean"` | `"infected"` | `"error"`.
134
+ * - `icapResponse`: the structured `b.safeIcap.parse` result on
135
+ * ICAP backend (omitted on clamav-instream).
136
+ * - `threats`: Array<string> of threat names when infected.
137
+ * - `durationMs`: round-trip ms (audit / metrics).
138
+ *
139
+ * @opts
140
+ * host: string — required. ICAP / clamd hostname or IP.
141
+ * port: number — required. ICAP port (default 1344) /
142
+ * clamd port (default 3310).
143
+ * service: string — ICAP service name (default "srv_clamav").
144
+ * protocol: "icap" | "clamav-instream" — default "icap".
145
+ * timeoutMs: number — per-request wall clock; default per profile.
146
+ * profile: "strict" | "balanced" | "permissive".
147
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2".
148
+ * audit: b.audit instance (drop-silent on failure).
149
+ *
150
+ * @example
151
+ * var scanner = b.mail.scan.create({
152
+ * host: "av.internal",
153
+ * port: 1344,
154
+ * service: "srv_clamav",
155
+ * });
156
+ * var verdict = await scanner.scan(rawMessage);
157
+ * if (verdict.verdict === "infected") refuseMessage(verdict.threats);
158
+ */
159
+ function create(opts) {
160
+ opts = validateOpts.requireObject(opts || {}, "mail.scan.create", MailScanError, "mail-scan/bad-opts");
161
+ validateOpts(opts, [
162
+ "host", "port", "service", "protocol",
163
+ "timeoutMs", "profile", "posture", "audit",
164
+ ], "mail.scan.create");
165
+
166
+ validateOpts.requireNonEmptyString(opts.host, "mail.scan.create.host",
167
+ MailScanError, "mail-scan/bad-host");
168
+ if (!numericBounds.isPositiveFiniteInt(opts.port) || opts.port > 65535) { // allow:raw-byte-literal — TCP port-number range cap
169
+ throw new MailScanError("mail-scan/bad-port",
170
+ "mail.scan.create.port must be a positive integer in [1,65535]; got " +
171
+ numericBounds.shape(opts.port));
172
+ }
173
+ var protocol = opts.protocol || DEFAULT_PROTOCOL;
174
+ if (!ALLOWED_PROTOCOLS[protocol]) {
175
+ throw new MailScanError("mail-scan/bad-protocol",
176
+ "mail.scan.create.protocol must be 'icap' or 'clamav-instream'; got '" + protocol + "'");
177
+ }
178
+ var service = opts.service || DEFAULT_ICAP_SERVICE;
179
+ if (protocol === "icap") {
180
+ validateOpts.requireNonEmptyString(service, "mail.scan.create.service",
181
+ MailScanError, "mail-scan/bad-service");
182
+ }
183
+ var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
184
+ if (!PROFILES[profile]) {
185
+ throw new MailScanError("mail-scan/bad-profile",
186
+ "mail.scan.create.profile: unknown '" + profile + "' (valid: strict / balanced / permissive)");
187
+ }
188
+ var caps = PROFILES[profile];
189
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.timeoutMs,
190
+ "mail.scan.create.timeoutMs", MailScanError, "mail-scan/bad-timeout");
191
+ var timeoutMs = opts.timeoutMs || caps.timeoutMs;
192
+ var auditImpl = opts.audit || audit();
193
+
194
+ function scan(messageBytes, scanOpts) {
195
+ scanOpts = scanOpts || {};
196
+ if (!Buffer.isBuffer(messageBytes)) {
197
+ throw new MailScanError("mail-scan/bad-input",
198
+ "mail.scan.scan: messageBytes must be a Buffer; got " + (typeof messageBytes));
199
+ }
200
+ if (messageBytes.length === 0) {
201
+ throw new MailScanError("mail-scan/bad-input",
202
+ "mail.scan.scan: messageBytes must be non-empty");
203
+ }
204
+ if (messageBytes.length > caps.maxMessageBytes) {
205
+ throw new MailScanError("mail-scan/oversize-message",
206
+ "mail.scan.scan: messageBytes=" + messageBytes.length + " exceeds maxMessageBytes=" +
207
+ caps.maxMessageBytes);
208
+ }
209
+
210
+ // Optional archive-entries gate — compose b.guardArchive when the
211
+ // operator enumerates entries before scanning. Hostile entry metadata
212
+ // (zip-slip / hardlink-escape / decompression-bomb-ratio) refuses
213
+ // before the AV daemon ever sees the bytes.
214
+ if (Array.isArray(scanOpts.archiveEntries)) {
215
+ var gv = guardArchive().validateEntries(scanOpts.archiveEntries, { profile: profile });
216
+ if (gv && gv.issues && gv.issues.length > 0) {
217
+ var infectedThreats = gv.issues.map(function (i) { return "archive:" + (i.code || "unknown"); });
218
+ _emitAudit(auditImpl, "mail.scan.infected", "success", {
219
+ reason: "archive-pre-scan", threats: infectedThreats,
220
+ });
221
+ return Promise.resolve({
222
+ verdict: "infected",
223
+ threats: infectedThreats,
224
+ durationMs: 0,
225
+ });
226
+ }
227
+ }
228
+
229
+ _emitAudit(auditImpl, "mail.scan.request", "success", {
230
+ protocol: protocol, host: opts.host, port: opts.port, bytes: messageBytes.length,
231
+ });
232
+
233
+ var t0 = Date.now();
234
+ if (protocol === "icap") {
235
+ return _scanIcap(messageBytes, scanOpts).then(function (rv) {
236
+ rv.durationMs = Date.now() - t0;
237
+ _emitScanResult(auditImpl, rv);
238
+ return rv;
239
+ }, function (e) {
240
+ var ms = Date.now() - t0;
241
+ return _failTo(auditImpl, e, ms);
242
+ });
243
+ }
244
+ return _scanClamavInstream(messageBytes).then(function (rv) {
245
+ rv.durationMs = Date.now() - t0;
246
+ _emitScanResult(auditImpl, rv);
247
+ return rv;
248
+ }, function (e) {
249
+ var ms = Date.now() - t0;
250
+ return _failTo(auditImpl, e, ms);
251
+ });
252
+ }
253
+
254
+ function _scanIcap(messageBytes, scanOpts) {
255
+ return new Promise(function (resolve, reject) {
256
+ var sock = (scanOpts._socket && typeof scanOpts._socket.write === "function")
257
+ ? scanOpts._socket
258
+ : net.createConnection({ host: opts.host, port: opts.port });
259
+
260
+ var collector = safeBuffer.boundedChunkCollector({
261
+ maxBytes: caps.maxResponseBytes,
262
+ errorClass: MailScanError,
263
+ sizeCode: "mail-scan/icap-response-too-large",
264
+ sizeMessage: "mail.scan.scan: ICAP response exceeded maxResponseBytes",
265
+ });
266
+ var done = false;
267
+ var to = setTimeout(function () {
268
+ if (done) return;
269
+ done = true;
270
+ try { sock.destroy(); } catch (_e) { /* drop */ }
271
+ reject(new MailScanError("mail-scan/timeout",
272
+ "mail.scan.scan: ICAP timeout after " + timeoutMs + "ms"));
273
+ }, timeoutMs);
274
+
275
+ sock.on("error", function (e) {
276
+ if (done) return;
277
+ done = true;
278
+ clearTimeout(to);
279
+ reject(new MailScanError("mail-scan/transport",
280
+ "mail.scan.scan: ICAP socket error: " + (e && e.message || e)));
281
+ });
282
+
283
+ sock.on("data", function (chunk) {
284
+ try { collector.push(chunk); }
285
+ catch (e) {
286
+ if (done) return;
287
+ done = true;
288
+ clearTimeout(to);
289
+ try { sock.destroy(); } catch (_e) { /* drop */ }
290
+ reject(e);
291
+ }
292
+ });
293
+
294
+ sock.on("end", function () {
295
+ if (done) return;
296
+ done = true;
297
+ clearTimeout(to);
298
+ try {
299
+ var raw = collector.result();
300
+ var parsed = safeIcap.parse(raw, { profile: profile });
301
+ var threats = [];
302
+ if (parsed.threatName) threats.push(parsed.threatName);
303
+ resolve({
304
+ verdict: parsed.threatFound ? "infected" : (parsed.statusCode === 200 || parsed.statusCode === 204 ? "clean" : "error"),
305
+ icapResponse: parsed,
306
+ threats: threats,
307
+ });
308
+ } catch (e) {
309
+ reject(e);
310
+ }
311
+ });
312
+
313
+ // RFC 3507 §4.3.2 — RESPMOD request: ICAP-Version, Host header,
314
+ // Encapsulated header pointing at res-hdr=0, res-body=N. We send
315
+ // a minimal HTTP-response wrapper around the raw mail bytes; the
316
+ // ICAP daemon scans the body region.
317
+ var httpHdr = "HTTP/1.1 200 OK\r\nContent-Type: message/rfc822\r\nContent-Length: " +
318
+ messageBytes.length + "\r\n\r\n";
319
+ var resBodyOffset = Buffer.byteLength(httpHdr, "ascii");
320
+ var icapHdr =
321
+ "RESPMOD icap://" + opts.host + ":" + opts.port + "/" + service + " ICAP/1.0\r\n" +
322
+ "Host: " + opts.host + "\r\n" +
323
+ "Allow: 204\r\n" +
324
+ "Encapsulated: res-hdr=0, res-body=" + resBodyOffset + "\r\n" +
325
+ "\r\n";
326
+ try {
327
+ sock.write(icapHdr);
328
+ sock.write(httpHdr);
329
+ // RFC 3507 §4.4.3 — body is chunked-transfer; we write a single
330
+ // chunk + terminator for simplicity. The wire format is the same
331
+ // as HTTP/1.1 chunked: `<hex-length>\r\n<bytes>\r\n0\r\n\r\n`.
332
+ var lenHex = messageBytes.length.toString(16); // allow:raw-byte-literal — hex radix
333
+ sock.write(lenHex + "\r\n");
334
+ sock.write(messageBytes);
335
+ sock.write("\r\n0\r\n\r\n");
336
+ if (typeof sock.end === "function") sock.end();
337
+ } catch (e) {
338
+ if (done) return;
339
+ done = true;
340
+ clearTimeout(to);
341
+ reject(new MailScanError("mail-scan/transport",
342
+ "mail.scan.scan: ICAP write error: " + (e && e.message || e)));
343
+ }
344
+ });
345
+ }
346
+
347
+ function _scanClamavInstream(messageBytes) {
348
+ return new Promise(function (resolve, reject) {
349
+ var sock = net.createConnection({ host: opts.host, port: opts.port });
350
+ var collector = safeBuffer.boundedChunkCollector({
351
+ maxBytes: caps.maxResponseBytes,
352
+ errorClass: MailScanError,
353
+ sizeCode: "mail-scan/clamav-response-too-large",
354
+ sizeMessage: "mail.scan.scan: clamav reply exceeded maxResponseBytes",
355
+ });
356
+ var done = false;
357
+ var to = setTimeout(function () {
358
+ if (done) return;
359
+ done = true;
360
+ try { sock.destroy(); } catch (_e) { /* drop */ }
361
+ reject(new MailScanError("mail-scan/timeout",
362
+ "mail.scan.scan: clamav-instream timeout after " + timeoutMs + "ms"));
363
+ }, timeoutMs);
364
+
365
+ sock.on("error", function (e) {
366
+ if (done) return;
367
+ done = true;
368
+ clearTimeout(to);
369
+ reject(new MailScanError("mail-scan/transport",
370
+ "mail.scan.scan: clamav socket error: " + (e && e.message || e)));
371
+ });
372
+
373
+ sock.on("data", function (chunk) {
374
+ try { collector.push(chunk); }
375
+ catch (e) {
376
+ if (done) return;
377
+ done = true;
378
+ clearTimeout(to);
379
+ try { sock.destroy(); } catch (_e) { /* drop */ }
380
+ reject(e);
381
+ }
382
+ });
383
+
384
+ sock.on("end", function () {
385
+ if (done) return;
386
+ done = true;
387
+ clearTimeout(to);
388
+ var reply = collector.result().toString("utf8").replace(/[\r\n\0]+$/g, ""); // allow:regex-no-length-cap — trailing-trim anchored
389
+ // ClamAV INSTREAM reply: "<id>: <verdict>" where verdict is
390
+ // "stream: OK", "stream: <Sig.Name> FOUND", or "INSTREAM size
391
+ // limit exceeded. ERROR".
392
+ if (/stream:\s+OK\b/.test(reply)) { // allow:regex-no-length-cap — anchored to fixed token
393
+ resolve({ verdict: "clean", threats: [] });
394
+ } else {
395
+ var m = reply.match(/stream:\s+(.+?)\s+FOUND\b/); // allow:regex-no-length-cap — anchored to fixed FOUND token
396
+ if (m) {
397
+ resolve({ verdict: "infected", threats: [m[1]] });
398
+ } else if (/ERROR/i.test(reply)) { // allow:regex-no-length-cap — anchored to fixed ERROR token
399
+ resolve({ verdict: "error", threats: [] });
400
+ } else {
401
+ // Unrecognized reply shape — treat as error so the caller
402
+ // gets a definite "do not deliver" signal instead of a
403
+ // silent clean verdict.
404
+ resolve({ verdict: "error", threats: [] });
405
+ }
406
+ }
407
+ });
408
+
409
+ try {
410
+ sock.write("zINSTREAM\0");
411
+ var off = 0;
412
+ while (off < messageBytes.length) {
413
+ var endOff = Math.min(off + CLAMAV_CHUNK_BYTES, messageBytes.length);
414
+ var lenBuf = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
415
+ lenBuf.writeUInt32BE(endOff - off, 0);
416
+ sock.write(lenBuf);
417
+ sock.write(messageBytes.slice(off, endOff));
418
+ off = endOff;
419
+ }
420
+ var term = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
421
+ term.writeUInt32BE(0, 0);
422
+ sock.write(term);
423
+ if (typeof sock.end === "function") sock.end();
424
+ } catch (e) {
425
+ if (done) return;
426
+ done = true;
427
+ clearTimeout(to);
428
+ reject(new MailScanError("mail-scan/transport",
429
+ "mail.scan.scan: clamav write error: " + (e && e.message || e)));
430
+ }
431
+ });
432
+ }
433
+
434
+ return {
435
+ scan: scan,
436
+ profile: profile,
437
+ protocol: protocol,
438
+ host: opts.host,
439
+ port: opts.port,
440
+ service: service,
441
+ timeoutMs: timeoutMs,
442
+ MailScanError: MailScanError,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * @primitive b.mail.scan.compliancePosture
448
+ * @signature b.mail.scan.compliancePosture(posture)
449
+ * @since 0.9.81
450
+ * @status stable
451
+ *
452
+ * Return the effective profile name for a compliance posture, or
453
+ * `null` for unknown posture names.
454
+ *
455
+ * @example
456
+ * b.mail.scan.compliancePosture("hipaa"); // → "strict"
457
+ */
458
+ function compliancePosture(posture) {
459
+ return COMPLIANCE_POSTURES[posture] || null;
460
+ }
461
+
462
+ function _emitScanResult(auditImpl, rv) {
463
+ if (rv.verdict === "clean") {
464
+ _emitAudit(auditImpl, "mail.scan.clean", "success", { durationMs: rv.durationMs });
465
+ } else if (rv.verdict === "infected") {
466
+ _emitAudit(auditImpl, "mail.scan.infected", "success", {
467
+ durationMs: rv.durationMs, threats: rv.threats,
468
+ });
469
+ } else {
470
+ _emitAudit(auditImpl, "mail.scan.error", "failure", { durationMs: rv.durationMs });
471
+ }
472
+ }
473
+
474
+ function _failTo(auditImpl, e, ms) {
475
+ if (e && e.code === "mail-scan/timeout") {
476
+ _emitAudit(auditImpl, "mail.scan.timeout", "failure", { durationMs: ms });
477
+ } else {
478
+ _emitAudit(auditImpl, "mail.scan.error", "failure", {
479
+ durationMs: ms, message: (e && e.message) || String(e),
480
+ });
481
+ }
482
+ return { verdict: "error", threats: [], durationMs: ms,
483
+ errorCode: (e && e.code) || "mail-scan/unknown",
484
+ errorMessage: (e && e.message) || String(e) };
485
+ }
486
+
487
+ function _emitAudit(auditImpl, action, outcome, metadata) {
488
+ try {
489
+ if (auditImpl && typeof auditImpl.safeEmit === "function") {
490
+ auditImpl.safeEmit({ action: action, outcome: outcome, metadata: metadata });
491
+ }
492
+ } catch (_e) { /* drop-silent — audit failures don't break scan path */ }
493
+ }
494
+
495
+ module.exports = {
496
+ create: create,
497
+ compliancePosture: compliancePosture,
498
+ PROFILES: PROFILES,
499
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
500
+ ALLOWED_PROTOCOLS: ALLOWED_PROTOCOLS,
501
+ MailScanError: MailScanError,
502
+ };
@@ -117,7 +117,6 @@
117
117
  */
118
118
 
119
119
  var net = require("node:net");
120
- var nodeTls = require("node:tls");
121
120
  var lazyRequire = require("./lazy-require");
122
121
  var C = require("./constants");
123
122
  var bCrypto = require("./crypto");
@@ -125,6 +124,7 @@ var numericBounds = require("./numeric-bounds");
125
124
  var validateOpts = require("./validate-opts");
126
125
  var guardImapCommand = require("./guard-imap-command");
127
126
  var mailServerRateLimit = require("./mail-server-rate-limit");
127
+ var mailServerTls = require("./mail-server-tls");
128
128
  var { defineClass } = require("./framework-error");
129
129
 
130
130
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -291,7 +291,7 @@ function create(opts) {
291
291
  socket.setTimeout(idleTimeoutMs);
292
292
  socket.on("timeout", function () {
293
293
  _writeUntagged(socket, "BYE Idle timeout");
294
- _close(socket);
294
+ _close(socket, state);
295
295
  });
296
296
  socket.on("error", function (err) {
297
297
  _emit("mail.server.imap.socket_error",
@@ -302,6 +302,22 @@ function create(opts) {
302
302
  _writeUntagged(socket, "OK [CAPABILITY " + _capabilityLine(state) + "] " + greeting);
303
303
 
304
304
  socket.on("data", function (chunk) {
305
+ // Per-line cap MUST gate the concat — a single large TCP chunk
306
+ // (~64 KiB on most kernels) can push the buffer past the line
307
+ // cap BEFORE the drain loop runs, so the cap-check inside the
308
+ // loop sees a buffer that's already grown past the policy
309
+ // floor. When the chunk would itself overrun the line cap AND
310
+ // no literal is pending (where over-cap bytes are legitimate
311
+ // payload), reject here and tear the connection down.
312
+ var pendingLiteral = state.pendingLiteral;
313
+ var room = pendingLiteral
314
+ ? (pendingLiteral.size - pendingLiteral.body.length) + maxLineBytes
315
+ : (maxLineBytes - state.lineBuffer.length);
316
+ if (chunk.length > room) {
317
+ _writeUntagged(socket, "BAD Line too long (cap " + maxLineBytes + ")");
318
+ _close(socket, state);
319
+ return;
320
+ }
305
321
  state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
306
322
  _drainBuffer(state, socket);
307
323
  });
@@ -329,7 +345,7 @@ function create(opts) {
329
345
  if (crlf === -1) {
330
346
  if (state.lineBuffer.length > maxLineBytes) {
331
347
  _writeUntagged(socket, "BAD Line too long (cap " + maxLineBytes + ")");
332
- _close(socket);
348
+ _close(socket, state);
333
349
  }
334
350
  return;
335
351
  }
@@ -498,7 +514,7 @@ function create(opts) {
498
514
  function _handleLogout(state, socket, tag) {
499
515
  _writeUntagged(socket, "BYE Logging out");
500
516
  _writeTagged(socket, tag, "OK LOGOUT completed");
501
- _close(socket);
517
+ _close(socket, state);
502
518
  }
503
519
 
504
520
  function _handleStartTls(state, socket, tag) {
@@ -507,27 +523,40 @@ function create(opts) {
507
523
  return;
508
524
  }
509
525
  _writeTagged(socket, tag, "OK Begin TLS negotiation now");
510
- // Drain pre-handshake buffer (RFC 9051 §11.1 / CVE-2021-33515
511
- // class STARTTLS-injection defense). Any commands a client
512
- // pipelined before the handshake are discarded — the post-TLS
513
- // socket reads fresh.
514
- state.lineBuffer = Buffer.alloc(0);
515
- var tlsSocket = new nodeTls.TLSSocket(socket, {
516
- isServer: true,
526
+ // Drain EVERY pre-handshake state field that could carry attacker-
527
+ // controlled bytes past the upgrade boundary (RFC 9051 §11.1 /
528
+ // CVE-2021-33515 class STARTTLS-injection defense):
529
+ // - lineBuffer: unparsed bytes pipelined before the handshake.
530
+ // - pendingLiteral: half-collected APPEND/AUTHENTICATE literal
531
+ // bytes; if not cleared, the literal completes after upgrade
532
+ // using bytes the peer sent in plaintext.
533
+ // - authPending: the AUTHENTICATE step token; a dangling token
534
+ // would let the post-TLS state machine resume an exchange that
535
+ // started in plaintext, conflating cleartext + TLS-protected
536
+ // phases of the same SASL run.
537
+ // Listener-removal + idle-timeout re-arm live in the shared
538
+ // upgradeSocket helper (b.mail.server.tls.upgradeSocket).
539
+ state.lineBuffer = Buffer.alloc(0);
540
+ state.pendingLiteral = null;
541
+ state.authPending = null;
542
+ mailServerTls.upgradeSocket({
543
+ plainSocket: socket,
517
544
  secureContext: opts.tlsContext,
518
- });
519
- tlsSocket.on("secure", function () {
520
- state.tls = true;
521
- tlsSocket.setTimeout(idleTimeoutMs);
522
- tlsSocket.on("data", function (chunk) {
545
+ idleTimeoutMs: idleTimeoutMs,
546
+ onSecure: function (_tlsSocket) { state.tls = true; },
547
+ onData: function (tlsSocket, chunk) {
523
548
  state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
524
549
  _drainBuffer(state, tlsSocket);
525
- });
526
- });
527
- tlsSocket.on("error", function (err) {
528
- _emit("mail.server.imap.tls_handshake_failed",
529
- { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
530
- _close(socket);
550
+ },
551
+ onError: function (err) {
552
+ _emit("mail.server.imap.tls_handshake_failed",
553
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
554
+ _close(socket, state);
555
+ },
556
+ onTimeout: function (tlsSocket) {
557
+ _writeUntagged(tlsSocket, "BYE Idle timeout");
558
+ _close(tlsSocket, state);
559
+ },
531
560
  });
532
561
  }
533
562
 
@@ -550,7 +579,7 @@ function create(opts) {
550
579
  { connectionId: state.id, remoteAddress: state.remoteAddress, reason: authAdmit.reason },
551
580
  "denied");
552
581
  _writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
553
- _close(socket);
582
+ _close(socket, state);
554
583
  return;
555
584
  }
556
585
  var mechName = args.split(" ")[0].toUpperCase();
@@ -637,7 +666,7 @@ function create(opts) {
637
666
  var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
638
667
  if (!authAdmit.ok) {
639
668
  _writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
640
- _close(socket);
669
+ _close(socket, state);
641
670
  return;
642
671
  }
643
672
  // LOGIN args: `user pass` (quoted or atom).
@@ -987,7 +1016,7 @@ function create(opts) {
987
1016
  if (state.idle) {
988
1017
  _writeUntagged(socket, "BYE IDLE timed out — re-issue");
989
1018
  state.idle = null;
990
- _close(socket);
1019
+ _close(socket, state);
991
1020
  }
992
1021
  }, IDLE_BANDWIDTH_TIMEOUT_MS);
993
1022
  state.idle = { tag: tag, timer: timer };
@@ -1005,7 +1034,16 @@ function create(opts) {
1005
1034
  try { socket.write("+ " + msg + "\r\n"); }
1006
1035
  catch (_e) { /* socket may be down */ }
1007
1036
  }
1008
- function _close(socket) {
1037
+ function _close(socket, state) {
1038
+ // The drain loop's `if (state.stage === "closed") return;` guard
1039
+ // (around the bottom of _drainBuffer) was dead before this —
1040
+ // _close never wrote the sentinel, so the drain loop kept
1041
+ // processing buffered bytes after the socket was destroyed.
1042
+ // Setting stage="closed" here makes the guard reachable so a
1043
+ // close mid-loop short-circuits the next command dispatch
1044
+ // (defense-in-depth against an exception thrown by a handler
1045
+ // that doesn't tear down the loop).
1046
+ if (state && typeof state === "object") state.stage = "closed";
1009
1047
  try { socket.end(); } catch (_e) { /* idempotent */ }
1010
1048
  try { socket.destroy(); } catch (_e2) { /* idempotent */ }
1011
1049
  connections.delete(socket);