@blamejs/core 0.18.50 → 0.18.53

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,127 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.53 (2026-08-24) — **The YAML screens refused ordinary documents: a list of records, and an exclamation mark in a sentence.** `b.guardYaml.parse` refused any sequence whose items carry more than one key, which is the shape of nearly every list of records anyone writes. Separately, both `b.guardYaml` and `b.parsers.yaml` decided whether a `!`, `&` or `*` opened a tag, anchor or alias by looking at the character in front of it, so they reported one inside a quoted string, inside a block scalar's shell script, in the middle of a value, and inside a comment. `x: 1 # note !bang` was refused. Both screens now read the document's structure, through one scanner shared by the two modules. **Fixed:** *`b.guardYaml` no longer reports a duplicate key for a list of records* — Key uniqueness was tracked per indentation level, and a sequence-item line never reset that tracking. The second item's keys were therefore checked against the first item's, so every key except the one written inline with the dash was reported as a duplicate:
12
+
13
+ ```yaml
14
+ steps:
15
+ - name: build
16
+ run: make
17
+ - name: test
18
+ run: make check
19
+ ```
20
+
21
+ That document was refused with `duplicate key "run"`, and it has no duplicate key in it. Under `duplicateKeyPolicy: "reject"` — the strict profile's default — the parse threw.
22
+
23
+ Each item of a sequence is now its own mapping, so a key may appear once per item. Two things that were already true stay true, and both are pinned by tests: a key repeated *within* one item is still a duplicate, and so is a key repeated in an ordinary mapping.
24
+
25
+ The key written inline with the dash was being skipped entirely rather than tracked, so `- name: a` followed by `name: b` inside the same item went unreported. It is now tracked with the rest of its mapping. · *A tag, anchor or alias is recognised where it can actually appear* — `!`, `&` and `*` introduce a tag, an anchor and an alias only at a node start. Both YAML screens decided the question by looking at the preceding character — whitespace meant a sigil, anything else meant not — and that rule cannot tell a node start from the middle of a scalar. Every one of these was refused:
26
+
27
+ ```yaml
28
+ x: "hello !world" # a bang in a quoted string
29
+ x: hello !world # a bang in the middle of a value
30
+ x: 1 # note !bang # a bang in a comment
31
+ x: |
32
+ echo !boom # a bang in a shell script
33
+ text: fish &chips # an ampersand in prose
34
+ ```
35
+
36
+ The two modules had separate implementations of the check and separate gaps. `b.parsers.yaml` masked quoted strings but copied comment text through verbatim and had no block-scalar handling, despite a note in it saying both were covered; `b.guardYaml` had none of the three. So a document refused by one was sometimes accepted by the other.
37
+
38
+ Both now use one scanner that tracks where each character sits: inside a quoted scalar, a comment, a block-scalar body, a plain scalar, or in the structure. A sigil is reported only where it opens a node. Scalars that run across several lines are followed rather than read afresh, so neither the continuation of a quoted string nor of an unquoted value is mistaken for structure, and a `!` after the closing quote or the ending comma still is one.
39
+
40
+ **What this changes for a document you already have:** a sigil inside a plain scalar is no longer reported. `text: this &notanchor` and `x: -&a` used to raise an anchor finding and no longer do. Neither declares an anchor — an anchor is a separate token — so nothing can reference it, and the amplification these findings exist to stop has no path through them. A merge key written without spaces, `<<:*d`, is still refused, by the merge-key screen rather than the alias one.
41
+
42
+ `b.parsers.yaml` continues to refuse every real tag, anchor, alias and directive, and the strict profile of `b.guardYaml` continues to report them. · *The residency gate's raw-SQL timing check no longer depends on how busy the machine is* — The check that a padded raw `UPDATE` costs no extra parse time compared the padded call against an unpadded one and allowed 500ms between them. A difference of two timings carries the noise of both, and on a loaded runner the padded sample alone drifted past that allowance while the statement itself parses in a fraction of a millisecond.
43
+
44
+ It now bounds the padded call on its own, and takes the fastest of fifteen samples rather than three. The defect it guards cost roughly seven seconds against 0.04ms once fixed, so the ceiling sits between the two behaviours with several orders of magnitude to spare rather than tracking the machine. The unpadded baseline is still measured and still printed, because a reader looking at a failure needs to know whether the box was slow or the parse was.
45
+
46
+ - v0.18.52 (2026-08-23) — **A refusal that would not say why, and a currency gate that skipped the one pin able to move underneath it.** `b.guardFilename` refuses a filename carrying NTFS alternate-data-stream syntax whatever `adsPolicy` says, which is correct and was documented, but the refusal never mentioned the option — so a caller who set `"allow"` was left comparing their code against a message that said nothing about the setting they had changed. Separately, the pinned-actions currency gate could only see a `uses:` pinned to a commit SHA, so the one workflow reference pinned to a tag was absent from every run rather than reported, and its `--json` output had a human summary line after the document, so nothing could parse it. **Fixed:** *The NTFS-ADS refusal now says where `adsPolicy: "allow"` applies* — A filename carrying alternate-data-stream syntax (`report.txt:stream`) is refused by `b.guardFilename.sanitize`, `validate` and `gate` regardless of `adsPolicy`. That is deliberate: on Windows the write lands on a hidden stream of the base file rather than on a file anyone can see, so it is one of the shapes a filename guard always refuses. `adsPolicy` still takes `"allow"` because `verifyExtractionPath` honours it, for an operator deliberately extracting stream-suffixed entries into a root they chose.
47
+
48
+ What was missing is that none of this was visible from the call site. The refusal said only that the name contained stream syntax, so setting `"allow"` and watching nothing change read as the option being broken rather than as it being scoped. The message now names the boundary, and the split is pinned by tests at all four entry points.
49
+
50
+ One thing worth knowing if you go looking: a colon is also a Windows reserved character, so `reservedCharPolicy` refuses such a name before the ADS check is reached. Setting `adsPolicy` alone never changes the outcome for a name like `12:30 notes.txt` — `reservedCharPolicy` is the one governing it, and `"strip"` there yields `12_30 notes.txt`.
51
+
52
+ This also records a change that shipped in 0.18.48 without being written down: before it, `sanitize` honoured `adsPolicy: "reject"` and so could be opted out of. It no longer can be, and there is no replacement value, because a stream-suffixed name has no safe repair. · *The pinned-actions currency gate could not see a tag-pinned workflow* — `check-actions-currency.js` matched a `uses:` line only when it was pinned to a 40-character commit SHA. A reference pinned to anything else matched nothing, was collected by nothing, and appeared in the run as neither current nor stale — absent, while the summary counted what it had looked at and read as a clean result.
53
+
54
+ The pin that fell through is the one least able to look after itself. A commit SHA is immutable, so a stale SHA pin becomes visible as soon as upstream cuts a release; a tag can be repointed at new code with no local diff at all. The single reference here pinned to a tag is pinned that way by necessity, because the SLSA provenance generator refuses to run from a commit SHA — so the one exception to the pinning discipline was also the one thing never checked.
55
+
56
+ Tag pins are now collected, version-checked like any other, and reported as tag pins. `--fix` deliberately leaves them alone and says so: there is no old SHA to compare against and none to write, and the review material `--fix` prints is a diff between two SHAs, which is exactly what a tag cannot give.
57
+
58
+ An action can also be pinned by SHA in one workflow and by tag in another, and the two need different answers in different places. The pin type is therefore tracked per reference: the ready-to-paste replacement line is printed only where a SHA reference can take it, each tag reference is marked as one, and `--fix` skips such an action entirely rather than bumping the references that were already current and leaving the stale one behind.
59
+
60
+ The cause of the gap is worth naming because it repeated one level down: the first pattern written to catch these anchored the version at end-of-line, and the line in question carries a trailing comment explaining why it is not SHA-pinned. It matched nothing either.
61
+
62
+ So the gate no longer relies on recognising every shape. It reads the `uses:` scalar first, quoting and all, then classifies what it found; a reference it cannot classify is listed and **fails the run** rather than dropping out of the report. That covers quoted values (`uses: "owner/repo@v1"`), version tags carrying a prerelease or build suffix (`@v2.1.0-rc.1`), a SHA pin whose `# vX.Y.Z` comment is missing, and a reference pinned to a branch. Local actions (`./…`) and `docker://` images are skipped deliberately, because neither has an upstream release to compare against, and the body of a `run: |` block is skipped because those lines are script rather than YAML.
63
+
64
+ Two consequences of reading more shapes are worth calling out. Version comparison now follows semver precedence for prerelease identifiers, so `rc.1` is older than `rc.2` and older than `rc.10`, and a release candidate left pinned after the final release ships reports stale rather than current. And `--fix` now verifies that each rewrite actually landed: it reaches through a closing quote to replace a quoted pin, and if a collected reference does not match the replacement it says so and exits non-zero instead of reporting the action fixed over an unchanged file.
65
+
66
+ The pattern matching is gone. Whether a `uses` token is a key is a question about YAML structure, and a pattern cannot answer it: every attempt to widen one admitted a shape it read wrongly, and every attempt to narrow one dropped a shape it should have read. The collector now scans, tracking the three things that actually decide it — quoting, comments, and flow-collection depth.
67
+
68
+ Every form is read as a result: block style, flow mappings whether `uses` is the first key or the fifth, mappings spanning lines, quoted keys (`"uses":`), quoted values, and values inside nested flow collections. And in the other direction, `uses` inside a quoted string, inside a comment, or inside a `run: |` body is text rather than a key, so `- { run: "echo a, uses: owner/repo@main" }` names no action at all. Both block-scalar indicator orders are handled, since `|2-` and `|-2` are equally valid and misreading one scans a shell script as YAML.
69
+
70
+ Position decides what counts, not the value. A reference is a `uses` under `steps`, or a job's own `uses` for a reusable workflow; anything else spelled `uses` is data. That distinction cannot be made from the value, because `owner/repo@main` is a perfectly ordinary string to put in an `env` block or pass through `with`, and matrix `include` entries carry properties an operator names themselves. Naming the two positions the schema defines is finite; listing every container that is not one is not.
71
+
72
+ What the scan covers is printed on every run, as a `scope` line in the report and a `scope` field in `--json`. The original defect was a form the gate did not read and did not say so; that is now two separate things it cannot do.
73
+
74
+ If you run this gate against your own workflows, expect it to fail on references it could previously not see. That is the point of the change; each one it names is a pin whose currency was never being checked. · *`check-actions-currency.js --json` emits only the JSON document* — The JSON branch wrote the document and then fell through into the summary blocks, which wrote to the same stream regardless of the flag. Every `--json` run therefore ended with a `[actions-currency] …` line after the closing brace, and `JSON.parse` on the stream failed with "Unexpected non-whitespace character after JSON". `--fix --json` was the same shape with more trailing text.
75
+
76
+ Every human-readable line now goes through a writer that is silent under `--json`. Exit codes are unchanged: a machine reader still gets a non-zero exit when something is stale.
77
+
78
+ - 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.
79
+
80
+ 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.
81
+
82
+ 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.
83
+
84
+ 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.
85
+
86
+ 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.
87
+
88
+ 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.
89
+
90
+ 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.
91
+
92
+ 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.
93
+
94
+ 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.
95
+
96
+ `classify` accepts 64 KiB by default. Measured at that size, against 1 millisecond for ordinary text of exactly the same length:
97
+
98
+ - 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**.
99
+ - 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.
100
+ - 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**.
101
+
102
+ 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.
103
+
104
+ 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.
105
+
106
+ 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 `=`.
107
+
108
+ 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.
109
+
110
+ 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.
111
+
112
+ 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.
113
+
114
+ 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.
115
+
116
+ 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.
117
+
118
+ 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.
119
+
120
+ 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.
121
+
122
+ 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.
123
+
124
+ 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.
125
+
126
+ 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.
127
+
128
+ 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.
129
+
130
+ 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.
131
+
11
132
  - 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.
12
133
 
13
134
  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.
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
  }
@@ -280,6 +280,19 @@ function _hasUncPrefix(name) {
280
280
 
281
281
  // An NTFS alternate-data-stream suffix: a colon followed by a run with no
282
282
  // further colon and no separator in it, at the very end of the name.
283
+ // One wording for every path that refuses a stream-suffixed name. A caller who
284
+ // set `adsPolicy: "allow"` can arrive at any of them — sanitize in either mode,
285
+ // validate, or gate — and each has to answer the same question, or the option
286
+ // reads as broken from whichever door they happened to use. Saying it in one
287
+ // place and only one is how three of the four ended up silent the first time.
288
+ //
289
+ // `verifyExtractionPath` is deliberately absent: it is the one entry point that
290
+ // HONOURS the option, so it never reaches this text.
291
+ var ADS_SCOPE_NOTE = "always refused here; adsPolicy \"allow\" applies only " +
292
+ "to verifyExtractionPath";
293
+ var ADS_SNIPPET = "NTFS alternate data stream syntax (name:stream) — " + ADS_SCOPE_NOTE;
294
+ var ADS_MESSAGE = "filename contains " + ADS_SNIPPET;
295
+
283
296
  function _hasAdsSuffix(name) {
284
297
  var colon = name.lastIndexOf(":");
285
298
  if (colon === -1 || colon === name.length - 1) return false;
@@ -469,7 +482,7 @@ function _detectIssues(input, opts) {
469
482
  issues.push({
470
483
  kind: "ntfs-ads", severity: "critical",
471
484
  ruleId: "filename.ntfs-ads",
472
- snippet: "NTFS alternate data stream syntax (name:stream)",
485
+ snippet: ADS_SNIPPET,
473
486
  });
474
487
  }
475
488
  }
@@ -660,9 +673,18 @@ function _sanitize(input, opts) {
660
673
  name = "_" + name;
661
674
  }
662
675
 
663
- // ADS detection.
676
+ // ADS detection. Not gated on `adsPolicy`, and the message says so: a
677
+ // stream-suffixed name is one of the shapes a filename guard always refuses,
678
+ // because on Windows the write lands on a hidden stream of the base file
679
+ // rather than on a file anyone can see. `adsPolicy` still takes "allow", for
680
+ // `verifyExtractionPath` alone, where an operator may be deliberately
681
+ // extracting stream-suffixed entries to a root they chose.
682
+ //
683
+ // The scope is in the message because it is invisible from the call site: a
684
+ // caller who sets "allow" here gets a refusal that, without it, never
685
+ // mentions the setting they changed and so reads as the option being broken.
664
686
  if (_hasAdsSuffix(name)) {
665
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
687
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
666
688
  }
667
689
 
668
690
  // Length cap.
@@ -703,10 +725,14 @@ function _sanitize(input, opts) {
703
725
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
704
726
  * bidiPolicy: "reject"|"strip"|"allow",
705
727
  * controlPolicy: "reject"|"strip"|"allow",
706
- * nullBytePolicy: "reject", // always reject
728
+ * nullBytePolicy: "reject", // fixed; not configurable
729
+ * // null-byte truncation
730
+ * // is never sanitizable
707
731
  * zeroWidthPolicy: "reject"|"strip"|"allow",
708
732
  * homoglyphPolicy: "reject"|"audit"|"allow",
709
- * traversalPolicy: "reject", // always reject
733
+ * traversalPolicy: "reject", // fixed; not configurable
734
+ * // no disposition other
735
+ * // than refusing is safe
710
736
  * reservedCharPolicy: "reject"|"strip"|"allow",
711
737
  * reservedNamePolicy: "reject"|"audit"|"allow",
712
738
  * adsPolicy: "reject"|"allow", // reject here; "allow"
@@ -792,7 +818,7 @@ function _sanitizeStripMode(input, opts) {
792
818
  throw _err("filename.unc", "UNC path syntax");
793
819
  }
794
820
  if (_hasAdsSuffix(name) && name.charAt(0) !== "/") {
795
- throw _err("filename.ntfs-ads", "filename contains NTFS alternate data stream syntax");
821
+ throw _err("filename.ntfs-ads", ADS_MESSAGE);
796
822
  }
797
823
  if (Buffer.byteLength(name, "utf8") > opts.maxBytes) {
798
824
  throw _err("filename.length", "filename exceeds maxBytes " + opts.maxBytes);
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",