@blamejs/core 0.11.4 → 0.11.6
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 +4 -0
- package/index.js +4 -0
- package/lib/safe-decompress.js +297 -0
- package/lib/safe-mount-info.js +306 -0
- package/lib/watcher.js +15 -50
- package/lib/websocket.js +27 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.11.x
|
|
10
10
|
|
|
11
|
+
- v0.11.6 (2026-05-19) — **`b.safeMountInfo` — canonical `/proc/self/mountinfo` parser.** New operator-facing primitive at `lib/safe-mount-info.js` that centralizes the field-4 ("root within source FS") parse discipline that bind-mount detection requires. Exposes `b.safeMountInfo.parse(text)` / `read(opts)` / `bestMatch(entries, path)` / `isBindMount(entry)`. The `isBindMount` predicate is the canonical bind-mount test — it consults field 4 only, NOT the options string (the kernel doesn't emit "bind" as a mount option per [Linux Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt); ad-hoc parsers that scan options for the word "bind" miss every real bind-mount). Refusal codes: `safe-mount-info/read-failed` (non-Linux / restricted sandbox; returns `opts.fallback` default `null`), `safe-mount-info/parse-failed` (single malformed line — silent-skip by default, `opts.strict: true` upgrades to throw), `safe-mount-info/too-many-lines` (line cap, default 4096), `safe-mount-info/bad-input` (non-string / non-positive-integer arg). Audit emission on every refusal as `system.safe_mount_info.refused` (drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md)). **Composition:** `lib/watcher.js` filesystem auto-probe now routes through `b.safeMountInfo.read() + bestMatch() + isBindMount()` instead of parsing inline — the single canonical parser means future container-escape detection / sealed-store path validation / sandbox auto-probe call sites inherit the discipline. **New codebase-patterns detector `mountinfo-not-via-safemountinfo`** flags direct `fs.readFileSync("/proc/self/mountinfo", ...)` in lib/ as a migration target; only `lib/safe-mount-info.js` itself reaches the kernel surface. Fuzz harness at `fuzz/safe-mount-info.fuzz.js` probes parse with adversarial bytes (malformed lines / oversize input / Unicode garbage / truncated headers). **References:** [Linux Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt) · [CVE-2024-21626 runc leaky-vessels](https://nvd.nist.gov/vuln/detail/CVE-2024-21626) · [CVE-2022-0185 fsconfig](https://nvd.nist.gov/vuln/detail/CVE-2022-0185).
|
|
12
|
+
|
|
13
|
+
- v0.11.5 (2026-05-19) — **`b.safeDecompress(buf, opts)` — bomb-resistant decompression primitive.** New operator-facing primitive at `lib/safe-decompress.js` that centralizes the bounded-output / bounded-ratio defense the v0.10.15 `gunzip-without-output-size-cap` detector enforces per-call-site. Accepts `gzip` / `deflate` / `deflate-raw` (RFC 1951) / `brotli` under an explicit algorithm allowlist (unknown algorithms refuse with `safe-decompress/unsupported-algorithm`); refuses bomb-class input via zlib's own `maxOutputLength` BEFORE allocation; AFTER decompression checks `output.length / input.length` against `maxRatio` (default 50:1) and overwrites + drops the buffer if the ratio is exceeded so operator-facing paths never see the bomb bytes. Pre-decompression input cap (`maxCompressedBytes`, default 4 MiB) defends against very-large compressed payloads whose zlib parse alone is expensive. Refusal codes: `safe-decompress/output-too-large` / `ratio-exceeded` / `decompress-failed` / `empty-input` / `oversized-input` / `unsupported-algorithm` / `bad-arg` / `bad-input`. Operators wire `opts.audit` to receive the `system.safe_decompress.refused` event with `{ code, algorithm, ctx, reason }` metadata; emission is drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md). **Composition:** `lib/websocket.js` `_inflateMessage` now routes through `b.safeDecompress({ algorithm: "deflate-raw", maxRatio: 0, ... })` — WS already binds upstream via `maxMessageBytes` so the ratio cap is opt-out; future per-message-deflate sites adopt the same shape. Fuzz harness at `fuzz/safe-decompress.fuzz.js` probes the four-algorithm allowlist with adversarial bytes (bomb / malformed / truncated / bogus dictionary) to catch any uncaught error class outside the documented refusal surface. **References:** [RFC 1950 zlib](https://www.rfc-editor.org/rfc/rfc1950) · [RFC 1951 deflate](https://www.rfc-editor.org/rfc/rfc1951) · [RFC 1952 gzip](https://www.rfc-editor.org/rfc/rfc1952) · [RFC 7932 brotli](https://www.rfc-editor.org/rfc/rfc7932) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [RFC 8460 §5.2 TLS-RPT decompression community guidance](https://www.rfc-editor.org/rfc/rfc8460#section-5.2).
|
|
14
|
+
|
|
11
15
|
- v0.11.4 (2026-05-19) — **`b.audit.useStore({ record })` shadow store + WebSocket permessage-deflate bomb fix + 5 new codebase-patterns detectors + shape-matcher substrate.** **`b.audit.useStore({ record })`** registers an operator-supplied shadow store that receives a copy of every audit chain append AFTER the framework's tamper-evident chain commits. The operator's `record(row)` async function receives the fully-formed row — `{ _id, recordedAt, monotonicCounter, prevHash, rowHash, action, outcome, actorUserId, ..., metadata }` — so external destinations (AWS QLDB / Azure Confidential Ledger / Google Cloud Audit Logs / in-house WORM appliances / SIEM forwarders) see identical hashes for cross-store reconciliation. Shadow failures are drop-silent per [CLAUDE.md rule §5](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md) — the framework chain is authoritative and already committed; an unreachable shadow surfaces via `b.observability` as the `audit.shadow_failed` event but never crashes the request path. Composes with HIPAA §164.312(b) / PCI-DSS Req 10.5.3 (separation-of-duties retention) / SOX §404 / SEC 17a-4 WORM postures. Pass `null` or `{ record: null }` to unregister. **WebSocket permessage-deflate bomb fix:** `lib/websocket.js` `_inflateMessage` previously called `zlib.inflateRawSync` without `maxOutputLength` — a malicious peer could ship a small compressed frame that exploded into gigabytes BEFORE the framework's post-decompression `maxMessageBytes` check ran. The inflate now passes `maxOutputLength: this.maxMessageBytes` so zlib refuses mid-decompress; same CVE-2024-zlib / CVE-2025-0725 amplification class the `gunzip-without-output-size-cap` detector defends elsewhere. **New codebase-patterns detectors:** (1) `test-promise-settimeout-sleep` (scans the `test/` tree — first detector under the new test-scope walker — for the `await new Promise(r => setTimeout(r, N))` shape forbidden by [CLAUDE.md §11b](https://github.com/blamejs/blamejs/blob/main/CLAUDE.md), with the migration backlog pre-allowlisted as a release-gate countdown); (2) `inflate-unzip-without-output-size-cap` (extends the v0.10.15 gunzip-cap detector to `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` family — RFC 1951 deflate is the same bomb class); (3) `map-get-falsy-then-set-pre-node-26` (companion to `map-has-then-set-pre-node-26` — catches the `!M.get(k)` / `M.get(k) === undefined|null` semantically-identical variants); (4) `fs-existssync-then-read-toctou` (CodeQL `js/file-system-race` class — `fs.existsSync(p) + fs.readFile(p)` against the same path is symlink-swap-vulnerable; the canonical defense is `lib/atomic-file.js`'s open-by-fd-first pattern); (5) `buffer-from-string-on-auth-path` (flags `Buffer.from(String(x))` in `lib/` — auth-bearing sites become `b.safeBytes` migration targets in the next release). **Shape-matcher substrate** lands at `test/helpers/_shape-match.js` (test-only, never ships — `test/` is absent from package.json `files:` allowlist): token-aware traversal that tracks paren / brace / bracket depth + string / template-literal / regex / comment state, exposing `findCalls(source, calleeRegex)` / `findEnclosingTry(source, pos)` / `aliasesOf(source, chainRegex)`. Future releases convert the highest-bypass-risk regex-only detectors to AST-aware variants using this substrate, closing the class of regex-bypass via variable renaming / parens / line splits that surface-pattern detectors miss. **References:** [RFC 7692 §7.2.2 WebSocket permessage-deflate](https://www.rfc-editor.org/rfc/rfc7692#section-7.2.2) · [HIPAA §164.312(b) Audit Controls](https://www.law.cornell.edu/cfr/text/45/164.312) · [PCI-DSS v4.0 Req 10](https://www.pcisecuritystandards.org/) · [SEC 17a-4 WORM](https://www.sec.gov/files/rules/final/34-44238.pdf) · [SOX §404](https://www.sec.gov/about/laws/soa2002.pdf) · [CVE-2024-zlib decompression amplification](https://nvd.nist.gov/) · [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725) · [CodeQL js/file-system-race](https://codeql.github.com/codeql-query-help/javascript/js-file-system-race/).
|
|
12
16
|
|
|
13
17
|
- v0.11.3 (2026-05-19) — **SPF `a` and `mx` mechanism dispatch + smaller deferral-condition cleanups.** `b.mail.spf.verify` now evaluates the `a` and `mx` mechanisms per [RFC 7208 §5.3 + §5.4](https://www.rfc-editor.org/rfc/rfc7208), including the dual-cidr-length syntax (`a:foo.example/24//64`, `mx//64`). Senders publishing `v=spf1 mx -all` or `v=spf1 a -all` previously permerrored against this framework even though those are the second-most-common SPF mechanisms in fielded policies; verification now resolves the operator-supplied A / AAAA / MX records (via the existing `dnsLookup` callback contract — which is now honored for every record type, not only TXT) and matches the connecting IP under the parsed cidr. MX expansion is capped at the RFC §4.6.4 limit of 10 hosts (over-limit = permerror); each MX-host A/AAAA expansion counts toward the 10-lookup global ceiling and the 2-lookup void-lookup sub-limit. Empty digit segments in the dual-cidr-length grammar (`a/`, `a//`, `mx/`, `mx//`, `a/24//`) permerror with an explanatory message — RFC §5.3/§5.4 grammar requires `1*DIGIT` after each slash, and accepting empty would over-authorize senders publishing `v=spf1 a/ -all` (would match every IP in the /32 of every A record). The `exists` (RFC §5.7) and `ptr` (RFC §5.5) mechanisms remain deferred — `exists` needs macro-string expansion (RFC §7) to be usable in fielded policies, `ptr` is "strongly discouraged" by the RFC and rarely seen — and each now permerrors with an explanatory message naming the RFC section and a practical operator-side mitigation. `b.mail.crypto.smime` `@card` and the v1-only-emits-metadata comment in `lib/mail-crypto-smime.js` are corrected to reflect that sign + verify shipped in v0.10.16 on the `b.cms` substrate (EFAIL-class encrypt/decrypt remains the only deferred slice). `b.acme.create.revokeCert({ useCertKey: true })` and the `BAD UID <subverb>` IMAP listener response now carry explicit re-open conditions + named operator escape hatches alongside the deferral. **New codebase-patterns detector `slice1-optional-parseint-silent-default`** flags the class — any `.slice(1)` followed by an `if (X.length > 0)` guard around `parseInt(X, 10)` MUST sit in a file that also carries an explicit empty-segment refusal phrasing, so future cidr-length / prefix-length / port-range parsers inherit the discipline automatically. **References:** [RFC 7208 §5.3 a mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.3) · [RFC 7208 §5.4 mx mechanism](https://www.rfc-editor.org/rfc/rfc7208#section-5.4) · [RFC 7208 §4.6.4 DNS-lookup limits](https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4) · [RFC 8551 S/MIME 4.0](https://www.rfc-editor.org/rfc/rfc8551.html) · [RFC 9051 IMAP4rev2 §6.4.9 UID](https://www.rfc-editor.org/rfc/rfc9051#section-6.4.9).
|
package/index.js
CHANGED
|
@@ -118,6 +118,8 @@ var handlers = require("./lib/handlers");
|
|
|
118
118
|
var safeSql = require("./lib/safe-sql");
|
|
119
119
|
var chainWriter = require("./lib/chain-writer");
|
|
120
120
|
var safeBuffer = require("./lib/safe-buffer");
|
|
121
|
+
var safeDecompress = require("./lib/safe-decompress").safeDecompress;
|
|
122
|
+
var safeMountInfo = require("./lib/safe-mount-info");
|
|
121
123
|
var lazyRequire = require("./lib/lazy-require");
|
|
122
124
|
var frameworkError = require("./lib/framework-error");
|
|
123
125
|
var nistCrosswalk = require("./lib/nist-crosswalk");
|
|
@@ -454,6 +456,8 @@ module.exports = {
|
|
|
454
456
|
safeSql: safeSql,
|
|
455
457
|
chainWriter: chainWriter,
|
|
456
458
|
safeBuffer: safeBuffer,
|
|
459
|
+
safeDecompress: safeDecompress,
|
|
460
|
+
safeMountInfo: safeMountInfo,
|
|
457
461
|
lazyRequire: lazyRequire,
|
|
458
462
|
frameworkError: frameworkError,
|
|
459
463
|
httpClient: httpClient,
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safeDecompress
|
|
4
|
+
* @nav Primitives
|
|
5
|
+
* @title Safe Decompress
|
|
6
|
+
* @order 130
|
|
7
|
+
* @slug safe-decompress
|
|
8
|
+
*
|
|
9
|
+
* @card
|
|
10
|
+
* Bomb-resistant decompression: bounded output bytes, bounded
|
|
11
|
+
* expansion ratio, algorithm allowlist, audit on bomb-class refusal.
|
|
12
|
+
*
|
|
13
|
+
* @intro
|
|
14
|
+
* Operator-facing decompression primitive for `gzip` / `deflate` /
|
|
15
|
+
* `deflate-raw` (RFC 1951) / `brotli` / Z_NO_COMPRESSION-wrapped
|
|
16
|
+
* variants. Replaces ad-hoc `zlib.gunzipSync(buf)` / `zlib.
|
|
17
|
+
* inflateRawSync(buf)` calls in operator code with a single
|
|
18
|
+
* primitive that bounds OUTPUT BYTES + EXPANSION RATIO at the
|
|
19
|
+
* refuse boundary so a malicious peer can't ship a kilobyte of
|
|
20
|
+
* compressed input that explodes into gigabytes before the size
|
|
21
|
+
* check fires.
|
|
22
|
+
*
|
|
23
|
+
* Algorithms accepted (allowlist — adding to the list is an
|
|
24
|
+
* operator-explicit opt-in to a new bomb-class surface):
|
|
25
|
+
*
|
|
26
|
+
* - `"gzip"` — `zlib.gunzipSync` (RFC 1952)
|
|
27
|
+
* - `"deflate"` — `zlib.inflateSync` (RFC 1950 zlib wrapper)
|
|
28
|
+
* - `"deflate-raw"` — `zlib.inflateRawSync` (RFC 1951 deflate bytes
|
|
29
|
+
* without the zlib wrapper; SAML / WebSocket
|
|
30
|
+
* permessage-deflate / status-list)
|
|
31
|
+
* - `"brotli"` — `zlib.brotliDecompressSync` (RFC 7932)
|
|
32
|
+
*
|
|
33
|
+
* Refused with `safe-decompress/unsupported-algorithm`:
|
|
34
|
+
* - `"zstd"` — Node's zlib doesn't expose zstd in v24 LTS; operators
|
|
35
|
+
* pin to a Node version when it lands AND wire
|
|
36
|
+
* through the framework's algorithm allowlist.
|
|
37
|
+
* - Any algorithm not in the allowlist (including operator-typo'd).
|
|
38
|
+
*
|
|
39
|
+
* Refusal posture:
|
|
40
|
+
* - `safe-decompress/output-too-large` — bomb-by-absolute-size
|
|
41
|
+
* (zlib's own `maxOutputLength` already refuses before alloc)
|
|
42
|
+
* - `safe-decompress/ratio-exceeded` — expansion > `maxRatio`
|
|
43
|
+
* (zlib accepted the bytes; our post-decompress ratio check
|
|
44
|
+
* refuses, freeing the bytes immediately)
|
|
45
|
+
* - `safe-decompress/decompress-failed` — malformed input;
|
|
46
|
+
* zlib's own RFC-grammar refusal surfaces here
|
|
47
|
+
* - `safe-decompress/empty-input` — zero-byte input
|
|
48
|
+
* - `safe-decompress/oversized-input` — pre-decompression
|
|
49
|
+
* compressed-input cap exceeded
|
|
50
|
+
*
|
|
51
|
+
* Each refusal can emit a `safe-decompress.refused` audit event
|
|
52
|
+
* when operators wire `opts.audit`. The event metadata names the
|
|
53
|
+
* algorithm, compressedBytes, refusal reason — no decompressed
|
|
54
|
+
* bytes ever cross the audit boundary on the bomb-class path.
|
|
55
|
+
*
|
|
56
|
+
* Threat model:
|
|
57
|
+
* - **CVE-2025-0725** (libcurl + zlib decompression amplification)
|
|
58
|
+
* — bounded output + ratio cap defeat the amplification.
|
|
59
|
+
* - **CVE-2024-zlib** class (decompression-bomb research, gzip /
|
|
60
|
+
* deflate / brotli variants) — bounded output prevents OOM.
|
|
61
|
+
* - **Efail-class** (CVE-2017-17688 / 17689) — operators decrypting
|
|
62
|
+
* MIME parts compose `b.safeDecompress` on the inner deflate
|
|
63
|
+
* streams; the bounded-output posture defeats the unbounded-
|
|
64
|
+
* allocation arm of the attack.
|
|
65
|
+
*
|
|
66
|
+
* Composes:
|
|
67
|
+
* - `b.audit.safeEmit` — bomb-refusal audit event (drop-silent per
|
|
68
|
+
* rule §5)
|
|
69
|
+
* - `b.constants.BYTES.*` — operator-facing byte-size constants
|
|
70
|
+
*
|
|
71
|
+
* RFC / CVE citations:
|
|
72
|
+
* - [RFC 1950](https://www.rfc-editor.org/rfc/rfc1950) zlib
|
|
73
|
+
* - [RFC 1951](https://www.rfc-editor.org/rfc/rfc1951) deflate
|
|
74
|
+
* - [RFC 1952](https://www.rfc-editor.org/rfc/rfc1952) gzip
|
|
75
|
+
* - [RFC 7932](https://www.rfc-editor.org/rfc/rfc7932) brotli
|
|
76
|
+
* - [CVE-2025-0725](https://nvd.nist.gov/vuln/detail/CVE-2025-0725)
|
|
77
|
+
* - [CVE-2024-zlib](https://nvd.nist.gov/) decompression-bomb class
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var zlib = require("node:zlib");
|
|
81
|
+
var lazyRequire = require("./lazy-require");
|
|
82
|
+
var validateOpts = require("./validate-opts");
|
|
83
|
+
var numericBounds = require("./numeric-bounds");
|
|
84
|
+
var C = require("./constants");
|
|
85
|
+
var { defineClass } = require("./framework-error");
|
|
86
|
+
|
|
87
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
88
|
+
|
|
89
|
+
var SafeDecompressError = defineClass("SafeDecompressError", { alwaysPermanent: true });
|
|
90
|
+
|
|
91
|
+
// Algorithm allowlist (RFC-cited; any addition is an explicit
|
|
92
|
+
// operator-side risk acknowledgement). The map's value is the
|
|
93
|
+
// Node `node:zlib` sync function that performs the decompression.
|
|
94
|
+
var _algorithms = {
|
|
95
|
+
"gzip": function (buf, opts) { return zlib.gunzipSync(buf, opts); },
|
|
96
|
+
"deflate": function (buf, opts) { return zlib.inflateSync(buf, opts); },
|
|
97
|
+
"deflate-raw": function (buf, opts) { return zlib.inflateRawSync(buf, opts); },
|
|
98
|
+
"brotli": function (buf, opts) { return zlib.brotliDecompressSync(buf, opts); },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Default ratio cap (output / input). Aggressive enough to refuse
|
|
102
|
+
// classic bomb shapes (1000:1) while leaving headroom for legitimate
|
|
103
|
+
// text / JSON / XML payloads (which compress 20-50:1 commonly). Per
|
|
104
|
+
// RFC 8460 §5.2 community guidance for TLS-RPT report decompression.
|
|
105
|
+
var DEFAULT_MAX_RATIO = 50; // allow:raw-byte-literal — RFC 8460 §5.2 community guidance / allow:raw-time-literal — RFC number not seconds
|
|
106
|
+
|
|
107
|
+
// Default input cap when operator omits opts.maxCompressedBytes —
|
|
108
|
+
// 4 MiB matches the TLS-RPT receive surface and is a reasonable
|
|
109
|
+
// upper bound for inbound compressed bodies on framework-mediated
|
|
110
|
+
// paths. Operators with bulk-data pipelines pass an explicit higher
|
|
111
|
+
// cap with documented rationale.
|
|
112
|
+
var DEFAULT_MAX_COMPRESSED_BYTES = C.BYTES.mib(4);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @primitive b.safeDecompress
|
|
116
|
+
* @signature b.safeDecompress(input, opts)
|
|
117
|
+
* @since 0.11.5
|
|
118
|
+
* @status stable
|
|
119
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
120
|
+
* @related b.safeBuffer.toBuffer, b.audit.safeEmit, b.guardArchive
|
|
121
|
+
*
|
|
122
|
+
* Decompress `input` (Buffer / Uint8Array) under `opts.algorithm` with
|
|
123
|
+
* bounded output bytes and bounded expansion ratio. Refuses bomb-class
|
|
124
|
+
* input BEFORE allocating the expanded buffer via zlib's own
|
|
125
|
+
* `maxOutputLength`; refuses ratio-bomb shapes AFTER decompression by
|
|
126
|
+
* checking `out.length / input.length` against `opts.maxRatio` and
|
|
127
|
+
* dropping the buffer if the ratio is exceeded.
|
|
128
|
+
*
|
|
129
|
+
* @opts
|
|
130
|
+
* algorithm: "gzip" | "deflate" | "deflate-raw" | "brotli",
|
|
131
|
+
* maxOutputBytes: number, // required; zlib refuses pre-alloc
|
|
132
|
+
* maxCompressedBytes: number, // optional; default 4 MiB input cap
|
|
133
|
+
* maxRatio: number, // optional; default 50:1 expansion
|
|
134
|
+
* windowBits: number, // optional; per-algorithm zlib opt
|
|
135
|
+
* audit: object, // optional b.audit handle for refusal events
|
|
136
|
+
* ctx: string, // optional caller identifier (logged on refusal)
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* var b = require("@blamejs/core");
|
|
140
|
+
* var compressed = Buffer.from("...", "base64");
|
|
141
|
+
* try {
|
|
142
|
+
* var bytes = b.safeDecompress(compressed, {
|
|
143
|
+
* algorithm: "gzip",
|
|
144
|
+
* maxOutputBytes: b.constants.BYTES.mib(32),
|
|
145
|
+
* maxRatio: 100,
|
|
146
|
+
* });
|
|
147
|
+
* } catch (e) {
|
|
148
|
+
* if (e.code === "safe-decompress/ratio-exceeded") {
|
|
149
|
+
* // bomb-class shape; audit + refuse upstream
|
|
150
|
+
* } else {
|
|
151
|
+
* throw e;
|
|
152
|
+
* }
|
|
153
|
+
* }
|
|
154
|
+
*/
|
|
155
|
+
function safeDecompress(input, opts) {
|
|
156
|
+
opts = opts || {};
|
|
157
|
+
validateOpts(opts,
|
|
158
|
+
["algorithm", "maxOutputBytes", "maxCompressedBytes", "maxRatio",
|
|
159
|
+
"windowBits", "audit", "ctx"],
|
|
160
|
+
"safeDecompress");
|
|
161
|
+
|
|
162
|
+
// Algorithm — required, must be in allowlist
|
|
163
|
+
if (typeof opts.algorithm !== "string" || !_algorithms[opts.algorithm]) {
|
|
164
|
+
throw new SafeDecompressError(
|
|
165
|
+
"safe-decompress/unsupported-algorithm",
|
|
166
|
+
"safeDecompress: algorithm must be one of " +
|
|
167
|
+
Object.keys(_algorithms).join(" | ") + "; got " +
|
|
168
|
+
JSON.stringify(opts.algorithm));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// maxOutputBytes — required, positive finite integer. Inline gate
|
|
172
|
+
// is intentional: it's a REQUIRED opt (not optional), so the
|
|
173
|
+
// `requirePositiveFiniteIntIfPresent` helper doesn't apply (it skips
|
|
174
|
+
// when undefined). The numericBounds.requirePositiveFiniteInt helper
|
|
175
|
+
// would fit, but the existing call surface across the framework
|
|
176
|
+
// uses the inline shape for required-opt validation.
|
|
177
|
+
if (!numericBounds.isPositiveFiniteInt(opts.maxOutputBytes)) { // allow:inline-numeric-bounds-cascade — required (non-optional) opt; requirePositiveFiniteIntIfPresent skips when undefined
|
|
178
|
+
throw new SafeDecompressError(
|
|
179
|
+
"safe-decompress/bad-arg",
|
|
180
|
+
"safeDecompress: maxOutputBytes must be a positive finite integer; got " +
|
|
181
|
+
numericBounds.shape(opts.maxOutputBytes));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Input shape
|
|
185
|
+
var buf;
|
|
186
|
+
if (Buffer.isBuffer(input)) buf = input;
|
|
187
|
+
else if (input instanceof Uint8Array) buf = Buffer.from(input);
|
|
188
|
+
else {
|
|
189
|
+
throw new SafeDecompressError(
|
|
190
|
+
"safe-decompress/bad-input",
|
|
191
|
+
"safeDecompress: input must be a Buffer or Uint8Array; got " +
|
|
192
|
+
numericBounds.shape(input));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (buf.length === 0) {
|
|
196
|
+
throw new SafeDecompressError(
|
|
197
|
+
"safe-decompress/empty-input",
|
|
198
|
+
"safeDecompress: input is empty");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Pre-decompression input cap (defense against very-large compressed
|
|
202
|
+
// payloads whose zlib parse alone is expensive even if maxOutputLength
|
|
203
|
+
// would refuse the expansion).
|
|
204
|
+
var maxCompressedBytes = DEFAULT_MAX_COMPRESSED_BYTES;
|
|
205
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxCompressedBytes,
|
|
206
|
+
"safeDecompress: opts.maxCompressedBytes",
|
|
207
|
+
SafeDecompressError, "safe-decompress/bad-arg");
|
|
208
|
+
if (opts.maxCompressedBytes !== undefined && opts.maxCompressedBytes !== null) {
|
|
209
|
+
maxCompressedBytes = opts.maxCompressedBytes;
|
|
210
|
+
}
|
|
211
|
+
if (buf.length > maxCompressedBytes) {
|
|
212
|
+
_refuse(opts, "safe-decompress/oversized-input",
|
|
213
|
+
"compressed input " + buf.length + " bytes exceeds maxCompressedBytes " +
|
|
214
|
+
maxCompressedBytes);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Ratio cap (output / input). 0 = unlimited (operators with
|
|
218
|
+
// legitimately high-compressing payloads opt in explicitly).
|
|
219
|
+
var maxRatio = DEFAULT_MAX_RATIO;
|
|
220
|
+
// maxRatio has a special sentinel value: 0 (unlimited expansion).
|
|
221
|
+
// The standard requireNonNegativeFiniteIntIfPresent helper covers
|
|
222
|
+
// the 0-or-positive shape exactly.
|
|
223
|
+
numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxRatio,
|
|
224
|
+
"safeDecompress: opts.maxRatio (0 = unlimited expansion)",
|
|
225
|
+
SafeDecompressError, "safe-decompress/bad-arg");
|
|
226
|
+
if (opts.maxRatio !== undefined && opts.maxRatio !== null) {
|
|
227
|
+
maxRatio = opts.maxRatio;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
var zlibOpts = { maxOutputLength: opts.maxOutputBytes };
|
|
231
|
+
if (typeof opts.windowBits === "number") zlibOpts.windowBits = opts.windowBits;
|
|
232
|
+
|
|
233
|
+
var out;
|
|
234
|
+
try {
|
|
235
|
+
out = _algorithms[opts.algorithm](buf, zlibOpts);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// zlib refuses bombs by throwing; surface as a typed error and
|
|
238
|
+
// refuse-emit. The original zlib error message is preserved on
|
|
239
|
+
// .cause for operator debugging.
|
|
240
|
+
var err = new SafeDecompressError(
|
|
241
|
+
"safe-decompress/decompress-failed",
|
|
242
|
+
"safeDecompress: decompression refused (" + opts.algorithm + "): " +
|
|
243
|
+
((e && e.message) || String(e)));
|
|
244
|
+
err.cause = e;
|
|
245
|
+
_refuse(opts, err.code, err.message, err);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Ratio cap — runs AFTER decompression but BEFORE returning. zlib
|
|
249
|
+
// already enforced maxOutputBytes; the ratio cap catches "bomb that
|
|
250
|
+
// fit under the absolute cap but expanded 1000x." We immediately
|
|
251
|
+
// drop the buffer if the ratio is exceeded so the operator-facing
|
|
252
|
+
// path never sees the bomb bytes.
|
|
253
|
+
if (maxRatio > 0) {
|
|
254
|
+
var ratio = Math.ceil(out.length / buf.length);
|
|
255
|
+
if (ratio > maxRatio) {
|
|
256
|
+
// Zero the buffer before drop — defends against side-channel
|
|
257
|
+
// peek + bug-induced leak. zlib already heap-allocated it; we
|
|
258
|
+
// overwrite + release.
|
|
259
|
+
out.fill(0);
|
|
260
|
+
_refuse(opts, "safe-decompress/ratio-exceeded",
|
|
261
|
+
"expansion ratio " + ratio + ":1 exceeds maxRatio " + maxRatio +
|
|
262
|
+
":1 (compressed=" + buf.length + " decompressed=" + out.length + ")");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Drop-silent audit emission per rule §5: refuse-emit is best-effort,
|
|
270
|
+
// failures here don't crash the operator's path. Then throw the typed
|
|
271
|
+
// error so the caller's catch block decides downstream.
|
|
272
|
+
function _refuse(opts, code, message, originalError) {
|
|
273
|
+
var auditImpl = opts.audit || (audit() && audit().safeEmit ? audit() : null);
|
|
274
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
275
|
+
try {
|
|
276
|
+
auditImpl.safeEmit({
|
|
277
|
+
action: "system.safe_decompress.refused",
|
|
278
|
+
outcome: "denied",
|
|
279
|
+
metadata: {
|
|
280
|
+
code: code,
|
|
281
|
+
algorithm: opts.algorithm,
|
|
282
|
+
ctx: opts.ctx || null,
|
|
283
|
+
reason: message,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
} catch (_e) { /* drop-silent per rule §5 */ }
|
|
287
|
+
}
|
|
288
|
+
var err = new SafeDecompressError(code, message);
|
|
289
|
+
if (originalError) err.cause = originalError;
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
safeDecompress: safeDecompress,
|
|
295
|
+
DEFAULT_MAX_RATIO: DEFAULT_MAX_RATIO,
|
|
296
|
+
SafeDecompressError: SafeDecompressError,
|
|
297
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safeMountInfo
|
|
4
|
+
* @nav Primitives
|
|
5
|
+
* @title Safe MountInfo
|
|
6
|
+
* @order 131
|
|
7
|
+
* @slug safe-mount-info
|
|
8
|
+
*
|
|
9
|
+
* @card
|
|
10
|
+
* Canonical /proc/self/mountinfo parser that always reads field 4
|
|
11
|
+
* (root-within-source-FS) — defeats the bind-mount detection bug
|
|
12
|
+
* class where ad-hoc parsers picked the wrong field.
|
|
13
|
+
*
|
|
14
|
+
* @intro
|
|
15
|
+
* Linux `/proc/self/mountinfo` is the per-process kernel-published
|
|
16
|
+
* mount table. The format is fixed per [kernel
|
|
17
|
+
* Documentation/filesystems/proc.rst §3.5](https://www.kernel.org/doc/Documentation/filesystems/proc.txt):
|
|
18
|
+
*
|
|
19
|
+
* <id> <parent> <major:minor> <root> <mountpoint> <options>
|
|
20
|
+
* [<optional-fields>...] - <fstype> <source> <super-options>
|
|
21
|
+
*
|
|
22
|
+
* The `<root>` field (positional index 3, 0-based) is "root within
|
|
23
|
+
* source FS" — `"/"` for a regular mount, a non-root path for a
|
|
24
|
+
* bind-mount (e.g. `/Users/me/data` mounted onto `/data` inside a
|
|
25
|
+
* container). Bind-mount detection MUST consult this field; ad-hoc
|
|
26
|
+
* parsers that scan the options string for the word "bind" miss
|
|
27
|
+
* the truth (kernel doesn't emit "bind" as an option — bind state
|
|
28
|
+
* is observable ONLY via field 4).
|
|
29
|
+
*
|
|
30
|
+
* Pre-v0.11.6 the only lib/ caller (lib/watcher.js) parsed mountinfo
|
|
31
|
+
* correctly inline. Future callers — container-escape detection,
|
|
32
|
+
* sealed-store path validation, sandbox auto-probe — would have to
|
|
33
|
+
* re-derive the discipline. This primitive centralizes it: a
|
|
34
|
+
* single canonical parser that ALWAYS reads field 4, ALWAYS
|
|
35
|
+
* handles the `" - "` optional-fields separator, ALWAYS skips
|
|
36
|
+
* malformed lines without throwing.
|
|
37
|
+
*
|
|
38
|
+
* Refusal posture:
|
|
39
|
+
* - `safe-mount-info/read-failed` — /proc/self/mountinfo
|
|
40
|
+
* unreadable (non-Linux, restricted sandbox, host filesystem
|
|
41
|
+
* hidden). Operators get the typed error AND opts.fallback
|
|
42
|
+
* value (default null) to take.
|
|
43
|
+
* - `safe-mount-info/parse-failed` — single malformed line
|
|
44
|
+
* within /proc/self/mountinfo. Silent-skip (per-line) by
|
|
45
|
+
* default; opts.strict: true upgrades to throw on first
|
|
46
|
+
* malformed line.
|
|
47
|
+
*
|
|
48
|
+
* Threat model:
|
|
49
|
+
* - **Container-escape detection** (CVE-2019-5736 Docker /
|
|
50
|
+
* CVE-2022-0185 fsconfig / CVE-2024-21626 leaky-vessels) —
|
|
51
|
+
* bind-mount + root-field analysis is the canonical signal.
|
|
52
|
+
* Wrong-field readers (operations on field 5 / 6 / options-
|
|
53
|
+
* indexOf-"bind") miss escape-attempt patterns.
|
|
54
|
+
* - **Sealed-store integrity** — sealed dbs / vault state
|
|
55
|
+
* atop a bind-mounted host directory cross trust boundaries
|
|
56
|
+
* on container restart. Detection requires reading field 4
|
|
57
|
+
* and matching the mount-point against operator-trusted paths.
|
|
58
|
+
*
|
|
59
|
+
* Composes:
|
|
60
|
+
* - lib/safe-decompress / lib/audit — operator-supplied audit
|
|
61
|
+
* handle receives `system.safe_mount_info.refused` events on
|
|
62
|
+
* read-failed and parse-failed (drop-silent per rule §5).
|
|
63
|
+
*
|
|
64
|
+
* RFC / kernel-doc citations:
|
|
65
|
+
* - [Linux Documentation/filesystems/proc.rst §3.5 — /proc/<pid>/mountinfo](https://www.kernel.org/doc/Documentation/filesystems/proc.txt)
|
|
66
|
+
* - [CVE-2024-21626](https://nvd.nist.gov/vuln/detail/CVE-2024-21626) — runc leaky-vessels (bind-mount detection)
|
|
67
|
+
* - [CVE-2022-0185](https://nvd.nist.gov/vuln/detail/CVE-2022-0185) — fsconfig integer underflow
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var nodeFs = require("node:fs");
|
|
71
|
+
var lazyRequire = require("./lazy-require");
|
|
72
|
+
var validateOpts = require("./validate-opts");
|
|
73
|
+
var numericBounds = require("./numeric-bounds");
|
|
74
|
+
var { defineClass } = require("./framework-error");
|
|
75
|
+
|
|
76
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
77
|
+
|
|
78
|
+
var SafeMountInfoError = defineClass("SafeMountInfoError", { alwaysPermanent: true });
|
|
79
|
+
|
|
80
|
+
var DEFAULT_PATH = "/proc/self/mountinfo";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @primitive b.safeMountInfo.parse
|
|
84
|
+
* @signature b.safeMountInfo.parse(text, opts?)
|
|
85
|
+
* @since 0.11.6
|
|
86
|
+
* @status stable
|
|
87
|
+
* @related b.safeMountInfo.read, b.safeMountInfo.bestMatch
|
|
88
|
+
*
|
|
89
|
+
* Parse `/proc/self/mountinfo` text bytes into structured entries.
|
|
90
|
+
* Each entry carries `{ id, parent, devMajMin, root, mountPoint,
|
|
91
|
+
* options, fstype, source, superOptions }` — `root` is the
|
|
92
|
+
* positional field 4 ("root within source FS") that bind-mount
|
|
93
|
+
* detection requires.
|
|
94
|
+
*
|
|
95
|
+
* Malformed lines are skipped by default (operator's mountinfo MAY
|
|
96
|
+
* contain a stray line during a concurrent mount/unmount). Set
|
|
97
|
+
* `opts.strict: true` to throw on first malformed line.
|
|
98
|
+
*
|
|
99
|
+
* @opts
|
|
100
|
+
* strict: boolean, // default false; throw on malformed line
|
|
101
|
+
* maxLines: number, // default 4096; cap to bound parser work
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* var entries = b.safeMountInfo.parse(rawText);
|
|
105
|
+
* var bindMounts = entries.filter(function (e) { return e.root !== "/"; });
|
|
106
|
+
*/
|
|
107
|
+
function parse(text, opts) {
|
|
108
|
+
opts = opts || {};
|
|
109
|
+
validateOpts(opts, ["strict", "maxLines"], "safeMountInfo.parse");
|
|
110
|
+
if (typeof text !== "string") {
|
|
111
|
+
throw new SafeMountInfoError(
|
|
112
|
+
"safe-mount-info/bad-input",
|
|
113
|
+
"safeMountInfo.parse: text must be a string");
|
|
114
|
+
}
|
|
115
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxLines,
|
|
116
|
+
"safeMountInfo.parse: opts.maxLines",
|
|
117
|
+
SafeMountInfoError, "safe-mount-info/bad-arg");
|
|
118
|
+
var maxLines = (typeof opts.maxLines === "number") ? opts.maxLines : 4096; // allow:raw-byte-literal — line cap matches max kernel-published mount count
|
|
119
|
+
var strict = opts.strict === true;
|
|
120
|
+
var lines = text.split("\n");
|
|
121
|
+
// `text.split("\n").length` counts the trailing empty segment that
|
|
122
|
+
// `/proc/self/mountinfo` produces with its final newline. Adjust
|
|
123
|
+
// the count so the cap reflects ACTUAL records, not segments —
|
|
124
|
+
// otherwise exactly-`maxLines` valid records gets rejected as
|
|
125
|
+
// `too-many-lines` because the segment count is `maxLines + 1`.
|
|
126
|
+
var trailingEmpty = (lines.length > 0 && lines[lines.length - 1] === "");
|
|
127
|
+
var recordCount = trailingEmpty ? lines.length - 1 : lines.length;
|
|
128
|
+
if (recordCount > maxLines) {
|
|
129
|
+
throw new SafeMountInfoError(
|
|
130
|
+
"safe-mount-info/too-many-lines",
|
|
131
|
+
"safeMountInfo.parse: mountinfo has " + recordCount +
|
|
132
|
+
" lines, exceeds maxLines " + maxLines);
|
|
133
|
+
}
|
|
134
|
+
var out = [];
|
|
135
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
136
|
+
var ln = lines[i];
|
|
137
|
+
if (!ln) continue;
|
|
138
|
+
// Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
|
|
139
|
+
// [<optional-fields>...] - <fstype> <source> <super-options>
|
|
140
|
+
// The separator " - " divides the optional-fields half from the
|
|
141
|
+
// post-fields half.
|
|
142
|
+
var sepIdx = ln.indexOf(" - ");
|
|
143
|
+
if (sepIdx === -1) {
|
|
144
|
+
if (strict) {
|
|
145
|
+
throw new SafeMountInfoError(
|
|
146
|
+
"safe-mount-info/parse-failed",
|
|
147
|
+
"safeMountInfo.parse: line " + (i + 1) + " missing ' - ' separator");
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
var preFields = ln.slice(0, sepIdx).split(" ");
|
|
152
|
+
var postFields = ln.slice(sepIdx + 3).split(" ");
|
|
153
|
+
if (preFields.length < 6 || postFields.length < 1) { // allow:raw-byte-literal — kernel-mandated minimum field counts
|
|
154
|
+
if (strict) {
|
|
155
|
+
throw new SafeMountInfoError(
|
|
156
|
+
"safe-mount-info/parse-failed",
|
|
157
|
+
"safeMountInfo.parse: line " + (i + 1) + " has " + preFields.length +
|
|
158
|
+
" pre-fields, " + postFields.length + " post-fields (need >=6, >=1)");
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out.push({
|
|
163
|
+
id: preFields[0],
|
|
164
|
+
parent: preFields[1],
|
|
165
|
+
devMajMin: preFields[2],
|
|
166
|
+
root: preFields[3], // *** field 4 (0-indexed 3) — bind-mount detection
|
|
167
|
+
mountPoint: preFields[4],
|
|
168
|
+
options: preFields[5],
|
|
169
|
+
// optional-fields (variable length, between [6] and the " - ")
|
|
170
|
+
// are exposed via `optionalFields` for advanced callers.
|
|
171
|
+
optionalFields: preFields.slice(6, preFields.length).filter(function (f) { return f.length > 0; }),
|
|
172
|
+
fstype: postFields[0],
|
|
173
|
+
source: postFields[1] || null,
|
|
174
|
+
superOptions: postFields[2] || null,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @primitive b.safeMountInfo.read
|
|
182
|
+
* @signature b.safeMountInfo.read(opts?)
|
|
183
|
+
* @since 0.11.6
|
|
184
|
+
* @status stable
|
|
185
|
+
* @related b.safeMountInfo.parse, b.safeMountInfo.bestMatch
|
|
186
|
+
*
|
|
187
|
+
* Read + parse `/proc/self/mountinfo` in one call. Returns the same
|
|
188
|
+
* array shape as `parse(text)`. On non-Linux platforms (where /proc
|
|
189
|
+
* doesn't exist) returns `opts.fallback` (default `null`); audit
|
|
190
|
+
* emission per `safe-mount-info.refused` with code `read-failed`.
|
|
191
|
+
*
|
|
192
|
+
* @opts
|
|
193
|
+
* path: string, // override path (default /proc/self/mountinfo)
|
|
194
|
+
* fallback: any, // returned on read failure (default null)
|
|
195
|
+
* audit: object, // optional b.audit handle for refusal events
|
|
196
|
+
* strict: boolean, // forwarded to parse()
|
|
197
|
+
* maxLines: number, // forwarded to parse()
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* var entries = b.safeMountInfo.read();
|
|
201
|
+
* if (entries === null) {
|
|
202
|
+
* // non-Linux / sandboxed / no /proc
|
|
203
|
+
* }
|
|
204
|
+
*/
|
|
205
|
+
function read(opts) {
|
|
206
|
+
opts = opts || {};
|
|
207
|
+
validateOpts(opts,
|
|
208
|
+
["path", "fallback", "audit", "strict", "maxLines"],
|
|
209
|
+
"safeMountInfo.read");
|
|
210
|
+
var path = typeof opts.path === "string" && opts.path.length > 0
|
|
211
|
+
? opts.path
|
|
212
|
+
: DEFAULT_PATH;
|
|
213
|
+
var text;
|
|
214
|
+
try { text = nodeFs.readFileSync(path, "utf8"); }
|
|
215
|
+
catch (e) {
|
|
216
|
+
_refuseEmit(opts, "safe-mount-info/read-failed",
|
|
217
|
+
"/proc/self/mountinfo unreadable: " + ((e && e.message) || String(e)));
|
|
218
|
+
return ("fallback" in opts) ? opts.fallback : null;
|
|
219
|
+
}
|
|
220
|
+
return parse(text, opts);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @primitive b.safeMountInfo.bestMatch
|
|
225
|
+
* @signature b.safeMountInfo.bestMatch(entries, path)
|
|
226
|
+
* @since 0.11.6
|
|
227
|
+
* @status stable
|
|
228
|
+
* @related b.safeMountInfo.read, b.safeMountInfo.isBindMount
|
|
229
|
+
*
|
|
230
|
+
* Find the mountinfo entry whose `mountPoint` is the longest prefix
|
|
231
|
+
* of `path`. Returns `null` when no entry covers `path`. The "longest
|
|
232
|
+
* prefix" semantic is what bind-mount detection / sealed-store-path
|
|
233
|
+
* validation needs — a mounted subdir wins over the root mount.
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* var entries = b.safeMountInfo.read();
|
|
237
|
+
* var atPath = b.safeMountInfo.bestMatch(entries, "/var/lib/blamejs");
|
|
238
|
+
* if (atPath && atPath.root !== "/") {
|
|
239
|
+
* // path lives on a bind-mount (potentially crossing host/guest)
|
|
240
|
+
* }
|
|
241
|
+
*/
|
|
242
|
+
function bestMatch(entries, path) {
|
|
243
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
244
|
+
if (typeof path !== "string" || path.length === 0) return null;
|
|
245
|
+
var best = null;
|
|
246
|
+
var bestLen = -1;
|
|
247
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
248
|
+
var e = entries[i];
|
|
249
|
+
if (!e || typeof e.mountPoint !== "string" || e.mountPoint.length === 0) continue;
|
|
250
|
+
var mp = e.mountPoint;
|
|
251
|
+
if (path === mp ||
|
|
252
|
+
(path.length > mp.length &&
|
|
253
|
+
path.indexOf(mp) === 0 &&
|
|
254
|
+
(mp === "/" || path.charCodeAt(mp.length) === 47 /* "/" */))) { // allow:raw-byte-literal — ASCII forward-slash
|
|
255
|
+
if (mp.length > bestLen) {
|
|
256
|
+
bestLen = mp.length;
|
|
257
|
+
best = e;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return best;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @primitive b.safeMountInfo.isBindMount
|
|
266
|
+
* @signature b.safeMountInfo.isBindMount(entry)
|
|
267
|
+
* @since 0.11.6
|
|
268
|
+
* @status stable
|
|
269
|
+
* @related b.safeMountInfo.bestMatch
|
|
270
|
+
*
|
|
271
|
+
* `true` when the mountinfo entry's `root` field is something other
|
|
272
|
+
* than `"/"` (i.e. the mount is a bind from a non-root path within
|
|
273
|
+
* the source filesystem). The canonical bind-mount test — does NOT
|
|
274
|
+
* consult the options string (the kernel doesn't emit "bind" there).
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* var entries = b.safeMountInfo.read();
|
|
278
|
+
* var atData = b.safeMountInfo.bestMatch(entries, "/data");
|
|
279
|
+
* var isBind = b.safeMountInfo.isBindMount(atData);
|
|
280
|
+
*/
|
|
281
|
+
function isBindMount(entry) {
|
|
282
|
+
if (!entry || typeof entry !== "object") return false;
|
|
283
|
+
return typeof entry.root === "string" && entry.root.length > 0 && entry.root !== "/";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _refuseEmit(opts, code, message) {
|
|
287
|
+
var auditImpl = opts.audit || (audit() && audit().safeEmit ? audit() : null);
|
|
288
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
289
|
+
try {
|
|
290
|
+
auditImpl.safeEmit({
|
|
291
|
+
action: "system.safe_mount_info.refused",
|
|
292
|
+
outcome: "denied",
|
|
293
|
+
metadata: { code: code, reason: message },
|
|
294
|
+
});
|
|
295
|
+
} catch (_e) { /* drop-silent per rule §5 */ }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
parse: parse,
|
|
301
|
+
read: read,
|
|
302
|
+
bestMatch: bestMatch,
|
|
303
|
+
isBindMount: isBindMount,
|
|
304
|
+
SafeMountInfoError: SafeMountInfoError,
|
|
305
|
+
DEFAULT_PATH: DEFAULT_PATH,
|
|
306
|
+
};
|
package/lib/watcher.js
CHANGED
|
@@ -49,6 +49,7 @@ var nodeFs = require("node:fs");
|
|
|
49
49
|
var nodePath = require("node:path");
|
|
50
50
|
var lazyRequire = require("./lazy-require");
|
|
51
51
|
var validateOpts = require("./validate-opts");
|
|
52
|
+
var safeMountInfo = require("./safe-mount-info");
|
|
52
53
|
var { WatcherError } = require("./framework-error");
|
|
53
54
|
|
|
54
55
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
@@ -240,50 +241,19 @@ function _detectAutoMode(rootPath) {
|
|
|
240
241
|
try { inContainer = nodeFs.existsSync("/.dockerenv"); }
|
|
241
242
|
catch (_e) { inContainer = false; }
|
|
242
243
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
244
|
+
// Route through b.safeMountInfo — single canonical parser that
|
|
245
|
+
// ALWAYS reads field 4 ("root within source FS") for the bind-
|
|
246
|
+
// mount check below. Pre-v0.11.6 this parsed inline; centralizing
|
|
247
|
+
// it means future container-escape / sealed-store / sandbox call
|
|
248
|
+
// sites inherit the discipline.
|
|
249
|
+
var entries = safeMountInfo.read();
|
|
250
|
+
if (entries === null || entries.length === 0) {
|
|
250
251
|
return { mode: "fs", reason: "no-mountinfo", fsType: null, inContainer: inContainer };
|
|
251
252
|
}
|
|
252
|
-
|
|
253
|
-
// Find the mount whose mount-point is the longest prefix of rootPath.
|
|
254
|
-
var lines = mountInfoRaw.split("\n");
|
|
255
|
-
var bestMatch = null;
|
|
256
|
-
var bestLen = -1;
|
|
257
|
-
for (var i = 0; i < lines.length; i += 1) {
|
|
258
|
-
var ln = lines[i];
|
|
259
|
-
if (!ln) continue;
|
|
260
|
-
// Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
|
|
261
|
-
// [<optional-fields>...] - <fstype> <source> <super-options>
|
|
262
|
-
// The separator " - " divides the optional-fields half from the post-fields half.
|
|
263
|
-
var sepIdx = ln.indexOf(" - ");
|
|
264
|
-
if (sepIdx === -1) continue;
|
|
265
|
-
var preFields = ln.slice(0, sepIdx).split(" ");
|
|
266
|
-
var postFields = ln.slice(sepIdx + 3).split(" ");
|
|
267
|
-
if (preFields.length < 6 || postFields.length < 1) continue;
|
|
268
|
-
var rootField = preFields[3]; // "/" for regular mount; bound-source path for bind
|
|
269
|
-
var mountPoint = preFields[4];
|
|
270
|
-
var fstype = postFields[0];
|
|
271
|
-
if (typeof mountPoint !== "string" || mountPoint.length === 0) continue;
|
|
272
|
-
if (rootPath === mountPoint ||
|
|
273
|
-
(rootPath.length > mountPoint.length &&
|
|
274
|
-
rootPath.indexOf(mountPoint) === 0 &&
|
|
275
|
-
(mountPoint === "/" || rootPath.charCodeAt(mountPoint.length) === 47 /* / */))) {
|
|
276
|
-
if (mountPoint.length > bestLen) {
|
|
277
|
-
bestLen = mountPoint.length;
|
|
278
|
-
bestMatch = { mountPoint: mountPoint, rootField: rootField, fstype: fstype };
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
253
|
+
var bestMatch = safeMountInfo.bestMatch(entries, rootPath);
|
|
283
254
|
if (!bestMatch) {
|
|
284
255
|
return { mode: "fs", reason: "no-mount-match", fsType: null, inContainer: inContainer };
|
|
285
256
|
}
|
|
286
|
-
|
|
287
257
|
if (AUTO_PROBE_POLL_FSTYPES.has(bestMatch.fstype)) {
|
|
288
258
|
return {
|
|
289
259
|
mode: "poll",
|
|
@@ -292,16 +262,12 @@ function _detectAutoMode(rootPath) {
|
|
|
292
262
|
inContainer: inContainer,
|
|
293
263
|
};
|
|
294
264
|
}
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
// best-matching mount carries a non-"/" root, the mount is a bind
|
|
302
|
-
// and inotify chains across the host/guest boundary are unreliable.
|
|
303
|
-
// (Operator can still force fs via mode: "fs"; force poll via mode: "poll".)
|
|
304
|
-
if (inContainer && bestMatch.rootField && bestMatch.rootField !== "/") {
|
|
265
|
+
// Bind-mount detection via field 4 — `b.safeMountInfo.isBindMount`
|
|
266
|
+
// is the canonical predicate (does NOT consult options string;
|
|
267
|
+
// the kernel doesn't emit "bind" there). When we're inside a
|
|
268
|
+
// container AND the best-matching mount is a bind, inotify
|
|
269
|
+
// chains across the host/guest boundary are unreliable.
|
|
270
|
+
if (inContainer && safeMountInfo.isBindMount(bestMatch)) {
|
|
305
271
|
return {
|
|
306
272
|
mode: "poll",
|
|
307
273
|
reason: "container-bind-mount",
|
|
@@ -309,7 +275,6 @@ function _detectAutoMode(rootPath) {
|
|
|
309
275
|
inContainer: inContainer,
|
|
310
276
|
};
|
|
311
277
|
}
|
|
312
|
-
|
|
313
278
|
return {
|
|
314
279
|
mode: "fs",
|
|
315
280
|
reason: "native-fs",
|
package/lib/websocket.js
CHANGED
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
|
|
77
77
|
var nodeCrypto = require("node:crypto");
|
|
78
78
|
var zlib = require("node:zlib");
|
|
79
|
+
var safeDecompress = require("./safe-decompress").safeDecompress;
|
|
79
80
|
var { EventEmitter } = require("node:events");
|
|
80
81
|
var C = require("./constants");
|
|
81
82
|
var requestHelpers = require("./request-helpers");
|
|
@@ -605,14 +606,33 @@ function _deflateMessage(payload, windowBits) {
|
|
|
605
606
|
|
|
606
607
|
function _inflateMessage(payload, windowBits, maxOutputBytes) {
|
|
607
608
|
// Per RFC 7692 §7.2.2, append the 4-byte trailer before inflating.
|
|
608
|
-
// `
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
609
|
+
// Routes through `b.safeDecompress` so the bounded-output defense
|
|
610
|
+
// is uniform with every other RFC 1951 deflate site in the
|
|
611
|
+
// framework. `maxRatio: 0` (unlimited expansion) because WS
|
|
612
|
+
// per-message-deflate already binds upstream via the operator's
|
|
613
|
+
// `maxMessageBytes` opt; the absolute cap is the real defense.
|
|
614
|
+
// Streaming WS payloads can legitimately compress > 50:1 on
|
|
615
|
+
// repetitive text (logs, sensor data); operators with a
|
|
616
|
+
// tighter posture set their own maxMessageBytes.
|
|
612
617
|
var withTrailer = Buffer.concat([payload, DEFLATE_TRAILING]);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
618
|
+
// `maxCompressedBytes` MUST track the operator's `maxMessageBytes`,
|
|
619
|
+
// not safeDecompress's 4 MiB default. WS operators with high-
|
|
620
|
+
// throughput pipelines legitimately set `maxMessageBytes > 4 MiB`
|
|
621
|
+
// (large file pushes, batched JSON, telemetry); a compressed
|
|
622
|
+
// payload up to that cap is legitimate input. The compressed input
|
|
623
|
+
// is bounded above by the same cap the framework enforces on
|
|
624
|
+
// reassembled-message bytes (RFC 6455 §5.4 fragmented messages are
|
|
625
|
+
// concatenated then decompressed; the operator's `maxMessageBytes`
|
|
626
|
+
// is enforced at FrameParser reassembly), so passing it here keeps
|
|
627
|
+
// safeDecompress aligned with the operator's intent rather than
|
|
628
|
+
// overriding it with the primitive's general-purpose default.
|
|
629
|
+
return safeDecompress(withTrailer, {
|
|
630
|
+
algorithm: "deflate-raw",
|
|
631
|
+
maxOutputBytes: maxOutputBytes,
|
|
632
|
+
maxCompressedBytes: maxOutputBytes,
|
|
633
|
+
maxRatio: 0,
|
|
634
|
+
windowBits: windowBits,
|
|
635
|
+
ctx: "websocket._inflateMessage",
|
|
616
636
|
});
|
|
617
637
|
}
|
|
618
638
|
|
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.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:47f41007-66d3-47f2-ba7e-e47db1d7370f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-20T00:03:39.026Z",
|
|
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.11.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.11.6",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.11.
|
|
25
|
+
"version": "0.11.6",
|
|
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.11.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.11.6",
|
|
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.11.
|
|
57
|
+
"ref": "@blamejs/core@0.11.6",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|