@blamejs/core 0.15.4 → 0.15.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/MIGRATING.md +43 -0
- package/README.md +6 -4
- package/lib/auth/oauth.js +28 -4
- package/lib/auth/saml.js +6 -8
- package/lib/auth/sd-jwt-vc.js +15 -2
- package/lib/codepoint-class.js +23 -0
- package/lib/crypto-field.js +10 -1
- package/lib/db-query.js +44 -19
- package/lib/db.js +4 -0
- package/lib/gate-contract.js +16 -6
- package/lib/legal-hold.js +16 -2
- package/lib/outbox.js +50 -3
- package/lib/retention.js +9 -1
- package/lib/safe-url.js +170 -3
- package/lib/sse.js +26 -0
- package/lib/ssrf-guard.js +100 -0
- package/lib/subject.js +9 -2
- package/lib/worker-pool.js +11 -0
- 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.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
|
|
12
|
+
|
|
13
|
+
- v0.15.5 (2026-06-12) — **Legal-hold and subject-restriction PII is sealed at rest, and a guard's compliance-posture forensic and runtime caps are applied on its default gate.** This release closes two data-protection gaps. The legal-hold registry and the subject-restriction flag stored their free-text fields - the legal basis, custodian, ticket citation, and restriction reason that link a data subject to a legal matter - in clear, because those local tables were written through a raw SQL path that bypassed the structured builder's at-rest sealing. Those columns are now sealed on write and unsealed on read, the same way the DSR ticket store already seals subject identifiers. Separately, a content guard built on the standard gate contract and gated with a compliance posture (for example b.guardCidr.gate({ compliancePosture: "hipaa" })) silently dropped that posture's forensic-snapshot cap and the profile's runtime cap, because the default gate passed the caller's raw options straight to the gate builder instead of resolving the profile and posture first - so a regulated-posture refusal carried no forensic evidence. The default gate now resolves the profile and posture before building the gate, matching the hand-written guard gates. **Security:** *Legal-hold and subject-restriction PII is sealed at rest* — `b.legalHold`'s `_blamejs_legal_hold` registry stored the hold reason, custodian, placed-by, and citation in clear, and `b.subject.restrict`'s `_blamejs_subject_restrictions` stored the restriction reason in clear - free text that ties a data subject to a litigation hold or an Art. 18 processing restriction. Those rows were written through a raw `sql.insert` + `prepare().run()` path that bypassed the structured builder's automatic field sealing (the subject-restrictions table even declared the field as sealed, but the raw write never applied it). Both now seal those columns on write and unseal on read through `cryptoField`, so the legal-basis and custodian text is encrypted at rest under the deployment's vault key. Pre-existing plaintext rows continue to read correctly (the unseal path passes through an already-plaintext value). · *A guard's default gate applies its compliance-posture forensic and runtime caps* — A guard built on `b.gateContract.defineGuard` with the standard gate (no bespoke gate) and gated with a compliance posture dropped that posture's `forensicSnippetBytes` cap and the profile's `maxRuntimeMs` cap: the default gate passed the caller's raw options straight to the gate builder, which reads those caps directly, but the values live on the resolved profile and posture, not the raw options. The effect was that a regulated-posture refusal captured no forensic snapshot (the cap defaulted to 0, i.e. disabled) and the check ran without the profile's runtime bound. The default gate now resolves the profile and posture before building the gate - matching the hand-written guard gates - so `gate({ compliancePosture: "hipaa" })` applies the posture's forensic cap and the profile's runtime cap as documented.
|
|
14
|
+
|
|
11
15
|
- v0.15.4 (2026-06-12) — **Telemetry attribute values are redacted before they leave the process, per-row data residency is enforced on every write and export path, DDL routes through the single-statement gate, the DPoP middleware requires its replay store, and session rotation re-keys the device binding.** This release closes a set of egress, data-residency, and session-binding gaps. OTLP span, span-event, and resource attributes are now scrubbed through the telemetry redactor before serialization, on both the JSON and protobuf paths, matching the metric exporter - an attribute value holding a bearer token, password, or API key no longer reaches the collector verbatim. Per-row data residency, previously enforced only at the structured query builder, is now enforced on the three paths that bypassed it: raw SQL writes, read-replica fan-out, and backup export. Every CREATE TABLE / ALTER TABLE the schema reconciler and the DSR store emit now passes through the same single-statement gate the query builder uses. The DPoP middleware now requires its replay store at mount time instead of silently mounting a proof-of-possession gate that performed no replay check. And session rotation re-keys the device fingerprint to the new session id, so a rotated session stays bound to its device instead of falsely reporting drift on the next request. **Fixed:** *Session rotation re-keys the device fingerprint to the new session id* — A session's optional device fingerprint is keyed to its session id, so that a stolen database cannot replay the binding. `b.session.rotate` moved the session id but left the stored fingerprint keyed to the old id, so the next `verify` recomputed the fingerprint against the new id and mismatched - reporting a false `fingerprintDrift` (which destroys the session under strict operators, logging the user out on every rotation) or silently breaking the binding. Rotation now re-keys the fingerprint to the new session id from the live request: pass the same `{ req, fingerprintFields }` to `rotate`. A fingerprint-bound session rotated without `req` now throws, because the binding cannot otherwise follow the new id; unbound sessions are unaffected. **Security:** *OTLP exporter redacts span, event, and resource attribute values before egress* — Span, span-event, and resource attributes were serialized to the OTLP collector verbatim on both the JSON and protobuf encodings - the metric exporter scrubbed its attributes through the telemetry redactor, but the span exporter did not. An attribute value carrying a secret or PII (a bearer token in `authorization`, a `password`, an `api_key`) was therefore shipped in clear to whatever collector the deployment points at (CWE-532). Every attribute-map encoder now runs each value through `b.observability.redactAttrs` (default composes `b.redact.redact`, dropping any attribute whose redactor throws) before the wire payload, so telemetry is redacted like the log and audit egress paths. The new `b.observability.redactAttrs(attrs)` is available for operators building custom exporters. · *Per-row data residency is enforced on raw writes, read replicas, and backups* — Per-row residency was enforced only at the structured query builder. Three paths reached storage or left the deployment without it: raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, INSERT and UPDATE forms) bypassed the local residency check entirely, so a cross-border row could be written under a regulated posture with no refusal; read-replica fan-out dropped the row-residency tag, routing a regulated read with no row region identified to a residency-tagged, non-cross-border replica with no check; and `b.backup.create`'s residency check compared only the single deployment region to the destination, blind to a per-row-residency table that admits rows from several regions. Raw writes now parse the target table and residency value and apply the same gate the builder does; the replica read now fails closed when the row region is unidentified; and backup now emits a per-row cross-border advisory for any declared residency table whose admitted regions differ from the backup destination. · *Schema and DSR DDL routes through the single-statement gate* — The CREATE TABLE / ALTER TABLE statements emitted by the schema reconciler and the DSR ticket store were assembled and run without the single-statement / NUL / unterminated-quote / unbalanced-paren gate that every query the builder emits already passes. That gate is now a shared check both the builder's catalog emitter and the schema/DSR DDL path call, so a terminator, comment marker, or unbalanced quote that reached a DDL fragment is refused at emit time on every backend. · *DPoP middleware requires its replay store at mount time* — `b.middleware.dpop` documented `replayStore` as required, but the factory read it optionally and gated the jti-replay check behind its presence - omitting it mounted a proof-of-possession gate that performed no replay check, so a captured DPoP proof could be replayed indefinitely (RFC 9449 §11.1). The middleware now requires the store at config time: a missing store, or a store lacking `checkAndInsert`, throws when the middleware is created instead of failing open at request time. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track jti themselves.
|
|
12
16
|
|
|
13
17
|
- v0.15.3 (2026-06-12) — **DDL hardening in b.sql, schema-confined column introspection on Postgres and MySQL, and a classical-downgrade audit on proxy-tunneled TLS.** This release hardens the data layer and closes a transport audit gap. The b.sql builder refuses an unrecognised column type that carries a statement terminator, quote, or comment marker - the one position in an otherwise quote-by-construction DDL builder where a verbatim string reached the emitted statement - and routes the finished CREATE TABLE through the same single-statement gate every other verb uses. The schema reconciler's column introspection is now confined to the schema or database the bare-named CREATE TABLE actually writes into (current_schema() on Postgres, DATABASE() on MySQL), so a same-named table in another schema no longer pollutes the column set, silently skipping an ADD COLUMN or fabricating false schema drift that refuses a regulated-posture boot. Two further builder gaps are fixed: a column-level primary key combined with a composite primaryKey now fails at build time with a clear error instead of producing invalid DDL, and a MySQL upsert read-back keyed by a cast or a server-evaluated function now renders the cast (or refuses the function) instead of binding an internal wrapper. Finally, an HTTPS request sent through a configured proxy now emits the tls.classical_downgrade audit when the handshake falls back to a classical group, the same as a direct connection. **Fixed:** *Schema reconciliation reads columns from the right schema on Postgres and MySQL* — The reconciler's column introspection queried information_schema with no schema filter, so on a Postgres instance or MySQL server hosting more than one schema/database with a same-named table, the live column set was the union across schemas. That could silently skip an ADD COLUMN the table needed, or report false drift that refuses a boot under a pinned regulated posture. Introspection is now confined to current_schema() (Postgres) / DATABASE() (MySQL) - the schema the bare-named CREATE TABLE lands in. SQLite (PRAGMA, per-file) is unchanged. · *createTable rejects a contradictory primary-key declaration at build time* — Declaring both a column-level primary key (primaryKey / autoIncrement / serial) and a composite opts.primaryKey emitted two PRIMARY KEY clauses, which every dialect rejects at the driver mid-migration. The builder now refuses the contradiction at build time with a clear error; a single column PK, or a composite primaryKey with no column-level PK, is unaffected. · *MySQL upsert read-back resolves a cast or function conflict key instead of binding a wrapper* — On MySQL, an upsert whose conflict key was a b.sql.cast(...) or b.sql.fn(...) built a read-back SELECT that bound the wrapper object, so the read-back matched no rows. A cast conflict key now renders as CAST(? AS type) binding the inner value; a server-evaluated function conflict key (which has no stable read-back identity) is refused with a clear error. Plain scalar conflict keys are unchanged. · *Proxy-tunneled TLS emits the classical-downgrade audit* — An HTTPS upstream reached through a configured proxy performed its TLS handshake without emitting the tls.classical_downgrade audit on a classical-group fallback, leaving the post-quantum-readiness inventory incomplete for proxied requests. Both the upstream handshake and the proxy-leg handshake now emit the audit on a classical fallback, matching the direct connection path. The handshake itself is unchanged (still hybrid-preferred TLSv1.3). **Security:** *b.sql refuses an injection-bearing verbatim column type and gates every CREATE TABLE* — An unrecognised column type passed to b.sql.createTable / alterTable was emitted into the DDL verbatim - the single raw-emission position in a builder that otherwise quotes every identifier and guards every constraint fragment. A type such as "text); DROP TABLE secrets; --" could therefore smuggle a stacked statement. The builder now refuses, at build time, a verbatim type carrying a statement terminator or comment marker, and routes the finished CREATE TABLE / ALTER TABLE statement through the same single-statement / NUL / unterminated-quote / unbalanced-paren gate every SELECT / INSERT / UPDATE / DELETE / UPSERT already used - so an unbalanced quote is caught there. Legitimate types are unaffected: VARCHAR(255), NUMERIC(10,2), DOUBLE PRECISION, TIMESTAMP WITH TIME ZONE, and MySQL ENUM('a','b') / SET(...) (which need balanced quotes) all still pass.
|
package/MIGRATING.md
CHANGED
|
@@ -14,6 +14,49 @@ The framework has no `deprecate()`-marked surface awaiting removal.
|
|
|
14
14
|
|
|
15
15
|
Listed newest-first.
|
|
16
16
|
|
|
17
|
+
### v0.15.6 — `b.auth.sdJwtVc — ES256 / ES384 signatures are now JOSE raw r||s, not DER`
|
|
18
|
+
|
|
19
|
+
`b.auth.sdJwtVc` now signs and verifies ES256 / ES384 with `dsaEncoding: "ieee-p1363"` (raw r||s), the encoding JOSE / JWS and EUDI wallets require. Previously it used node:crypto's default DER ECDSA encoding, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The signature bytes change shape (64 bytes for ES256, 96 for ES384, no leading `0x30` SEQUENCE tag).
|
|
20
|
+
|
|
21
|
+
No code change is needed — interop with conformant JOSE / wallet verifiers now works where it previously failed. Two things to re-check if you integrated with the OLD output:
|
|
22
|
+
|
|
23
|
+
- A previously-issued ES256 / ES384 SD-JWT-VC signed by an earlier version is DER-encoded; re-issue it (signatures are not portable across the encodings). Tokens are short-lived, so this clears on the next issuance cycle.
|
|
24
|
+
- If you pinned, cached, or asserted on the raw signature bytes of this library's ES256 / ES384 output, update the fixture — the bytes are now `ieee-p1363`. EdDSA / ML-DSA signatures are unchanged.
|
|
25
|
+
|
|
26
|
+
### v0.15.6 — `b.auth.oauth verifyIdToken — skipExpCheck is restricted to logout tokens`
|
|
27
|
+
|
|
28
|
+
`verifyIdToken`'s `skipExpCheck` option now throws (`auth-oauth/skip-exp-check-not-allowed`) on any token that is not an OIDC Back-Channel-Logout token (no `http://schemas.openid.net/event/backchannel-logout` event claim), and enforces an `iat` freshness floor on logout tokens (`auth-oauth/logout-token-stale`). Previously any caller could pass `skipExpCheck: true` to verify an expired — or replayed — ID token. The option was undocumented and only used internally by the back-channel-logout path, which is unaffected.
|
|
29
|
+
|
|
30
|
+
No change for normal ID-token verification or for the framework's back-channel-logout handling. If you called `verifyIdToken(token, { skipExpCheck: true })` directly on a non-logout token (an undocumented use), it now throws: drop the option so expiry is validated, or verify the token through the back-channel-logout path if it really is a logout token.
|
|
31
|
+
|
|
32
|
+
### v0.15.4 — `b.middleware.dpop — replayStore now required at mount`
|
|
33
|
+
|
|
34
|
+
`b.middleware.dpop` now REQUIRES a `replayStore` at mount time and throws (`auth-dpop/replay-store-required`) if it is omitted or lacks `checkAndInsert`. Previously the jti-replay check was gated behind store presence, so omitting it silently mounted a DPoP gate with NO replay defense — a captured proof could be replayed indefinitely (RFC 9449 §11.1).
|
|
35
|
+
|
|
36
|
+
Operators mounting `b.middleware.dpop` without a `replayStore`:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
b.middleware.dpop({
|
|
40
|
+
replayStore: b.nonceStore.create({ backend: "memory" }), // shared backend on multi-process
|
|
41
|
+
// ...other opts
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use a process-shared `replayStore` backend (not `"memory"`) on a multi-process / multi-node deployment so a proof replayed against a different worker is still caught. The low-level `b.auth.dpop.verify` primitive keeps `replayStore` optional for advanced callers that track `jti` themselves.
|
|
46
|
+
|
|
47
|
+
### v0.15.4 — `b.session.rotate — { req } required for a fingerprint-bound session`
|
|
48
|
+
|
|
49
|
+
Rotating a session created with a device fingerprint (`{ req, fingerprintFields }`) now requires the same `{ req, fingerprintFields }` at `b.session.rotate()`; a bound session rotated without `req` throws (`ROTATE_FINGERPRINT_REQ_REQUIRED`). The fingerprint is keyed to the session id, so rotation must re-key it to the new id from the live request — previously rotation left the old-id-keyed hash in place, which made the next `verify` false-drift (logging the user out under strict operators) or silently break the binding. Unbound sessions are unaffected.
|
|
50
|
+
|
|
51
|
+
Operators who rotate fingerprint-bound sessions (login / MFA / role-change transitions):
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// Pass the same { req, fingerprintFields } used at create():
|
|
55
|
+
await b.session.rotate(oldToken, { req, fingerprintFields: ["clientIp", "userAgent"], reason: "mfa" });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If you rotate a bound session from a context without the request, you must supply `req` so the binding can follow to the new session id. Sessions created WITHOUT a fingerprint need no change.
|
|
59
|
+
|
|
17
60
|
### v0.9.15 — `b.middleware.idempotencyKey.dbStore — table schema`
|
|
18
61
|
|
|
19
62
|
Single `v` JSON-envelope column split into discrete `fingerprint` / `status_code` / `headers` / `body` / `expires_at` columns; `headers` + `body` are sealed via `b.cryptoField.sealRow` when vault is initialized; `k` column carries the sha3-512 namespace-hash of the operator-supplied key.
|
package/README.md
CHANGED
|
@@ -85,10 +85,12 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
85
85
|
- Pluggable storage via `b.session.useStore` + first-party `b.session.stores.localDbThin` (tmpfs-fast)
|
|
86
86
|
- Opaque-userId anonymous sessions via `create({ anonymous: true })`
|
|
87
87
|
- Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
|
|
88
|
+
- Session-fixation rotation (`b.session.rotate`) re-keys the sid-bound device fingerprint to the new id — pass the same `{ req, fingerprintFields }` used at `create` (a fingerprint-bound session rotated without `req` is refused, so the binding can never silently break or false-drift)
|
|
88
89
|
- **Authorization** — RBAC + per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`)
|
|
89
90
|
- **Workflow gates** — break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule m-of-n approval with cooling-off lock + cancellation (`b.dualControl`)
|
|
90
91
|
- **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
|
|
91
|
-
- **
|
|
92
|
+
- **DPoP proof-of-possession** — RFC 9449 sender-constrained tokens; `b.middleware.dpop` requires a `replayStore` (a `b.nonceStore`-shaped `{ checkAndInsert }`) at mount so single-use jti-replay defense is always enforced — mounting it without the store throws at config time rather than failing open at request time
|
|
93
|
+
- **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`). The legal-hold reason / custodian / citation and the Art. 18 restriction reason — free text that ties a subject to a legal matter — are sealed at rest under the vault key, not stored in clear
|
|
92
94
|
- **WORM retention** — write-once-read-many records over any backing store (`b.worm.create`): `compliance` / `governance` Object-Lock modes, extend-only `retainUntil`, legal holds, and a tamper-evident SHA3-512 digest verified on read — the store-agnostic application-level companion to `b.objectStore`'s S3 Object Lock, for sealed-DB / filesystem / non-S3 backends (SEC 17a-4(f), CFTC 1.31, FINRA 4511)
|
|
93
95
|
- **Account safety** — adaptive bot-challenge staircase (`b.authBotChallenge`); session-to-device-posture binding with fail-closed verify (`b.sessionDeviceBinding`)
|
|
94
96
|
- **Anonymous authorization** — Privacy Pass origin side (RFC 9577/9578 — `b.privacyPass`): issue a `WWW-Authenticate: PrivateToken` challenge and verify a presented Blind-RSA (type 0x0002) token against the issuer public key, with no issuer callback and no client identity
|
|
@@ -97,7 +99,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
97
99
|
|
|
98
100
|
- **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
|
|
99
101
|
- **Power-on self-test** — `b.crypto.selfTest()` runs FIPS 140-3-style integrity checks: NIST FIPS 202 known-answer tests (SHA3-256/512, SHAKE256), AEAD round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests; fails closed (throws) on any mismatch
|
|
100
|
-
- **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures)
|
|
102
|
+
- **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) — on the structured builder, on raw SQL writes (`b.db.runSql` / `b.db.prepare().run()`, parsed quote-aware and failing closed when unparseable), on read-replica fan-out (a regulated read with no row region identified is refused), and surfaced by `b.backup.create` for any per-row-residency table whose admitted regions differ from the backup destination — plus per-row keys (each row's key derives from a CSPRNG row-secret sealed under the vault root, never from an on-disk value) so destroying a row's wrapped secret leaves its WAL / replica / backup residual ciphertext undecryptable even with the vault root key (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.listPerRowResidency`, `b.cryptoField.declarePerRowKey`)
|
|
101
103
|
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load. A vault-key rotation re-seals every AAD-bound cell, the database key, and tenant archives under the new keypair and refuses rather than silently orphaning a store it cannot reach (`b.vaultRotate`, `b.vault.aad.resealRoot`, `b.archive.rewrapTenant`)
|
|
102
104
|
- **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
|
|
103
105
|
- **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
|
|
@@ -138,7 +140,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
138
140
|
### Defensive parsers
|
|
139
141
|
|
|
140
142
|
- **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
|
|
141
|
-
- **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
|
|
143
|
+
- **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse; `canonicalize` collapses obfuscated host + IP forms — decimal / octal / hex / IPv4-mapped / zero-compressed IPv6, IDN → punycode, default-port, trailing-dot, percent-encoding — to one comparable string so SSRF allowlist / dedup checks can't be bypassed by re-encoding the same address, with `b.ssrfGuard.canonicalizeHost` for the host-only path); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
|
|
142
144
|
- **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
|
|
143
145
|
- **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject) and `exportKey` (KeyObject → COSE_Key, the inverse — ship a verification key as RFC 9052 §7 bytes). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
|
|
144
146
|
- **CBOR Web Token** — `b.cwt` CWT sign/verify (RFC 8392) over `b.cose`: standard-claim mapping (iss/sub/aud/exp/nbf/iat/cti) + `exp`/`nbf` clock-skew enforcement + `iss`/`aud` matching; the CBOR-native JWT for constrained / IoT / FIDO / verifiable-credential contexts
|
|
@@ -218,7 +220,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
218
220
|
### Observability
|
|
219
221
|
|
|
220
222
|
- **Audit chain** — tamper-evident, SLH-DSA-signed checkpoints; CADF (ISO/IEC 19395:2017) envelope export for federated SIEM (`b.audit`, `b.audit.export({ format: "cadf" })`)
|
|
221
|
-
- **Metrics + tracing** — `b.metrics`, `b.tracing` (OTel pass-through); OTLP/HTTP-JSON exporter for traces + metrics (`b.otelExport`)
|
|
223
|
+
- **Metrics + tracing** — `b.metrics`, `b.tracing` (OTel pass-through); OTLP/HTTP-JSON exporter for traces + metrics (`b.otelExport`). Span / metric / resource attribute **values** are scrubbed through the telemetry redactor before egress (`b.observability.redactAttrs`, default composes `b.redact.redact`) so a secret or PII in an attribute value never reaches the collector verbatim (CWE-532); operators building a custom exporter apply the same gate
|
|
222
224
|
- **Log-stream sinks** — local file rotation, generic webhook, OTLP/HTTP-JSON OR OTLP/gRPC, AWS CloudWatch Logs via SigV4 with optional autoCreate, RFC 5424 syslog over UDP/TCP/TLS (`b.logStream`)
|
|
223
225
|
- **PII redaction** — `b.redact`
|
|
224
226
|
- **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
|
package/lib/auth/oauth.js
CHANGED
|
@@ -1833,10 +1833,30 @@ function create(opts) {
|
|
|
1833
1833
|
var now = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
1834
1834
|
var skewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
|
|
1835
1835
|
// OIDC Back-Channel Logout 1.0 §2.4 — logout tokens have no `exp`
|
|
1836
|
-
// claim; freshness comes from `iat` + jti-replay window.
|
|
1837
|
-
//
|
|
1838
|
-
//
|
|
1839
|
-
|
|
1836
|
+
// claim; freshness comes from `iat` + jti-replay window. `skipExpCheck`
|
|
1837
|
+
// bypasses the exp gate for that path ONLY. It is a public-API option,
|
|
1838
|
+
// so it must be self-guarding: refuse it on any token that is not a
|
|
1839
|
+
// logout token (no back-channel-logout event), or a caller could verify
|
|
1840
|
+
// an expired/replayed id_token clean. Logout tokens then carry no exp,
|
|
1841
|
+
// so `iat` is the only freshness bound — enforce a max-age floor.
|
|
1842
|
+
if (vopts.skipExpCheck) {
|
|
1843
|
+
if (!payload.events || typeof payload.events !== "object" ||
|
|
1844
|
+
!payload.events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
1845
|
+
throw new OAuthError("auth-oauth/skip-exp-check-not-allowed",
|
|
1846
|
+
"skipExpCheck is only valid for back-channel-logout tokens " +
|
|
1847
|
+
"(OIDC Back-Channel Logout 1.0 §2.4); this token carries no logout event claim");
|
|
1848
|
+
}
|
|
1849
|
+
// Honor the operator's configured replay window. verifyBackchannelLogoutToken
|
|
1850
|
+
// exposes vopts.maxAgeSec (default 5 min) and passes it through here; a
|
|
1851
|
+
// deployment that widened the window must not have this freshness floor
|
|
1852
|
+
// reject a token between the default and its configured max age.
|
|
1853
|
+
var logoutMaxAgeSec = (typeof vopts.maxAgeSec === "number" && isFinite(vopts.maxAgeSec) &&
|
|
1854
|
+
vopts.maxAgeSec > 0) ? vopts.maxAgeSec : DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
|
|
1855
|
+
if (typeof payload.iat !== "number" || payload.iat + logoutMaxAgeSec + skewSec < now) {
|
|
1856
|
+
throw new OAuthError("auth-oauth/logout-token-stale",
|
|
1857
|
+
"logout token iat is older than " + logoutMaxAgeSec + "s (no exp; iat is the freshness bound)");
|
|
1858
|
+
}
|
|
1859
|
+
} else {
|
|
1840
1860
|
if (typeof payload.exp !== "number" || payload.exp + skewSec < now) {
|
|
1841
1861
|
throw new OAuthError("auth-oauth/expired", "ID token expired (exp=" + payload.exp + ", now=" + now + ")");
|
|
1842
1862
|
}
|
|
@@ -2212,6 +2232,10 @@ function create(opts) {
|
|
|
2212
2232
|
// Logout tokens have no exp claim per OIDC Back-Channel Logout
|
|
2213
2233
|
// §2.4 — the freshness gate is iat + jti-replay window.
|
|
2214
2234
|
skipExpCheck: true,
|
|
2235
|
+
// Pass the operator's configured replay window through so verifyIdToken's
|
|
2236
|
+
// iat freshness floor uses it, not the 5-min default (the wrapper's own
|
|
2237
|
+
// maxAgeSec check below stays as a belt-and-suspenders bound).
|
|
2238
|
+
maxAgeSec: vopts.maxAgeSec,
|
|
2215
2239
|
});
|
|
2216
2240
|
var claims = verified.claims;
|
|
2217
2241
|
|
package/lib/auth/saml.js
CHANGED
|
@@ -684,10 +684,11 @@ function create(opts) {
|
|
|
684
684
|
// as Bearer (Profile §3.1 incorporates §3 by reference).
|
|
685
685
|
var nbHok = _attr(scdHok, "NotBefore");
|
|
686
686
|
var noaHok = _attr(scdHok, "NotOnOrAfter");
|
|
687
|
+
if (!noaHok) continue; // §3.1 (incorporates §3) — time-bound required
|
|
688
|
+
var noaHokSec = Date.parse(noaHok) / 1000; // ms→s
|
|
689
|
+
if (!isFinite(noaHokSec) || noaHokSec < nowSec - clockSkewSec) continue; // unparseable or expired
|
|
687
690
|
if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // ms→s
|
|
688
691
|
Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // ms→s
|
|
689
|
-
if (noaHok && isFinite(Date.parse(noaHok) / 1000) && // ms→s
|
|
690
|
-
Date.parse(noaHok) / 1000 < nowSec - clockSkewSec) continue; // ms→s
|
|
691
692
|
var recipHok = _attr(scdHok, "Recipient");
|
|
692
693
|
if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
|
|
693
694
|
hokOk = true;
|
|
@@ -697,12 +698,9 @@ function create(opts) {
|
|
|
697
698
|
var scd = _findChild(sc, "SubjectConfirmationData", SAML_NS.assertion);
|
|
698
699
|
if (!scd) continue;
|
|
699
700
|
var notOnOrAfter = _attr(scd, "NotOnOrAfter");
|
|
700
|
-
if (notOnOrAfter)
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
continue; // expired confirmation — try next
|
|
704
|
-
}
|
|
705
|
-
}
|
|
701
|
+
if (!notOnOrAfter) continue; // §4.1.4.2 — Bearer requires NotOnOrAfter (no unbounded freshness)
|
|
702
|
+
var t = Date.parse(notOnOrAfter) / 1000; // ms→s
|
|
703
|
+
if (!isFinite(t) || t < nowSec - clockSkewSec) continue; // unparseable or expired confirmation — try next
|
|
706
704
|
var notBefore = _attr(scd, "NotBefore");
|
|
707
705
|
if (notBefore) {
|
|
708
706
|
var nb = Date.parse(notBefore) / 1000; // ms→s
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -126,12 +126,25 @@ function _hashDisclosure(disclosureStr, hashAlg) {
|
|
|
126
126
|
return h.digest().toString("base64url");
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// JOSE/JWS — and EUDI wallets — require ECDSA signatures as raw r||s
|
|
130
|
+
// ("ieee-p1363"); node:crypto defaults to DER (ASN.1 SEQUENCE). Wrap the EC
|
|
131
|
+
// key so ES256/ES384 sign + verify emit/expect the JOSE encoding. Without it
|
|
132
|
+
// a token this library signs is rejected by every conformant verifier (and
|
|
133
|
+
// the library rejects their raw-r||s KB-JWTs). EdDSA / ML-DSA keys pass
|
|
134
|
+
// through unchanged. Mirrors oauth.js / dpop.js / jwt-external.js.
|
|
135
|
+
function _ecKeyParam(algorithm, key) {
|
|
136
|
+
if (algorithm === "ES256" || algorithm === "ES384") {
|
|
137
|
+
return { key: key, dsaEncoding: "ieee-p1363" };
|
|
138
|
+
}
|
|
139
|
+
return key;
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
function _signJwt(header, payload, privateKey, algorithm) {
|
|
130
143
|
var headerStr = _b64uEncode(safeJson.stringify(header));
|
|
131
144
|
var payloadStr = _b64uEncode(safeJson.stringify(payload));
|
|
132
145
|
var signingInput = headerStr + "." + payloadStr;
|
|
133
146
|
var sigAlgo = _resolveSigAlgo(algorithm);
|
|
134
|
-
var sig = nodeCrypto.sign(sigAlgo, Buffer.from(signingInput, "ascii"), privateKey);
|
|
147
|
+
var sig = nodeCrypto.sign(sigAlgo, Buffer.from(signingInput, "ascii"), _ecKeyParam(algorithm, privateKey));
|
|
135
148
|
return signingInput + "." + sig.toString("base64url");
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -144,7 +157,7 @@ function _verifyJwt(token, publicKey, algorithm) {
|
|
|
144
157
|
var signingInput = parts[0] + "." + parts[1];
|
|
145
158
|
var sig = _b64uDecodeBuf(parts[2]);
|
|
146
159
|
var sigAlgo = _resolveSigAlgo(algorithm);
|
|
147
|
-
var ok = nodeCrypto.verify(sigAlgo, Buffer.from(signingInput, "ascii"), publicKey, sig);
|
|
160
|
+
var ok = nodeCrypto.verify(sigAlgo, Buffer.from(signingInput, "ascii"), _ecKeyParam(algorithm, publicKey), sig);
|
|
148
161
|
if (!ok) {
|
|
149
162
|
throw new AuthError("auth-sd-jwt-vc/bad-signature",
|
|
150
163
|
"JWT signature verification failed");
|
package/lib/codepoint-class.js
CHANGED
|
@@ -271,7 +271,30 @@ function escapeRegExp(s) {
|
|
|
271
271
|
// lives once.
|
|
272
272
|
var HEX_PAIR_RE = /^[0-9A-Fa-f]{2}$/;
|
|
273
273
|
|
|
274
|
+
// isAsciiAlnum — codepoint is an ASCII letter or digit (A-Z / a-z / 0-9).
|
|
275
|
+
// The ASCII-alphanumeric range test recurs across every byte-class parser
|
|
276
|
+
// (URL unreserved, XML name chars, header tokens); centralized so the three
|
|
277
|
+
// range literals live once rather than as a re-rolled `cc>=0x41&&...` chain.
|
|
278
|
+
function isAsciiAlnum(cc) {
|
|
279
|
+
return (cc >= 0x41 && cc <= 0x5a) || // A-Z
|
|
280
|
+
(cc >= 0x61 && cc <= 0x7a) || // a-z
|
|
281
|
+
(cc >= 0x30 && cc <= 0x39); // 0-9
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// isUnreserved — codepoint is in the RFC 3986 §2.3 unreserved set:
|
|
285
|
+
// ALPHA / DIGIT / "-" / "." / "_" / "~". A percent-escape of an unreserved
|
|
286
|
+
// character is over-encoding the URI spec says SHOULD be decoded (§6.2.2.3).
|
|
287
|
+
function isUnreserved(cc) {
|
|
288
|
+
return isAsciiAlnum(cc) ||
|
|
289
|
+
cc === 0x2d || // -
|
|
290
|
+
cc === 0x2e || // .
|
|
291
|
+
cc === 0x5f || // _
|
|
292
|
+
cc === 0x7e; // ~
|
|
293
|
+
}
|
|
294
|
+
|
|
274
295
|
module.exports = {
|
|
296
|
+
isAsciiAlnum: isAsciiAlnum,
|
|
297
|
+
isUnreserved: isUnreserved,
|
|
275
298
|
hex4: hex4,
|
|
276
299
|
charClass: charClass,
|
|
277
300
|
fromCp: fromCp,
|
package/lib/crypto-field.js
CHANGED
|
@@ -1348,7 +1348,16 @@ function _upgradeDerivedHashesOnRead(s, table, out, dbHandle) {
|
|
|
1348
1348
|
: _resolveLocalDbHandle();
|
|
1349
1349
|
if (!handle) return;
|
|
1350
1350
|
try {
|
|
1351
|
-
|
|
1351
|
+
// The rewrite runs on whatever handle resolved the read. The local b.db is
|
|
1352
|
+
// node:sqlite; a caller-supplied external handle declares its dialect on
|
|
1353
|
+
// handle.dialect ("postgres" | "mysql"), so the UPDATE must quote
|
|
1354
|
+
// identifiers for THAT dialect — a sqlite-quoted UPDATE ("users") is parsed
|
|
1355
|
+
// as a string literal by MySQL (which expects `users`) and the durable
|
|
1356
|
+
// re-hash silently no-ops. Resolve the dialect the way db-query._dialect()
|
|
1357
|
+
// does (validated set, sqlite default).
|
|
1358
|
+
var handleDialect = (handle.dialect === "postgres" || handle.dialect === "mysql" ||
|
|
1359
|
+
handle.dialect === "sqlite") ? handle.dialect : "sqlite";
|
|
1360
|
+
var updBuilt = sql.update(table, { dialect: handleDialect, quoteName: true })
|
|
1352
1361
|
.set(upgrades)
|
|
1353
1362
|
.where("_id", rowId)
|
|
1354
1363
|
.toSql();
|
package/lib/db-query.js
CHANGED
|
@@ -421,26 +421,51 @@ class Query {
|
|
|
421
421
|
}
|
|
422
422
|
// Sealed-field translation: rewrite predicate to use derived hash if available
|
|
423
423
|
if (this._isSealedField(field)) {
|
|
424
|
-
var
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
424
|
+
var missingHashMsg =
|
|
425
|
+
"cannot query sealed column '" + this._cryptoFieldKey() + "." + field +
|
|
426
|
+
"' without a derived hash. Declare derivedHashes: { <name>: { from: '" + field + "' } } " +
|
|
427
|
+
"in the table's schema config.";
|
|
428
|
+
if (op === "IN") {
|
|
429
|
+
// Membership query on a sealed column: each candidate plaintext maps
|
|
430
|
+
// to its own derived hash. Hashing the whole array as one value (the
|
|
431
|
+
// scalar path below) never matches — whereIn/$in on a sealed column
|
|
432
|
+
// would throw or silently miss. Expand to the per-element hash set,
|
|
433
|
+
// and for each element ALSO include the legacy salted-sha3 digest so
|
|
434
|
+
// membership dual-reads across the v0.15.0 keyed-MAC flip exactly as
|
|
435
|
+
// the "=" path does (un-migrated rows must still be found).
|
|
436
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
437
|
+
throw new Error("where IN on sealed column '" + this._cryptoFieldKey() +
|
|
438
|
+
"." + field + "' requires a non-empty array of values");
|
|
439
|
+
}
|
|
440
|
+
var sealedField = null;
|
|
441
|
+
var hashedValues = [];
|
|
442
|
+
for (var inI = 0; inI < value.length; inI++) {
|
|
443
|
+
var elemLookup = cryptoField.lookupHash(this._cryptoFieldKey(), field, value[inI]);
|
|
444
|
+
if (!elemLookup) throw new Error(missingHashMsg);
|
|
445
|
+
sealedField = elemLookup.field;
|
|
446
|
+
hashedValues.push(elemLookup.value);
|
|
447
|
+
if (elemLookup.legacyValue != null && elemLookup.legacyValue !== elemLookup.value) {
|
|
448
|
+
hashedValues.push(elemLookup.legacyValue);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
field = sealedField;
|
|
452
|
+
value = hashedValues;
|
|
442
453
|
} else {
|
|
443
|
-
|
|
454
|
+
var lookup = cryptoField.lookupHash(this._cryptoFieldKey(), field, value);
|
|
455
|
+
if (!lookup) throw new Error(missingHashMsg);
|
|
456
|
+
field = lookup.field;
|
|
457
|
+
if (op === "=" && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
|
|
458
|
+
// Dual-read across the v0.15.0 keyed-MAC default flip: a row written
|
|
459
|
+
// before the flip carries the legacy salted-sha3 digest, so an
|
|
460
|
+
// equality lookup on a sealed field must match BOTH the active
|
|
461
|
+
// keyed-MAC digest and the legacy one — otherwise the flip silently
|
|
462
|
+
// drops every un-migrated row from the result. b.sql expands the
|
|
463
|
+
// IN-list to (?, ?) and binds each digest.
|
|
464
|
+
op = "IN";
|
|
465
|
+
value = [lookup.value, lookup.legacyValue];
|
|
466
|
+
} else {
|
|
467
|
+
value = lookup.value;
|
|
468
|
+
}
|
|
444
469
|
}
|
|
445
470
|
}
|
|
446
471
|
_validateField(field);
|
package/lib/db.js
CHANGED
|
@@ -304,6 +304,10 @@ var FRAMEWORK_SCHEMA = [
|
|
|
304
304
|
citation: "TEXT",
|
|
305
305
|
retainUntil: "INTEGER",
|
|
306
306
|
},
|
|
307
|
+
// The legal-basis / custodian / ticket-citation free text links a data
|
|
308
|
+
// subject to a legal matter — PII at rest. Sealed like the DSR ticket store
|
|
309
|
+
// (b.legalHold seals on write + unseals on read through cryptoField).
|
|
310
|
+
sealedFields: ["reason", "placedBy", "custodian", "citation"],
|
|
307
311
|
indexes: ["placedAt"],
|
|
308
312
|
},
|
|
309
313
|
{
|
package/lib/gate-contract.js
CHANGED
|
@@ -1992,12 +1992,22 @@ function defineGuard(spec) {
|
|
|
1992
1992
|
// to the right ctx field by KIND (or spec.ctxFields). Guards with a bespoke
|
|
1993
1993
|
// gate pass spec.gate; guards whose gate is the standard chain take this
|
|
1994
1994
|
// default.
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
//
|
|
1998
|
-
//
|
|
1999
|
-
|
|
2000
|
-
|
|
1995
|
+
// Resolve the profile + posture BEFORE buildGuardGate reads its runtime /
|
|
1996
|
+
// forensic caps: forensicSnippetBytes lives on the posture and maxRuntimeMs
|
|
1997
|
+
// on the profile, NOT on the raw caller opts. Passing raw opts through dropped
|
|
1998
|
+
// a regulated posture's forensic cap to 0 (no forensic snapshot on a refusal)
|
|
1999
|
+
// and the profile's runtime cap to uncapped — the hand-written gates resolve
|
|
2000
|
+
// in their own gate(), and the default gate must match. resolveProfileAndPosture
|
|
2001
|
+
// is idempotent over an already-resolved opts, so spec.validate's internal
|
|
2002
|
+
// resolution stays correct.
|
|
2003
|
+
function defaultGate(rawOpts) {
|
|
2004
|
+
var opts = resolveProfileAndPosture(rawOpts || {}, {
|
|
2005
|
+
profiles: profiles,
|
|
2006
|
+
compliancePostures: postures,
|
|
2007
|
+
defaults: defaults,
|
|
2008
|
+
errorClass: ErrorClass,
|
|
2009
|
+
errCodePrefix: prefix,
|
|
2010
|
+
});
|
|
2001
2011
|
var perCtx = spec.defaultGateCheck || function (ctx) {
|
|
2002
2012
|
var value = _ctxValueForKind(spec.kind, ctx, ctxFields);
|
|
2003
2013
|
if (!value) return { ok: true, action: "serve" };
|
package/lib/legal-hold.js
CHANGED
|
@@ -64,6 +64,7 @@ var lazyRequire = require("./lazy-require");
|
|
|
64
64
|
var safeJson = require("./safe-json");
|
|
65
65
|
var validateOpts = require("./validate-opts");
|
|
66
66
|
var sql = require("./sql");
|
|
67
|
+
var cryptoField = require("./crypto-field");
|
|
67
68
|
var { defineClass } = require("./framework-error");
|
|
68
69
|
|
|
69
70
|
// Local-SQLite framework table names. These run against the b.db() handle
|
|
@@ -185,6 +186,16 @@ function create(opts) {
|
|
|
185
186
|
], SQL_OPTS);
|
|
186
187
|
fn(ddl.sql);
|
|
187
188
|
}
|
|
189
|
+
// Register the table's PII columns for at-rest sealing. The framework
|
|
190
|
+
// SQLite path also registers this via db.init() FRAMEWORK_SCHEMA, but the
|
|
191
|
+
// external-db / cluster path and a post-_resetForTest registry need this
|
|
192
|
+
// idempotent guard so sealRow/unsealRow below are never no-ops (which would
|
|
193
|
+
// store the legal-basis / custodian / citation free text in clear).
|
|
194
|
+
if (!cryptoField.getSchema(HOLD_TABLE)) {
|
|
195
|
+
cryptoField.registerTable(HOLD_TABLE, {
|
|
196
|
+
sealedFields: ["reason", "placedBy", "custodian", "citation"],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
function place(subjectId, args) {
|
|
@@ -222,7 +233,7 @@ function create(opts) {
|
|
|
222
233
|
}
|
|
223
234
|
var nowMs = Date.now();
|
|
224
235
|
var placeInsBuilt = sql.insert(HOLD_TABLE, SQL_OPTS)
|
|
225
|
-
.values({
|
|
236
|
+
.values(cryptoField.sealRow(HOLD_TABLE, {
|
|
226
237
|
subjectIdHash: hash,
|
|
227
238
|
placedAt: nowMs,
|
|
228
239
|
placedBy: args.placedBy || null,
|
|
@@ -230,7 +241,7 @@ function create(opts) {
|
|
|
230
241
|
custodian: args.custodian || null,
|
|
231
242
|
citation: args.citation || null,
|
|
232
243
|
retainUntil: args.retainUntil || null,
|
|
233
|
-
})
|
|
244
|
+
}))
|
|
234
245
|
.toSql();
|
|
235
246
|
var placeInsStmt = db.prepare(placeInsBuilt.sql);
|
|
236
247
|
placeInsStmt.run.apply(placeInsStmt, placeInsBuilt.params);
|
|
@@ -268,6 +279,7 @@ function create(opts) {
|
|
|
268
279
|
"denied");
|
|
269
280
|
return { error: "not-held" };
|
|
270
281
|
}
|
|
282
|
+
existing = cryptoField.unsealRow(HOLD_TABLE, existing);
|
|
271
283
|
var relDelBuilt = sql.delete(HOLD_TABLE, SQL_OPTS)
|
|
272
284
|
.where("subjectIdHash", hash)
|
|
273
285
|
.toSql();
|
|
@@ -312,6 +324,7 @@ function create(opts) {
|
|
|
312
324
|
var getStmt = db.prepare(getBuilt.sql);
|
|
313
325
|
var row = getStmt.get.apply(getStmt, getBuilt.params);
|
|
314
326
|
if (!row) return null;
|
|
327
|
+
row = cryptoField.unsealRow(HOLD_TABLE, row);
|
|
315
328
|
return {
|
|
316
329
|
subjectId: sid,
|
|
317
330
|
placedAt: row.placedAt,
|
|
@@ -334,6 +347,7 @@ function create(opts) {
|
|
|
334
347
|
var rows = listStmt.all.apply(listStmt, listBuilt.params);
|
|
335
348
|
var nowMs = Date.now();
|
|
336
349
|
return rows.map(function (r) {
|
|
350
|
+
r = cryptoField.unsealRow(HOLD_TABLE, r);
|
|
337
351
|
return {
|
|
338
352
|
subjectIdHash: r.subjectIdHash,
|
|
339
353
|
placedAt: r.placedAt,
|
package/lib/outbox.js
CHANGED
|
@@ -85,6 +85,12 @@ var DEFAULT_MAX_ATTEMPTS = 10;
|
|
|
85
85
|
var DEFAULT_BACKOFF_INITIAL = C.TIME.seconds(1);
|
|
86
86
|
var DEFAULT_BACKOFF_MAX = C.TIME.minutes(5);
|
|
87
87
|
var DEFAULT_BACKOFF_FACTOR = 2; // multiplier, not bytes
|
|
88
|
+
// Lease after which an in-flight row is treated as stranded by a crashed
|
|
89
|
+
// publisher and reclaimed to 'pending'. Must exceed the longest expected
|
|
90
|
+
// publish so a slow-but-live publish isn't reclaimed mid-flight (a reclaim
|
|
91
|
+
// then re-publish is a duplicate, which at-least-once tolerates, but a tight
|
|
92
|
+
// lease makes duplicates routine). Default 5 min, matching backoff.maxMs.
|
|
93
|
+
var DEFAULT_CLAIM_RECLAIM_MS = C.TIME.minutes(5);
|
|
88
94
|
var TOPIC_MAX_LEN = C.BYTES.bytes(255);
|
|
89
95
|
var KEY_MAX_LEN = C.BYTES.bytes(255);
|
|
90
96
|
|
|
@@ -197,7 +203,7 @@ function create(opts) {
|
|
|
197
203
|
validateOpts.requireObject(opts, "outbox", OutboxError);
|
|
198
204
|
validateOpts(opts, [
|
|
199
205
|
"externalDb", "table", "publisher",
|
|
200
|
-
"pollIntervalMs", "batchSize", "maxAttempts",
|
|
206
|
+
"pollIntervalMs", "batchSize", "maxAttempts", "claimReclaimMs",
|
|
201
207
|
"retryBackoff", "audit", "name",
|
|
202
208
|
"envelope", "connectorName", "connectorVersion", "dbName",
|
|
203
209
|
], "outbox.create");
|
|
@@ -223,10 +229,13 @@ function create(opts) {
|
|
|
223
229
|
"outbox.create: batchSize", OutboxError, "outbox/bad-opts");
|
|
224
230
|
validateOpts.optionalPositiveFinite(opts.maxAttempts,
|
|
225
231
|
"outbox.create: maxAttempts", OutboxError, "outbox/bad-opts");
|
|
232
|
+
validateOpts.optionalPositiveFinite(opts.claimReclaimMs,
|
|
233
|
+
"outbox.create: claimReclaimMs", OutboxError, "outbox/bad-opts");
|
|
226
234
|
|
|
227
235
|
var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_MS;
|
|
228
236
|
var batchSize = opts.batchSize || DEFAULT_BATCH_SIZE;
|
|
229
237
|
var maxAttempts = opts.maxAttempts || DEFAULT_MAX_ATTEMPTS;
|
|
238
|
+
var claimReclaimMs = opts.claimReclaimMs || DEFAULT_CLAIM_RECLAIM_MS;
|
|
230
239
|
var name = opts.name || "outbox";
|
|
231
240
|
|
|
232
241
|
var backoff = opts.retryBackoff || {};
|
|
@@ -355,6 +364,7 @@ function create(opts) {
|
|
|
355
364
|
{ name: "enqueued_at", type: tsType, notNull: true },
|
|
356
365
|
{ name: "next_attempt_at", type: tsType, notNull: true },
|
|
357
366
|
{ name: "published_at", type: tsType },
|
|
367
|
+
{ name: "claimed_at", type: tsType },
|
|
358
368
|
{ name: "attempts", type: "INTEGER", notNull: true, default: 0 },
|
|
359
369
|
{ name: "last_error", type: "TEXT" },
|
|
360
370
|
{ name: "status", type: "VARCHAR(16)", notNull: true, default: "pending" },
|
|
@@ -366,6 +376,17 @@ function create(opts) {
|
|
|
366
376
|
["next_attempt_at"], { dialect: dialect, where: "status = 'pending'" }), dialect);
|
|
367
377
|
await target.query(ddl.sql, ddl.params);
|
|
368
378
|
await target.query(idx.sql, idx.params);
|
|
379
|
+
// Back-compat: an outbox table created before the claimed_at column
|
|
380
|
+
// existed predates the stale-in-flight reaper. CREATE TABLE above is
|
|
381
|
+
// IF NOT EXISTS, so it won't add the column to an existing table — add it
|
|
382
|
+
// idempotently here (every dialect errors if the column already exists,
|
|
383
|
+
// which a fresh table from the CREATE will; swallow that). Without
|
|
384
|
+
// claimed_at the reaper can't tell a stranded claim from a live one.
|
|
385
|
+
try {
|
|
386
|
+
var alter = sql.toExternalSql(sql.alterTable(opts.table,
|
|
387
|
+
{ addColumn: { name: "claimed_at", type: tsType } }, { dialect: dialect }), dialect);
|
|
388
|
+
await target.query(alter.sql, alter.params);
|
|
389
|
+
} catch (_e) { /* column already present — idempotent add */ }
|
|
369
390
|
}
|
|
370
391
|
|
|
371
392
|
// ---- Publisher worker ----
|
|
@@ -428,7 +449,7 @@ function create(opts) {
|
|
|
428
449
|
// on postgres (the whole id set as one bound array) / expanded
|
|
429
450
|
// `IN (?, ?, ...)` on mysql.
|
|
430
451
|
var claimUpdate = sql.update(opts.table, { dialect: dialect })
|
|
431
|
-
.set({ status: "in-flight" })
|
|
452
|
+
.set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
|
|
432
453
|
.whereInArray("id", ids)
|
|
433
454
|
.toExternalSql(dialect);
|
|
434
455
|
await xdb.query(claimUpdate.sql, claimUpdate.params);
|
|
@@ -440,7 +461,7 @@ function create(opts) {
|
|
|
440
461
|
// another publisher beat us to are skipped. whereInArray expands
|
|
441
462
|
// to an `IN (?, ?, ...)` placeholder list on sqlite.
|
|
442
463
|
var markUpdate = sql.update(opts.table, { dialect: dialect })
|
|
443
|
-
.set({ status: "in-flight" })
|
|
464
|
+
.set({ status: "in-flight", claimed_at: _utcNowExpr(externalDb) })
|
|
444
465
|
.whereRaw("status = 'pending'", [], { allowLiterals: true })
|
|
445
466
|
.whereInArray("id", ids)
|
|
446
467
|
.toExternalSql(dialect);
|
|
@@ -466,6 +487,28 @@ function create(opts) {
|
|
|
466
487
|
});
|
|
467
488
|
}
|
|
468
489
|
|
|
490
|
+
// Reclaim rows stranded in 'in-flight' by a crashed publisher. A claim
|
|
491
|
+
// flips status pending → in-flight and stamps claimed_at; if the process
|
|
492
|
+
// dies before the row is marked published / retry / dead, it sits in-flight
|
|
493
|
+
// forever, because the claim path only selects status='pending'. That
|
|
494
|
+
// silently drops the event and violates the at-least-once guarantee. Reset
|
|
495
|
+
// any in-flight row whose claim is older than the lease — or that predates
|
|
496
|
+
// the claimed_at column (NULL) — back to 'pending' so the next poll
|
|
497
|
+
// re-publishes it. The lease bounds how long a legitimately slow publish is
|
|
498
|
+
// protected from reclaim; a reclaim+re-publish is a duplicate, which
|
|
499
|
+
// at-least-once tolerates. Best-effort: a failed sweep retries next poll.
|
|
500
|
+
async function _reapStaleInflight() {
|
|
501
|
+
var dialect = _sqlDialect(externalDb);
|
|
502
|
+
var cutoff = new Date(Date.now() - claimReclaimMs);
|
|
503
|
+
var stmt = sql.update(opts.table, { dialect: dialect })
|
|
504
|
+
.set({ status: "pending", claimed_at: null })
|
|
505
|
+
.whereRaw("status = 'in-flight'", [], { allowLiterals: true })
|
|
506
|
+
.whereRaw("(claimed_at IS NULL OR claimed_at <= ?)", [cutoff])
|
|
507
|
+
.toExternalSql(dialect);
|
|
508
|
+
var res = await externalDb.query(stmt.sql, stmt.params);
|
|
509
|
+
return res;
|
|
510
|
+
}
|
|
511
|
+
|
|
469
512
|
async function _markPublished(id) {
|
|
470
513
|
var dialect = _sqlDialect(externalDb);
|
|
471
514
|
var stmt = sql.update(opts.table, { dialect: dialect })
|
|
@@ -506,6 +549,10 @@ function create(opts) {
|
|
|
506
549
|
}
|
|
507
550
|
|
|
508
551
|
async function _processOnce() {
|
|
552
|
+
// Reclaim crashed-publisher rows before claiming new work, so a stranded
|
|
553
|
+
// in-flight row re-enters the pending pool and is published this cycle.
|
|
554
|
+
try { await _reapStaleInflight(); }
|
|
555
|
+
catch (_e) { /* drop-silent — reaper retries next poll */ }
|
|
509
556
|
var batch = await _claimBatch();
|
|
510
557
|
if (batch.length === 0) return 0;
|
|
511
558
|
for (var i = 0; i < batch.length; i++) {
|
package/lib/retention.js
CHANGED
|
@@ -282,7 +282,6 @@ function create(opts) {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
function _erase(table, row, dryRun) {
|
|
285
|
-
var erased = cryptoField.eraseRow(table, row);
|
|
286
285
|
var sealedFields = cryptoField.getSealedFields(table) || [];
|
|
287
286
|
var hashFields = [];
|
|
288
287
|
var schema = cryptoField.getSchema(table);
|
|
@@ -294,6 +293,15 @@ function create(opts) {
|
|
|
294
293
|
return _hardDelete(table, row._id, dryRun);
|
|
295
294
|
}
|
|
296
295
|
if (dryRun) return { wouldErase: 1, sealedFieldCount: sealedFields.length };
|
|
296
|
+
// eraseRow produces the in-memory tombstone AND drives the posture-gated
|
|
297
|
+
// side effects: under a regime whose POSTURE_DEFAULTS sets
|
|
298
|
+
// requireVacuumAfterErase it schedules db.vacuumAfterErase({ mode: "full" })
|
|
299
|
+
// and emits cryptofield.erase.row. Both belong only to the COMMITTING path
|
|
300
|
+
// — running them above the dry-run gate made a preview VACUUM the whole
|
|
301
|
+
// database file per candidate row (#120). Its return value is unused (the
|
|
302
|
+
// UPDATE below NULLs the columns directly); we keep the call for the
|
|
303
|
+
// tombstone bucketing + posture vacuum on the real path.
|
|
304
|
+
var erased = cryptoField.eraseRow(table, row);
|
|
297
305
|
// NULL every sealed column + its derived-hash sibling. b.sql binds each
|
|
298
306
|
// null as a placeholder (the set map preserves the column ordering).
|
|
299
307
|
var eraseSet = {};
|
package/lib/safe-url.js
CHANGED
|
@@ -53,6 +53,11 @@ var nodeUrl = require("node:url");
|
|
|
53
53
|
var { URL } = require("node:url");
|
|
54
54
|
|
|
55
55
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
56
|
+
// ssrf-guard requires safe-url at top of file (the SSRF gate parses through
|
|
57
|
+
// the framework's defensive URL parser); lazyRequire breaks that cycle so the
|
|
58
|
+
// canonicalizer can reuse ssrf-guard's IP-literal canonicalization without an
|
|
59
|
+
// at-load circular require.
|
|
60
|
+
var ssrfGuard = lazyRequire(function () { return require("./ssrf-guard"); });
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* @primitive b.safeUrl.ALLOW_HTTP_TLS
|
|
@@ -168,9 +173,9 @@ var ALLOW_ANY = Object.freeze(["http:", "https:", "ws:", "wss:"]);
|
|
|
168
173
|
* Extends `FrameworkError`. Carries a stable `.code`:
|
|
169
174
|
* `safe-url/missing` / `safe-url/too-long` / `safe-url/malformed` /
|
|
170
175
|
* `safe-url/protocol-disallowed` / `safe-url/userinfo-disallowed` /
|
|
171
|
-
* `safe-url/idn-homograph` / `safe-url/
|
|
172
|
-
* inspects `.code` to translate
|
|
173
|
-
* leaking parser internals.
|
|
176
|
+
* `safe-url/idn-homograph` / `safe-url/uncanonicalizable` /
|
|
177
|
+
* `safe-url/bad-opt`. HTTP middleware inspects `.code` to translate
|
|
178
|
+
* the throw into a 400 without leaking parser internals.
|
|
174
179
|
*
|
|
175
180
|
* @example
|
|
176
181
|
* var b = require("blamejs");
|
|
@@ -205,6 +210,38 @@ function _makeError(errorClass, code, message) {
|
|
|
205
210
|
// payloads) override via opts.maxUrlLength.
|
|
206
211
|
var DEFAULT_MAX_URL_LENGTH = C.BYTES.kib(8);
|
|
207
212
|
|
|
213
|
+
// RFC 3986 §6.2.2 percent-encoding normalization applied CONSERVATIVELY to a
|
|
214
|
+
// path segment string: uppercase the two hex digits of every valid escape
|
|
215
|
+
// (§6.2.2.2) and decode an escape of an unreserved character to its literal
|
|
216
|
+
// (§6.2.2.3). A malformed escape (`%`, `%g`, `%4`) is passed through verbatim
|
|
217
|
+
// so no information is invented. Query and fragment are NOT touched —
|
|
218
|
+
// reordering / re-decoding there can change application semantics (a `%26`
|
|
219
|
+
// inside a value is NOT the same as a literal `&`).
|
|
220
|
+
function _normalizePctPath(path) {
|
|
221
|
+
if (typeof path !== "string" || path.indexOf("%") === -1) return path;
|
|
222
|
+
var out = "";
|
|
223
|
+
for (var i = 0; i < path.length; i += 1) {
|
|
224
|
+
var ch = path.charAt(i);
|
|
225
|
+
if (ch === "%") {
|
|
226
|
+
// The escape's two hex digits — sliced to a fixed two-char window so
|
|
227
|
+
// the regex test runs on a length-bounded string (no ReDoS surface).
|
|
228
|
+
var pair = path.slice(i + 1, i + 3);
|
|
229
|
+
if (pair.length === 2 && codepointClass.HEX_PAIR_RE.test(pair)) {
|
|
230
|
+
var cc = parseInt(pair, 16);
|
|
231
|
+
if (codepointClass.isUnreserved(cc)) {
|
|
232
|
+
out += String.fromCharCode(cc);
|
|
233
|
+
} else {
|
|
234
|
+
out += "%" + pair.toUpperCase();
|
|
235
|
+
}
|
|
236
|
+
i += 2;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
out += ch;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
208
245
|
/**
|
|
209
246
|
* @primitive b.safeUrl.parse
|
|
210
247
|
* @signature b.safeUrl.parse(url, opts?)
|
|
@@ -416,9 +453,139 @@ function format(url) {
|
|
|
416
453
|
}
|
|
417
454
|
}
|
|
418
455
|
|
|
456
|
+
/**
|
|
457
|
+
* @primitive b.safeUrl.canonicalize
|
|
458
|
+
* @signature b.safeUrl.canonicalize(input, opts?)
|
|
459
|
+
* @since 0.15.6
|
|
460
|
+
* @status stable
|
|
461
|
+
* @related b.safeUrl.parse, b.ssrfGuard.canonicalizeHost, b.ssrfGuard.checkUrl
|
|
462
|
+
*
|
|
463
|
+
* Return the single canonical, comparable form of a URL so two
|
|
464
|
+
* spellings of the same destination compare equal as strings. The use
|
|
465
|
+
* cases are host allowlists, dedup / cache keys, and SSRF pre-checks —
|
|
466
|
+
* exactly the places an attacker reaches for an obfuscated host
|
|
467
|
+
* (`http://0177.0.0.1/`, `http://2130706433/`, `http://[::ffff:7f00:1]/`,
|
|
468
|
+
* an IDN homograph, a trailing-dot or default-port variation) to slip
|
|
469
|
+
* past a naive `===` allowlist. Routing every comparison through one
|
|
470
|
+
* audited canonicalizer closes that class instead of leaving each
|
|
471
|
+
* caller to re-derive normalization (which is how the bypasses happen).
|
|
472
|
+
*
|
|
473
|
+
* The canonical form is built from `parse`'s defensive gates plus the
|
|
474
|
+
* security-relevant normalization set:
|
|
475
|
+
*
|
|
476
|
+
* - Scheme and host lowercased (the WHATWG URL parser does this).
|
|
477
|
+
* - Host IDN labels emitted as their punycode `xn--` A-label; a
|
|
478
|
+
* mixed-script / confusable host label THROWS exactly as
|
|
479
|
+
* `parse` does (a homograph is never silently passed) unless the
|
|
480
|
+
* caller opts in via `allowMixedScript` / `allowedScripts`.
|
|
481
|
+
* - A trailing dot on the host is removed (`example.com.` →
|
|
482
|
+
* `example.com`) — DNS-equivalent but breaks string comparison.
|
|
483
|
+
* - An IP-literal host in ANY notation collapses to one canonical
|
|
484
|
+
* string via `b.ssrfGuard.canonicalizeHost` (the SAME byte parser
|
|
485
|
+
* the SSRF classifier matches on): IPv4 decimal / octal / hex /
|
|
486
|
+
* shorthand → dotted-quad; IPv6 (incl. IPv4-mapped + any
|
|
487
|
+
* zero-compression) → RFC 5952 lower-hex, bracketed.
|
|
488
|
+
* - The default port for the scheme is stripped (`:80` http/ws,
|
|
489
|
+
* `:443` https/wss — the parser does this).
|
|
490
|
+
* - Path `.` / `..` segments resolved (WHATWG), then RFC 3986 §6.2.2
|
|
491
|
+
* percent-normalization applied to the path: hex digits uppercased,
|
|
492
|
+
* escapes of unreserved characters decoded. Query and fragment are
|
|
493
|
+
* left BYTE-FOR-BYTE as parsed — reordering or re-decoding there can
|
|
494
|
+
* change application semantics.
|
|
495
|
+
*
|
|
496
|
+
* Throws `SafeUrlError` (or `opts.errorClass`): the `parse` codes for a
|
|
497
|
+
* missing / too-long / malformed / disallowed-scheme / userinfo /
|
|
498
|
+
* homograph input, plus `safe-url/uncanonicalizable` when a parsed URL
|
|
499
|
+
* cannot be reduced to a safe canonical form. This is a config /
|
|
500
|
+
* entry-point validator — it THROWS on bad input, it does NOT return a
|
|
501
|
+
* best-effort string.
|
|
502
|
+
*
|
|
503
|
+
* @opts
|
|
504
|
+
* allowedSchemes: string[], // default ALLOW_ANY (http/https/ws/wss); canonicalize is a compare tool, not a fetch gate
|
|
505
|
+
* allowUserinfo: boolean, // default false; opt-in to keep user:pass@ (still discouraged)
|
|
506
|
+
* allowMixedScript: boolean, // default false; opt-in to mixed-script host labels
|
|
507
|
+
* allowedScripts: string[], // narrow mixed-script allowlist (e.g. ["latin","cyrillic"])
|
|
508
|
+
* maxUrlLength: number, // default 8192 (RFC 7230 §3.1.1)
|
|
509
|
+
* errorClass: Function, // throw this instead of SafeUrlError
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* var b = require("blamejs");
|
|
513
|
+
*
|
|
514
|
+
* // Every obfuscated loopback spelling collapses to one string.
|
|
515
|
+
* b.safeUrl.canonicalize("http://0177.0.0.1/"); // → "http://127.0.0.1/"
|
|
516
|
+
* b.safeUrl.canonicalize("http://2130706433/"); // → "http://127.0.0.1/"
|
|
517
|
+
* b.safeUrl.canonicalize("http://127.1/"); // → "http://127.0.0.1/"
|
|
518
|
+
*
|
|
519
|
+
* // Case, default port, trailing dot, and `..` all normalize.
|
|
520
|
+
* b.safeUrl.canonicalize("https://Example.COM:443/a/../b");
|
|
521
|
+
* // → "https://example.com/b"
|
|
522
|
+
*
|
|
523
|
+
* // A disallowed scheme throws SafeUrlError.
|
|
524
|
+
* try { b.safeUrl.canonicalize("ftp://example.com/"); }
|
|
525
|
+
* catch (e) { e.code; } // → "safe-url/protocol-disallowed"
|
|
526
|
+
*/
|
|
527
|
+
function canonicalize(input, opts) {
|
|
528
|
+
opts = opts || {};
|
|
529
|
+
var allowedSchemes = Array.isArray(opts.allowedSchemes) && opts.allowedSchemes.length > 0
|
|
530
|
+
? opts.allowedSchemes
|
|
531
|
+
: ALLOW_ANY;
|
|
532
|
+
|
|
533
|
+
// Route through parse — it owns the length cap, the scheme allowlist,
|
|
534
|
+
// userinfo refusal, and the IDN-homograph defense. A parse throw (with
|
|
535
|
+
// its stable .code) propagates unchanged so callers branch on the same
|
|
536
|
+
// codes parse documents.
|
|
537
|
+
var parsed = parse(input, {
|
|
538
|
+
allowedProtocols: allowedSchemes,
|
|
539
|
+
maxUrlLength: opts.maxUrlLength,
|
|
540
|
+
allowUserinfo: opts.allowUserinfo,
|
|
541
|
+
allowMixedScript: opts.allowMixedScript,
|
|
542
|
+
allowedScripts: opts.allowedScripts,
|
|
543
|
+
errorClass: opts.errorClass,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
// The parser already lowercased the scheme + host, stripped the
|
|
548
|
+
// default port, emitted IDN as punycode, and resolved `.`/`..`. The
|
|
549
|
+
// canonicalizer adds: IP-literal collapse (shared with the SSRF
|
|
550
|
+
// classifier), trailing-dot removal, and path percent-normalization.
|
|
551
|
+
var scheme = parsed.protocol; // e.g. "https:"
|
|
552
|
+
var rawHost = parsed.hostname; // already lowercase / punycode / default-port-free
|
|
553
|
+
var canonHost = ssrfGuard().canonicalizeHost(rawHost);
|
|
554
|
+
// canonicalizeHost returns IPv6 UNbracketed; a URL authority needs the
|
|
555
|
+
// brackets back. net.isIP via the colon is a sufficient discriminator
|
|
556
|
+
// (a DNS label can't contain a colon).
|
|
557
|
+
var host = canonHost.indexOf(":") !== -1 ? "[" + canonHost + "]" : canonHost;
|
|
558
|
+
|
|
559
|
+
// Userinfo (`user:pass@`) is deliberately DROPPED from the canonical form.
|
|
560
|
+
// The canonical string is built to be compared, used as a dedup / cache
|
|
561
|
+
// key, or logged; carrying a credential into any of those leaks it, and
|
|
562
|
+
// the username/password are not part of the resource's target identity for
|
|
563
|
+
// an allowlist / SSRF decision. parse() already refuses userinfo unless
|
|
564
|
+
// allowUserinfo:true; even then, the canonical output omits it (never reads
|
|
565
|
+
// parsed.password), so two URLs that differ only in credentials canonicalize
|
|
566
|
+
// equal.
|
|
567
|
+
|
|
568
|
+
var port = parsed.port !== "" ? ":" + parsed.port : "";
|
|
569
|
+
var path = _normalizePctPath(parsed.pathname);
|
|
570
|
+
// Query + fragment: byte-for-byte as the parser emitted them. Reordering
|
|
571
|
+
// a query or re-decoding a value changes semantics — out of scope for a
|
|
572
|
+
// safe canonicalizer.
|
|
573
|
+
var query = parsed.search;
|
|
574
|
+
var fragment = parsed.hash;
|
|
575
|
+
|
|
576
|
+
return scheme + "//" + host + port + path + query + fragment;
|
|
577
|
+
} catch (e) {
|
|
578
|
+
if (e && e.isSafeUrlError) throw e;
|
|
579
|
+
throw _makeError(opts.errorClass, "safe-url/uncanonicalizable",
|
|
580
|
+
"safeUrl.canonicalize could not reduce the URL to a canonical form: " +
|
|
581
|
+
((e && e.message) || String(e)));
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
419
585
|
module.exports = {
|
|
420
586
|
parse: parse,
|
|
421
587
|
format: format,
|
|
588
|
+
canonicalize: canonicalize,
|
|
422
589
|
SafeUrlError: SafeUrlError,
|
|
423
590
|
ALLOW_HTTP_TLS: ALLOW_HTTP_TLS,
|
|
424
591
|
ALLOW_HTTP_ALL: ALLOW_HTTP_ALL,
|
package/lib/sse.js
CHANGED
|
@@ -247,6 +247,21 @@ function create(req, res, opts) {
|
|
|
247
247
|
// proxyBuffer: false to suppress the nginx-specific header.
|
|
248
248
|
var proxyBuffer = opts.proxyBuffer !== false;
|
|
249
249
|
|
|
250
|
+
// Slow-consumer bound. SSE is server-push: when a client stalls, res.write()
|
|
251
|
+
// returns false but the app keeps pushing, so Node buffers the unsent bytes
|
|
252
|
+
// in res.writableLength without limit — one stuck connection grows the heap
|
|
253
|
+
// until exhaustion (memory-exhaustion DoS). Cap the per-channel buffer and
|
|
254
|
+
// evict the slow consumer past it. A healthy client (writableLength ~0) is
|
|
255
|
+
// never affected. Config-time input → throw on a bad value.
|
|
256
|
+
var maxBufferedBytes = opts.maxBufferedBytes;
|
|
257
|
+
if (maxBufferedBytes === undefined) maxBufferedBytes = C.BYTES.mib(1);
|
|
258
|
+
if (typeof maxBufferedBytes !== "number" || !isFinite(maxBufferedBytes) ||
|
|
259
|
+
maxBufferedBytes <= 0 || Math.floor(maxBufferedBytes) !== maxBufferedBytes) {
|
|
260
|
+
throw errorClass.factory("sse/bad-opts",
|
|
261
|
+
"sse.create: maxBufferedBytes must be a positive integer byte count (got " +
|
|
262
|
+
JSON.stringify(maxBufferedBytes) + ")");
|
|
263
|
+
}
|
|
264
|
+
|
|
250
265
|
var lastEventId = _readLastEventId(req);
|
|
251
266
|
|
|
252
267
|
// Headers. text/event-stream is the contract; Cache-Control: no-cache
|
|
@@ -275,6 +290,17 @@ function create(req, res, opts) {
|
|
|
275
290
|
"sse.send: channel closed");
|
|
276
291
|
}
|
|
277
292
|
res.write(s);
|
|
293
|
+
// res.writableLength is the count of bytes Node has buffered but not yet
|
|
294
|
+
// flushed to the socket. A healthy client drains it (≈0); a stalled one
|
|
295
|
+
// lets it climb. Past the per-channel cap, evict the slow consumer rather
|
|
296
|
+
// than buffer without bound. h2 streams + h1 responses both expose it.
|
|
297
|
+
var buffered = (typeof res.writableLength === "number") ? res.writableLength : 0;
|
|
298
|
+
if (buffered > maxBufferedBytes) {
|
|
299
|
+
close({ reason: "backpressure-exceeded" });
|
|
300
|
+
throw errorClass.factory("sse/backpressure",
|
|
301
|
+
"sse.send: client too slow — buffered " + buffered +
|
|
302
|
+
" bytes exceeds maxBufferedBytes " + maxBufferedBytes + "; channel closed");
|
|
303
|
+
}
|
|
278
304
|
}
|
|
279
305
|
|
|
280
306
|
function send(eventOpts) {
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -247,6 +247,105 @@ function _expandIpv6(ip) {
|
|
|
247
247
|
return left.concat(fill).concat(right);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
function _ipv6BytesToString(bytes) {
|
|
251
|
+
// RFC 5952 §4 canonical textual form from 16 canonical bytes: lower-hex,
|
|
252
|
+
// no leading zeros per group, the LONGEST run of two-or-more zero groups
|
|
253
|
+
// compressed to "::" (leftmost run on a length tie — §4.2.3), and the
|
|
254
|
+
// shortened-but-not-IPv4-dotted form (the framework keeps IPv4-mapped as
|
|
255
|
+
// pure hex so every mapped spelling collapses to one string). Driven off
|
|
256
|
+
// the same 16-byte buffer classify() matches on, so the emitted string and
|
|
257
|
+
// the security verdict can never disagree about which address this is.
|
|
258
|
+
var groups = [];
|
|
259
|
+
for (var i = 0; i < IPV6_GROUPS; i++) {
|
|
260
|
+
groups.push(((bytes[i * 2] << 8) | bytes[i * 2 + 1]) & 0xffff);
|
|
261
|
+
}
|
|
262
|
+
var bestStart = -1, bestLen = 0, curStart = -1, curLen = 0;
|
|
263
|
+
for (var g = 0; g < IPV6_GROUPS; g++) {
|
|
264
|
+
if (groups[g] === 0) {
|
|
265
|
+
if (curStart === -1) { curStart = g; curLen = 1; } else { curLen += 1; }
|
|
266
|
+
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
|
|
267
|
+
} else {
|
|
268
|
+
curStart = -1;
|
|
269
|
+
curLen = 0;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// A single zero group is NOT compressed (RFC 5952 §4.2.2).
|
|
273
|
+
if (bestLen < 2) bestStart = -1;
|
|
274
|
+
var parts = [];
|
|
275
|
+
for (var k = 0; k < IPV6_GROUPS; k++) {
|
|
276
|
+
if (bestStart !== -1 && k === bestStart) {
|
|
277
|
+
parts.push("");
|
|
278
|
+
k += bestLen - 1;
|
|
279
|
+
// A run reaching the final group needs a trailing empty part so the
|
|
280
|
+
// join yields the "::"-terminated form (e.g. fe80:: not fe80:).
|
|
281
|
+
if (k === IPV6_GROUPS - 1) parts.push("");
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
parts.push(groups[k].toString(HEX_RADIX));
|
|
285
|
+
}
|
|
286
|
+
var out = parts.join(":");
|
|
287
|
+
// A run starting at group 0 needs a leading empty part ("::1", "::").
|
|
288
|
+
if (bestStart === 0) out = ":" + out;
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @primitive b.ssrfGuard.canonicalizeHost
|
|
294
|
+
* @signature b.ssrfGuard.canonicalizeHost(host)
|
|
295
|
+
* @since 0.15.6
|
|
296
|
+
* @status stable
|
|
297
|
+
* @related b.ssrfGuard.classify, b.safeUrl.canonicalize
|
|
298
|
+
*
|
|
299
|
+
* Canonicalize a bare host string to its single comparable form for
|
|
300
|
+
* host allowlists, dedup keys, and SSRF pre-checks. A `net.isIP`-valid
|
|
301
|
+
* IP literal collapses to one canonical string: a dotted-quad IPv4
|
|
302
|
+
* stays dotted-quad; IPv6 in any zero-compression / mixed-case /
|
|
303
|
+
* IPv4-mapped spelling (`[0:0:0:0:0:ffff:7f00:1]`, `::FFFF:7F00:1`)
|
|
304
|
+
* becomes the RFC 5952 lower-hex compressed form. The IP bytes are
|
|
305
|
+
* parsed by the SAME routines `classify` matches on, so the canonical
|
|
306
|
+
* string and the SSRF verdict can never disagree about which address a
|
|
307
|
+
* host is.
|
|
308
|
+
*
|
|
309
|
+
* The numeric-base IPv4 decode (octal `0177.0.0.1`, hex `0x7f000001`,
|
|
310
|
+
* single-integer `2130706433`, shorthand `127.1`) is the WHATWG URL
|
|
311
|
+
* parser's job — `b.safeUrl.canonicalize` runs that FIRST and hands this
|
|
312
|
+
* primitive the already-decoded dotted-quad. This is the IP-byte + case
|
|
313
|
+
* layer, not the base decoder.
|
|
314
|
+
*
|
|
315
|
+
* A DNS name (not an IP literal) is lowercased and any trailing dot is
|
|
316
|
+
* stripped — `Example.COM.` → `example.com`. IDN A-label / U-label
|
|
317
|
+
* normalization is NOT done here (the WHATWG URL parser owns that via
|
|
318
|
+
* `b.safeUrl.canonicalize`).
|
|
319
|
+
* `[...]`-bracketed IPv6 input is accepted (brackets stripped); the
|
|
320
|
+
* returned IPv6 string is UNbracketed (the URL layer re-adds brackets).
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* var b = require("blamejs");
|
|
324
|
+
* b.ssrfGuard.canonicalizeHost("[0:0:0:0:0:0:0:1]"); // → "::1"
|
|
325
|
+
* b.ssrfGuard.canonicalizeHost("::FFFF:7F00:1"); // → "::ffff:7f00:1"
|
|
326
|
+
* b.ssrfGuard.canonicalizeHost("Example.COM."); // → "example.com"
|
|
327
|
+
*/
|
|
328
|
+
function canonicalizeHost(host) {
|
|
329
|
+
if (typeof host !== "string" || host.length === 0) return host;
|
|
330
|
+
var bare = host.replace(/^\[|\]$/g, "");
|
|
331
|
+
var family = net.isIP(bare);
|
|
332
|
+
if (family === 4) {
|
|
333
|
+
var v4 = _ipv4ToBytes(bare);
|
|
334
|
+
if (v4) return v4[0] + "." + v4[1] + "." + v4[2] + "." + v4[3];
|
|
335
|
+
return bare.toLowerCase();
|
|
336
|
+
}
|
|
337
|
+
if (family === 6) {
|
|
338
|
+
return _ipv6BytesToString(_ipv6ToBytes(bare));
|
|
339
|
+
}
|
|
340
|
+
// Not an IP literal — DNS name. Lowercase + strip a single trailing dot
|
|
341
|
+
// (the root-label dot is DNS-equivalent but breaks string comparison).
|
|
342
|
+
var name = bare.toLowerCase();
|
|
343
|
+
if (name.length > 1 && name.charAt(name.length - 1) === ".") {
|
|
344
|
+
name = name.slice(0, name.length - 1);
|
|
345
|
+
}
|
|
346
|
+
return name;
|
|
347
|
+
}
|
|
348
|
+
|
|
250
349
|
function _cidrIpv4Match(cidr, ip) {
|
|
251
350
|
var slash = cidr.indexOf("/");
|
|
252
351
|
if (slash === -1) return false;
|
|
@@ -826,6 +925,7 @@ function checkUrlTextual(url, opts) {
|
|
|
826
925
|
|
|
827
926
|
module.exports = {
|
|
828
927
|
classify: classify,
|
|
928
|
+
canonicalizeHost: canonicalizeHost,
|
|
829
929
|
cidrContains: cidrContains,
|
|
830
930
|
checkUrl: checkUrl,
|
|
831
931
|
checkUrlTextual: checkUrlTextual,
|
package/lib/subject.js
CHANGED
|
@@ -561,12 +561,19 @@ function restrict(subjectId, opts) {
|
|
|
561
561
|
|
|
562
562
|
if (opts.on) {
|
|
563
563
|
if (!existing) {
|
|
564
|
+
// The restriction `reason` is a ticket reference / legal basis — PII at
|
|
565
|
+
// rest. db.js declares sealedFields:["reason"] on this table, but the raw
|
|
566
|
+
// write path bypasses the structured builder's auto-seal, so seal here
|
|
567
|
+
// explicitly (idempotent registration guard covers a reset registry).
|
|
568
|
+
if (!cryptoField.getSchema(RESTRICTIONS_TABLE)) {
|
|
569
|
+
cryptoField.registerTable(RESTRICTIONS_TABLE, { sealedFields: ["reason"] });
|
|
570
|
+
}
|
|
564
571
|
var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
|
|
565
|
-
.values({
|
|
572
|
+
.values(cryptoField.sealRow(RESTRICTIONS_TABLE, {
|
|
566
573
|
subjectIdHash: _subjectHash(subjectId),
|
|
567
574
|
since: Date.now(),
|
|
568
575
|
reason: opts.reason || null,
|
|
569
|
-
})
|
|
576
|
+
}))
|
|
570
577
|
.toSql();
|
|
571
578
|
var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
|
|
572
579
|
restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
|
package/lib/worker-pool.js
CHANGED
|
@@ -287,6 +287,11 @@ function create(scriptPath, opts) {
|
|
|
287
287
|
reason: "workerpool/worker-error",
|
|
288
288
|
message: (err && err.message) || String(err),
|
|
289
289
|
});
|
|
290
|
+
// Mark the slot dying BEFORE _finishTask. _finishTask sets slot.busy =
|
|
291
|
+
// false and drains the queue, so an unmarked slot would be handed a
|
|
292
|
+
// freshly-queued task that then dies with this same worker. _recycleWorker
|
|
293
|
+
// re-asserts the flag idempotently.
|
|
294
|
+
slot.recycling = true;
|
|
290
295
|
if (failingTask) {
|
|
291
296
|
_finishTask(slot, true,
|
|
292
297
|
new WorkerPoolError("workerpool/worker-error",
|
|
@@ -331,6 +336,12 @@ function create(scriptPath, opts) {
|
|
|
331
336
|
workerId: slot.id, taskId: taskId, taskTimeoutMs: taskTimeoutMs,
|
|
332
337
|
});
|
|
333
338
|
var failingTask = slot.currentTask;
|
|
339
|
+
// Mark the slot dying BEFORE _finishTask drains the queue, so the drain
|
|
340
|
+
// skips this about-to-be-terminated slot instead of dispatching a queued
|
|
341
|
+
// task onto it (which would die with the worker on terminate, surfacing as
|
|
342
|
+
// workerpool/worker-exit on a task that never ran). _recycleWorker
|
|
343
|
+
// re-asserts the flag idempotently.
|
|
344
|
+
slot.recycling = true;
|
|
334
345
|
if (failingTask) {
|
|
335
346
|
_finishTask(slot, true,
|
|
336
347
|
new WorkerPoolError("workerpool/timeout",
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:05cef77c-2364-4c63-8af3-3dd59f5dd56f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-13T09:12:01.883Z",
|
|
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.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.6",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.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.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.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.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.6",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|