@blamejs/core 0.15.13 → 0.15.15

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 (208) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/a2a-tasks.js +38 -6
  4. package/lib/agent-event-bus.js +13 -0
  5. package/lib/agent-idempotency.js +5 -1
  6. package/lib/agent-snapshot.js +32 -2
  7. package/lib/ai-aedt-bias-audit.js +2 -1
  8. package/lib/ai-content-detect.js +1 -3
  9. package/lib/ai-frontier-protocol.js +1 -1
  10. package/lib/ai-model-manifest.js +1 -1
  11. package/lib/ai-output.js +16 -7
  12. package/lib/api-snapshot.js +4 -1
  13. package/lib/app-shutdown.js +7 -1
  14. package/lib/archive-gz.js +9 -0
  15. package/lib/archive-read.js +9 -7
  16. package/lib/archive-tar-read.js +51 -8
  17. package/lib/archive.js +4 -2
  18. package/lib/asn1-der.js +70 -22
  19. package/lib/atomic-file.js +204 -2
  20. package/lib/audit-chain.js +54 -5
  21. package/lib/audit-daily-review.js +12 -2
  22. package/lib/audit-sign.js +2 -1
  23. package/lib/audit-tools.js +108 -23
  24. package/lib/audit.js +7 -2
  25. package/lib/auth/access-lock.js +2 -2
  26. package/lib/auth/bot-challenge.js +1 -1
  27. package/lib/auth/ciba.js +71 -8
  28. package/lib/auth/dpop.js +15 -1
  29. package/lib/auth/fido-mds3.js +30 -13
  30. package/lib/auth/jwt.js +21 -5
  31. package/lib/auth/lockout.js +18 -2
  32. package/lib/auth/oauth.js +8 -2
  33. package/lib/auth/passkey.js +1 -1
  34. package/lib/auth/password.js +1 -1
  35. package/lib/auth/saml.js +42 -12
  36. package/lib/auth/sd-jwt-vc.js +24 -4
  37. package/lib/auth/status-list.js +14 -2
  38. package/lib/auth/step-up.js +9 -1
  39. package/lib/auth-bot-challenge.js +21 -2
  40. package/lib/backup/bundle.js +7 -2
  41. package/lib/backup/crypto.js +23 -8
  42. package/lib/backup/index.js +41 -20
  43. package/lib/backup/manifest.js +7 -1
  44. package/lib/break-glass.js +41 -22
  45. package/lib/cbor.js +34 -11
  46. package/lib/cdn-cache-control.js +7 -3
  47. package/lib/cert.js +5 -3
  48. package/lib/cli.js +5 -1
  49. package/lib/cloud-events.js +3 -2
  50. package/lib/cluster-storage.js +7 -3
  51. package/lib/codepoint-class.js +17 -0
  52. package/lib/compliance-eaa.js +1 -1
  53. package/lib/compliance-sanctions.js +9 -7
  54. package/lib/compliance.js +1 -1
  55. package/lib/config-drift.js +22 -8
  56. package/lib/content-credentials.js +11 -8
  57. package/lib/content-digest.js +11 -4
  58. package/lib/cookies.js +10 -2
  59. package/lib/cose.js +20 -0
  60. package/lib/crdt.js +2 -1
  61. package/lib/crypto-field.js +48 -24
  62. package/lib/crypto.js +18 -4
  63. package/lib/csp.js +13 -0
  64. package/lib/daemon.js +4 -1
  65. package/lib/data-act.js +27 -4
  66. package/lib/db-file-lifecycle.js +14 -6
  67. package/lib/db-query.js +102 -11
  68. package/lib/db.js +32 -15
  69. package/lib/dora.js +43 -13
  70. package/lib/dr-runbook.js +1 -1
  71. package/lib/dsa.js +2 -1
  72. package/lib/dsr.js +22 -8
  73. package/lib/early-hints.js +19 -0
  74. package/lib/eat.js +5 -1
  75. package/lib/external-db.js +60 -4
  76. package/lib/fda-21cfr11.js +30 -5
  77. package/lib/flag-providers.js +6 -2
  78. package/lib/forms.js +1 -1
  79. package/lib/gate-contract.js +46 -5
  80. package/lib/gdpr-ropa.js +18 -9
  81. package/lib/graphql-federation.js +17 -4
  82. package/lib/guard-all.js +2 -2
  83. package/lib/guard-dsn.js +1 -1
  84. package/lib/guard-envelope.js +1 -1
  85. package/lib/guard-html.js +9 -11
  86. package/lib/guard-imap-command.js +1 -1
  87. package/lib/guard-jmap.js +1 -1
  88. package/lib/guard-json.js +14 -6
  89. package/lib/guard-mail-move.js +1 -1
  90. package/lib/guard-managesieve-command.js +1 -1
  91. package/lib/guard-pop3-command.js +1 -1
  92. package/lib/guard-smtp-command.js +1 -1
  93. package/lib/guard-svg.js +8 -9
  94. package/lib/html-balance.js +7 -3
  95. package/lib/http-client-cookie-jar.js +33 -12
  96. package/lib/http-client.js +225 -53
  97. package/lib/iab-tcf.js +3 -2
  98. package/lib/importmap-integrity.js +41 -1
  99. package/lib/incident-report.js +9 -6
  100. package/lib/json-patch.js +1 -1
  101. package/lib/json-path.js +24 -3
  102. package/lib/jtd.js +2 -2
  103. package/lib/legal-hold.js +24 -8
  104. package/lib/log.js +2 -2
  105. package/lib/mail-agent.js +2 -2
  106. package/lib/mail-arf.js +1 -1
  107. package/lib/mail-auth.js +27 -4
  108. package/lib/mail-bimi.js +16 -16
  109. package/lib/mail-bounce.js +3 -3
  110. package/lib/mail-crypto-smime.js +71 -6
  111. package/lib/mail-deploy.js +9 -5
  112. package/lib/mail-dkim.js +20 -7
  113. package/lib/mail-greylist.js +2 -4
  114. package/lib/mail-helo.js +2 -4
  115. package/lib/mail-journal.js +11 -8
  116. package/lib/mail-mdn.js +8 -4
  117. package/lib/mail-rbl.js +2 -4
  118. package/lib/mail-scan.js +3 -5
  119. package/lib/mail-server-jmap.js +4 -1
  120. package/lib/mail-server-registry.js +1 -1
  121. package/lib/mail-server-tls.js +9 -2
  122. package/lib/mail-spam-score.js +2 -4
  123. package/lib/mail-store-fts.js +1 -1
  124. package/lib/mail.js +22 -2
  125. package/lib/markup-tokenizer.js +24 -0
  126. package/lib/mcp.js +6 -4
  127. package/lib/mdoc.js +26 -3
  128. package/lib/metrics.js +14 -3
  129. package/lib/middleware/api-encrypt.js +2 -2
  130. package/lib/middleware/body-parser.js +10 -4
  131. package/lib/middleware/bot-guard.js +26 -18
  132. package/lib/middleware/clear-site-data.js +5 -1
  133. package/lib/middleware/compose-pipeline.js +39 -5
  134. package/lib/middleware/compression.js +9 -0
  135. package/lib/middleware/cors.js +32 -23
  136. package/lib/middleware/csrf-protect.js +60 -21
  137. package/lib/middleware/daily-byte-quota.js +6 -4
  138. package/lib/middleware/fetch-metadata.js +28 -4
  139. package/lib/middleware/network-allowlist.js +61 -30
  140. package/lib/middleware/rate-limit.js +25 -16
  141. package/lib/middleware/scim-server.js +2 -1
  142. package/lib/middleware/security-headers.js +24 -6
  143. package/lib/middleware/speculation-rules.js +6 -3
  144. package/lib/middleware/tus-upload.js +2 -2
  145. package/lib/money.js +1 -1
  146. package/lib/mtls-ca.js +10 -6
  147. package/lib/network-dns-resolver.js +1 -1
  148. package/lib/network-dns.js +9 -2
  149. package/lib/network-dnssec.js +2 -1
  150. package/lib/network-smtp-policy.js +23 -5
  151. package/lib/network-tls.js +27 -5
  152. package/lib/network-tsig.js +2 -2
  153. package/lib/nis2-report.js +1 -1
  154. package/lib/nist-crosswalk.js +1 -1
  155. package/lib/ntp-check.js +28 -0
  156. package/lib/numeric-bounds.js +9 -0
  157. package/lib/object-store/azure-blob.js +1 -2
  158. package/lib/object-store/gcs-bucket-ops.js +4 -2
  159. package/lib/object-store/gcs.js +6 -4
  160. package/lib/object-store/http-put.js +1 -2
  161. package/lib/object-store/http-request.js +30 -1
  162. package/lib/object-store/local.js +37 -17
  163. package/lib/object-store/sigv4.js +1 -2
  164. package/lib/observability-otlp-exporter.js +20 -4
  165. package/lib/outbox.js +11 -4
  166. package/lib/parsers/safe-xml.js +1 -1
  167. package/lib/parsers/safe-yaml.js +21 -3
  168. package/lib/pipl-cn.js +11 -8
  169. package/lib/queue-local.js +10 -3
  170. package/lib/redact.js +7 -3
  171. package/lib/request-helpers.js +347 -36
  172. package/lib/resource-access-lock.js +3 -3
  173. package/lib/restore-bundle.js +46 -18
  174. package/lib/restore-rollback.js +10 -4
  175. package/lib/restore.js +19 -0
  176. package/lib/retention.js +20 -4
  177. package/lib/router.js +17 -4
  178. package/lib/safe-ical.js +2 -2
  179. package/lib/safe-icap.js +1 -1
  180. package/lib/safe-json.js +70 -0
  181. package/lib/safe-sieve.js +1 -1
  182. package/lib/safe-vcard.js +1 -1
  183. package/lib/sandbox-worker.js +6 -0
  184. package/lib/sandbox.js +1 -1
  185. package/lib/scheduler.js +17 -1
  186. package/lib/self-update-standalone-verifier.js +16 -0
  187. package/lib/session.js +62 -120
  188. package/lib/sql.js +25 -3
  189. package/lib/static.js +65 -13
  190. package/lib/template.js +7 -5
  191. package/lib/tenant-quota.js +52 -19
  192. package/lib/tsa.js +5 -2
  193. package/lib/vault/index.js +5 -0
  194. package/lib/vault/passphrase-ops.js +22 -26
  195. package/lib/vault/passphrase-source.js +8 -3
  196. package/lib/vault/rotate.js +13 -18
  197. package/lib/vault/seal-pem-file.js +4 -1
  198. package/lib/vc.js +1 -1
  199. package/lib/vendor/MANIFEST.json +10 -10
  200. package/lib/vendor/public-suffix-list.dat +23 -10
  201. package/lib/vendor/public-suffix-list.data.js +5498 -5494
  202. package/lib/webhook.js +16 -1
  203. package/lib/websocket.js +1 -1
  204. package/lib/worm.js +1 -1
  205. package/lib/ws-client.js +83 -46
  206. package/lib/x509-chain.js +91 -0
  207. package/package.json +1 -1
  208. package/sbom.cdx.json +6 -6
package/lib/asn1-der.js CHANGED
@@ -79,13 +79,25 @@ function readNode(buf, offset) {
79
79
  // High-tag-number form (multi-byte tag). Walk continuation octets
80
80
  // (each top bit set means another follows).
81
81
  tag = 0;
82
+ var tagOctets = 0;
82
83
  while (true) {
83
84
  if (offset + headerLen >= buf.length) {
84
85
  throw new Asn1Error("asn1/short", "tag continuation truncated");
85
86
  }
86
87
  var byte = buf[offset + headerLen];
88
+ if (tagOctets === 0 && byte === 0x80) { // leading 0x80 = non-minimal
89
+ throw new Asn1Error("asn1/tag-non-minimal",
90
+ "high-tag-number form has a non-minimal leading 0x80 octet");
91
+ }
87
92
  headerLen += 1;
88
- tag = (tag << 7) | (byte & 0x7f); // base-128 tag bits
93
+ tagOctets += 1;
94
+ // Base-128 multiplication (NOT `tag << 7`, which coerces to 32-bit
95
+ // signed int and overflows to a negative/wrong tag past 2^28).
96
+ tag = (tag * 128) + (byte & 0x7f);
97
+ if (tag > Number.MAX_SAFE_INTEGER) {
98
+ throw new Asn1Error("asn1/tag-too-large",
99
+ "high-tag-number exceeds the safe-integer range");
100
+ }
89
101
  if ((byte & 0x80) === 0) break; // continuation bit
90
102
  }
91
103
  }
@@ -161,29 +173,45 @@ function readOid(node) {
161
173
  if (bytes.length === 0) {
162
174
  throw new Asn1Error("asn1/oid-empty", "OID value is empty");
163
175
  }
164
- // First two arcs are encoded as `40*X + Y`.
165
- var first = Math.floor(bytes[0] / 40); // OID encoding constant
166
- var second = bytes[0] % 40; // OID encoding constant
167
- // Per X.690, when first byte >= 80 the first arc is 2 and second is byte-80.
168
- if (first > 2) { first = 2; second = bytes[0] - 80; } // OID encoding constant
169
- var arcs = [String(first), String(second)];
170
-
171
- var i = 1;
176
+ // Decode every subidentifier as a full base-128 quantity (X.690 §8.19),
177
+ // rejecting non-minimal leading-0x80 padding and unterminated/oversized
178
+ // arcs. The FIRST subidentifier may itself span continuation octets
179
+ // (§8.19.4); only after decoding it do we split into the first two arcs.
180
+ var subids = [];
181
+ var i = 0;
172
182
  while (i < bytes.length) {
173
183
  var arc = 0;
174
184
  var j = i;
185
+ var done = false;
175
186
  while (j < bytes.length) {
176
187
  var b = bytes[j];
188
+ if (j === i && b === 0x80) { // leading 0x80 = non-minimal
189
+ throw new Asn1Error("asn1/oid-non-minimal",
190
+ "OID subidentifier has a non-minimal leading 0x80 octet");
191
+ }
177
192
  arc = (arc * 128) + (b & 0x7f); // base-128 OID arc
193
+ if (arc > Number.MAX_SAFE_INTEGER) {
194
+ throw new Asn1Error("asn1/oid-overflow",
195
+ "OID subidentifier exceeds the safe-integer range");
196
+ }
178
197
  j += 1;
179
- if ((b & 0x80) === 0) break; // continuation bit
198
+ if ((b & 0x80) === 0) { done = true; break; } // continuation bit
180
199
  }
181
- if (j === i) {
200
+ if (!done) {
182
201
  throw new Asn1Error("asn1/oid-malformed", "OID arc never terminated");
183
202
  }
184
- arcs.push(String(arc));
203
+ subids.push(arc);
185
204
  i = j;
186
205
  }
206
+
207
+ // Split the first subidentifier into the first two arcs (40*X + Y).
208
+ var firstSubid = subids[0];
209
+ var first, second;
210
+ if (firstSubid < 40) { first = 0; second = firstSubid; }
211
+ else if (firstSubid < 80) { first = 1; second = firstSubid - 40; }
212
+ else { first = 2; second = firstSubid - 80; }
213
+ var arcs = [String(first), String(second)];
214
+ for (var k = 1; k < subids.length; k += 1) arcs.push(String(subids[k]));
187
215
  return arcs.join(".");
188
216
  }
189
217
 
@@ -298,24 +326,44 @@ function writeInteger(buf) {
298
326
  return writeNode(TAG.INTEGER, buf);
299
327
  }
300
328
 
329
+ // Append `arc` to `bytes` as a base-128 subidentifier (X.690 §8.19), most-
330
+ // significant octet first, every octet but the last carrying the
331
+ // continuation bit. Uses integer division (not 32-bit `>>> 7`) so arcs
332
+ // beyond 2^31 encode correctly.
333
+ function _writeBase128Subid(arc, bytes) {
334
+ if (arc === 0) { bytes.push(0); return; }
335
+ var stack = [];
336
+ while (arc > 0) {
337
+ stack.unshift(arc % 128); // base-128 digit
338
+ arc = Math.floor(arc / 128);
339
+ }
340
+ for (var j = 0; j < stack.length - 1; j += 1) stack[j] |= 0x80; // continuation bit
341
+ for (var k = 0; k < stack.length; k += 1) bytes.push(stack[k]);
342
+ }
343
+
301
344
  function writeOid(dotted) {
302
345
  // Encode dotted-decimal OID per X.690 §8.19.
303
346
  var parts = String(dotted).split(".").map(function (s) { return parseInt(s, 10); });
304
347
  if (parts.length < 2) {
305
348
  throw new Asn1Error("asn1/oid-too-short", "OID needs at least 2 arcs");
306
349
  }
307
- var bytes = [parts[0] * 40 + parts[1]]; // OID first-arc encoding
308
- for (var i = 2; i < parts.length; i += 1) {
309
- var arc = parts[i];
310
- if (arc === 0) { bytes.push(0); continue; }
311
- var stack = [];
312
- while (arc > 0) {
313
- stack.unshift(arc & 0x7f); // base-128 mask
314
- arc = arc >>> 7; // base-128 shift
350
+ for (var p = 0; p < parts.length; p += 1) {
351
+ if (!Number.isInteger(parts[p]) || parts[p] < 0 || parts[p] > Number.MAX_SAFE_INTEGER) {
352
+ throw new Asn1Error("asn1/oid-bad-arc", "OID arc " + p + " is not a non-negative integer");
315
353
  }
316
- for (var j = 0; j < stack.length - 1; j += 1) stack[j] |= 0x80; // continuation bit
317
- for (var k = 0; k < stack.length; k += 1) bytes.push(stack[k]);
318
354
  }
355
+ if (parts[0] > 2) {
356
+ throw new Asn1Error("asn1/oid-bad-arc", "OID first arc must be 0, 1, or 2");
357
+ }
358
+ if (parts[0] < 2 && parts[1] >= 40) {
359
+ throw new Asn1Error("asn1/oid-bad-arc",
360
+ "OID second arc must be < 40 when the first arc is 0 or 1");
361
+ }
362
+ var bytes = [];
363
+ // The first two arcs share one subidentifier (40*X + Y), which may itself
364
+ // need multiple base-128 octets (e.g. 2.999 -> 1079).
365
+ _writeBase128Subid(parts[0] * 40 + parts[1], bytes);
366
+ for (var i = 2; i < parts.length; i += 1) _writeBase128Subid(parts[i], bytes);
319
367
  return writeNode(TAG.OID, Buffer.from(bytes));
320
368
  }
321
369
 
@@ -40,6 +40,8 @@
40
40
  */
41
41
  var nodeFs = require("node:fs");
42
42
  var nodePath = require("node:path");
43
+ var nodeStream = require("node:stream");
44
+ var streamPromises = require("node:stream/promises");
43
45
  var { generateToken, sha3Hash } = require("./crypto");
44
46
  var safeJson = require("./safe-json");
45
47
  var C = require("./constants");
@@ -81,7 +83,11 @@ class AtomicFileError extends FrameworkError {
81
83
  // primitive's standard names; jitterFactor 0.5 reproduces the original
82
84
  // `delay * (0.5 + Math.random()/2)` range of [delay/2, delay].
83
85
 
84
- var TRANSIENT_FS_ERRNOS = new Set(["EBUSY", "EAGAIN", "ENFILE", "EMFILE", "EPERM"]);
86
+ // EACCES joins the transient set: on Windows a freshly-written file is briefly
87
+ // locked by AV / the search indexer / a file-sync client (Dropbox, OneDrive),
88
+ // surfacing as EACCES (alongside EPERM/EBUSY) on the next open/rename — the same
89
+ // transient contention the sync _renameWithRetry already treats as retryable.
90
+ var TRANSIENT_FS_ERRNOS = new Set(["EBUSY", "EAGAIN", "ENFILE", "EMFILE", "EPERM", "EACCES"]);
85
91
 
86
92
  function _isFsRetryable(e) {
87
93
  return e != null && TRANSIENT_FS_ERRNOS.has(e.code);
@@ -485,6 +491,146 @@ function writeSync(filepath, data, opts) {
485
491
  };
486
492
  }
487
493
 
494
+ /**
495
+ * @primitive b.atomicFile.writeStream
496
+ * @signature b.atomicFile.writeStream(filepath, source, opts?)
497
+ * @since 0.15.14
498
+ * @status stable
499
+ * @related b.atomicFile.writeSync, b.atomicFile.openNoFollowSync
500
+ *
501
+ * Streaming sibling of `writeSync` for payloads too large to buffer in
502
+ * memory. Pipes a Readable `source` into a sibling temp file opened with
503
+ * `O_EXCL | O_NOFOLLOW` (the same exclusive, symlink-refusing create
504
+ * every atomic write uses), fsyncs, then atomically renames over
505
+ * `filepath` and fsyncs the parent directory. A plain
506
+ * `fs.createWriteStream(filepath)` instead follows a symlink an attacker
507
+ * pre-planted at `filepath` (CWE-59 arbitrary write) and leaves a
508
+ * half-written object at the canonical name if the source aborts
509
+ * mid-stream — this primitive does neither: the file appears at
510
+ * `filepath` only after the full stream has landed and synced.
511
+ *
512
+ * Enforces a byte ceiling while streaming (`maxBytes`, default 64 MiB) so
513
+ * an unbounded source cannot fill the disk; the partial temp is removed
514
+ * on overflow or any pipeline error.
515
+ *
516
+ * @opts
517
+ * fileMode: 0o600, // mode applied to the temp file (and thus the renamed final)
518
+ * maxBytes: 64 * 1024 * 1024, // refuse + clean up once the source exceeds this many bytes
519
+ * signal: undefined, // optional AbortSignal forwarded to the pipeline
520
+ *
521
+ * @example
522
+ * await b.atomicFile.writeStream(
523
+ * "/var/lib/blamejs/object",
524
+ * incomingRequestStream,
525
+ * { fileMode: 0o600, maxBytes: b.C.BYTES.gib(2) }
526
+ * );
527
+ * // → { bytesWritten: 12345 }
528
+ */
529
+ async function writeStream(filepath, source, opts) {
530
+ opts = Object.assign({}, DEFAULTS, opts || {});
531
+ if (!source || typeof source.pipe !== "function") {
532
+ throw new AtomicFileError(
533
+ "writeStream: source must be a Readable stream", "atomic-file/invalid-source");
534
+ }
535
+ var maxBytes = opts.maxBytes;
536
+
537
+ var dir = nodePath.dirname(filepath);
538
+ if (!nodeFs.existsSync(dir)) nodeFs.mkdirSync(dir, { recursive: true, mode: 0o700 });
539
+
540
+ var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
541
+ var fd = _openExclTemp(tmpPath, opts.fileMode);
542
+ var fileStream = nodeFs.createWriteStream(null, { fd: fd, autoClose: false });
543
+ var bytesWritten = 0;
544
+ var renamed = false;
545
+
546
+ // Cap the stream as it flows — an unbounded source must not fill the disk.
547
+ var counter = new nodeStream.Transform({
548
+ transform: function (chunk, _enc, cb) {
549
+ bytesWritten += chunk.length;
550
+ if (typeof maxBytes === "number" && bytesWritten > maxBytes) {
551
+ return cb(new AtomicFileError(
552
+ "writeStream: source exceeds maxBytes " + maxBytes, "atomic-file/too-large"));
553
+ }
554
+ cb(null, chunk);
555
+ },
556
+ });
557
+
558
+ try {
559
+ if (opts.signal) {
560
+ await streamPromises.pipeline(source, counter, fileStream, { signal: opts.signal });
561
+ } else {
562
+ await streamPromises.pipeline(source, counter, fileStream);
563
+ }
564
+ _fsync(fd);
565
+ nodeFs.closeSync(fd);
566
+ fd = -1;
567
+ _renameWithRetry(tmpPath, filepath);
568
+ renamed = true;
569
+ _fsyncDir(dir);
570
+ } finally {
571
+ if (fd >= 0) { try { nodeFs.closeSync(fd); } catch (_e) { /* already closed? */ } }
572
+ if (!renamed) {
573
+ // Source aborted, overflowed, or the rename failed — remove the temp so
574
+ // no half-written object survives at the canonical name.
575
+ try { nodeFs.unlinkSync(tmpPath); } catch (_e) { /* may not exist */ }
576
+ }
577
+ }
578
+
579
+ return { bytesWritten: bytesWritten };
580
+ }
581
+
582
+ /**
583
+ * @primitive b.atomicFile.writeExclSync
584
+ * @signature b.atomicFile.writeExclSync(filepath, data, opts?)
585
+ * @since 0.15.14
586
+ * @status stable
587
+ * @related b.atomicFile.writeSync, b.atomicFile.openNoFollowSync
588
+ *
589
+ * Exclusive, symlink-refusing write to `filepath` WITHOUT the atomic
590
+ * rename — for staged "write → fsync → verify → rename" flows where the
591
+ * caller must re-read and validate the written bytes before committing them
592
+ * over the live file (the vault seal/unseal round-trip re-reads the staged
593
+ * file and confirms it decrypts before renaming it into place). Clears any
594
+ * stale leftover at `filepath` first (an aborted prior run, or a planted
595
+ * symlink — `unlink` removes the LINK, never its target), then creates the
596
+ * file with `O_EXCL | O_NOFOLLOW`, so a symlink re-planted in the race
597
+ * window fails the open closed instead of being followed (CWE-59 / CWE-377).
598
+ * fsyncs the data before returning. For an ordinary write-and-replace use
599
+ * `writeSync`, which renames atomically; reach for this only when a
600
+ * verify-before-commit step sits between the write and the rename.
601
+ *
602
+ * @opts
603
+ * fileMode: 0o600, // mode applied to the created file
604
+ *
605
+ * @example
606
+ * b.atomicFile.writeExclSync(stagingPath, bytes, { fileMode: 0o600 });
607
+ * // re-read + verify stagingPath, then:
608
+ * b.atomicFile.renameWithRetry(stagingPath, finalPath);
609
+ */
610
+ function writeExclSync(filepath, data, opts) {
611
+ opts = Object.assign({}, DEFAULTS, opts || {});
612
+ var buf = safeBuffer.toBuffer(data, {
613
+ errorClass: AtomicFileError,
614
+ typeCode: "atomic-file/invalid-data",
615
+ typeMessage: "data must be Buffer, Uint8Array, or string",
616
+ });
617
+ // Clear any stale leftover so the exclusive create can proceed; unlink
618
+ // removes a planted symlink itself (not its target), and the O_EXCL open
619
+ // then fails closed if anything re-appears at the path in the race window.
620
+ try { nodeFs.unlinkSync(filepath); } catch (_e) { /* nothing to clear */ }
621
+ var fd = _openExclTemp(filepath, opts.fileMode);
622
+ try {
623
+ var pos = 0;
624
+ while (pos < buf.length) {
625
+ pos += nodeFs.writeSync(fd, buf, pos, buf.length - pos, null);
626
+ }
627
+ _fsync(fd);
628
+ } finally {
629
+ try { nodeFs.closeSync(fd); } catch (_e) { /* already closed? */ }
630
+ }
631
+ return { bytesWritten: buf.length };
632
+ }
633
+
488
634
  /**
489
635
  * @primitive b.atomicFile.cleanOrphans
490
636
  * @signature b.atomicFile.cleanOrphans(filepath, opts)
@@ -730,6 +876,7 @@ function _validateMaxBytes(maxBytes) {
730
876
  * expectedHash: string, // SHA3-512 the content must match (default: none)
731
877
  * encoding: string, // decode to a string (default: return a Buffer)
732
878
  * allowShortRead: boolean, // slice to the bytes read instead of throwing (default: false)
879
+ * withStat: boolean, // return { bytes, stat } — stat of the bound fd (mode/uid/gid/size/ino/nlink/mtimeMs), TOCTOU-free
733
880
  * errorFor: Function, // (kind, detail) => Error|undefined; kinds: enoent / symlink / too-large / toctou / short-read / integrity
734
881
  *
735
882
  * @example
@@ -737,6 +884,11 @@ function _validateMaxBytes(maxBytes) {
737
884
  * maxBytes: b.constants.BYTES.mib(1),
738
885
  * encoding: "utf8",
739
886
  * });
887
+ *
888
+ * // Assert mode + owner on the exact inode the bytes came from (no re-stat):
889
+ * var r = b.atomicFile.fdSafeReadSync("/etc/app/secret", { withStat: true });
890
+ * if ((r.stat.mode & 0o077) !== 0) throw new Error("secret is group/other-readable");
891
+ * // r.bytes is the Buffer (or string under `encoding`)
740
892
  */
741
893
  function fdSafeReadSync(filepath, opts) {
742
894
  opts = opts || {};
@@ -806,7 +958,26 @@ function fdSafeReadSync(filepath, opts) {
806
958
  throw errorFor("integrity", { expected: opts.expectedHash, actual: actual });
807
959
  }
808
960
  }
809
- return opts.encoding ? buf.toString(opts.encoding) : buf;
961
+ var content = opts.encoding ? buf.toString(opts.encoding) : buf;
962
+ // withStat: return the fstat of the SAME bound fd alongside the bytes, so a
963
+ // caller that needs the mode / owner (e.g. to assert 0o600 + owned-by-me on a
964
+ // secrets file) reads it TOCTOU-free — the stat describes the exact inode the
965
+ // bytes came from, not a re-stat that an attacker could swap underneath.
966
+ if (opts.withStat) {
967
+ return {
968
+ bytes: content,
969
+ stat: {
970
+ mode: fstat.mode,
971
+ uid: fstat.uid,
972
+ gid: fstat.gid,
973
+ size: fstat.size,
974
+ ino: fstat.ino,
975
+ nlink: fstat.nlink,
976
+ mtimeMs: fstat.mtimeMs,
977
+ },
978
+ };
979
+ }
980
+ return content;
810
981
  }
811
982
 
812
983
  // Atomic-file's own reads route through fdSafeReadSync with an errorFor
@@ -1137,12 +1308,43 @@ function listDir(dir, opts) {
1137
1308
  return out;
1138
1309
  }
1139
1310
 
1311
+ /**
1312
+ * @primitive b.atomicFile.openNoFollowSync
1313
+ * @signature b.atomicFile.openNoFollowSync(filepath, mode?)
1314
+ * @since 0.15.14
1315
+ * @status stable
1316
+ * @related b.atomicFile.fdSafeReadSync, b.atomicFile.readSync
1317
+ *
1318
+ * Open a path read-only with `O_NOFOLLOW` so a symlink at the final path
1319
+ * component is refused (`ELOOP`) instead of followed — the streaming-read
1320
+ * counterpart to `fdSafeReadSync` for callers that must `fs.createReadStream`
1321
+ * (range serving, SRI/ETag hashing, large-object download) and cannot buffer
1322
+ * the whole file. Stream from the returned fd: `fs.createReadStream(path, { fd
1323
+ * })`. Defends a post-confinement symlink swap (CWE-22 / CWE-367) on
1324
+ * request-reachable static-serve and object-store read paths, where a lexical
1325
+ * `_assertInsideRoot` check alone leaves a swap window between the check and the
1326
+ * open. `O_NOFOLLOW` is POSIX-only; on platforms without it the flag is 0 (a
1327
+ * plain `O_RDONLY` open) — Windows symlink semantics differ and are out of
1328
+ * scope. Throws the raw `openSync` error (caller maps `ELOOP` / `ENOENT`).
1329
+ *
1330
+ * @example
1331
+ * var fd = b.atomicFile.openNoFollowSync(absPath);
1332
+ * var stream = fs.createReadStream(absPath, { fd: fd }); // autoClose closes fd
1333
+ */
1334
+ function openNoFollowSync(filepath, mode) {
1335
+ var flags = nodeFs.constants.O_RDONLY | (nodeFs.constants.O_NOFOLLOW || 0);
1336
+ return nodeFs.openSync(filepath, flags, mode === undefined ? 0o600 : mode);
1337
+ }
1338
+
1140
1339
  module.exports = {
1141
1340
  write: write,
1142
1341
  writeSync: writeSync,
1342
+ writeStream: writeStream,
1343
+ writeExclSync: writeExclSync,
1143
1344
  read: read,
1144
1345
  readSync: readSync,
1145
1346
  fdSafeReadSync: fdSafeReadSync,
1347
+ openNoFollowSync: openNoFollowSync,
1146
1348
  writeJson: writeJson,
1147
1349
  readJson: readJson,
1148
1350
  copy: copy,
@@ -39,6 +39,7 @@ var clusterStorage = require("./cluster-storage");
39
39
  var frameworkSchema = require("./framework-schema");
40
40
  var sql = require("./sql");
41
41
  var safeSql = require("./safe-sql");
42
+ var safeBuffer = require("./safe-buffer");
42
43
  var { sha3Hash } = require("./crypto");
43
44
 
44
45
  // b.sql opts for the chain read SQL these primitives compose. The reader
@@ -219,6 +220,8 @@ async function getChainTip(queryOneAsync, tableName, opts) {
219
220
  * maxRows: number, // stop after N rows per (sub-)chain (default: walk every row)
220
221
  * chainKey: string, // partition column — verify each sub-chain independently
221
222
  * maxChains: number, // max partitions to verify under chainKey (default 100000; fails closed)
223
+ * from: number, // single-chain only: verify rows with monotonicCounter >= from, anchored at the predecessor's rowHash (incremental verify after a known-good checkpoint)
224
+ * to: number, // single-chain only: verify rows with monotonicCounter <= to
222
225
  *
223
226
  * @example
224
227
  * async function queryAll(sql) { return await myDriver.query(sql); }
@@ -275,7 +278,12 @@ function _walkRows(rows, tableName, startPrevHash, opts) {
275
278
 
276
279
  if (opts.maxRows && i >= opts.maxRows - 1) break;
277
280
  }
278
- return { ok: true, table: tableName, rowsVerified: rows.length, lastHash: prevHash };
281
+ // Report the count ACTUALLY walked, not rows.length under maxRows the walk
282
+ // stops early, so rows.length would over-report coverage (a caller reading
283
+ // rowsVerified to judge how much of the chain was checked must see the real
284
+ // number, not be told the whole table verified when only maxRows did).
285
+ var verifiedCount = opts.maxRows ? Math.min(rows.length, opts.maxRows) : rows.length;
286
+ return { ok: true, table: tableName, rowsVerified: verifiedCount, lastHash: prevHash };
279
287
  }
280
288
 
281
289
  async function verifyChain(queryAllAsync, tableName, opts) {
@@ -349,11 +357,30 @@ async function verifyChain(queryAllAsync, tableName, opts) {
349
357
  anchor = [];
350
358
  }
351
359
  if (Array.isArray(anchor) && anchor.length > 0) {
352
- prevHash = anchor[0].lastPurgedRowHash;
353
- skipBeforeCounter = Number(anchor[0].lastPurgedCounter);
360
+ var aHash = anchor[0].lastPurgedRowHash;
361
+ var aCounter = Number(anchor[0].lastPurgedCounter);
362
+ // A corrupted / tampered purge anchor (non-hex lastPurgedRowHash or a
363
+ // non-numeric lastPurgedCounter) must fail CLOSED with a clear reason.
364
+ // Passing a garbage prevHash into _walkRows → computeRowHash would THROW
365
+ // ("prevHash must be a 128-char hex"), turning a defensive verify into an
366
+ // uncaught exception; a NaN counter would skip nothing and surface as an
367
+ // opaque chain-break. Detect it here and return { ok:false }.
368
+ if (!safeBuffer.isHex(aHash, SHA3_512_HEX_LEN) || !isFinite(aCounter) || aCounter < 0) {
369
+ return { ok: false, table: tableName, rowsVerified: 0, reason: "corrupted purge anchor" };
370
+ }
371
+ prevHash = aHash;
372
+ skipBeforeCounter = aCounter;
354
373
  }
355
374
  }
356
375
 
376
+ // Incremental verify (b.audit.verify { from, to }): verify only rows whose
377
+ // monotonicCounter is in [from, to]. `from` must anchor on the rowHash of the
378
+ // row immediately BEFORE it, so the scoped walk chains correctly — otherwise
379
+ // the first in-range row's prevHash (= the predecessor's rowHash) wouldn't
380
+ // match ZERO_HASH and a good chain would falsely report a break.
381
+ var fromCounter = (opts.from != null && isFinite(Number(opts.from))) ? Number(opts.from) : null;
382
+ var toCounter = (opts.to != null && isFinite(Number(opts.to))) ? Number(opts.to) : null;
383
+
357
384
  var rowsBuilt = sql.select(tableName, _sqlOpts())
358
385
  .orderBy("monotonicCounter", "asc")
359
386
  .toSql();
@@ -364,9 +391,31 @@ async function verifyChain(queryAllAsync, tableName, opts) {
364
391
  // verified on SQLite without this. coerceRow makes the recompute
365
392
  // type-stable across backends (no-op on already-numeric SQLite rows).
366
393
  rows = frameworkSchema.coerceRows(rows);
367
- if (skipBeforeCounter > 0) {
394
+
395
+ // Resolve the incremental-verify anchor: the highest row strictly below
396
+ // `from` (derived from the already-read rows, no extra query). Raise
397
+ // skipBeforeCounter to it and adopt its rowHash as the chain anchor.
398
+ if (fromCounter != null && fromCounter > skipBeforeCounter + 1) {
399
+ var pred = null;
400
+ for (var pi = 0; pi < rows.length; pi++) {
401
+ var pc = Number(rows[pi].monotonicCounter);
402
+ if (pc < fromCounter && pc > skipBeforeCounter) pred = rows[pi]; else if (pc >= fromCounter) break;
403
+ }
404
+ if (pred) {
405
+ if (!safeBuffer.isHex(pred.rowHash, SHA3_512_HEX_LEN)) {
406
+ return { ok: false, table: tableName, rowsVerified: 0, reason: "incremental-verify anchor row has a corrupt rowHash" };
407
+ }
408
+ prevHash = pred.rowHash;
409
+ skipBeforeCounter = Math.max(skipBeforeCounter, Number(pred.monotonicCounter));
410
+ }
411
+ }
412
+
413
+ if (skipBeforeCounter > 0 || toCounter != null) {
368
414
  rows = rows.filter(function (r) {
369
- return Number(r.monotonicCounter) > skipBeforeCounter;
415
+ var c = Number(r.monotonicCounter);
416
+ if (c <= skipBeforeCounter) return false;
417
+ if (toCounter != null && c > toCounter) return false;
418
+ return true;
370
419
  });
371
420
  }
372
421
 
@@ -83,9 +83,15 @@ function _defaultClassify(event) {
83
83
  }
84
84
 
85
85
  function _severityAtLeast(severity, threshold) {
86
- var sIdx = SEVERITY_ORDER.indexOf(severity);
87
86
  var tIdx = SEVERITY_ORDER.indexOf(threshold);
88
- if (sIdx === -1 || tIdx === -1) return false;
87
+ if (tIdx === -1) return false; // unknown threshold (validated at config)
88
+ var sIdx = SEVERITY_ORDER.indexOf(severity);
89
+ // An UNKNOWN event severity — e.g. a custom classify(event) returning an
90
+ // unexpected value — must NOT silently drop the event from the review. Fail
91
+ // SAFE: treat it as meeting the threshold so the operator still sees the
92
+ // event (and notices their classify mis-returned) rather than missing a
93
+ // flagged event.
94
+ if (sIdx === -1) return true;
89
95
  return sIdx >= tIdx;
90
96
  }
91
97
 
@@ -247,6 +253,10 @@ function create(opts) {
247
253
  from: fromMs,
248
254
  to: startedAt,
249
255
  limit: queryLimit,
256
+ // Newest-first: if the window holds more than queryLimit events, keep
257
+ // the MOST RECENT (the actionable ones) — an ascending+limit query would
258
+ // keep the oldest and silently drop the newest from the review.
259
+ order: "desc",
250
260
  });
251
261
  } catch (e) {
252
262
  _emit("audit.daily_review.failed", {
package/lib/audit-sign.js CHANGED
@@ -59,6 +59,7 @@
59
59
  * SLH-DSA-SHAKE-256f post-quantum signature for audit-chain checkpoints.
60
60
  */
61
61
  var nodeFs = require("node:fs");
62
+ var numericBounds = require("./numeric-bounds");
62
63
  var nodePath = require("node:path");
63
64
  var nodeCrypto = require("node:crypto");
64
65
  var atomicFile = require("./atomic-file");
@@ -835,7 +836,7 @@ function _normalizeTip(tip, fnLabel) {
835
836
  "auditSign." + fnLabel + ": tip must be an object { counter, tipHash }");
836
837
  }
837
838
  var counter = tip.counter;
838
- if (typeof counter !== "number" || !isFinite(counter) || counter < 0 || Math.floor(counter) !== counter) {
839
+ if (!numericBounds.isNonNegativeFiniteInt(counter)) {
839
840
  throw _err("ANCHOR_BAD_COUNTER",
840
841
  "auditSign." + fnLabel + ": tip.counter must be a non-negative integer (got: " + counter + ")");
841
842
  }