@blamejs/core 0.18.51 → 0.18.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +39 -16
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +212 -31
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/parsers/safe-yaml.js +24 -3
  38. package/lib/redact.js +13 -3
  39. package/lib/retention.js +22 -2
  40. package/lib/vendor/MANIFEST.json +12 -12
  41. package/lib/vendor/blamejs-pki.cjs +278 -40
  42. package/lib/yaml-lex.js +587 -0
  43. package/package.json +1 -1
  44. package/sbom.cdx.json +6 -6
@@ -35,6 +35,7 @@
35
35
  * });
36
36
  */
37
37
 
38
+ var codepointClass = require("../codepoint-class");
38
39
  var lazyRequire = require("../lazy-require");
39
40
  var safeBuffer = require("../safe-buffer");
40
41
 
@@ -104,16 +105,12 @@ function _detectIssues(headers, opts) {
104
105
  });
105
106
  continue;
106
107
  }
107
- for (var ci = 0; ci < v.length; ci += 1) {
108
- var cc = v.charCodeAt(ci);
109
- if (cc === 0x0D || cc === 0x0A || cc === 0x00) { // CR / LF / NUL forbidden in header value
110
- issues.push({
111
- kind: "header-value-control-byte", severity: "high", header: name,
112
- snippet: "header `" + name + "` value contains CR / LF / NUL " +
113
- "— header-injection defense in depth",
114
- });
115
- break;
116
- }
108
+ if (codepointClass.firstLineInjectionCharOffset(v) !== -1) {
109
+ issues.push({
110
+ kind: "header-value-control-byte", severity: "high", header: name,
111
+ snippet: "header `" + name + "` value contains CR / LF / NUL " +
112
+ "— header-injection defense in depth",
113
+ });
117
114
  }
118
115
  }
119
116
  }
@@ -402,7 +402,21 @@ function create(opts) {
402
402
 
403
403
  if (parsed.rcode !== 0) {
404
404
  // RFC 1035 §4.1.1 — non-zero RCODE. Surface and refuse caching.
405
- throw new ResolverError("resolver/nxdomain-or-error",
405
+ //
406
+ // NXDOMAIN and everything else are DIFFERENT ANSWERS and no longer share
407
+ // a code. NXDOMAIN (RCODE 3) means the name does not exist, which for a
408
+ // policy fetch or a blocklist is the ordinary case: no MTA-STS record, not
409
+ // on the list. SERVFAIL and REFUSED mean the question was not answered,
410
+ // and reading those as absence is a downgrade an attacker can arrange —
411
+ // break the lookup and the policy that would have refused you disappears.
412
+ //
413
+ // One code called `nxdomain-or-error` forced both readings on every
414
+ // caller, and both callers picked absence: `safeResolveTxt` could not
415
+ // match it at all so the commonest form of absence threw, and `b.mail.rbl`
416
+ // matched it and returned "not listed" for a lookup that failed.
417
+ var absent = parsed.rcode === 3; // RFC 1035 §4.1.1 NXDOMAIN
418
+ throw new ResolverError(
419
+ absent ? "resolver/nxdomain" : "resolver/query-failed",
406
420
  "query: upstream RCODE=" + parsed.rcode + " for " + name + "/" + qtype);
407
421
  }
408
422
 
@@ -501,6 +515,12 @@ function create(opts) {
501
515
  queryCname: _typed("CNAME"),
502
516
  queryMx: _typed("MX"),
503
517
  queryNs: _typed("NS"),
518
+ // PTR was the one common type with no helper, though it has been in
519
+ // QTYPE_BY_NAME all along. `b.mail.helo` called `resolver.queryPtr` for its
520
+ // forward-confirmed reverse DNS check, got `undefined`, and the resulting
521
+ // TypeError landed in a catch written for NXDOMAIN — so the check reported
522
+ // a clean "no reverse name" for every address instead of a broken call.
523
+ queryPtr: _typed("PTR"),
504
524
  queryTxt: _typed("TXT"),
505
525
  querySrv: _typed("SRV"),
506
526
  queryTlsa: _typed("TLSA"),
@@ -533,12 +553,31 @@ function _defaultTransport(timeoutMs) {
533
553
  async function _wireLookup(name, qtype, timeoutMs) {
534
554
  var ms = typeof timeoutMs === "number" && isFinite(timeoutMs) && timeoutMs > 0
535
555
  ? timeoutMs : DEFAULT_TIMEOUT_MS;
536
- var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
556
+ // Where the deployment's DoH actually points. This used to be a hardcoded
557
+ // public provider, so an operator who had configured DoH to their own
558
+ // resolver still had every resolver.query() leave for the public one — and
559
+ // any statement they made about that resolver (that it validates DNSSEC, that
560
+ // it serves a split-horizon zone) described a resolver the query never
561
+ // reached. b.mail.send.deliver's `policy.dnssecValidated` is exactly such a
562
+ // statement. The endpoint is read per lookup rather than captured at create,
563
+ // so a later useDnsOverHttps reaches the next query instead of a handle's
564
+ // whole lifetime being pinned to boot-time configuration.
565
+ var endpoint = networkDns.activeDohEndpoint();
566
+ var url = (endpoint && endpoint.url) || "https://cloudflare-dns.com/dns-query";
537
567
  // Encode a wire-format query for the target qtype.
538
568
  var qbuf = _encodeWireQuery(name, qtype);
539
569
  var b64 = bCrypto.toBase64Url(qbuf);
540
570
  var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
541
- var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
571
+ // RFC 8484 §4.1 — GET carries the query in the URL, POST in the body. An
572
+ // endpoint may accept only POST, and `useDnsOverHttps({ method })` says so;
573
+ // honouring the operator's URL while ignoring their method leaves that
574
+ // endpoint unusable. With no method configured the choice falls to URL
575
+ // length, using the threshold b.network.dns owns rather than a second copy
576
+ // of the number.
577
+ var maxGet = (endpoint && endpoint.getUrlMaxBytes) || 2048; // RFC 9112 §3-conservative GET ceiling
578
+ var usePost = (endpoint && endpoint.method === "POST") ||
579
+ (!(endpoint && endpoint.method) && getUrl.length > maxGet);
580
+ var u = safeUrl.parse(usePost ? url : getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
542
581
  return new Promise(function (resolve, reject) {
543
582
  var settled = false;
544
583
  function _fail(err) {
@@ -555,13 +594,23 @@ async function _wireLookup(name, qtype, timeoutMs) {
555
594
  // Raw DoH wire-format request — bypasses b.httpClient envelope
556
595
  // because we need the raw binary response bytes for safeDns to
557
596
  // parse (httpClient assumes JSON/text shapes).
558
- var req = https.request(Object.assign({ // allow:raw-outbound-http-framework-internal DoH wire-format response bytes; b.httpClient envelopes assume text/JSON, and httpClient → ssrfGuard → DNS → DoH would form a cycle
597
+ // The operator's CA travels with the endpoint. A private DoH resolver is
598
+ // usually issued by a private CA, so honouring the URL without the trust
599
+ // anchor that validates it would swap one unreachable endpoint for another.
600
+ var headers = { "accept": "application/dns-message" };
601
+ if (usePost) {
602
+ headers["content-type"] = "application/dns-message";
603
+ headers["content-length"] = String(qbuf.length);
604
+ }
605
+ var reqOpts = Object.assign({
559
606
  hostname: u.hostname,
560
607
  port: u.port || 443, // HTTPS port
561
608
  path: u.pathname + u.search,
562
- method: "GET",
563
- headers: { "accept": "application/dns-message" },
564
- }, networkTls().outboundPosture()), function (res) {
609
+ method: usePost ? "POST" : "GET",
610
+ headers: headers,
611
+ }, networkTls().outboundPosture());
612
+ if (endpoint && endpoint.ca) reqOpts.ca = endpoint.ca;
613
+ var req = https.request(reqOpts, function (res) { // allow:raw-outbound-http-framework-internal — DoH wire-format response bytes; b.httpClient envelopes assume text/JSON, and httpClient → ssrfGuard → DNS → DoH would form a cycle
565
614
  var collector = safeBuffer.boundedChunkCollector({
566
615
  maxBytes: C.BYTES.kib(64),
567
616
  errorClass: ResolverError,
@@ -599,6 +648,8 @@ async function _wireLookup(name, qtype, timeoutMs) {
599
648
  _fail(new ResolverError("resolver/upstream-failed",
600
649
  "DoH request failed: " + e.message));
601
650
  });
651
+ // POST carries the wire-format query as the body; GET carried it in the URL.
652
+ if (usePost) req.write(qbuf);
602
653
  req.end();
603
654
  });
604
655
  }
@@ -709,7 +760,19 @@ async function safeResolveTxt(qname, opts) {
709
760
  try {
710
761
  return await resolveTxt(qname, opts.dnsLookup);
711
762
  } catch (e) {
712
- if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
763
+ // `resolver/nxdomain` belongs here and was the omission: it is how THIS
764
+ // framework's own resolver reports a name that does not exist, which is the
765
+ // commonest way a policy record is absent. Only ENOTFOUND / ENODATA were
766
+ // listed — the codes node's stub resolver uses — so the documented
767
+ // "absence is not an error" convention held for an operator-supplied
768
+ // `dnsLookup` and not for the resolver the module ships with.
769
+ //
770
+ // `resolver/query-failed` deliberately does NOT belong here. A lookup that
771
+ // was not answered is not a record that is not published, and treating it
772
+ // as one lets an attacker who can break the query delete the policy that
773
+ // would have refused them.
774
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA" ||
775
+ e.code === "resolver/nxdomain")) return null;
713
776
  var msg = "TXT lookup for " + qname + " failed: " + ((e && e.message) || String(e));
714
777
  if (typeof opts.errorFactory === "function") throw opts.errorFactory(opts.code, msg);
715
778
  throw e;
@@ -437,6 +437,31 @@ function useDnsOverHttps(opts) {
437
437
  observability().safeEvent("network.dns.doh.set", 1, { url: url, method: method || "auto" });
438
438
  }
439
439
 
440
+ // activeDohEndpoint() — where DoH queries go right now, or null when the
441
+ // deployment is not on DoH (the operator chose DoT or the system resolver).
442
+ //
443
+ // b.network.dns.resolver runs its own wire-format request, because it needs the
444
+ // raw response bytes for arbitrary record types and this module's DoH path
445
+ // returns decoded addresses. Reading the endpoint from here is what keeps the
446
+ // two agreeing: the resolver previously hardcoded a public provider, so a
447
+ // deployment that had configured DoH to its own resolver — for split horizon,
448
+ // for privacy, or because that resolver validates DNSSEC — had every
449
+ // resolver.query() go to the public one instead, silently.
450
+ // `getUrlMaxBytes` travels with it because the method is not always configured:
451
+ // RFC 8484 §4.1 lets a client switch to POST when the GET URL would be too
452
+ // long, and that threshold is this module's to choose. Returning it keeps the
453
+ // resolver's decision identical to the one made here rather than a second
454
+ // number that can drift.
455
+ function activeDohEndpoint() {
456
+ if (!STATE.doh) return null;
457
+ return {
458
+ url: STATE.doh.url,
459
+ method: STATE.doh.method || null,
460
+ ca: STATE.doh.ca || null,
461
+ getUrlMaxBytes: DOH_GET_URL_MAX_BYTES,
462
+ };
463
+ }
464
+
440
465
  function useDnsOverTls(opts) {
441
466
  opts = opts || {};
442
467
  validateOpts(opts, ["host", "port", "servername", "ca"], "dns.useDnsOverTls");
@@ -2259,6 +2284,7 @@ module.exports = {
2259
2284
  setLookupTimeoutMs: setLookupTimeoutMs,
2260
2285
  setCacheTtlMs: setCacheTtlMs,
2261
2286
  useDnsOverHttps: useDnsOverHttps,
2287
+ activeDohEndpoint: activeDohEndpoint,
2262
2288
  useDnsOverTls: useDnsOverTls,
2263
2289
  useSystemResolver: useSystemResolver,
2264
2290
  useDesignatedResolvers: useDesignatedResolvers,
@@ -243,17 +243,49 @@ async function daneTlsa(domain, port, opts) {
243
243
  opts = opts || {};
244
244
  var p = typeof port === "number" ? port : 25; // IANA SMTP port
245
245
  var qname = "_" + p + "._tcp." + domain.toLowerCase();
246
- // node:dns has resolveTlsa() since Node 18.16.0.
247
- if (typeof dnsPromises.resolveTlsa !== "function") {
248
- throw new SmtpPolicyError("smtp/dane-unavailable",
249
- "node:dns.resolveTlsa is not available on this runtime");
250
- }
246
+
247
+ // `opts.resolver` is the caller's own resolver — the one whose DNSSEC posture
248
+ // `opts.dnssecValidated` is an assertion ABOUT. Falling back to node:dns here
249
+ // when one was supplied would mean the assertion described resolver A while
250
+ // the records arrived from resolver B, so a non-validating system resolver
251
+ // could hand over spoofed TLSA data that DANE then treats as authenticated.
252
+ // A supplied resolver that cannot answer TLSA is refused rather than bypassed.
251
253
  var records;
252
- try { records = await dnsPromises.resolveTlsa(qname); }
253
- catch (e) {
254
- if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return [];
255
- throw new SmtpPolicyError("smtp/dane-lookup-failed",
256
- "TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
254
+ if (opts.resolver) {
255
+ if (typeof opts.resolver.queryTlsa !== "function") {
256
+ throw new SmtpPolicyError("smtp/dane-resolver-no-tlsa",
257
+ "dane.tlsa: opts.resolver was supplied but has no queryTlsa(name); the " +
258
+ "records must come from the resolver whose DNSSEC posture was asserted, " +
259
+ "so falling back to the system resolver is refused");
260
+ }
261
+ var rv;
262
+ try { rv = await opts.resolver.queryTlsa(qname); }
263
+ catch (e0) {
264
+ if (e0 && e0.code === "resolver/nxdomain") return []; // no TLSA published
265
+ throw new SmtpPolicyError("smtp/dane-lookup-failed",
266
+ "TLSA lookup for " + qname + " failed: " + ((e0 && e0.message) || String(e0)));
267
+ }
268
+ // b.network.dns decodes TLSA into { usage, selector, matchingType, certData };
269
+ // node:dns names the same fields certUsage / selector / match / data. Mapped
270
+ // here so the normalisation below has one shape to read.
271
+ records = ((rv && rv.rrs) || []).filter(function (r) {
272
+ return r && r.decoded && typeof r.decoded.usage === "number";
273
+ }).map(function (r) {
274
+ return { certUsage: r.decoded.usage, selector: r.decoded.selector,
275
+ match: r.decoded.matchingType, data: r.decoded.certData };
276
+ });
277
+ } else {
278
+ // node:dns has resolveTlsa() since Node 18.16.0.
279
+ if (typeof dnsPromises.resolveTlsa !== "function") {
280
+ throw new SmtpPolicyError("smtp/dane-unavailable",
281
+ "node:dns.resolveTlsa is not available on this runtime");
282
+ }
283
+ try { records = await dnsPromises.resolveTlsa(qname); }
284
+ catch (e) {
285
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return [];
286
+ throw new SmtpPolicyError("smtp/dane-lookup-failed",
287
+ "TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
288
+ }
257
289
  }
258
290
  // RFC 7672 §1.3 — TLSA records that are NOT DNSSEC-validated MUST
259
291
  // NOT be used. node:dns.resolveTlsa does not surface the AD bit
@@ -60,6 +60,7 @@ var boundedMap = require("../bounded-map");
60
60
  var numericBounds = require("../numeric-bounds");
61
61
  var safeBuffer = require("../safe-buffer");
62
62
  var codepointClass = require("../codepoint-class");
63
+ var yamlLex = require("../yaml-lex");
63
64
  var { FrameworkError } = require("../framework-error");
64
65
 
65
66
  class SafeYamlError extends FrameworkError {
@@ -227,7 +228,10 @@ function _findAnchorOrAlias(text) {
227
228
  var ch = text.charAt(i);
228
229
  if (ch !== "&" && ch !== "*") continue;
229
230
  var atStart = i === 0;
230
- if (!atStart && !_isWhitespaceChar(text.charAt(i - 1))) continue;
231
+ // No test on the preceding character: `text` is the shared mask, in which a
232
+ // sigil survives only where it opens a node. The whitespace test was the
233
+ // original defect and it also had no way to admit `{"a":&anchor v}`, where
234
+ // a quoted key takes its colon with no space after it.
231
235
  if (_NAME_HEAD.indexOf(text.charAt(i + 1)) === -1 || text.charAt(i + 1) === "") continue;
232
236
  return { index: atStart ? i : i - 1, sigil: ch };
233
237
  }
@@ -239,8 +243,10 @@ function _findTag(text) {
239
243
  for (var i = 0; i < text.length; i += 1) {
240
244
  if (text.charAt(i) !== "!") continue;
241
245
  var atStart = i === 0;
242
- var before = text.charAt(i - 1);
243
- if (!atStart && !(before === "-" || _isWhitespaceChar(before))) continue;
246
+ // Same as above: position is the mask's judgement. Requiring whitespace or
247
+ // a dash before the tag let `{"a":!!python/object x}` through, because the
248
+ // character in front of it is the colon of a JSON-style key — and a
249
+ // deserialization tag is the one thing this ban exists for.
244
250
  // The pattern is greedy, so it takes two `!` when both are present.
245
251
  var after = text.charAt(i + 1) === "!" ? text.charAt(i + 2) : text.charAt(i + 1);
246
252
  if (after.length !== 1) continue;
@@ -1194,6 +1200,21 @@ function _preValidate(input) {
1194
1200
  advance();
1195
1201
  }
1196
1202
 
1203
+ // The masking above is kept for ONE thing: it is the scan that notices an
1204
+ // unterminated quote, and that diagnosis is this parser's to give. What it
1205
+ // produced was not a usable screen. Its own header promised that block-scalar
1206
+ // bodies were masked and there was no block-scalar handling in it at all, and
1207
+ // comment text was copied through verbatim under a comment saying so — which
1208
+ // is why `x: 1 # note !bang` and `x: |` / ` echo !boom` were both refused as
1209
+ // tags. A plain scalar was not understood either, so `x: hello !world` was
1210
+ // refused for a bang sitting in the middle of a value.
1211
+ //
1212
+ // The banned-construct scan therefore runs against the shared lexer's mask.
1213
+ // `guard-yaml` had the same three gaps in a separate implementation, and one
1214
+ // scan answering "which region is this character in" is what stops the two
1215
+ // drifting apart again.
1216
+ safe = yamlLex.maskNonStructural(input);
1217
+
1197
1218
  // Now scan `safe` for banned constructs.
1198
1219
  // Banned tokens (must be at line-start or after whitespace, not in keys):
1199
1220
  // &name anchor
package/lib/redact.js CHANGED
@@ -527,8 +527,14 @@ function classifyDefaults(opts) {
527
527
  "redact.classifyDefaults: patterns[" + p + "] must be a string, got " +
528
528
  typeof patterns[p]);
529
529
  }
530
+ // Own-key on BOTH tables. `extra` is operator-supplied, so reading it
531
+ // plainly resolved "constructor" / "toString" / "valueOf" to inherited
532
+ // functions: the name passed as known, and the scanner then met a function
533
+ // where a pattern spec belongs and threw an untyped error at scan time.
534
+ // A DLP classifier must fail at boot on a name it cannot honour, never at
535
+ // the moment it is asked to look.
530
536
  if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, patterns[p]) &&
531
- !(opts.extra && opts.extra[patterns[p]])) {
537
+ !(opts.extra && Object.prototype.hasOwnProperty.call(opts.extra, patterns[p]))) {
532
538
  throw new DlpError("redact-dlp/unknown-pattern",
533
539
  "redact.classifyDefaults: unknown pattern '" + patterns[p] +
534
540
  "'. Known: " + Object.keys(CLASSIFIER_PATTERNS).join(", "));
@@ -544,8 +550,12 @@ function classifyDefaults(opts) {
544
550
  var extra = opts.extra || {};
545
551
 
546
552
  function _resolve(name) {
547
- var spec = CLASSIFIER_PATTERNS[name] || extra[name];
548
- return spec;
553
+ // Own-key, matching the validation above: the names reaching here are
554
+ // already vetted, and reading through the prototype anyway would let a
555
+ // future caller that skips the vetting resolve a function as a spec.
556
+ if (Object.prototype.hasOwnProperty.call(CLASSIFIER_PATTERNS, name)) return CLASSIFIER_PATTERNS[name];
557
+ if (Object.prototype.hasOwnProperty.call(extra, name)) return extra[name];
558
+ return undefined;
549
559
  }
550
560
 
551
561
  return function classify(input) {
package/lib/retention.js CHANGED
@@ -55,6 +55,9 @@ var { defineClass } = require("./framework-error");
55
55
  var audit = lazyRequire(function () { return require("./audit"); });
56
56
  var cryptoField = require("./crypto-field");
57
57
  var legalHold = lazyRequire(function () { return require("./legal-hold"); });
58
+ // Lazy — compliance.js requires this module for its posture cascade, so the
59
+ // dependency is mutual and neither may load the other at module scope.
60
+ var compliance = lazyRequire(function () { return require("./compliance"); });
58
61
 
59
62
  var RetentionError = defineClass("RetentionError", { alwaysPermanent: true });
60
63
  var _err = RetentionError.factory;
@@ -648,10 +651,27 @@ function complianceFloor(posture, candidateTtlMs) {
648
651
  var floor = Object.prototype.hasOwnProperty.call(COMPLIANCE_RETENTION_FLOOR_MS, posture)
649
652
  ? COMPLIANCE_RETENTION_FLOOR_MS[posture] : undefined;
650
653
  if (floor === undefined) {
654
+ // "This regime sets no retention minimum" and "this is not a regime" are
655
+ // different facts, and the floor table only ever answered the first. Most
656
+ // postures the framework knows impose no minimum — GDPR Art. 5(1)(e) is
657
+ // storage LIMITATION, the opposite of a floor — so treating absence from
658
+ // this table as a typo reported 158 of the 169 postures b.compliance.set
659
+ // accepts as misspellings, and an operator who set one could not compute a
660
+ // TTL at all. The posture vocabulary lives in b.compliance; this table
661
+ // holds only the subset carrying a regulator-mandated minimum.
662
+ if (compliance().KNOWN_POSTURES.indexOf(posture) !== -1) return _atLeast(0, candidateTtlMs);
651
663
  throw new RetentionError("retention/unknown-posture",
652
- "complianceFloor: unknown posture '" + posture + "'; expected one of " +
653
- Object.keys(COMPLIANCE_RETENTION_FLOOR_MS).join(", "));
664
+ "complianceFloor: unknown posture '" + posture + "'; it is not in " +
665
+ "b.compliance.KNOWN_POSTURES. Postures carrying a retention minimum are " +
666
+ Object.keys(COMPLIANCE_RETENTION_FLOOR_MS).join(", ") + "; the rest resolve to 0");
654
667
  }
668
+ return _atLeast(floor, candidateTtlMs);
669
+ }
670
+
671
+ // The floor wins unless the operator's candidate is longer. A candidate that
672
+ // is absent or not a positive finite number is no candidate at all, and the
673
+ // floor stands alone.
674
+ function _atLeast(floor, candidateTtlMs) {
655
675
  if (typeof candidateTtlMs !== "number" || !isFinite(candidateTtlMs) || candidateTtlMs <= 0) {
656
676
  return floor;
657
677
  }
@@ -21,7 +21,7 @@
21
21
  "server": "sha256:f3325f480cb8eb814fcb0baaa19336cbbf2b993f48624c6aa9600ffd69d0be5e",
22
22
  "browser": "sha256:0ffd91540bcb586a29b56e52ee1c29df69097b50776beb4036a07558f7a4e12e"
23
23
  },
24
- "refreshedAt": "2026-08-23T07:32:38.917Z"
24
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
25
25
  },
26
26
  "@noble/hashes": {
27
27
  "version": "2.3.0",
@@ -48,7 +48,7 @@
48
48
  "hashes": {
49
49
  "browser": "sha256:dfe4b7ae3c9880e388c8da4b68f44742b229b53afacd1e674179527e33da62b0"
50
50
  },
51
- "refreshedAt": "2026-08-23T07:32:38.917Z"
51
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
52
52
  },
53
53
  "@noble/curves": {
54
54
  "version": "2.3.0",
@@ -70,7 +70,7 @@
70
70
  "hashes": {
71
71
  "server": "sha256:b5fe88d1ea780d0581dee6145d666f89d46fc9531b5db35db2e5b16627840890"
72
72
  },
73
- "refreshedAt": "2026-08-23T07:32:38.917Z",
73
+ "refreshedAt": "2026-08-24T23:54:10.282Z",
74
74
  "components": {
75
75
  "@noble/hashes": {
76
76
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -114,7 +114,7 @@
114
114
  "server": "sha256:fab7ebe5737793862c473444f4ee5912f79dd1edec86683acbb4eecbca0f5892",
115
115
  "browser": "sha256:cae1d5bbdc7184b202b6ca68df6e1db7b0d0f668c77809ded189ca7f271accc9"
116
116
  },
117
- "refreshedAt": "2026-08-23T07:32:38.917Z",
117
+ "refreshedAt": "2026-08-24T23:54:10.282Z",
118
118
  "components": {
119
119
  "@noble/hashes": {
120
120
  "url": "https://github.com/paulmillr/noble-hashes",
@@ -148,7 +148,7 @@
148
148
  },
149
149
  "runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
150
150
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
151
- "refreshedAt": "2026-08-23T07:32:38.917Z"
151
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
152
152
  },
153
153
  "bimi-trust-anchors": {
154
154
  "version": "operator-managed",
@@ -173,7 +173,7 @@
173
173
  },
174
174
  "runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
175
175
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
176
- "refreshedAt": "2026-08-23T07:32:38.917Z"
176
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
177
177
  },
178
178
  "publicsuffix-list": {
179
179
  "version": "master",
@@ -193,10 +193,10 @@
193
193
  },
194
194
  "runtime_artifact": "lib/vendor/public-suffix-list.data.js",
195
195
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
196
- "refreshedAt": "2026-08-23T07:32:38.917Z"
196
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
197
197
  },
198
198
  "@blamejs/pki": {
199
- "version": "0.5.28",
199
+ "version": "0.5.30",
200
200
  "license": "Apache-2.0",
201
201
  "author": "blamejs",
202
202
  "source": "https://github.com/blamejs/pki",
@@ -216,12 +216,12 @@
216
216
  "server": "lib/vendor/blamejs-pki.cjs"
217
217
  },
218
218
  "bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
219
- "bundledAt": "2026-08-23T00:00:00Z",
220
- "cpe": "cpe:2.3:a:blamejs:pki:0.5.28:*:*:*:*:node.js:*:*",
219
+ "bundledAt": "2026-08-24T00:00:00Z",
220
+ "cpe": "cpe:2.3:a:blamejs:pki:0.5.30:*:*:*:*:node.js:*:*",
221
221
  "hashes": {
222
- "server": "sha256:a11e84272034dc4065dac44f55b722b3b4e1803ddeb27b09cbe6d8644225209c"
222
+ "server": "sha256:fc8b1783ae40c377c1bc7afb7bd902b5282c165ed11354b803867f7d9f75f021"
223
223
  },
224
- "refreshedAt": "2026-08-23T07:32:38.917Z"
224
+ "refreshedAt": "2026-08-24T23:54:10.282Z"
225
225
  }
226
226
  }
227
227
  }