@blamejs/core 0.18.39 → 0.18.41

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.
@@ -85,7 +85,9 @@ var DEFAULT_CACHE_TTL_MS = C.TIME.minutes(5);
85
85
  * shape violations: `gate-contract/bad-shape` from
86
86
  * `validateGateShape`, `gate-contract/bad-opt` from `defineGate` /
87
87
  * `cachingGate` / `workerThreadGate`, `gate-contract/profile-cycle`
88
- * and `gate-contract/unknown-profile` from `buildProfile`.
88
+ * and `gate-contract/unknown-profile` from `buildProfile`, and
89
+ * `gate-contract/bad-context` from a gate's `check` when the context
90
+ * it is handed is neither an object nor absent.
89
91
  * `alwaysPermanent` — never retried by `b.retry`.
90
92
  *
91
93
  * @example
@@ -290,6 +292,126 @@ function validateGateShape(gate, label, errorClass) {
290
292
  * var d = await gate.check({ bytes: Buffer.from("name,age\nada,36") });
291
293
  * d.action; // → "serve"
292
294
  */
295
+ // A gate context with some fields REPLACED, leaving the caller's object alone.
296
+ // Used by the two paths that genuinely change the context — a `beforeCheck`
297
+ // transform, and the sanitize rebind that feeds scrubbed bytes to the next gate
298
+ // in a composition. Module scope on purpose: `composeGates` is not inside
299
+ // defineGate's closure.
300
+ //
301
+ // A context is not required to be a plain object, and the obvious shortcuts
302
+ // each break a different kind of one. Every line here is a fix for a shape that
303
+ // was mishandled:
304
+ //
305
+ // - `Object.assign({}, base, ...)` copies OWN enumerable properties, so a
306
+ // class instance whose fields come from prototype getters arrives with them
307
+ // missing. A guard reading an absent subject as nothing-to-inspect then
308
+ // serves bytes it used to examine.
309
+ // - `Object.create(base)` fixes that but makes inherited getters run with the
310
+ // DERIVED object as `this`, so a getter returning a private field throws
311
+ // and a valid context is refused.
312
+ // - Assigning the overrides (including via `Object.assign`) performs [[Set]],
313
+ // which walks the chain: an inherited non-writable property throws, and an
314
+ // inherited setter runs and writes into the caller.
315
+ //
316
+ // So: forward each readable name to `base` with `base` as the receiver, and
317
+ // install the overrides as own data properties via [[DefineOwnProperty]]. The
318
+ // readable names are collected once, which is sound because both callers derive
319
+ // from a context that is already fully formed.
320
+ function _deriveContext(base, overrides) {
321
+ // Same prototype as the caller's object, so `ctx instanceof TheirClass` still
322
+ // answers what it did — a guard may dispatch or validate on the context's
323
+ // type, and a wrapper on a bare object would fail that while carrying every
324
+ // one of the instance's fields. The forwarding accessors below are installed
325
+ // as OWN properties, so they shadow the prototype for everything readable and
326
+ // the prototype serves only identity.
327
+ var derived = Object.create(Object.getPrototypeOf(base));
328
+ var seen = Object.create(null);
329
+ // Copy-on-write. The getter forwards to `base` with `base` as the receiver;
330
+ // the setter REPLACES itself with a plain own data property holding the new
331
+ // value, so a guard that normalizes its subject in place (`ctx.bytes = ...`)
332
+ // succeeds and every later read sees the update. Without the setter the
333
+ // assignment silently does nothing, or throws under strict mode and turns a
334
+ // completed check into a refusal. The caller's object is still never touched.
335
+ //
336
+ // Functions are forwarded AS THEY ARE — never bound. That is a deliberate
337
+ // choice between two things that cannot both hold, and it is worth stating
338
+ // because the losing side is a real capability:
339
+ //
340
+ // - Called as `ctx.read()`, an unbound method runs with the DERIVED context
341
+ // as `this`, so a method reading `this.bytes` sees an override a
342
+ // `beforeCheck` transform or a sanitize step installed. This is the whole
343
+ // point of a sanitize chain: nothing downstream may see the original
344
+ // bytes. Binding to the caller's object was measured handing back
345
+ // `ORIGINAL` from a method while direct access returned `SANITIZED`.
346
+ // - Bound to the caller's object instead, a method carrying a brand — one
347
+ // reading a private field, or a built-in like `Map#get` — works, but then
348
+ // every ordinary method reads around the overrides.
349
+ //
350
+ // A brand requires the original instance as the receiver; an override
351
+ // requires the derived one. No receiver satisfies both, so the security
352
+ // property wins: overrides are always visible. A context whose data is only
353
+ // reachable through a branded method is therefore REFUSED rather than
354
+ // inspected against stale bytes — fail-closed, and visible to the operator,
355
+ // who can pass the fields directly instead.
356
+ //
357
+ // Not binding also keeps every function's identity and lets a guard choose a
358
+ // receiver with `.call()`, which matters for a callback the caller stored on
359
+ // the context.
360
+ var forward = function (key, enumerable) {
361
+ Object.defineProperty(derived, key, {
362
+ get: function () { return base[key]; },
363
+ set: function (value) {
364
+ Object.defineProperty(derived, key, {
365
+ value: value,
366
+ writable: true,
367
+ enumerable: enumerable,
368
+ configurable: true,
369
+ });
370
+ },
371
+ // Mirror the source. Enumerability is observable — a guard that spreads
372
+ // the context, calls Object.keys, or serializes it would otherwise see
373
+ // fields the original hid, and serializing a hidden accessor RUNS a
374
+ // getter the guard never chose to read.
375
+ enumerable: enumerable,
376
+ configurable: true,
377
+ });
378
+ };
379
+ // Reflect.ownKeys, not getOwnPropertyNames: a guard may tag its context with
380
+ // a Symbol to avoid colliding with a caller's field names, and a
381
+ // string-key-only walk drops it silently.
382
+ for (var proto = base; proto && proto !== Object.prototype;
383
+ proto = Object.getPrototypeOf(proto)) {
384
+ var names = Reflect.ownKeys(proto);
385
+ for (var i = 0; i < names.length; i += 1) {
386
+ var key = names[i];
387
+ if (seen[key]) continue;
388
+ var desc = Object.getOwnPropertyDescriptor(proto, key);
389
+ // Skip a class prototype's own back-reference to its constructor, which
390
+ // is plumbing rather than context — but NOT a field the caller happens to
391
+ // have named `constructor`, which is theirs and carries their value.
392
+ if (key === "constructor" && proto !== base && desc &&
393
+ typeof desc.value === "function" && desc.value.prototype === proto) {
394
+ continue;
395
+ }
396
+ seen[key] = true;
397
+ forward(key, desc ? desc.enumerable : true);
398
+ }
399
+ }
400
+ if (overrides) {
401
+ var keys = Reflect.ownKeys(overrides);
402
+ for (var k = 0; k < keys.length; k += 1) {
403
+ var od = Object.getOwnPropertyDescriptor(overrides, keys[k]);
404
+ Object.defineProperty(derived, keys[k], {
405
+ value: overrides[keys[k]],
406
+ writable: true,
407
+ enumerable: od ? od.enumerable : true,
408
+ configurable: true,
409
+ });
410
+ }
411
+ }
412
+ return derived;
413
+ }
414
+
293
415
  function defineGate(opts) {
294
416
  validateOpts.requireObject(opts, "gateContract.defineGate", GateContractError);
295
417
  validateOpts.requireNonEmptyString(opts.name, "gateContract.defineGate: name", GateContractError, "gate-contract/bad-opt");
@@ -350,8 +472,30 @@ function defineGate(opts) {
350
472
 
351
473
  async function check(ctx) {
352
474
  var startedAt = Date.now();
353
- ctx = ctx || {};
354
- if (!ctx.forensicId) ctx.forensicId = bCrypto.generateToken(FORENSIC_ID_BYTES);
475
+ // The gate owns the context it works with; the caller's object is read, not
476
+ // written. Stamping the forensic id onto whatever arrived meant a string
477
+ // raised a raw TypeError — and `check` is async, so that surfaced as an
478
+ // unhandled rejection taking the process down rather than the request — and
479
+ // a frozen context, which an operator freezes precisely so middleware
480
+ // cannot edit the request shape, was refused for being frozen.
481
+ if (ctx === null || ctx === undefined) {
482
+ ctx = {};
483
+ } else if (typeof ctx !== "object") {
484
+ throw _err("gate-contract/bad-context",
485
+ opts.name + ".check: context must be an object (got " + typeof ctx + ")");
486
+ }
487
+ // A guard can read `ctx.forensicId` to correlate its own audit records, so
488
+ // the id stays visible on the context the check receives. What changes is
489
+ // how it gets there: an id the caller supplied is used as-is and the
490
+ // caller's object is passed straight through, and only when one has to be
491
+ // generated is a context derived to carry it — never by writing into the
492
+ // object the caller handed over.
493
+ // Derive unconditionally. Whether the caller supplied an id decides only
494
+ // whether one is generated — never whether the caller's object is isolated,
495
+ // because a hook or guard that assigns to the context would otherwise be
496
+ // writing into the caller's own state, and would throw against a frozen one.
497
+ var forensicId = ctx.forensicId || bCrypto.generateToken(FORENSIC_ID_BYTES);
498
+ ctx = _deriveContext(ctx, { forensicId: forensicId });
355
499
 
356
500
  // Decision cache lookup (memoize per-forensicHash).
357
501
  var bytes = ctx.bytes;
@@ -376,7 +520,11 @@ function defineGate(opts) {
376
520
  return _build({ ok: true, action: "serve", forensicHash: forensicHash, runtimeMs: Date.now() - startedAt });
377
521
  }
378
522
  if (beforeRv && beforeRv.transform) {
379
- ctx = Object.assign({}, ctx, beforeRv.transform);
523
+ // Extend the chain, never flatten it: the caller's fields are reached
524
+ // THROUGH `ctx` rather than owned by it, so rebuilding from own
525
+ // enumerable properties would drop the subject on exactly the gates an
526
+ // operator attached a hook to.
527
+ ctx = _deriveContext(ctx, beforeRv.transform);
380
528
  }
381
529
 
382
530
  // Run operator check with optional runtime cap.
@@ -455,6 +603,10 @@ function defineGate(opts) {
455
603
  decision.forensicSnapshot = snippet;
456
604
  if (forensicEvidenceStore && typeof forensicEvidenceStore.write === "function") {
457
605
  await forensicEvidenceStore.write({
606
+ // Read from the context as it stands, not from the id computed
607
+ // before the hooks ran: a `beforeCheck` transform may replace the
608
+ // forensic id, and the guard saw the replacement. Recording the
609
+ // earlier one breaks exactly the correlation the id exists for.
458
610
  forensicId: ctx.forensicId,
459
611
  forensicHash: forensicHash,
460
612
  ruleHash: ruleHash,
@@ -664,7 +816,9 @@ function composeGates(gates, opts) {
664
816
  if (d.sanitized) sanitized = d.sanitized;
665
817
  // Feeding the scrubbed bytes forward is what makes the chain a
666
818
  // pipeline rather than N independent opinions on the same input.
667
- if (firstRefusalWins) ctx = Object.assign({}, ctx, { bytes: d.sanitized });
819
+ // Same chain-extending rebind as the beforeCheck transform above
820
+ // only `bytes` is being replaced, not the rest of the context.
821
+ if (firstRefusalWins) ctx = _deriveContext(ctx, { bytes: d.sanitized });
668
822
  }
669
823
  }
670
824
  return _build({
@@ -881,7 +881,17 @@ function sanitize(input, opts) {
881
881
  * every reject-policy off — strip-eligible classes only) → `refuse`
882
882
  * (any reject-policy active or sanitize fails). Path-traversal /
883
883
  * null-byte / NTFS-ADS / UNC / overlong-UTF-8 always cause `refuse`
884
- * — there is no `sanitize` action for those classes.
884
+ * — there is no `sanitize` action for those classes, and no policy
885
+ * setting reaches them.
886
+ *
887
+ * Each finding is dispositioned by its own policy and the strongest
888
+ * answer across them decides the action. The repair itself is not
889
+ * per-finding: `sanitize` dispatches to `b.guardFilename.sanitize`,
890
+ * which applies every transform the profile declares, so a name that
891
+ * enters sanitization because one class asked to strip also has the
892
+ * profile's other repairs applied — including to a class whose own
893
+ * policy was `audit`. The verdict's `sanitized` is byte-identical to
894
+ * calling `b.guardFilename.sanitize(name, opts)` directly.
885
895
  *
886
896
  * @opts
887
897
  * profile: "strict"|"balanced"|"permissive",
@@ -896,6 +906,52 @@ function sanitize(input, opts) {
896
906
  * var ok = await fnGate.check({ filename: "report.txt" });
897
907
  * ok.action; // → "serve"
898
908
  */
909
+ // Bind each finding to the operator's policy for it. The shared character
910
+ // classes resolve through the one family helper; the filename-specific kinds
911
+ // map to the policy this guard names for them. A kind with no policy returns
912
+ // null and the caller applies the conservative severity answer.
913
+ // The classes this guard refuses unconditionally, whatever any policy says.
914
+ // Documented on the gate itself: there is no `sanitize` action for them, and a
915
+ // name carrying one is not repairable into a safe name — a UNC prefix reaches
916
+ // another host, a traversal segment escapes the directory, a NUL truncates the
917
+ // name at whichever consumer reads it first, and an ADS suffix names a second
918
+ // stream on the same file. Reading these from `traversalPolicy` would let a
919
+ // profile that sets it to `audit` serve the name unchanged, which is the
920
+ // bypass the floor exists to prevent.
921
+ var ALWAYS_REFUSE_KINDS = Object.freeze({
922
+ "path-traversal": true,
923
+ "path-traversal-encoded": true,
924
+ "unc-path": true,
925
+ "ntfs-ads": true,
926
+ "null-byte": true,
927
+ "overlong-utf8": true,
928
+ });
929
+
930
+ function _gateDispositionFor(issue, opts) {
931
+ if (ALWAYS_REFUSE_KINDS[issue.kind]) return "refuse";
932
+ var shared = gateContract.charThreatDisposition(issue, opts);
933
+ if (shared) return shared;
934
+ switch (issue.kind) {
935
+ case "path-separator-in-leaf":
936
+ case "url-encoded-separator": return gateContract.policyDisposition(opts.pathSeparatorsPolicy);
937
+ case "reserved-char": return gateContract.policyDisposition(opts.reservedCharPolicy);
938
+ case "reserved-name": return gateContract.policyDisposition(opts.reservedNamePolicy);
939
+ case "leading-trailing-strip": return gateContract.policyDisposition(opts.leadingTrailingPolicy);
940
+ case "homoglyph": return gateContract.policyDisposition(opts.homoglyphPolicy);
941
+ case "non-ascii": return gateContract.policyDisposition(opts.nonAsciiPolicy);
942
+ // Both of these fire on the same condition — a last extension in
943
+ // SHELL_EXEC_EXTS — so they are one finding reported twice and answer to
944
+ // one policy. Mapping only the first left the second on the conservative
945
+ // severity default, where `critical` refuses, and a profile asking to
946
+ // audit a disguised executable refused it instead.
947
+ case "shell-exec-ext":
948
+ case "double-extension": return gateContract.policyDisposition(opts.shellExecExtPolicy);
949
+ // Length, extension allowlisting and the dot-shape findings carry no
950
+ // policy of their own and admit no repair that preserves intent.
951
+ default: return null;
952
+ }
953
+ }
954
+
899
955
  function gate(opts) {
900
956
  opts = _resolveOpts(opts);
901
957
  return gateContract.buildGuardGate(
@@ -907,27 +963,46 @@ function gate(opts) {
907
963
  if (!name) return { ok: true, action: "serve" };
908
964
  var rv = validate(name, opts);
909
965
  if (rv.issues.length === 0) return { ok: true, action: "serve" };
910
- var hasCritical = rv.issues.some(function (i) {
911
- return i.severity === "critical" || i.severity === "high";
912
- });
913
- if (!hasCritical) return { ok: true, action: "audit-only", issues: rv.issues };
914
-
915
- // Sanitize-eligibilityevery reject-policy must be off.
916
- var canSanitize = opts.bidiPolicy !== "reject" &&
917
- opts.controlPolicy !== "reject" &&
918
- opts.nullBytePolicy !== "reject" &&
919
- opts.traversalPolicy !== "reject" &&
920
- opts.reservedCharPolicy !== "reject" &&
921
- opts.reservedNamePolicy !== "reject" &&
922
- opts.adsPolicy !== "reject" &&
923
- opts.pathSeparatorsPolicy !== "reject" &&
924
- opts.leadingTrailingPolicy !== "reject";
925
- if (canSanitize) {
966
+
967
+ // The action comes from what each finding's OWN policy asks, and the
968
+ // strongest answer across the findings wins. Resolving it from severity
969
+ // instead made `critical` and `high` both refuse, so a profile asking to
970
+ // strip a zero-width character refused the filename rather than cleaning
971
+ // itand the sanitize-eligibility test below compounded that by being
972
+ // all-or-nothing: one `reject` policy anywhere in the profile made every
973
+ // OTHER policy refuse too, whatever it declared.
974
+ //
975
+ // Taking the strongest disposition keeps that from weakening anything: a
976
+ // traversal finding still refuses on its own policy no matter what the
977
+ // character policies say, which is the case the all-or-nothing test was
978
+ // reaching for.
979
+ var strongest = "serve";
980
+ var RANK = { serve: 0, "audit-only": 1, sanitize: 2, refuse: 3 };
981
+ for (var qi = 0; qi < rv.issues.length; qi += 1) {
982
+ var d = _gateDispositionFor(rv.issues[qi], opts);
983
+ if (d === "audit") d = "audit-only";
984
+ // A finding with no policy of its own carries no instruction, so it
985
+ // falls back to the conservative severity answer rather than serving.
986
+ if (!d) {
987
+ d = (rv.issues[qi].severity === "critical" || rv.issues[qi].severity === "high")
988
+ ? "refuse" : "audit-only";
989
+ }
990
+ if (RANK[d] > RANK[strongest]) strongest = d;
991
+ }
992
+ if (strongest === "serve") return { ok: true, action: "serve", issues: rv.issues };
993
+ if (strongest === "audit-only") return { ok: true, action: "audit-only", issues: rv.issues };
994
+
995
+ if (strongest === "sanitize") {
926
996
  try {
927
997
  var clean = sanitize(name, opts);
998
+ // `sanitized` is the field the gate contract carries through; a
999
+ // guard-specific name is dropped by the verdict builder, which would
1000
+ // hand the caller `action: "sanitize"` with nothing to use. This
1001
+ // branch was all but unreachable while sanitize-eligibility was
1002
+ // all-or-nothing, so the wrong field name never showed.
928
1003
  return {
929
1004
  ok: true, action: "sanitize",
930
- sanitizedFilename: clean,
1005
+ sanitized: clean,
931
1006
  issues: rv.issues,
932
1007
  };
933
1008
  } catch (_e) { /* fall through */ }
package/lib/guard-yaml.js CHANGED
@@ -735,6 +735,69 @@ function _sanitizeTransform(input, opts) {
735
735
  return codepointClass.scrubCharThreats(input, opts, _err, "yaml");
736
736
  }
737
737
 
738
+ /**
739
+ * @primitive b.guardYaml.gate
740
+ * @signature b.guardYaml.gate(opts?)
741
+ * @since 0.7.14
742
+ * @status stable
743
+ * @compliance hipaa, pci-dss, gdpr, soc2
744
+ * @related b.guardYaml.validate, b.guardYaml.parse, b.staticServe.create, b.fileUpload.create
745
+ *
746
+ * Build a `b.gateContract` gate for plugging into
747
+ * `b.staticServe({ contentSafety: { ".yaml": gate } })`,
748
+ * `b.fileUpload({ contentSafety: { "application/yaml": gate } })`,
749
+ * or any host primitive that consumes the gate-contract shape.
750
+ *
751
+ * The action comes from the POLICY the active profile declares for each
752
+ * finding, not from the finding's severity: a character class set to `strip`
753
+ * is repaired and returned as `sanitize`, one set to `audit` reports
754
+ * `audit-only`, and one set to `reject` refuses. Findings that carry no policy
755
+ * and admit no repair — an alias explosion, a blown anchor cap, an oversized
756
+ * document, one that does not parse — fall back to the conservative severity
757
+ * answer and refuse.
758
+ *
759
+ * Only character-level repair is offered. Stripping an invisible or control
760
+ * character is a text edit needing no re-emit; the tag, alias and
761
+ * multi-document shapes have no faithful round-trip, so they refuse through
762
+ * their own policies rather than being rewritten.
763
+ *
764
+ * @opts
765
+ * profile: "strict"|"balanced"|"permissive",
766
+ * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
767
+ * name: string, // gate identity for audit / observability
768
+ *
769
+ * @example
770
+ * var yamlGate = b.guardYaml.gate({ profile: "balanced" });
771
+ * var d = await yamlGate.check({ bytes: Buffer.from("key: value\n") });
772
+ * d.action; // → "serve"
773
+ */
774
+ // The gate every other content guard builds explicitly. Without it `defineGuard`
775
+ // falls back to the default gate, which ends in `severityDisposition` and never
776
+ // consults this guard's disposition map — so a class the profile sets to `strip`
777
+ // or `audit` was refused anyway, because its finding happens to carry a high
778
+ // severity. Seven of the twelve character-policy cells resolved that way.
779
+ function gate(opts) {
780
+ // Resolve the profile FIRST. `buildContentGate` hands these opts straight to
781
+ // the disposition map, and an unresolved bag carries no `zeroWidthPolicy` /
782
+ // `bidiPolicy` / `controlPolicy` / `nullBytePolicy` at all — so every char
783
+ // finding falls through to the severity answer and refuses, which is the
784
+ // same wrong outcome by a different route.
785
+ opts = gateContract.resolveProfileAndPosture(opts, {
786
+ profiles: PROFILES,
787
+ compliancePostures: COMPLIANCE_POSTURES,
788
+ defaults: DEFAULTS,
789
+ errorClass: GuardYamlError,
790
+ errCodePrefix: "yaml",
791
+ });
792
+ return gateContract.buildContentGate({
793
+ name: opts.name || "guardYaml:" + (opts.profile || "default"),
794
+ opts: opts,
795
+ validate: module.exports.validate,
796
+ dispositionFor: _gateDispositionFor,
797
+ produceSanitized: _sanitizeTransform,
798
+ });
799
+ }
800
+
738
801
  module.exports = gateContract.defineGuard({
739
802
  name: "yaml",
740
803
  kind: "content",
@@ -748,6 +811,7 @@ module.exports = gateContract.defineGuard({
748
811
  detect: _detectIssues,
749
812
  intOpts: ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
750
813
  "maxDocuments", "maxNodes", "maxScalarLength"],
814
+ gate: gate,
751
815
  dispositionFor: _gateDispositionFor,
752
816
  sanitizeTransform: _sanitizeTransform,
753
817
  extra: {
@@ -851,9 +851,7 @@ function _attachJarCookie(headers, jar, url) {
851
851
  function _buildMultipartBody(spec) {
852
852
  var boundary = "----blamejs-mp-" + bCrypto.generateToken(C.BYTES.bytes(16));
853
853
  var CRLF = "\r\n";
854
- var nodeFs = require("node:fs"); // allow:inline-require — only on multipart paths that touch the filesystem
855
- var path = require("node:path"); // allow:inline-require — same
856
- var nodeStream = require("node:stream"); // allow:inline-require — Readable subclass only when streaming
854
+ var path = require("node:path"); // allow:inline-require — only on multipart paths that touch the filesystem
857
855
 
858
856
  // Each entry is { headerBytes, source } where source is one of:
859
857
  // { kind: "buffer", buf: Buffer }