@blamejs/core 0.17.23 → 0.18.0

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/lib/outbox.js CHANGED
@@ -102,6 +102,28 @@ function _validateTableName(name) {
102
102
  return safeSql.quoteIdentifier(name, undefined, { allowReserved: true }); // parity with b.db.from()
103
103
  }
104
104
 
105
+ // The outbox is always operator-supplied `table` executed against a concrete
106
+ // externalDb handle (never b.clusterStorage — nothing here rewrites bare table
107
+ // names), so every b.sql builder quotes the table name by construction
108
+ // (quoteName: true). A quoted identifier is what makes a reserved-word or
109
+ // case-sensitive operator table (a table literally named "from") emit valid
110
+ // SQL — parity with b.db.from()'s allowReserved. Same posture as b.mailStore
111
+ // against its concrete sqlite handle.
112
+ function _tableOpts(dialect) { return { dialect: dialect, quoteName: true }; }
113
+
114
+ // PostgreSQL folds UNQUOTED identifiers to lowercase, so every pre-0.18 deployment
115
+ // (whose DDL and queries emitted the table name unquoted) already has a
116
+ // lowercase-folded table. Now that the name is always quoted (quoteName above), a
117
+ // mixed-case config like table: "MyOutbox" would target a NEW case-sensitive
118
+ // "MyOutbox" table and strand the rows in the original folded "myoutbox". Fold to
119
+ // lowercase on postgres BEFORE quoting so a legacy config keeps resolving to its
120
+ // existing table; reserved words (already lowercase) still quote correctly. sqlite
121
+ // is case-insensitive (no fold); MySQL's folding is server-configured
122
+ // (lower_case_table_names) and applied equally to the pre-quote name, so leave it.
123
+ function _foldTableForDialect(name, dialect) {
124
+ return dialect === "postgres" ? String(name).toLowerCase() : name;
125
+ }
126
+
105
127
  // Map the operator backend's dialect tag to the b.sql dialect vocabulary.
106
128
  // b.sql's terminal toExternalSql() then emits $1..$N for postgres and `?`
107
129
  // for sqlite / mysql, matching what the operator-supplied driver expects.
@@ -182,6 +204,8 @@ function _toDebeziumEnvelope(rawEvent, opts) {
182
204
  before: (payload && payload.before) || null,
183
205
  after: (payload && payload.after !== undefined) ? payload.after : payload,
184
206
  source: {
207
+ // connectorName/connectorVersion are defaulted to non-empty strings in create(), so these || fallbacks are unreachable.
208
+ /* c8 ignore next 2 */
185
209
  connector: opts.connectorName || "blamejs",
186
210
  version: opts.connectorVersion || DEFAULT_DEBEZIUM_CONNECTOR_VERSION,
187
211
  db: opts.dbName || null,
@@ -300,6 +324,10 @@ function create(opts) {
300
324
 
301
325
  var auditOn = opts.audit !== false;
302
326
  var externalDb = opts.externalDb;
327
+ // Fold the operator table name for the backend's identifier rules BEFORE it is
328
+ // quoted downstream, so a legacy mixed-case config still targets its existing
329
+ // (folded) table on postgres. Reassign a clone so the caller's opts is untouched.
330
+ opts = Object.assign({}, opts, { table: _foldTableForDialect(opts.table, _sqlDialect(externalDb)) });
303
331
  var publisher = opts.publisher;
304
332
  var envelope = opts.envelope || "raw";
305
333
  if (envelope !== "raw" && envelope !== "debezium") {
@@ -365,7 +393,7 @@ function create(opts) {
365
393
  // enqueued_at and next_attempt_at both take the same publisher-clock
366
394
  // moment; b.sql binds it as two separate `?` so the placeholder/param
367
395
  // parity gate holds (no $5-reused-twice shorthand).
368
- var stmt = sql.insert(opts.table, { dialect: _sqlDialect(externalDb) })
396
+ var stmt = sql.insert(opts.table, _tableOpts(_sqlDialect(externalDb)))
369
397
  .values({
370
398
  topic: event.topic,
371
399
  payload: payloadJson,
@@ -405,7 +433,7 @@ function create(opts) {
405
433
  { name: "attempts", type: "INTEGER", notNull: true, default: 0 },
406
434
  { name: "last_error", type: "TEXT" },
407
435
  { name: "status", type: "VARCHAR(16)", notNull: true, default: "pending" },
408
- ], { dialect: dialect }), dialect);
436
+ ], _tableOpts(dialect)), dialect);
409
437
  // Index for the publisher's claim path (scans status='pending' ORDER BY
410
438
  // next_attempt_at). sqlite/postgres support a partial index (WHERE on
411
439
  // CREATE INDEX) on next_attempt_at; MySQL does NOT — a WHERE there is a
@@ -414,7 +442,7 @@ function create(opts) {
414
442
  // equality+range scan. The 'pending' literal is a builder-emitted static
415
443
  // predicate, opted in via allowLiterals.
416
444
  var idxCols = dialect === "mysql" ? ["status", "next_attempt_at"] : ["next_attempt_at"];
417
- var idxOpts = { dialect: dialect };
445
+ var idxOpts = _tableOpts(dialect);
418
446
  if (dialect !== "mysql") idxOpts.where = "status = 'pending'";
419
447
  var idx = sql.toExternalSql(sql.createIndex(opts.table + "_pending_idx", opts.table,
420
448
  idxCols, idxOpts), dialect);
@@ -428,7 +456,7 @@ function create(opts) {
428
456
  // claimed_at the reaper can't tell a stranded claim from a live one.
429
457
  try {
430
458
  var alter = sql.toExternalSql(sql.alterTable(opts.table,
431
- { addColumn: { name: "claimed_at", type: tsType } }, { dialect: dialect }), dialect);
459
+ { addColumn: { name: "claimed_at", type: tsType } }, _tableOpts(dialect)), dialect);
432
460
  await target.query(alter.sql, alter.params);
433
461
  } catch (_e) { /* column already present — idempotent add */ }
434
462
  }
@@ -466,7 +494,7 @@ function create(opts) {
466
494
  var nowExpr = _utcNowExpr(externalDb);
467
495
  // status='pending' is a builder-emitted static predicate (opted in
468
496
  // via allowLiterals); next_attempt_at <= ? + the LIMIT both bind.
469
- var selectBuilder = sql.select(opts.table, { dialect: dialect })
497
+ var selectBuilder = sql.select(opts.table, _tableOpts(dialect))
470
498
  .columns(CLAIM_COLS)
471
499
  .whereRaw("status = 'pending'", [], { allowLiterals: true })
472
500
  .whereRaw("next_attempt_at <= ?", [nowExpr])
@@ -492,7 +520,7 @@ function create(opts) {
492
520
  // Postgres/MySQL: row lock held; whereInArray emits `id = ANY(?)`
493
521
  // on postgres (the whole id set as one bound array) / expanded
494
522
  // `IN (?, ?, ...)` on mysql.
495
- var claimUpdate = sql.update(opts.table, { dialect: dialect })
523
+ var claimUpdate = sql.update(opts.table, _tableOpts(dialect))
496
524
  .set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
497
525
  .whereInArray("id", ids)
498
526
  .toExternalSql(dialect);
@@ -504,13 +532,13 @@ function create(opts) {
504
532
  // update we re-read the in-flight rows we own; rows that
505
533
  // another publisher beat us to are skipped. whereInArray expands
506
534
  // to an `IN (?, ?, ...)` placeholder list on sqlite.
507
- var markUpdate = sql.update(opts.table, { dialect: dialect })
535
+ var markUpdate = sql.update(opts.table, _tableOpts(dialect))
508
536
  .set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
509
537
  .whereRaw("status = 'pending'", [], { allowLiterals: true })
510
538
  .whereInArray("id", ids)
511
539
  .toExternalSql(dialect);
512
540
  await xdb.query(markUpdate.sql, markUpdate.params);
513
- var afterSelect = sql.select(opts.table, { dialect: dialect })
541
+ var afterSelect = sql.select(opts.table, _tableOpts(dialect))
514
542
  .columns(CLAIM_COLS)
515
543
  .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
516
544
  .whereInArray("id", ids)
@@ -544,7 +572,7 @@ function create(opts) {
544
572
  async function _reapStaleInflight() {
545
573
  var dialect = _sqlDialect(externalDb);
546
574
  var cutoff = new Date(Date.now() - claimReclaimMs);
547
- var stmt = sql.update(opts.table, { dialect: dialect })
575
+ var stmt = sql.update(opts.table, _tableOpts(dialect))
548
576
  .set({ status: "pending", claimed_at: null })
549
577
  .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
550
578
  .whereRaw("(claimed_at IS NULL OR claimed_at <= ?)", [cutoff])
@@ -555,7 +583,7 @@ function create(opts) {
555
583
 
556
584
  async function _markPublished(id) {
557
585
  var dialect = _sqlDialect(externalDb);
558
- var stmt = sql.update(opts.table, { dialect: dialect })
586
+ var stmt = sql.update(opts.table, _tableOpts(dialect))
559
587
  .set({ status: "published", published_at: _utcNowExpr(externalDb) })
560
588
  .where("id", id)
561
589
  .toExternalSql(dialect);
@@ -565,7 +593,7 @@ function create(opts) {
565
593
  async function _markRetry(id, attempts, errMsg) {
566
594
  var dialect = _sqlDialect(externalDb);
567
595
  var nextAt = new Date(Date.now() + _backoffMs(attempts + 1));
568
- var stmt = sql.update(opts.table, { dialect: dialect })
596
+ var stmt = sql.update(opts.table, _tableOpts(dialect))
569
597
  .set({
570
598
  status: "pending",
571
599
  attempts: attempts + 1,
@@ -579,7 +607,7 @@ function create(opts) {
579
607
 
580
608
  async function _markDead(id, attempts, errMsg) {
581
609
  var dialect = _sqlDialect(externalDb);
582
- var stmt = sql.update(opts.table, { dialect: dialect })
610
+ var stmt = sql.update(opts.table, _tableOpts(dialect))
583
611
  .set({
584
612
  status: "dead",
585
613
  attempts: attempts + 1,
@@ -654,6 +682,8 @@ function create(opts) {
654
682
  workerHandle = null;
655
683
  }
656
684
  if (inFlight) {
685
+ // inFlight is _processOnce().catch(...).finally(...), which never rejects, so this drop-silent catch cannot fire.
686
+ /* c8 ignore next */
657
687
  try { await inFlight; } catch (_e) { /* drop-silent */ }
658
688
  }
659
689
  _emitAudit("system.outbox.stopped", "success", { name: name });
@@ -664,7 +694,7 @@ function create(opts) {
664
694
  // status is a fixed builder-internal literal ('pending' / 'dead'),
665
695
  // never operator input; opted in via allowLiterals. COUNT(*) AS n is
666
696
  // the count aggregate with an alias.
667
- var stmt = sql.select(opts.table, { dialect: dialect })
697
+ var stmt = sql.select(opts.table, _tableOpts(dialect))
668
698
  .count("*", "n")
669
699
  .whereRaw("status = '" + status + "'", [], { allowLiterals: true })
670
700
  .toExternalSql(dialect);
package/lib/redact.js CHANGED
@@ -74,6 +74,8 @@ var SENSITIVE_FIELDS = [
74
74
  // matches the pattern. Used as a fallback when field-name redaction misses.
75
75
  function _luhnCheck(num) {
76
76
  var digits = num.replace(/\D/g, "");
77
+ // unreachable: every _luhnCheck caller pre-gates digit length to 13-19
78
+ /* c8 ignore next */
77
79
  if (digits.length < 13 || digits.length > 19) return false;
78
80
  var sum = 0;
79
81
  var alt = false;
@@ -90,6 +92,8 @@ var VALUE_DETECTORS = [
90
92
  {
91
93
  name: "credit-card",
92
94
  test: function (v) {
95
+ // unreachable: _redactValue only invokes value detectors on strings
96
+ /* c8 ignore next */
93
97
  if (typeof v !== "string") return false;
94
98
  var digits = v.replace(/\s|-/g, "");
95
99
  if (!/^\d{13,19}$/.test(digits)) return false;
@@ -231,6 +235,8 @@ function _isSensitiveFieldName(key) {
231
235
  }
232
236
 
233
237
  function _redactValue(value) {
238
+ // unreachable: _redact only calls _redactValue inside its string branch
239
+ /* c8 ignore next */
234
240
  if (typeof value !== "string") return value;
235
241
  var allDetectors = VALUE_DETECTORS.concat(customDetectors);
236
242
  for (var i = 0; i < allDetectors.length; i++) {
@@ -396,7 +402,9 @@ var CLASSIFIER_PATTERNS = Object.freeze({
396
402
  var c = rearranged.charCodeAt(i);
397
403
  if (c >= 48 && c <= 57) num += rearranged.charAt(i); // ASCII '0'..'9' codepoint range
398
404
  else if (c >= 65 && c <= 90) num += String(c - 55);
405
+ /* c8 ignore start */ // unreachable: the regex gate above admits only [A-Z0-9]
399
406
  else return false;
407
+ /* c8 ignore stop */
400
408
  }
401
409
  // Long-integer mod 97 in chunks
402
410
  var rem = 0;
@@ -600,6 +608,8 @@ function classifyDefaults(opts) {
600
608
  if (Buffer.isBuffer(bodyVal) || bodyVal instanceof Uint8Array) {
601
609
  var asText;
602
610
  try { asText = Buffer.from(bodyVal).toString("utf8"); }
611
+ // unreachable drop-silent: Buffer.toString only throws on >512MiB payloads
612
+ /* c8 ignore next */
603
613
  catch (_e) { asText = ""; }
604
614
  var scannedText = _scanString(asText, "body");
605
615
  redactedBody = scannedText === asText ? bodyVal : Buffer.from(scannedText, "utf8");
@@ -646,6 +656,8 @@ function _emitDlp(action, outcome, metadata) {
646
656
  audit().safeEmit({
647
657
  action: action,
648
658
  outcome: outcome,
659
+ // unreachable: every _emitDlp caller passes a metadata object literal
660
+ /* c8 ignore next */
649
661
  metadata: metadata || {},
650
662
  });
651
663
  } catch (_e) { /* drop-silent */ }
@@ -658,6 +670,8 @@ function _wrapClassifier(fn, where) {
658
670
  }
659
671
  return function safeClassify(input) {
660
672
  var v;
673
+ // unreachable: the wrapped classifier is only invoked internally with an object
674
+ /* c8 ignore next */
661
675
  try { v = fn(input || {}); }
662
676
  catch (e) {
663
677
  // Classifier threw — treat as refuse (fail-closed, since the
@@ -1069,8 +1083,9 @@ var TEXT_REDACT_MARKER = "[redacted]";
1069
1083
  * secret may be interpolated mid-sentence. Unlike `redact` (structured,
1070
1084
  * whole-value, anchored), this uses word-boundary fragment replacement so
1071
1085
  * "login failed: &lt;jwt&gt; for bob" keeps everything but the jwt. Detects PEM
1072
- * blocks, JWTs, AWS access keys, URL-userinfo passwords, bearer tokens,
1073
- * `key=secret` assignments, SSN/EIN, and Luhn-valid PANs. The high-entropy
1086
+ * blocks, JWTs, AWS access keys, vault-sealed ciphertext, URL-userinfo passwords
1087
+ * (including empty-username forms), bearer tokens, `key=secret` assignments,
1088
+ * SSN/EIN, and Luhn-valid PANs. The high-entropy
1074
1089
  * api-key-shape detector is deliberately excluded (on free text it eats
1075
1090
  * ordinary IDs / hashes / base64). Drop-safe: never throws (it runs on the
1076
1091
  * hot-path log-emit sink); on any error it returns a fully-masked marker rather
@@ -1089,12 +1104,28 @@ function redactText(str) {
1089
1104
  .replace(/\beyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,8192}\.[A-Za-z0-9_-]{1,4096}\b/g, TEXT_REDACT_MARKER)
1090
1105
  // AWS access-key IDs.
1091
1106
  .replace(/\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASCA)[A-Z0-9]{16}\b/g, TEXT_REDACT_MARKER)
1092
- // Credentials in a URL userinfo: scheme://user:secret@host.
1093
- .replace(/([a-z][a-z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:)[^\s@/]{1,256}(@)/gi, "$1" + TEXT_REDACT_MARKER + "$2")
1107
+ // Vault-sealed ciphertext (vault:v1:…) the structured meta path strips
1108
+ // these via the vault-sealed value detector (anchored to startsWith), so
1109
+ // an embedded one in a free-text message must not survive either.
1110
+ .replace(/\bvault:[A-Za-z0-9+/=_.:-]{3,8192}/g, TEXT_REDACT_MARKER)
1111
+ // Credentials in a URL userinfo: scheme://user:secret@host. The username
1112
+ // segment is optional ({0,…}) so an empty-username form (redis://:pw@host)
1113
+ // — which the meta path's connection-string detector catches — is scrubbed
1114
+ // too; a password segment before the '@' is what flags the credential.
1115
+ .replace(/([a-z][a-z0-9+.-]{0,32}:\/\/[^\s:@/]{0,256}:)[^\s@/]{1,256}(@)/gi, "$1" + TEXT_REDACT_MARKER + "$2")
1094
1116
  // Bearer tokens.
1095
1117
  .replace(/\b([Bb]earer\s{1,4})[A-Za-z0-9._~+/-]{8,4096}=*/g, "$1" + TEXT_REDACT_MARKER)
1096
- // key=secret / password: secret style assignments.
1097
- .replace(/\b((?:api[_-]?key|access[_-]?token|secret|password|passwd|pwd|token)\s{0,4}[=:]\s{0,4})[^\s&;"']{6,4096}/gi, "$1" + TEXT_REDACT_MARKER)
1118
+ // key=secret / password: secret style assignments. The specific
1119
+ // *_token forms precede bare `token` so an underscore-joined name
1120
+ // (id_token, refresh_token — where \btoken cannot match across the
1121
+ // underscore) is still redacted, matching the meta path's coverage.
1122
+ // The optional ["'] on BOTH sides of the delimiter lets the JSON / quoted
1123
+ // form ({"refresh_token":"opaque…"}) match: the key's closing quote sits
1124
+ // before the delimiter and the value's opening quote after it, and the value
1125
+ // class excludes quotes so it could otherwise neither reach the delimiter nor
1126
+ // begin at the value. The value still stops at the closing quote, leaving the
1127
+ // surrounding structure intact.
1128
+ .replace(/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|secret|password|passwd|pwd|token)["']?\s{0,4}[=:]\s{0,4}["']?)[^\s&;"']{6,4096}/gi, "$1" + TEXT_REDACT_MARKER)
1098
1129
  // SSN / EIN.
1099
1130
  .replace(/\b\d{3}-\d{2}-\d{4}\b/g, TEXT_REDACT_MARKER)
1100
1131
  .replace(/\b\d{2}-\d{7}\b/g, TEXT_REDACT_MARKER)
@@ -1103,6 +1134,8 @@ function redactText(str) {
1103
1134
  var inner = m.replace(/[\s-]/g, "");
1104
1135
  return (inner.length >= 13 && inner.length <= 19 && _luhnCheck(inner)) ? TEXT_REDACT_MARKER : m;
1105
1136
  });
1137
+ // unreachable drop-silent: the replace chain runs on a guaranteed string with static regexes
1138
+ /* c8 ignore next 3 */
1106
1139
  } catch (_e) {
1107
1140
  return TEXT_REDACT_MARKER;
1108
1141
  }
package/lib/safe-json.js CHANGED
@@ -170,7 +170,14 @@ function parse(input, opts) {
170
170
  try {
171
171
  parsed = JSON.parse(input, allowProto ? undefined : _stripProtoKeys);
172
172
  } catch (e) {
173
- throw new SafeJsonError("invalid JSON: " + e.message, "json/syntax");
173
+ // V8's SyntaxError.message echoes a window of the offending input
174
+ // ("Unexpected token 'M', \"{...secret...\"... is not valid JSON").
175
+ // At a trust boundary that snippet can carry secret-bearing bytes
176
+ // (a decrypted key, a token) straight into any log that records the
177
+ // thrown error (CWE-532). Keep only the non-secret facts: the stable
178
+ // code and, when V8 provides it, the numeric character offset.
179
+ var pos = (/position (\d+)/.exec(e && e.message) || [])[1];
180
+ throw new SafeJsonError("invalid JSON syntax" + (pos ? " at position " + pos : ""), "json/syntax");
174
181
  }
175
182
 
176
183
  _walkAndCheck(parsed, 0, maxDepth, allowProto, maxKeys);
@@ -313,6 +320,7 @@ function _walkAndCheck(value, depth, maxDepth, allowProto, maxKeys) {
313
320
  }
314
321
  if (!allowProto) {
315
322
  pick.POISONED_KEYS.forEach(function (k) {
323
+ /* c8 ignore next -- defensive second layer: when !allowProto the parse reviver already stripped every poisoned key before this walk, so the own-key delete is never reached from the public API */
316
324
  if (Object.prototype.hasOwnProperty.call(value, k)) delete value[k];
317
325
  });
318
326
  }