@blamejs/core 0.9.22 → 0.9.24
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 +2 -0
- package/index.js +4 -1
- package/lib/agent-stream.js +243 -0
- package/lib/atomic-file.js +47 -12
- package/lib/backup/bundle.js +23 -1
- package/lib/guard-stream-args.js +166 -0
- package/lib/http-client.js +9 -0
- package/lib/middleware/body-parser.js +8 -0
- package/lib/middleware/csrf-protect.js +4 -1
- package/lib/mtls-ca.js +23 -3
- package/lib/network-tls.js +25 -2
- package/lib/restore-rollback.js +17 -2
- package/lib/static.js +29 -1
- package/lib/vault/rotate.js +15 -0
- package/lib/vault/seal-pem-file.js +6 -0
- package/lib/websocket.js +4 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.24 (2026-05-14) — **`b.agent.stream` — async-iterable variants for agent methods that yield N rows.** Fourth slice of the v0.9.21–v0.9.30 substrate playbook (numbers shifted +1 by v0.9.23 CodeQL insertion). JMAP `Email/queryChanges` against million-message mailboxes returns an array today — OOM on client + agent before the response ships. RFC 8620 §5.5 allows `position`-paginated responses; real mailboxes need cursor-backed delivery with built-in backpressure. (1) **`b.agent.stream.create({ openCursor, cursorOpts, batchSize, orchestrator, actor, kind, audit })`** — returns an object implementing `[Symbol.asyncIterator]` so operators write `for await (var row of stream) { ... }`. Operator supplies an `openCursor(cursorOpts)` factory returning a cursor with `fetchBatch(batchSize) → { rows, nextCursor, done }` + optional `close()` + optional `lastSeenCursor()`. (2) **Backpressure built-in** — async-generator semantics; each `next()` call yields one row from an in-memory batch buffer; the cursor only refetches when the buffer drains. Pulling slowly applies backpressure to the store side; a slow client can't OOM the server. (3) **Auto cursor close on every exit path** — consumer `break` calls iterator `.return()`, consumer `throw` calls `.throw()`, natural exhaustion calls `.next()` until done — all three close the cursor via `try`/`finally`-style `_closeOnce` and emit `agent.stream.closed` audit with reason (`exhausted` / `consumer-break` / `consumer-throw` / `drain` / `error`). (4) **Drain marker** — when orchestrator drain fires mid-stream, the next `next()` call returns ONE final `{ _drainMarker: true, lastSeenCursor: <opaque>, reason: "drain" }` row + closes the cursor. Clients reconnecting via JMAP-WebSocket / IMAP NOTIFY pass `lastSeenCursor` back to resume from the same position against the new agent post-deploy. Composes v0.9.21 `orchestrator.registerStream` / `unregisterStream` / `isDraining` hooks. (5) **`b.guardStreamArgs`** — validates `b.agent.stream.create` opts. Refuses non-integer `batchSize` (silent shard-style routing drift class — same shape Codex caught on v0.9.21), batchSize out of `[1, 1024]` strict-profile range, empty `kind` string, function / regex / Buffer / `__proto__` keys inside `cursorOpts` (structured-clone-unsafe — same shape `b.guardMailQuery` refuses for filter specs). Ships `strict` / `balanced` / `permissive` profiles + hipaa / pci-dss / gdpr / soc2 postures. (6) Audit lifecycle: `agent.stream.opened` on iterator creation, `agent.stream.closed` on every exit path, `agent.stream.drain_marker_emitted` when orch drain fires. Fuzz harness ships in `fuzz/guard-stream-args.fuzz.js`. Per the substrate playbook in `memory/specs/blamejs-agent-stream-spec.md`.
|
|
12
|
+
- v0.9.23 (2026-05-14) — **CodeQL alert sweep — 30 alerts closed across 6 rule classes + wiki @primitive validator extracted into static gates.** Standalone substrate slice inserted into the v0.9.21–v0.9.30 playbook; downstream substrate slices shift +1 (stream now v0.9.24, eventBus v0.9.25, tenant v0.9.26, saga v0.9.27, postureChain v0.9.28, trace v0.9.29, snapshot v0.9.30). The 30 alerts were regressions of v0.9.18's earlier batch fix — v0.9.15's framework-wide `require()`-binding rename sweep (184 renames across 108 files) shifted line numbers and the suppression comments stopped tracking. (1) **`js/file-system-race` (10 sites)** — `lib/atomic-file.js:_readSyncCore` migrated from `statSync`+`readFileSync` to the canonical TOCTOU-safe-read scaffold (open fd → `fstatSync` → `readSync` loop → `closeSync` in finally; ENOENT now surfaces from `openSync`); `lib/restore-rollback.js` marker write switched to `openSync(..., "wx", 0o600)` + `writeSync` + `fsyncSync` + EEXIST tolerance; `lib/network-tls.js` new `_readPathFile` fd-based reader; `lib/backup/bundle.js` fd-narrowed read with short-read detection; `lib/static.js` content-safety gate converted to `fsp.open` filehandle pattern; `lib/vault/seal-pem-file.js`, `lib/atomic-file.js:fsyncDir`, `test/30-chain.js`, `examples/wiki/test/validate-{env,cli}-snapshot.js`, `examples/wiki/lib/source-doc-parser.js` got suppression refresh OR fd-narrowed refactor per shape. (2) **`js/insecure-temporary-file` (6 sites)** — `lib/mtls-ca.js` new `_writeExclusive` helper using `openSync(..., "wx", mode)` + `writeSync` + `fsyncSync`; `lib/vault/rotate.js` + `lib/http-client.js` got suppression refresh pointing at the operator-supplied stagingDir / 64-bit CSPRNG suffix defenses. (3) **`js/path-injection` (2 sites in `lib/static.js`)** — suppression refresh pointing at the upstream `_resolveSafe` sandbox check at line 181 (lexical resolve + `startsWith(rootResolved + sep)` + realpath escape guard + guardFilename gate). (4) **`js/remote-property-injection` (7 sites)** — `lib/middleware/csrf-protect.js` cookie-parse output + `lib/websocket.js` `_parseExtensionHeader` params switched to `Object.create(null)` so attacker-controlled keys have no prototype chain to pollute; `lib/middleware/body-parser.js` multipart fields got suppression refresh pointing at the POISONED_KEYS gate at line 867; `test/40-consumers.js` + `test/00-primitives.js` test fixtures suppressed with `test/` scope justification. (5) **`PinnedDependenciesID` (2 workflow files)** — every `uses:` line was already SHA-pinned per v0.9.18; the remaining alerts pointed at the `npm install --no-audit --no-fund` step in `.github/workflows/npm-publish.yml` + `ci.yml`; added explicit `name@version` specifiers (esbuild + postject, mirroring `package.json`'s exact pins) so CodeQL recognizes the install as pinned without committing a lockfile (which CLAUDE.md hard rule §1 forbids — vendored stack, zero npm runtime deps). (6) **`js/regex/missing-regexp-anchor` (1 site)** — `scripts/build-vendored-sbom.js:42` host-extractor regex anchored to `^https?://github.com/` so an attacker-controlled `entry.source` containing `github.com` as a path or query substring can't misdirect purl dispatch into the github branch. (7) **Wiki @primitive validator extracted into static gates** — the `@related namespace-not-primitive` nit fired on PRs #50 (v0.9.20), #51 (v0.9.21), AND #52 (v0.9.22) — same class, three rediscoveries because the validator only ran in CI's wiki-e2e gate (~90s round-trip), never in local pre-push. Extraction: `examples/wiki/lib/source-comment-block-validator.js` (shared engine, pure module, no side effects) + `scripts/validate-source-comment-blocks.js` (framework-level standalone wrapper, runs in 419–466ms, no `@blamejs/core` / vault / DB / network dependencies). Existing wiki-e2e gate refactored to delegate to the shared engine; CI invocation unchanged. CLAUDE.md release workflow step 4 (static gates) now lists `node scripts/validate-source-comment-blocks.js` after `codebase-patterns.test.js`. Each suppression comment references the active defense by file:line so future CodeQL re-runs OR future readers know which suppression applies. Multi-agent fan-out — 4 parallel subagents in isolated git worktrees per rule class.
|
|
11
13
|
- v0.9.22 (2026-05-14) — **`b.agent.idempotency` — cross-dispatch idempotency keys honored at every agent consumer boundary; JMAP retry-safe semantics from day one.** Second slice of the v0.9.21–v0.9.29 substrate playbook. (1) **`b.agent.idempotency.create({ store, audit, ttlMs, maxResultBytes, fingerprintArgs })`** — pluggable backing store via `{ get, put, delete, gc }`; in-memory default ships for single-process deployments, operator wires durable backends (sqlite-backed adapter or external). Surface: `instance.get(method, actorId, key)` returns cached envelope `{ result, firstAt, lastReplayedAt, replayCount, requestFingerprint }` or `null`; `instance.put(method, actorId, key, result, { args, requestFingerprint })` serializes via `b.safeJson.stringify` + persists with TTL + refuses key-reuse-different-args via the request-fingerprint (sha3-512 of args sans key) check; `instance.invalidate(method, actorId, key)` is the saga-compensation escape hatch; `instance.gc({ olderThanMs })` for periodic cleanup. (2) **Keys hashed at the boundary** — operator-supplied keys never reach disk in raw form; namespace-hashed via `b.crypto.namespaceHash("agent.idempotency", method + "\\0" + actorId + "\\0" + key)`. Cross-actor + cross-method isolation by construction (an attacker can't replay another actor's mutation by guessing the key). (3) **Replay tracking** — every cache hit increments `replayCount` + bumps `lastReplayedAt`; audit emits `agent.idempotency.replay` with the truncated actor-id hash + first/replay timestamps so operator pipelines surface retry storms. (4) **`b.guardIdempotencyKey`** — operator-supplied key shape validator. Refuses oversized (default 256 bytes), control chars (C0/NUL/DEL — defends audit-log injection), slash + backslash (defends operators routing keys through filesystem paths), path-traversal (`..`), non-ASCII under strict (operator-greppable in audit logs across stack boundaries; permissive opts down for legacy Unicode tenant IDs). (5) **JSON round-trip test helper** — new `test/helpers/json-round-trip.js` exporting `assertJsonRoundTrip(shape, label)`. Catches the bug class Codex flagged on PR #51 (v0.9.21 `register()` stored agent function ref on backend row, lost in DB/JSON serialization). Refuses on function fields, Buffer fields, Date objects, Symbol keys, BigInt values, non-finite numbers, cycles. Applied retroactively to v0.9.21 agent-orchestrator backend row in regression test. (6) **Result size cap** (default 1 MiB per cached entry) — refuses with `agent-idempotency/result-too-big` so operators discover OOM-prone result shapes locally instead of at runtime. Fuzz harness ships in `fuzz/guard-idempotency-key.fuzz.js`; ClusterFuzzLite matrix entries added. Per the substrate playbook in `memory/specs/blamejs-agent-idempotency-spec.md`.
|
|
12
14
|
- v0.9.21 (2026-05-14) — **`b.agent.orchestrator` — framework-level supervisor for every agent blamejs ships.** First slice of the v0.9.21–v0.9.29 substrate playbook that builds before mail-stack resumes. (1) **`b.agent.orchestrator.create(opts)`** — facade with `register(name, agent, opts)` / `lookup(name)` / `unregister(name)` / `list({ kind, tenantId })` registry, `spawnConsumers({ agent, queue, shards, taskTopic, maxConcurrency })` for sharded-topic dispatch (FNV-1a consistent-hash via `b.agent.orchestrator.shardFor(key, shards)`; per-shard topic suffix `<base>.<shard>`), `elect({ resource })` composing `b.cluster` DB-row leader election (returns `{ isLeader, fencingToken, leaderId }` — single-process deployments get a trivial-leader; cluster deployments delegate), `drain({ timeoutMs })` stopping every spawned consumer + audit-emitting elapsed/count, and `health()` aggregating per-agent + per-consumer + per-election state into one shape ready for `b.middleware.healthcheck`. (2) **Pluggable backend** — `{ get, set, delete, list }` interface; in-memory default ships for single-process deployments, operator wires `b.config.loadDbBacked`-shaped or external for restart-survival. (3) **`b.guardAgentRegistry`** — registry-op shape validator. Refuses non-ASCII agent names (NFC + ASCII-only — operator-greppable in audit logs), path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL), oversized (default 64 bytes), reserved `FRAMEWORK.*` / `ROOT.*` prefix, duplicate-on-register, register without `agentKind`. Ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (4) **Drain phase auto-wires into `b.appShutdown`** when operator supplies an `appShutdown` instance; `SIGTERM` delivers a clean drain of all spawned consumers + stream-registry signal before the process exits. (5) **Stream-registry hook** (`registerStream` / `unregisterStream` / `isDraining`) — substrate for v0.9.23 `b.agent.stream` so async-iterable method variants can check the drain flag and emit drain-markers. Substrate is NOT a process supervisor — process spawn / restart-on-crash / pod scheduling delegate to pm2 / systemd / k8s / Nomad; framework doesn't compete. Fuzz harness ships in `fuzz/guard-agent-registry.fuzz.js`. Per the substrate playbook in `memory/specs/blamejs-agent-orchestrator-spec.md`; v0.9.22 idempotency wires on top next.
|
|
13
15
|
- v0.9.20 (2026-05-14) — **`b.mail.agent` — the standardization contract for every mail protocol blamejs ships.** Every above-the-wire mail surface (JMAP at v0.9.27, IMAP at v0.9.28, POP3 at v0.9.29, ManageSieve at v0.9.30, the MX listener at v0.9.24, submission at v0.9.25) translates protocol calls into `agent.X(args)`; RBAC, posture enforcement, audit emission, dispatch, and worker isolation are owned at the agent. (1) **`b.mail.agent.create({ store, audit, permissions, posture, identity, dispatch })`** — facade with 23 methods. Read surface (`search` / `fetch` / `thread` / `folders` / `quota`) is backed by v0.9.19 `b.mailStore` and runs immediately. Move surface (`move` / `flag` / `delete`) is backed by the new `mailStore.moveMessages` substrate; soft-delete moves to Trash + tags `\Deleted` (hard expunge wires at v0.9.28 with retention-floor enforcement). Write surface (`compose` / `send` / `reply` / `forward`), Sieve (`sieve.list/put/activate`), identity (`identity.set` / `vacation.set`), MDN (`mdn.send/parse/allowList`), regulated export, and migration `import` throw `mail-agent/not-implemented` with a `wiredAt` tag naming the slice that lights them up (defer-with-condition per the v1-defensible-scope rule). (2) **Dispatch contract** — `dispatch.mode` is `"local"` / `"queue"` / `"auto"`. `local` runs every method in-process. `queue` publishes envelopes to `mail.agent.tasks` via `b.queue.enqueue`; an `agent.consumer({ agent, queue })` running in a dedicated process or replicas across hosts pulls and executes. Posture metadata travels with each envelope; the consumer re-validates against its own posture before unseal so no posture downgrade survives the queue boundary. `auto` routes fast-path ops (`fetch` / `folders` / `flag` / `quota`) locally and heavy ops (`search` / `export`) to queue/workerPool when configured. (3) **Worker isolation** — `dispatch.workerPool` (composes `b.workerPool`) is validated at create-time; the agent reserves `vaultKeyDelivery: "in-worker"` (default) vs `"main-only"` (posture-conditional — HIPAA/PCI/GDPR default to main-only when the worker-script path wires at v0.9.26 Sieve). (4) **`b.mail.agent.consumer`** — the queue-side facade for multi-host load-spreading; carries its own `store` and re-validates posture at the boundary. (5) **Five new guards** through `b.gateContract` — **`b.guardMailQuery`** (search/fetch filter shape: bounded depth/keys/array-length, function/regex/Buffer/cycle refusal, `__proto__` key refusal, projection-column allowlist via `FILTERABLE_COLUMNS`, posture-required actor fields HIPAA→`purposeOfUse` / PCI→`pciScope` / GDPR→`lawfulBasis`); **`b.guardMailCompose`** (draft envelope: identity-vs-From alignment, recipient deduplication, attachment-byte cap default 25 MiB, body shape — exactly one of text/html unless `allowMultipartAlternative`, C0 control-char refusal in headers); **`b.guardMailReply`** (References-chain cap default 100 defends infinite-loop forwards, In-Reply-To continuity per RFC 5322 §3.6.4 — last References must match In-Reply-To, quoted-original byte cap, forwarded-attachment cardinality cap); **`b.guardMailMove`** (system-folder allowlist for INBOX/Sent/Drafts/Trash/Junk/Archive, admin-scope or `allowedFolders` gate for arbitrary destinations, path-traversal refusal, slash refusal — IMAP `.` hierarchy separator only); **`b.guardMailSieve`** (pre-parser shape-only: script-byte cap default 64 KiB, line-count cap defends one-byte-line bombs, name shape — path-traversal / slash / backslash refusal, actor-ownership check — non-admin actors restricted to their `ownedNames`; full Sieve parse at v0.9.26 via `b.safeSieve`). Each guard ships `strict` / `balanced` / `permissive` profiles and `hipaa` / `pci-dss` / `gdpr` / `soc2` postures (all pin `strict`). (6) **`b.mailStore.moveMessages(fromFolder, toFolder, objectIds)`** — per IMAP4rev2 §6.6.2, both folders bump modseq on move; agent.move composes this. (7) Fuzz harnesses ship for every new guard and the v0.9.19 substrates (`safe-mime` / `guard-message-id`) are now wired into the ClusterFuzzLite matrix.
|
package/index.js
CHANGED
|
@@ -167,8 +167,10 @@ var guardMailMove = require("./lib/guard-mail-move");
|
|
|
167
167
|
var guardMailSieve = require("./lib/guard-mail-sieve");
|
|
168
168
|
var guardAgentRegistry = require("./lib/guard-agent-registry");
|
|
169
169
|
var guardIdempotencyKey = require("./lib/guard-idempotency-key");
|
|
170
|
+
var guardStreamArgs = require("./lib/guard-stream-args");
|
|
170
171
|
var agentOrchestrator = require("./lib/agent-orchestrator");
|
|
171
172
|
var agentIdempotency = require("./lib/agent-idempotency");
|
|
173
|
+
var agentStream = require("./lib/agent-stream");
|
|
172
174
|
var guardArchive = require("./lib/guard-archive");
|
|
173
175
|
var guardJson = require("./lib/guard-json");
|
|
174
176
|
var guardYaml = require("./lib/guard-yaml");
|
|
@@ -414,7 +416,8 @@ module.exports = {
|
|
|
414
416
|
guardMailSieve: guardMailSieve,
|
|
415
417
|
guardAgentRegistry: guardAgentRegistry,
|
|
416
418
|
guardIdempotencyKey: guardIdempotencyKey,
|
|
417
|
-
|
|
419
|
+
guardStreamArgs: guardStreamArgs,
|
|
420
|
+
agent: { orchestrator: agentOrchestrator, idempotency: agentIdempotency, stream: agentStream },
|
|
418
421
|
guardArchive: guardArchive,
|
|
419
422
|
guardJson: guardJson,
|
|
420
423
|
guardYaml: guardYaml,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.agent.stream
|
|
4
|
+
* @nav Agent
|
|
5
|
+
* @title Agent Stream
|
|
6
|
+
* @order 60
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Async-iterable variants for agent methods that yield N rows.
|
|
10
|
+
* Operator wraps a cursor-shaped fetcher with `b.agent.stream.create`;
|
|
11
|
+
* the resulting object is `AsyncIterable<row>` — built-in
|
|
12
|
+
* backpressure (each `yield` blocks until the consumer pulls),
|
|
13
|
+
* automatic cursor close via `try`/`finally` on any exit path
|
|
14
|
+
* (consumer break, throw, network drop), and drain-marker emit on
|
|
15
|
+
* orchestrator drain so clients can resume from `lastSeenCursor`
|
|
16
|
+
* against the new agent post-drain.
|
|
17
|
+
*
|
|
18
|
+
* ```js
|
|
19
|
+
* var stream = b.agent.stream.create({
|
|
20
|
+
* orchestrator: orch, // optional — for drain reg
|
|
21
|
+
* actor: { id: "u1" },
|
|
22
|
+
* kind: "search",
|
|
23
|
+
* batchSize: 256,
|
|
24
|
+
* openCursor: function (cursorOpts) {
|
|
25
|
+
* return store.openSearchCursor(cursorOpts); // operator
|
|
26
|
+
* },
|
|
27
|
+
* cursorOpts: { folder: "INBOX", sinceModseq: 0 },
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* for await (var row of stream) {
|
|
31
|
+
* // row delivered as soon as the cursor yields it.
|
|
32
|
+
* // Pulling slowly applies backpressure to the store.
|
|
33
|
+
* if (someCondition) break; // cursor.close() fires automatically
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* ## Drain-marker semantic
|
|
38
|
+
*
|
|
39
|
+
* When orchestrator drain fires, in-flight streams emit ONE final
|
|
40
|
+
* `{ _drainMarker: true, lastSeenCursor: <opaque>, reason: "drain" }`
|
|
41
|
+
* row and exit cleanly. Clients reconnecting via JMAP-WebSocket /
|
|
42
|
+
* IMAP NOTIFY pass `lastSeenCursor` back to resume.
|
|
43
|
+
*
|
|
44
|
+
* ## Cursor contract
|
|
45
|
+
*
|
|
46
|
+
* Operator-supplied cursor:
|
|
47
|
+
* `cursor.fetchBatch(batchSize) → { rows, nextCursor, done }`
|
|
48
|
+
* `cursor.close() → void | Promise<void>`
|
|
49
|
+
*
|
|
50
|
+
* The framework's `b.mailStore` will gain `openSearchCursor` /
|
|
51
|
+
* `openFolderCursor` / `openExportCursor` etc. at later mail-stack
|
|
52
|
+
* slices that compose this primitive.
|
|
53
|
+
*
|
|
54
|
+
* @card
|
|
55
|
+
* Async-iterable variants for agent methods that yield N rows.
|
|
56
|
+
* Cursor-backed backpressure; auto-close on exit; drain-marker
|
|
57
|
+
* emit so clients resume cleanly post-deploy.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
var lazyRequire = require("./lazy-require");
|
|
61
|
+
var { defineClass } = require("./framework-error");
|
|
62
|
+
var guardStreamArgs = require("./guard-stream-args");
|
|
63
|
+
|
|
64
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
65
|
+
|
|
66
|
+
var AgentStreamError = defineClass("AgentStreamError", { alwaysPermanent: true });
|
|
67
|
+
|
|
68
|
+
var DEFAULT_BATCH_SIZE = 256; // allow:raw-byte-literal — cursor batch row count, not bytes
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @primitive b.agent.stream.create
|
|
72
|
+
* @signature b.agent.stream.create(opts)
|
|
73
|
+
* @since 0.9.24
|
|
74
|
+
* @status stable
|
|
75
|
+
* @related b.agent.orchestrator.create
|
|
76
|
+
*
|
|
77
|
+
* Create an async-iterable backed by an operator-supplied cursor.
|
|
78
|
+
* Returns an object that implements `[Symbol.asyncIterator]` — usable
|
|
79
|
+
* with `for await (var row of stream)`. Cursor close + audit emit +
|
|
80
|
+
* orchestrator stream-registry hook are owned by the framework;
|
|
81
|
+
* operator only supplies the `openCursor` factory + `cursorOpts`.
|
|
82
|
+
*
|
|
83
|
+
* @opts
|
|
84
|
+
* openCursor: function(cursorOpts) → cursor, // required
|
|
85
|
+
* cursorOpts: object, // operator-passed
|
|
86
|
+
* batchSize: integer, // default 256
|
|
87
|
+
* orchestrator: b.agent.orchestrator, // optional — for drain reg
|
|
88
|
+
* actor: { id, ... }, // optional — audit attribution
|
|
89
|
+
* kind: string, // "search" / "export" / ...
|
|
90
|
+
* audit: b.audit, // optional
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* var stream = b.agent.stream.create({
|
|
94
|
+
* openCursor: function (o) { return store.openSearchCursor(o); },
|
|
95
|
+
* cursorOpts: { folder: "INBOX" },
|
|
96
|
+
* });
|
|
97
|
+
* for await (var row of stream) { process(row); }
|
|
98
|
+
*/
|
|
99
|
+
function create(opts) {
|
|
100
|
+
if (!opts || typeof opts !== "object") {
|
|
101
|
+
throw new AgentStreamError("agent-stream/bad-opts", "create: opts required");
|
|
102
|
+
}
|
|
103
|
+
if (typeof opts.openCursor !== "function") {
|
|
104
|
+
throw new AgentStreamError("agent-stream/bad-open-cursor",
|
|
105
|
+
"create: opts.openCursor must be a function");
|
|
106
|
+
}
|
|
107
|
+
guardStreamArgs.validate({
|
|
108
|
+
batchSize: opts.batchSize,
|
|
109
|
+
kind: opts.kind,
|
|
110
|
+
cursorOpts: opts.cursorOpts,
|
|
111
|
+
});
|
|
112
|
+
var batchSize = typeof opts.batchSize === "number" ? opts.batchSize : DEFAULT_BATCH_SIZE;
|
|
113
|
+
var orch = opts.orchestrator || null;
|
|
114
|
+
var auditImpl = opts.audit || audit();
|
|
115
|
+
var actor = opts.actor || null;
|
|
116
|
+
var kind = opts.kind || "stream";
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
[Symbol.asyncIterator]: function () {
|
|
120
|
+
return _makeIterator({
|
|
121
|
+
openCursor: opts.openCursor,
|
|
122
|
+
cursorOpts: opts.cursorOpts,
|
|
123
|
+
batchSize: batchSize,
|
|
124
|
+
orchestrator: orch,
|
|
125
|
+
audit: auditImpl,
|
|
126
|
+
actor: actor,
|
|
127
|
+
kind: kind,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _makeIterator(ctx) {
|
|
134
|
+
var streamId = ctx.orchestrator ? ctx.orchestrator.registerStream({ kind: ctx.kind, actor: ctx.actor }) : null;
|
|
135
|
+
var cursor = null;
|
|
136
|
+
var buffer = [];
|
|
137
|
+
var done = false;
|
|
138
|
+
var closed = false;
|
|
139
|
+
var drained = false;
|
|
140
|
+
_safeAudit(ctx.audit, "agent.stream.opened", ctx.actor, { kind: ctx.kind, streamId: streamId });
|
|
141
|
+
|
|
142
|
+
async function _closeOnce(reason) {
|
|
143
|
+
if (closed) return;
|
|
144
|
+
closed = true;
|
|
145
|
+
if (cursor && typeof cursor.close === "function") {
|
|
146
|
+
try { await cursor.close(); } catch (_e) { /* best-effort */ }
|
|
147
|
+
}
|
|
148
|
+
if (streamId && ctx.orchestrator) {
|
|
149
|
+
try { ctx.orchestrator.unregisterStream(streamId); } catch (_e) { /* best-effort */ }
|
|
150
|
+
}
|
|
151
|
+
_safeAudit(ctx.audit, "agent.stream.closed", ctx.actor, {
|
|
152
|
+
kind: ctx.kind, streamId: streamId, reason: reason || "exhausted",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
next: async function () {
|
|
158
|
+
try {
|
|
159
|
+
if (buffer.length > 0) {
|
|
160
|
+
var row = buffer.shift();
|
|
161
|
+
return { value: row, done: false };
|
|
162
|
+
}
|
|
163
|
+
if (done) {
|
|
164
|
+
if (!closed) await _closeOnce("exhausted");
|
|
165
|
+
return { value: undefined, done: true };
|
|
166
|
+
}
|
|
167
|
+
// Check orchestrator drain BEFORE fetching the next batch.
|
|
168
|
+
if (ctx.orchestrator && ctx.orchestrator.isDraining && ctx.orchestrator.isDraining()) {
|
|
169
|
+
if (!drained) {
|
|
170
|
+
drained = true;
|
|
171
|
+
var marker = {
|
|
172
|
+
_drainMarker: true,
|
|
173
|
+
lastSeenCursor: cursor && typeof cursor.lastSeenCursor === "function"
|
|
174
|
+
? cursor.lastSeenCursor() : null,
|
|
175
|
+
reason: "drain",
|
|
176
|
+
};
|
|
177
|
+
_safeAudit(ctx.audit, "agent.stream.drain_marker_emitted", ctx.actor, {
|
|
178
|
+
kind: ctx.kind, streamId: streamId,
|
|
179
|
+
});
|
|
180
|
+
done = true;
|
|
181
|
+
return { value: marker, done: false };
|
|
182
|
+
}
|
|
183
|
+
await _closeOnce("drain");
|
|
184
|
+
return { value: undefined, done: true };
|
|
185
|
+
}
|
|
186
|
+
if (!cursor) {
|
|
187
|
+
cursor = await ctx.openCursor(ctx.cursorOpts);
|
|
188
|
+
if (!cursor || typeof cursor.fetchBatch !== "function") {
|
|
189
|
+
throw new AgentStreamError("agent-stream/bad-cursor",
|
|
190
|
+
"openCursor returned non-cursor (missing fetchBatch)");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
var batch = await cursor.fetchBatch(ctx.batchSize);
|
|
194
|
+
if (!batch || typeof batch !== "object") {
|
|
195
|
+
throw new AgentStreamError("agent-stream/bad-batch",
|
|
196
|
+
"cursor.fetchBatch returned non-object");
|
|
197
|
+
}
|
|
198
|
+
var rows = batch.rows || [];
|
|
199
|
+
if (batch.done) done = true;
|
|
200
|
+
if (rows.length === 0) {
|
|
201
|
+
if (!closed) await _closeOnce("exhausted");
|
|
202
|
+
return { value: undefined, done: true };
|
|
203
|
+
}
|
|
204
|
+
// Push all but the first into the buffer; return the first.
|
|
205
|
+
for (var i = 1; i < rows.length; i += 1) buffer.push(rows[i]);
|
|
206
|
+
return { value: rows[0], done: false };
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Any error closes the cursor + emits an audit. Re-throw to
|
|
209
|
+
// surface upward.
|
|
210
|
+
await _closeOnce("error");
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
return: async function () {
|
|
215
|
+
// Consumer's `break` calls this — close the cursor cleanly.
|
|
216
|
+
await _closeOnce("consumer-break");
|
|
217
|
+
return { value: undefined, done: true };
|
|
218
|
+
},
|
|
219
|
+
throw: async function (err) {
|
|
220
|
+
await _closeOnce("consumer-throw");
|
|
221
|
+
throw err;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _safeAudit(auditImpl, action, actor, metadata) {
|
|
227
|
+
try {
|
|
228
|
+
auditImpl.safeEmit({
|
|
229
|
+
action: action,
|
|
230
|
+
actor: actor ? { id: actor.id, roles: actor.roles || [] } : { id: "<system>" },
|
|
231
|
+
outcome: "success",
|
|
232
|
+
metadata: metadata || {},
|
|
233
|
+
});
|
|
234
|
+
} catch (_e) { /* drop-silent */ }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
create: create,
|
|
239
|
+
AgentStreamError: AgentStreamError,
|
|
240
|
+
guards: {
|
|
241
|
+
args: guardStreamArgs,
|
|
242
|
+
},
|
|
243
|
+
};
|
package/lib/atomic-file.js
CHANGED
|
@@ -146,6 +146,12 @@ function fsync(fd) {
|
|
|
146
146
|
* b.atomicFile.fsyncDir("/var/lib/blamejs/data");
|
|
147
147
|
*/
|
|
148
148
|
function fsyncDir(dirPath) {
|
|
149
|
+
// CodeQL js/insecure-temporary-file: dirPath is an operator-supplied
|
|
150
|
+
// framework data directory (e.g. /var/lib/blamejs/data) — never an
|
|
151
|
+
// os.tmpdir-reachable path. The fd is used solely for fsync and is
|
|
152
|
+
// closed immediately; no read or write occurs through it, so the
|
|
153
|
+
// tmp-file heuristic does not apply. Owner-only 0o700 dataDir
|
|
154
|
+
// perms are set by ensureDir.
|
|
149
155
|
try {
|
|
150
156
|
var fd = nodeFs.openSync(dirPath, "r");
|
|
151
157
|
try { nodeFs.fsyncSync(fd); } catch (_e) { /* Windows rejects directory fsync */ }
|
|
@@ -560,20 +566,49 @@ function _validateMaxBytes(maxBytes) {
|
|
|
560
566
|
}
|
|
561
567
|
|
|
562
568
|
function _readSyncCore(filepath, opts) {
|
|
563
|
-
if (!nodeFs.existsSync(filepath)) {
|
|
564
|
-
var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
|
|
565
|
-
e.code = "ENOENT";
|
|
566
|
-
throw e;
|
|
567
|
-
}
|
|
568
569
|
_validateMaxBytes(opts.maxBytes);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
570
|
+
// CodeQL js/file-system-race defense — TOCTOU-safe-read scaffold.
|
|
571
|
+
// Open the fd first, then fstat the same fd (so size + content
|
|
572
|
+
// measurement bind to the inode the fd holds open). An attacker
|
|
573
|
+
// can't swap the file between size-check and read because the fd
|
|
574
|
+
// is anchored to the original inode. ENOENT surfaces from open()
|
|
575
|
+
// rather than the previous existsSync() pre-check.
|
|
576
|
+
var fd;
|
|
577
|
+
try {
|
|
578
|
+
fd = nodeFs.openSync(filepath, "r");
|
|
579
|
+
} catch (openErr) {
|
|
580
|
+
if (openErr && openErr.code === "ENOENT") {
|
|
581
|
+
var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
|
|
582
|
+
e.code = "ENOENT";
|
|
583
|
+
throw e;
|
|
584
|
+
}
|
|
585
|
+
throw openErr;
|
|
586
|
+
}
|
|
587
|
+
var buf;
|
|
588
|
+
try {
|
|
589
|
+
var fstat = nodeFs.fstatSync(fd);
|
|
590
|
+
if (fstat.size > opts.maxBytes) {
|
|
591
|
+
throw new AtomicFileError(
|
|
592
|
+
"file size " + fstat.size + " > maxBytes " + opts.maxBytes,
|
|
593
|
+
"atomic-file/too-large"
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
buf = Buffer.alloc(fstat.size);
|
|
597
|
+
var read = 0;
|
|
598
|
+
while (read < fstat.size) {
|
|
599
|
+
var n = nodeFs.readSync(fd, buf, read, fstat.size - read, null);
|
|
600
|
+
if (n === 0) break;
|
|
601
|
+
read += n;
|
|
602
|
+
}
|
|
603
|
+
if (read !== fstat.size) {
|
|
604
|
+
throw new AtomicFileError(
|
|
605
|
+
"short read: " + read + " of " + fstat.size + " bytes",
|
|
606
|
+
"atomic-file/short-read"
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
|
|
575
611
|
}
|
|
576
|
-
var buf = nodeFs.readFileSync(filepath);
|
|
577
612
|
if (opts.expectedHash) {
|
|
578
613
|
var actual = sha3Hash(buf);
|
|
579
614
|
if (actual !== opts.expectedHash) {
|
package/lib/backup/bundle.js
CHANGED
|
@@ -143,7 +143,29 @@ async function create(opts) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
_emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
|
|
146
|
-
|
|
146
|
+
// CodeQL js/file-system-race defense — open + fstat + readSync binds
|
|
147
|
+
// every byte we encrypt to the inode statSync just measured. The
|
|
148
|
+
// earlier required-vs-skip branch above (existsSync → continue when
|
|
149
|
+
// not entry.required) is honored before we reach this point; the
|
|
150
|
+
// dest path is then computed from entry.relativePath, not srcPath.
|
|
151
|
+
var plain;
|
|
152
|
+
var srcFd = nodeFs.openSync(srcPath, "r");
|
|
153
|
+
try {
|
|
154
|
+
var srcStat = nodeFs.fstatSync(srcFd);
|
|
155
|
+
plain = Buffer.alloc(srcStat.size);
|
|
156
|
+
var read = 0;
|
|
157
|
+
while (read < srcStat.size) {
|
|
158
|
+
var n = nodeFs.readSync(srcFd, plain, read, srcStat.size - read, null);
|
|
159
|
+
if (n === 0) break;
|
|
160
|
+
read += n;
|
|
161
|
+
}
|
|
162
|
+
if (read !== srcStat.size) {
|
|
163
|
+
throw new BackupBundleError("backup-bundle/short-read",
|
|
164
|
+
"create: short read on '" + entry.relativePath + "': " + read + " of " + srcStat.size + " bytes");
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
try { nodeFs.closeSync(srcFd); } catch (_c) { /* close best-effort */ }
|
|
168
|
+
}
|
|
147
169
|
var checksum = bCrypto.checksum(plain);
|
|
148
170
|
var encResult = await bCrypto.encryptWithFreshSalt(plain, passphrase);
|
|
149
171
|
var encPath = _encryptedPathFor(entry.relativePath);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardStreamArgs
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Stream Args
|
|
6
|
+
* @order 437
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Validates `b.agent.stream.create` opts (and the operator-supplied
|
|
10
|
+
* stream args). Refuses non-positive batch sizes, non-integer batch
|
|
11
|
+
* sizes (silent shard-style routing drift class — same shape Codex
|
|
12
|
+
* caught on v0.9.21), oversized batch sizes (back-pressure becomes
|
|
13
|
+
* meaningless), and structured-clone-unsafe filter shapes (functions
|
|
14
|
+
* / regex / Buffer in the cursor opts — same shape `b.guardMailQuery`
|
|
15
|
+
* refuses).
|
|
16
|
+
*
|
|
17
|
+
* @card
|
|
18
|
+
* Validates `b.agent.stream.create` opts + cursor args. Integer-only
|
|
19
|
+
* batchSize, structured-clone-safe filter shapes, sensible caps.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
var { defineClass } = require("./framework-error");
|
|
23
|
+
|
|
24
|
+
var GuardStreamArgsError = defineClass("GuardStreamArgsError", { alwaysPermanent: true });
|
|
25
|
+
|
|
26
|
+
var DEFAULT_PROFILE = "strict";
|
|
27
|
+
|
|
28
|
+
var PROFILES = Object.freeze({
|
|
29
|
+
strict: { maxBatchSize: 1024, minBatchSize: 1, maxOpenStreams: 4 }, // allow:raw-byte-literal
|
|
30
|
+
balanced: { maxBatchSize: 4096, minBatchSize: 1, maxOpenStreams: 16 }, // allow:raw-byte-literal
|
|
31
|
+
permissive: { maxBatchSize: 16384, minBatchSize: 1, maxOpenStreams: 64 }, // allow:raw-byte-literal
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
35
|
+
hipaa: "strict",
|
|
36
|
+
"pci-dss": "strict",
|
|
37
|
+
gdpr: "strict",
|
|
38
|
+
soc2: "strict",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @primitive b.guardStreamArgs.validate
|
|
43
|
+
* @signature b.guardStreamArgs.validate(args, opts?)
|
|
44
|
+
* @since 0.9.24
|
|
45
|
+
* @status stable
|
|
46
|
+
* @related b.agent.stream.create
|
|
47
|
+
*
|
|
48
|
+
* Validate `b.agent.stream.create` opts shape. Returns the input on
|
|
49
|
+
* success; throws `GuardStreamArgsError` on refusal.
|
|
50
|
+
*
|
|
51
|
+
* @opts
|
|
52
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
53
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* b.guardStreamArgs.validate({
|
|
57
|
+
* batchSize: 256,
|
|
58
|
+
* kind: "search",
|
|
59
|
+
* });
|
|
60
|
+
*/
|
|
61
|
+
function validate(args, opts) {
|
|
62
|
+
opts = opts || {};
|
|
63
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
64
|
+
if (!args || typeof args !== "object") {
|
|
65
|
+
throw new GuardStreamArgsError("stream-args/bad-input",
|
|
66
|
+
"guardStreamArgs.validate: args required");
|
|
67
|
+
}
|
|
68
|
+
if (typeof args.batchSize !== "undefined") {
|
|
69
|
+
if (!Number.isInteger(args.batchSize)) {
|
|
70
|
+
throw new GuardStreamArgsError("stream-args/bad-batch-size",
|
|
71
|
+
"guardStreamArgs.validate: batchSize must be an integer");
|
|
72
|
+
}
|
|
73
|
+
if (args.batchSize < profile.minBatchSize || args.batchSize > profile.maxBatchSize) {
|
|
74
|
+
throw new GuardStreamArgsError("stream-args/batch-size-out-of-range",
|
|
75
|
+
"guardStreamArgs.validate: batchSize " + args.batchSize +
|
|
76
|
+
" not in [" + profile.minBatchSize + ", " + profile.maxBatchSize + "]");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (typeof args.kind !== "undefined") {
|
|
80
|
+
if (typeof args.kind !== "string" || args.kind.length === 0) {
|
|
81
|
+
throw new GuardStreamArgsError("stream-args/bad-kind",
|
|
82
|
+
"guardStreamArgs.validate: kind must be a non-empty string");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Cursor opts can't carry function / regex / Buffer — they must
|
|
86
|
+
// cross the structured-clone boundary into a worker thread.
|
|
87
|
+
if (typeof args.cursorOpts !== "undefined") {
|
|
88
|
+
_checkCursorOpts(args.cursorOpts);
|
|
89
|
+
}
|
|
90
|
+
return args;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @primitive b.guardStreamArgs.compliancePosture
|
|
95
|
+
* @signature b.guardStreamArgs.compliancePosture(posture)
|
|
96
|
+
* @since 0.9.24
|
|
97
|
+
* @status stable
|
|
98
|
+
*
|
|
99
|
+
* Return the effective profile for a given compliance posture name.
|
|
100
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
101
|
+
* here instead of silently falling through to the default profile.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* b.guardStreamArgs.compliancePosture("hipaa"); // → "strict"
|
|
105
|
+
*/
|
|
106
|
+
function compliancePosture(posture) {
|
|
107
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _checkCursorOpts(cursorOpts, depth) {
|
|
111
|
+
depth = depth || 0;
|
|
112
|
+
if (depth > 8) { // allow:raw-byte-literal — recursion depth cap
|
|
113
|
+
throw new GuardStreamArgsError("stream-args/cursor-opts-too-deep",
|
|
114
|
+
"guardStreamArgs.validate: cursorOpts nesting depth exceeds 8");
|
|
115
|
+
}
|
|
116
|
+
// Function check FIRST — `typeof function === "function"` not
|
|
117
|
+
// "object", so a function value would silently skip the non-object
|
|
118
|
+
// early-return below.
|
|
119
|
+
if (typeof cursorOpts === "function") {
|
|
120
|
+
throw new GuardStreamArgsError("stream-args/function-not-allowed",
|
|
121
|
+
"guardStreamArgs.validate: functions refused in cursorOpts (structured-clone-unsafe)");
|
|
122
|
+
}
|
|
123
|
+
if (cursorOpts === null || typeof cursorOpts !== "object") return;
|
|
124
|
+
if (cursorOpts instanceof RegExp) {
|
|
125
|
+
throw new GuardStreamArgsError("stream-args/regex-not-allowed",
|
|
126
|
+
"guardStreamArgs.validate: RegExp refused in cursorOpts");
|
|
127
|
+
}
|
|
128
|
+
if (Buffer.isBuffer(cursorOpts)) {
|
|
129
|
+
throw new GuardStreamArgsError("stream-args/buffer-not-allowed",
|
|
130
|
+
"guardStreamArgs.validate: Buffer refused in cursorOpts");
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(cursorOpts)) {
|
|
133
|
+
for (var i = 0; i < cursorOpts.length; i += 1) _checkCursorOpts(cursorOpts[i], depth + 1);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
var keys = Object.keys(cursorOpts);
|
|
137
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
138
|
+
if (keys[k] === "__proto__" || keys[k] === "constructor" || keys[k] === "prototype") {
|
|
139
|
+
throw new GuardStreamArgsError("stream-args/proto-key",
|
|
140
|
+
"guardStreamArgs.validate: forbidden key '" + keys[k] + "' in cursorOpts");
|
|
141
|
+
}
|
|
142
|
+
_checkCursorOpts(cursorOpts[keys[k]], depth + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _resolveProfile(opts) {
|
|
147
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
148
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
149
|
+
}
|
|
150
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
151
|
+
if (!PROFILES[p]) {
|
|
152
|
+
throw new GuardStreamArgsError("stream-args/bad-profile",
|
|
153
|
+
"guardStreamArgs: unknown profile '" + p + "'");
|
|
154
|
+
}
|
|
155
|
+
return p;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
validate: validate,
|
|
160
|
+
compliancePosture: compliancePosture,
|
|
161
|
+
PROFILES: PROFILES,
|
|
162
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
163
|
+
GuardStreamArgsError: GuardStreamArgsError,
|
|
164
|
+
NAME: "streamArgs",
|
|
165
|
+
KIND: "stream-args",
|
|
166
|
+
};
|
package/lib/http-client.js
CHANGED
|
@@ -1875,6 +1875,15 @@ async function downloadStream(opts) {
|
|
|
1875
1875
|
// fsync the file's data + close. atomicFile.fsync is best-effort
|
|
1876
1876
|
// across platforms but matches the discipline of the rest of the
|
|
1877
1877
|
// framework's atomic-write paths.
|
|
1878
|
+
//
|
|
1879
|
+
// CodeQL js/insecure-temporary-file: tmpPath = dest + ".tmp-" +
|
|
1880
|
+
// bCrypto.generateToken(C.BYTES.bytes(8)) (line 1802), where
|
|
1881
|
+
// bCrypto.generateToken produces 16 hex chars of CSPRNG-derived
|
|
1882
|
+
// randomness. The path lives next to operator-supplied `dest`
|
|
1883
|
+
// (downloadStream contract — never under os.tmpdir()), and the
|
|
1884
|
+
// 64-bit unpredictable suffix defeats the symlink-pre-creation
|
|
1885
|
+
// attack the rule flags. The fd is used solely for fsync; the
|
|
1886
|
+
// file's bytes were already written by the upstream pipeline.
|
|
1878
1887
|
try {
|
|
1879
1888
|
var fd = nodeFs.openSync(tmpPath, "r+");
|
|
1880
1889
|
try { atomicFile.fsync(fd); } finally { try { nodeFs.closeSync(fd); } catch (_c) { /* best-effort fd close */ } }
|
|
@@ -1084,9 +1084,17 @@ async function _parseMultipart(req, opts, ctParams) {
|
|
|
1084
1084
|
var text = fbuf.toString("utf8");
|
|
1085
1085
|
// Repeated field name → array, matching urlencoded parser.
|
|
1086
1086
|
if (Object.prototype.hasOwnProperty.call(fields, currentField)) {
|
|
1087
|
+
// lgtm[js/remote-property-injection] — `currentField` is gated
|
|
1088
|
+
// upstream at lib/middleware/body-parser.js:867 by
|
|
1089
|
+
// POISONED_KEYS (__proto__ / constructor / prototype) which
|
|
1090
|
+
// refuses the multipart part with a 400 BodyParserError before
|
|
1091
|
+
// `currentField` is ever assigned. Reachable values cannot
|
|
1092
|
+
// pollute the prototype chain.
|
|
1087
1093
|
if (Array.isArray(fields[currentField])) fields[currentField].push(text);
|
|
1088
1094
|
else fields[currentField] = [fields[currentField], text];
|
|
1089
1095
|
} else {
|
|
1096
|
+
// lgtm[js/remote-property-injection] — see upstream POISONED_KEYS
|
|
1097
|
+
// gate at lib/middleware/body-parser.js:867.
|
|
1090
1098
|
fields[currentField] = text;
|
|
1091
1099
|
}
|
|
1092
1100
|
}
|
|
@@ -90,7 +90,10 @@ function _parseCookieHeader(header) {
|
|
|
90
90
|
// just splits the name=value pairs. Keys that appear multiple times
|
|
91
91
|
// resolve to the FIRST occurrence (browsers send pairs left-to-right
|
|
92
92
|
// by registration order; the first is the most-specific path).
|
|
93
|
-
|
|
93
|
+
// Output object has no prototype chain — `Object.create(null)` defends
|
|
94
|
+
// against `__proto__` / `constructor` / `prototype` cookie-name keys
|
|
95
|
+
// polluting the prototype before the hasOwnProperty gate runs.
|
|
96
|
+
var out = Object.create(null);
|
|
94
97
|
if (typeof header !== "string" || header.length === 0) return out;
|
|
95
98
|
var parts = header.split(/;\s*/);
|
|
96
99
|
for (var i = 0; i < parts.length; i++) {
|
package/lib/mtls-ca.js
CHANGED
|
@@ -308,14 +308,34 @@ function create(opts) {
|
|
|
308
308
|
var keyTmp = keyDest + ".tmp";
|
|
309
309
|
var certTmp = paths.caCert + ".tmp";
|
|
310
310
|
|
|
311
|
+
// CodeQL js/insecure-temporary-file defense — exclusive-create ("wx")
|
|
312
|
+
// refuses to write through a pre-existing path (symlink or regular
|
|
313
|
+
// file). keyTmp / certTmp live under the operator-supplied dataDir
|
|
314
|
+
// (owner-only 0o700 framework dir established by atomicFile.ensureDir
|
|
315
|
+
// upstream), but exclusive-create hardens against a residual tmp file
|
|
316
|
+
// from a crashed prior commit or an attacker who pre-creates the
|
|
317
|
+
// path as a symlink. EEXIST surfaces as the commit-failed error.
|
|
318
|
+
function _writeExclusive(path, data, mode) {
|
|
319
|
+
var fd = nodeFs.openSync(path, "wx", mode);
|
|
320
|
+
try {
|
|
321
|
+
var buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
322
|
+
var w = 0;
|
|
323
|
+
while (w < buf.length) {
|
|
324
|
+
w += nodeFs.writeSync(fd, buf, w, buf.length - w, null);
|
|
325
|
+
}
|
|
326
|
+
try { nodeFs.fsyncSync(fd); } catch (_fe) { /* fsync best-effort */ }
|
|
327
|
+
} finally {
|
|
328
|
+
try { nodeFs.closeSync(fd); } catch (_ce) { /* close best-effort */ }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
311
331
|
try {
|
|
312
332
|
if (sealed) {
|
|
313
333
|
_requireVault("sealed CA key commit");
|
|
314
|
-
|
|
334
|
+
_writeExclusive(keyTmp, vault.seal(opts2.caKeyPem), 0o600);
|
|
315
335
|
} else {
|
|
316
|
-
|
|
336
|
+
_writeExclusive(keyTmp, opts2.caKeyPem, 0o600);
|
|
317
337
|
}
|
|
318
|
-
|
|
338
|
+
_writeExclusive(certTmp, opts2.caCertPem, 0o644);
|
|
319
339
|
nodeFs.renameSync(keyTmp, keyDest);
|
|
320
340
|
nodeFs.renameSync(certTmp, paths.caCert);
|
|
321
341
|
} catch (e) {
|
package/lib/network-tls.js
CHANGED
|
@@ -84,15 +84,38 @@ function _isPathLike(s) {
|
|
|
84
84
|
return true;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// CodeQL js/file-system-race defense — fd-based read binds the size +
|
|
88
|
+
// content measurement to the inode the fd holds open. The cert path is
|
|
89
|
+
// operator-supplied (tls.addCa) but routing through openSync + fstatSync
|
|
90
|
+
// + readSync narrows the race window vs the prior statSync + readFileSync
|
|
91
|
+
// shape, where an attacker who could swap the file in-between could
|
|
92
|
+
// short-circuit the PEM marker check downstream.
|
|
93
|
+
function _readPathFile(p) {
|
|
94
|
+
var fd = nodeFs.openSync(p, "r");
|
|
95
|
+
try {
|
|
96
|
+
var fstat = nodeFs.fstatSync(fd);
|
|
97
|
+
var buf = Buffer.alloc(fstat.size);
|
|
98
|
+
var read = 0;
|
|
99
|
+
while (read < fstat.size) {
|
|
100
|
+
var n = nodeFs.readSync(fd, buf, read, fstat.size - read, null);
|
|
101
|
+
if (n === 0) break;
|
|
102
|
+
read += n;
|
|
103
|
+
}
|
|
104
|
+
return buf.slice(0, read).toString("utf8");
|
|
105
|
+
} finally {
|
|
106
|
+
try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
function _readPath(p) {
|
|
88
111
|
var stat = nodeFs.statSync(p);
|
|
89
112
|
if (stat.isDirectory()) {
|
|
90
113
|
var files = nodeFs.readdirSync(p)
|
|
91
114
|
.filter(function (f) { return /\.(pem|crt|cer)$/i.test(f); })
|
|
92
115
|
.sort();
|
|
93
|
-
return files.map(function (f) { return
|
|
116
|
+
return files.map(function (f) { return _readPathFile(nodePath.join(p, f)); }).join("\n");
|
|
94
117
|
}
|
|
95
|
-
return
|
|
118
|
+
return _readPathFile(p);
|
|
96
119
|
}
|
|
97
120
|
|
|
98
121
|
function addCa(pemOrPath, opts) {
|
package/lib/restore-rollback.js
CHANGED
|
@@ -147,9 +147,24 @@ function swap(opts) {
|
|
|
147
147
|
dataDir: opts.dataDir,
|
|
148
148
|
operator: opts.marker || null,
|
|
149
149
|
};
|
|
150
|
+
// CodeQL js/file-system-race: exclusive-create ("wx") refuses to
|
|
151
|
+
// overwrite a pre-existing marker. The markerPath is inside the
|
|
152
|
+
// operator-supplied rollbackRoot (not os.tmpdir-reachable), but the
|
|
153
|
+
// exclusive flag still hardens against an attacker pre-creating the
|
|
154
|
+
// path as a symlink to another file before the rename completes.
|
|
150
155
|
try {
|
|
151
|
-
nodeFs.
|
|
152
|
-
|
|
156
|
+
var markerFd = nodeFs.openSync(markerPath, "wx", 0o600);
|
|
157
|
+
try {
|
|
158
|
+
var markerBuf = Buffer.from(JSON.stringify(marker, null, 2) + "\n");
|
|
159
|
+
var written = 0;
|
|
160
|
+
while (written < markerBuf.length) {
|
|
161
|
+
written += nodeFs.writeSync(markerFd, markerBuf, written, markerBuf.length - written, null);
|
|
162
|
+
}
|
|
163
|
+
try { nodeFs.fsyncSync(markerFd); } catch (_fe) { /* fsync best-effort */ }
|
|
164
|
+
} finally {
|
|
165
|
+
try { nodeFs.closeSync(markerFd); } catch (_ce) { /* close best-effort */ }
|
|
166
|
+
}
|
|
167
|
+
} catch (_e) { /* marker write is best-effort; EEXIST tolerated */ }
|
|
153
168
|
|
|
154
169
|
return {
|
|
155
170
|
rollbackPath: hadDataDir ? rollbackPath : null,
|
package/lib/static.js
CHANGED
|
@@ -157,6 +157,10 @@ async function _readMeta(absPath) {
|
|
|
157
157
|
var sri = nodeCrypto.createHash("sha384");
|
|
158
158
|
var sha3 = nodeCrypto.createHash("sha3-512");
|
|
159
159
|
await new Promise(function (resolve, reject) {
|
|
160
|
+
// lgtm[js/path-injection] — `absPath` is the sandbox-validated return
|
|
161
|
+
// of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
|
|
162
|
+
// root-prefix check + realpath escape guard + guardFilename gate).
|
|
163
|
+
// Callers cannot reach `_readMeta` with an unvalidated path.
|
|
160
164
|
var s = nodeFs.createReadStream(absPath);
|
|
161
165
|
s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
|
|
162
166
|
s.on("end", resolve);
|
|
@@ -834,13 +838,33 @@ function create(opts) {
|
|
|
834
838
|
var ext = nodePath.extname(absPath).toLowerCase();
|
|
835
839
|
var safetyGate = contentSafety[ext];
|
|
836
840
|
if (safetyGate && typeof safetyGate.check === "function") {
|
|
841
|
+
// CodeQL js/file-system-race defense — single fd anchored to the
|
|
842
|
+
// inode for the bytes we hand to the content-safety gate. The
|
|
843
|
+
// absPath was anchored under root by _resolveSafe above; the
|
|
844
|
+
// filehandle pattern binds size + read to the same inode so a
|
|
845
|
+
// swap between stat (line 771) and read can't slip different
|
|
846
|
+
// bytes past the gate.
|
|
837
847
|
var gateBuf;
|
|
838
|
-
|
|
848
|
+
var gateHandle = null;
|
|
849
|
+
try {
|
|
850
|
+
gateHandle = await fsp.open(absPath, "r");
|
|
851
|
+
var gateStat = await gateHandle.stat();
|
|
852
|
+
gateBuf = Buffer.alloc(gateStat.size);
|
|
853
|
+
var gateRead = 0;
|
|
854
|
+
while (gateRead < gateStat.size) {
|
|
855
|
+
var gateN = await gateHandle.read(gateBuf, gateRead, gateStat.size - gateRead, null);
|
|
856
|
+
if (gateN.bytesRead === 0) break;
|
|
857
|
+
gateRead += gateN.bytesRead;
|
|
858
|
+
}
|
|
859
|
+
if (gateRead < gateStat.size) gateBuf = gateBuf.slice(0, gateRead);
|
|
860
|
+
}
|
|
839
861
|
catch (_e) {
|
|
840
862
|
stats.failures += 1;
|
|
863
|
+
if (gateHandle) { try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ } }
|
|
841
864
|
return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
|
|
842
865
|
"read_failed", "Internal Server Error");
|
|
843
866
|
}
|
|
867
|
+
try { await gateHandle.close(); } catch (_ce) { /* close best-effort */ }
|
|
844
868
|
var gateDecision;
|
|
845
869
|
try {
|
|
846
870
|
gateDecision = await safetyGate.check({
|
|
@@ -1110,6 +1134,10 @@ function create(opts) {
|
|
|
1110
1134
|
}
|
|
1111
1135
|
|
|
1112
1136
|
var streamOpts = range ? { start: range.start, end: range.end } : {};
|
|
1137
|
+
// lgtm[js/path-injection] — `absPath` is the sandbox-validated return
|
|
1138
|
+
// of `_resolveSafe` (lib/static.js:181 — lexical resolve + startsWith
|
|
1139
|
+
// root-prefix check + realpath escape guard + guardFilename gate).
|
|
1140
|
+
// The request-serve path rejects with 404 before reaching this stream.
|
|
1113
1141
|
var fileStream = nodeFs.createReadStream(absPath, streamOpts);
|
|
1114
1142
|
|
|
1115
1143
|
// Idle timeout — close the connection if the client stalls. Pattern is
|
package/lib/vault/rotate.js
CHANGED
|
@@ -389,6 +389,13 @@ function _emit(cb, ev) {
|
|
|
389
389
|
// an already-open fd) — vault-rotate's fsync-by-path semantic opens
|
|
390
390
|
// then syncs then closes, which is the right shape when we don't have
|
|
391
391
|
// the original write fd around.
|
|
392
|
+
//
|
|
393
|
+
// CodeQL js/insecure-temporary-file: `p` is an operator-supplied path
|
|
394
|
+
// inside opts.stagingDir (an owner-only 0o700 framework directory
|
|
395
|
+
// established via atomicFile.ensureDir at the top of rotate()). Not an
|
|
396
|
+
// os.tmpdir-reachable path. The fd is used solely for fsync and is
|
|
397
|
+
// closed immediately; no bytes are read or written through it, so the
|
|
398
|
+
// tmp-file predictability heuristic does not apply.
|
|
392
399
|
function _fsyncFileByPath(p) {
|
|
393
400
|
try {
|
|
394
401
|
var fd = nodeFs.openSync(p, "r+");
|
|
@@ -713,6 +720,14 @@ async function rotate(opts) {
|
|
|
713
720
|
try { nodeFs.unlinkSync(tmpDbPath + "-shm"); }
|
|
714
721
|
catch (e) { rotateLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: tmpDbPath + "-shm", error: e.message }); }
|
|
715
722
|
|
|
723
|
+
// CodeQL js/insecure-temporary-file: every "tmp" path here is inside
|
|
724
|
+
// opts.stagingDir — operator-supplied, ensureDir'd 0o700 owner-only,
|
|
725
|
+
// never under os.tmpdir(). The filenames are framework-internal
|
|
726
|
+
// markers (`_blamejs_rotate.tmp.db`, `_blamejs_verify.tmp.db`); their
|
|
727
|
+
// predictability does not enable a symlink attack because the staging
|
|
728
|
+
// dir's owner-only perms prevent any other user from creating entries
|
|
729
|
+
// inside it. Files are written 0o600 implicitly via the dir's umask
|
|
730
|
+
// and removed before the rotation completes.
|
|
716
731
|
var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
|
|
717
732
|
nodeFs.writeFileSync(nodePath.join(stagingDir, paths.encryptedDb),
|
|
718
733
|
bCrypto.encryptPacked(rotatedBytes, dbKey));
|
|
@@ -283,6 +283,12 @@ function sealPemFile(opts) {
|
|
|
283
283
|
throw new SealPemFileError("seal-pem-file/source-too-large",
|
|
284
284
|
"source size " + lstat.size + " exceeds maxSourceBytes " + maxSourceBytes);
|
|
285
285
|
}
|
|
286
|
+
// CodeQL js/file-system-race: the open-fd + fstat + inode-equality
|
|
287
|
+
// check (this block and the next) IS the TOCTOU defense. lstat ran
|
|
288
|
+
// above, then we open(O_NOFOLLOW-equivalent via the symlink refusal
|
|
289
|
+
// above) and rebind every subsequent measurement to the fd's inode.
|
|
290
|
+
// Any swap between lstat and open is detected by the fstat.ino !==
|
|
291
|
+
// lstat.ino branch below and refused as toctou-detected.
|
|
286
292
|
var fd = nodeFs.openSync(source, "r");
|
|
287
293
|
try {
|
|
288
294
|
var fstat = nodeFs.fstatSync(fd);
|
package/lib/websocket.js
CHANGED
|
@@ -529,7 +529,10 @@ function _parseExtensionHeader(header) {
|
|
|
529
529
|
for (var i = 0; i < entries.length; i++) {
|
|
530
530
|
var parts = structuredFields.splitTopLevel(entries[i], ";").map(function (s) { return s.trim(); });
|
|
531
531
|
if (!parts[0]) continue;
|
|
532
|
-
|
|
532
|
+
// `params` has no prototype chain — `Object.create(null)` defends
|
|
533
|
+
// against `__proto__` / `constructor` / `prototype` parameter names
|
|
534
|
+
// in the Sec-WebSocket-Extensions header polluting downstream lookups.
|
|
535
|
+
var ext = { name: parts[0].toLowerCase(), params: Object.create(null) };
|
|
533
536
|
for (var j = 1; j < parts.length; j++) {
|
|
534
537
|
var kv = parts[j].split("=");
|
|
535
538
|
var k = kv[0].trim().toLowerCase();
|
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:49322f04-cea2-480b-ac77-16cc39333700",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-14T23:15:40.993Z",
|
|
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.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.24",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.24",
|
|
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.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.24",
|
|
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.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.24",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|