@blamejs/core 0.9.45 → 0.9.49

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 CHANGED
@@ -8,6 +8,22 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.49 (2026-05-16) — **`b.mail.server.imap` IMAP4rev2 listener (RFC 9051) + `b.guardImapCommand` wire-protocol validator.** Closes the second slice of downstream-consumer gap item #12 (submission + IMAP + JMAP listeners). Modern MUAs (Thunderbird, Apple Mail, mutt, K-9, FairEmail) connect here to read + manage messages without operators running dovecot/cyrus alongside. **(A) `b.guardImapCommand`** — wire-protocol validator for IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051), August 2021; obsoletes [RFC 3501](https://www.rfc-editor.org/rfc/rfc3501)). Refuses bare-CR/LF/NUL/C0/DEL outside literals (smuggling defense analogous to SMTP), enforces RFC 9051 §2.2.2 literal framing (mid-line `{n}` openers refused via `detectLiteralSmuggling`; LITERAL+ per [RFC 7888](https://www.rfc-editor.org/rfc/rfc7888) refused pre-AUTH per §1), enforces per-verb shape, line cap (8 KiB strict / 16 KiB balanced / 64 KiB permissive), literal cap (64 MiB strict / 128 MiB balanced / 256 MiB permissive), mailbox-name cap, sequence-set cap, SEARCH-depth cap. Strict + balanced + permissive profiles + HIPAA/PCI-DSS/GDPR/SOC2 compliance postures (all map to strict). **(B) `b.mail.server.imap`** — state machine (NOT-AUTHENTICATED → STARTTLS → AUTH → SELECTED → LOGOUT). Commands: CAPABILITY / NOOP / LOGOUT / ID ([RFC 2971](https://www.rfc-editor.org/rfc/rfc2971)) / STARTTLS / AUTHENTICATE (PLAIN + multi-step SASL via `{ pending, challenge }`) / LOGIN (refused under strict — RFC 9051 §6.3.4 deprecated) / ENABLE / SELECT / EXAMINE / LIST / STATUS / NAMESPACE / APPEND / CHECK / CLOSE / UNSELECT / EXPUNGE / FETCH / STORE / UID (FETCH/STORE wrappers) / IDLE + DONE ([RFC 2177](https://www.rfc-editor.org/rfc/rfc2177); 29-min bandwidth timeout per §3). Composes `b.guardImapCommand` (wire-protocol gate) + `b.mail.server.rateLimit` (per-IP concurrent + rate + AUTH-failure budget, default-on) + operator-supplied `b.mailStore` backend + operator-supplied SASL authenticator. STARTTLS-injection defense (pre-handshake receive buffer cleared per [CVE-2021-33515](https://nvd.nist.gov/vuln/detail/CVE-2021-33515) Dovecot class), literal-injection defense ([CVE-2018-19518](https://nvd.nist.gov/vuln/detail/CVE-2018-19518) INC IMAP class), mailbox-name traversal refusal (`..` / NUL / controls / oversize / modified-UTF7 under strict). Per-connection state (lineBuffer included) lives on the `state` object so concurrent connections don't clobber each other. Audit lifecycle: `mail.server.imap.{connect, auth_attempt, auth_success, auth_failed, auth_rate_limit_refused, select, append, fetch_bulk, expunge, literal_overflow_refused, rate_limit_refused, smtp_smuggling_detected, listening, closed, socket_error, tls_handshake_failed}`. **What v1 does NOT ship** (follow-up slices): SEARCH expressions (operator wires `mailStore.search` when ready — SEARCH semantics are operator-domain logic), NOTIFY (RFC 5465), METADATA (RFC 5464), CATENATE (RFC 4469), URLAUTH (RFC 4467), IMAPSIEVE (RFC 6785), COMPRESS=DEFLATE (RFC 4978; CRIME-class), CONDSTORE/QRESYNC ([RFC 7162](https://www.rfc-editor.org/rfc/rfc7162); modseq exposed via STATUS but per-FETCH CHANGEDSINCE delta deferred). JMAP listener ([RFC 8620](https://www.rfc-editor.org/rfc/rfc8620) + [RFC 8621](https://www.rfc-editor.org/rfc/rfc8621)) ships as the next slice.
12
+
13
+ - v0.9.47 (2026-05-15) — **`b.mail.server.submission` outbound SMTP submission listener + `b.mail.server.rateLimit` per-IP DoS defenses + `b.guardDomain` wiring on every mail-listener domain crossing + `b.selfUpdate.compareTags(a, b)` + `b.metrics.snapshot.render` field-type metadata.** Bundled mail-listener completion + DX shape patch.
14
+
15
+ **(A) `b.mail.server.submission`** — Closes the first slice of downstream-consumer gap item #12 (submission + IMAP + JMAP listeners). Where the v0.9.46 MX listener accepts inbound mail from the internet, the submission listener accepts outbound mail from authenticated MUAs / app-side mail-senders on port 587 (explicit STARTTLS) or port 465 (implicit-TLS per RFC 8314). Composes the framework's existing primitives: `b.guardSmtpCommand` for wire-protocol shape + smuggling defense, `b.safeSmtp` for DATA-body parsing, the operator's SASL authenticator for credentials, and an operator-supplied `agent.handoff` for outbound routing through `b.mail.send`. **Defenses inherited from the MX listener pattern:** SMTP smuggling (CVE-2023-51764 / -51765 / -51766 / 2024-32178 / RFC 5321 §2.3.8), STARTTLS-injection (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot), per-line / per-message / per-recipient bounds (RFC 5321 §4.5.3.1.7/§4.5.3.1.8/§4.5.3.2.7). **New for submission:** (1) **AUTH required before MAIL FROM** under strict + balanced profiles (RFC 6409 §3; submission listener is authenticated by design). Operator-supplied `auth.verify(mechanism, credentials)` async predicate decides the credential check; multi-step SASL mechanisms (SCRAM-SHA-256, GS2-* family) supported via `{ pending: true, challenge }` return shape per RFC 4954 §4. (2) **AUTH-needs-TLS** (RFC 4954 §4) — pre-STARTTLS AUTH refused with 538 5.7.11 under strict + balanced; permissive opts down for legacy operator-acknowledged downgrade. (3) **Implicit-TLS mode** (`implicitTls: true` → port 465 per RFC 8314 §3.3) wraps every connection in TLS from the SYN; STARTTLS not advertised because the connection is already secure; refused with 502 5.5.1 if a client tries it. (4) **Identity binding** under strict profile — `MAIL FROM:<x@y>` MUST match an entry in the authenticated actor's mailbox set; refused with 553 5.7.1 Sender address rejected. Permissive logs the mismatch but allows. (5) **Recipient policy hook** (`opts.recipientPolicy`) — operator-supplied async predicate decides per-RCPT whether the authenticated actor may send to a destination; refusal returns 550 5.7.1 Recipient policy refused. Wires policy decisions like "block *.gov from this tenant" / "this actor's outbound budget is exhausted" / "destination is in the operator's deny list". Policy-engine failure → 451 4.7.1 (transient) so the sender retries. Audit lifecycle: `mail.server.submission.{connect, helo, auth_attempt, auth_success, auth_failed, mail_from, identity_mismatch, rcpt_to, recipient_refused, recipient_policy_threw, data_accepted, data_refused, outbound_routed, smtp_smuggling_detected, tls_handshake_failed, listening, closed, socket_error, handler_threw}`. **What v1 does NOT ship:** DKIM signing pre-relay (operator wires `b.mail.dkim.sign` in their outbound agent), CHUNKING / BDAT extension (RFC 3030; clients use DATA instead), per-actor outbound quota (operator wires `b.dailyByteQuota` against the authenticated actor). Some duplication with `b.mail.server.mx` is intentional + tracked for factoring after IMAP / JMAP listeners ship (informs the right base abstraction shape).
16
+
17
+ **(B) `b.mail.server.rateLimit`** — new per-IP DoS-defense module wired into BOTH `b.mail.server.mx` and `b.mail.server.submission` as default-on belt-and-suspenders to kernel/proxy-level limits. (1) **Per-IP concurrent connection cap** (`maxConcurrentConnectionsPerIp`, default 10) — a single hostile peer cannot open thousands of TCP slots and starve legitimate senders. (2) **Per-IP connection rate** (`connectionsPerIpPerMinute`, default 60) — rapid reconnect / scan attacks tripped here; legitimate retry-with-backoff traffic stays under the cap. (3) **Per-IP AUTH-failure budget** (`authFailuresPerIpPer15Min`, default 10; submission listener only) — credential-stuffing class. The framework's authenticator is unaware of this layer; the rate-limit lives at the wire-protocol boundary so a credential leak past the listener stays bounded. (4) **Slow-loris / `minBytesPerSecond`** floor (default 100 bytes/sec) on the DATA-body phase complements the existing `idleTimeoutMs` (which cuts fully-stalled connections) by also cutting peers that trickle one byte per minute to hold a connection within the idle window. Refused connections receive `421 4.7.0 Too many connections from your IP` (transient — RFC 5321 §3.8 + §4.5.4.2 negative completion). Audit emits: `mail.server.rate_limit.refused` / `mail.server.rate_limit.auth_refused`. Operators pass `rateLimit: false` to disable for tests, a shared handle via `b.mail.server.rateLimit.create({...})` to share one budget across multiple listeners, or an opts object to override defaults.
18
+
19
+ **(C) `b.guardDomain` wiring on every mail-listener domain crossing** — HELO / EHLO greeting, MAIL FROM domain, RCPT TO domain, and operator-supplied `opts.localDomains` all route through `b.guardDomain.validate` (default-on; opt-out via `guardDomain: false`). Defends [CVE-2017-5469-class](https://nvd.nist.gov/vuln/detail/CVE-2017-5469) IDN homograph spoofs, refuses [RFC 6761](https://www.rfc-editor.org/rfc/rfc6761) special-use domain names in production (`.localhost`, `.test`, `.invalid`, `.example`), enforces RFC 1035 §2.3.4 label-length caps, and refuses bare IPv4/IPv6 as a domain ([CVE-2021-22931](https://nvd.nist.gov/vuln/detail/CVE-2021-22931) class allowlist-bypass via DNS rebinding). RFC 5321 §4.1.3 address literals (`[1.2.3.4]` / `[IPv6:...]`) skip guardDomain — they're already constrained by `b.guardSmtpCommand`'s bracket-syntax validator. RFC 5321 §4.5.5 empty reverse-path (`<>` for bounces) also skipped. `opts.localDomains` is pre-validated at `create()` time so an operator who typed an IDN homograph into their allowlist gets a `mail-server-mx/bad-local-domain` boot failure instead of a silently-weakened gate. Refusal returns `501 5.5.4 <domain> refused (<kind>)` with an audit emit on `mail.server.{mx,submission}.domain_refused`.
20
+
21
+ **(D) Codex P1 fix — RCPT TO cap-check counts in-flight async verdicts.** When `opts.recipientPolicy` is async, the recipient-limit guard ran before the policy promise resolved and the accepted recipient was appended later. Under SMTP PIPELINING (RFC 2920) each new RCPT TO saw the same `state.rcpts.length == 0` (prior commands hadn't pushed yet), so the cap-check passed for every command and `state.rcpts` grew past `maxRcptsPerMessage` once all verdicts resolved. Fix: track in-flight verdicts in `state.rcptsPending`; cap-check counts BOTH committed AND in-flight against `maxRcptsPerMessage`; defense-in-depth re-check inside the `.then()` before push. `_resetTransaction` zeroes the pending counter.
22
+
23
+ **(E) `b.selfUpdate.compareTags(a, b)`** — the existing internal `_compareTags` helper (used by `b.selfUpdate.poll` / `pickRelease` since v0.7.x) is now part of the public API. Downstream consumers replacing one-off compareVersions implementations call `b.selfUpdate.compareTags("v0.9.46", "v0.9.47")` (returns `-1` / `0` / `+1`) instead of carrying their own 8-line semver-shaped comparator. Strips a leading `v` / `V`, then walks dot-separated components: numeric pairs compared numerically, non-numeric components (release suffixes like `1.0.0-rc.1`) fall back to lexicographic compare. Follows [SemVer 2.0.0 §11](https://semver.org/spec/v2.0.0.html#spec-item-11) precedence for the numeric prefix; pre-release identifier comparison is lexicographic rather than the full SemVer-mandated alphanumeric rule (operators with strict SemVer §11 pre-release needs should use a dedicated parser; this primitive targets the common framework-update polling shape).
24
+
25
+ **(F) `b.metrics.snapshot.render({ format: "prometheus" })` field-type metadata** — pre-v0.9.47 every numeric field rendered as `# TYPE <name> gauge` regardless of name, which broke `rate()` queries against counter-shaped series (rate() requires monotonic data; running it against a gauge produces nonsense). The renderer now auto-detects per the Prometheus naming convention + [OpenMetrics 1.0.0 §6.2](https://github.com/prometheus/OpenMetrics/blob/main/specification/OpenMetrics.md): field names ending in `_total` render as `counter`; everything else renders as `gauge`. Operators with metrics that don't fit the convention opt the right type via `opts.fieldTypes: { fieldName: "counter" | "gauge" }` (e.g. a counter named `bytes_sent` without the `_total` suffix, or a gauge that happens to end in `_total`). Behavior change for operators scraping a long-running deployment — `rate(*_total[5m])` queries start returning correct answers once the new types reach the scrape target.
26
+ - v0.9.46 (2026-05-15) — **`b.mail.server.mx` — inbound SMTP / MX listener + `b.safeSmtp` parser + `b.guardSmtpCommand.detectBodySmuggling`.** The wire-protocol primitives extracted from the listener inline copy into reusable safe/guard modules — `b.safeSmtp.findDotTerminator(buf)` + `b.safeSmtp.dotUnstuff(buf)` for the parsing concerns (where the body terminator is, how to reverse dot-stuffing per RFC 5321 §4.5.2), and `b.guardSmtpCommand.detectBodySmuggling(buf)` for the security concern (CVE-2023-51764 / -51765 / -51766 / 2024-32178 bare-LF dot-terminator detection). The MX listener consumes both. Same primitives ship for the upcoming submission / IMAP / JMAP listeners and for any operator-side tooling that needs to parse SMTP bytes (proxies, log analyzers, test fixtures) without booting a full server. Closes downstream-consumer gap item #11. Composes the existing mail-gate substrates (`b.mail.helo`, `b.mail.rbl`, `b.mail.greylist`, `b.guardEnvelope`, `b.mail.auth.dmarc`, `b.safeMime`, `b.guardEmail`, `b.guardSmtpCommand`, `b.mail.agent`) into one operator-facing inbound listener that drives the RFC 5321 CONNECT → EHLO → [STARTTLS → EHLO] → MAIL → RCPT → DATA → DATA-body → QUIT state machine. **Defenses baked in:** (1) **SMTP smuggling** (CVE-2023-51764 / CVE-2024-32178 / RFC 5321 §2.3.8) — every wire line passes through `b.guardSmtpCommand.validate` refusing bare LF / bare CR / NUL / C0 / DEL / oversize; the DATA body's `\r\n.\r\n` terminator is matched on canonical CRLF only — bare-LF dot-terminators are detected via `_detectSmugglingShape` and refused with 554 5.7.0 + an `mail.server.mx.smtp_smuggling_detected` audit event. (2) **Open-relay defense** — `localDomains` allowlist with default-deny posture; RCPT TO non-local refused with 550 5.7.1 unless `relayAllowedFor: [{ cidr, scope }]` opts the destination in explicitly. (3) **STARTTLS-injection defense (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot)** — command buffer + body collector cleared at upgrade time so pre-handshake pipelined commands (RFC 2920 PIPELINING) can't take effect post-handshake. (4) **TLS posture** — `tlsContext` is required (no implicit plaintext-only mode); pre-STARTTLS plain commands limited to EHLO / HELO / STARTTLS / NOOP / QUIT / RSET under strict + balanced profiles; MAIL / RCPT / DATA refused with 530 5.7.0 Must issue a STARTTLS command first. Permissive profile accepts plaintext for legacy operator-acknowledged downgrade. (5) **Resource exhaustion** — per-command line cap (default 1 KiB), DATA body cap (default 50 MiB per RFC 5321 §4.5.3.1.7), per-recipient cap (default 100 per RFC 5321 §4.5.3.1.8), idle timeout (default 5 minutes per RFC 5321 §4.5.3.2.7). RFC 5321 §4.5.2 dot-stuffing reversal via `_dotUnstuff`. RFC 1870 §3 SIZE param parsed at MAIL FROM time + refused with 552 5.3.4 if oversize. RFC 2920 PIPELINING + RFC 6152 8BITMIME (obsoletes RFC 1652) + RFC 2034 ENHANCEDSTATUSCODES advertised in EHLO capabilities. RFC 3463 enhanced status codes embedded in every reply for operator-side observability. RFC 6531 SMTPUTF8 / RFC 5891 IDN deliberately NOT advertised — non-ASCII MAIL FROM / RCPT TO bytes refused via `b.guardSmtpCommand` until the operator's downstream (mail-store + delivery agent) accepts Unicode mailbox-local-part bytes. Audit lifecycle: `mail.server.mx.{connect,helo,mail_from,rcpt_to,data_accepted,data_refused,delivered,tls_handshake_failed,smtp_smuggling_detected,relay_refused,listening,closed,handler_threw,socket_error}`. **What v1 does NOT ship:** AUTH / submission auth (port-587 listener is its own slice), Sieve filtering (composes via `b.mail.agent` at delivery), outbound DSN generation (deferred to submission slice), 8BITMIME / SMTPUTF8 transcoding (advertised but parser-agnostic).
11
27
  - v0.9.45 (2026-05-15) — **`b.crypto.toBase64Url` / `fromBase64Url` helpers + lib-wide `.replace(/X+$/, ...)` ReDoS-shape sweep.** The trailing-greedy regex `.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")` base64url-by-hand pattern was duplicated across 9 framework call sites (JWT / DPoP / OAuth / SD-JWT VC status-list / DNS-over-HTTPS GET encoding ×3 / GCS service-account JWT signing / pagination cursors). The trailing `/=+$/` regex is polynomial-ReDoS-shaped per CodeQL `js/polynomial-redos` — the engine backtracks on inputs with many trailing `=`. (1) **`b.crypto.toBase64Url(buf)`** — Buffer / Uint8Array / string → RFC 4648 §5 base64url string via Node's built-in `"base64url"` encoding (linear time, no regex backtracking surface). (2) **`b.crypto.fromBase64Url(s)`** — inverse decode. (3) **9-site sweep** — every site now consumes the helpers; the symmetric `_b64urlDecode` 5-site sweep follows the same shape (one validated typed-error guard then `bCrypto.fromBase64Url`). `lib/argon2-builtin.js` retains its own `_b64NoPad` helper (PHC strings use standard base64 alphabet `+/` not url-safe `-_`); converted from `.replace(/=+$/, "")` to a linear `charCodeAt`+`slice` loop. (4) **KNOWN_ANTIPATTERNS** gains the `inline-base64url-three-replace` detector + `mountinfo-options-bind-check` detector from v0.9.43 — any future site that reaches for either pattern trips the gate at n=1. (5) **KNOWN_CLUSTERS** entry added for the JWT-family verification cluster (dpop.verify / jwt._requireNumericDate / oauth.verifyBackchannelLogoutToken) that surfaced after the redos sweep shifted line offsets; structurally distinct RFC primitives (RFC 9449 DPoP / RFC 7519 JWT / OIDC Back-Channel Logout) sharing a replayStore.checkAndInsert + numeric-date-bound shingle. References: [RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648#section-5) (base64url encoding spec), [CodeQL js/polynomial-redos](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/) (the regex-engine backtracking class CodeQL flags).
12
28
  - v0.9.44 (2026-05-15) — **Two downstream-consumer gap items bundled: `b.storage.chunkScratch` + `b.agent.tenant` cryptoField adoption helper.** Closes the third batch of gap-list items. **(A) `b.storage.chunkScratch`** — resumable-chunked-upload primitive. Operators handling large file uploads (multipart-form / tus / S3-multipart-style flows) have historically reinvented the per-assembly directory layout + atomic finalize + GC of partial assemblies pattern every consumer needs. `b.storage.chunkScratch(opts?)` owns it once. Returns a handle with 10 lifecycle methods. (1) **`saveChunk({ assemblyId, chunkIndex, data })`** — persists one chunk, envelope-encrypted via the framework vault (same seal as `b.storage.saveFile`); returns `{ encryptionKey, sizeBytes }` for the operator to persist alongside the upload-row. Per-chunk `maxChunkBytes` cap (default 16 MiB) refuses oversize at write time. (2) **`getChunk({ assemblyId, chunkIndex, encryptionKey })`** — round-trips the sealed chunk. (3) **`chunkExists({ assemblyId, chunkIndex })`** — boolean probe. (4) **`listChunks(assemblyId)`** — sorted array of chunk indices present. (5) **`countChunks(assemblyId)`** — count. (6) **`removeChunk({ assemblyId, chunkIndex })`** — single-chunk delete. (7) **`assemble({ assemblyId, expectedTotal?, chunkEncryptionKeys })`** — verifies monotonic 0..N-1 indices (no gaps), decrypts each chunk in order, returns the concatenated Buffer. Refuses on count mismatch with `expectedTotal` or any chunk-index gap. (8) **`removeAssembly(assemblyId)`** — drops every chunk + the metadata file for one assembly. (9) **`listAssemblies()`** — every assembly with at least one chunk. (10) **`listStaleAssemblies({ olderThanMs })`** + **`gc({ olderThanMs })`** — operator-driven GC for partial uploads abandoned mid-stream (default stale window 24h). `assemblyId` shape is validated to refuse path-traversal (`..`), slash / backslash, NUL / C0 / DEL, dot-prefix, and oversize (>128 bytes). Backend is the operator-configured `b.storage` backend (no new backend concept). Audit events: `system.storage.chunk_scratch.chunk_saved` / `assembled` / `removed` / `gc`. Composes the existing `b.storage.saveFile` envelope; no new crypto. Wire-protocol reference: tus.io v1.0.0, RFC 9110 §14.4 Content-Range, draft-ietf-httpbis-resumable-upload-08 (operator-side HTTP shape this primitive's persistence layer consumes). Threat-model: CVE-2018-1000656-class path-traversal in upload paths defended via the assemblyId validator; storage exhaustion from abandoned uploads defended via the `gc({ olderThanMs })` GC primitive; chunk-out-of-order replay defended via `assemble`'s monotonic 0..N-1 index check. **(B) `b.agent.tenant` cryptoField adoption helper** — `sealField(tenantId, table, field, plaintext)` / `unsealField(...)` / `sealRowForTenant(tenantId, table, row)` / `unsealRowForTenant(tenantId, table, row)`. `b.cryptoField.sealRow` uses the singleton vault keypair — every tenant's sealed data decrypts under the same framework key, which fails the cross-tenant cryptographic isolation that HIPAA §164.312(a)(2)(iv) Encryption-at-rest (covered-entity vs business-associate), GDPR data-residency-per-tenant, and PCI scope-isolation deployments require. The adoption helper derives a per-tenant 32-byte AEAD key via `b.crypto.namespaceHash("agent.tenant.derive.cryptoField:<table>", tenantId)` (NIST SP 800-108 r1 §5.1 KDF-in-Counter-mode shape using SHA3-512) and routes each sealed field through `b.crypto.encryptPacked` (XChaCha20-Poly1305 per draft-irtf-cfrg-xchacha-03; 24-byte nonce making random-nonce generation safe at framework scale) with AAD-bound context (`tenantId|table|field`) per RFC 8439 §2.5 so a ciphertext from tenant A literally cannot decrypt as tenant B's row — even with the wrong tenantId the Poly1305 tag check fails. Threat-model coverage: cross-tenant data exposure class (CVE-2019-19528 was an early multi-tenant example where shared encryption keys allowed cross-tenant decrypt with DB access; this primitive's AAD-binding + per-tenant key derivation defends the class by construction). Ciphertext shape: `"tnt-v1:" + base64(packed)`, distinguishable from `vault.seal`-sealed cells (which start with `"vault:"`) so a storage layer can mix both. `sealRowForTenant` adopts the existing `b.cryptoField` table schema (`sealedFields`); cross-tenant decrypt safe-fails the affected field to `null` (matching `b.cryptoField.unsealRow`'s posture).
13
29
  - v0.9.43 (2026-05-15) — **Three downstream-consumer DX primitives bundled: `b.testHarness.start` + `b.middleware.composePipeline` + `b.watcher` `mode: "auto"`.** Closes the second batch of operator-friction gaps. (1) **`b.testHarness.start(opts?)`** — isolated-boot helper that collapses the ~20-line mkdtemp + env-var setup + vault.init + teardown pattern every consumer was reinventing in `tests/helpers/`. Returns a handle exposing `{ dataDir, dbPath, vaultDir, env, stop() }`. Generates a mkdtemp-based isolated dataDir under `os.tmpdir()` with `b.crypto.generateToken(4)` random suffix, sets `<prefix>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR` env vars, optionally awaits `b.vault.init` in plaintext mode. Concurrent harnesses with `initVault: true` share the process-global vault state via internal reference counting; the last `stop()` releases vault. (2) **`b.middleware.composePipeline(entries, opts?)`** — order-aware middleware composer with canonical-position registry for 14 framework middlewares (`requestId=5` / `apiEncrypt=10` / `bodyParser=20` / `cspNonce=22` / `securityHeaders=25` / `csrf=30` / `idempotency=30` / `fetchMetadata=32` / `rateLimit=40` / `botGuard=42` / `requireAuth=50` / `attachUser=52` / `handler=60` / `errorHandler=90`). Conflict detection at registration time refuses duplicate names, duplicate explicit-position values, and non-monotonic positions. Strict mode (`opts.strict: true`) refuses canonical-name position mismatches; default `false` runs but emits `system.middleware.compose.canonical_mismatch` audit. Sync throws inside middleware propagate to `finalNext`. Boot-time `system.middleware.compose.pipeline_built` audit lists final ordered entries. (3) **`b.watcher.create({ root, mode: "auto", ... })`** — Docker bind-mount / non-inotify-fs auto-fallback. Inside a Linux container with a host bind-mount, `fs.watch` returns no events across gRPC-FUSE / VirtioFS / 9p / NFS / CIFS / vboxsf boundaries; `mode: "auto"` reads `/proc/self/mountinfo`, finds the mount carrying the watcher root, and falls back to `mode: "poll"` when the fstype is non-inotify OR when `/.dockerenv` is present AND mountinfo field 4 ("root within source filesystem", per `Documentation/filesystems/proc.rst §3.5`) is `!= "/"` (bind-mount signature — the kernel exposes the bound source path in this field; regular mounts always carry `/`). Native Linux mounts + non-Linux hosts (FSEvents / ReadDirectoryChangesW) keep `mode: "fs"`. The chosen backend + reason emits as `watcher.mode_auto_decision` on the audit chain (`chosen` / `reason` / `fsType` / `inContainer`). `mode: "fs"` (default) and `mode: "poll"` (explicit) unchanged; `mode: "auto"` is opt-in.
package/index.js CHANGED
@@ -88,6 +88,7 @@ var safeJson = require("./lib/safe-json");
88
88
  var safeJsonPath = require("./lib/safe-jsonpath");
89
89
  var safeMime = require("./lib/safe-mime");
90
90
  var safeDns = require("./lib/safe-dns");
91
+ var safeSmtp = require("./lib/safe-smtp");
91
92
  var mailStore = require("./lib/mail-store");
92
93
  var ntpCheck = require("./lib/ntp-check");
93
94
  var auditSign = require("./lib/audit-sign");
@@ -163,6 +164,7 @@ var guardSvg = require("./lib/guard-svg");
163
164
  var guardFilename = require("./lib/guard-filename");
164
165
  var guardMessageId = require("./lib/guard-message-id");
165
166
  var guardSmtpCommand = require("./lib/guard-smtp-command");
167
+ var guardImapCommand = require("./lib/guard-imap-command");
166
168
  var guardEnvelope = require("./lib/guard-envelope");
167
169
  var guardDsn = require("./lib/guard-dsn");
168
170
  var guardListUnsubscribe = require("./lib/guard-list-unsubscribe");
@@ -260,6 +262,11 @@ var mail = require("./lib/mail");
260
262
  mail.rbl = require("./lib/mail-rbl");
261
263
  mail.greylist = require("./lib/mail-greylist");
262
264
  mail.helo = require("./lib/mail-helo");
265
+ mail.server = mail.server || {};
266
+ mail.server.mx = require("./lib/mail-server-mx");
267
+ mail.server.submission = require("./lib/mail-server-submission");
268
+ mail.server.imap = require("./lib/mail-server-imap");
269
+ mail.server.rateLimit = require("./lib/mail-server-rate-limit");
263
270
  var mailArf = require("./lib/mail-arf");
264
271
  var mailBounce = require("./lib/mail-bounce");
265
272
  var mailMdn = require("./lib/mail-mdn");
@@ -434,6 +441,7 @@ module.exports = {
434
441
  guardFilename: guardFilename,
435
442
  guardMessageId: guardMessageId,
436
443
  guardSmtpCommand: guardSmtpCommand,
444
+ guardImapCommand: guardImapCommand,
437
445
  guardEnvelope: guardEnvelope,
438
446
  guardDsn: guardDsn,
439
447
  guardListUnsubscribe: guardListUnsubscribe,
@@ -535,6 +543,7 @@ module.exports = {
535
543
  safeJsonPath: safeJsonPath,
536
544
  safeMime: safeMime,
537
545
  safeDns: safeDns,
546
+ safeSmtp: safeSmtp,
538
547
  mailStore: mailStore,
539
548
  safeSchema: safeSchema,
540
549
  pagination: pagination,
package/lib/auth/fal.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * @module b.auth.fal
4
- * @nav Identity & Access
4
+ * @nav Identity
5
5
  * @title NIST 800-63-4 FAL Classifier
6
6
  * @order 120
7
7
  *
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardImapCommand
4
+ * @nav Guards
5
+ * @title Guard IMAP Command
6
+ * @order 451
7
+ *
8
+ * @intro
9
+ * IMAP command-line validator (RFC 9051 IMAP4rev2; obsoletes
10
+ * RFC 3501). Gates every command-line the framework's inbound
11
+ * IMAP listener accepts from peers — `CAPABILITY` / `NOOP` /
12
+ * `LOGOUT` / `STARTTLS` / `AUTHENTICATE` / `LOGIN` / `ENABLE` /
13
+ * `SELECT` / `EXAMINE` / `CREATE` / `DELETE` / `RENAME` /
14
+ * `SUBSCRIBE` / `UNSUBSCRIBE` / `LIST` / `NAMESPACE` / `STATUS` /
15
+ * `APPEND` / `IDLE` / `CHECK` / `CLOSE` / `UNSELECT` / `EXPUNGE` /
16
+ * `SEARCH` / `FETCH` / `STORE` / `COPY` / `MOVE` / `UID` /
17
+ * `GETQUOTA` / `SETQUOTA` / `GETQUOTAROOT` / `ID`.
18
+ *
19
+ * ## Smuggling defense — bare-CR / bare-LF refusal
20
+ *
21
+ * Same wire-protocol smuggling class as SMTP: implementations that
22
+ * accept bare-CR or bare-LF in a command line let a hostile peer
23
+ * inject a second command past a per-line filter. RFC 9051 §2.2.1
24
+ * requires CRLF only; this validator refuses every bare CR / bare
25
+ * LF / NUL / C0 / DEL byte outside of explicit literal blocks
26
+ * (which the wire-protocol reader has already framed before
27
+ * handing the line to this validator).
28
+ *
29
+ * ## Literal-injection defense
30
+ *
31
+ * IMAP carries inline length-prefixed literals: `{n}<CRLF><n bytes>`.
32
+ * Per RFC 9051 §2.2.2 the literal opener `{n}` MUST appear at the
33
+ * end of a command line, with the n bytes following on subsequent
34
+ * line(s). RFC 7888 LITERAL+ relaxes the round-trip but is only
35
+ * honored post-AUTH. The validator detects literal openers as
36
+ * either:
37
+ *
38
+ * - well-formed: `{42}` or `{42+}` at the end of the line
39
+ * - injected: `{42}` mid-line (smuggling shape — refuse)
40
+ *
41
+ * Per-literal byte cap defaults to 64 MiB (operator opts down via
42
+ * `maxLiteralBytes`); the LISTENER then enforces the post-literal
43
+ * read against this cap.
44
+ *
45
+ * ## Mailbox-name traversal
46
+ *
47
+ * Mailbox names per RFC 9051 §5.1 — UTF-8 hierarchy with the
48
+ * server-chosen delimiter (typically `/` or `.`). Refuses path-
49
+ * traversal (`..`), NUL bytes, control chars, leading/trailing
50
+ * slash, overlong UTF-8 sequences, and (under strict) modified-
51
+ * UTF7 (RFC 3501 §5.1.3 legacy encoding — operators with legacy
52
+ * MUAs opt in via `allowLegacyMUtf7`).
53
+ *
54
+ * ## Per-verb shape
55
+ *
56
+ * Each command verb has a fixed argument shape per RFC 9051 §6.
57
+ * `LOGIN user pass` takes exactly two atoms or strings. `SELECT`
58
+ * takes one mailbox name. `FETCH` takes a sequence-set + a parts
59
+ * list. Refusals under strict use `guard-imap-command/bad-shape`.
60
+ *
61
+ * ## Caps
62
+ *
63
+ * - Command line (tag + verb + arguments excluding literal
64
+ * payload) capped at 8 KiB. RFC 9051 does not mandate a line
65
+ * cap but most servers limit at 8 KiB or 16 KiB to bound
66
+ * memory; operators on permissive can extend.
67
+ * - Mailbox name capped at 1 KiB.
68
+ * - Sequence set element count capped at 10,000 per command.
69
+ * - SEARCH expression nesting (AND/OR/NOT) capped at 32 levels.
70
+ * - Per-literal byte cap (64 MiB default).
71
+ *
72
+ * Throws `GuardImapCommandError` on every refusal. Pure-functional —
73
+ * no I/O, no state. The IMAP listener composes one instance per
74
+ * accepted connection.
75
+ *
76
+ * @card
77
+ * IMAP command-line validator (RFC 9051 IMAP4rev2). Refuses bare-CR /
78
+ * bare-LF (smuggling defense), enforces literal-injection refusal
79
+ * (RFC 9051 §2.2.2), caps line / mailbox / sequence-set / SEARCH-
80
+ * nesting bytes, validates per-verb shape (CAPABILITY / AUTHENTICATE
81
+ * / LOGIN / SELECT / FETCH / STORE / APPEND / SEARCH / ...).
82
+ */
83
+
84
+ var { defineClass } = require("./framework-error");
85
+
86
+ var GuardImapCommandError = defineClass("GuardImapCommandError", { alwaysPermanent: true });
87
+
88
+ var DEFAULT_PROFILE = "strict";
89
+
90
+ var PROFILES = Object.freeze({
91
+ strict: {
92
+ maxLineBytes: 8192, // allow:raw-byte-literal — 8 KiB command-line cap
93
+ maxLiteralBytes: 67108864, // allow:raw-byte-literal — 64 MiB per-literal cap
94
+ maxMailboxBytes: 1024, // allow:raw-byte-literal — RFC 9051 §5.1 mailbox cap
95
+ maxSequenceSetItems: 10000, // allow:raw-byte-literal — FETCH/STORE sequence-set element cap
96
+ maxSearchDepth: 32, // allow:raw-byte-literal — SEARCH AND/OR/NOT nesting cap
97
+ allowBareLf: false,
98
+ allowLiteralPlus: false, // LITERAL+ (RFC 7888) only post-AUTH; the listener flips this
99
+ allowLegacyMUtf7: false, // RFC 3501 §5.1.3 modified-UTF7 mailbox names — legacy MUA escape hatch
100
+ },
101
+ balanced: {
102
+ maxLineBytes: 16384, // allow:raw-byte-literal — 16 KiB command-line cap
103
+ maxLiteralBytes: 134217728, // allow:raw-byte-literal — 128 MiB per-literal cap
104
+ maxMailboxBytes: 2048, // allow:raw-byte-literal — balanced mailbox cap
105
+ maxSequenceSetItems: 50000, // allow:raw-byte-literal — balanced sequence-set cap
106
+ maxSearchDepth: 48, // allow:raw-byte-literal — balanced SEARCH-depth cap
107
+ allowBareLf: false,
108
+ allowLiteralPlus: true,
109
+ allowLegacyMUtf7: true,
110
+ },
111
+ permissive: {
112
+ maxLineBytes: 65536, // allow:raw-byte-literal — 64 KiB command-line cap (legacy peers)
113
+ maxLiteralBytes: 268435456, // allow:raw-byte-literal — 256 MiB per-literal cap
114
+ maxMailboxBytes: 4096, // allow:raw-byte-literal — permissive mailbox cap
115
+ maxSequenceSetItems: 100000, // allow:raw-byte-literal — permissive sequence-set cap
116
+ maxSearchDepth: 64, // allow:raw-byte-literal — permissive SEARCH-depth cap
117
+ allowBareLf: true,
118
+ allowLiteralPlus: true,
119
+ allowLegacyMUtf7: true,
120
+ },
121
+ });
122
+
123
+ var COMPLIANCE_POSTURES = Object.freeze({
124
+ hipaa: "strict",
125
+ "pci-dss": "strict",
126
+ gdpr: "strict",
127
+ soc2: "strict",
128
+ });
129
+
130
+ // IMAP4rev2 commands per RFC 9051 §6.
131
+ var KNOWN_VERBS = Object.freeze({
132
+ CAPABILITY: true, NOOP: true, LOGOUT: true,
133
+ STARTTLS: true, AUTHENTICATE: true, LOGIN: true,
134
+ ENABLE: true, SELECT: true, EXAMINE: true,
135
+ CREATE: true, DELETE: true, RENAME: true,
136
+ SUBSCRIBE: true, UNSUBSCRIBE: true, LIST: true,
137
+ NAMESPACE: true, STATUS: true, APPEND: true,
138
+ IDLE: true, DONE: true, CHECK: true,
139
+ CLOSE: true, UNSELECT: true, EXPUNGE: true,
140
+ SEARCH: true, FETCH: true, STORE: true,
141
+ COPY: true, MOVE: true, UID: true,
142
+ GETQUOTA: true, SETQUOTA: true, GETQUOTAROOT: true,
143
+ ID: true,
144
+ });
145
+
146
+ var ZERO_ARG_VERBS = Object.freeze({
147
+ CAPABILITY: true, NOOP: true, LOGOUT: true,
148
+ STARTTLS: true, IDLE: true, DONE: true,
149
+ CHECK: true, CLOSE: true, UNSELECT: true,
150
+ EXPUNGE: true,
151
+ NAMESPACE: true,
152
+ });
153
+
154
+ // IMAP tag per RFC 9051 §9 ABNF: `tag = 1*<any ASTRING-CHAR except "+">`.
155
+ // We narrow further: letters, digits, hyphen, underscore, dot — refuses
156
+ // `+` (continuation request marker; reserved by §9 explicitly) and
157
+ // `*` (server-untagged response marker) which are reserved.
158
+ var TAG_RE = /^[A-Za-z0-9._-]{1,64}$/; // allow:regex-no-length-cap — anchored + bounded repeat
159
+
160
+ // Literal-opener detection — `{n}` or `{n+}` at end of line per
161
+ // RFC 9051 §2.2.2 / RFC 7888 §2. The `+` form is LITERAL+ (non-
162
+ // synchronizing).
163
+ var LITERAL_OPEN_RE = /\{([0-9]+)(\+?)\}$/; // allow:regex-no-length-cap — anchored + bounded numeric run
164
+
165
+ // Detect a literal-opener mid-line (smuggling shape) — same `{n}` /
166
+ // `{n+}` pattern but NOT at end of line. Used by detectLiteralSmuggling.
167
+ var LITERAL_SMUGGLE_RE = /\{[0-9]+\+?\}(?!\s*$)/; // allow:regex-no-length-cap — bounded numeric run + tail anchor
168
+
169
+ /**
170
+ * @primitive b.guardImapCommand.validate
171
+ * @signature b.guardImapCommand.validate(line, opts?)
172
+ * @since 0.9.49
173
+ * @status stable
174
+ * @related b.guardImapCommand.detectLiteralSmuggling, b.guardSmtpCommand.validate
175
+ *
176
+ * Validate a single IMAP command line (without its CRLF terminator —
177
+ * the listener strips that before calling this). Returns
178
+ * `{ tag, verb, args, literalSize, literalNonSync }` on success;
179
+ * throws `GuardImapCommandError` on any refusal. `literalSize` is the
180
+ * pending-literal byte count when the line ends in `{n}`; `null`
181
+ * otherwise. `literalNonSync` is true for RFC 7888 LITERAL+ (`{n+}`).
182
+ *
183
+ * @opts
184
+ * profile: "strict" | "balanced" | "permissive",
185
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
186
+ * authenticated: boolean, // when true, LITERAL+ (RFC 7888) is honored under
187
+ * strict; pre-AUTH literal+ is refused per RFC 7888 §1
188
+ *
189
+ * @example
190
+ * var parsed = b.guardImapCommand.validate("A001 LOGIN alice secret");
191
+ * // → { tag: "A001", verb: "LOGIN", args: ["alice", "secret"], literalSize: null, literalNonSync: false }
192
+ *
193
+ * var pending = b.guardImapCommand.validate("A002 APPEND INBOX {1024}");
194
+ * // → { tag: "A002", verb: "APPEND", args: ["INBOX"], literalSize: 1024, literalNonSync: false }
195
+ */
196
+ function validate(line, opts) {
197
+ opts = opts || {};
198
+ var profileName = typeof opts.profile === "string" ? opts.profile : DEFAULT_PROFILE;
199
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
200
+ profileName = COMPLIANCE_POSTURES[opts.posture];
201
+ }
202
+ var caps = PROFILES[profileName];
203
+ if (!caps) {
204
+ throw new GuardImapCommandError("guard-imap-command/bad-profile",
205
+ "guardImapCommand.validate: unknown profile '" + profileName + "'");
206
+ }
207
+ if (typeof line !== "string") {
208
+ throw new GuardImapCommandError("guard-imap-command/bad-input",
209
+ "guardImapCommand.validate: line must be a string");
210
+ }
211
+ if (line.length === 0) {
212
+ throw new GuardImapCommandError("guard-imap-command/empty-line",
213
+ "guardImapCommand.validate: empty command line");
214
+ }
215
+ if (line.length > caps.maxLineBytes) {
216
+ throw new GuardImapCommandError("guard-imap-command/line-too-long",
217
+ "guardImapCommand.validate: line " + line.length + " bytes exceeds cap " + caps.maxLineBytes);
218
+ }
219
+ // Byte-safety: refuse bare CR / bare LF / NUL / C0 / DEL. The
220
+ // wire-protocol reader has already stripped the terminating CRLF
221
+ // before calling validate(); any remaining CR or LF is a smuggling
222
+ // shape.
223
+ for (var i = 0; i < line.length; i += 1) {
224
+ var c = line.charCodeAt(i);
225
+ if (c === 0x00 || c === 0x7F || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — control-byte refusal
226
+ if (c === 0x0A && caps.allowBareLf) continue;
227
+ throw new GuardImapCommandError("guard-imap-command/bad-byte",
228
+ "guardImapCommand.validate: control byte 0x" + c.toString(16) + " at offset " + i); // allow:raw-byte-literal — hex format literal in error message
229
+ }
230
+ }
231
+
232
+ // RFC 9051 §2.2.1 — `tag SP command [SP args] CRLF`
233
+ var firstSpace = line.indexOf(" ");
234
+ if (firstSpace === -1) {
235
+ throw new GuardImapCommandError("guard-imap-command/missing-verb",
236
+ "guardImapCommand.validate: command line missing verb (no SP after tag)");
237
+ }
238
+ var tag = line.slice(0, firstSpace);
239
+ if (!TAG_RE.test(tag)) { // allow:regex-no-length-cap — TAG_RE anchored + bounded-repeat
240
+ throw new GuardImapCommandError("guard-imap-command/bad-tag",
241
+ "guardImapCommand.validate: bad tag '" + tag + "' (RFC 9051 §9 atom)");
242
+ }
243
+ var rest = line.slice(firstSpace + 1);
244
+ var verbSpace = rest.indexOf(" ");
245
+ var verb = (verbSpace === -1 ? rest : rest.slice(0, verbSpace)).toUpperCase();
246
+ var args = verbSpace === -1 ? "" : rest.slice(verbSpace + 1);
247
+
248
+ if (!KNOWN_VERBS[verb]) {
249
+ throw new GuardImapCommandError("guard-imap-command/unknown-verb",
250
+ "guardImapCommand.validate: unknown verb '" + verb + "'");
251
+ }
252
+ if (ZERO_ARG_VERBS[verb] && args.length > 0) {
253
+ throw new GuardImapCommandError("guard-imap-command/unexpected-args",
254
+ "guardImapCommand.validate: verb '" + verb + "' takes no arguments");
255
+ }
256
+
257
+ // Literal-opener detection — `{n}` at end of line.
258
+ var literalSize = null;
259
+ var literalNonSync = false;
260
+ var litMatch = args.match(LITERAL_OPEN_RE);
261
+ if (litMatch) {
262
+ var sz = parseInt(litMatch[1], 10);
263
+ if (!isFinite(sz) || sz < 0 || sz > caps.maxLiteralBytes) {
264
+ throw new GuardImapCommandError("guard-imap-command/literal-too-large",
265
+ "guardImapCommand.validate: literal size " + sz + " exceeds cap " + caps.maxLiteralBytes);
266
+ }
267
+ literalSize = sz;
268
+ literalNonSync = litMatch[2] === "+";
269
+ if (literalNonSync && !caps.allowLiteralPlus) {
270
+ throw new GuardImapCommandError("guard-imap-command/literal-plus-refused",
271
+ "guardImapCommand.validate: LITERAL+ (RFC 7888) refused under profile '" + profileName + "'");
272
+ }
273
+ if (literalNonSync && opts.authenticated === false) {
274
+ // RFC 7888 §1: LITERAL+ MAY be used by clients but servers MAY
275
+ // refuse it pre-AUTH. We refuse pre-AUTH to bound resource use
276
+ // before authentication.
277
+ throw new GuardImapCommandError("guard-imap-command/literal-plus-pre-auth",
278
+ "guardImapCommand.validate: LITERAL+ refused pre-authentication");
279
+ }
280
+ }
281
+
282
+ // Mid-line literal opener is smuggling-shaped.
283
+ if (detectLiteralSmuggling(line)) {
284
+ throw new GuardImapCommandError("guard-imap-command/literal-smuggling",
285
+ "guardImapCommand.validate: literal opener `{n}` MUST appear at end of line (RFC 9051 §2.2.2)");
286
+ }
287
+
288
+ return { tag: tag, verb: verb, args: args, literalSize: literalSize, literalNonSync: literalNonSync };
289
+ }
290
+
291
+ /**
292
+ * @primitive b.guardImapCommand.detectLiteralSmuggling
293
+ * @signature b.guardImapCommand.detectLiteralSmuggling(line)
294
+ * @since 0.9.49
295
+ * @status stable
296
+ *
297
+ * Return `true` when the input line contains a literal opener
298
+ * `{n}` or `{n+}` that is NOT at the end of the line — the
299
+ * smuggling-shape per RFC 9051 §2.2.2.
300
+ *
301
+ * @example
302
+ * b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10} hostile"); // → true
303
+ * b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10}"); // → false (well-formed)
304
+ */
305
+ function detectLiteralSmuggling(line) {
306
+ if (typeof line !== "string") return false;
307
+ return LITERAL_SMUGGLE_RE.test(line); // allow:regex-no-length-cap — caller's input is already length-capped upstream by the listener's per-line cap
308
+ }
309
+
310
+ /**
311
+ * @primitive b.guardImapCommand.compliancePosture
312
+ * @signature b.guardImapCommand.compliancePosture(posture)
313
+ * @since 0.9.49
314
+ * @status stable
315
+ *
316
+ * Return the effective profile for a compliance posture, or `null`
317
+ * for unknown names.
318
+ *
319
+ * @example
320
+ * b.guardImapCommand.compliancePosture("hipaa"); // → "strict"
321
+ */
322
+ function compliancePosture(posture) {
323
+ return COMPLIANCE_POSTURES[posture] || null;
324
+ }
325
+
326
+ module.exports = {
327
+ validate: validate,
328
+ detectLiteralSmuggling: detectLiteralSmuggling,
329
+ compliancePosture: compliancePosture,
330
+ PROFILES: PROFILES,
331
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
332
+ KNOWN_VERBS: KNOWN_VERBS,
333
+ ZERO_ARG_VERBS: ZERO_ARG_VERBS,
334
+ GuardImapCommandError: GuardImapCommandError,
335
+ };
@@ -215,15 +215,27 @@ function validate(line, opts) {
215
215
  "guardSmtpCommand.validate: verb '" + verb + "' takes no arguments");
216
216
  }
217
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: {} };
218
+ // Verb→parser dispatch via switch the switch arms are not a
219
+ // dynamic call: each `case` invokes a statically-resolved function
220
+ // by name, so CodeQL's js/unvalidated-dynamic-method-call tracker
221
+ // sees a fixed call graph rather than user-controlled dispatch.
222
+ // (KNOWN_VERBS gates `verb` upstream to the closed set below; the
223
+ // KNOWN_VERBS check itself is a property read on a frozen
224
+ // Object.create(null)-equivalent table, which CodeQL accepts as
225
+ // boolean data access.)
226
+ switch (verb) {
227
+ case "EHLO":
228
+ case "HELO": return _validateGreeting(verb, rest, caps);
229
+ case "MAIL": return _validatePath(verb, rest, caps, "FROM:");
230
+ case "RCPT": return _validatePath(verb, rest, caps, "TO:");
231
+ case "BDAT": return _validateBdat(rest);
232
+ case "VRFY":
233
+ case "EXPN": return _validateMailbox(verb, rest, caps);
234
+ case "AUTH": return _parseAuthCommandSyntax(rest);
235
+ case "NOOP":
236
+ case "HELP": return { verb: verb, args: rest ? [rest] : [], params: {} };
237
+ default: return { verb: verb, args: [], params: {} };
238
+ }
227
239
  }
228
240
 
229
241
  /**
@@ -377,7 +389,7 @@ function _validateMailbox(verb, rest, caps) {
377
389
  return { verb: verb, args: [rest], params: {} };
378
390
  }
379
391
 
380
- function _validateAuth(rest) {
392
+ function _parseAuthCommandSyntax(rest) {
381
393
  // RFC 4954: `AUTH <SASL-mech> [<initial-response>]`
382
394
  var parts = rest.split(/\s+/).filter(Boolean);
383
395
  if (parts.length === 0 || parts.length > 2) {
@@ -461,8 +473,51 @@ function gate(opts) {
461
473
  });
462
474
  }
463
475
 
476
+ /**
477
+ * @primitive b.guardSmtpCommand.detectBodySmuggling
478
+ * @signature b.guardSmtpCommand.detectBodySmuggling(buf)
479
+ * @since 0.9.46
480
+ * @status stable
481
+ * @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
482
+ *
483
+ * Scan a DATA-body byte buffer for the SMTP smuggling shape per
484
+ * CVE-2023-51764 (Postfix), CVE-2023-51765 (Sendmail), CVE-2023-51766
485
+ * (Exim), CVE-2024-32178 (.NET System.Net.Mail). RFC 5321 §2.3.8
486
+ * mandates canonical CRLF line termination; the smuggling exploit
487
+ * relies on parsers that accept `\n.\n` (bare LF before / after the
488
+ * dot) as an alternate body terminator and then resume parsing the
489
+ * NEXT bytes as a new SMTP transaction.
490
+ *
491
+ * Returns `true` if the buffer contains a bare-LF dot-line (a `\n`
492
+ * NOT preceded by `\r`, immediately followed by `.\n`), `false`
493
+ * otherwise. Operators wiring an MX / submission listener call this
494
+ * on every DATA chunk + refuse the whole transaction on `true` per
495
+ * the framework's strict-CRLF posture.
496
+ *
497
+ * @example
498
+ * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\r\n.\r\n"));
499
+ * // → false
500
+ *
501
+ * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
502
+ * // → true (bare-LF dot-line — CVE-2023-51764 shape)
503
+ */
504
+ function detectBodySmuggling(buf) {
505
+ if (!Buffer.isBuffer(buf)) {
506
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
507
+ "detectBodySmuggling: input must be a Buffer");
508
+ }
509
+ for (var i = 1; i < buf.length - 2; i += 1) {
510
+ if (buf[i] === 0x0a /* LF */ && buf[i - 1] !== 0x0d /* CR */ &&
511
+ buf[i + 1] === 0x2e /* . */ && buf[i + 2] === 0x0a /* LF */) {
512
+ return true;
513
+ }
514
+ }
515
+ return false;
516
+ }
517
+
464
518
  module.exports = {
465
519
  validate: validate,
520
+ detectBodySmuggling: detectBodySmuggling,
466
521
  gate: gate,
467
522
  compliancePosture: compliancePosture,
468
523
  PROFILES: PROFILES,