@blamejs/core 0.8.0 → 0.8.4

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 (63) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/audit-sign.js +1 -1
  3. package/lib/audit.js +62 -2
  4. package/lib/auth/jwt.js +13 -0
  5. package/lib/auth/lockout.js +16 -3
  6. package/lib/auth/oauth.js +15 -1
  7. package/lib/auth/password.js +22 -2
  8. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  9. package/lib/auth/sd-jwt-vc.js +7 -2
  10. package/lib/break-glass.js +53 -14
  11. package/lib/cache-redis.js +1 -1
  12. package/lib/cache.js +6 -1
  13. package/lib/cli.js +3 -3
  14. package/lib/cluster.js +24 -1
  15. package/lib/compliance-ai-act-logging.js +7 -3
  16. package/lib/compliance.js +10 -2
  17. package/lib/config-drift.js +2 -2
  18. package/lib/crypto-field.js +21 -1
  19. package/lib/crypto.js +82 -1
  20. package/lib/db.js +35 -4
  21. package/lib/dev.js +30 -3
  22. package/lib/dual-control.js +19 -1
  23. package/lib/external-db.js +10 -0
  24. package/lib/file-upload.js +30 -3
  25. package/lib/flag.js +1 -1
  26. package/lib/guard-all.js +33 -16
  27. package/lib/guard-csv.js +16 -2
  28. package/lib/guard-html.js +35 -0
  29. package/lib/guard-svg.js +20 -0
  30. package/lib/http-client.js +57 -11
  31. package/lib/inbox.js +34 -10
  32. package/lib/log-stream-syslog.js +8 -0
  33. package/lib/log-stream.js +1 -1
  34. package/lib/mail.js +40 -0
  35. package/lib/middleware/attach-user.js +25 -2
  36. package/lib/middleware/bearer-auth.js +71 -6
  37. package/lib/middleware/body-parser.js +13 -0
  38. package/lib/middleware/cors.js +10 -0
  39. package/lib/middleware/csrf-protect.js +34 -3
  40. package/lib/middleware/dpop.js +3 -3
  41. package/lib/middleware/host-allowlist.js +1 -1
  42. package/lib/middleware/require-aal.js +2 -2
  43. package/lib/middleware/trace-propagate.js +1 -1
  44. package/lib/mtls-ca.js +23 -29
  45. package/lib/mtls-engine-default.js +21 -1
  46. package/lib/network-tls.js +21 -6
  47. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  48. package/lib/observability-otlp-exporter.js +35 -2
  49. package/lib/outbox.js +3 -3
  50. package/lib/permissions.js +10 -1
  51. package/lib/pqc-agent.js +22 -1
  52. package/lib/pubsub.js +8 -4
  53. package/lib/redact.js +26 -1
  54. package/lib/retention.js +26 -0
  55. package/lib/router.js +1 -0
  56. package/lib/scheduler.js +57 -1
  57. package/lib/session.js +3 -3
  58. package/lib/ssrf-guard.js +19 -4
  59. package/lib/static.js +12 -0
  60. package/lib/totp.js +16 -0
  61. package/lib/ws-client.js +158 -9
  62. package/package.json +1 -1
  63. package/sbom.cyclonedx.json +6 -6
package/lib/crypto.js CHANGED
@@ -164,7 +164,26 @@ function verify(data, signature, publicKeyPem) {
164
164
  function encrypt(plaintext, publicKeys) {
165
165
  var mlkemPubPem = typeof publicKeys === "string" ? publicKeys : publicKeys.publicKey;
166
166
  var ecPubPem = typeof publicKeys === "string" ? null : publicKeys.ecPublicKey;
167
- if (!ecPubPem) return encryptMlkemOnly(plaintext, mlkemPubPem);
167
+ if (!ecPubPem) {
168
+ // Operator passed only an ML-KEM public key — silently dropping
169
+ // the P-384 hybrid leg means the operator's defense-in-depth
170
+ // posture (classical ECDH backstop on top of PQC KEM) is gone
171
+ // without any signal. Emit on next tick (crypto must not import
172
+ // audit synchronously — audit imports crypto for chain hashing).
173
+ // Operators who genuinely want KEM-only should call
174
+ // encryptMlkemOnly explicitly so this audit doesn't fire.
175
+ setImmediate(function () {
176
+ try {
177
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
178
+ auditMod.safeEmit({
179
+ action: "system.crypto.hybrid_disabled",
180
+ outcome: "success",
181
+ metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence" },
182
+ });
183
+ } catch (_e) { /* drop-silent — best-effort */ }
184
+ });
185
+ return encryptMlkemOnly(plaintext, mlkemPubPem);
186
+ }
168
187
 
169
188
  var mlkemPub = nodeCrypto.createPublicKey(mlkemPubPem);
170
189
  var kem = nodeCrypto.encapsulate(mlkemPub);
@@ -518,6 +537,65 @@ function decryptEnvelopeAsCertPeer(envelope, opts) {
518
537
  // Operator-audit accessor — exposes every supported KEM hybrid for
519
538
  // compliance audit visibility ("which envelopes does this deploy
520
539
  // accept on decrypt?").
540
+ // ---- Certificate fingerprint helpers ----
541
+ //
542
+ // Operators pinning peer-cert fingerprints (mtls bootstrap, webhook
543
+ // verification, certificate transparency cross-checks) want a stable
544
+ // SHA3-512 hash of the DER bytes plus a colon-separated hex form that
545
+ // matches what most operator tooling renders for X.509 fingerprints.
546
+ // hashCertFingerprint accepts either a Buffer (DER) or a PEM string;
547
+ // if PEM, the BEGIN/END envelope is stripped and the base64 body is
548
+ // decoded before hashing. The hash is the framework's standard SHA3-
549
+ // 512 (not SHA-256 — operators using OpenSSL's `-sha256` defaults can
550
+ // keep their own SHA-256 hashes, this primitive is the framework-
551
+ // canonical form). Returns { hex, colon } so callers can compare
552
+ // against either rendering.
553
+ function _pemToDer(pemOrDer) {
554
+ if (Buffer.isBuffer(pemOrDer)) return pemOrDer;
555
+ if (typeof pemOrDer !== "string") {
556
+ throw new TypeError("crypto.hashCertFingerprint: input must be a Buffer (DER) or a PEM-encoded string");
557
+ }
558
+ var match = pemOrDer.match(/-----BEGIN [A-Z0-9 ]+-----([\s\S]+?)-----END [A-Z0-9 ]+-----/);
559
+ if (!match) {
560
+ throw new TypeError("crypto.hashCertFingerprint: PEM input lacks BEGIN/END markers");
561
+ }
562
+ return Buffer.from(match[1].replace(/\s+/g, ""), "base64");
563
+ }
564
+ function hashCertFingerprint(pemOrDer) {
565
+ var der = _pemToDer(pemOrDer);
566
+ var digest = hash(der, "sha3-512");
567
+ var hex = digest.toString("hex");
568
+ // Colon-separated, uppercase — matches openssl x509 -fingerprint
569
+ // output style (which is SHA-1 by default, but the rendering shape
570
+ // operators expect is the same).
571
+ var colon = hex.toUpperCase().match(/.{2}/g).join(":");
572
+ return { hex: hex, colon: colon };
573
+ }
574
+ // Compares a peer's PEM/DER cert against an allowlist of pinned
575
+ // fingerprints. Allowlist entries may be the colon form, the lower-
576
+ // case hex form, or both — every comparison runs through
577
+ // timingSafeEqual to avoid leaking which entry matched.
578
+ function isCertRevoked(pemOrDer, denyList) {
579
+ if (!Array.isArray(denyList)) {
580
+ throw new TypeError("crypto.isCertRevoked: denyList must be an array of fingerprint strings");
581
+ }
582
+ var fp = hashCertFingerprint(pemOrDer);
583
+ var fpHex = Buffer.from(fp.hex, "hex");
584
+ var fpColon = Buffer.from(fp.colon);
585
+ for (var i = 0; i < denyList.length; i++) {
586
+ var entry = denyList[i];
587
+ if (typeof entry !== "string" || entry.length === 0) continue;
588
+ var normalized = entry.indexOf(":") !== -1 ? entry.toUpperCase() : entry.toLowerCase();
589
+ var normalizedBuf = entry.indexOf(":") !== -1 ? Buffer.from(normalized) : Buffer.from(normalized, "hex");
590
+ var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
591
+ if (normalizedBuf.length === compareBuf.length &&
592
+ nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
593
+ return true;
594
+ }
595
+ }
596
+ return false;
597
+ }
598
+
521
599
  var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
522
600
  { id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
523
601
  { id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
@@ -532,6 +610,9 @@ module.exports = {
532
610
  kdf: kdf,
533
611
  // Comparison
534
612
  timingSafeEqual: timingSafeEqual,
613
+ // Cert fingerprint helpers
614
+ hashCertFingerprint: hashCertFingerprint,
615
+ isCertRevoked: isCertRevoked,
535
616
  // Random
536
617
  generateBytes: generateBytes,
537
618
  generateToken: generateToken,
package/lib/db.js CHANGED
@@ -585,7 +585,21 @@ function decryptToTmp() {
585
585
  }
586
586
  var packed = fs.readFileSync(encPath);
587
587
  if (packed.length < 26) return; // too short to be a valid envelope
588
- atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
588
+ // AAD binds the envelope to this deployment's data dir so two
589
+ // installs sharing the same operator passphrase can't swap each
590
+ // other's db.enc files. Backwards-compat: if the AAD-bound decrypt
591
+ // fails, retry without AAD for envelopes written by pre-AAD
592
+ // versions (one-release transition window).
593
+ var aad = _dbEncAad(dataDir);
594
+ try {
595
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey, aad));
596
+ } catch (_e) {
597
+ atomicFile.writeSync(dbPath, decryptPacked(packed, encKey));
598
+ }
599
+ }
600
+
601
+ function _dbEncAad(dir) {
602
+ return Buffer.from("blamejs.db-enc.v1\0" + (dir || ""), "utf8");
589
603
  }
590
604
 
591
605
  function encryptToDisk() {
@@ -593,7 +607,7 @@ function encryptToDisk() {
593
607
  // Force WAL checkpoint so the .db file holds all committed transactions.
594
608
  try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
595
609
  if (!fs.existsSync(dbPath)) return;
596
- atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey));
610
+ atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
597
611
  }
598
612
 
599
613
  // Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
@@ -691,6 +705,23 @@ async function init(opts) {
691
705
  // dominates with audit-chain emissions and cascade fan-out.
692
706
  runSql(database, "PRAGMA secure_delete=ON");
693
707
 
708
+ // Boot-time integrity check — refuse to boot on B-tree corruption.
709
+ // SQLite normally surfaces corruption only when a query stumbles on
710
+ // a bad page; that's a "first failure during request handling"
711
+ // surface, not a clean fail-closed boot. integrity_check is fast on
712
+ // the freshly-decrypted-into-tmpfs file (<1 second on a typical
713
+ // multi-MB DB) and the result is "ok" or a list of issues.
714
+ if (opts.skipBootIntegrityCheck !== true) {
715
+ var ic = database.prepare("PRAGMA integrity_check").all();
716
+ var icIssues = ic.map(function (r) { return r && r.integrity_check; })
717
+ .filter(function (s) { return s && s !== "ok"; });
718
+ if (icIssues.length > 0) {
719
+ throw new DbError("db/integrity-check-failed",
720
+ "PRAGMA integrity_check at boot reported " + icIssues.length +
721
+ " issue(s): " + icIssues.slice(0, 3).join("; "));
722
+ }
723
+ }
724
+
694
725
  // PRAGMA integrity_check — refuse boot on B-tree corruption (per
695
726
  // audit-batch finding). SQLite returns "ok" for a healthy database;
696
727
  // any other result means corruption. Catching it at boot beats
@@ -1264,7 +1295,7 @@ module.exports = {
1264
1295
  catch (_e) { /* drop-silent */ }
1265
1296
  if (auditOn) {
1266
1297
  try { audit.safeEmit({
1267
- action: "system.db.integrity_ok", outcome: "ok", metadata: {},
1298
+ action: "system.db.integrity_ok", outcome: "success", metadata: {},
1268
1299
  }); } catch (_e) { /* drop-silent */ }
1269
1300
  }
1270
1301
  return;
@@ -1274,7 +1305,7 @@ module.exports = {
1274
1305
  catch (_e) { /* drop-silent */ }
1275
1306
  if (auditOn) {
1276
1307
  try { audit.safeEmit({
1277
- action: "system.db.integrity_corrupt", outcome: "fail",
1308
+ action: "system.db.integrity_corrupt", outcome: "failure",
1278
1309
  metadata: { issueCount: issues.length },
1279
1310
  }); } catch (_e) { /* drop-silent */ }
1280
1311
  }
package/lib/dev.js CHANGED
@@ -48,13 +48,24 @@
48
48
  */
49
49
 
50
50
  var path = require("path");
51
- var childProcess = require("child_process");
52
51
  var fs = require("fs");
52
+ var lazyRequire = require("./lazy-require");
53
53
  var logModule = require("./log");
54
54
  var nb = require("./numeric-bounds");
55
+ var safeEnv = require("./parsers/safe-env");
55
56
  var validateOpts = require("./validate-opts");
56
57
  var { FrameworkError } = require("./framework-error");
57
58
 
59
+ // child_process is required ONLY when dev.create() is actually
60
+ // called — not at module load. The dev primitive spawns subprocesses
61
+ // (nodemon-style restart loop) by design; isolating the dependency
62
+ // behind lazy-load means a production process that never calls
63
+ // b.dev.create() never loads child_process, and supply-chain scanners
64
+ // inspecting a deployed bundle don't see it as a top-level dep of an
65
+ // otherwise hermetic framework. Production deployments additionally
66
+ // refuse to construct dev.create() — see _refuseInProduction below.
67
+ var childProcess = lazyRequire(function () { return require("child_process"); });
68
+
58
69
  class DevError extends FrameworkError {
59
70
  constructor(code, message) {
60
71
  super(message, code);
@@ -123,9 +134,25 @@ function create(opts) {
123
134
  var env = opts.env || process.env;
124
135
  var cwd = opts.cwd || process.cwd();
125
136
 
126
- // Test seams
137
+ // dev.create() is intended for development-mode use only —
138
+ // restarting subprocesses on file change is a feature operators
139
+ // run on their laptop, never in production. Refusing here means a
140
+ // mis-configured production deployment that accidentally wires the
141
+ // dev primitive crashes loudly at boot rather than spawning shells
142
+ // on every save. Operators with a legitimate cross-cutting need
143
+ // (e.g. a CI runner that uses dev() to drive end-to-end tests)
144
+ // explicitly opt in via opts.allowProduction with an audited reason.
145
+ if (safeEnv.readVar("NODE_ENV") === "production" && !opts.allowProduction) {
146
+ throw new DevError("dev/refused-in-production",
147
+ "b.dev.create: dev mode refuses to load when NODE_ENV=production. " +
148
+ "Set opts.allowProduction:true with an audited reason if a non-dev " +
149
+ "context legitimately needs subprocess spawn-on-watch behaviour.");
150
+ }
151
+
152
+ // Test seams. childProcess is lazily-required; calling spawn here
153
+ // pulls in node's child_process module on first dev.create() use.
127
154
  var spawnFn = opts._spawn || function (cmd, sargs, sopts) {
128
- return childProcess.spawn(cmd, sargs, sopts);
155
+ return childProcess().spawn(cmd, sargs, sopts);
129
156
  };
130
157
  var watchFn = opts._watch || function (dir, wopts, listener) {
131
158
  return fs.watch(dir, wopts, listener);
@@ -186,7 +186,25 @@ function create(opts) {
186
186
  if (!approverRoles) return true;
187
187
  if (!actor || !Array.isArray(actor.roles)) return false;
188
188
  for (var i = 0; i < approverRoles.length; i++) {
189
- if (actor.roles.indexOf(approverRoles[i]) !== -1) return true;
189
+ var required = approverRoles[i];
190
+ // Wildcard match — actor's "security:*" satisfies a required
191
+ // "security:officer" (matching the b.permissions.match
192
+ // semantics elsewhere in the framework). Without this, an
193
+ // operator with a wildcard-shaped role can't approve dual-
194
+ // control flows even when b.permissions would consider the
195
+ // role assignment satisfied.
196
+ for (var j = 0; j < actor.roles.length; j++) {
197
+ var actorRole = actor.roles[j];
198
+ if (actorRole === required) return true;
199
+ if (typeof actorRole === "string" &&
200
+ actorRole.length > 0 &&
201
+ actorRole.charAt(actorRole.length - 1) === "*") {
202
+ var prefix = actorRole.slice(0, -1);
203
+ if (typeof required === "string" && required.indexOf(prefix) === 0) {
204
+ return true;
205
+ }
206
+ }
207
+ }
190
208
  }
191
209
  return false;
192
210
  }
@@ -570,6 +570,16 @@ function _buildSessionGucsStatements(sessionGucs) {
570
570
  // Postgres SET accepts on/off/true/false — render true/false.
571
571
  literal = value ? "true" : "false";
572
572
  } else if (typeof value === "string") {
573
+ // Cap the value length so an operator-controlled tenant_id of
574
+ // 100 KB doesn't hit Postgres' SET LOCAL parser with payload
575
+ // that bloats query logs and consumes max_stack_depth. The cap
576
+ // is generous for legitimate tenant identifiers but rejects
577
+ // amplification.
578
+ if (value.length > C.BYTES.kib(4)) {
579
+ throw _err("INVALID_SESSION_GUCS",
580
+ "sessionGucs['" + name + "']: value exceeds 4 KiB cap (got " +
581
+ value.length + " chars)", true);
582
+ }
573
583
  literal = "'" + value.replace(/'/g, "''") + "'";
574
584
  } else if (value === null || value === undefined) {
575
585
  throw _err("INVALID_SESSION_GUCS",
@@ -763,7 +763,7 @@ function create(opts) {
763
763
  return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
764
764
  }
765
765
 
766
- function _checkAllowedFileType(firstChunkBody) {
766
+ function _checkAllowedFileType(firstChunkBody, claimedMime) {
767
767
  if (!allowedFileTypes || allowedFileTypes.length === 0) return;
768
768
  if (!fileType) return; // create() guards this; defensive
769
769
  var detected = fileType.detect(firstChunkBody);
@@ -787,6 +787,30 @@ function create(opts) {
787
787
  "fileUpload.finalize: detected MIME '" + detectedMime +
788
788
  "' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
789
789
  }
790
+ // Cross-check claimed MIME (Content-Type header) against detected
791
+ // magic bytes. When both exist and disagree, refuse — downstream
792
+ // renderers / storage layers that trust metadata.contentType
793
+ // (CDN cache routing, MIME-sniff fallbacks, attachment-disposition
794
+ // decisions) will mis-handle the file. The strict-MIME check is
795
+ // load-bearing for image-pipelines that pass to image processors:
796
+ // a "image/png" claim with PDF magic bytes lands in image-rendering
797
+ // code-paths that will exec PDF parsers with surprising semantics.
798
+ if (claimedMime && typeof claimedMime === "string" && claimedMime.indexOf("/") !== -1) {
799
+ var claimedNormalized = claimedMime.split(";")[0].trim().toLowerCase();
800
+ if (claimedNormalized && claimedNormalized !== detectedMime) {
801
+ // Wildcard / family acceptance: "image/jpeg" claim + "image/jpg"
802
+ // detect (synonyms) is OK; "image/png" claim + "image/jpeg"
803
+ // detect is NOT.
804
+ var claimedFamily = claimedNormalized.split("/")[0];
805
+ var detectedFamily = detectedMime.split("/")[0];
806
+ if (claimedFamily !== detectedFamily) {
807
+ throw _err("MIME_CLAIM_MISMATCH",
808
+ "fileUpload.finalize: claimed Content-Type '" + claimedNormalized +
809
+ "' disagrees with detected magic-byte MIME '" + detectedMime +
810
+ "'. Refusing to proceed with mis-typed file.");
811
+ }
812
+ }
813
+ }
790
814
  }
791
815
 
792
816
  function _streamFromChunkPaths(paths /* totalBytes */) {
@@ -842,8 +866,11 @@ function create(opts) {
842
866
  firstChunk = pieces[0];
843
867
  }
844
868
 
845
- // MIME allowlist gate (if configured).
846
- try { _checkAllowedFileType(firstChunk); }
869
+ // MIME allowlist gate (if configured). Pass the operator-supplied
870
+ // contentType from upload metadata so the cross-check can refuse
871
+ // claimed-vs-detected mismatches.
872
+ var claimedMime = (meta && meta.metadata && meta.metadata.contentType) || null;
873
+ try { _checkAllowedFileType(firstChunk, claimedMime); }
847
874
  catch (e) {
848
875
  _emitObs("fileUpload.mime_rejected", 1);
849
876
  _emitAudit("fileUpload.finalize", {
package/lib/flag.js CHANGED
@@ -98,7 +98,7 @@ function create(opts) {
98
98
  try {
99
99
  audit().safeEmit({
100
100
  action: "flag.evaluation.error",
101
- outcome: "fail",
101
+ outcome: "failure",
102
102
  actor: { targetingKey: ctx && ctx.targetingKey || null },
103
103
  metadata: {
104
104
  flagKey: flagKey,
package/lib/guard-all.js CHANGED
@@ -116,8 +116,16 @@ var SHARED_POSTURES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
116
116
 
117
117
  function _verifyParity() {
118
118
  var failures = [];
119
- for (var i = 0; i < GUARDS.length; i += 1) {
120
- var g = GUARDS[i];
119
+ // Walk both registries content guards (with MIME_TYPES + EXTENSIONS)
120
+ // and standalone guards (filename / domain / uuid / cidr / time /
121
+ // mime / jwt / oauth / graphql / shell / regex / jsonpath / template /
122
+ // image / pdf / auth). Standalone guards skip the MIME/EXTENSION
123
+ // checks but every guard MUST declare the shared profile + posture
124
+ // vocabulary so b.guardAll.allGuards() returns a uniform surface.
125
+ var allGuards = GUARDS.concat(STANDALONE_GUARDS);
126
+ for (var i = 0; i < allGuards.length; i += 1) {
127
+ var g = allGuards[i];
128
+ var isContent = i < GUARDS.length;
121
129
  if (!g || typeof g !== "object") {
122
130
  failures.push("guard at index " + i + " is not an exported module object");
123
131
  continue;
@@ -126,11 +134,13 @@ function _verifyParity() {
126
134
  failures.push("guard at index " + i + ": missing NAME export");
127
135
  continue;
128
136
  }
129
- if (!Array.isArray(g.MIME_TYPES) || g.MIME_TYPES.length === 0) {
130
- failures.push(g.NAME + ": missing or empty MIME_TYPES export");
131
- }
132
- if (!Array.isArray(g.EXTENSIONS) || g.EXTENSIONS.length === 0) {
133
- failures.push(g.NAME + ": missing or empty EXTENSIONS export");
137
+ if (isContent) {
138
+ if (!Array.isArray(g.MIME_TYPES) || g.MIME_TYPES.length === 0) {
139
+ failures.push(g.NAME + ": missing or empty MIME_TYPES export");
140
+ }
141
+ if (!Array.isArray(g.EXTENSIONS) || g.EXTENSIONS.length === 0) {
142
+ failures.push(g.NAME + ": missing or empty EXTENSIONS export");
143
+ }
134
144
  }
135
145
  if (typeof g.gate !== "function") {
136
146
  failures.push(g.NAME + ": missing gate(opts) function");
@@ -146,27 +156,34 @@ function _verifyParity() {
146
156
  }
147
157
  });
148
158
  }
149
- // Detect duplicate NAMEs / MIME_TYPES / EXTENSIONS would cause silent
150
- // override in the aggregated gate map; surface at boot instead.
159
+ // Detect duplicate NAMEs across the full registry (both content +
160
+ // standalone) so a future guard with a NAME collision surfaces at
161
+ // boot instead of silently overriding _byName lookups. MIME / EXT
162
+ // collision detection stays scoped to content guards (standalone
163
+ // guards have no MIME/EXTENSIONS).
151
164
  var nameSeen = Object.create(null);
152
165
  var mimeSeen = Object.create(null);
153
166
  var extSeen = Object.create(null);
154
- for (var j = 0; j < GUARDS.length; j += 1) {
155
- var gg = GUARDS[j];
167
+ for (var j = 0; j < allGuards.length; j += 1) {
168
+ var gg = allGuards[j];
156
169
  if (gg && gg.NAME) {
157
- if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME));
170
+ if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME) +
171
+ " across the full guard registry");
158
172
  nameSeen[gg.NAME] = true;
159
173
  }
160
- if (gg && Array.isArray(gg.MIME_TYPES)) {
161
- gg.MIME_TYPES.forEach(function (m) {
174
+ }
175
+ for (var jc = 0; jc < GUARDS.length; jc += 1) {
176
+ var ggc = GUARDS[jc];
177
+ if (ggc && Array.isArray(ggc.MIME_TYPES)) {
178
+ ggc.MIME_TYPES.forEach(function (m) {
162
179
  var k = String(m).toLowerCase();
163
180
  if (mimeSeen[k]) failures.push("duplicate MIME_TYPE " + JSON.stringify(k) +
164
181
  " across multiple guards");
165
182
  mimeSeen[k] = true;
166
183
  });
167
184
  }
168
- if (gg && Array.isArray(gg.EXTENSIONS)) {
169
- gg.EXTENSIONS.forEach(function (e) {
185
+ if (ggc && Array.isArray(ggc.EXTENSIONS)) {
186
+ ggc.EXTENSIONS.forEach(function (e) {
170
187
  var k = String(e).toLowerCase();
171
188
  if (extSeen[k]) failures.push("duplicate EXTENSION " + JSON.stringify(k) +
172
189
  " across multiple guards");
package/lib/guard-csv.js CHANGED
@@ -360,7 +360,20 @@ function _detectIssues(text, opts) {
360
360
  }
361
361
 
362
362
  if (opts.formulaInjectionPolicy !== "audit-only" && opts.formulaInjectionPolicy !== "allow") {
363
- var formulaMatch = _firstMatch(text, FORMULA_SCAN_RE);
363
+ // Strip ZWSP / RTLO / LRM / RLM / BOM at cell-start before the
364
+ // formula scan. Without this, a cell beginning with U+200B (zero-
365
+ // width space), U+202E (RTLO), U+200E/F (LTR/RTL marks), or U+FEFF
366
+ // (BOM) followed by `=` slips past the start-anchor check (the `^`
367
+ // sits before the codepoint, not after) and the formula reaches
368
+ // the spreadsheet evaluator. Browsers + Excel + Sheets all strip
369
+ // these silently — operator users see "=SUM(...)" rendered, the
370
+ // file shipped a hidden bidi prefix that bypassed the scanner.
371
+ // U+200B-200F (ZWSP / ZWNJ / ZWJ / LRM / RLM) +
372
+ // U+202A-202E (LRE / RLE / PDF / LRO / RLO) +
373
+ // U+2066-2069 (LRI / RLI / FSI / PDI) +
374
+ // U+FEFF (BOM) // allow:dynamic-regex — explicit codepoints, no operator input
375
+ var stripped = text.replace(new RegExp("^[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]+"), "");
376
+ var formulaMatch = _firstMatch(stripped, FORMULA_SCAN_RE);
364
377
  if (formulaMatch) {
365
378
  issues.push({
366
379
  kind: "formula-prefix-cell", severity: "critical",
@@ -368,7 +381,8 @@ function _detectIssues(text, opts) {
368
381
  location: formulaMatch.index,
369
382
  snippet: "cell beginning with formula trigger " +
370
383
  JSON.stringify(formulaMatch.char.slice(-1)) +
371
- " at byte " + formulaMatch.index,
384
+ " at byte " + formulaMatch.index +
385
+ (stripped.length !== text.length ? " (after stripping leading bidi/zero-width prefix)" : ""),
372
386
  });
373
387
  }
374
388
  }
package/lib/guard-html.js CHANGED
@@ -402,6 +402,30 @@ function escapeAttr(value) {
402
402
  .replace(/=/g, "&#61;");
403
403
  }
404
404
 
405
+ // HTML5 named entities that decode to ASCII codepoints — focused on
406
+ // the entries browsers honor inside URL contexts (whitespace, control
407
+ // chars, scheme-significant punctuation). The full WHATWG named-
408
+ // character-reference table is ~2,231 entries; this is the
409
+ // security-load-bearing subset documented in scheme-bypass writeups
410
+ // (CVE-2026-30838 class). High-codepoint named entities (e.g. mathematical
411
+ // symbols) don't affect URL scheme parsing, so they're omitted.
412
+ var NAMED_ENTITY_ASCII = {
413
+ // Whitespace + control chars browsers strip inside URL schemes
414
+ Tab: "\t", NewLine: "\n",
415
+ // Scheme-significant punctuation
416
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
417
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
418
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
419
+ // Quotes / brackets
420
+ quot: "\"", apos: "'", lt: "<", gt: ">",
421
+ // Misc ASCII
422
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
423
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
424
+ // Whitespace markers (codepoints in the ASCII / Latin-1 range that
425
+ // browsers treat as URL-strippable)
426
+ nbsp: " ",
427
+ };
428
+
405
429
  // _normalizeUrl — peel off entity-encoded leading whitespace and
406
430
  // HTML/URL-encoded scheme prefix tricks, then return the lowercased
407
431
  // scheme. Returns "" if no scheme.
@@ -415,6 +439,17 @@ function _extractScheme(rawUrl) {
415
439
  s = s.replace(/&#(\d+);/g, function (_m, d) {
416
440
  return String.fromCharCode(parseInt(d, 10));
417
441
  });
442
+ // Decode HTML5 named entities that browsers honor inside URL
443
+ // contexts. Without this, payloads like `java&Tab;script:alert(1)`
444
+ // bypass the scheme allowlist (the literal `&Tab;` between `java`
445
+ // and `script:` doesn't match any denied scheme; the browser then
446
+ // decodes the entity, strips the tab, and executes javascript:).
447
+ s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
448
+ if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
449
+ return NAMED_ENTITY_ASCII[name];
450
+ }
451
+ return m;
452
+ });
418
453
  // Strip embedded whitespace + control chars + zero-widths the
419
454
  // URL parser would tolerate.
420
455
  s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
package/lib/guard-svg.js CHANGED
@@ -352,6 +352,20 @@ function _resolveOpts(opts) {
352
352
  });
353
353
  }
354
354
 
355
+ // HTML5 named-entity ASCII subset — same shape as guard-html.
356
+ // Browsers honor these inside URL contexts; without decoding them,
357
+ // `java&Tab;script:` and friends bypass the scheme allowlist.
358
+ var SVG_NAMED_ENTITY_ASCII = {
359
+ Tab: "\t", NewLine: "\n",
360
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
361
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
362
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
363
+ quot: "\"", apos: "'", lt: "<", gt: ">",
364
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
365
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
366
+ nbsp: " ",
367
+ };
368
+
355
369
  function _extractScheme(rawUrl) {
356
370
  var s = String(rawUrl || "").trim();
357
371
  s = s.replace(/&#x([0-9a-f]+);/gi, function (_m, h) {
@@ -360,6 +374,12 @@ function _extractScheme(rawUrl) {
360
374
  s = s.replace(/&#(\d+);/g, function (_m, d) {
361
375
  return String.fromCharCode(parseInt(d, 10));
362
376
  });
377
+ s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
378
+ if (Object.prototype.hasOwnProperty.call(SVG_NAMED_ENTITY_ASCII, name)) {
379
+ return SVG_NAMED_ENTITY_ASCII[name];
380
+ }
381
+ return m;
382
+ });
363
383
  s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
364
384
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
365
385
  return m ? m[1].toLowerCase() : "";