@blamejs/core 0.18.49 → 0.18.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,88 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.51 (2026-08-22) — **Five parsers could be made to spend their time on the thing they were parsing.** Three prompt-injection detectors, the BIMI logo parser and the raw-write data-residency gate each ran a pattern whose cost grew with the square or the cube of its input while an ordinary input of the same length cost a millisecond. A 64 KiB prompt took up to 4.5 seconds to classify; a 32 KiB logo took 409 milliseconds to parse; a 4 KB raw UPDATE took 7 seconds to get a verdict from a gate that accepts statements twenty-four times longer. All five now cost what ordinary input of that length costs, and each was checked against a corpus to confirm it still decides what it decided before. Separately, a verified VMC logo was reported as absent when its SVG began with an XML declaration and a DOCTYPE. **Changed:** *`b.guardFilename` no longer repairs a null byte, and five options that accept one value now say so* — 0.18.47 accepted `nullBytePolicy: "strip"` on `b.guardFilename` and removed the null byte from the name. 0.18.48 stopped accepting it. That removal was correct and was not written down, which is the part being fixed here.
12
+
13
+ It was correct because a null byte in a filename is a truncation attack, not a typo: the name the check reads and the name the operating system acts on differ at the byte, so repairing it produces a name nobody validated. The guard refuses with `filename.null-byte` and the message has said `null-byte truncation is never sanitizable` throughout. There is no replacement policy value, because there is no safe repair. A caller that was asking for the strip should refuse the input instead, or rename before validating.
14
+
15
+ Separately, five options across three guards accept exactly one value: `nullBytePolicy` and `traversalPolicy` on `b.guardFilename`, `algNonePolicy` and `kidTraversalPolicy` on `b.guardJwt`, and `svgzPolicy` on `b.guardSvg`. Each is a check where any disposition other than refusing is a hole, so the single value is a deliberate lock. They were refusing a wrong value with `must be one of reject`, which reads as a vocabulary that lost its other members and sends the reader looking for them. The refusal now says the option is fixed and not configurable. Passing the legal value still resolves, so nothing that spells it out breaks; what is enforced is unchanged. · *Vendored `@blamejs/pki` refreshed to 0.5.28* — The bundle behind `b.mtlsCa` and `b.auth.passkey`, moved on from 0.5.25 across three releases.
16
+
17
+ The one that matters is in 0.5.28: a `CryptoKey`'s `algorithm` is now immutable once the key exists. The engine reads `key.algorithm.hash` at sign time, and while that object could still be replaced — including from a microtask during the signing await — the hash checked against a JWS `alg` header could be rewritten between the check and the signature, producing a JWS whose signature does not match the algorithm it advertises.
18
+
19
+ 0.5.27 makes an unrecognized option to `pki.trust.anchor` or the `pki.acme.client` constructor a named error rather than a silent default, so a misspelled security-relevant setting can no longer read as absent. 0.5.26 adds the RFC 9483 revocation and support-message exchanges to the CMP session, with each response bound to the request that asked for it. 0.5.28 also stops the toolkit printing Node experimental-feature warnings when it loads.
20
+
21
+ Nothing in this framework's surface changes; `lib/vendor/MANIFEST.json` carries the new version and hash. **Fixed:** *A VMC logo was not extracted when the SVG began with a declaration and a DOCTYPE* — `b.mail.bimi.fetchAndVerifyMark` documents that `mark` carries the SVG whenever the certificate's RFC 3709 logotype extension is present. It decided whether an extension payload was an SVG by looking for `<svg` in the first 64 characters, and a conformant SVG may put an XML declaration, a DOCTYPE and a comment ahead of its root element. In a document with the usual SVG 1.1 DOCTYPE the root sits 154 characters in, so the logo was signed into the certificate, served, verified, and then reported as absent: `mark.svg` came back null.
22
+
23
+ The scanner now steps over what XML actually permits ahead of a root element — whitespace and a byte-order mark, the declaration, processing instructions, comments, and a DOCTYPE including an internal subset — and then asks whether `<svg` begins there. A prologue has no length limit, so no fixed window is the right answer for this; whichever number were chosen, a legal document could put its root past it.
24
+
25
+ The check is also stricter than the one it replaces in two ways. A document that merely mentions `<svg` inside a comment is no longer taken for a logo, and a root whose name merely begins with those letters — `<svgfoo`, or `<svg.foo`, since `.` is a legal name character — is no longer taken for `<svg`. A payload with no root element at all still yields null however long it is.
26
+
27
+ The alternative pattern that stood beside the original search looked like it was meant for exactly this case, but it required `<svg` inside the same 64 characters the search already covered, so it could never have supplied an answer the search had not. It is gone. **Security:** *`b.ai.input.classify` could be made to spend seconds on a single 64 KiB prompt* — Three of the injection detectors ran a pattern whose leading run could begin at every offset inside a stretch of characters it accepts. At each offset the engine consumed the whole run before the part that had to follow it failed to arrive, so one call cost the length of the input squared.
28
+
29
+ `classify` accepts 64 KiB by default. Measured at that size, against 1 millisecond for ordinary text of exactly the same length:
30
+
31
+ - The base64-marker detector looks for a long base64-looking run followed by a word like `means` or `decodes to`. `/` is inside the run's character class, so text such as `a/a/a/…` feeds it: **4,536ms**.
32
+ - The role-tag detector matched `<`, optional whitespace, an optional `/`, optional whitespace. Two whitespace runs either side of an optional character means every way of dividing a run of spaces between them is retried: **1,642ms** on `<` followed by spaces.
33
+ - The event-handler detector matched `on` followed by word characters. `o` and `n` are themselves word characters, so `onononon…` begins a match at every other offset and each one walks to the end of the input: **870ms**.
34
+
35
+ All three cost the shape rather than the size, and an operator who raises `maxBytes` raises the cost with the square of it. This is the primitive whose entire premise is that its input is hostile, which is what makes it worth naming plainly: the scan meant to catch an attack was one.
36
+
37
+ Each run may now only begin where the preceding character is not one the run itself accepts, and the optional `/` in the role tag carries the whitespace that follows it so there is only one way to divide any input. All three now classify a hostile-shaped 64 KiB prompt in 1.3 to 1.5 milliseconds, which is what benign input of that length costs.
38
+
39
+ The role-tag detector accepts exactly the inputs it accepted before, confirmed across 5,214 combinations of spacing, slash placement and tag name. The base64 detector was checked against fifteen cases covering padding, the three trailing keywords, and blobs at the start of a string, after a newline, after a letter, after a slash and after an `=`.
40
+
41
+ One deliberate narrowing comes with the event-handler detector. It no longer matches an `on` that begins in the middle of a word, so `fooonclick="fetch(1)"` is no longer reported. An event-handler attribute begins a word, so that was a false report rather than coverage; `onclick=` still matches at the start of the input and after any character that is not a word character, which covers every place an attribute can actually appear, and every `<script` form is untouched. · *Compiling a JSON Schema could be made to hang before any instance was validated* — 0.18.50 added a pre-screen that walks every subschema position so a `pattern` is checked before it can run. The walk was bounded only by depth, and a depth bound limits how far one path runs while saying nothing about how many paths there are. A schema graph that reaches one object through a branching position was therefore walked once per path rather than once per object.
42
+
43
+ Building a schema out of reused JavaScript objects produces exactly that shape and needs no `$ref`, so nothing about the source reads as unusual: `anyOf: [shared, shared]` at each of twenty levels is twenty-one objects and a million paths. Measured, twenty-one objects took 2.6 seconds and each added level doubled it; the depth cap allowed 256. A schema that referred to itself never finished at all.
44
+
45
+ The same shape was present, and older, in the walk that indexes subschemas for `$ref` resolution, where it was the larger cost. That one cannot skip a repeated object, because it indexes each position by pointer and a shared object legitimately has one entry per pointer that reaches it. So the screen now visits each object once, and the index carries a ceiling on how many positions it will register, refusing beyond it with `json-schema/schema-too-large`. The ceiling is far above any authored schema: a thousand-endpoint OpenAPI document is tens of thousands of positions.
46
+
47
+ This is worth naming plainly. `SECURITY.md` promises that a content screen costs the length of its input and never a function of its shape, and names this call site as one of the places that promise is kept. The screen added to keep it was itself a function of shape.
48
+
49
+ Reported privately from downstream vendoring during a routine dependency refresh. · *A raw write to a data-residency table could hang the process in its own residency gate* — `b.db.runSql`, `execRaw` and `b.db.prepare(sql).run()` bypass the structured builder, so a residency gate parses the statement itself and refuses anything it cannot read. Both of its body patterns ended `\s*;?\s*$`, and the UPDATE one put a lazy run in front of that. The lazy run and both whitespace runs can all absorb the same trailing spaces, with an optional semicolon between two of them, so every division of a trailing whitespace run is a distinct path and the lazy run retries all of them at every length it takes.
50
+
51
+ The growth is cubic: 117 milliseconds on a 1 KB statement, 6.8 seconds on 4 KB, roughly eight times the cost for each doubling. Driven through `b.db.runSql` against a residency table, a 4 KB `UPDATE` carrying trailing whitespace took 7,071 milliseconds before the gate reached a verdict.
52
+
53
+ The gate caps its parse input at 100,000 characters and the comment above that cap says it exists to bound these scans. It does the opposite: it is the ceiling that lets a single statement reach a cost measured in hours. A statement long enough to matter is not exotic either, since the body is whatever SQL the application wrote.
54
+
55
+ The trailing semicolon and the whitespace around it are now walked off the end before either pattern runs, which leaves one greedy run with nothing after it to divide. The same parse costs 0.04 milliseconds at the full 100,000-character cap. Captures are unchanged across a corpus of qualified names, quoted identifiers, embedded semicolons inside string literals, repeated terminators and mixed casing.
56
+
57
+ One shape now parses differently: `UPDATE t SET` followed only by whitespace, which has no SET body at all. It previously parsed with a whitespace body and is now refused as unparseable, which is the direction this gate already documents for anything it cannot read.
58
+
59
+ This affects deployments that have declared per-row or per-column residency; a table without a residency declaration never enters the gate. · *A BIMI logo could cost 409 milliseconds to parse* — The Tiny-PS attribute parser reads names matching `[A-Za-z_:][A-Za-z0-9:._-]*`, and the same restart applied: inside a long run of name characters the name could begin at every offset, consuming the run before `\s*=\s*` failed.
60
+
61
+ A logo is fetched from a URL published in the sending domain's own DNS record, so those bytes are chosen by whoever controls the domain. `validateTinyPsSvg` refuses anything over 32 KiB before it parses, and at that size a hostile-shaped document took 409ms against 0.6ms for a well-formed SVG of the same size.
62
+
63
+ An attribute name may now only begin after a character that is not a name character, which is what XML requires in any case. Checked against a fifteen-case corpus covering namespaced names, dotted and dashed names, bare-token values, single quotes, empty values, extra whitespace around `=`, and attributes with no space between them: the same attributes are read, with the same values.
64
+
65
+ - v0.18.50 (2026-08-22) — **Five places let someone else choose how much CPU your server spends on one match.** A Sieve `:matches` wildcard, a JSONPath `match()` filter and a JSON Schema `pattern` each took a pattern from outside the framework and ran it on a backtracking engine. A Sieve rule with five wildcards spent 452ms on a 60-character Subject; a JSONPath filter spent 7 seconds on a 29-character value; a schema pattern spent 1.5 seconds on 28 characters and doubled with every two more. Two more were the same cost from the other direction, a fixed pattern against attacker-shaped input: a DKIM tag scan and a scanner-reply trim, both quadratic. All five now refuse the shape or match without backtracking. **Security:** *`b.mail.sieve` matches `:matches` wildcards without a regular expression* — RFC 5228 `:matches` uses `*` for any sequence and `?` for one character. Translating that into a regular expression turns each `*` into `.*`, and a backtracking engine facing several of them tries every way of dividing the subject between them. The cost is polynomial in the subject length with degree equal to the number of wildcards.
66
+
67
+ Measured on the shipped path, `b.mail.sieve.runScript` with `if header :matches "Subject" ...` and a subject that never supplies the pattern's trailing literal: three wildcards grew by a factor of 7.7 for each doubling of the subject, and ten wildcards — which a script author can type without meaning anything by it — did not finish on a 64-character Subject.
68
+
69
+ The `maxGas` budget did not bound it. Gas counts operations, and one match is one operation however long the engine spends inside it: five wildcards cost 452ms for three gas units.
70
+
71
+ This matters because of who writes the script. Sieve is a user-level filtering language, so in most deployments the pattern comes from the mailbox owner rather than from you. The wildcard is now matched directly against the value, walking both strings with a single remembered wildcard position, so the work is bounded by their lengths rather than by how the pattern is shaped. Every pattern decides exactly as it did before, checked across 42,976 pattern/subject/case combinations against the previous implementation.
72
+
73
+ One deliberate change of behaviour comes with it. `:comparator "i;ascii-casemap"` now folds US-ASCII `A-Z` only, which is what RFC 4790 §9.2 defines it as; the regular expression it replaced carried JavaScript's `i` flag and folded the whole of Unicode. The narrower fold is also the only one that preserves length: `"İ".toLowerCase()` is two UTF-16 units, so folding a subject up front shifts every position after it and leaves `?`, which matches exactly one character, facing two. A script relying on non-ASCII case folding under `i;ascii-casemap` was relying on something the comparator does not promise. · *`b.jsonPath` refuses a catastrophic `match()` or `search()` pattern* — The second argument of an RFC 9535 `match()` or `search()` filter is an I-Regexp (RFC 9485), which is a general regular-expression language: a filter can carry `(a+)+$` as easily as `a+`. `$[?match(@.name, "(a+)+$")]` against a 29-character value that fails the match took 7 seconds.
74
+
75
+ The node ceilings on descendant walks and running nodelists never saw it, because the whole cost is inside one match on one node.
76
+
77
+ The pattern is now screened with `b.guardRegex.assertSafe` before it can run, which is what the framework already does wherever else it compiles a caller-supplied pattern — `b.flag` targeting conditions and MCP tool schemas both go through the same screen. A refusal raises `json-path/unsafe-pattern` rather than being folded into a false test result: a malformed I-Regexp yields false per RFC 9535 §2.4.6, and a refused one must not wear those clothes, or the caller is left with a filter that silently matches nothing. · *A folded DKIM-Signature header made the submission listener spend its CPU on a trim* — `b.mail.server.submission` reads the `d=` tag out of each DKIM-Signature header, and trimmed each tag with `/^\s+|\s+$/`. That is the quadratic trim: the `\s+$` alternative is retried from every start position when the whitespace run does not reach the end of the string.
78
+
79
+ The tag was believed to be bounded by the line cap. It is not, because the header is unfolded first. Every empty continuation line contributes one space to the joined value, so the lever is the number of folded lines rather than the length of any one of them, and the real bound is `maxMessageBytes` — 50 MiB by default. An authenticated client sending 40,000 empty continuation lines, a 120 KB message, produced a 40,000-character run and 446ms of backtracking on one header. Three megabytes would have bought minutes.
80
+
81
+ The trim is now `String.prototype.trim`, which decides identically (the same WhiteSpace and LineTerminator set as `\s`) and does not backtrack. · *A virus-scanner reply with a long newline run cost the same shape* — The ClamAV INSTREAM reply is trimmed of trailing CR / LF / NUL before it is classified, and the trim was `/[\r\n\0]+$/`. Anchoring at `$` does not stop the engine retrying from every start position; it is what makes the scan quadratic. A reply carrying a long interior run of newlines took 1.2 seconds through the real scan path.
82
+
83
+ The reply is capped, but by `maxResponseBytes`, which is 50 MiB on the strict profile and 300 MiB on permissive, so the cap was not what stood between a reply and the cost. The trim now walks backwards from the end.
84
+
85
+ A local daemon is normally trusted, which makes this hardening rather than a hole — but `b.mail.scan` dials a host and a port, a scanning service can be remote, and a reply parser is the wrong place to assume the peer is well behaved. · *`b.jsonSchema` screens `pattern` and `patternProperties` when the schema compiles* — A `pattern` keyword is compiled and then run against the instance, so the schema author decides what each validation costs. `(a+)+$` against a 28-character string that fails took 1.5 seconds, doubling with every two characters added.
86
+
87
+ The reasoning that left this unscreened was that a schema is operator-supplied and therefore trusted. The framework does not hold that assumption elsewhere: `b.mcp` already screens the `pattern` in a tool schema for exactly this shape before matching it against request input. A schema arrives from a registry, a tool manifest, an upload or a config file as readily as from your own source.
88
+
89
+ Every pattern in a schema is now screened, and screened at `b.jsonSchema.compile` rather than on the first instance that reaches it — a bad schema should be refused while you are still looking at the schema, not on a request, through a validator you have already been handed and have no reason to doubt.
90
+
91
+ The screen reads the compiled expression rather than the pattern text, which is what `b.safeJson` does with the same keyword and for the reason recorded there: the flags decide what the source means, so a text-only screen reads `(a|A)+` as two disjoint branches where the engine sees one branch twice.
92
+
11
93
  - 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
94
 
13
95
  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.
package/NOTICE CHANGED
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
68
68
  reference implementation.
69
69
  --------------------------------------------------------------------------------
70
70
  Component: @blamejs/pki
71
- Version: 0.5.25
71
+ Version: 0.5.28
72
72
  Source: https://github.com/blamejs/pki
73
73
  License: Apache-2.0
74
74
  Copyright: Copyright (c) blamejs contributors
package/README.md CHANGED
@@ -323,7 +323,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
323
323
  | [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
324
324
  | [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
325
325
  | [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
326
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.25 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
326
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.28 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
327
327
  | [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
328
328
  | [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
329
329
 
package/lib/ai-input.js CHANGED
@@ -44,22 +44,44 @@ var PATTERNS = [
44
44
  /\byou\s+(?:are|will\s+be|must\s+be)\s+(?:now|from\s+now\s+on)?\s*(?:a|an)\s+\w{2,40}/i },
45
45
  { id: "jailbreak-persona", severity: 3, re:
46
46
  /\b(?:DAN|do\s+anything\s+now|developer\s+mode|sudo\s+mode|jailbroken|unfiltered|uncensored|unrestricted)\b/i },
47
+ // The `/` carries the whitespace that follows it. Written as `\s*\/?\s*` the
48
+ // two runs sit either side of an OPTIONAL character, so on `<` and a long run
49
+ // of spaces the engine retries every way of dividing that run between them:
50
+ // 1,642ms on a 64 KiB input against 0.02ms for benign text of the same
51
+ // length. Moving the `/` and its trailing run into one optional group leaves
52
+ // a single way to divide any input and accepts exactly the same tags.
47
53
  { id: "role-reset-marker", severity: 3, re:
48
- /<\s*\/?\s*(?:system|user|assistant|sys|im_(?:start|end)|\|im_(?:start|end)\|)\s*>/i },
54
+ /<\s*(?:\/\s*)?(?:system|user|assistant|sys|im_(?:start|end)|\|im_(?:start|end)\|)\s*>/i },
49
55
  { id: "openai-system-tag", severity: 3, re:
50
56
  /\b(?:<\|im_start\|>|<\|im_end\|>|\[INST\]|\[\/INST\]|<\|user\|>|<\|assistant\|>|<\|system\|>)\b/ },
51
57
  { id: "tool-call-injection", severity: 3, re:
52
58
  /\b(?:tool|function|action)\s*[:=]\s*["']?(?:exec|eval|read_file|exfil|leak|extract)\b/i },
53
59
  { id: "exfil-callback", severity: 3, re:
54
60
  /\b(?:send|post|fetch|exfil|leak|paste|forward)\b[\s\S]{0,40}(?:secret|key|token|password|cred|env|\.ssh|private)/i },
61
+ // The lookbehind is what keeps this linear, and it is also what the detector
62
+ // meant: a base64 TOKEN, not any 40-character window inside a longer run.
63
+ // Without it the run can start at every offset, and at each one the engine
64
+ // consumes the whole run before `\s+` fails — O(n) starts times O(n) work.
65
+ // `/` is inside the class, so `a/` repeated feeds it: 4,536ms on a 64 KiB
66
+ // prompt, against 1ms for benign input of the same length. The lookbehind
67
+ // fails in constant time at every offset except the one after a delimiter.
68
+ // The exclusion is exactly the run's own character class and nothing more.
69
+ // Adding `=` to it also looked reasonable and silently dropped a real shape,
70
+ // `token=<blob> means ...`, where the blob begins right after an `=`.
55
71
  { id: "base64-marker-around-instructions", severity: 2, re:
56
- /(?:[A-Za-z0-9+/]{40,}={0,2})\s+(?:means|decodes?\s+to|=)/i }, // regex repetition floor, not bytes
72
+ /(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}\s+(?:means|decodes?\s+to|=)/i }, // regex repetition floor, not bytes
57
73
  { id: "rot13-shape", severity: 2, re:
58
74
  /\b(?:rot13|rotcipher|cipher|caesar)\s*[:=]\s*[a-zA-Z]{20,}/i },
59
75
  { id: "markdown-injection", severity: 2, re:
60
76
  /!\[[^\]]{0,40}\]\((?:javascript:|data:|file:)/i },
77
+ // `\w` covers `o` and `n`, so without the boundary an input of `on` repeated
78
+ // starts a match at every other offset and each one walks `\w+` to the end of
79
+ // the subject before the `=` fails: 870ms on 64 KiB against 0.03ms for benign
80
+ // text of the same length. The boundary is also what the detector meant — an
81
+ // event-handler attribute begins a word, so `foonclick="fetch()"` was a match
82
+ // it should never have had.
61
83
  { id: "html-script-shape", severity: 2, re:
62
- /<script[\s>]|on\w+\s*=\s*["'][^"']*\b(?:fetch|xhr|eval|location)\b/i },
84
+ /<script[\s>]|\bon\w+\s*=\s*["'][^"']*\b(?:fetch|xhr|eval|location)\b/i },
63
85
  { id: "stop-helping", severity: 2, re:
64
86
  /\b(?:stop|cease|quit)\s+(?:helping|assisting|following)\b/i },
65
87
  { id: "now-instead", severity: 2, re:
package/lib/db-query.js CHANGED
@@ -44,6 +44,7 @@
44
44
  */
45
45
  var { Readable } = require("node:stream");
46
46
  var C = require("./constants");
47
+ var codepointClass = require("./codepoint-class");
47
48
  var cryptoField = require("./crypto-field");
48
49
  var { generateToken } = require("./crypto");
49
50
  var safeJson = require("./safe-json");
@@ -1397,8 +1398,34 @@ function _validateField(field) {
1397
1398
  // and run it through the SAME gate; a write to a residency table the framework
1398
1399
  // cannot parse fails CLOSED (refused) - a raw write never skips the check.
1399
1400
  var _RAW_WRITE_KEYWORD_RE = /^\s*(?:INSERT|REPLACE|UPDATE)\b/i;
1400
- var _RAW_INSERT_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)\s*;?\s*$/i;
1401
- var _RAW_UPDATE_RE = /^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\s+([\s\S]+?)\s*;?\s*$/i;
1401
+ // Both bodies used to end `\s*;?\s*$`, which put two whitespace runs either
1402
+ // side of an optional semicolon and, for UPDATE, a lazy body run in front of
1403
+ // them. All three could absorb the same trailing spaces, so every division of
1404
+ // that run was a distinct path and the lazy run retried all of them at every
1405
+ // length it took: cubic, 117ms on a 1 KB statement and 6.8 seconds on 4 KB.
1406
+ // The 100,000-character ceiling below is what the parse is allowed, so it set
1407
+ // the cost rather than bounding it.
1408
+ //
1409
+ // The terminator is now walked off the end by _stripStatementTail before either
1410
+ // pattern runs, which leaves one greedy run with nothing after it to divide.
1411
+ var _RAW_INSERT_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)$/i;
1412
+ var _RAW_UPDATE_RE = /^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\s+([\s\S]+)$/i;
1413
+
1414
+ // The trailing `;` and the whitespace around it, removed by walking backwards
1415
+ // so no pattern has to express it. WHITESPACE_RANGES is what a regular
1416
+ // expression means by `\s`, so the two agree on U+00A0 and U+3000 as well as
1417
+ // the ASCII five; a hand-listed "space and tab" would leave a terminator the
1418
+ // old pattern removed.
1419
+ function _stripStatementTail(s) {
1420
+ var ws = codepointClass.WHITESPACE_RANGES;
1421
+ var end = s.length;
1422
+ while (end > 0 && codepointClass.inRanges(s.charCodeAt(end - 1), ws)) end -= 1;
1423
+ if (end > 0 && s.charCodeAt(end - 1) === 0x3B /* ; */) {
1424
+ end -= 1;
1425
+ while (end > 0 && codepointClass.inRanges(s.charCodeAt(end - 1), ws)) end -= 1;
1426
+ }
1427
+ return end === s.length ? s : s.slice(0, end);
1428
+ }
1402
1429
  var _RAW_TABLE_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?|^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?/i;
1403
1430
 
1404
1431
  function _unquoteIdent(s) {
@@ -1588,8 +1615,9 @@ function _assertRawWriteResidency(sql, boundParams) {
1588
1615
  norm.length + " chars) - use b.db.from(\"" + table + "\") so residency is validated", true);
1589
1616
  }
1590
1617
 
1591
- var mi = _RAW_INSERT_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1592
- var mu = mi ? null : _RAW_UPDATE_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1618
+ var body = _stripStatementTail(norm);
1619
+ var mi = _RAW_INSERT_RE.exec(body); // allow:regex-no-length-cap — input length-capped above
1620
+ var mu = mi ? null : _RAW_UPDATE_RE.exec(body); // allow:regex-no-length-cap — input length-capped above
1593
1621
  if (!mi && !mu) {
1594
1622
  throw new DbQueryError("db-query/row-residency-raw-unparseable",
1595
1623
  "raw write to residency table '" + table + "' cannot be parsed to validate its " +
@@ -2745,9 +2745,18 @@ function resolveProfileAndPosture(opts, cfg) {
2745
2745
  var v = resolved[k];
2746
2746
  if (v === undefined || !Array.isArray(allowed)) return;
2747
2747
  if (typeof v !== "string" || allowed.indexOf(v) === -1) {
2748
+ // A vocabulary of ONE is not a vocabulary, and saying "must be one of
2749
+ // reject" invites the reader to go looking for the members that are
2750
+ // missing. These are the checks where every disposition except refusing
2751
+ // is a hole — a traversal sequence, a null byte in a filename, `alg:
2752
+ // none` — so the single value is a deliberate lock rather than a
2753
+ // vocabulary that lost its siblings. The message says which.
2748
2754
  throw ErrorClass.factory(prefix + ".bad-opt",
2749
- prefix + ": " + k + " must be one of " + allowed.join(", ") +
2750
- "; got " + JSON.stringify(v));
2755
+ allowed.length === 1
2756
+ ? prefix + ": " + k + " is fixed at " + allowed[0] +
2757
+ " and is not configurable; got " + JSON.stringify(v)
2758
+ : prefix + ": " + k + " must be one of " + allowed.join(", ") +
2759
+ "; got " + JSON.stringify(v));
2751
2760
  }
2752
2761
  });
2753
2762
  }
@@ -703,10 +703,14 @@ function _sanitize(input, opts) {
703
703
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
704
704
  * bidiPolicy: "reject"|"strip"|"allow",
705
705
  * controlPolicy: "reject"|"strip"|"allow",
706
- * nullBytePolicy: "reject", // always reject
706
+ * nullBytePolicy: "reject", // fixed; not configurable
707
+ * // null-byte truncation
708
+ * // is never sanitizable
707
709
  * zeroWidthPolicy: "reject"|"strip"|"allow",
708
710
  * homoglyphPolicy: "reject"|"audit"|"allow",
709
- * traversalPolicy: "reject", // always reject
711
+ * traversalPolicy: "reject", // fixed; not configurable
712
+ * // no disposition other
713
+ * // than refusing is safe
710
714
  * reservedCharPolicy: "reject"|"strip"|"allow",
711
715
  * reservedNamePolicy: "reject"|"audit"|"allow",
712
716
  * adsPolicy: "reject"|"allow", // reject here; "allow"
package/lib/guard-jwt.js CHANGED
@@ -441,9 +441,9 @@ function _detectIssues(input, opts) {
441
441
  * allowedAlgs: string[],
442
442
  * requiredClaims: string[],
443
443
  * knownCrit: string[],
444
- * algNonePolicy: "reject", // alg=none is always critical
444
+ * algNonePolicy: "reject", // fixed; alg=none is always critical
445
445
  * algAllowlistPolicy: "reject"|"audit"|"allow",
446
- * kidTraversalPolicy: "reject", // kid traversal is always critical
446
+ * kidTraversalPolicy: "reject", // fixed; kid traversal is always critical
447
447
  * typConfusionPolicy: "reject"|"audit"|"allow",
448
448
  * expSanityPolicy: "reject"|"audit"|"allow",
449
449
  * nbfSanityPolicy: "reject"|"audit"|"allow",
package/lib/json-path.js CHANGED
@@ -33,6 +33,11 @@
33
33
  */
34
34
 
35
35
  var { defineClass } = require("./framework-error");
36
+ var lazyRequire = require("./lazy-require");
37
+ // Lazy: guard-regex composes the parser in this file's sibling modules, and the
38
+ // screen is only reached by a filter that calls match() or search().
39
+ var guardRegex = lazyRequire(function () { return require("./guard-regex"); });
40
+ var boundedMap = require("./bounded-map");
36
41
 
37
42
  var JsonPathError = defineClass("JsonPathError", { alwaysPermanent: true });
38
43
 
@@ -573,7 +578,25 @@ function _evalFunctionValue(node, current, root) {
573
578
  return NOTHING;
574
579
  }
575
580
 
581
+ // A filter runs once per candidate node, and the pattern is almost always the
582
+ // same literal throughout a query, so translating and screening it per node
583
+ // would put the cost of the analysis on the nodelist length. Screened once per
584
+ // distinct (pattern, anchored) pair instead.
585
+ //
586
+ // Bounded, because the key comes from the query: an unbounded map keyed by
587
+ // caller-supplied text is a slower way to spend the same memory. The framework's
588
+ // own ceiling primitive owns the eviction, so the oldest entry goes rather than
589
+ // the whole cache.
590
+ var _filterRegexCache = boundedMap.boundedMap({ maxEntries: 256 });
591
+
592
+ // Only an accepted pattern is stored: a refusal throws out of `query()` on the
593
+ // first candidate node, so there is no second node to re-decide it for.
576
594
  function _iRegexpToJs(pattern, anchored) {
595
+ return boundedMap.getOrInsert(_filterRegexCache, (anchored ? "1" : "0") + pattern,
596
+ function () { return _buildFilterRegex(pattern, anchored); });
597
+ }
598
+
599
+ function _buildFilterRegex(pattern, anchored) {
577
600
  // I-Regexp (RFC 9485) is close to a JS regex subset. Translate the one
578
601
  // systematic difference — "." must not match line separators — and
579
602
  // (for match) anchor the whole input.
@@ -582,16 +605,43 @@ function _iRegexpToJs(pattern, anchored) {
582
605
  if (cls) return cls;
583
606
  return "[^\\n\\r]";
584
607
  });
585
- // The pattern is the I-Regexp argument of an RFC 9535 match()/search()
586
- // filter translated (not raw) and used only as a boolean test.
587
- return new RegExp(anchored ? "^(?:" + translated + ")$" : translated, "su"); // allow:dynamic-regex translated I-Regexp from a match()/search() filter argument
608
+ var source = anchored ? "^(?:" + translated + ")$" : translated;
609
+ // I-Regexp is a general regular-expression language, so the argument of a
610
+ // match() or search() filter can be `(a+)+$` as easily as `a+`, and a
611
+ // backtracking engine will spend the caller's chosen amount of CPU deciding
612
+ // it. Being a boolean test is no protection: `.test()` is exactly where the
613
+ // backtracking happens, and the node caps above never see it because the
614
+ // whole blow-up is inside one call on one node. Measured before this screen:
615
+ // 7 seconds on a 29-character subject, doubling per added character.
616
+ //
617
+ // Screened with the same primitive the framework already uses wherever it
618
+ // compiles a caller-supplied pattern — b.flag targeting conditions and MCP
619
+ // tool schemas both go through it.
620
+ //
621
+ // Compiled first and screened on the COMPILED form, matching b.safeJson: the
622
+ // flags decide what the source means, so a source-only screen reads `(a|A)+`
623
+ // as two disjoint branches where the engine sees one branch twice. Compiling
624
+ // is safe on its own; the cost is in matching, which has not happened yet.
625
+ var re = new RegExp(source, "su"); // allow:dynamic-regex — translated I-Regexp, ReDoS-screened via guardRegex.assertSafe below, before it is returned to the caller that will run it
626
+ guardRegex().assertSafe(re, "jsonPath.filter.pattern",
627
+ JsonPathError, "json-path/unsafe-pattern");
628
+ return re;
588
629
  }
589
630
  function _evalFunctionLogical(node, current, root) {
590
631
  var input = _funcArgValue(node.args[0], current, root);
591
632
  var pat = _funcArgValue(node.args[1], current, root);
592
633
  if (typeof input !== "string" || typeof pat !== "string") return false;
593
634
  var re;
594
- try { re = _iRegexpToJs(pat, node.name === "match"); } catch (_e) { return false; }
635
+ try { re = _iRegexpToJs(pat, node.name === "match"); }
636
+ catch (e) {
637
+ // A malformed I-Regexp yields Nothing, which RFC 9535 §2.4.6 renders as a
638
+ // false test result. A pattern REFUSED as catastrophic is a different
639
+ // answer and must not wear the same clothes: swallowing it would leave the
640
+ // caller with a filter that silently matches nothing and no way to tell
641
+ // that from a filter that matched nothing.
642
+ if (e && e.code === "json-path/unsafe-pattern") throw e;
643
+ return false;
644
+ }
595
645
  return re.test(input);
596
646
  }
597
647