@blamejs/core 0.18.47 → 0.18.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/lib/ai-model-manifest.js +2 -2
  3. package/lib/audit-sign.js +3 -3
  4. package/lib/auth/passkey.js +1 -1
  5. package/lib/backup/index.js +2 -2
  6. package/lib/client-hints.js +1 -2
  7. package/lib/codepoint-class.js +6 -5
  8. package/lib/gate-contract.js +147 -1
  9. package/lib/guard-archive.js +24 -5
  10. package/lib/guard-auth.js +28 -0
  11. package/lib/guard-cidr.js +19 -0
  12. package/lib/guard-country.js +4 -4
  13. package/lib/guard-csv.js +31 -2
  14. package/lib/guard-domain.js +22 -0
  15. package/lib/guard-email.js +17 -0
  16. package/lib/guard-filename.js +45 -11
  17. package/lib/guard-graphql.js +16 -0
  18. package/lib/guard-html.js +17 -0
  19. package/lib/guard-image.js +19 -0
  20. package/lib/guard-json.js +23 -0
  21. package/lib/guard-jsonpath.js +14 -1
  22. package/lib/guard-jwt.js +20 -2
  23. package/lib/guard-markdown.js +25 -0
  24. package/lib/guard-mime.js +15 -5
  25. package/lib/guard-oauth.js +23 -0
  26. package/lib/guard-pdf.js +19 -1
  27. package/lib/guard-regex.js +31 -15
  28. package/lib/guard-shell.js +14 -1
  29. package/lib/guard-svg.js +17 -0
  30. package/lib/guard-template.js +14 -1
  31. package/lib/guard-text.js +16 -1
  32. package/lib/guard-time.js +20 -4
  33. package/lib/guard-uuid.js +28 -4
  34. package/lib/guard-xml.js +19 -0
  35. package/lib/guard-yaml.js +22 -2
  36. package/lib/mail-auth.js +3 -6
  37. package/lib/metrics.js +4 -4
  38. package/lib/mtls-ca.js +4 -4
  39. package/lib/network-dns.js +2 -2
  40. package/lib/session-device-binding.js +2 -2
  41. package/lib/vault/index.js +2 -2
  42. package/lib/ws-client.js +2 -2
  43. package/package.json +1 -1
  44. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.49 (2026-08-22) — **Comments in the shipped tree that answered a question you cannot ask.** A comment in `lib/` reaches anyone who opens the published package, and twenty of them were written for a reader who remembers the previous version or can look up an issue number that resolves nowhere outside this repository. They now say what the code does and why, in the present tense, keeping every reason they carried. No behaviour changes. **Changed:** *Comments that narrated a change now describe the code* — Fourteen comments across `lib/` were written against a version the reader has never seen: "this used to be opt-in", "it used to declare them in every profile anyway", "the legacy fallback was removed". Each is now a property of the design rather than a diff, and each keeps the reason it carried. The zero-width scan's comment still explains that an opt-in argument is an argument a caller can omit, and still names the six call sites that omitted it — it just no longer reads as a changelog entry pasted into the source.
12
+
13
+ One of them was wrong in a way worth naming: a note in the gate-contract context builder said a guard "serves bytes it used to examine", which reads as a past version's behaviour. It means bytes the guard would otherwise examine, and now says so. · *Issue numbers that resolve nowhere are gone; the upstream ones stay* — Six comments cited a bare `issue #100` / `#330` / `#430` / `#532`. Opened from the published tarball those name nothing an operator can look up, and the explanation beside them was already doing the work. The references are gone and the explanations stay.
14
+
15
+ The references that name their upstream are untouched and now read unambiguously: `cure53/DOMPurify issue #233` on the SVG `xlink:href` filtering, and CycloneDX spec issue #702 on the EU CRA property mapping, which is now attributed on both of its mentions rather than one.
16
+
17
+ The boundary is what ships. `test/` carries 144 of the same kind of reference, most of them a `PR #nnn` on a regression test, and those are left alone: they resolve in the repository a contributor is already reading, and on a regression test the pull-request reference is often the most useful line in the comment. · *Three sentences in SECURITY.md* — A bot-guard rationale said an abuser "simply sends the header", which tells a reader the step is trivial and adds nothing to the argument. Two lists ended in `etc.` without saying where the rest was: the post-quantum algorithm-agility example now names HQC and FrodoKEM as the examples they are, and the session-risk scorer now says it scores whatever signal you have. The other six `etc.` in the file name their generating rule in the next clause and are unchanged.
18
+
19
+ - v0.18.48 (2026-08-22) — **A guard option you set and a guard option that exists were not the same set.** Every `*Policy` option across the guard family now names the values it takes, and refuses everything else where options are resolved rather than at the first request that trips the scan. Holding the options to their vocabularies surfaced four places where what a guard advertised and what it did had come apart: an option documented under a name nothing read, a value that disabled a critical check without appearing in any opts block, two policies whose settings the gate's floor overrode anyway, and one declared in all three profiles and read nowhere. **Fixed:** *A guard policy refuses a value outside its vocabulary* — The two hundred and sixty-two `*Policy` options across the guard family each declare the values they accept, checked where options are resolved — boot, not the first hostile input — and on every entry point, including the `gate()` and `validate()` of guards that bind their own resolver.
20
+
21
+ The failure it ends is quiet, because policies are read leniently. `duplicateKeyPolicy: "rejct"` is not `allow`, so the check still runs; it is not `reject` either, so the finding drops from critical to a warning. The operator asked to refuse a duplicate key and silently got an audit line. Nothing was let through that a stricter reading would have caught, so this was never a hole — but a setting that means something other than what it says is worth a restart to learn about.
22
+
23
+ `b.gateContract.policyVocabulary(names, values, overrides?)` builds these tables: most of a guard's policies answer the same question — refuse the construct, record it, or let it through — and one or two do not. Written out per option instead, a family of nine ends up with eight that accept `audit-only` and one that does not. Each guard also exports its `POLICY_VOCABULARY`, so a caller can ask what an option takes instead of reading the source. · *An option name the guard does not have is refused rather than ignored* — A `*Policy` key that no guard option matches was kept by the option merge, read by nothing, and left the profile default in force. `b.guardMime` had shipped that way: its opts block named `riskyTypesPolicy` while the code reads `riskyTypePolicy`, so an operator following the documentation and setting it to `reject` kept the balanced profile's `audit` and had risky content types reported rather than refused, with nothing to see but the documentation that was wrong to begin with.
24
+
25
+ A guard's policy set is fully described by its profiles, postures and defaults, so a near-miss is decidable and now refused by name. The rest of the option surface stays open — callbacks, caches and host wiring are not enumerable in advance.
26
+
27
+ The two statements a guard makes about an option — the vocabulary it enforces and the opts block an operator reads — are now compared to each other, which is what surfaced this one. · *`kidTraversalPolicy: "allow"` turned off JWT key-path traversal detection* — `b.guardJwt` documents the `kid` traversal check as unconditional, and the header field it inspects is the one that steers key lookup — a `kid` of `../../etc/passwd` reaching a file-backed resolver is the point of the check. The code consulted `kidTraversalPolicy` for an `allow` its opts block never offered, and that value skipped the check entirely.
28
+
29
+ The check no longer reads a policy, and the option accepts `reject` alone. `algNonePolicy` is likewise fixed at `reject`: `alg=none` was already refused unconditionally and the option had never been read. · *Two guardFilename policies were settings the floor already overrode* — `traversalPolicy` and `nullBytePolicy` accepted `allow` and `audit`, and both were overridden by the refusal floor the gate applies to a leaf filename. An operator setting either believed they had turned a class of finding down; the refusal stood regardless, and the two disagreed in the audit record rather than in the verdict. Both now accept `reject` alone, so the floor is visible in what the guard will take rather than only in what it does.
30
+
31
+ `adsPolicy` keeps `allow` for the one place that honours it — `verifyExtractionPath`, for an operator deliberately extracting stream-suffixed names — and no longer accepts an `audit` that did nothing. · *`svgzPolicy` was declared in every profile and read nowhere* — A gzip-compressed SVG is refused on its byte signature before any parse, and `b.guardSvg.sanitize` throws on one outright. `svgzPolicy` sat in all three profiles without being consulted, so an operator writing `svgzPolicy: "allow"` got a silent no-op and a reasonable belief that SVGZ now passed. The option takes `reject` alone, which is what the guard has always done. · *Three policies do not take `audit-only`, because their code reads the literal `audit`* — `audit-only` is the family's documented synonym for `audit`, and most policies treat them identically because the disposition mapper asks one predicate. Three conditions test for the literal spelling instead, and there the two words took different branches with nothing in the option's name to say which you had picked: `pkcePolicy: "audit-only"` refused every OAuth flow missing a PKCE challenge while `"audit"` served it with a finding; `pollutionPolicy: "audit-only"` removed `__proto__` from the parsed object while `"audit"` preserved it, a difference in the data rather than in a finding; and `pathSeparatorsPolicy: "audit-only"` failed a path-shaped name that `"audit"` accepted.
32
+
33
+ `b.guardOauth.pkcePolicy`, `b.guardJson.pollutionPolicy` and `b.guardFilename.pathSeparatorsPolicy` now refuse `audit-only` rather than accepting it and meaning something else by it — as `b.guardArchive.nestedArchivePolicy`, `b.guardDomain.trailingDotPolicy` and `b.guardYaml.tagPolicy` already did, for the same reason. Write `audit` on those six.
34
+
35
+ A detector holds the pairing: a policy compared against the literal `"audit"` may not also advertise the synonym, so the next one is a failing gate rather than two spellings that quietly disagree. · *Which guards refuse `strip` on a character policy, in full* — 0.18.47 made repair capability a declaration rather than something inferred from the presence of a `sanitize`, and named four guards the inference had been wrong about. Fourteen refuse `strip` on the shared character policies; the list is here because the boot error lands on a caller who had a working configuration, and four names is not the set.
36
+
37
+ Refuse it: `b.guardAuth`, `b.guardCidr`, `b.guardCountry`, `b.guardDomain`, `b.guardGraphql`, `b.guardJsonpath`, `b.guardJwt`, `b.guardMime`, `b.guardOauth`, `b.guardRegex`, `b.guardShell`, `b.guardTemplate`, `b.guardTime`, `b.guardUuid`. Accept it: `b.guardEmail`, `b.guardFilename`, `b.guardHtml`, `b.guardJson`, `b.guardMarkdown`, `b.guardSvg`, `b.guardText`, `b.guardXml`, `b.guardYaml`.
38
+
39
+ All fourteen refuse the character rather than removing it. Feed `b.guardUuid.sanitize` an identifier with a zero-width character in it and it throws `uuid.zero-width`, because a UUID with the character removed is a different identifier — and the same holds for a CIDR block, a country code, a domain, a JSONPath, a media type, a timestamp, a GraphQL document, an OAuth flow and an auth bundle. There is no repaired form to route a `strip` to.
40
+
41
+ `b.guardAuth`, `b.guardOauth` and `b.guardGraphql` are the clearest case for making capability a declaration rather than an inference: each carries a `sanitizeTransform` that is the identity function, because an auth bundle and an OAuth flow are composed of values the framework must not rewrite — forging a JWT `alg` or editing a `state` parameter would disarm the token the guard exists to catch. Inferring "can repair" from the presence of a `sanitize` read all three as repairing.
42
+
43
+ `b.guardCsv`, `b.guardImage` and `b.guardPdf` expose no shared character policy at all. guardCsv repairs characters through its own `bidiCharPolicy`, `controlCharPolicy` and `homoglyphPolicy`, and those still take `strip`. · *Twenty opts-block lines offered a repair the guard cannot perform* — `b.guardUuid`, `b.guardCountry`, `b.guardTime`, `b.guardMime`, `b.guardShell`, `b.guardRegex`, `b.guardJsonpath` and `b.guardTemplate` documented `strip` on their character policies. None of them repairs a character — a UUID with a zero-width character in it is not a UUID with the character removed, it is a different identifier — so the value was refused at construction while the documentation offered it. The lines now name what the guards accept.
44
+
11
45
  - v0.18.47 (2026-08-22) — **A misspelled character policy was accepted at boot and quietly meant something else.** `bidiPolicy: "rejct"` was accepted by every door the framework offers — the guard's own option resolver and the gate built from it — and then read leniently at runtime: the scan runs because the value is not `allow`, an issue is raised, and the disposition falls through to refuse. Nothing is let through, so it was never a hole; the operator simply asked for one disposition, got another, and was told nothing at the moment a boot error would have cost them a restart. Ninety-three settings across the guard family behaved that way. **Fixed:** *A character policy refuses a value outside its vocabulary* — The five character-threat policies every guard shares — `bidiPolicy`, `nullBytePolicy`, `controlPolicy`, `zeroWidthPolicy`, `tagsPolicy` — now accept only `allow`, `audit`, `audit-only`, `reject`, and `strip` where the guard can carry a repair out. Anything else is refused where options are resolved, which is boot, not the first request that trips the scan. Every entry point takes the check: the generated resolver, and the `gate()` and `validate()` of the guards that bind their own.
12
46
 
13
47
  The check is derived from each guard's own declared defaults rather than hand-listed per guard, for the same reason the size caps beside it are. Hand-listing is what produced the gap: `b.guardCountry` added this for its own three options after `{ reservedPolicy: "rejcet" }` served a reserved country code and reported success, and the other two hundred and fifty-nine policy options across the family never followed. A guard that declares its own vocabulary explicitly still wins, so that one is unchanged.
@@ -178,8 +178,8 @@ function build(opts) {
178
178
  }
179
179
  }
180
180
 
181
- // Hyperparameters → CycloneDX properties[] kv pairs per spec
182
- // issue #702 EU CRA alignment.
181
+ // Hyperparameters → CycloneDX properties[] kv pairs, per CycloneDX spec
182
+ // issue #702 (EU CRA alignment).
183
183
  var properties = [];
184
184
  if (opts.hyperparameters && typeof opts.hyperparameters === "object") {
185
185
  var keys = Object.keys(opts.hyperparameters);
package/lib/audit-sign.js CHANGED
@@ -85,8 +85,8 @@ var _err = AuditSignError.factory;
85
85
  // via opts.algorithm — e.g. `auditSigning: { algorithm: "ml-dsa-87" }`
86
86
  // for throughput-sensitive deployments. Every key file MUST carry the
87
87
  // `algorithm` field on disk — the framework refuses to load a key file
88
- // that lacks it. The legacy implicit-default-to-ml-dsa-87 fallback was
89
- // removed as part of the pre-v1 compat-shim sweep.
88
+ // that lacks it. There is no implicit default: a key file whose algorithm has
89
+ // to be guessed is a key file whose signatures cannot be attributed.
90
90
  var DEFAULT_SIGNING_ALG = "slh-dsa-shake-256f";
91
91
  // ml-dsa-65 (FIPS 204 Category 3, ~192-bit symmetric security) is opt-
92
92
  // in alongside ml-dsa-87 — same code path (both auto-detected by
@@ -102,7 +102,7 @@ var SIGNING_KEY_SCHEMA = {
102
102
  properties: {
103
103
  publicKey: { type: "string" },
104
104
  privateKey: { type: "string" },
105
- algorithm: { type: "string" }, // load-time-required — _initPlaintext + _initWrapped both throw KEY_FILE_MISSING_ALG / UNWRAPPED_MISSING_ALG when the field is absent (legacy implicit-default-to-ml-dsa-87 was removed in the pre-v1 compat-shim sweep). Schema's `required` keeps publicKey + privateKey only so the runtime checks fire with the precise error codes operators have wired alerting on.
105
+ algorithm: { type: "string" }, // load-time-required — _initPlaintext + _initWrapped both throw KEY_FILE_MISSING_ALG / UNWRAPPED_MISSING_ALG when the field is absent, rather than defaulting to an algorithm the file never named. Schema's `required` keeps publicKey + privateKey only so the runtime checks fire with the precise error codes operators have wired alerting on.
106
106
  },
107
107
  };
108
108
 
@@ -986,7 +986,7 @@ function _requireCredentialIdMatches(response, authoritativeId, why) {
986
986
  // _credentialDescriptors deliberately supports -- while the browser returns
987
987
  // the unpadded spelling. Two spellings of ONE credential must not read as
988
988
  // two credentials, or the compatibility this binding sits next to would
989
- // lock out exactly the deployments it was added for.
989
+ // lock out exactly the deployments it exists for.
990
990
  var expected = _canonicalBase64Url(authoritativeId);
991
991
  var fields = ["id", "rawId"];
992
992
  var stated = 0;
@@ -1697,8 +1697,8 @@ function bundleAdapterStorage(opts) {
1697
1697
  // "unknown" rather than risk a full payload load. For
1698
1698
  // rewrap, we already have to load the payload (to unwrap),
1699
1699
  // so fall back to a sniffEnvelope on the loaded sealed
1700
- // bytes fixes the regression where adapters satisfying
1701
- // the minimum contract couldn't use rewrapBundle.
1700
+ // bytes, which keeps rewrapBundle reachable for an adapter
1701
+ // that satisfies only the minimum contract.
1702
1702
  if (envelopeKind === "unknown") {
1703
1703
  envelopeKind = archiveLazy().sniffEnvelope(sealed);
1704
1704
  }
@@ -136,8 +136,7 @@ function _parseSfBrandList(s) {
136
136
  // sf-list members don't allow parenthesized inner-list values in
137
137
  // the Sec-CH-UA grammar (only sf-string + parameters), so the
138
138
  // simple top-level comma split suffices — no `depth` tracking
139
- // needed (the earlier inline shape carried defensive paren
140
- // tracking left over from a generic sf-list walker prototype).
139
+ // needed.
141
140
  var pieces = structuredFields.splitTopLevel(t, ",");
142
141
  var out = [];
143
142
  for (var i = 0; i < pieces.length; i += 1) {
@@ -860,11 +860,12 @@ function detectCharThreats(text, opts, codePrefix) {
860
860
  // zero-width-only input under `strip` reaches the sanitizer instead of being
861
861
  // served unchanged.
862
862
  //
863
- // This used to be OPT-IN via a `zeroWidthSeverity` argument, which meant a
864
- // caller that forgot it disabled the scan no matter what the operator's
865
- // policy said and six did, including the shared gate path, so seven guards
866
- // declared `zeroWidthPolicy: "reject"` and never applied it. Six more passed
867
- // a hardcoded "warn", which dispositions to serve, so they reported the
863
+ // Not opt-in, and deliberately: an argument the caller has to pass in order
864
+ // for the scan to run is an argument a caller can omit, and omitting it
865
+ // disables the scan no matter what the operator's policy says. Six call
866
+ // sites omitted it, the shared gate path among them, so seven guards declared
867
+ // `zeroWidthPolicy: "reject"` and never applied it. Six more passed a
868
+ // hardcoded "warn", which dispositions to serve, so they reported the
868
869
  // character and shipped it anyway.
869
870
  //
870
871
  // Severity follows the resolved POLICY, not a per-caller constant — the same
@@ -305,7 +305,7 @@ function validateGateShape(gate, label, errorClass) {
305
305
  // - `Object.assign({}, base, ...)` copies OWN enumerable properties, so a
306
306
  // class instance whose fields come from prototype getters arrives with them
307
307
  // missing. A guard reading an absent subject as nothing-to-inspect then
308
- // serves bytes it used to examine.
308
+ // serves bytes it would otherwise examine.
309
309
  // - `Object.create(base)` fixes that but makes inherited getters run with the
310
310
  // DERIVED object as `this`, so a getter returning a private field throws
311
311
  // and a valid context is refused.
@@ -2160,6 +2160,75 @@ var CHAR_POLICY_KEYS = Object.freeze([
2160
2160
  ]);
2161
2161
  var CHAR_POLICY_BASE = Object.freeze(["allow", "audit", "audit-only", "reject"]);
2162
2162
 
2163
+ // The vocabularies a guard's own policies keep reaching for. Named because
2164
+ // most of a guard's policies answer the same question — refuse this construct,
2165
+ // record it, or let it through — and because a hand-written array per option
2166
+ // is where a family of nine drops `audit-only` from one of them.
2167
+ var POLICY_VALUES = Object.freeze({
2168
+ // Refuse the construct, record it, or let it through. `audit-only` is the
2169
+ // family's documented synonym for `audit`.
2170
+ rejectAuditAllow: Object.freeze(["reject", "audit", "audit-only", "allow"]),
2171
+ // The same, where the guard can also remove the construct and still produce
2172
+ // a usable document. Only for a finding the sanitizer genuinely repairs:
2173
+ // routing one to a repair that never happens serves the input as sanitized.
2174
+ rejectStripAuditAllow: Object.freeze(["reject", "strip", "audit", "audit-only", "allow"]),
2175
+ });
2176
+
2177
+ /**
2178
+ * @primitive b.gateContract.policyVocabulary
2179
+ * @signature b.gateContract.policyVocabulary(names, values, overrides?)
2180
+ * @since 0.18.48
2181
+ * @status stable
2182
+ * @related b.gateContract.charPolicyEnums, b.gateContract.defineGuard
2183
+ *
2184
+ * An `enumOpts` table where `names` all take `values`, with `overrides` merged
2185
+ * over the top for the options that differ. Most of a guard's policies share
2186
+ * one vocabulary and one or two do not, and writing the shared one out per
2187
+ * option is how a family of nine ends up with eight that accept `audit-only`
2188
+ * and one that does not.
2189
+ *
2190
+ * @example
2191
+ * var POLICY_ENUM = gateContract.policyVocabulary(
2192
+ * ["doctypePolicy", "entityPolicy", "cdataPolicy"],
2193
+ * gateContract.POLICY_VALUES.rejectAuditAllow,
2194
+ * { xmlDsigPolicy: ["audit", "audit-only", "allow"] });
2195
+ * POLICY_ENUM.entityPolicy; // → ["reject", "audit", "audit-only", "allow"]
2196
+ * POLICY_ENUM.xmlDsigPolicy; // → ["audit", "audit-only", "allow"]
2197
+ */
2198
+ function policyVocabulary(names, values, overrides) {
2199
+ if (!Array.isArray(names) || !Array.isArray(values) || values.length === 0) {
2200
+ throw GateContractError.factory("gate-contract/bad-opt",
2201
+ "gateContract.policyVocabulary: names and values must both be arrays " +
2202
+ "and values must be non-empty");
2203
+ }
2204
+ // Frozen a level down, and from a copy. A guard publishes this table as its
2205
+ // `POLICY_VOCABULARY` while the resolver checks against the same arrays, so
2206
+ // an introspection caller that pushed onto one would be editing the rule, not
2207
+ // reading it: `POLICY_VOCABULARY.symlinkPolicy.push("bogus")` and the guard
2208
+ // accepts "bogus". Copying also keeps the caller's own array theirs.
2209
+ var out = {};
2210
+ var shared = Object.freeze(values.slice());
2211
+ names.forEach(function (name) {
2212
+ out[name] = shared;
2213
+ });
2214
+ if (overrides && typeof overrides === "object") {
2215
+ Object.keys(overrides).forEach(function (name) {
2216
+ // A non-array override would be dropped by the resolver's own
2217
+ // `Array.isArray` test and leave the option unconstrained — the option
2218
+ // would read as declared while accepting anything, which is the state
2219
+ // this whole mechanism exists to end. It is an author mistake, so it
2220
+ // belongs where the author is looking.
2221
+ if (!Array.isArray(overrides[name]) || overrides[name].length === 0) {
2222
+ throw GateContractError.factory("gate-contract/bad-opt",
2223
+ "gateContract.policyVocabulary: override for " + JSON.stringify(name) +
2224
+ " must be a non-empty array of values");
2225
+ }
2226
+ out[name] = Object.freeze(overrides[name].slice());
2227
+ });
2228
+ }
2229
+ return Object.freeze(out);
2230
+ }
2231
+
2163
2232
  /**
2164
2233
  * @primitive b.gateContract.charPolicyEnums
2165
2234
  * @signature b.gateContract.charPolicyEnums(defaults, opts?)
@@ -2682,10 +2751,77 @@ function resolveProfileAndPosture(opts, cfg) {
2682
2751
  }
2683
2752
  });
2684
2753
  }
2754
+ // The same failure with the NAME wrong instead of the value, which is
2755
+ // quieter because there is no value to inspect: the merge keeps the key,
2756
+ // nothing reads it, and the profile default stays in force. An operator who
2757
+ // sets `riskyTypesPolicy: "reject"` on a guard whose option is
2758
+ // `riskyTypePolicy` keeps the balanced profile's "audit" and gets no sign
2759
+ // the setting was dropped.
2760
+ //
2761
+ // Only `*Policy` keys are decidable this way: a guard's policy set is fully
2762
+ // described by its profiles, postures and defaults, so anything else with
2763
+ // that suffix is a near-miss. The rest of the opts surface stays open by
2764
+ // design — callbacks, caches and host wiring are not enumerable in advance.
2765
+ _refuseUnknownPolicyKeys(opts, resolved, cfg, ErrorClass, prefix);
2685
2766
  return resolved;
2686
2767
  }
2687
2768
 
2688
2769
 
2770
+ // A published copy of an enumOpts table, frozen a level down so a caller
2771
+ // reading it cannot reorder the guard's own vocabulary underneath it.
2772
+ function _freezeVocabulary(enumOpts) {
2773
+ var out = {};
2774
+ if (enumOpts && typeof enumOpts === "object") {
2775
+ Object.keys(enumOpts).forEach(function (k) {
2776
+ out[k] = Array.isArray(enumOpts[k]) ? Object.freeze(enumOpts[k].slice()) : enumOpts[k];
2777
+ });
2778
+ }
2779
+ return Object.freeze(out);
2780
+ }
2781
+
2782
+ // Every `*Policy` name this guard answers to: whatever its defaults carry, plus
2783
+ // whatever any profile or posture overlay can introduce, plus the vocabularies
2784
+ // declared for it. The union matters — a policy that only a strict profile sets
2785
+ // is still a real option, and `tagsPolicy` is governed through the family's
2786
+ // derivation without appearing in any defaults block.
2787
+ function _knownPolicyKeys(cfg) {
2788
+ var known = Object.create(null);
2789
+ function absorb(o) {
2790
+ if (!o || typeof o !== "object") return;
2791
+ Object.keys(o).forEach(function (k) {
2792
+ if (/Policy$/.test(k)) known[k] = true;
2793
+ });
2794
+ }
2795
+ absorb(cfg.defaults);
2796
+ absorb(cfg.enumOpts);
2797
+ [cfg.profiles, cfg.compliancePostures].forEach(function (table) {
2798
+ if (!table || typeof table !== "object") return;
2799
+ Object.keys(table).forEach(function (name) { absorb(table[name]); });
2800
+ });
2801
+ return known;
2802
+ }
2803
+
2804
+ // Refuses a caller-supplied `*Policy` the guard has no such option for. Reads
2805
+ // the caller's own opts rather than the merged result, so a guard's internal
2806
+ // wiring is never the thing that fails, and stays silent for a guard that has
2807
+ // no policies at all — there, the suffix carries no meaning to trade on.
2808
+ function _refuseUnknownPolicyKeys(opts, resolved, cfg, ErrorClass, prefix) {
2809
+ if (!opts || typeof opts !== "object") return;
2810
+ var supplied = Object.keys(opts).filter(function (k) {
2811
+ return /Policy$/.test(k) && opts[k] !== undefined;
2812
+ });
2813
+ if (!supplied.length) return;
2814
+ var known = _knownPolicyKeys(cfg);
2815
+ var names = Object.keys(known);
2816
+ if (!names.length) return;
2817
+ for (var i = 0; i < supplied.length; i += 1) {
2818
+ if (known[supplied[i]]) continue;
2819
+ throw ErrorClass.factory(prefix + ".bad-opt",
2820
+ prefix + ": no option named " + JSON.stringify(supplied[i]) +
2821
+ " — this guard's policies are " + names.sort().join(", "));
2822
+ }
2823
+ }
2824
+
2689
2825
  // The own enumerable properties of `o` that were actually given a value. A key
2690
2826
  // present with the value `undefined` is treated as absent, so it cannot erase
2691
2827
  // a default it was never meant to touch.
@@ -3458,6 +3594,14 @@ function defineGuard(spec) {
3458
3594
  // "non-negative integer", because derivation cannot tell a cap from a
3459
3595
  // tolerance. Exposed so the family tests can tell the two apart.
3460
3596
  INT_OPTS: Object.freeze(Array.isArray(spec.intOpts) ? spec.intOpts.slice() : []),
3597
+ // The value each policy option accepts, after the family's character
3598
+ // policies are folded in. Exposed for the same reason as INT_OPTS: the
3599
+ // family sweep has to ask each guard what it takes rather than assume one
3600
+ // vocabulary for all of them. Where a guard pins a policy to a single
3601
+ // value — guardFilename's traversal, guardJwt's alg=none — the answer is a
3602
+ // one-element list, and a sweep that assumed otherwise would report the
3603
+ // pinning as a regression.
3604
+ POLICY_VOCABULARY: _freezeVocabulary(_withCharPolicyEnums(defaults, spec)),
3461
3605
  validate: spec.validate,
3462
3606
  resolveOpts: _resolveGuardOpts,
3463
3607
  buildProfile: buildProfileFn,
@@ -3821,6 +3965,8 @@ module.exports = {
3821
3965
  // list cannot drift away from them.
3822
3966
  capKeysOf: capKeysOf,
3823
3967
  charPolicyEnums: charPolicyEnums,
3968
+ policyVocabulary: policyVocabulary,
3969
+ POLICY_VALUES: POLICY_VALUES,
3824
3970
  identitySanitize: identitySanitize,
3825
3971
  ctxValueFrom: ctxValueFrom,
3826
3972
  runIssueValidator: runIssueValidator,
@@ -115,11 +115,12 @@ var MAGIC_SIGNATURES = Object.freeze([
115
115
 
116
116
  // Character-class policy for an entry NAME comes from `filenameProfile`, which
117
117
  // routes the name through b.guardFilename — this guard never reads a
118
- // bidi/control/null/zero-width policy of its own. It used to declare them in
119
- // every profile anyway, so an operator passing `zeroWidthPolicy` here was
120
- // configuring nothing and had no way to find that out. The declarations are
121
- // gone rather than wired, because `filenameProfile` is already the one place
122
- // that decides it and two spellings of the same setting is how they disagree.
118
+ // bidi/control/null/zero-width policy of its own, and does not declare them in
119
+ // its profiles either. Declaring an option nothing reads leaves an operator
120
+ // setting `zeroWidthPolicy` here configuring nothing, with no way to find that
121
+ // out. Wiring them instead would give the same setting two spellings, which is
122
+ // how the two come to disagree, and `filenameProfile` is already the one place
123
+ // that decides it.
123
124
  var PROFILES = Object.freeze({
124
125
  "strict": {
125
126
  traversalPolicy: "reject",
@@ -197,6 +198,20 @@ var INT_OPTS = ["maxEntries", "maxTotalBytes", "maxEntryBytes",
197
198
  // DERIVED from DEFAULTS rather than written out, because a hand-kept list is
198
199
  // what drifted away from the defaults it was meant to mirror and left limits
199
200
  // unchecked across the family.
201
+ // Each policy's vocabulary, so a misspelling is a boot error rather than a
202
+ // runtime surprise. Read leniently, a typo takes whichever branch is not the
203
+ // strict one: `symlinkPolicy: "rejct"` is not "allow", so the check runs, and
204
+ // it is not "reject" either, so the finding drops from critical to warn.
205
+ //
206
+ // `audit-only` is deliberately absent, unlike elsewhere in the family:
207
+ // nestedArchivePolicy tests for "audit" exactly, so the synonym would fall
208
+ // past both branches and record nothing at all.
209
+ var POLICY_ENUM = gateContract.policyVocabulary([
210
+ "traversalPolicy", "absolutePathPolicy", "symlinkPolicy", "hardlinkPolicy",
211
+ "nestedArchivePolicy", "duplicateNamePolicy", "caseInsensitiveCollisionPolicy",
212
+ "encryptionPolicy", "sparseEntryPolicy",
213
+ ], ["reject", "audit", "allow"]);
214
+
200
215
  function _resolveOpts(opts) {
201
216
  return gateContract.resolveProfileAndPosture(opts, {
202
217
  profiles: PROFILES,
@@ -206,6 +221,7 @@ function _resolveOpts(opts) {
206
221
  errCodePrefix: "archive",
207
222
  intOpts: INT_OPTS,
208
223
  nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
224
+ enumOpts: POLICY_ENUM,
209
225
  });
210
226
  }
211
227
 
@@ -866,6 +882,9 @@ module.exports = {
866
882
  // ---- guard-* family registry exports (consumed by b.guardAll) ----
867
883
  NAME: "archive",
868
884
  KIND: "entries", // archive-entries guard (consumes ctx.entries)
885
+ // The value each policy accepts. Named here rather than derived, because
886
+ // this guard binds its own resolver instead of going through defineGuard.
887
+ POLICY_VOCABULARY: POLICY_ENUM,
869
888
  INTEGRATION_FIXTURES: Object.freeze({
870
889
  kind: "entries",
871
890
  contentType: "application/zip",
package/lib/guard-auth.js CHANGED
@@ -393,7 +393,35 @@ var INTEGRATION_FIXTURES = Object.freeze({
393
393
  // surface (validate / sanitize / bespoke gate) passed through verbatim.
394
394
  // The custom KIND ("auth-bundle") is accepted because the bespoke gate
395
395
  // reads its own ctx fields (ctx.authBundle / ctx.auth).
396
+ // The policies this wrapper accepts on the wrapped guard's behalf, with the
397
+ // wrapped guard's own vocabulary for each. Derived rather than restated: a
398
+ // second copy of guardOauth's values here would be free to drift from the ones
399
+ // the child actually enforces, and the operator would meet whichever of the
400
+ // two happened to be wrong.
401
+ //
402
+ // Without this, forwarding `codeReusePolicy` through the wrapper failed at the
403
+ // wrapper's own resolver, because the name belongs to the child and this
404
+ // guard's profiles never mention it.
405
+ //
406
+ // The character policies are excluded even though they are forwarded: their
407
+ // vocabulary is derived per guard from whether that guard can repair a
408
+ // character, and this guard cannot. Taking the child's answer would hand this
409
+ // one a `strip` it has no way to carry out the moment the two differ — the
410
+ // substitution that accepting-then-refusing was introduced to stop.
411
+ var CHILD_OWNED_POLICIES = Object.freeze([
412
+ "bidiPolicy", "controlPolicy", "nullBytePolicy", "zeroWidthPolicy", "tagsPolicy",
413
+ ]);
414
+ var FORWARDED_POLICY_ENUM = Object.freeze(
415
+ OAUTH_FORWARDED_OPTS.reduce(function (acc, key) {
416
+ if (CHILD_OWNED_POLICIES.indexOf(key) !== -1) return acc;
417
+ var vocabulary = guardOauth.POLICY_VOCABULARY &&
418
+ guardOauth.POLICY_VOCABULARY[key];
419
+ if (Array.isArray(vocabulary)) acc[key] = vocabulary;
420
+ return acc;
421
+ }, {}));
422
+
396
423
  var _guard = module.exports = gateContract.defineGuard({
424
+ enumOpts: FORWARDED_POLICY_ENUM,
397
425
  name: "auth",
398
426
  kind: "auth-bundle",
399
427
  errorClass: GuardAuthError,
package/lib/guard-cidr.js CHANGED
@@ -494,10 +494,29 @@ var INTEGRATION_FIXTURES = gateContract.identifierFixtures("8.8.8.0/24", "10.0.0
494
494
  // standard serve -> audit-only -> refuse chain — reading ctx.identifier ||
495
495
  // ctx.cidr via ctxFields. No sanitize action: an allowlist gate never
496
496
  // rewrites the operator's stored network range.
497
+ // Each policy's vocabulary, so a misspelling is a boot error rather than a
498
+ // runtime surprise. Read leniently, a typo takes whichever branch is not the
499
+ // strict one: `reservedRangesPolicy: "rejct"` is not "allow", so the check
500
+ // runs, and it is not "reject" either, so the finding drops to a warning — the
501
+ // operator asked to refuse a reserved range and silently got an audit.
502
+ //
503
+ // requireMaskPolicy carries its own spellings because the question is about the
504
+ // mask rather than a threat disposition. `audit-only` rides with `audit` on the
505
+ // three that take one, matching how the framework treats the two everywhere
506
+ // else.
507
+ var POLICY_ENUM = gateContract.policyVocabulary([
508
+ "networkAlignmentPolicy", "reservedRangesPolicy", "ipv4MappedIpv6Policy",
509
+ ], gateContract.POLICY_VALUES.rejectAuditAllow, {
510
+ // Names its own values because the finding is an absence — a bare address
511
+ // where a mask was expected — so "reject" alone would not say what of.
512
+ requireMaskPolicy: ["allow-bare-ip", "audit-bare-ip", "reject-bare-ip"],
513
+ });
514
+
497
515
  module.exports = gateContract.defineGuard({
498
516
  name: "cidr",
499
517
  kind: "identifier",
500
518
  errorClass: GuardCidrError,
519
+ enumOpts: POLICY_ENUM,
501
520
  profiles: PROFILES,
502
521
  base: 128,
503
522
  integrationFixtures: INTEGRATION_FIXTURES,
@@ -348,10 +348,10 @@ function _detectIssues(input, opts) {
348
348
  * @opts
349
349
  * profile: "strict"|"balanced"|"permissive",
350
350
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
351
- * bidiPolicy: "reject"|"strip"|"audit"|"allow",
352
- * controlPolicy: "reject"|"strip"|"allow",
353
- * nullBytePolicy: "reject"|"strip"|"allow",
354
- * zeroWidthPolicy: "reject"|"strip"|"allow",
351
+ * bidiPolicy: "reject"|"audit"|"allow",
352
+ * controlPolicy: "reject"|"audit"|"allow",
353
+ * nullBytePolicy: "reject"|"audit"|"allow",
354
+ * zeroWidthPolicy: "reject"|"audit"|"allow",
355
355
  * reservedPolicy: "reject"|"audit"|"allow",
356
356
  * userAssignedPolicy: "reject"|"audit"|"allow",
357
357
  * formerlyUsedPolicy: "reject"|"audit"|"allow",
package/lib/guard-csv.js CHANGED
@@ -639,7 +639,7 @@ function _stripIssues(text, opts) {
639
639
  * twice does not stack prefixes.
640
640
  *
641
641
  * @opts
642
- * formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"allowlist",
642
+ * formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"allowlist"|"audit-only"|"allow",
643
643
  * formulasAllowlist: string[], // when policy === "allowlist"
644
644
  * bidiCharPolicy: "reject"|"strip"|"audit"|"allow",
645
645
  * controlCharPolicy: "reject"|"strip"|"allow",
@@ -1047,7 +1047,7 @@ function serialize(rows, opts) {
1047
1047
  * controlCharPolicy: "reject"|"strip"|"allow",
1048
1048
  * nullByteHandling: "reject"|"strip"|"allow",
1049
1049
  * homoglyphPolicy: "audit"|"strip"|"allow",
1050
- * formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"audit-only"|"allow",
1050
+ * formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"allowlist"|"audit-only"|"allow",
1051
1051
  * dangerousFunctions: string[],
1052
1052
  * dialectPolicy: "strict"|"permissive",
1053
1053
  *
@@ -1409,7 +1409,36 @@ var INTEGRATION_FIXTURES = Object.freeze({
1409
1409
  // extras (serialize / escapeCell / detect / schema / FORMULA_PREFIXES /
1410
1410
  // DANGEROUS_FUNCTIONS) passed through verbatim. The bespoke `gate` carries
1411
1411
  // CSV's sanitize-reparse-reserialize chain unchanged.
1412
+ // Each policy's vocabulary, so a misspelling is a boot error rather than a
1413
+ // runtime surprise. Read leniently, a typo takes whichever branch is not the
1414
+ // strict one: `formulaInjectionPolicy: "prefix-tabb"` is neither "allow" nor
1415
+ // "audit-only", so a mitigation is attempted, and it is not a spelling the
1416
+ // mitigation recognises — the operator asked for a tab prefix and got something
1417
+ // else without being told.
1418
+ //
1419
+ // The sets are the UNION of what the code compares, what the opts blocks
1420
+ // document and what the shipped profiles select, because no single source was
1421
+ // complete: the two opts blocks disagreed with each other, one listing
1422
+ // `allowlist` and the other `audit-only` / `allow`, while the code compares all
1423
+ // three. Both blocks now carry the full set.
1424
+ //
1425
+ // The last four are MODE settings rather than threat dispositions — a dialect,
1426
+ // a numeric rendering, whether whitespace is kept — so they share nothing with
1427
+ // the disposition vocabulary and are listed on their own terms.
1428
+ var POLICY_ENUM = Object.freeze({
1429
+ formulaInjectionPolicy: ["prefix-tab", "prefix-quote", "wrap-with-quotes-and-prefix",
1430
+ "reject", "allowlist", "audit-only", "allow"],
1431
+ bidiCharPolicy: ["reject", "strip", "audit", "allow"],
1432
+ controlCharPolicy: ["reject", "strip", "allow"],
1433
+ homoglyphPolicy: ["audit", "strip", "allow"],
1434
+ trailingWhitespacePolicy: ["trim", "preserve", "reject"],
1435
+ dialectPolicy: ["strict", "permissive"],
1436
+ numericPrecisionPolicy: ["decimal-string-above-safe-int", "scientific", "reject-bigint"],
1437
+ piiPolicy: ["preserve", "redact"],
1438
+ });
1439
+
1412
1440
  module.exports = gateContract.defineGuard({
1441
+ enumOpts: POLICY_ENUM,
1413
1442
  name: "csv",
1414
1443
  kind: "content",
1415
1444
  errorClass: GuardCsvError,
@@ -657,7 +657,29 @@ var INTEGRATION_FIXTURES = gateContract.identifierFixtures("example.com", "192.1
657
657
  // standard serve -> audit-only -> refuse chain — reading ctx.identifier ||
658
658
  // ctx.domain via ctxFields. No sanitize action: an allowlist gate never
659
659
  // rewrites the operator's stored allowlist key.
660
+ // Each policy's vocabulary, taken from this guard's own documented opts above,
661
+ // so a misspelling is a boot error rather than a runtime surprise. Read
662
+ // leniently, a typo takes whichever branch is not the strict one:
663
+ // `punycodePolicy: "rejct"` is not "allow", so the check runs, and it is not
664
+ // "reject" either, so the finding drops to a warning — the operator asked to
665
+ // refuse punycode and silently got an audit.
666
+ //
667
+ // trailingDotPolicy is deliberately different on both counts: it takes
668
+ // `normalize` rather than `allow`, and it does NOT take `audit-only`, because
669
+ // its check compares `=== "audit"` exactly and the synonym would not be
670
+ // honoured. A shared list would have advertised it.
671
+ var POLICY_ENUM = gateContract.policyVocabulary([
672
+ "ldhPolicy", "punycodePolicy", "mixedScriptPolicy", "specialUsePolicy",
673
+ "ipLiteralPolicy", "wildcardPolicy", "singleLabelPolicy", "underscorePolicy",
674
+ "dgaPolicy",
675
+ ], gateContract.POLICY_VALUES.rejectAuditAllow, {
676
+ // No `allow`, and no `audit-only`: a trailing dot is either removed or
677
+ // reported, and the code compares for "audit" exactly.
678
+ trailingDotPolicy: ["normalize", "audit", "reject"],
679
+ });
680
+
660
681
  module.exports = gateContract.defineGuard({
682
+ enumOpts: POLICY_ENUM,
661
683
  name: "domain",
662
684
  kind: "identifier",
663
685
  errorClass: GuardDomainError,
@@ -1115,7 +1115,24 @@ var INTEGRATION_FIXTURES = Object.freeze({
1115
1115
  // the address/message entries (validateAddress / validateMessage) passed
1116
1116
  // through verbatim. The bespoke `gate` validates via validateMessage and
1117
1117
  // carries the serve->audit-only->refuse chain unchanged.
1118
+ // Each policy's vocabulary, so a misspelling is a boot error rather than a
1119
+ // runtime surprise. Read leniently, a typo takes whichever branch is not the
1120
+ // strict one: `crlfHeaderInjectionPolicy: "rejct"` is not "allow", so the
1121
+ // check runs, and it is not "reject" either, so a header-injection attempt
1122
+ // drops to a warning.
1123
+ //
1124
+ // The character policies are absent on purpose — they are derived for the
1125
+ // whole guard family, and an entry here would shadow that derivation.
1126
+ var POLICY_ENUM = gateContract.policyVocabulary([
1127
+ "multiAtPolicy", "ipLiteralPolicy", "punycodePolicy", "mixedScriptPolicy",
1128
+ "addressCommentPolicy", "crlfHeaderInjectionPolicy", "bareCrPolicy",
1129
+ "bareLfPolicy", "smtpSmugglingPolicy", "displayNameSpoofPolicy",
1130
+ ], gateContract.POLICY_VALUES.rejectAuditAllow, {
1131
+ bomPolicy: gateContract.POLICY_VALUES.rejectStripAuditAllow,
1132
+ });
1133
+
1118
1134
  module.exports = gateContract.defineGuard({
1135
+ enumOpts: POLICY_ENUM,
1119
1136
  name: "email",
1120
1137
  kind: "content",
1121
1138
  charRepair: true,