@blamejs/core 0.18.37 → 0.18.38
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 +34 -0
- package/lib/guard-regex.js +6 -0
- package/lib/guard-sql.js +49 -4
- package/lib/safe-json.js +6 -0
- package/package.json +1 -1
- 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.38 (2026-08-19) — **`COPY ... TO STDIN` was reported as a server-side file access whenever it carried more than one space.** `b.guardSql`'s `copy-file` detector finds a `COPY` that reads or writes a file on the database server, and excludes the client-streaming `STDIN` and `STDOUT` forms because those touch no file. The exclusion was a negative lookahead, and it only worked when the whitespace before the keyword was exactly one character: `COPY t TO STDIN` was quiet, `COPY t TO STDIN` was reported critical.
|
|
12
|
+
|
|
13
|
+
The exclusion is now decided by reading the following word rather than by a lookahead, so the spacing no longer changes the verdict.
|
|
14
|
+
|
|
15
|
+
No API changes. Upgrade if you pass SQL fragments through `b.guardSql`. **Changed:** *The content-safety gate now refuses a pattern built at runtime* — `blamejs/no-regex-in-content-safety` reported pattern literals only, so `new RegExp(source)` passed it unnoticed anywhere under `lib/**/safe-*.js` or `lib/**/guard-*.js`. It now reports `new RegExp(...)` and `RegExp(...)`, in both the bare and member spellings — `globalThis.RegExp(src)` puts a MemberExpression in the callee and an identifier-only check permits it silently.
|
|
16
|
+
|
|
17
|
+
Three call sites carry a suppression with its reason recorded beside it. Two of them never match anything against input: `b.guardRegex` asks the parser whether an operator-supplied pattern compiles at all and discards the result, and `b.safeJson` compiles a schema `pattern` precisely so `assertSafe` can screen the compiled form. The third is `b.guardSql`'s detector table, described below. · *The SQL detectors stay on the platform engine, and the reason is now recorded* — These 32 detectors are built with `new RegExp` and run against a caller's SQL, which reads like something the content-safety rule should forbid. Moving them to `b.regexLinear` — whose cost is the length of the subject whatever the pattern says — was tried and measured, and it is worse:
|
|
18
|
+
|
|
19
|
+
| subject | linear | platform |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| 158-byte benign SELECT | 1.4 ms | 0.001 ms |
|
|
22
|
+
| 4 KiB benign SELECT | 15.4 ms | 0.012 ms |
|
|
23
|
+
|
|
24
|
+
The engine expands each `{0,4000}` span into thousands of states, so every ordinary fragment pays. The platform engine's worst case over these same patterns, on adversarial input built to defeat every lazy span, is 5 ms at 32 KiB — less than the linear engine charges for a benign 4 KiB one. The swap would have made every request cost more than the attack it was meant to prevent.
|
|
25
|
+
|
|
26
|
+
What bounds these patterns is the patterns: every span is explicitly capped and none pairs two quantifiers over an overlapping alphabet. That is a property of this table rather than a general licence, and it is written where the table is defined so a new detector gets the same check.
|
|
27
|
+
|
|
28
|
+
SECURITY.md is corrected to match. It named a build gate retired in 0.18.37, said no primitive in the family "contains a regular expression" where the enforced claim is that none SCREENS with one, and now states plainly that a runtime-built pattern is not yet reported by the gate. **Fixed:** *The COPY file-access detector no longer depends on how much whitespace precedes STDIN* — The pattern excluded the safe forms with `\s+(?!STDIN\b|STDOUT\b)`. `\s+` is greedy but backtracks: given two spaces it could give one back, leaving ` STDIN` in front of the lookahead, which then read as "not STDIN" and reported a statement that opens no file.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
COPY t TO STDIN quiet
|
|
32
|
+
COPY t TO STDIN reported as "reads or writes a server-side file"
|
|
33
|
+
COPY t TO STDIN reported
|
|
34
|
+
COPY t TO \n STDIN reported
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The exclusion is now tested BEFORE the whitespace is consumed — `\b(?:TO|FROM)\b(?!\s+(?:STDIN|STDOUT)\b)\s+` — so it is evaluated once, at a fixed position, and cannot interact with that quantifier at all.
|
|
38
|
+
|
|
39
|
+
The obvious repair is worse. Teaching the lookahead to skip whitespace where it stood, `\s+(?!\s*(?:STDIN|STDOUT)\b)`, gives the two quantifiers the same alphabet: every backtrack re-scans the remainder of the run, which measures 45 ms on a 16,000-space run and grows quadratically — a denial of service introduced into the primitive whose job is to prevent one. Moving the test in front of `\s+` measures flat, and a cost regression check now covers it.
|
|
40
|
+
|
|
41
|
+
What the detector catches is unchanged, and the lookahead is kept rather than replaced with a scan for a specific reason: its backtracking is what finds the file in `COPY (SELECT x FROM STDOUT) TO '/tmp/x'`, where the first candidate is excluded and the one that matters appears later in the same statement. A scan that resumed past the excluded match would miss it, because every match has to begin at `COPY`. `COPY t TO '/etc/passwd'` fires, `COPY t TO STDINX` fires because the word does not end at `STDIN`, and `COPY t TO STDOUT; COPY u TO '/tmp/x'` fires on the second statement.
|
|
42
|
+
|
|
43
|
+
Twenty regression checks cover the whitespace forms, the nested case and the boundary. **References:** [PostgreSQL COPY — TO/FROM STDIN and STDOUT stream to the client](https://www.postgresql.org/docs/current/sql-copy.html)
|
|
44
|
+
|
|
11
45
|
- v0.18.37 (2026-08-19) — **The no-regex rule for content-safety primitives never covered lib/parsers, where 55 patterns were screening adversarial input.** SECURITY.md states that no `b.guard*` or `b.safe*` primitive contains a regular expression, so a screen costs the length of its input and the byte cap that bounds the input bounds the screen. The build gate enforcing that selected files with `lib/(safe-|guard-)[^/]+\.js` — top level only. Every nested primitive was therefore outside it, and all five of them are the ones that parse untrusted bytes: `b.parsers.yaml`, `b.parsers.toml`, `b.parsers.ini`, `b.parsers.env` and `b.parsers.xml`. Between them they carried 55 pattern literals.
|
|
12
46
|
|
|
13
47
|
All 55 are gone, and the gate now covers nested paths. Nothing about the parsers' behaviour changes: each screen was differential-tested against the pattern it replaced before the call site was swapped.
|
package/lib/guard-regex.js
CHANGED
|
@@ -2321,6 +2321,12 @@ function _ambiguityFindings(src, flags) {
|
|
|
2321
2321
|
// at all" and returned every finding false, so a quadratic pattern using
|
|
2322
2322
|
// any of that syntax walked straight past this gate.
|
|
2323
2323
|
var compiles = true;
|
|
2324
|
+
// The result is discarded and the pattern is never run against a subject —
|
|
2325
|
+
// this asks the parser whether `text` is a regular expression at all, which
|
|
2326
|
+
// is the opposite direction from screening input with one. Nothing a caller
|
|
2327
|
+
// supplies is matched here; a source that does not compile simply reports as
|
|
2328
|
+
// not-a-regex.
|
|
2329
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
2324
2330
|
try { RegExp(text, typeof flags === "string" ? flags : ""); }
|
|
2325
2331
|
catch (_e) { compiles = false; }
|
|
2326
2332
|
if (compiles) {
|
package/lib/guard-sql.js
CHANGED
|
@@ -248,11 +248,32 @@ var MASK_SPACE = " ";
|
|
|
248
248
|
|
|
249
249
|
function _re(source) {
|
|
250
250
|
// Construct each detector regex from an ASCII source string so the
|
|
251
|
-
// source file embeds no attack-character literals. Case-insensitive
|
|
252
|
-
//
|
|
253
|
-
|
|
251
|
+
// source file embeds no attack-character literals. Case-insensitive, and
|
|
252
|
+
// deliberately NOT global: `.test()` on a `g` regex advances `lastIndex` and
|
|
253
|
+
// answers differently on the next call with the same input.
|
|
254
|
+
//
|
|
255
|
+
// These stay on the platform engine, and that was measured rather than
|
|
256
|
+
// assumed. Compiling all 32 on b.regexLinear — whose cost is the length of the
|
|
257
|
+
// subject whatever the pattern says — makes a BENIGN 4 KiB fragment cost 15 ms
|
|
258
|
+
// against 0.012 ms here, because the NFA expands each `{0,4000}` span into
|
|
259
|
+
// thousands of states. The platform engine's own worst case over these
|
|
260
|
+
// patterns is 5 ms at 32 KiB of adversarial SQL, so the swap would have made
|
|
261
|
+
// every request pay more than the attack it was meant to prevent.
|
|
262
|
+
//
|
|
263
|
+
// What bounds these is the patterns themselves: every span is explicitly
|
|
264
|
+
// capped, and none pairs two quantifiers over an overlapping alphabet. That is
|
|
265
|
+
// a property of THIS table, not a general licence — a new detector needs the
|
|
266
|
+
// same check before it is added.
|
|
267
|
+
//
|
|
268
|
+
// The suppression below is the ONE audited exception in the family, and it is
|
|
269
|
+
// recorded rather than assumed: the alternative was measured and is worse, the
|
|
270
|
+
// sources are a fixed compile-time table rather than anything a caller
|
|
271
|
+
// supplies, and the cost is covered by a regression check in guard-sql.test.js.
|
|
272
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
273
|
+
return new RegExp(source, "i"); // allow:dynamic-regex — compile-time ASCII literal table, every span bounded
|
|
254
274
|
}
|
|
255
275
|
|
|
276
|
+
|
|
256
277
|
// \b word-boundary + optional whitespace/paren tolerance baked into
|
|
257
278
|
// each source string. `[\s]` spans the comment-collapsed single spaces.
|
|
258
279
|
var DETECTORS = [
|
|
@@ -263,7 +284,31 @@ var DETECTORS = [
|
|
|
263
284
|
reason: "COPY ... PROGRAM executes a shell command (Postgres RCE)" },
|
|
264
285
|
{ code: "sql.file-access", severity: "critical", kind: "copy-file",
|
|
265
286
|
family: "floor", dialect: "postgres",
|
|
266
|
-
|
|
287
|
+
// STDIN / STDOUT stream to the client and touch no server-side file, so
|
|
288
|
+
// they are excluded — and the exclusion is tested BEFORE the whitespace is
|
|
289
|
+
// consumed, which is the whole trick here.
|
|
290
|
+
//
|
|
291
|
+
// Written the other way round, after `\s+`, it is wrong: `\s+` is greedy but
|
|
292
|
+
// backtracks, so given two spaces it gives one back, leaves " STDIN" in
|
|
293
|
+
// front of a lookahead that only knows how to reject "STDIN", and reports a
|
|
294
|
+
// client-side `COPY t TO STDIN` as a server-side file access. One space was
|
|
295
|
+
// quiet and two were critical.
|
|
296
|
+
//
|
|
297
|
+
// Teaching that lookahead to skip whitespace (`(?!\s*(?:STDIN|STDOUT)\b)`)
|
|
298
|
+
// fixes the verdict and introduces a worse problem: `\s+` and the `\s*`
|
|
299
|
+
// range over the same characters, so every backtrack re-scans the rest of
|
|
300
|
+
// the run — 45 ms on a 16k-space run, quadratic, in the one primitive whose
|
|
301
|
+
// job is to not be a denial of service.
|
|
302
|
+
//
|
|
303
|
+
// In front of `\s+` the lookahead is evaluated once, at a fixed position,
|
|
304
|
+
// and cannot interact with that quantifier at all. Measured flat.
|
|
305
|
+
//
|
|
306
|
+
// A lookahead rather than a scan-and-compare because its backtracking is
|
|
307
|
+
// what finds the file in `COPY (SELECT x FROM STDOUT) TO '/tmp/x'`: the
|
|
308
|
+
// first candidate is excluded and the one that matters comes later in the
|
|
309
|
+
// SAME statement, which a scan resuming past the excluded match would miss,
|
|
310
|
+
// since every match has to begin at COPY.
|
|
311
|
+
re: _re("\\bCOPY\\b[\\s\\S]{0,4000}?\\b(?:TO|FROM)\\b(?!\\s+(?:STDIN|STDOUT)\\b)\\s+"),
|
|
267
312
|
reason: "COPY TO/FROM <file> reads or writes a server-side file" },
|
|
268
313
|
{ code: "sql.file-access", severity: "critical", kind: "large-object",
|
|
269
314
|
family: "floor", dialect: "postgres",
|
package/lib/safe-json.js
CHANGED
|
@@ -837,6 +837,12 @@ function _patternMatcher(pattern) {
|
|
|
837
837
|
// branch twice. That is the overlap the alternation rule exists to
|
|
838
838
|
// catch, so `/^(?=(a|A)+$)a+$/i` would pass a source-only screen and
|
|
839
839
|
// then run, under those flags, against a value from the wire.
|
|
840
|
+
// Compiled precisely so assertSafe can screen the COMPILED form, per the
|
|
841
|
+
// comment above: a source-only screen misreads `(a|A)+` under `i`. The
|
|
842
|
+
// pattern here is the operator's schema `pattern` keyword — the input
|
|
843
|
+
// being validated rather than the implementation of a screen — and it is
|
|
844
|
+
// refused before it ever runs against a value.
|
|
845
|
+
// eslint-disable-next-line blamejs/no-regex-in-content-safety
|
|
840
846
|
var native = new RegExp(source, flags);
|
|
841
847
|
// assertSafe builds `new ErrorClass(code, message)`; SafeJsonError takes
|
|
842
848
|
// them the other way round, so the screen reports through its own error.
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:3e525c66-4a72-4004-90a2-8985bbb5daa4",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-19T06:46:25.351Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.18.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.38",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.18.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.18.38",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.18.
|
|
57
|
+
"ref": "@blamejs/core@0.18.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|