@blamejs/core 0.13.11 → 0.13.13
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 +4 -0
- package/README.md +1 -1
- package/lib/guard-filename.js +90 -5
- package/lib/mail-server-mx.js +138 -43
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.13 (2026-05-27) — **Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment.** b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow") for Linux-only targets. **Security:** *`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)* — Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `"allow"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate).
|
|
12
|
+
|
|
13
|
+
- v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
|
|
14
|
+
|
|
11
15
|
- v0.13.11 (2026-05-27) — **Test-suite reliability: replaced fixed-delay waits in the rate-limiter and scheduler suites with condition polling.** No runtime behaviour changes. The rate-limiter, scheduler, and websocket-channel test suites waited for asynchronous work to settle by draining a fixed number of event-loop ticks before asserting. Under heavily parallel CI that budget was occasionally too short, so an assertion read state before the async work (a cluster-backend counter update, a scheduler tick-claim) had landed — an intermittent failure unrelated to the code under test. Those waits now poll the observable condition (helpers.waitUntil) and exit as soon as it holds, with a generous upper bound, so they pass quickly on fast machines and reliably under load. A build gate is added so the fixed-tick-drain shape cannot be reintroduced. **Fixed:** *Flaky fixed-budget waits in the rate-limiter / scheduler / sandbox test suites made contention-tolerant* — The rate-limit-cluster and scheduler-exactly-once suites drained a fixed count of event-loop ticks before asserting on asynchronously-updated state; under contended CI the budget could expire before the work settled, producing intermittent failures. They now wait on the actual observable condition (a written response, a settled counter). The sandbox suite's success-path cases gave the worker a 5 s execution budget that cold worker-thread startup under heavily parallel Windows CI could just exceed; those are raised to the framework's 10 s ceiling. Affects test code only — no change to shipped framework behaviour. The unused tick-drain helper in the websocket-channel suite was removed. **Detectors:** *Build gate rejects the fixed-tick-drain wait shape in tests* — A new test-suite lint rule flags the counted microtask/tick-drain idiom (reassigning a promise to its own `.then()` in a loop to wait a fixed number of ticks), the sibling of the existing fixed-`setTimeout`-sleep rule. A single event-loop yield is unaffected; only the drain-as-wait shape is rejected, directing the wait to condition polling instead.
|
|
12
16
|
|
|
13
17
|
- v0.13.10 (2026-05-27) — **Documented-but-inert options wired up, a non-existent CVE reference removed, and a silent iCalendar cap-bypass fixed.** A sweep for places where a documented option or citation did not match what the code does. The most operator-relevant fix: b.calendar.fromIcal documented a safeIcalOpts option that forwards parser caps (byte size, RRULE limits, nesting depth) to b.safeIcal.parse, but the value was never forwarded — so an operator who set tight caps through it got the default profile instead, silently. That is corrected; the nested options now reach the parser. b.archive.read.zip documented an AbortSignal option that was never honored; it now aborts the read at the entry boundary. b.auth.fal documented a bearerOnly alias that had no effect; it now forces the no-proof-of-possession path and refuses the contradictory combination of bearerOnly:true with a holder-of-key binding. Separately, the auth verification paths cited CVE-2026-23993 (13 places) for the "reject an unknown alg before key lookup" guard — that CVE id does not exist (the registry has no record of it); the citation is replaced with the weakness class (CWE-347 / CWE-757) and the real, verifiable neighboring CVEs. The circuit-breaker error-code note that promised a rename "in v0.10" is corrected to the actual plan (v1.0), and the build gate that catches overdue version promises now also catches two-part version numbers. **Changed:** *`b.auth.fal` `bearerOnly` is now a real alias and refuses contradictions* — `bearerOnly: true` now forces the no-proof-of-possession path (equivalent to `hokBinding: null`), as documented. Passing `bearerOnly: true` together with a non-null `hokBinding` is a contradictory assurance request and is now refused at the call rather than silently resolved one way. **Fixed:** *`b.calendar.fromIcal` now forwards `safeIcalOpts` to the parser* — The documented `safeIcalOpts` option (parser caps: max bytes, RRULE COUNT/BYxxx limits, nesting depth) was not being passed to `b.safeIcal.parse` — when supplied under the documented nested key it was silently ignored and the parser ran with its default profile. Both forms now reach the parser: the documented nested `{ safeIcalOpts: { ... } }` and the top-level `{ profile, ... }` that earlier releases accepted, with the nested form winning on conflict. No caller regresses. · *`b.archive.read.zip` honors the documented `signal` (AbortSignal)* — The `signal` option was documented but never read. A large or slow archive read can now be aborted cooperatively — the reader checks the signal at each entry boundary (`inspect`, `entries`, `extractEntries`, `extract`) and rejects with an `archive-read/aborted` error. · *Removed a non-existent CVE reference from the JWT/JWE verification paths* — The "reject an unknown/unsupported `alg` before any key lookup" guard in `b.auth.jwt.verifyExternal`, `b.auth.oauth.verifyIdToken`, `b.auth.oid4vci`, and `b.auth.sd-jwt-vc` cited a CVE id that the registry has no record of. The behaviour is unchanged; the citation is now the weakness class it defends (CWE-347 improper signature verification / CWE-757 algorithm downgrade) alongside the real, verifiable alg-confusion / JWE-bypass CVEs already cited beside it. **Detectors:** *Overdue-version-promise gate now catches two-part version numbers* — The build gate that flags a deferral whose promised landing version has already shipped previously matched only three-part versions (`vN.N.N`); a two-part promise (`vN.N`) slipped past it. It now matches both. The `b.circuitBreaker` `CIRCUIT_OPEN` error-code note that pointed at a passed version is corrected to its actual plan (rename at v1.0, with a deprecation warning a minor ahead).
|
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
169
169
|
- **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
|
|
170
170
|
- **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
|
|
171
171
|
- **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
|
|
172
|
-
- **Mail server listeners** — RFC 5321 MX inbound (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
172
|
+
- **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
|
|
173
173
|
- **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
|
|
174
174
|
- **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
|
|
175
175
|
- **Mail-stack agent** — multi-threaded worker pool + queue dispatch + sealed mail-store backed by SQLite FTS5 (`b.mail.agent`, `b.mailStore`)
|
package/lib/guard-filename.js
CHANGED
|
@@ -91,6 +91,20 @@ var WIN_RESERVED_NAMES = Object.freeze([
|
|
|
91
91
|
"CLOCK$", "CONFIG$",
|
|
92
92
|
]);
|
|
93
93
|
|
|
94
|
+
// Windows folds the superscript digits U+00B9 / U+00B2 / U+00B3 to
|
|
95
|
+
// 1 / 2 / 3 when matching COM/LPT device names, so a superscript-digit
|
|
96
|
+
// form resolves to the same device. Built from numeric codepoints so
|
|
97
|
+
// the source stays pure-ASCII (guard-family rule).
|
|
98
|
+
var _SUPERSCRIPT_DIGIT_MAP = (function () {
|
|
99
|
+
var m = {};
|
|
100
|
+
m[String.fromCharCode(0xB9)] = "1";
|
|
101
|
+
m[String.fromCharCode(0xB2)] = "2";
|
|
102
|
+
m[String.fromCharCode(0xB3)] = "3";
|
|
103
|
+
return m;
|
|
104
|
+
})();
|
|
105
|
+
var _SUPERSCRIPT_DIGIT_RE = new RegExp("[" + String.fromCharCode(0xB9, 0xB2, 0xB3) + "]", "g"); // allow:dynamic-regex — superscript-digit codepoints from a numeric table
|
|
106
|
+
|
|
107
|
+
|
|
94
108
|
// Path-traversal indicators (anchored matches on raw and percent-decoded
|
|
95
109
|
// forms).
|
|
96
110
|
var PATH_TRAVERSAL_RE = /(^|[/\\])\.\.($|[/\\])/;
|
|
@@ -243,7 +257,16 @@ function _normalizeNFC(s) {
|
|
|
243
257
|
function _isWinReserved(name) {
|
|
244
258
|
// Reserved-name check applies to the base (without extension) AND to
|
|
245
259
|
// the entire leaf — both `CON` and `CON.txt` collide with the device.
|
|
246
|
-
|
|
260
|
+
// Windows normalizes the superscript digits U+00B9 / U+00B2 / U+00B3
|
|
261
|
+
// to 1 / 2 / 3 when matching COM/LPT device names, so those superscript
|
|
262
|
+
// forms resolve to the same devices as COM1 / LPT3; fold them to ASCII
|
|
263
|
+
// before comparison so the spoofed forms are caught too. (Source stays
|
|
264
|
+
// pure-ASCII per the guard-family rule — the codepoints are escaped.)
|
|
265
|
+
// Fold COM/LPT superscript-digit spoofs to ASCII before matching
|
|
266
|
+
// (Windows treats them as the device). See _SUPERSCRIPT_DIGIT_* below.
|
|
267
|
+
var upper = name.toUpperCase().replace(_SUPERSCRIPT_DIGIT_RE, function (ch) {
|
|
268
|
+
return _SUPERSCRIPT_DIGIT_MAP[ch] || ch;
|
|
269
|
+
});
|
|
247
270
|
for (var i = 0; i < WIN_RESERVED_NAMES.length; i += 1) {
|
|
248
271
|
var r = WIN_RESERVED_NAMES[i];
|
|
249
272
|
if (upper === r) return true;
|
|
@@ -952,6 +975,29 @@ var PATH_MAX_BYTES = 4096;
|
|
|
952
975
|
* resolved absolute path on success; throws `GuardFilenameError` on
|
|
953
976
|
* any refusal.
|
|
954
977
|
*
|
|
978
|
+
* Per-segment Windows-extraction hazards are refused too — these are
|
|
979
|
+
* within-root write-target redirections / collisions that the
|
|
980
|
+
* containment + realpath checks structurally cannot see, so they need
|
|
981
|
+
* a name-level check the disk `validate` / `sanitize` paths already
|
|
982
|
+
* carry: a Windows reserved device name (`CON` / `NUL` / `COM1` / …,
|
|
983
|
+
* which resolves to the device), NTFS alternate-data-stream syntax
|
|
984
|
+
* (`name:stream`, which writes a hidden stream of the base file), and a
|
|
985
|
+
* trailing dot / leading-or-trailing whitespace (`secret.txt.`, which
|
|
986
|
+
* Windows strips so the entry overwrites an existing sibling). The
|
|
987
|
+
* checks are platform-unconditional — the verifier may run on Linux
|
|
988
|
+
* while extraction happens on Windows — and each has an opt-out for
|
|
989
|
+
* Linux-only targets (`reservedNamePolicy` / `adsPolicy` /
|
|
990
|
+
* `leadingTrailingPolicy: "allow"`), mirroring `validate`.
|
|
991
|
+
*
|
|
992
|
+
* Out of this primitive's scope (single-entry, name-only): 8.3 short-name
|
|
993
|
+
* aliasing (`PROGRA~1`), case-insensitive cross-entry collision
|
|
994
|
+
* (`Readme.txt` vs `README.TXT` on a case-preserving FS), and archive
|
|
995
|
+
* symlink/hardlink ENTRY-target validation. The first two are cross-entry
|
|
996
|
+
* properties and the third needs the entry's declared link target, which
|
|
997
|
+
* this function never sees — they belong to the extract orchestrator
|
|
998
|
+
* (`b.archive.read.zip.extract` / `b.safeArchive`), which owns the
|
|
999
|
+
* case-folded seen-set and the link-target gate.
|
|
1000
|
+
*
|
|
955
1001
|
* Companion to `b.guardArchive.checkExtractionPath` (the string-only
|
|
956
1002
|
* portable gate the guard-archive primitive keeps fs-free for use as
|
|
957
1003
|
* a posture cascade member). `verifyExtractionPath` deliberately
|
|
@@ -964,8 +1010,13 @@ var PATH_MAX_BYTES = 4096;
|
|
|
964
1010
|
* Operators rolling their own extract loop call it per entry.
|
|
965
1011
|
*
|
|
966
1012
|
* @opts
|
|
967
|
-
* followSymlinks:
|
|
1013
|
+
* followSymlinks: boolean, // default false — symlink in the
|
|
968
1014
|
* // resolved path refuses unless set
|
|
1015
|
+
* reservedNamePolicy: string, // "allow" opts out of the Windows
|
|
1016
|
+
* // reserved-device-name segment check
|
|
1017
|
+
* adsPolicy: string, // "allow" opts out of the NTFS-ADS check
|
|
1018
|
+
* leadingTrailingPolicy: string, // "allow" opts out of the trailing-dot /
|
|
1019
|
+
* // leading-or-trailing-whitespace check
|
|
969
1020
|
*
|
|
970
1021
|
* @example
|
|
971
1022
|
* var resolved = b.guardFilename.verifyExtractionPath(
|
|
@@ -1023,19 +1074,53 @@ function verifyExtractionPath(entryName, extractionRoot, opts) {
|
|
|
1023
1074
|
throw new GuardFilenameError("filename.extraction-unc",
|
|
1024
1075
|
"verifyExtractionPath: entryName starts with a UNC prefix");
|
|
1025
1076
|
}
|
|
1026
|
-
// `..` segment refuses — walk path components.
|
|
1077
|
+
// `..` segment refuses — walk path components. The same walk also
|
|
1078
|
+
// refuses per-segment Windows-extraction hazards the disk `validate`
|
|
1079
|
+
// / `sanitize` paths already catch but that string-containment +
|
|
1080
|
+
// realpath agreement cannot see, because they're WITHIN-root
|
|
1081
|
+
// collisions / write-target redirections rather than boundary
|
|
1082
|
+
// escapes. These checks are platform-UNCONDITIONAL: the verifier may
|
|
1083
|
+
// run on Linux while the archive is extracted on Windows, so a name
|
|
1084
|
+
// that's only dangerous on Windows must still be refused here.
|
|
1085
|
+
// Operators on a Linux-only target opt out per check, mirroring
|
|
1086
|
+
// `validate`'s policy vocabulary.
|
|
1027
1087
|
var segs = normalized.split("/");
|
|
1028
1088
|
for (var si = 0; si < segs.length; si += 1) {
|
|
1029
|
-
|
|
1089
|
+
var seg = segs[si];
|
|
1090
|
+
if (seg === ".." || seg === "..\\" || seg === "..%2f" || seg === "..%5c") {
|
|
1030
1091
|
throw new GuardFilenameError("filename.extraction-traversal",
|
|
1031
1092
|
"verifyExtractionPath: entryName contains .. segment");
|
|
1032
1093
|
}
|
|
1033
1094
|
// URL-encoded variants — explicit refusal so operators don't
|
|
1034
1095
|
// need to percent-decode before passing the entry name in.
|
|
1035
|
-
if (/%2e%2e/i.test(
|
|
1096
|
+
if (/%2e%2e/i.test(seg) || /%c0%ae/i.test(seg)) {
|
|
1036
1097
|
throw new GuardFilenameError("filename.extraction-traversal-encoded",
|
|
1037
1098
|
"verifyExtractionPath: entryName contains encoded .. segment");
|
|
1038
1099
|
}
|
|
1100
|
+
if (seg === "" || seg === ".") continue; // separators / current-dir — nothing to name-check
|
|
1101
|
+
// Windows reserved device name (CON / NUL / COM1 / LPT1 / …): on
|
|
1102
|
+
// Windows the segment resolves to the device, redirecting the write.
|
|
1103
|
+
if (opts.reservedNamePolicy !== "allow" && _isWinReserved(seg)) {
|
|
1104
|
+
throw new GuardFilenameError("filename.extraction-reserved-name",
|
|
1105
|
+
"verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
|
|
1106
|
+
" collides with a Windows reserved device name");
|
|
1107
|
+
}
|
|
1108
|
+
// NTFS alternate data stream (name:stream): on Windows the write
|
|
1109
|
+
// lands on a hidden stream of the base file, not a normal file.
|
|
1110
|
+
if (opts.adsPolicy !== "allow" && /:[^:\\/]+$/.test(seg)) {
|
|
1111
|
+
throw new GuardFilenameError("filename.extraction-ntfs-ads",
|
|
1112
|
+
"verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
|
|
1113
|
+
" uses NTFS alternate-data-stream syntax (name:stream)");
|
|
1114
|
+
}
|
|
1115
|
+
// Trailing dot / leading-or-trailing whitespace: Windows silently
|
|
1116
|
+
// strips these, so `secret.txt.` or `secret.txt ` collides with an
|
|
1117
|
+
// existing sibling — an in-root overwrite the containment check
|
|
1118
|
+
// cannot see.
|
|
1119
|
+
if (opts.leadingTrailingPolicy !== "allow" && /^\s|\s$|\.$/.test(seg)) {
|
|
1120
|
+
throw new GuardFilenameError("filename.extraction-leading-trailing",
|
|
1121
|
+
"verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
|
|
1122
|
+
" has leading/trailing whitespace or a trailing dot (Windows strips it)");
|
|
1123
|
+
}
|
|
1039
1124
|
}
|
|
1040
1125
|
// Resolve the destination path against the root via path.resolve
|
|
1041
1126
|
// (string-level computation; no fs hits).
|
package/lib/mail-server-mx.js
CHANGED
|
@@ -67,9 +67,11 @@
|
|
|
67
67
|
*
|
|
68
68
|
* - `mail.server.mx.connect` — IP, TLS state, FCrDNS hostname
|
|
69
69
|
* - `mail.server.mx.helo` — HELO greeting, helo-gate verdict
|
|
70
|
-
* - `mail.server.mx.
|
|
71
|
-
* - `mail.server.mx.
|
|
72
|
-
* - `mail.server.mx.
|
|
70
|
+
* - `mail.server.mx.helo_gate_refused` — HELO identity refused (gate action)
|
|
71
|
+
* - `mail.server.mx.mail_from` — sender address
|
|
72
|
+
* - `mail.server.mx.rcpt_to` — recipient, rblListed flag, greylist action
|
|
73
|
+
* - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
|
|
74
|
+
* - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
|
|
73
75
|
* - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
|
|
74
76
|
* - `mail.server.mx.delivered` — agent.handoff ack
|
|
75
77
|
* - `mail.server.mx.tls_handshake_failed` — handshake error
|
|
@@ -100,19 +102,30 @@
|
|
|
100
102
|
*
|
|
101
103
|
* Every gate is a primitive that already exists. The MX slice is a
|
|
102
104
|
* state-machine + wire-protocol coordinator — no new crypto, no
|
|
103
|
-
* new parsing, no new RFC-layer primitives.
|
|
104
|
-
* (e.g.
|
|
105
|
-
* skips that phase
|
|
106
|
-
*
|
|
105
|
+
* new parsing, no new RFC-layer primitives. When the operator
|
|
106
|
+
* doesn't wire a gate (e.g. omits `opts.greylist`), the listener
|
|
107
|
+
* skips that phase rather than synthesizing a verdict.
|
|
108
|
+
*
|
|
109
|
+
* Connection-level gates are wired into the live state machine:
|
|
110
|
+
* `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
|
|
111
|
+
* (connecting-IP DNS blocklist, evaluated once per connection) and
|
|
112
|
+
* `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
|
|
113
|
+
* RCPT TO and surface their verdicts on the `rcpt_to` event. The
|
|
114
|
+
* message-authentication gates (`b.guardEnvelope` SPF/DKIM/DMARC
|
|
115
|
+
* alignment) require the inbound SPF + DKIM verification results as
|
|
116
|
+
* inputs; that inbound-auth pipeline composes `b.mail.spf` +
|
|
117
|
+
* `b.mail.dmarc` + DKIM verification and lands as a follow-up, at
|
|
118
|
+
* which point the DATA-phase envelope/DMARC gate wires in. Until
|
|
119
|
+
* then operators run those checks on the delivered message via the
|
|
120
|
+
* agent handoff.
|
|
107
121
|
*
|
|
108
122
|
* @card
|
|
109
123
|
* Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
|
|
110
124
|
* smuggling defense baked into the wire-protocol layer (RFC 5321
|
|
111
125
|
* §2.3.8 + CVE-2023-51764 / 51765 / 51766), open-relay refusal by
|
|
112
126
|
* default, STARTTLS-stripping defense (CVE-2021-38371), and the
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* appropriate phase.
|
|
127
|
+
* connection-level gate cascade (HELO identity / RBL / greylist)
|
|
128
|
+
* running at the appropriate phase.
|
|
116
129
|
*/
|
|
117
130
|
|
|
118
131
|
var net = require("node:net");
|
|
@@ -149,6 +162,7 @@ var REPLY_221_BYE = "221";
|
|
|
149
162
|
var REPLY_250_OK = "250";
|
|
150
163
|
var REPLY_354_START_INPUT = "354";
|
|
151
164
|
var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
|
|
165
|
+
var REPLY_450_MAILBOX_BUSY = "450"; // allow:raw-byte-literal — SMTP transient code (greylist tempfail)
|
|
152
166
|
var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
|
|
153
167
|
var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
|
|
154
168
|
var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
|
|
@@ -177,11 +191,9 @@ var RE_SIZE = /SIZE=(\d+)/i;
|
|
|
177
191
|
* @opts
|
|
178
192
|
* tlsContext: TlsContext, // required — b.network.tls.context() output (no implicit plaintext)
|
|
179
193
|
* greeting: string, // default "blamejs ESMTP" — HELO/EHLO 220-line banner
|
|
180
|
-
* helo: b.mail.helo,
|
|
181
|
-
* rbl: b.mail.rbl,
|
|
182
|
-
* greylist: b.mail.greylist, // optional gate
|
|
183
|
-
* envelope: b.guardEnvelope, // optional gate (SPF/DKIM alignment)
|
|
184
|
-
* dmarc: b.mail.auth.dmarc, // optional gate
|
|
194
|
+
* helo: b.mail.helo, // optional gate — HELO identity (FCrDNS / shape / self-name)
|
|
195
|
+
* rbl: b.mail.rbl.create(…), // optional gate — DNS blocklist on the connecting IP
|
|
196
|
+
* greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
|
|
185
197
|
* agent: b.mail.agent, // optional delivery handoff
|
|
186
198
|
* relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
|
|
187
199
|
* localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
|
|
@@ -199,7 +211,6 @@ var RE_SIZE = /SIZE=(\d+)/i;
|
|
|
199
211
|
* helo: b.mail.helo,
|
|
200
212
|
* rbl: b.mail.rbl.create({ providers: ["zen.spamhaus.org"] }),
|
|
201
213
|
* greylist: b.mail.greylist.create({ store: greylistStore }),
|
|
202
|
-
* envelope: b.guardEnvelope,
|
|
203
214
|
* agent: b.mail.agent.create({ store: mailStore }),
|
|
204
215
|
* localDomains: ["example.com"],
|
|
205
216
|
* });
|
|
@@ -367,6 +378,15 @@ function create(opts) {
|
|
|
367
378
|
var lineBuffer = Buffer.alloc(0);
|
|
368
379
|
var bodyCollector = null;
|
|
369
380
|
var inDataBody = false;
|
|
381
|
+
// Async command pump: gates (HELO / RBL / greylist / envelope /
|
|
382
|
+
// DMARC) may await DNS or a store, so command handling is async.
|
|
383
|
+
// `pumpChain` FIFO-serializes per-chunk processing so a gate
|
|
384
|
+
// resolving cannot let a later pipelined command (RFC 2920) jump
|
|
385
|
+
// ahead of an earlier one — reply ordering + the per-command
|
|
386
|
+
// smuggling defenses stay intact. `connClosed` short-circuits any
|
|
387
|
+
// chunk queued before a teardown.
|
|
388
|
+
var pumpChain = Promise.resolve();
|
|
389
|
+
var connClosed = false;
|
|
370
390
|
|
|
371
391
|
socket.setTimeout(idleTimeoutMs);
|
|
372
392
|
socket.on("timeout", function () {
|
|
@@ -382,6 +402,7 @@ function create(opts) {
|
|
|
382
402
|
});
|
|
383
403
|
|
|
384
404
|
socket.on("close", function () {
|
|
405
|
+
connClosed = true;
|
|
385
406
|
connections.delete(socket);
|
|
386
407
|
});
|
|
387
408
|
|
|
@@ -395,20 +416,35 @@ function create(opts) {
|
|
|
395
416
|
// 220 banner — RFC 5321 §3.1.
|
|
396
417
|
_writeReply(socket, REPLY_220_READY, greeting + " ready");
|
|
397
418
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
419
|
+
// Feed a chunk into the per-connection command pump. Chains each
|
|
420
|
+
// chunk behind the previous one's full (async) processing so command
|
|
421
|
+
// handlers + their gates run strictly in arrival order. Used by BOTH
|
|
422
|
+
// the plaintext `socket.on("data")` path AND the post-STARTTLS
|
|
423
|
+
// TLSSocket onData path — otherwise gate awaits on the upgraded
|
|
424
|
+
// socket would overlap later TLS chunks (the default strict/balanced
|
|
425
|
+
// profiles require STARTTLS before MAIL, so the gates run there) and
|
|
426
|
+
// async gate rejections would go unhandled instead of producing the
|
|
427
|
+
// 421 path. `activeSock` is whichever socket is current (plaintext or
|
|
428
|
+
// TLS) so the 421/close lands on the right transport.
|
|
429
|
+
function _feedChunk(activeSock, chunk) {
|
|
430
|
+
pumpChain = pumpChain.then(function () {
|
|
431
|
+
if (connClosed) return undefined;
|
|
432
|
+
return _ingestBytes(state, activeSock, chunk);
|
|
433
|
+
}).catch(function (err) {
|
|
434
|
+
if (connClosed) return;
|
|
401
435
|
_emit("mail.server.mx.handler_threw",
|
|
402
436
|
{ connectionId: state.id, error: (err && err.message) || String(err) },
|
|
403
437
|
"failure");
|
|
404
|
-
try { _writeReply(
|
|
438
|
+
try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
|
|
405
439
|
catch (_e) { /* socket already gone */ }
|
|
406
|
-
_closeConnection(
|
|
407
|
-
}
|
|
408
|
-
}
|
|
440
|
+
_closeConnection(activeSock);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
socket.on("data", function (chunk) { _feedChunk(socket, chunk); });
|
|
409
445
|
|
|
410
446
|
// ---- Byte-level ingestion --------------------------------------------
|
|
411
|
-
function _ingestBytes(state, socket, chunk) {
|
|
447
|
+
async function _ingestBytes(state, socket, chunk) {
|
|
412
448
|
if (inDataBody) {
|
|
413
449
|
// DATA body — accumulate via boundedChunkCollector, watch for
|
|
414
450
|
// canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
|
|
@@ -444,9 +480,9 @@ function create(opts) {
|
|
|
444
480
|
var endIdx = safeSmtp.findDotTerminator(collected);
|
|
445
481
|
if (endIdx !== -1) {
|
|
446
482
|
var body = collected.subarray(0, endIdx);
|
|
447
|
-
_finalizeDataBody(state, socket, body);
|
|
448
483
|
inDataBody = false;
|
|
449
484
|
bodyCollector = null;
|
|
485
|
+
await _finalizeDataBody(state, socket, body);
|
|
450
486
|
}
|
|
451
487
|
return;
|
|
452
488
|
}
|
|
@@ -464,12 +500,13 @@ function create(opts) {
|
|
|
464
500
|
while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
|
|
465
501
|
var line = lineBuffer.subarray(0, crlf).toString("utf8");
|
|
466
502
|
lineBuffer = lineBuffer.subarray(crlf + 2);
|
|
467
|
-
_handleCommand(state, socket, line);
|
|
503
|
+
await _handleCommand(state, socket, line);
|
|
468
504
|
if (inDataBody) return;
|
|
505
|
+
if (connClosed) return;
|
|
469
506
|
}
|
|
470
507
|
}
|
|
471
508
|
|
|
472
|
-
function _handleCommand(state, socket, line) {
|
|
509
|
+
async function _handleCommand(state, socket, line) {
|
|
473
510
|
// Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
|
|
474
511
|
// BEFORE state-machine dispatch.
|
|
475
512
|
try {
|
|
@@ -494,16 +531,16 @@ function create(opts) {
|
|
|
494
531
|
switch (verb) {
|
|
495
532
|
case "EHLO":
|
|
496
533
|
case "HELO":
|
|
497
|
-
_handleEhlo(state, socket, line, verb);
|
|
534
|
+
await _handleEhlo(state, socket, line, verb);
|
|
498
535
|
return;
|
|
499
536
|
case "STARTTLS":
|
|
500
537
|
_handleStartTls(state, socket);
|
|
501
538
|
return;
|
|
502
539
|
case "MAIL":
|
|
503
|
-
_handleMailFrom(state, socket, line);
|
|
540
|
+
await _handleMailFrom(state, socket, line);
|
|
504
541
|
return;
|
|
505
542
|
case "RCPT":
|
|
506
|
-
_handleRcptTo(state, socket, line);
|
|
543
|
+
await _handleRcptTo(state, socket, line);
|
|
507
544
|
return;
|
|
508
545
|
case "DATA":
|
|
509
546
|
_handleData(state, socket);
|
|
@@ -531,7 +568,7 @@ function create(opts) {
|
|
|
531
568
|
}
|
|
532
569
|
|
|
533
570
|
// ---- EHLO / HELO ------------------------------------------------------
|
|
534
|
-
function _handleEhlo(state, socket, line, verb) {
|
|
571
|
+
async function _handleEhlo(state, socket, line, verb) {
|
|
535
572
|
var helo = line.slice(verb.length).trim();
|
|
536
573
|
if (!helo) {
|
|
537
574
|
_writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
|
|
@@ -552,6 +589,25 @@ function create(opts) {
|
|
|
552
589
|
return;
|
|
553
590
|
}
|
|
554
591
|
}
|
|
592
|
+
// Operator HELO-identity gate (b.mail.helo) — FCrDNS / HELO-shape /
|
|
593
|
+
// self-name spoofing checks. Composed when the operator wires
|
|
594
|
+
// `opts.helo`; skipped silently otherwise (no synthesized verdict).
|
|
595
|
+
// Hard-reject actions (reject-shape / match-self-refused /
|
|
596
|
+
// literal-mismatch) refuse the connection; "accept" and the
|
|
597
|
+
// advisory "soft-*" actions pass (the soft verdict rides the event).
|
|
598
|
+
if (opts.helo && typeof opts.helo.evaluate === "function") {
|
|
599
|
+
var heloGate = await opts.helo.evaluate(
|
|
600
|
+
{ claimedName: helo, ip: state.remoteAddress, tls: state.tls }, {});
|
|
601
|
+
state.heloVerdict = heloGate && heloGate.action;
|
|
602
|
+
if (heloGate && heloGate.action && heloGate.action !== "accept" &&
|
|
603
|
+
heloGate.action.indexOf("soft") !== 0) {
|
|
604
|
+
_emit("mail.server.mx.helo_gate_refused",
|
|
605
|
+
{ connectionId: state.id, helo: helo, action: heloGate.action }, "denied");
|
|
606
|
+
_writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
|
|
607
|
+
"5.7.1 " + verb + " identity refused (" + heloGate.action + ")");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
555
611
|
state.helo = helo;
|
|
556
612
|
state.stage = "ehlo";
|
|
557
613
|
// Multi-line 250 capabilities advertisement per RFC 5321 §4.1.1.1.
|
|
@@ -572,7 +628,8 @@ function create(opts) {
|
|
|
572
628
|
_writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
|
|
573
629
|
}
|
|
574
630
|
_emit("mail.server.mx.helo",
|
|
575
|
-
{ connectionId: state.id, verb: verb, helo: helo, tls: state.tls
|
|
631
|
+
{ connectionId: state.id, verb: verb, helo: helo, tls: state.tls,
|
|
632
|
+
heloVerdict: state.heloVerdict || null });
|
|
576
633
|
}
|
|
577
634
|
|
|
578
635
|
// ---- STARTTLS ---------------------------------------------------------
|
|
@@ -604,13 +661,11 @@ function create(opts) {
|
|
|
604
661
|
state.helo = null;
|
|
605
662
|
},
|
|
606
663
|
onData: function (tlsSocket, chunk) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
_closeConnection(tlsSocket);
|
|
613
|
-
}
|
|
664
|
+
// Route the upgraded socket through the SAME serialized pump as
|
|
665
|
+
// the plaintext path — post-STARTTLS is where the gates run in
|
|
666
|
+
// the default strict/balanced profiles, so it MUST be serialized
|
|
667
|
+
// and its async rejections MUST hit the 421 path.
|
|
668
|
+
_feedChunk(tlsSocket, chunk);
|
|
614
669
|
},
|
|
615
670
|
onError: function (err) {
|
|
616
671
|
_emit("mail.server.mx.tls_handshake_failed",
|
|
@@ -626,7 +681,7 @@ function create(opts) {
|
|
|
626
681
|
}
|
|
627
682
|
|
|
628
683
|
// ---- MAIL FROM --------------------------------------------------------
|
|
629
|
-
function _handleMailFrom(state, socket, line) {
|
|
684
|
+
async function _handleMailFrom(state, socket, line) {
|
|
630
685
|
if (!state.tls && _requiresStartTls()) {
|
|
631
686
|
_writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
|
|
632
687
|
return;
|
|
@@ -677,7 +732,7 @@ function create(opts) {
|
|
|
677
732
|
}
|
|
678
733
|
|
|
679
734
|
// ---- RCPT TO ----------------------------------------------------------
|
|
680
|
-
function _handleRcptTo(state, socket, line) {
|
|
735
|
+
async function _handleRcptTo(state, socket, line) {
|
|
681
736
|
if (state.stage !== "rcpt") {
|
|
682
737
|
_writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
|
|
683
738
|
return;
|
|
@@ -740,9 +795,49 @@ function create(opts) {
|
|
|
740
795
|
return;
|
|
741
796
|
}
|
|
742
797
|
}
|
|
798
|
+
// RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
|
|
799
|
+
// IP. The verdict is per-connection, so it's evaluated once and
|
|
800
|
+
// cached on state; a listed IP refuses with 554. Skipped silently
|
|
801
|
+
// when opts.rbl isn't wired.
|
|
802
|
+
if (opts.rbl && typeof opts.rbl.query === "function") {
|
|
803
|
+
if (state.rblVerdict === undefined) {
|
|
804
|
+
state.rblVerdict = await opts.rbl.query(state.remoteAddress);
|
|
805
|
+
}
|
|
806
|
+
if (state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
|
|
807
|
+
state.rblVerdict.listed.length > 0) {
|
|
808
|
+
_trackRefusedRcpt(state, rcpt, "rbl-listed");
|
|
809
|
+
_emit("mail.server.mx.rbl_refused",
|
|
810
|
+
{ connectionId: state.id, remoteAddress: state.remoteAddress,
|
|
811
|
+
zones: state.rblVerdict.listed.map(function (l) { return l.zone; }) }, "denied");
|
|
812
|
+
_writeReply(socket, REPLY_554_TRANSACTION_FAILED,
|
|
813
|
+
"5.7.1 Connecting IP is on a DNS blocklist");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// Greylist gate (b.mail.greylist) — defer first sight of an
|
|
818
|
+
// (ip, mailFrom, rcpt) tuple with a 450 tempfail; legitimate
|
|
819
|
+
// senders retry and pass. "defer" → 450; "accept" → continue.
|
|
820
|
+
// Skipped silently when opts.greylist isn't wired.
|
|
821
|
+
var greyVerdict = null;
|
|
822
|
+
if (opts.greylist && typeof opts.greylist.check === "function") {
|
|
823
|
+
greyVerdict = await opts.greylist.check(
|
|
824
|
+
{ ip: state.remoteAddress, mailFrom: state.mailFrom || "", rcptTo: rcpt });
|
|
825
|
+
if (greyVerdict && greyVerdict.action === "defer") {
|
|
826
|
+
_emit("mail.server.mx.greylist_deferred",
|
|
827
|
+
{ connectionId: state.id, remoteAddress: state.remoteAddress,
|
|
828
|
+
mailFrom: state.mailFrom, rcptTo: rcpt,
|
|
829
|
+
reason: greyVerdict.reason }, "denied");
|
|
830
|
+
_writeReply(socket, REPLY_450_MAILBOX_BUSY,
|
|
831
|
+
"4.7.1 Greylisted — please retry shortly");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
743
835
|
state.rcpts.push(rcpt);
|
|
744
836
|
_emit("mail.server.mx.rcpt_to",
|
|
745
|
-
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length
|
|
837
|
+
{ connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
|
|
838
|
+
rblListed: !!(state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
|
|
839
|
+
state.rblVerdict.listed.length > 0),
|
|
840
|
+
greylist: greyVerdict ? greyVerdict.action : null });
|
|
746
841
|
_writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
|
|
747
842
|
}
|
|
748
843
|
|
|
@@ -764,7 +859,7 @@ function create(opts) {
|
|
|
764
859
|
});
|
|
765
860
|
}
|
|
766
861
|
|
|
767
|
-
function _finalizeDataBody(state, socket, body) {
|
|
862
|
+
async function _finalizeDataBody(state, socket, body) {
|
|
768
863
|
// body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
|
|
769
864
|
// §4.5.2 — a single leading "." is doubled on the wire; undo.
|
|
770
865
|
var dedotted = safeSmtp.dotUnstuff(body);
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:b3c8f3dc-4ada-41d3-9b05-66ce6774c125",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-27T12:37:28.862Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.13",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.13",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.13",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.13",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|