@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.
@@ -0,0 +1,484 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardSmtpCommand
4
+ * @nav Guards
5
+ * @title Guard SMTP Command
6
+ * @order 450
7
+ *
8
+ * @intro
9
+ * SMTP command-line validator. Gates every command verb the framework's
10
+ * inbound MX listener (v0.9.34) and outbound submission listener
11
+ * (v0.9.35) accept from peers — `EHLO` / `HELO` / `MAIL FROM` /
12
+ * `RCPT TO` / `DATA` / `BDAT` / `VRFY` / `EXPN` / `NOOP` / `RSET` /
13
+ * `QUIT` / `AUTH` / `STARTTLS` / `HELP`.
14
+ *
15
+ * ## Smuggling defense — bare-CR / bare-LF refusal
16
+ *
17
+ * The SMTP smuggling class (`CVE-2023-51764` Postfix, `CVE-2023-51765`
18
+ * Sendmail, `CVE-2023-51766` Exim, `CVE-2026-32178` .NET
19
+ * `System.Net.Mail`) exploits implementations that accept the
20
+ * non-standard end-of-data sequence `<LF>.<LF>` or `<LF>.<CR><LF>`
21
+ * instead of the standard `<CR><LF>.<CR><LF>`. The introduced break-
22
+ * out lets a malicious peer inject a second message past SPF / DMARC
23
+ * checks performed only on the outer envelope.
24
+ *
25
+ * At the command-line level the defense is the same: every command
26
+ * line MUST be CRLF-terminated; bare `\r` or `\n` anywhere inside a
27
+ * command line is refused. Operators with peers that legitimately
28
+ * speak bare-LF (rare; legacy Sendmail-to-Sendmail) opt into
29
+ * `permissive` profile with audit emit per accepted bare-LF line.
30
+ *
31
+ * ## STARTTLS command-buffer injection
32
+ *
33
+ * `CVE-2021-38371` (Exim STARTTLS response injection) and
34
+ * `CVE-2021-33515` (Dovecot lib-smtp STARTTLS command injection)
35
+ * exploit implementations that don't drain the pre-STARTTLS receive
36
+ * buffer when negotiating TLS — commands queued by an MitM before
37
+ * the handshake get applied to the post-handshake (TLS-protected)
38
+ * stream. The fix is stateful (drain the buffer on STARTTLS), so
39
+ * this guard alone can't fully defend; it surfaces the requirement
40
+ * to the v0.9.34 listener via `validate({ verb: "STARTTLS" })`
41
+ * refusing trailing payload on the STARTTLS line and the listener's
42
+ * pipelining-after-STARTTLS check enforcing buffer drain.
43
+ *
44
+ * ## Per-verb shape
45
+ *
46
+ * Each verb has a fixed argument shape (RFC 5321 §3 / §4.1):
47
+ *
48
+ * - `EHLO` / `HELO` — exactly one arg (domain or address literal).
49
+ * - `MAIL` — `FROM:<reverse-path>` (RFC 5321 §3.3) + optional
50
+ * `SIZE=` / `BODY=` / `RET=` / `ENVID=` / `AUTH=` extension
51
+ * params.
52
+ * - `RCPT` — `TO:<forward-path>` (RFC 5321 §3.3) + optional
53
+ * `NOTIFY=` / `ORCPT=` extension params.
54
+ * - `DATA` — no args.
55
+ * - `BDAT` — single decimal chunk size + optional `LAST` keyword
56
+ * (RFC 3030 CHUNKING).
57
+ * - `VRFY` / `EXPN` — single mailbox arg.
58
+ * - `NOOP` — optional opaque string.
59
+ * - `RSET` / `QUIT` / `STARTTLS` — no args.
60
+ * - `AUTH` — SASL mechanism name + optional initial-response
61
+ * (RFC 4954).
62
+ * - `HELP` — optional argument.
63
+ *
64
+ * Anything not matching the shape under `strict` profile is refused
65
+ * with `guard-smtp-command/bad-shape`.
66
+ *
67
+ * ## Caps
68
+ *
69
+ * - Command line (path + arguments + CRLF) capped at 512 bytes
70
+ * per RFC 5321 §4.5.3.1.1. SMTPUTF8 / EAI peers (RFC 6531) may
71
+ * send longer command lines for non-ASCII addresses; `balanced`
72
+ * profile bumps the cap to 1024.
73
+ * - Forward-path / reverse-path mailbox capped at 256 bytes per
74
+ * RFC 5321 §4.5.3.1.3.
75
+ * - Domain part of a path capped at 255 bytes per RFC 1035
76
+ * §2.3.4.
77
+ * - Local part capped at 64 bytes per RFC 5321 §4.5.3.1.1.
78
+ *
79
+ * Throws `GuardSmtpCommandError` on every refusal. Pure-functional —
80
+ * no I/O, no state. The MX / submission listener composes one
81
+ * instance per accepted connection.
82
+ *
83
+ * @card
84
+ * SMTP command-line validator. Refuses bare-CR / bare-LF (smuggling
85
+ * defense, CVE-2023-51764/51765/51766/2026-32178), caps line + path
86
+ * + domain + local-part byte lengths (RFC 5321 §4.5.3.1), per-verb
87
+ * shape check (EHLO / HELO / MAIL FROM / RCPT TO / DATA / BDAT /
88
+ * VRFY / EXPN / NOOP / RSET / QUIT / AUTH / STARTTLS / HELP).
89
+ */
90
+
91
+ var { defineClass } = require("./framework-error");
92
+ var gateContract = require("./gate-contract");
93
+
94
+ var GuardSmtpCommandError = defineClass("GuardSmtpCommandError", { alwaysPermanent: true });
95
+
96
+ var DEFAULT_PROFILE = "strict";
97
+
98
+ // RFC 5321 §4.5.3.1.1 — 512-octet line cap (excluding the trailing
99
+ // CRLF). SMTPUTF8 / EAI extends this in practice; balanced/permissive
100
+ // raise the cap accordingly.
101
+ var PROFILES = Object.freeze({
102
+ strict: { maxLineBytes: 512, maxMailbox: 256, maxLocalPart: 64, maxDomain: 255, allowBareLf: false, allowSmtpUtf8: false }, // allow:raw-byte-literal — RFC 5321 §4.5.3.1.1 caps
103
+ balanced: { maxLineBytes: 1024, maxMailbox: 320, maxLocalPart: 64, maxDomain: 255, allowBareLf: false, allowSmtpUtf8: true }, // allow:raw-byte-literal — SMTPUTF8 (RFC 6531) line cap
104
+ permissive: { maxLineBytes: 4096, maxMailbox: 512, maxLocalPart: 64, maxDomain: 255, allowBareLf: true, allowSmtpUtf8: true }, // allow:raw-byte-literal — permissive cap for legacy peers
105
+ });
106
+
107
+ var COMPLIANCE_POSTURES = Object.freeze({
108
+ hipaa: "strict",
109
+ "pci-dss": "strict",
110
+ gdpr: "strict",
111
+ soc2: "strict",
112
+ });
113
+
114
+ // Verbs we know — anything else is refused under strict, accepted as
115
+ // opaque under permissive (operator's responsibility to handle).
116
+ var KNOWN_VERBS = Object.freeze({
117
+ EHLO: true, HELO: true, MAIL: true, RCPT: true, DATA: true,
118
+ BDAT: true, VRFY: true, EXPN: true, NOOP: true, RSET: true,
119
+ QUIT: true, AUTH: true, STARTTLS: true, HELP: true,
120
+ });
121
+
122
+ // Verbs that take exactly zero arguments (anything trailing the verb
123
+ // itself is refused). DATA's trailing CRLF is the end-of-command, not
124
+ // an argument.
125
+ var ZERO_ARG_VERBS = Object.freeze({ DATA: true, RSET: true, QUIT: true, STARTTLS: true });
126
+
127
+ // Address-literal shape per RFC 5321 §4.1.3 (very loose — full
128
+ // validation lives in safeUrl / IP-address parsing; here we just
129
+ // gate the SMTP-side bracket shape).
130
+ var ADDR_LIT_RE = /^\[(?:IPv6:)?[0-9A-Fa-f:.]+\]$/; // allow:regex-no-length-cap — caller's command line is already maxLineBytes-capped
131
+ var DOMAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; // allow:regex-no-length-cap — domain length is checked separately against maxDomain
132
+ var DECIMAL_RE = /^[1-9][0-9]{0,9}$|^0$/; // allow:regex-no-length-cap — bounded by anchor + repeat-cap
133
+
134
+ /**
135
+ * @primitive b.guardSmtpCommand.validate
136
+ * @signature b.guardSmtpCommand.validate(line, opts?)
137
+ * @since 0.9.32
138
+ * @status stable
139
+ * @related b.guardEmail.validateMessage, b.safeMime.parse
140
+ *
141
+ * Validate a single SMTP command line (without its CRLF terminator —
142
+ * the listener strips that before calling this). Returns a structured
143
+ * `{ verb, args, params }` shape on success; throws
144
+ * `GuardSmtpCommandError` on any refusal.
145
+ *
146
+ * @opts
147
+ * profile: "strict" | "balanced" | "permissive",
148
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
149
+ *
150
+ * @example
151
+ * var parsed = b.guardSmtpCommand.validate("MAIL FROM:<alice@example.com> SIZE=12345");
152
+ * // → { verb: "MAIL", args: ["FROM:<alice@example.com>"], params: { SIZE: "12345" } }
153
+ */
154
+ function validate(line, opts) {
155
+ opts = opts || {};
156
+ var caps = _resolveProfile(opts);
157
+ if (typeof line !== "string") {
158
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
159
+ "guardSmtpCommand.validate: line must be a string; got " + (typeof line));
160
+ }
161
+ if (line.length === 0) {
162
+ throw new GuardSmtpCommandError("guard-smtp-command/empty",
163
+ "guardSmtpCommand.validate: empty command line");
164
+ }
165
+ if (Buffer.byteLength(line, caps.allowSmtpUtf8 ? "utf8" : "ascii") > caps.maxLineBytes) {
166
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-line",
167
+ "guardSmtpCommand.validate: line exceeds maxLineBytes=" + caps.maxLineBytes +
168
+ " (RFC 5321 §4.5.3.1.1)");
169
+ }
170
+ // Smuggling defense — refuse bare CR / bare LF anywhere in the line.
171
+ // Listener has already stripped the terminating CRLF before calling.
172
+ if (line.indexOf("\r") !== -1) {
173
+ throw new GuardSmtpCommandError("guard-smtp-command/bare-cr",
174
+ "guardSmtpCommand.validate: bare CR in command line (RFC 5321 §2.3.8; " +
175
+ "smuggling defense CVE-2023-51764/51765/51766)");
176
+ }
177
+ if (line.indexOf("\n") !== -1 && !caps.allowBareLf) {
178
+ throw new GuardSmtpCommandError("guard-smtp-command/bare-lf",
179
+ "guardSmtpCommand.validate: bare LF in command line (RFC 5321 §2.3.8; " +
180
+ "smuggling defense CVE-2023-51764/51765/51766/2026-32178)");
181
+ }
182
+ if (line.indexOf("\u0000") !== -1) {
183
+ throw new GuardSmtpCommandError("guard-smtp-command/nul",
184
+ "guardSmtpCommand.validate: NUL byte refused");
185
+ }
186
+ // C0 controls (except SP=0x20, HTAB=0x09 not legal in commands, and
187
+ // LF=0x0a when allowBareLf is true under permissive profile per
188
+ // legacy Sendmail compat) plus DEL.
189
+ for (var i = 0; i < line.length; i += 1) {
190
+ var c = line.charCodeAt(i);
191
+ // LF under permissive: allowed by profile, already passed the
192
+ // bare-LF refusal earlier in this fn. Skip the control-char throw
193
+ // so the documented allowBareLf path actually accepts LF (Codex
194
+ // caught this: permissive profile was effectively broken).
195
+ if (c === 0x0a && caps.allowBareLf) continue; // allow:raw-byte-literal — RFC 5321 §2.3.8 LF, permissive bypass
196
+ if (c < 0x20 || c === 0x7f) { // allow:raw-byte-literal — RFC 5321 §2.3.8 forbids C0 / DEL
197
+ throw new GuardSmtpCommandError("guard-smtp-command/control-char",
198
+ "guardSmtpCommand.validate: control char 0x" + c.toString(16) + " refused");
199
+ }
200
+ if (!caps.allowSmtpUtf8 && c > 0x7e) { // allow:raw-byte-literal — RFC 5321 §2.3.1 7-bit ASCII; SMTPUTF8 relaxes
201
+ throw new GuardSmtpCommandError("guard-smtp-command/non-ascii",
202
+ "guardSmtpCommand.validate: non-ASCII byte refused (no SMTPUTF8 negotiated)");
203
+ }
204
+ }
205
+ var firstSpace = line.indexOf(" ");
206
+ var verb = (firstSpace === -1 ? line : line.slice(0, firstSpace)).toUpperCase();
207
+ var rest = firstSpace === -1 ? "" : line.slice(firstSpace + 1);
208
+
209
+ if (!KNOWN_VERBS[verb]) {
210
+ throw new GuardSmtpCommandError("guard-smtp-command/unknown-verb",
211
+ "guardSmtpCommand.validate: unknown verb '" + verb + "' (RFC 5321 §3)");
212
+ }
213
+ if (ZERO_ARG_VERBS[verb] && rest.length > 0) {
214
+ throw new GuardSmtpCommandError("guard-smtp-command/unexpected-args",
215
+ "guardSmtpCommand.validate: verb '" + verb + "' takes no arguments");
216
+ }
217
+
218
+ if (verb === "EHLO" || verb === "HELO") return _validateGreeting(verb, rest, caps);
219
+ if (verb === "MAIL") return _validatePath(verb, rest, caps, "FROM:");
220
+ if (verb === "RCPT") return _validatePath(verb, rest, caps, "TO:");
221
+ if (verb === "BDAT") return _validateBdat(rest);
222
+ if (verb === "VRFY" || verb === "EXPN") return _validateMailbox(verb, rest, caps);
223
+ if (verb === "AUTH") return _validateAuth(rest);
224
+ if (verb === "NOOP" || verb === "HELP") return { verb: verb, args: rest ? [rest] : [], params: {} };
225
+
226
+ return { verb: verb, args: [], params: {} };
227
+ }
228
+
229
+ /**
230
+ * @primitive b.guardSmtpCommand.compliancePosture
231
+ * @signature b.guardSmtpCommand.compliancePosture(posture)
232
+ * @since 0.9.32
233
+ * @status stable
234
+ *
235
+ * Return the effective profile name for a compliance posture, or
236
+ * `null` for unknown posture names.
237
+ *
238
+ * @example
239
+ * b.guardSmtpCommand.compliancePosture("hipaa"); // → "strict"
240
+ */
241
+ function compliancePosture(posture) {
242
+ return COMPLIANCE_POSTURES[posture] || null;
243
+ }
244
+
245
+ function _validateGreeting(verb, rest, caps) {
246
+ if (rest.length === 0) {
247
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
248
+ verb + " requires a domain or address literal argument (RFC 5321 §4.1.1.1)");
249
+ }
250
+ // Trim trailing-space tolerance — most peers send a single space; we
251
+ // accept it but refuse multiple spaces or leading spaces.
252
+ if (rest.charAt(0) === " " || rest.indexOf(" ") !== -1) {
253
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-whitespace",
254
+ verb + " greeting has anomalous whitespace");
255
+ }
256
+ var arg = rest;
257
+ if (ADDR_LIT_RE.test(arg)) return { verb: verb, args: [arg], params: {} }; // allow:regex-no-length-cap — line is maxLineBytes-capped upstream
258
+ if (Buffer.byteLength(arg, "utf8") > caps.maxDomain) {
259
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-domain",
260
+ verb + ": domain exceeds maxDomain=" + caps.maxDomain + " (RFC 1035 §2.3.4)");
261
+ }
262
+ if (!DOMAIN_RE.test(arg)) { // allow:regex-no-length-cap — domain just length-checked above
263
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
264
+ verb + ": domain '" + arg + "' does not match LDH shape (RFC 5321 §4.1.2)");
265
+ }
266
+ return { verb: verb, args: [arg], params: {} };
267
+ }
268
+
269
+ function _validatePath(verb, rest, caps, requiredPrefix) {
270
+ // SMTP allows OPTIONAL whitespace between e.g. MAIL and FROM:<...>
271
+ // per RFC 5321 §4.1.1.2; we accept a single space (consumed above)
272
+ // and refuse multiple spaces around the colon.
273
+ if (rest.indexOf(requiredPrefix) !== 0) {
274
+ // Allow a single leading SP — but our caller already split on the
275
+ // first SP; rest is post-SP. Some implementations send the path
276
+ // prefix verbatim. Refuse anything else.
277
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
278
+ verb + " must begin with '" + requiredPrefix + "' (RFC 5321 §3.3)");
279
+ }
280
+ var after = rest.slice(requiredPrefix.length);
281
+ var pathEnd = after.indexOf(">");
282
+ if (after.charAt(0) !== "<" || pathEnd === -1) {
283
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
284
+ verb + " path missing angle brackets (RFC 5321 §3.3)");
285
+ }
286
+ var pathRaw = after.slice(0, pathEnd + 1);
287
+ if (Buffer.byteLength(pathRaw, "utf8") > caps.maxMailbox) {
288
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-path",
289
+ verb + ": path '" + pathRaw + "' exceeds maxMailbox=" + caps.maxMailbox +
290
+ " (RFC 5321 §4.5.3.1.3)");
291
+ }
292
+ var pathBody = pathRaw.slice(1, -1);
293
+ // Reverse-path can be empty (`MAIL FROM:<>` per RFC 5321 §3.3 bounce
294
+ // sender). Forward-path can't be empty.
295
+ if (pathBody.length === 0) {
296
+ if (verb === "MAIL") return { verb: verb, args: [pathRaw], params: _parseExtParams(after.slice(pathEnd + 1)) };
297
+ throw new GuardSmtpCommandError("guard-smtp-command/empty-path",
298
+ verb + " path must not be empty");
299
+ }
300
+ // Validate mailbox local-part and domain caps.
301
+ var atIdx = pathBody.lastIndexOf("@");
302
+ if (atIdx === -1) {
303
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
304
+ verb + ": mailbox missing '@' (RFC 5321 §4.1.2)");
305
+ }
306
+ var localPart = pathBody.slice(0, atIdx);
307
+ var domain = pathBody.slice(atIdx + 1);
308
+ if (Buffer.byteLength(localPart, "utf8") > caps.maxLocalPart) {
309
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-local-part",
310
+ verb + ": local-part exceeds maxLocalPart=" + caps.maxLocalPart);
311
+ }
312
+ if (Buffer.byteLength(domain, "utf8") > caps.maxDomain) {
313
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-domain",
314
+ verb + ": domain exceeds maxDomain=" + caps.maxDomain);
315
+ }
316
+ if (!ADDR_LIT_RE.test(domain) && !DOMAIN_RE.test(domain)) { // allow:regex-no-length-cap — domain length-checked above
317
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
318
+ verb + ": domain '" + domain + "' does not match LDH or address-literal shape");
319
+ }
320
+ return {
321
+ verb: verb,
322
+ args: [pathRaw],
323
+ params: _parseExtParams(after.slice(pathEnd + 1)),
324
+ };
325
+ }
326
+
327
+ function _parseExtParams(tail) {
328
+ // RFC 5321 §4.1.1.11 — esmtp-keyword + optional '=' + esmtp-value.
329
+ // Caller passes the post-path slice; leading SP is allowed and
330
+ // separates params from each other.
331
+ var params = {};
332
+ var parts = tail.trim().split(/\s+/).filter(Boolean);
333
+ for (var i = 0; i < parts.length; i += 1) {
334
+ var eq = parts[i].indexOf("=");
335
+ var key = (eq === -1 ? parts[i] : parts[i].slice(0, eq)).toUpperCase();
336
+ var val = eq === -1 ? true : parts[i].slice(eq + 1);
337
+ if (!/^[A-Za-z0-9-]+$/.test(key)) { // allow:regex-no-length-cap — bounded by maxLineBytes via line cap
338
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-ext-param",
339
+ "esmtp-keyword '" + key + "' not in [A-Za-z0-9-] (RFC 5321 §4.1.1.11)");
340
+ }
341
+ params[key] = val;
342
+ }
343
+ return params;
344
+ }
345
+
346
+ function _validateBdat(rest) {
347
+ // RFC 3030 §2: `BDAT <chunk-size> [LAST]`
348
+ var parts = rest.split(/\s+/).filter(Boolean);
349
+ if (parts.length === 0 || parts.length > 2) {
350
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
351
+ "BDAT requires chunk-size and optional LAST (RFC 3030)");
352
+ }
353
+ if (!DECIMAL_RE.test(parts[0])) { // allow:regex-no-length-cap — DECIMAL_RE has built-in repeat cap
354
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
355
+ "BDAT chunk-size must be a decimal number");
356
+ }
357
+ if (parts.length === 2 && parts[1].toUpperCase() !== "LAST") {
358
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
359
+ "BDAT second arg must be LAST (RFC 3030)");
360
+ }
361
+ return {
362
+ verb: "BDAT",
363
+ args: [parts[0]],
364
+ params: parts.length === 2 ? { LAST: true } : {},
365
+ };
366
+ }
367
+
368
+ function _validateMailbox(verb, rest, caps) {
369
+ if (rest.length === 0) {
370
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
371
+ verb + " requires a mailbox argument (RFC 5321 §4.1.1.7/§4.1.1.8)");
372
+ }
373
+ if (Buffer.byteLength(rest, "utf8") > caps.maxMailbox) {
374
+ throw new GuardSmtpCommandError("guard-smtp-command/oversize-path",
375
+ verb + ": mailbox exceeds maxMailbox=" + caps.maxMailbox);
376
+ }
377
+ return { verb: verb, args: [rest], params: {} };
378
+ }
379
+
380
+ function _validateAuth(rest) {
381
+ // RFC 4954: `AUTH <SASL-mech> [<initial-response>]`
382
+ var parts = rest.split(/\s+/).filter(Boolean);
383
+ if (parts.length === 0 || parts.length > 2) {
384
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
385
+ "AUTH requires mechanism and optional initial-response (RFC 4954)");
386
+ }
387
+ // SASL mechanism names are RFC 4422 — ALPHA + DIGIT + "-" + "_", up
388
+ // to 20 octets. We accept the 4422 charset and a 32-byte cap so
389
+ // operator-extension mechanisms have room.
390
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(parts[0])) { // allow:regex-no-length-cap — anchored + repeat-cap
391
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-shape",
392
+ "AUTH: SASL mechanism '" + parts[0] + "' not in RFC 4422 charset or too long");
393
+ }
394
+ return {
395
+ verb: "AUTH",
396
+ args: [parts[0].toUpperCase()],
397
+ params: parts.length === 2 ? { initialResponse: parts[1] } : {},
398
+ };
399
+ }
400
+
401
+ function _resolveProfile(opts) {
402
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
403
+ return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
404
+ }
405
+ var p = opts.profile || DEFAULT_PROFILE;
406
+ if (!PROFILES[p]) {
407
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-profile",
408
+ "guardSmtpCommand: unknown profile '" + p + "'");
409
+ }
410
+ return PROFILES[p];
411
+ }
412
+
413
+ /**
414
+ * @primitive b.guardSmtpCommand.gate
415
+ * @signature b.guardSmtpCommand.gate(opts?)
416
+ * @since 0.9.32
417
+ * @status stable
418
+ *
419
+ * Build a guard gate compatible with `b.guardAll.allGuards()`. The
420
+ * gate's `decide(ctx)` reads `ctx.identifier` (or `ctx.commandLine`)
421
+ * and routes through `validate()`; refuse on any thrown
422
+ * `GuardSmtpCommandError`, serve otherwise.
423
+ *
424
+ * @opts
425
+ * profile: "strict" | "balanced" | "permissive",
426
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
427
+ * name: string, // gate identity label
428
+ *
429
+ * @example
430
+ * var gate = b.guardSmtpCommand.gate({ profile: "strict" });
431
+ * await gate.decide({ identifier: "EHLO mail.example.com" });
432
+ * // → { ok: true, action: "serve" }
433
+ */
434
+ function gate(opts) {
435
+ opts = opts || {};
436
+ // Resolve profile eagerly so a bad profile / posture surfaces here
437
+ // rather than inside the first gate.check() call.
438
+ _resolveProfile(opts);
439
+ var name = opts.name || "guardSmtpCommand:" + (opts.profile || opts.posture || "default");
440
+ return gateContract.buildGuardGate(name, opts, async function (ctx) {
441
+ var line = ctx && (ctx.identifier || ctx.commandLine || "");
442
+ if (!line) return { ok: true, action: "serve" };
443
+ try {
444
+ validate(line, opts);
445
+ return { ok: true, action: "serve" };
446
+ } catch (e) {
447
+ if (e && typeof e.code === "string" && e.code.indexOf("guard-smtp-command/") === 0) {
448
+ return {
449
+ ok: false,
450
+ action: "refuse",
451
+ issues: [{
452
+ kind: e.code.split("/")[1],
453
+ severity: "critical",
454
+ ruleId: "smtp-command." + e.code.split("/")[1],
455
+ snippet: e.message,
456
+ }],
457
+ };
458
+ }
459
+ throw e;
460
+ }
461
+ });
462
+ }
463
+
464
+ module.exports = {
465
+ validate: validate,
466
+ gate: gate,
467
+ compliancePosture: compliancePosture,
468
+ PROFILES: PROFILES,
469
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
470
+ KNOWN_VERBS: KNOWN_VERBS,
471
+ GuardSmtpCommandError: GuardSmtpCommandError,
472
+ NAME: "smtpCommand",
473
+ KIND: "identifier",
474
+ INTEGRATION_FIXTURES: Object.freeze({
475
+ kind: "identifier",
476
+ // Benign: standard EHLO greeting.
477
+ benignBytes: Buffer.from("EHLO mail.example.com", "ascii"),
478
+ // Hostile: CRLF smuggling attempt — bare CR inside a command line
479
+ // (CVE-2023-51764 / 51765 / 51766 class).
480
+ hostileBytes: Buffer.from("MAIL FROM:<a@b.com>\r\n.\r\nMAIL FROM:<evil@x.com>", "ascii"),
481
+ benignIdentifier: "EHLO mail.example.com",
482
+ hostileIdentifier: "MAIL FROM:<a@b.com>\r\n.\r\nMAIL FROM:<evil@x.com>",
483
+ }),
484
+ };
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardSnapshotEnvelope
4
+ * @nav Guards
5
+ * @title Guard Snapshot Envelope
6
+ * @order 444
7
+ *
8
+ * @intro
9
+ * Snapshot envelope shape validator. The agent snapshot primitive
10
+ * (v0.9.30) writes a structured envelope to durable storage on drain
11
+ * and reads it back on restart. The guard refuses malformed
12
+ * envelopes at the boundary so a corrupt or tampered snapshot
13
+ * doesn't get partially restored.
14
+ *
15
+ * Envelope contract: snapshotId, takenAt, frameworkVersion,
16
+ * orchestratorState, inFlight, idempotencyCache (optional), sig,
17
+ * schemaVersion.
18
+ *
19
+ * Hard caps:
20
+ * - total serialized size (default 50 MiB)
21
+ * - in-flight items count (default 65536 — orchestrator can't
22
+ * legitimately hold more in-flight streams + sagas + outbox-
23
+ * jobs at one moment than that)
24
+ * - schemaVersion must be a positive integer
25
+ *
26
+ * @card
27
+ * Validates snapshot envelopes at drain/restore boundary. Bounded
28
+ * size + in-flight count + schema-version monotonic check.
29
+ */
30
+
31
+ var { defineClass } = require("./framework-error");
32
+
33
+ var GuardSnapshotEnvelopeError = defineClass("GuardSnapshotEnvelopeError", { alwaysPermanent: true });
34
+
35
+ var DEFAULT_PROFILE = "strict";
36
+
37
+ var PROFILES = Object.freeze({
38
+ strict: { maxBytes: 52428800, maxInFlight: 65536 }, // allow:raw-byte-literal — 50 MiB cap
39
+ balanced: { maxBytes: 209715200, maxInFlight: 262144 }, // allow:raw-byte-literal — 200 MiB
40
+ permissive: { maxBytes: 1073741824, maxInFlight: 1048576 }, // allow:raw-byte-literal — 1 GiB
41
+ });
42
+
43
+ var COMPLIANCE_POSTURES = Object.freeze({
44
+ hipaa: "strict",
45
+ "pci-dss": "strict",
46
+ gdpr: "strict",
47
+ soc2: "strict",
48
+ });
49
+
50
+ /**
51
+ * @primitive b.guardSnapshotEnvelope.validate
52
+ * @signature b.guardSnapshotEnvelope.validate(envelope, opts?)
53
+ * @since 0.9.30
54
+ * @status stable
55
+ * @related b.agent.snapshot.create
56
+ *
57
+ * Validate a snapshot envelope shape. Returns envelope on success;
58
+ * throws on shape refusal.
59
+ *
60
+ * @opts
61
+ * profile: "strict" | "balanced" | "permissive",
62
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
63
+ *
64
+ * @example
65
+ * b.guardSnapshotEnvelope.validate({
66
+ * snapshotId: "snap-abc",
67
+ * takenAt: 1700000000000,
68
+ * frameworkVersion: "0.9.30",
69
+ * schemaVersion: 1,
70
+ * orchestratorState: {},
71
+ * inFlight: {},
72
+ * });
73
+ */
74
+ function validate(envelope, opts) {
75
+ opts = opts || {};
76
+ var profile = PROFILES[_resolveProfile(opts)];
77
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
78
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-input",
79
+ "guardSnapshotEnvelope.validate: envelope required");
80
+ }
81
+ if (typeof envelope.snapshotId !== "string" || envelope.snapshotId.length === 0) {
82
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-snapshot-id",
83
+ "guardSnapshotEnvelope.validate: snapshotId required");
84
+ }
85
+ if (typeof envelope.takenAt !== "number" || !isFinite(envelope.takenAt) || envelope.takenAt <= 0) {
86
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-taken-at",
87
+ "guardSnapshotEnvelope.validate: takenAt must be a positive finite number");
88
+ }
89
+ if (typeof envelope.frameworkVersion !== "string" || envelope.frameworkVersion.length === 0) {
90
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-framework-version",
91
+ "guardSnapshotEnvelope.validate: frameworkVersion required");
92
+ }
93
+ if (!Number.isInteger(envelope.schemaVersion) || envelope.schemaVersion < 1) {
94
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-schema-version",
95
+ "guardSnapshotEnvelope.validate: schemaVersion must be a positive integer");
96
+ }
97
+ if (!envelope.orchestratorState || typeof envelope.orchestratorState !== "object") {
98
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-orchestrator-state",
99
+ "guardSnapshotEnvelope.validate: orchestratorState object required");
100
+ }
101
+ if (!envelope.inFlight || typeof envelope.inFlight !== "object") {
102
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/missing-in-flight",
103
+ "guardSnapshotEnvelope.validate: inFlight object required");
104
+ }
105
+ // Total in-flight count cap — sum of streams + sagas + subscribers + pendingDeliveries.
106
+ var inFlightCount = 0;
107
+ ["streams", "sagas", "outboxJobs", "busSubscribers", "pendingDeliveries"].forEach(function (k) {
108
+ if (Array.isArray(envelope.inFlight[k])) inFlightCount += envelope.inFlight[k].length;
109
+ });
110
+ if (inFlightCount > profile.maxInFlight) {
111
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/in-flight-cap",
112
+ "guardSnapshotEnvelope.validate: " + inFlightCount +
113
+ " in-flight items exceeds maxInFlight=" + profile.maxInFlight);
114
+ }
115
+ // Size cap — serialize the whole envelope to JSON for size check.
116
+ var serialized;
117
+ try { serialized = JSON.stringify(envelope); }
118
+ catch (e) {
119
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/unserializable",
120
+ "guardSnapshotEnvelope.validate: envelope not JSON-serializable: " +
121
+ (e && e.message ? e.message : String(e)));
122
+ }
123
+ if (Buffer.byteLength(serialized, "utf8") > profile.maxBytes) {
124
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/oversize",
125
+ "guardSnapshotEnvelope.validate: " + Buffer.byteLength(serialized, "utf8") +
126
+ " bytes exceeds maxBytes=" + profile.maxBytes);
127
+ }
128
+ return envelope;
129
+ }
130
+
131
+ /**
132
+ * @primitive b.guardSnapshotEnvelope.compliancePosture
133
+ * @signature b.guardSnapshotEnvelope.compliancePosture(posture)
134
+ * @since 0.9.30
135
+ * @status stable
136
+ *
137
+ * Return the effective profile for a given compliance posture name.
138
+ * Returns `null` for unknown posture names so operator typos surface
139
+ * here instead of silently falling through to the default profile.
140
+ *
141
+ * @example
142
+ * b.guardSnapshotEnvelope.compliancePosture("hipaa"); // returns "strict"
143
+ */
144
+ function compliancePosture(posture) {
145
+ return COMPLIANCE_POSTURES[posture] || null;
146
+ }
147
+
148
+ function _resolveProfile(opts) {
149
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
150
+ return COMPLIANCE_POSTURES[opts.posture];
151
+ }
152
+ var p = opts.profile || DEFAULT_PROFILE;
153
+ if (!PROFILES[p]) {
154
+ throw new GuardSnapshotEnvelopeError("snapshot-envelope/bad-profile",
155
+ "guardSnapshotEnvelope: unknown profile '" + p + "'");
156
+ }
157
+ return p;
158
+ }
159
+
160
+ module.exports = {
161
+ validate: validate,
162
+ compliancePosture: compliancePosture,
163
+ PROFILES: PROFILES,
164
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
165
+ GuardSnapshotEnvelopeError: GuardSnapshotEnvelopeError,
166
+ NAME: "snapshotEnvelope",
167
+ KIND: "snapshot-envelope",
168
+ };