@blamejs/core 0.14.12 → 0.14.14
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/README.md +2 -2
- package/index.js +3 -1
- package/lib/audit.js +1 -0
- package/lib/auth/oid4vp.js +47 -28
- package/lib/consent.js +104 -8
- package/lib/framework-error.js +5 -0
- package/lib/mail-srs.js +122 -19
- package/lib/privacy.js +168 -0
- package/lib/safe-archive.js +196 -136
- 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.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.14 (2026-05-31) — **Recognized consent purposes with lawful-basis gating, and a new b.privacy namespace for annual EdTech vendor-review attestations.** Closes the student-data gap where an educational-only consent purpose and an annual third-party vendor-review report were described but never implemented. b.consent gains a recognized-purpose vocabulary: a purpose value matching a recognized key carries lawful-basis constraints that grant() enforces, and the named educational-only purpose (FERPA's school-official exception and California's SOPIPA) refuses a legitimate_interests lawful basis. The new b.privacy namespace ships vendorReview(), a builder for the dated, clause-by-clause annual EdTech third-party / processor review FERPA and SOPIPA expect a school or district to keep — it computes whether every required clause (no targeted advertising, no commercial profiling, no sale of student data, deletion on request, school-official designation, and so on) is attested, names the gaps, and stamps a 365-day re-review clock. Free-form consent purposes keep working unchanged, so the vocabulary is opt-in and additive. **Added:** *`b.consent` recognized-purpose vocabulary + lawful-basis gating* — `b.consent.recognizedPurpose(name)` looks up a recognized purpose and `b.consent.listPurposes()` enumerates them. When a `grant({ purpose })` value matches a recognized key, `grant()` enforces that purpose's lawful-basis constraints; the `educational-only` purpose forbids a `legitimate_interests` basis (FERPA 34 CFR 99.31(a)(1) school-official exception; California SOPIPA Cal. B&P 22584; FTC school-authorized COPPA consent 16 CFR 312.5(c)(10)) and marks the data commercial-use-prohibited. The commercial-use prohibition is an operator trust-boundary obligation — `isGranted()` does not re-derive it. Any purpose value NOT in the vocabulary stays free-form and unconstrained, so existing callers are unaffected; the hash-chain column set is unchanged, so `b.consent.verify()` over existing rows is unaffected. · *`b.privacy.vendorReview` — annual EdTech vendor-review attestation* — A new `b.privacy` namespace whose `vendorReview(opts)` builds the dated third-party / processor review a FERPA school-official arrangement and California SOPIPA expect for every vendor that touches student data. The operator supplies a boolean attestation per clause (educational-purpose-only, no-targeted-advertising, no-commercial-profiling, no-sale-of-student-data, security-safeguards, deletion-on-request, sub-processor-currency, breach-notification, school-official-designation, directory-information-handling); `vendorReview` validates the shape, computes whether every required clause is attested (`attested`) and which are not (`gaps`), and stamps `reviewedAt` plus a 365-day `nextReviewDueAt` re-review clock. `b.privacy.listVendorReviewClauses()` returns the clause set with citations. Operator-feeds-metadata: the frozen report is not framework-persisted — compose it into your retention / audit / export sink. A best-effort `privacy.vendor_review.recorded` audit event fires when an audit sink is wired. **Detectors:** *A gated consent purpose must go through `b.consent`* — A new check flags any lib code that mints a consent row with a hardcoded `educational-only` purpose literal without composing the recognized-purpose vocabulary — which would record the value while never enforcing its FERPA / SOPIPA lawful-basis constraint.
|
|
12
|
+
|
|
13
|
+
- v0.14.13 (2026-05-31) — **Close advertised-but-missing surface: SRS1 chained forwarding, DCQL array-wildcard claim paths, and in-memory safe-archive extraction.** Three primitives advertised a capability in their documentation or card but refused or omitted it at runtime; this release implements each. b.mail.srs gains srs1Rewrite for the SRS1 double-forward (and multi-hop) case — previously the @intro described SRS1 and create() threw, pointing at a function that was never exported. b.safeArchive gains extractToMemory, the in-memory counterpart to extract for read-only / serverless filesystems — previously the card advertised in-memory extraction but the orchestrator required a destination directory. b.auth.oid4vp.matchDcql now honours a null claims-path segment as the array wildcard the OpenID4VP DCQL spec defines, rather than refusing it as unsupported while the card advertised DCQL. A stale version-pinned wording in a safe-archive error message is corrected. Every change is additive or message-only — no existing caller changes behaviour. **Added:** *`b.mail.srs` SRS1 chained forwarding — `srs1Rewrite`* — `b.mail.srs.create(...)` now returns `srs1Rewrite` alongside `rewrite` / `reverse`. `srs1Rewrite(srsAddress)` chains an already-SRS0 (or SRS1) envelope-from for a further forwarding hop: it keeps the original SRS0 body verbatim, prepends the SRS0 originator's domain, and binds the pair with this forwarder's own HMAC-SHA-256 tag — no new timestamp, no repeated original local-part — emitting `SRS1=tag=originator==<SRS0-body>@thisForwarder`. `reverse()` now detects an SRS1 address, verifies this hop's tag and forwarder-domain binding, and unwraps exactly one hop back to the originator's SRS0 so a multi-hop bounce routes straight to the forwarder that can recover the original sender. Typed failure modes: `srs/not-srs0` (input not SRS-encoded), `srs/malformed` (missing the `==` separator), `srs/bad-tag` (tampered), `srs/too-long` (chain exceeds the RFC 5321 256-octet path limit). Implements the Sender Rewriting Scheme SRS1 wire format; the second-hop SPF rationale is RFC 7208 §2.4. · *`b.safeArchive.extractToMemory` — in-memory safe extraction* — An async generator counterpart to `b.safeArchive.extract` for read-only / serverless filesystems: it resolves the source, sniffs the format, auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries()`, yielding `{ name, bytes, size }` per regular-file entry without ever writing to disk. It takes no `destination`. Every defense the disk path runs applies unchanged: the zip-bomb caps (entry-count / per-entry / total / expansion-ratio), the `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-escape / encrypted-entry refusal, CVE-2025-3445 class), and the entry-type policy. The disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU defense) is intentionally absent — there is no extraction root — so the archive-level name refusals carry containment. Trusted-stream sources are refused upfront (the adversarial-safe central-directory walk needs random access). gzip magic per RFC 1952 §2.3.1. **Fixed:** *OID4VP DCQL `null` claim-path segment now resolves the array wildcard* — `b.auth.oid4vp.matchDcql` previously threw `auth-oid4vp/null-path-segment-not-supported` for a `null` claims-path segment while the namespace card advertised DCQL — under-disclosing a legitimate presentation (CWE-863). Per OpenID4VP 1.0 §7.1.1 a `null` segment selects all elements of the array at that depth; the matcher now recurses over array elements with existence semantics (with DCQL value-matching applied to any selected leaf), composed to arbitrary depth. A `null` segment on a non-array node — like an integer index into a non-array, or a string key into an array — is a clean non-match, not a thrown error, because the matcher walks holder credential data rather than operator config. String and integer claim paths are byte-identical to before; only queries that previously threw now succeed or fail cleanly. · *safe-archive trusted-stream refusal message no longer cites a stale version* — The thrown `safe-archive/trusted-stream-unsupported` message and its comment claimed trusted-stream extraction was "deferred to v0.12.8 / when the v0.12.8 sequential extract path lands." That path shipped long ago — `b.archive.read.zip.fromTrustedStream` and the tar sequential mode exist — so the message now points at them as present capabilities and drops the version-pinned wording. The error code is unchanged. **Detectors:** *A primitive may not advertise a capability and then throw an unimplemented stub* — A new check flags a bare `not yet supported` / `operator demand TBD` / `not supported in v1` refusal in a lib throw string (comments excluded). A defer is only complete with a written re-open condition; the SRS1 and DCQL stubs that this release implements both carried this bare-defer shape, and the detector keeps it from re-entering. · *DCQL `null` path segments must recurse, never refuse* — A new check flags the `null path segment not supported` refusal shape in `lib/auth/oid4vp.js`, so the spec-mandated array wildcard cannot be re-stubbed. · *`extractToMemory` must stay disk-free* — A new check flags any `writeFileSync` / `renameSync` / `mkdirSync` / `createWriteStream` inside the `extractToMemory` generator body, so the read-only / serverless contract cannot regress into a disk write.
|
|
14
|
+
|
|
11
15
|
- v0.14.12 (2026-05-31) — **Vault key rotation re-seals AAD-bound storage under the new root instead of silently orphaning it.** Every AAD-sealed cell derives its key from the live vault root, so rotating the vault keypair changes those keys. `b.vaultRotate.rotate` previously re-sealed only legacy `vault:`-prefixed cells in `db.enc` and skipped `vault.aad:` cells, AAD-bound at-rest files, and operator-supplied AAD stores — leaving them encrypted under the retired keypair while still returning a success result and a passing round-trip verify, so the loss was invisible until the old keypair was discarded and the cells became permanently undecryptable. Rotation now re-seals `db.enc` (preserving its dataDir-bound AAD), `db.key.enc` (location-bound), every `{ aad: true }` table column, and the overflow store under the new root; refuses up front with a fail-closed error when operator-supplied AAD stores (agent idempotency / orchestrator / tenant / snapshot) are reachable unless each has been re-sealed via its module hook and explicitly acknowledged; and the round-trip verify now decrypts AAD-sealed cells under the new root and treats any cell that still opens under the old root as a regression. New explicit-root `b.vault.aad` seal / unseal / reseal primitives carry a cell from the old root to the new one while preserving its AAD tuple; `b.archive.rewrapTenant` re-wraps tenant-scoped archive envelopes; and `b.cluster` can adopt a rotated vault-key fingerprint instead of partitioning the membership during a rolling rotation. **Added:** *`b.vault.aad.sealRoot` / `unsealRoot` / `resealRoot`* — Explicit-root variants of the AAD seal / unseal that take a root-keypair JSON (`b.vault.getKeysJson()` output) instead of reading the live vault singleton. `resealRoot(value, aadParts, oldRootJson, newRootJson)` opens a cell under the old root and re-seals it under the new one while preserving the same AAD tuple (`table` / `rowId` / `column` / `schemaVersion`), which is what lets a rotation worker move AAD-bound state across a keypair change without altering the bound context. The default-root `b.vault.aad.seal` / `unseal` behaviour is unchanged. · *Per-store AAD re-seal hooks on the agent primitives* — `b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, and the `b.agent.tenant` registry / tenant-cell reseal paths re-seal that module's AAD-bound rows from an old root to a new root over the operator's own store. Each module also exposes an `AAD_ROTATION` descriptor naming the store the rotation pipeline cannot reach on its own, so an operator can enumerate exactly what to re-seal before a rotation. · *`b.archive.rewrapTenant`* — Re-wraps a `recipient: "tenant"` archive envelope from an old vault root to a new one for a given `tenantId`, so a keypair rotation does not strand tenant-scoped archives. Opens the blob under the old root + tenantId, refuses a blob that is not a tenant-recipient envelope or that does not open under the supplied old root, and emits a fresh envelope bound to the new root. This is offered alongside the documented re-export path (decrypt with the old keypair, re-archive with the new one) for operators who hold the envelope but not the source. · *Cluster vault-key rotation acceptance* — A vault-key rotation changes the public-key fingerprint recorded in the canonical cluster-state row, which a peer would otherwise report as `VAULT_KEY_DRIFT`. `b.cluster` configuration gains `acceptVaultKeyRotation: true` to declare the change legitimate — the node adopts the rotated fingerprint and bumps a rotation epoch instead of refusing — and an optional `expectedVaultKeyFp` that narrows acceptance to a single blessed post-rotation fingerprint. The drift guard stays in force whenever a rotation is not declared; supplying `expectedVaultKeyFp` without `acceptVaultKeyRotation` is rejected at configuration time as a misconfiguration. **Changed:** *`b.vaultRotate.rotate` refuses when reachable AAD stores are not acknowledged* — Because the rotation pipeline walks only `db.enc` and cannot introspect an operator's own AAD-backed stores, it now detects which AAD-store modules are loadable and throws `vault-rotate/external-aad-unresealed` unless `opts.externalAadResealed` is either `true` (you do not use those features) or an array naming every detected store (you have re-sealed each via its hook). This converts a path that previously discarded data and reported success into a fail-closed gate. The error names each store and the hook to call. **Fixed:** *Rotation re-seals `vault.aad:` cells and AAD-bound at-rest files* — `db.enc` is re-written bound to its dataDir-scoped AAD (it was previously re-written un-bound, silently stripping the at-rest AAD binding on every rotation), `db.key.enc` retains its location-bound AAD, and every `{ aad: true }` table column plus the overflow store is re-sealed under the new root. Previously only `vault:`-prefixed cells were carried across, so AAD-sealed data was left encrypted under the retired keypair and lost once it was discarded. · *Round-trip verify no longer reports a false success* — `b.vaultRotate.verify` now samples and decrypts AAD-sealed cells under the new root and treats any cell that still decrypts under the old root as a regression, so an incomplete rotation fails verification instead of passing it. The prior verify checked only `vault:` cells and therefore reported `ok` even when AAD-sealed cells had been orphaned. **Security:** *A vault key rotation can no longer silently destroy encrypted data* — The orphaning path lost agent idempotency / orchestrator / tenant / snapshot state, `{ aad: true }` columns, and tenant archives with no error and a passing verify; the data became unrecoverable the moment the old keypair was retired. Rotation is now fail-closed end to end: it re-seals what it can reach, refuses to proceed past what it cannot until you acknowledge it, and verifies the result under the new root. If you performed a rotation on v0.14.11 or earlier and still hold the retired keypair, re-seal the affected cells under the current root with the explicit-root primitives before discarding it. **Detectors:** *AAD-backed store modules must expose a rotation reseal path* — A new check flags a module that registers an external `{ aad: true }` store but does not expose an `AAD_ROTATION` descriptor and reseal hook, which would leave its state unreachable by the rotation pipeline. · *A root-keyed seal family must ship its reseal* — A new check flags adding a `sealRoot` / `unsealRoot` pair without the matching `resealRoot`, since without it a rotated cell cannot be carried from the old root to the new one. · *Live-root AAD seals need a reseal path* — A new check flags a primitive that AAD-seals under the live vault root without a way to re-seal that state under a new root during rotation. · *Tenant archive re-wrapping must compose `b.archive.rewrapTenant`* — A new check flags tenant-scoped archive re-wrapping that opens and re-seals a tenant envelope by hand instead of routing through `b.archive.rewrapTenant`. · *Cluster vault-key drift needs the rotation-epoch accept gate* — A new check flags a cluster vault-key fingerprint comparison that hard-rejects a mismatch without honouring the `acceptVaultKeyRotation` epoch window. **Migration:** *Re-seal operator AAD stores before rotating* — Before calling `b.vaultRotate.rotate`, re-seal each AAD-backed store you use via its hook (`b.agent.idempotency.reseal`, `b.agent.orchestrator.reseal`, `b.agent.snapshot.reseal`, the `b.agent.tenant` `AAD_ROTATION` reseal paths) with the old and new root JSON, re-wrap tenant archives with `b.archive.rewrapTenant`, then pass `opts.externalAadResealed` as an array naming each re-sealed store. If you use none of these features, pass `opts.externalAadResealed: true`. Declare the rotation to each cluster node with `acceptVaultKeyRotation: true` so the membership adopts the new fingerprint rather than reporting drift.
|
|
12
16
|
|
|
13
17
|
- v0.14.11 (2026-05-31) — **Defensive LLM model-I/O primitives, C2PA timestamp countersignatures with CAWG identity assertions, and signed EU AI Act GPAI adherence declarations.** Closes the output side of the LLM trust boundary and hardens content provenance and AI-Act attestation. b.ai.output.sanitize treats model output as untrusted and neutralizes XSS, gates every markdown-image / link and HTML src/href URL against SSRF (the EchoLeak zero-click exfiltration class, CVE-2025-32711), and flags SQL- and command-shaped fragments; b.ai.output.redact strips PII and secret disclosures. b.ai.input.classifyWithSources classifies a prompt together with its retrieval-augmented sources under a stricter, trust-tier-relative threshold, and the new b.ai.prompt namespace assembles prompts with escape-by-default boundaries — untrusted context / user segments are fenced in a per-render crypto-nonce delimiter the content cannot forge and stripped of bidi, control, zero-width, and Unicode-Tags smuggling characters. b.contentCredentials COSE signatures now carry an RFC 3161 timestamp countersignature (C2PA sigTst2, RFC 9921) verified entirely through b.tsa, so a signed manifest stays verifiable after its signing certificate expires, plus a CAWG identity assertion with trust-anchored verification. b.compliance.aiAct.gpai.declareAdherence emits a tamper-evident, ML-DSA-87-signed GPAI Code-of-Practice adherence declaration whose obligation set is derived from the regulation rather than operator-asserted. **Added:** *`b.ai.output.sanitize` and `b.ai.output.redact`* — A new `b.ai.output` namespace that treats LLM output as untrusted before it reaches a browser, a downstream fetcher, a SQL / command sink, or a log. `sanitize(text, opts)` neutralizes active markup via `b.guardHtml`, gates every markdown image / link and HTML `src` / `href` URL through `b.safeUrl.parse` (scheme + credential) and `b.ssrfGuard.classify` (internal / loopback / link-local / cloud-metadata IP-range) so auto-fetch URLs to attacker or internal hosts are neutralized, and flags SQL- and command-shaped fragments rather than silently repairing them. `redact(text, opts)` strips PII and secret disclosures via `b.redact` plus an entity-selectable pass (`pan` / `ssn` / `ein` / `iban` / `jwt` / `aws` / `phi` / `email` / `phone`). Defends OWASP LLM05:2025 Improper Output Handling and LLM02:2025 Sensitive Information Disclosure; the markdown-image URL gate closes the EchoLeak zero-click exfiltration class (CVE-2025-32711, CVSS 9.3). · *`b.ai.input.classifyWithSources`* — Classifies a prompt together with its retrieval-augmented (RAG) sources, applying a stricter, trust-tier-relative threshold to retrieved data. Each source is `{ id, text, trust? }` with `trust` of `trusted` / `internal` / `untrusted` (unset defaults to `untrusted`, fail-closed); untrusted and internal sources escalate to `suspicious` on a single severity-2 signal and to `malicious` on any severity-3, where the direct prompt keeps the baseline threshold. The aggregate verdict is the worst across the prompt and all sources, and every malicious source is reported in `taintedSources`. Defends indirect prompt injection from poisoned context (OWASP LLM01:2025; NIST AI 600-1). · *`b.ai.prompt.template`* — A new `b.ai.prompt` namespace for assembling LLM prompts with escape-by-default boundaries. The `system` segment is operator-trusted; `context` and `user` segments are treated as untrusted (no global opt-out — mark a segment `{ text, trusted: true }` individually). Untrusted segments are wrapped in a per-render, high-entropy delimiter nonce the content cannot forge, with any forged boundary stripped before wrapping (spotlighting / datamarking, Microsoft 2024; NIST AI 100-2e2025), and stripped of bidi overrides (CVE-2021-42574 Trojan Source), C0 controls, zero-width characters, null bytes, and Unicode Tags (U+E0000..U+E007F ASCII-smuggling). Run `b.ai.input.refuseIfMalicious` on the untrusted content as defense in depth. · *C2PA RFC 3161 timestamp countersignature and CAWG identity assertion* — `b.contentCredentials.signCose` attaches an RFC 3161 timestamp countersignature (C2PA `sigTst2`, RFC 9921) and `b.contentCredentials.verifyCose` verifies it. Pass `timestamp:{ token }` to embed a TimeStampToken, or `timestamp:{}` to get back the DER `application/timestamp-query` to POST to a timestamp authority. `b.contentCredentials.attachIdentityAssertion` / `verifyIdentityAssertion` add the CAWG Identity Assertion v1.2: a signed creator / organization identity hash-bound to a manifest's referenced assertions, where the `x509` binding reports `verified:true` only when an identity trust anchor is supplied and the leaf chain verifies, and the `identity-claims-aggregator` and self-asserted paths stay `verified:false`. · *`b.compliance.aiAct.gpai.declareAdherence` / `verifyAdherence`* — Signed, tamper-evident GPAI Code-of-Practice adherence declarations (Regulation (EU) 2024/1689 Art. 53(1)(a-d); Art. 55 for systemic-risk models under Art. 51(2)). The in-scope obligation set is derived from the classifier, never operator-asserted — a model at or above the 10^25-FLOP systemic-risk threshold that omits the Art. 55 chapter is refused. Each commitment's evidence reference must be a SHA3-512 digest; a malformed hash is rejected so a hollow attestation cannot bind. The declaration ships inside an ML-DSA-87-signed CycloneDX 1.6 ML-BOM via `b.ai.modelManifest`; verify re-canonicalizes before trusting any field and rejects a declaration past its validity window. Cites the GPAI Code of Practice (10 July 2025), Annex XI/XII, and Directive (EU) 2019/790 Art. 4(3). **Security:** *Model output is now an untrusted channel by default* — When feeding retrieved documents into an LLM, classify them with `b.ai.input.classifyWithSources` (untrusted sources escalate on a single signal) rather than trusting model input; assemble prompts with `b.ai.prompt.template` so untrusted context / user text is fenced in a per-render crypto-nonce boundary it cannot forge; and pass model output through `b.ai.output.sanitize` / `b.ai.output.redact` before it is rendered, fetched, or logged. Each primitive is on by default and fail-closed — no opt-in flag enables the protection. · *Timestamp verification routes only through `b.tsa.verifyToken`* — C2PA `sigTst2` verification performs the full RFC 3161 check (CMS signature over the signed attributes, messageDigest recompute, critical sole `id-kp-timeStamping` EKU) — never a chain-only shortcut — closing the timestamp-validation-bypass class (CVE-2025-52556, CWE-347). Supply `timestampTrustAnchorsPem` to `verifyCose` to check the timestamp certificate chain; `verifyCose` returns `{ valid, reason, claims, alg, timestamp }` and never throws. **Detectors:** *LLM output URLs must keep the SSRF gate* — A new check requires the output sanitizer to gate every extracted URL through both `b.safeUrl.parse` and `b.ssrfGuard.classify`, so the markdown-image SSRF gate (the EchoLeak class) cannot be silently dropped. · *RAG sources must compose `classifyWithSources`* — A new check flags any code that maps `b.ai.input.classify` over a sources array by hand, which would lose the trust-tier-relative threshold for retrieved data. · *Prompt boundaries must use a per-render nonce* — A new check flags prompt-assembly that wraps untrusted content in a fixed, guessable literal fence (`<user_input>`, `[DATA]`) instead of a per-render high-entropy delimiter the content cannot forge. · *C2PA timestamp verification must route through `b.tsa`* — A new check flags any bespoke certificate-chain-only walk on a timestamp token in place of `b.tsa.verifyToken`, preventing a re-introduction of the timestamp-validation-bypass class. · *GPAI adherence declarations must be signed* — A new check flags any code that emits the GPAI Code-of-Practice adherence property without routing it through the `b.ai.modelManifest` signed envelope, keeping the declaration tamper-evident.
|
package/README.md
CHANGED
|
@@ -212,7 +212,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
212
212
|
- **Change control + WORM** — m-of-n approver DDL change-control with maintenance-window + ML-DSA-87 signed proposals (`b.ddlChangeControl`); row-level WORM triggers boot-asserted under `sec-17a-4` / `finra-4511` / `fda-21cfr11` (`b.db.declareWorm`); dual-control physical delete + crypto-erase + REINDEX in one transaction (`b.db.declareRequireDualControl`, `b.db.eraseHard`)
|
|
213
213
|
- **Consumer-protection** — FTC click-to-cancel UX-parity attestation (`ftc-2024` / `ca-sb942` / `strict`) (`b.darkPatterns`)
|
|
214
214
|
- **Differential privacy** — float-safe DP for aggregate releases: snapping-mechanism Laplace (Mironov 2012) + discrete Gaussian (Canonne–Kamath–Steinke 2020), CSPRNG noise, per-scope ε/δ budgets with basic + Rényi-DP accounting; defends the floating-point distinguishing attack that breaks naive Laplace samplers (NIST SP 800-226) (`b.ai.dp`)
|
|
215
|
-
- **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2 consent-string parse + encode + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`)
|
|
215
|
+
- **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2 consent-string parse + encode + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`); educational-only consent purpose with FERPA / SOPIPA lawful-basis gating + annual EdTech third-party vendor-review attestation (`b.consent.recognizedPurpose`, `b.privacy.vendorReview`)
|
|
216
216
|
- **Incident reporters** — EU DORA Article 17 ICT-incident workflow per Commission Delegated Regulation 2024/1772 (`b.dora`); EU NIS2 (`b.nis2`); EU Cyber Resilience Act SBOM + secure-software-attestation (`b.cra`); SEC Form 8-K Item 1.05 cybersecurity-incident materiality-disclosure (`b.secCyber`); incident lifecycle coordinator (`b.incident`)
|
|
217
217
|
- **Outbound DLP** — interceptor-installed on httpClient + mail + webhook with built-in detectors for PAN (Luhn), SSN, EIN, IBAN (mod-97), api-key shapes, PEM, SSH private keys, JWTs, AWS access keys, PHI composite; refuse / redact / audit-only verdicts under pci-dss / hipaa / fapi2 / soc2 / gdpr presets (`b.redact.installOutboundDlp`)
|
|
218
218
|
### Observability
|
|
@@ -230,7 +230,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
230
230
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
|
|
231
231
|
- **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
|
|
232
232
|
- **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
|
|
233
|
-
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`); recipient-sealed envelopes — hybrid-PQC key-pair, peer certificate, or per-tenant key with no key-pair to manage (`b.archive.wrap({ recipient: "tenant", tenantId })`)
|
|
233
|
+
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); one-liner in-memory extraction with no disk write for read-only / serverless filesystems (`b.safeArchive.extractToMemory`, or the low-level `b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`); recipient-sealed envelopes — hybrid-PQC key-pair, peer certificate, or per-tenant key with no key-pair to manage (`b.archive.wrap({ recipient: "tenant", tenantId })`)
|
|
234
234
|
- **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
|
|
235
235
|
|
|
236
236
|
### Production
|
package/index.js
CHANGED
|
@@ -26,7 +26,7 @@ _tls.DEFAULT_MIN_VERSION = "TLSv1.3";
|
|
|
26
26
|
* frameworkSchema, clusterStorage, session, atomicFile,
|
|
27
27
|
* cookies
|
|
28
28
|
* Audit: audit, auditChain, auditSign, auditTools, consent,
|
|
29
|
-
* subject, events, redact
|
|
29
|
+
* subject, events, redact, privacy
|
|
30
30
|
* HTTP: router, middleware (csrf, cors, rate-limit, request-id,
|
|
31
31
|
* security-headers, bot-guard, attach-user, require-auth,
|
|
32
32
|
* error-handler, body-parser, csp-nonce, compression,
|
|
@@ -88,6 +88,7 @@ audit.export = function (opts) {
|
|
|
88
88
|
};
|
|
89
89
|
var auditChain = require("./lib/audit-chain");
|
|
90
90
|
var consent = require("./lib/consent");
|
|
91
|
+
var privacy = require("./lib/privacy");
|
|
91
92
|
var subject = require("./lib/subject");
|
|
92
93
|
var session = require("./lib/session");
|
|
93
94
|
var storage = require("./lib/storage");
|
|
@@ -459,6 +460,7 @@ module.exports = {
|
|
|
459
460
|
auditTools: auditTools,
|
|
460
461
|
events: events,
|
|
461
462
|
consent: consent,
|
|
463
|
+
privacy: privacy,
|
|
462
464
|
subject: subject,
|
|
463
465
|
session: session,
|
|
464
466
|
storage: storage,
|
package/lib/audit.js
CHANGED
|
@@ -270,6 +270,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
270
270
|
"flag", // b.flag (flag.evaluated / flag.evaluation.error / flag.cache.bust)
|
|
271
271
|
"permissions", // b.permissions
|
|
272
272
|
"pqcagent", // b.pqcAgent (pqcagent.operator_group.accepted)
|
|
273
|
+
"privacy", // b.privacy (privacy.vendor_review.recorded)
|
|
273
274
|
"restore", // b.restore
|
|
274
275
|
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
|
275
276
|
"scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
|
package/lib/auth/oid4vp.js
CHANGED
|
@@ -148,40 +148,52 @@ function _validateDcql(dcql) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
* Walk
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
151
|
+
* Walk a DCQL claims path pointer (OpenID4VP 1.0 §7.1.1) against the
|
|
152
|
+
* resolved-claim object the SD-JWT VC verifier produced, applying
|
|
153
|
+
* `leafPredicate` at the terminal node and returning a boolean:
|
|
154
|
+
* string → object property (["address", "country"])
|
|
155
|
+
* integer → array index (["array", 0])
|
|
156
|
+
* null → all elements of the array at this depth (recurse; the
|
|
157
|
+
* match is an existence check over the candidate leaves)
|
|
158
|
+
* A `null` segment on a non-array node — like an integer index into a
|
|
159
|
+
* non-array or a string key into an array — is a NON-MATCH, not an
|
|
160
|
+
* error: this walks holder credential data, not operator config, so a
|
|
161
|
+
* structural mismatch fails the match cleanly (rule §5 defensive
|
|
162
|
+
* request-shape reader tier) rather than throwing and crashing the
|
|
163
|
+
* verify request.
|
|
158
164
|
*/
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (node === undefined || node === null) return { found: false, value: undefined };
|
|
171
|
-
node = node[seg];
|
|
165
|
+
function _walkPath(node, path, idx, leafPredicate) {
|
|
166
|
+
if (idx === path.length) return leafPredicate(node);
|
|
167
|
+
if (node === undefined || node === null) return false;
|
|
168
|
+
var seg = path[idx];
|
|
169
|
+
if (seg === null) {
|
|
170
|
+
if (!Array.isArray(node)) return false;
|
|
171
|
+
return node.some(function (el) { return _walkPath(el, path, idx + 1, leafPredicate); });
|
|
172
|
+
}
|
|
173
|
+
if (typeof seg === "number") {
|
|
174
|
+
if (!Array.isArray(node)) return false;
|
|
175
|
+
return _walkPath(node[seg], path, idx + 1, leafPredicate);
|
|
172
176
|
}
|
|
173
|
-
|
|
177
|
+
// string segment selects an object property; a string key into an
|
|
178
|
+
// array is a non-match (use a null wildcard or integer index instead).
|
|
179
|
+
if (Array.isArray(node)) return false;
|
|
180
|
+
return _walkPath(node[seg], path, idx + 1, leafPredicate);
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
function _matchClaim(claims, claimQuery) {
|
|
177
|
-
var
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
var values = claimQuery.values;
|
|
185
|
+
var leafPredicate;
|
|
186
|
+
if (values && values.length > 0) {
|
|
187
|
+
leafPredicate = function (leaf) {
|
|
188
|
+
if (leaf === undefined) return false;
|
|
189
|
+
return values.some(function (v) {
|
|
190
|
+
return v === leaf || JSON.stringify(v) === JSON.stringify(leaf);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
} else {
|
|
194
|
+
leafPredicate = function (leaf) { return leaf !== undefined; };
|
|
183
195
|
}
|
|
184
|
-
return
|
|
196
|
+
return _walkPath(claims, claimQuery.path, 0, leafPredicate);
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
function _matchCredentialQuery(presentation, query) {
|
|
@@ -219,6 +231,13 @@ function _matchCredentialQuery(presentation, query) {
|
|
|
219
231
|
* implement their own verifier transport call this directly after
|
|
220
232
|
* SD-JWT VC verification.
|
|
221
233
|
*
|
|
234
|
+
* Claim path pointers follow OpenID4VP 1.0 §7.1.1: a string segment
|
|
235
|
+
* selects an object property, a non-negative integer indexes an array,
|
|
236
|
+
* and a `null` segment matches any element of the array at that depth
|
|
237
|
+
* (e.g. `["degrees", null, "type"]` matches the `type` claim of any
|
|
238
|
+
* element in the `degrees` array). A `null` segment applied to a
|
|
239
|
+
* non-array node is a non-match.
|
|
240
|
+
*
|
|
222
241
|
* @example
|
|
223
242
|
* var match = b.auth.oid4vp.matchDcql([
|
|
224
243
|
* { id: "id-card", format: "vc+sd-jwt", claims: { vct: "...", given_name: "Alice" } }
|
package/lib/consent.js
CHANGED
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
* enforcement at the trust boundary is the app's call (typical shape:
|
|
27
27
|
* `if (!b.consent.isGranted({ subjectId, purpose })) return 403`).
|
|
28
28
|
*
|
|
29
|
+
* `purpose` is free-form, but values matching the recognized-purpose
|
|
30
|
+
* vocabulary (`b.consent.recognizedPurpose` / `listPurposes`) carry
|
|
31
|
+
* lawful-basis constraints `grant()` enforces — e.g. `educational-only`
|
|
32
|
+
* (FERPA school-official exception / California SOPIPA) refuses a
|
|
33
|
+
* `legitimate_interests` basis.
|
|
34
|
+
*
|
|
29
35
|
* Cluster mode keeps `_blamejs_consent_tip` current with a fenced
|
|
30
36
|
* `INSERT … ON CONFLICT DO UPDATE … WHERE fencingToken <= EXCLUDED`
|
|
31
37
|
* so a partitioned old leader cannot rewrite the tip even if its
|
|
@@ -52,6 +58,31 @@ var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
|
|
|
52
58
|
var LAWFUL_BASES = ["consent", "contract", "legal_obligation", "vital_interests", "public_task", "legitimate_interests"];
|
|
53
59
|
var ACTIONS = ["granted", "withdrawn", "expired", "superseded"];
|
|
54
60
|
|
|
61
|
+
// Recognized consent purposes. A purpose value matching a key here carries
|
|
62
|
+
// lawful-basis constraints that grant() enforces; any other (free-form)
|
|
63
|
+
// purpose string stays valid and unconstrained, so the vocabulary is opt-in
|
|
64
|
+
// and never breaks operators passing their own purpose names. FERPA's
|
|
65
|
+
// school-official exception + California SOPIPA make "educational-only" the
|
|
66
|
+
// canonical constrained purpose.
|
|
67
|
+
//
|
|
68
|
+
// Null-prototype map: the purpose value is operator-controlled, so a plain
|
|
69
|
+
// object would let a free-form purpose colliding with an Object.prototype
|
|
70
|
+
// member ("toString" / "constructor" / "__proto__") resolve to the prototype
|
|
71
|
+
// value instead of undefined — recognizedPurpose() would return a function
|
|
72
|
+
// and grant() would enter the recognized branch for a value listPurposes()
|
|
73
|
+
// never exposes. A null prototype makes every unrecognized key resolve to
|
|
74
|
+
// undefined (CWE-1321 defense).
|
|
75
|
+
var PURPOSES = Object.freeze(Object.assign(Object.create(null), {
|
|
76
|
+
"educational-only": Object.freeze({
|
|
77
|
+
purpose: "educational-only",
|
|
78
|
+
forbidsLawfulBasis: Object.freeze(["legitimate_interests"]),
|
|
79
|
+
commercialUseProhibited: true,
|
|
80
|
+
dataMinimization: true,
|
|
81
|
+
citation: "FERPA 34 CFR 99.31(a)(1) school-official exception; Cal. SB 1177 SOPIPA 22584(b)(1)-(4); 16 CFR 312.5(c)(10) FTC school-authorized COPPA consent",
|
|
82
|
+
notes: "K-12 / school-official use only; no targeted advertising, no commercial profiling, no sale. Lawful basis must be school authorization (consent / public_task / legal_obligation), not legitimate_interests. The commercial-use prohibition is an operator trust-boundary obligation — isGranted() does not re-derive it.",
|
|
83
|
+
}),
|
|
84
|
+
}));
|
|
85
|
+
|
|
55
86
|
var HASHABLE_COLS = [
|
|
56
87
|
"_id", "recordedAt", "monotonicCounter",
|
|
57
88
|
"subjectId", "subjectIdHash",
|
|
@@ -122,6 +153,22 @@ function grant(opts) {
|
|
|
122
153
|
if (LAWFUL_BASES.indexOf(opts.lawfulBasis) === -1) {
|
|
123
154
|
throw new Error("invalid lawfulBasis: '" + opts.lawfulBasis + "' (must be one of " + LAWFUL_BASES.join(", ") + ")");
|
|
124
155
|
}
|
|
156
|
+
// Recognized-purpose vocabulary (opt-in): when the purpose matches a
|
|
157
|
+
// PURPOSES key, enforce its lawful-basis constraints. Free-form purposes
|
|
158
|
+
// remain unconstrained, so operators passing their own purpose names are
|
|
159
|
+
// unaffected.
|
|
160
|
+
var recognized = PURPOSES[opts.purpose];
|
|
161
|
+
if (recognized) {
|
|
162
|
+
if (recognized.forbidsLawfulBasis && recognized.forbidsLawfulBasis.indexOf(opts.lawfulBasis) !== -1) {
|
|
163
|
+
throw new Error("consent.grant: purpose '" + opts.purpose + "' forbids lawfulBasis '" +
|
|
164
|
+
opts.lawfulBasis + "' (" + recognized.citation + ")");
|
|
165
|
+
}
|
|
166
|
+
if (recognized.requiresLawfulBasis && recognized.requiresLawfulBasis.length > 0 &&
|
|
167
|
+
recognized.requiresLawfulBasis.indexOf(opts.lawfulBasis) === -1) {
|
|
168
|
+
throw new Error("consent.grant: purpose '" + opts.purpose + "' requires lawfulBasis in [" +
|
|
169
|
+
recognized.requiresLawfulBasis.join(", ") + "] (" + recognized.citation + ")");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
125
172
|
return _appendConsentRow({
|
|
126
173
|
subjectId: opts.subjectId,
|
|
127
174
|
purpose: opts.purpose,
|
|
@@ -133,6 +180,52 @@ function grant(opts) {
|
|
|
133
180
|
});
|
|
134
181
|
}
|
|
135
182
|
|
|
183
|
+
/**
|
|
184
|
+
* @primitive b.consent.recognizedPurpose
|
|
185
|
+
* @signature b.consent.recognizedPurpose(name)
|
|
186
|
+
* @since 0.14.14
|
|
187
|
+
* @status stable
|
|
188
|
+
* @compliance ferpa, ca-sopipa, coppa, gdpr
|
|
189
|
+
* @related b.consent.grant, b.consent.listPurposes, b.consent.isGranted
|
|
190
|
+
*
|
|
191
|
+
* Look up a recognized consent purpose by value. Recognized purposes carry
|
|
192
|
+
* lawful-basis constraints that `grant()` enforces; the `educational-only`
|
|
193
|
+
* purpose (FERPA school-official exception / SOPIPA) forbids a
|
|
194
|
+
* `legitimate_interests` basis and marks the data commercial-use-prohibited.
|
|
195
|
+
* That commercial-use prohibition is an operator trust-boundary obligation —
|
|
196
|
+
* `isGranted()` does not re-derive it. Returns the frozen entry, or `null`
|
|
197
|
+
* for a free-form purpose (which remains valid for `grant()`).
|
|
198
|
+
*
|
|
199
|
+
* @opts
|
|
200
|
+
* name: string, // a purpose value, e.g. "educational-only"
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* b.consent.recognizedPurpose("educational-only");
|
|
204
|
+
* // → { purpose: "educational-only", forbidsLawfulBasis: ["legitimate_interests"], ... }
|
|
205
|
+
* b.consent.recognizedPurpose("marketing"); // → null (free-form)
|
|
206
|
+
*/
|
|
207
|
+
function recognizedPurpose(name) {
|
|
208
|
+
return PURPOSES[name] || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @primitive b.consent.listPurposes
|
|
213
|
+
* @signature b.consent.listPurposes()
|
|
214
|
+
* @since 0.14.14
|
|
215
|
+
* @status stable
|
|
216
|
+
* @related b.consent.recognizedPurpose, b.consent.grant
|
|
217
|
+
*
|
|
218
|
+
* Return the recognized-purpose values as a frozen array. Free-form
|
|
219
|
+
* purposes are not listed — they remain valid for `grant()` but carry no
|
|
220
|
+
* lawful-basis constraint.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* b.consent.listPurposes(); // → ["educational-only"]
|
|
224
|
+
*/
|
|
225
|
+
function listPurposes() {
|
|
226
|
+
return Object.freeze(Object.keys(PURPOSES));
|
|
227
|
+
}
|
|
228
|
+
|
|
136
229
|
/**
|
|
137
230
|
* @primitive b.consent.withdraw
|
|
138
231
|
* @signature b.consent.withdraw(opts)
|
|
@@ -358,12 +451,15 @@ function _resetForTest() {
|
|
|
358
451
|
}
|
|
359
452
|
|
|
360
453
|
module.exports = {
|
|
361
|
-
grant:
|
|
362
|
-
withdraw:
|
|
363
|
-
isGranted:
|
|
364
|
-
history:
|
|
365
|
-
verify:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
454
|
+
grant: grant,
|
|
455
|
+
withdraw: withdraw,
|
|
456
|
+
isGranted: isGranted,
|
|
457
|
+
history: history,
|
|
458
|
+
verify: verify,
|
|
459
|
+
recognizedPurpose: recognizedPurpose,
|
|
460
|
+
listPurposes: listPurposes,
|
|
461
|
+
LAWFUL_BASES: LAWFUL_BASES,
|
|
462
|
+
PURPOSES: PURPOSES,
|
|
463
|
+
ACTIONS: ACTIONS,
|
|
464
|
+
_resetForTest: _resetForTest,
|
|
369
465
|
};
|
package/lib/framework-error.js
CHANGED
|
@@ -370,6 +370,10 @@ var DoraError = defineClass("DoraError", { alwaysPermane
|
|
|
370
370
|
// posture name, runtime-switch refusal, assertion failures.
|
|
371
371
|
// Permanent — these are configuration errors, not transient.
|
|
372
372
|
var ComplianceError = defineClass("ComplianceError", { alwaysPermanent: true });
|
|
373
|
+
// PrivacyError covers b.privacy config-time misuse: a malformed
|
|
374
|
+
// vendorReview opts object, a non-boolean clause attestation, or an
|
|
375
|
+
// unknown clause key. Permanent — operator configuration, not transient.
|
|
376
|
+
var PrivacyError = defineClass("PrivacyError", { alwaysPermanent: true });
|
|
373
377
|
// SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
|
|
374
378
|
// shape, fetch failures, TLSA-record format errors, missing records.
|
|
375
379
|
// Permanent — these are policy / DNS configuration errors, not
|
|
@@ -698,6 +702,7 @@ module.exports = {
|
|
|
698
702
|
GuardAuthError: GuardAuthError,
|
|
699
703
|
DoraError: DoraError,
|
|
700
704
|
ComplianceError: ComplianceError,
|
|
705
|
+
PrivacyError: PrivacyError,
|
|
701
706
|
SmtpPolicyError: SmtpPolicyError,
|
|
702
707
|
MailAuthError: MailAuthError,
|
|
703
708
|
MailArfError: MailArfError,
|
package/lib/mail-srs.js
CHANGED
|
@@ -28,15 +28,24 @@
|
|
|
28
28
|
* - `local` is the original sender's local-part
|
|
29
29
|
* - `forwarder.example` is the rewriting forwarder's domain
|
|
30
30
|
*
|
|
31
|
-
* SRS1
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Wire format (SRS1 — the multi-hop chain case):
|
|
32
|
+
*
|
|
33
|
+
* SRS1=HHH=priorForwarder==<SRS0-body>@thisForwarder
|
|
34
|
+
*
|
|
35
|
+
* When an already-SRS0 (or SRS1) address is forwarded again,
|
|
36
|
+
* `srs1Rewrite(srsAddress)` wraps it: it keeps the original SRS0
|
|
37
|
+
* body verbatim, prepends the preceding forwarder's domain, and
|
|
38
|
+
* binds the pair with this forwarder's own HMAC tag — no new
|
|
39
|
+
* timestamp, no repeated original local-part. `reverse()` detects
|
|
40
|
+
* SRS1, verifies this hop's tag, and unwraps exactly one hop back to
|
|
41
|
+
* the prior forwarder's SRS0 address so the bounce re-routes to it.
|
|
35
42
|
*
|
|
36
43
|
* `b.mail.srs.create({ secret, forwarderDomain })` returns
|
|
37
|
-
* `{ rewrite, reverse }`. `rewrite(originalSender)`
|
|
38
|
-
*
|
|
39
|
-
*
|
|
44
|
+
* `{ rewrite, srs1Rewrite, reverse }`. `rewrite(originalSender)`
|
|
45
|
+
* produces the SRS0 address; `srs1Rewrite(srsAddress)` chains a
|
|
46
|
+
* further hop as SRS1; `reverse(srsAddress)` decodes an SRS0 back to
|
|
47
|
+
* the original sender (verifying HMAC + expiry) or unwraps an SRS1
|
|
48
|
+
* one hop back to the prior forwarder.
|
|
40
49
|
*
|
|
41
50
|
* @card
|
|
42
51
|
* SRS Sender Rewriting Scheme — forwarder envelope-from rewriting with HMAC-bound day-rotated tags so the next-hop SPF check passes and bounces route correctly back to the original sender.
|
|
@@ -94,6 +103,34 @@ function _dayDiff(stamp, nowMs) {
|
|
|
94
103
|
return diff;
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
// Parse an SRS1 local-part "SRS1=<tag>=<priorForwarder>==<srs0Body>"
|
|
107
|
+
// into its three fields. The 4-char base32 tag and the prior-forwarder
|
|
108
|
+
// domain both carry no "=", so the FIRST "=" ends the tag and the FIRST
|
|
109
|
+
// "==" (which can only fall immediately after the "="-free prior-forwarder
|
|
110
|
+
// domain) ends the prior forwarder — even when the inner SRS0 body carries
|
|
111
|
+
// its own single "=" separators.
|
|
112
|
+
function _parseSrs1(localPart) {
|
|
113
|
+
var rest = localPart.slice(5); // strip "SRS1="
|
|
114
|
+
var firstEq = rest.indexOf("=");
|
|
115
|
+
if (firstEq <= 0) {
|
|
116
|
+
throw new SrsError("srs/malformed",
|
|
117
|
+
"srs.reverse: SRS1 must be SRS1=tag=priorForwarder==<srs0body>");
|
|
118
|
+
}
|
|
119
|
+
var tag = rest.slice(0, firstEq);
|
|
120
|
+
var afterTag = rest.slice(firstEq + 1);
|
|
121
|
+
var sep = afterTag.indexOf("==");
|
|
122
|
+
if (sep <= 0) {
|
|
123
|
+
throw new SrsError("srs/malformed",
|
|
124
|
+
"srs.reverse: SRS1 missing the '==' prior-forwarder separator");
|
|
125
|
+
}
|
|
126
|
+
var srs0Body = afterTag.slice(sep + 2);
|
|
127
|
+
if (!srs0Body) {
|
|
128
|
+
throw new SrsError("srs/malformed",
|
|
129
|
+
"srs.reverse: SRS1 carries an empty inner SRS0 body");
|
|
130
|
+
}
|
|
131
|
+
return { tag: tag, priorForwarder: afterTag.slice(0, sep), srs0Body: srs0Body };
|
|
132
|
+
}
|
|
133
|
+
|
|
97
134
|
/**
|
|
98
135
|
* @primitive b.mail.srs.create
|
|
99
136
|
* @signature b.mail.srs.create(opts)
|
|
@@ -101,7 +138,11 @@ function _dayDiff(stamp, nowMs) {
|
|
|
101
138
|
* @status stable
|
|
102
139
|
*
|
|
103
140
|
* Build an SRS rewriter bound to the operator's forwarder domain +
|
|
104
|
-
* HMAC signing secret. Returns `{ rewrite, reverse }
|
|
141
|
+
* HMAC signing secret. Returns `{ rewrite, srs1Rewrite, reverse }` —
|
|
142
|
+
* `rewrite` produces an SRS0 origin address, `srs1Rewrite` chains an
|
|
143
|
+
* already-SRS0/SRS1 address as SRS1 for a further forwarding hop, and
|
|
144
|
+
* `reverse` decodes either form (SRS0 → original sender with HMAC +
|
|
145
|
+
* expiry checks; SRS1 → the prior forwarder's address, one hop back).
|
|
105
146
|
*
|
|
106
147
|
* @opts
|
|
107
148
|
* secret: string, // operator's HMAC-SHA-256 signing secret (>=32 bytes recommended)
|
|
@@ -121,6 +162,11 @@ function _dayDiff(stamp, nowMs) {
|
|
|
121
162
|
* // Bounce arrives back at SRS0=...; decode to deliver
|
|
122
163
|
* var original = srs.reverse(rewritten);
|
|
123
164
|
* // → "alice@bob.com"
|
|
165
|
+
*
|
|
166
|
+
* // A further forwarding hop chains the already-SRS0 address as SRS1
|
|
167
|
+
* var hop2 = srs.srs1Rewrite(rewritten);
|
|
168
|
+
* // → "SRS1=HHHH=forwarder.example==HHHH=TT=bob.com=alice@forwarder.example"
|
|
169
|
+
* srs.reverse(hop2); // → the prior-hop SRS0 address, re-routed one hop back
|
|
124
170
|
*/
|
|
125
171
|
function create(opts) {
|
|
126
172
|
if (!opts || typeof opts !== "object") {
|
|
@@ -158,13 +204,12 @@ function create(opts) {
|
|
|
158
204
|
throw new SrsError("srs/bad-address",
|
|
159
205
|
"srs.rewrite: localPart / domain exceeds RFC 5321 length cap");
|
|
160
206
|
}
|
|
161
|
-
// Refuse SRS double-encoding from this primitive —
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// deployments and adds substantial spec surface).
|
|
207
|
+
// Refuse SRS double-encoding from this primitive — already-SRS0 (or
|
|
208
|
+
// SRS1) inputs chain through srs1Rewrite(), which keeps the original
|
|
209
|
+
// SRS0 body verbatim rather than re-stamping it as a fresh origin.
|
|
165
210
|
if (/^SRS[01]=/i.test(localPart)) {
|
|
166
211
|
throw new SrsError("srs/already-rewritten",
|
|
167
|
-
"srs.rewrite: address already SRS-encoded;
|
|
212
|
+
"srs.rewrite: address already SRS-encoded; use srs1Rewrite() to chain a further forwarding hop");
|
|
168
213
|
}
|
|
169
214
|
var now = typeof nowMs === "number" ? nowMs : Date.now();
|
|
170
215
|
var ts = _dayStamp(now);
|
|
@@ -173,6 +218,49 @@ function create(opts) {
|
|
|
173
218
|
return "SRS0=" + tag + "=" + ts + "=" + domain + "=" + localPart + "@" + forwarderDomain;
|
|
174
219
|
}
|
|
175
220
|
|
|
221
|
+
function srs1Rewrite(srsAddress) {
|
|
222
|
+
validateOpts.requireNonEmptyString(
|
|
223
|
+
srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
|
|
224
|
+
var at = srsAddress.lastIndexOf("@");
|
|
225
|
+
if (at <= 0 || at === srsAddress.length - 1) {
|
|
226
|
+
throw new SrsError("srs/bad-address",
|
|
227
|
+
"srs.srs1Rewrite: address must be in localPart@domain form");
|
|
228
|
+
}
|
|
229
|
+
var localPart = srsAddress.slice(0, at);
|
|
230
|
+
// The SRS0 body is kept verbatim across the whole chain (the SRS1
|
|
231
|
+
// optimization: no new timestamp, no repeated original local-part).
|
|
232
|
+
// `priorForwarder` is the domain the bounce must ultimately reach to
|
|
233
|
+
// recover the original sender — i.e. the forwarder that MINTED the
|
|
234
|
+
// inner SRS0. From an SRS0 input that is its own @domain; from an
|
|
235
|
+
// SRS1 input (a third or later hop) it is the originator already
|
|
236
|
+
// recorded in the SRS1, NOT the immediately-preceding forwarder, so
|
|
237
|
+
// every hop's bounce routes straight back to the SRS0 originator.
|
|
238
|
+
var priorForwarder, srs0Body;
|
|
239
|
+
if (/^SRS0=/i.test(localPart)) {
|
|
240
|
+
priorForwarder = srsAddress.slice(at + 1);
|
|
241
|
+
srs0Body = localPart.slice(5);
|
|
242
|
+
} else if (/^SRS1=/i.test(localPart)) {
|
|
243
|
+
var inner = _parseSrs1(localPart);
|
|
244
|
+
priorForwarder = inner.priorForwarder;
|
|
245
|
+
srs0Body = inner.srs0Body;
|
|
246
|
+
} else {
|
|
247
|
+
throw new SrsError("srs/not-srs0",
|
|
248
|
+
"srs.srs1Rewrite: input must be an SRS0 or SRS1 address (use rewrite() for a plain address)");
|
|
249
|
+
}
|
|
250
|
+
if (!priorForwarder || priorForwarder.indexOf("=") !== -1) {
|
|
251
|
+
throw new SrsError("srs/bad-address",
|
|
252
|
+
"srs.srs1Rewrite: prior forwarder domain must be a non-empty domain without '=' (would corrupt SRS1 field parsing)");
|
|
253
|
+
}
|
|
254
|
+
var opaque = priorForwarder + "==" + srs0Body;
|
|
255
|
+
var tag = _hashTag(secret, opaque);
|
|
256
|
+
var result = "SRS1=" + tag + "=" + priorForwarder + "==" + srs0Body + "@" + forwarderDomain;
|
|
257
|
+
if (result.length > 256) { // RFC 5321 §4.5.3.1.3 path-length cap
|
|
258
|
+
throw new SrsError("srs/too-long",
|
|
259
|
+
"srs.srs1Rewrite: rewritten address exceeds the RFC 5321 256-octet path limit (forwarding chain too deep)");
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
176
264
|
function reverse(srsAddress, nowMs) {
|
|
177
265
|
validateOpts.requireNonEmptyString(
|
|
178
266
|
srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
|
|
@@ -183,13 +271,15 @@ function create(opts) {
|
|
|
183
271
|
}
|
|
184
272
|
var localPart = srsAddress.slice(0, at);
|
|
185
273
|
var rcptDomain = srsAddress.slice(at + 1);
|
|
186
|
-
// Allow case-insensitive SRS0
|
|
187
|
-
// FIRST so an obviously-non-
|
|
274
|
+
// Allow case-insensitive SRS0 / SRS1 prefixes per the spec. Check
|
|
275
|
+
// this FIRST so an obviously-non-SRS input (`plain@example.com`)
|
|
188
276
|
// gets the specific not-srs0 verdict instead of the more general
|
|
189
277
|
// wrong-forwarder verdict.
|
|
190
|
-
|
|
278
|
+
var isSrs0 = /^SRS0=/i.test(localPart);
|
|
279
|
+
var isSrs1 = /^SRS1=/i.test(localPart);
|
|
280
|
+
if (!isSrs0 && !isSrs1) {
|
|
191
281
|
throw new SrsError("srs/not-srs0",
|
|
192
|
-
"srs.reverse: address local-part does not start with SRS0=");
|
|
282
|
+
"srs.reverse: address local-part does not start with SRS0= or SRS1=");
|
|
193
283
|
}
|
|
194
284
|
// Domain binding — the rewriter is scoped to a specific forwarder
|
|
195
285
|
// domain, and reverse() must verify the bounce arrived at THAT
|
|
@@ -203,6 +293,18 @@ function create(opts) {
|
|
|
203
293
|
"srs.reverse: bounce addressed to '" + rcptDomain + "' but rewriter " +
|
|
204
294
|
"is bound to forwarderDomain '" + forwarderDomain + "'");
|
|
205
295
|
}
|
|
296
|
+
if (isSrs1) {
|
|
297
|
+
var s1 = _parseSrs1(localPart);
|
|
298
|
+
if (!_timingSafeStringEqual(s1.tag, _hashTag(secret, s1.priorForwarder + "==" + s1.srs0Body))) {
|
|
299
|
+
throw new SrsError("srs/bad-tag",
|
|
300
|
+
"srs.reverse: SRS1 HMAC tag does not verify (wrong secret or tampered envelope-from)");
|
|
301
|
+
}
|
|
302
|
+
// Unwrap exactly one hop: re-address the bounce to the prior
|
|
303
|
+
// forwarder's SRS0. That forwarder owns the inner SRS0's tag +
|
|
304
|
+
// expiry, so we do NOT re-check them here — per the SRS spec each
|
|
305
|
+
// hop verifies only its OWN hash.
|
|
306
|
+
return "SRS0=" + s1.srs0Body + "@" + s1.priorForwarder;
|
|
307
|
+
}
|
|
206
308
|
var rest = localPart.slice(5);
|
|
207
309
|
var parts = rest.split("=");
|
|
208
310
|
if (parts.length < 4) {
|
|
@@ -231,8 +333,9 @@ function create(opts) {
|
|
|
231
333
|
}
|
|
232
334
|
|
|
233
335
|
return Object.freeze({
|
|
234
|
-
rewrite:
|
|
235
|
-
|
|
336
|
+
rewrite: rewrite,
|
|
337
|
+
srs1Rewrite: srs1Rewrite,
|
|
338
|
+
reverse: reverse,
|
|
236
339
|
forwarderDomain: forwarderDomain,
|
|
237
340
|
});
|
|
238
341
|
}
|
package/lib/privacy.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.privacy
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title Privacy
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Privacy-program operational helpers. The first primitive,
|
|
9
|
+
* `vendorReview`, builds the annual third-party / EdTech vendor-review
|
|
10
|
+
* attestation that FERPA's school-official exception and California's
|
|
11
|
+
* SOPIPA expect a school or district to keep on file for every
|
|
12
|
+
* processor that touches student data: a dated, clause-by-clause
|
|
13
|
+
* record that the vendor uses the data only for the authorized
|
|
14
|
+
* educational purpose, runs no targeted advertising or commercial
|
|
15
|
+
* profiling, sells nothing, keeps reasonable security safeguards,
|
|
16
|
+
* deletes on request, and so on.
|
|
17
|
+
*
|
|
18
|
+
* The builder follows the operator-feeds-metadata pattern: the
|
|
19
|
+
* operator supplies the vendor's attested answers and `vendorReview`
|
|
20
|
+
* returns a frozen report — `{ attested, gaps, reviewedAt,
|
|
21
|
+
* nextReviewDueAt, ... }` — that composes into the operator's own
|
|
22
|
+
* retention / audit / export sink. It is not framework-persisted.
|
|
23
|
+
*
|
|
24
|
+
* @card
|
|
25
|
+
* Privacy-program helpers — annual FERPA / SOPIPA EdTech vendor-review attestation reports (`vendorReview`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var lazyRequire = require("./lazy-require");
|
|
29
|
+
var validateOpts = require("./validate-opts");
|
|
30
|
+
var C = require("./constants");
|
|
31
|
+
var { PrivacyError } = require("./framework-error");
|
|
32
|
+
|
|
33
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
34
|
+
|
|
35
|
+
// The clause set a FERPA school-official / SOPIPA vendor review attests.
|
|
36
|
+
// Each entry: { id, required, citation, description }. Every `required`
|
|
37
|
+
// clause must be attested true for the review to pass (attested:true).
|
|
38
|
+
var VENDOR_REVIEW_CLAUSES = Object.freeze([
|
|
39
|
+
Object.freeze({ id: "educationalPurposeOnly", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor uses student data only for the authorized educational purpose under direct school control; no redisclosure." }),
|
|
40
|
+
Object.freeze({ id: "noTargetedAdvertising", required: true, citation: "SOPIPA Cal. B&P 22584(b)(1)", description: "No targeted advertising to students based on covered information." }),
|
|
41
|
+
Object.freeze({ id: "noCommercialProfiling", required: true, citation: "SOPIPA Cal. B&P 22584(b)(2)", description: "No amassing of a student profile except in furtherance of K-12 purposes." }),
|
|
42
|
+
Object.freeze({ id: "noSaleOfStudentData", required: true, citation: "SOPIPA Cal. B&P 22584(b)(3)", description: "No sale or rental of student information." }),
|
|
43
|
+
Object.freeze({ id: "securitySafeguards", required: true, citation: "SOPIPA Cal. B&P 22584(d)(1)", description: "Reasonable security procedures and practices appropriate to the data's sensitivity." }),
|
|
44
|
+
Object.freeze({ id: "deletionOnRequest", required: true, citation: "SOPIPA Cal. B&P 22584(d)(2)", description: "Deletes student PII within a reasonable time at the school's or district's request." }),
|
|
45
|
+
Object.freeze({ id: "subProcessorsCurrent", required: true, citation: "FERPA 34 CFR 99.33 (redisclosure)", description: "Sub-processor list is current and each is bound to the same restrictions." }),
|
|
46
|
+
Object.freeze({ id: "breachNotification", required: true, citation: "FERPA 34 CFR 99.31(a)(1) control + state breach law", description: "Notifies the school / district of any security breach without undue delay." }),
|
|
47
|
+
Object.freeze({ id: "schoolOfficialDesignation", required: true, citation: "FERPA 34 CFR 99.31(a)(1)(i)(B)", description: "Vendor is designated a school official with a legitimate educational interest." }),
|
|
48
|
+
Object.freeze({ id: "directoryInformationHandling", required: false, citation: "FERPA 34 CFR 99.37", description: "Handles directory information per the school's opt-out notice (only when applicable)." }),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
var CLAUSE_IDS = VENDOR_REVIEW_CLAUSES.map(function (c) { return c.id; });
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @primitive b.privacy.vendorReview
|
|
55
|
+
* @signature b.privacy.vendorReview(opts)
|
|
56
|
+
* @since 0.14.14
|
|
57
|
+
* @status stable
|
|
58
|
+
* @compliance ferpa, ca-sopipa, coppa
|
|
59
|
+
* @related b.consent.recognizedPurpose, b.compliance.describe, b.retention
|
|
60
|
+
*
|
|
61
|
+
* Build a dated annual third-party / EdTech vendor-review attestation —
|
|
62
|
+
* the record a FERPA school-official arrangement and California SOPIPA
|
|
63
|
+
* expect a school or district to keep for every processor of student
|
|
64
|
+
* data. The operator supplies the vendor's attested answer (a boolean)
|
|
65
|
+
* per clause; `vendorReview` validates the shape, computes whether every
|
|
66
|
+
* REQUIRED clause is attested (`attested`) and which are not (`gaps`),
|
|
67
|
+
* and stamps the review date plus a 365-day `nextReviewDueAt` re-review
|
|
68
|
+
* clock. Operator-feeds-metadata: the returned report is frozen and is
|
|
69
|
+
* NOT framework-persisted — compose it into your retention / audit /
|
|
70
|
+
* export sink. A best-effort `privacy.vendor_review.recorded` audit event
|
|
71
|
+
* fires when an audit sink is wired.
|
|
72
|
+
*
|
|
73
|
+
* @opts
|
|
74
|
+
* vendorName: string, // required — the processor under review
|
|
75
|
+
* reviewedAt: number, // required — epoch ms of this review
|
|
76
|
+
* clauses: { <clauseId>: boolean }, // attested answer per clause (see listVendorReviewClauses)
|
|
77
|
+
* reviewer: string, // optional — who performed the review
|
|
78
|
+
* notes: string, // optional — free-text reviewer notes
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* var report = b.privacy.vendorReview({
|
|
82
|
+
* vendorName: "Acme LMS",
|
|
83
|
+
* reviewedAt: Date.now(),
|
|
84
|
+
* clauses: {
|
|
85
|
+
* educationalPurposeOnly: true, noTargetedAdvertising: true,
|
|
86
|
+
* noCommercialProfiling: true, noSaleOfStudentData: true,
|
|
87
|
+
* securitySafeguards: true, deletionOnRequest: true,
|
|
88
|
+
* subProcessorsCurrent: true, breachNotification: true,
|
|
89
|
+
* schoolOfficialDesignation: true,
|
|
90
|
+
* },
|
|
91
|
+
* });
|
|
92
|
+
* // → { vendorName, reviewedAt, nextReviewDueAt, attested: true, gaps: [], clauses: {...} }
|
|
93
|
+
*/
|
|
94
|
+
function vendorReview(opts) {
|
|
95
|
+
validateOpts.requireObject(opts, "b.privacy.vendorReview: opts", PrivacyError, "privacy/bad-opts");
|
|
96
|
+
validateOpts.requireNonEmptyString(opts.vendorName, "b.privacy.vendorReview: opts.vendorName", PrivacyError, "privacy/bad-vendor");
|
|
97
|
+
if (typeof opts.reviewedAt !== "number" || !isFinite(opts.reviewedAt) || opts.reviewedAt <= 0) {
|
|
98
|
+
throw new PrivacyError("privacy/bad-reviewed-at",
|
|
99
|
+
"b.privacy.vendorReview: opts.reviewedAt must be a positive epoch-ms number");
|
|
100
|
+
}
|
|
101
|
+
var clauses = opts.clauses || {};
|
|
102
|
+
validateOpts.requireObject(clauses, "b.privacy.vendorReview: opts.clauses", PrivacyError, "privacy/bad-clauses");
|
|
103
|
+
// Reject unknown clause keys — a misspelled clause would otherwise
|
|
104
|
+
// silently never gate.
|
|
105
|
+
Object.keys(clauses).forEach(function (k) {
|
|
106
|
+
if (CLAUSE_IDS.indexOf(k) === -1) {
|
|
107
|
+
throw new PrivacyError("privacy/unknown-clause",
|
|
108
|
+
"b.privacy.vendorReview: unknown clause '" + k + "' (see b.privacy.listVendorReviewClauses())");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
var resolved = {};
|
|
112
|
+
var gaps = [];
|
|
113
|
+
VENDOR_REVIEW_CLAUSES.forEach(function (clause) {
|
|
114
|
+
var v = clauses[clause.id];
|
|
115
|
+
// A supplied clause answer must be a boolean (config-time THROW); an
|
|
116
|
+
// omitted one defaults to not-attested.
|
|
117
|
+
validateOpts.optionalBoolean(v, "b.privacy.vendorReview: clauses." + clause.id, PrivacyError, "privacy/bad-clause-value");
|
|
118
|
+
var attestedTrue = v === true;
|
|
119
|
+
resolved[clause.id] = attestedTrue;
|
|
120
|
+
if (clause.required && !attestedTrue) gaps.push(clause.id);
|
|
121
|
+
});
|
|
122
|
+
var attested = gaps.length === 0;
|
|
123
|
+
var report = Object.freeze({
|
|
124
|
+
vendorName: opts.vendorName,
|
|
125
|
+
reviewedAt: opts.reviewedAt,
|
|
126
|
+
nextReviewDueAt: opts.reviewedAt + C.TIME.days(365),
|
|
127
|
+
coversPeriod: Object.freeze({ from: opts.reviewedAt - C.TIME.days(365), to: opts.reviewedAt }),
|
|
128
|
+
reviewer: opts.reviewer || null,
|
|
129
|
+
notes: opts.notes || null,
|
|
130
|
+
attested: attested,
|
|
131
|
+
gaps: Object.freeze(gaps),
|
|
132
|
+
clauses: Object.freeze(resolved),
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
audit().safeEmit({
|
|
136
|
+
action: "privacy.vendor_review.recorded",
|
|
137
|
+
outcome: attested ? "success" : "denied",
|
|
138
|
+
metadata: { vendorName: opts.vendorName, attested: attested, gaps: gaps, reviewedAt: opts.reviewedAt },
|
|
139
|
+
});
|
|
140
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @primitive b.privacy.listVendorReviewClauses
|
|
146
|
+
* @signature b.privacy.listVendorReviewClauses()
|
|
147
|
+
* @since 0.14.14
|
|
148
|
+
* @status stable
|
|
149
|
+
* @related b.privacy.vendorReview
|
|
150
|
+
*
|
|
151
|
+
* Return the frozen FERPA / SOPIPA vendor-review clause set — each entry
|
|
152
|
+
* is `{ id, required, citation, description }`. Use it to render a review
|
|
153
|
+
* form or to enumerate the clauses `vendorReview` evaluates.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* b.privacy.listVendorReviewClauses().map(function (c) { return c.id; });
|
|
157
|
+
* // → ["educationalPurposeOnly", "noTargetedAdvertising", ...]
|
|
158
|
+
*/
|
|
159
|
+
function listVendorReviewClauses() {
|
|
160
|
+
return VENDOR_REVIEW_CLAUSES;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
vendorReview: vendorReview,
|
|
165
|
+
listVendorReviewClauses: listVendorReviewClauses,
|
|
166
|
+
VENDOR_REVIEW_CLAUSES: VENDOR_REVIEW_CLAUSES,
|
|
167
|
+
PrivacyError: PrivacyError,
|
|
168
|
+
};
|
package/lib/safe-archive.js
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* Format auto-detection sniffs the first ~512 bytes for magic
|
|
25
25
|
* signatures: ZIP (LFH magic `0x04034b50` + EOCD magic `0x06054b50`),
|
|
26
26
|
* tar (`ustar` at offset 257), gzip / tar.gz (RFC 1952 magic), and
|
|
27
|
-
* `b.
|
|
28
|
-
* format detection). Unrecognized
|
|
29
|
-
* `safe-archive/format-unsupported`.
|
|
27
|
+
* `b.archive.wrap` recipient (`BAWRP`) / passphrase (`BAWPP`)
|
|
28
|
+
* envelopes (auto-unwrapped before format detection). Unrecognized
|
|
29
|
+
* inputs are flagged `safe-archive/format-unsupported`.
|
|
30
30
|
*
|
|
31
31
|
* The orchestrator refuses the WHOLE archive on any single critical
|
|
32
32
|
* guard issue — no partial extraction. Cleanup is `fs.rm`-recursive
|
|
@@ -58,13 +58,10 @@ var MAGIC_ZIP_LFH = 0x04034b50;
|
|
|
58
58
|
var MAGIC_ZIP_EOCD = 0x06054b50;
|
|
59
59
|
// GZIP magic per RFC 1952 §2.3.1.
|
|
60
60
|
var MAGIC_GZIP_BE = 0x1f8b;
|
|
61
|
-
// b.crypto.encryptPacked envelope magic — the prefix the framework's
|
|
62
|
-
// PQ envelope writes. (Sentinel value for v0.12.10+ Flavor 1 unwrap.)
|
|
63
|
-
var MAGIC_ENCPACKED = "EPACK";
|
|
64
61
|
|
|
65
62
|
async function _sniffMagic(adapter) {
|
|
66
63
|
// For random-access adapters, the format sniffer reads the first
|
|
67
|
-
// 512 bytes — enough for ZIP + GZIP +
|
|
64
|
+
// 512 bytes — enough for ZIP + GZIP + wrap-envelope magic
|
|
68
65
|
// detection. tar magic lives at offset 257 inside the first 512-
|
|
69
66
|
// byte header block, so we need at least 263 bytes; 512 covers it.
|
|
70
67
|
if (adapter.kind !== "random-access") {
|
|
@@ -91,13 +88,13 @@ async function _sniffMagic(adapter) {
|
|
|
91
88
|
var be2 = head.readUInt16BE(0);
|
|
92
89
|
if (be2 === MAGIC_GZIP_BE) return { format: "gzip" };
|
|
93
90
|
}
|
|
94
|
-
//
|
|
91
|
+
// archive-wrap envelopes — 5-byte ASCII prefix. BAWRP (recipient) and
|
|
92
|
+
// BAWPP (passphrase) are the only wrap envelopes the framework produces
|
|
93
|
+
// (b.archive.wrap / b.archive.wrapWithPassphrase) and the only ones
|
|
94
|
+
// b.archive.sniffEnvelope recognizes.
|
|
95
95
|
if (head.length >= 5) {
|
|
96
96
|
var prefix = head.slice(0, 5).toString("utf8");
|
|
97
|
-
if (prefix === MAGIC_ENCPACKED) return { format: "encryptPacked" };
|
|
98
|
-
// v0.12.15 — archive-wrap recipient envelope (v0.12.10 / BAWRP).
|
|
99
97
|
if (prefix === "BAWRP") return { format: "wrap-recipient" };
|
|
100
|
-
// v0.12.15 — archive-wrap passphrase envelope (v0.12.11 / BAWPP).
|
|
101
98
|
if (prefix === "BAWPP") return { format: "wrap-passphrase" };
|
|
102
99
|
}
|
|
103
100
|
// tar — "ustar" at offset 257 within the first 512-byte header.
|
|
@@ -124,6 +121,82 @@ async function _collectSourceBytes(source) {
|
|
|
124
121
|
return source.range(0, size);
|
|
125
122
|
}
|
|
126
123
|
|
|
124
|
+
// Shared source→adapter resolution + envelope auto-unwrap for the three
|
|
125
|
+
// orchestrator entry points (extract / extractToMemory / inspect). Returns
|
|
126
|
+
// { source, format } — `source` is a random-access adapter positioned at the
|
|
127
|
+
// (possibly unwrapped) archive and `format` is the sniffed inner format. The
|
|
128
|
+
// CALLER owns closing the returned source in its own `finally`; this helper
|
|
129
|
+
// performs the pre-unwrap fd-close-before-replace + the signal-forward-to-
|
|
130
|
+
// inner-adapter discipline internally, and closes a string-opened descriptor
|
|
131
|
+
// if it throws mid-resolve so a sniff/unwrap failure can't leak it.
|
|
132
|
+
async function _resolveAndUnwrap(opts, label, refuseTrustedStream) {
|
|
133
|
+
var openedFromString = typeof opts.source === "string";
|
|
134
|
+
var source = opts.source;
|
|
135
|
+
if (openedFromString) {
|
|
136
|
+
source = archiveAdapters().fs(source, { signal: opts.signal });
|
|
137
|
+
} else if (Buffer.isBuffer(source)) {
|
|
138
|
+
source = archiveAdapters().buffer(source, { signal: opts.signal });
|
|
139
|
+
} else if (refuseTrustedStream && archiveAdapters().isTrustedStreamAdapter(source)) {
|
|
140
|
+
// Trusted-stream adapters satisfy the adapter contract, but the
|
|
141
|
+
// orchestrator's adversarial-safe central-directory walk + LFH/CD skew
|
|
142
|
+
// defense needs random access. Refuse upfront with a typed error so the
|
|
143
|
+
// operator sees the constraint at the entry point rather than a
|
|
144
|
+
// downstream `archive-read/wrong-entry-point`.
|
|
145
|
+
throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
|
|
146
|
+
label + ": trusted-stream adapter sources are not supported by the orchestrator " +
|
|
147
|
+
"(the adversarial-safe central-directory walk requires random access). Collect the " +
|
|
148
|
+
"bytes into a buffer adapter — `b.archive.adapters.buffer(await collect(readable))` — " +
|
|
149
|
+
"and pass that, or read with `b.archive.read.zip.fromTrustedStream` directly.");
|
|
150
|
+
} else if (!archiveAdapters().isRandomAccessAdapter(source)) {
|
|
151
|
+
throw new SafeArchiveError("safe-archive/bad-source",
|
|
152
|
+
label + ": opts.source must be a string path, Buffer, or b.archive.adapters.* result");
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
var format = opts.format || "auto";
|
|
156
|
+
if (format === "auto") {
|
|
157
|
+
format = (await _sniffMagic(source)).format;
|
|
158
|
+
}
|
|
159
|
+
// Auto-unwrap path: when the sniffer identifies a wrap envelope, unwrap
|
|
160
|
+
// inline + re-sniff the inner bytes so operators get a single call
|
|
161
|
+
// regardless of envelope shape. Operator supplies opts.recipient or
|
|
162
|
+
// opts.passphrase matching the envelope kind.
|
|
163
|
+
if (format === "wrap-recipient" || format === "wrap-passphrase") {
|
|
164
|
+
var sealedBytes = await _collectSourceBytes(source);
|
|
165
|
+
var inner;
|
|
166
|
+
if (format === "wrap-recipient") {
|
|
167
|
+
if (!opts.recipient) {
|
|
168
|
+
throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
|
|
169
|
+
label + ": source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
|
|
170
|
+
"Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
|
|
171
|
+
}
|
|
172
|
+
inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
|
|
173
|
+
} else {
|
|
174
|
+
if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
|
|
175
|
+
throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
|
|
176
|
+
label + ": source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
|
|
177
|
+
"Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
|
|
178
|
+
}
|
|
179
|
+
inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
|
|
180
|
+
}
|
|
181
|
+
// Close the original string-opened descriptor BEFORE replacing the
|
|
182
|
+
// source reference (overwriting it would leak the fd across repeated
|
|
183
|
+
// calls → EMFILE under load), then forward opts.signal to the inner
|
|
184
|
+
// buffer adapter so abort propagation survives the unwrap boundary.
|
|
185
|
+
if (typeof source.close === "function" && openedFromString) {
|
|
186
|
+
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
187
|
+
}
|
|
188
|
+
source = archiveAdapters().buffer(inner, { signal: opts.signal });
|
|
189
|
+
format = (await _sniffMagic(source)).format;
|
|
190
|
+
}
|
|
191
|
+
return { source: source, format: format };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (typeof source.close === "function" && openedFromString) {
|
|
194
|
+
try { source.close(); } catch (_e2) { /* drop-silent */ }
|
|
195
|
+
}
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
127
200
|
// ---- Public extract orchestrator ----------------------------------------
|
|
128
201
|
|
|
129
202
|
/**
|
|
@@ -168,84 +241,10 @@ async function extract(opts) {
|
|
|
168
241
|
opts = opts || {};
|
|
169
242
|
validateOpts.requireNonEmptyString(opts.destination,
|
|
170
243
|
"b.safeArchive.extract: opts.destination", SafeArchiveError, "safe-archive/no-destination");
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
var source = opts.source;
|
|
175
|
-
if (typeof source === "string") {
|
|
176
|
-
source = archiveAdapters().fs(source, { signal: opts.signal });
|
|
177
|
-
} else if (Buffer.isBuffer(source)) {
|
|
178
|
-
source = archiveAdapters().buffer(source, { signal: opts.signal });
|
|
179
|
-
} else if (archiveAdapters().isTrustedStreamAdapter(source)) {
|
|
180
|
-
// Trusted-stream adapters are accepted by the contract but the
|
|
181
|
-
// orchestrator's extract path needs random-access (CD-walk +
|
|
182
|
-
// LFH/CD skew defense). Refuse upfront with a typed safe-archive
|
|
183
|
-
// error so the operator sees the constraint at the entry point
|
|
184
|
-
// rather than an `archive-read/wrong-entry-point` thrown by the
|
|
185
|
-
// downstream reader. Trusted-stream extract via
|
|
186
|
-
// `b.archive.read.zip.fromTrustedStream` is deferred to v0.12.8
|
|
187
|
-
// alongside the tar reader's sequential mode.
|
|
188
|
-
throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
|
|
189
|
-
"extract: trusted-stream adapter sources are not supported by the orchestrator " +
|
|
190
|
-
"(the adversarial-safe CD-walk requires random-access). Collect the bytes via " +
|
|
191
|
-
"`b.archive.adapters.buffer(await collect(readable))` and pass that, or use " +
|
|
192
|
-
"`b.archive.read.zip.fromTrustedStream` directly when the v0.12.8 sequential " +
|
|
193
|
-
"extract path lands");
|
|
194
|
-
} else if (!archiveAdapters().isRandomAccessAdapter(source)) {
|
|
195
|
-
throw new SafeArchiveError("safe-archive/bad-source",
|
|
196
|
-
"extract: opts.source must be a string path, Buffer, or b.archive.adapters.* result");
|
|
197
|
-
}
|
|
198
|
-
|
|
244
|
+
var resolved = await _resolveAndUnwrap(opts, "extract", true);
|
|
245
|
+
var source = resolved.source;
|
|
246
|
+
var format = resolved.format;
|
|
199
247
|
try {
|
|
200
|
-
var format = opts.format || "auto";
|
|
201
|
-
if (format === "auto") {
|
|
202
|
-
var sniff = await _sniffMagic(source);
|
|
203
|
-
format = sniff.format;
|
|
204
|
-
}
|
|
205
|
-
// v0.12.15 — auto-unwrap path. When the sniffer identifies a
|
|
206
|
-
// wrap envelope, unwrap inline + re-sniff the inner bytes so
|
|
207
|
-
// operators get a single extract() call regardless of envelope
|
|
208
|
-
// shape. Operator must supply opts.recipient or opts.passphrase
|
|
209
|
-
// matching the envelope kind.
|
|
210
|
-
if (format === "wrap-recipient" || format === "wrap-passphrase") {
|
|
211
|
-
var sealedBytes = await _collectSourceBytes(source);
|
|
212
|
-
var inner;
|
|
213
|
-
if (format === "wrap-recipient") {
|
|
214
|
-
if (!opts.recipient) {
|
|
215
|
-
throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
|
|
216
|
-
"extract: source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
|
|
217
|
-
"Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
|
|
218
|
-
}
|
|
219
|
-
inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
|
|
220
|
-
} else {
|
|
221
|
-
if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
|
|
222
|
-
throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
|
|
223
|
-
"extract: source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
|
|
224
|
-
"Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
|
|
225
|
-
}
|
|
226
|
-
inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
|
|
227
|
-
}
|
|
228
|
-
// Close the original source
|
|
229
|
-
// adapter BEFORE replacing it. When opts.source was a string
|
|
230
|
-
// path, the fs adapter opened a file descriptor; overwriting
|
|
231
|
-
// `source` loses the close reference and the descriptor
|
|
232
|
-
// leaks across repeated extract() calls (eventually EMFILE
|
|
233
|
-
// under load). The outer finally still closes whatever
|
|
234
|
-
// `source` points at, but the original handle needs explicit
|
|
235
|
-
// release here.
|
|
236
|
-
if (typeof source.close === "function" && typeof opts.source === "string") {
|
|
237
|
-
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
238
|
-
}
|
|
239
|
-
// Forward opts.signal to the
|
|
240
|
-
// inner buffer adapter so abort propagation stays intact
|
|
241
|
-
// across the unwrap boundary. Without it, an abort raised
|
|
242
|
-
// after unwrapping would no longer cancel inner range()
|
|
243
|
-
// calls, breaking the documented signal contract for
|
|
244
|
-
// large wrapped archives.
|
|
245
|
-
source = archiveAdapters().buffer(inner, { signal: opts.signal });
|
|
246
|
-
var innerSniff = await _sniffMagic(source);
|
|
247
|
-
format = innerSniff.format;
|
|
248
|
-
}
|
|
249
248
|
var reader;
|
|
250
249
|
if (format === "zip") {
|
|
251
250
|
reader = archiveRead().zip(source, {
|
|
@@ -277,7 +276,7 @@ async function extract(opts) {
|
|
|
277
276
|
} else {
|
|
278
277
|
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
279
278
|
"extract: format=" + JSON.stringify(format) + " — supported formats are " +
|
|
280
|
-
"zip, tar, tar.gz; b.
|
|
279
|
+
"zip, tar, tar.gz; b.archive.wrap recipient/passphrase envelopes are auto-unwrapped first");
|
|
281
280
|
}
|
|
282
281
|
var result = await reader.extract({
|
|
283
282
|
destination: opts.destination,
|
|
@@ -291,6 +290,109 @@ async function extract(opts) {
|
|
|
291
290
|
}
|
|
292
291
|
}
|
|
293
292
|
|
|
293
|
+
/**
|
|
294
|
+
* @primitive b.safeArchive.extractToMemory
|
|
295
|
+
* @signature b.safeArchive.extractToMemory(opts)
|
|
296
|
+
* @since 0.14.13
|
|
297
|
+
* @status stable
|
|
298
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
299
|
+
* @related b.safeArchive.extract, b.archive.read.zip, b.archive.read.tar
|
|
300
|
+
*
|
|
301
|
+
* In-memory counterpart to `b.safeArchive.extract` for read-only /
|
|
302
|
+
* serverless filesystems. Resolves the source, sniffs the format,
|
|
303
|
+
* auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and
|
|
304
|
+
* dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries`
|
|
305
|
+
* — an async generator that yields each regular file entry's decompressed
|
|
306
|
+
* bytes without ever writing to disk. Takes no `destination`; the caller
|
|
307
|
+
* owns where, if anywhere, the bytes land.
|
|
308
|
+
*
|
|
309
|
+
* Every defense the disk `extract` runs applies unchanged: the zip-bomb
|
|
310
|
+
* caps (entry-count / per-entry / total / expansion-ratio), the
|
|
311
|
+
* `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-
|
|
312
|
+
* escape / encrypted-entry refusal — CVE-2025-3445 class), and the
|
|
313
|
+
* entry-type policy. Directory entries carry no bytes and are skipped. The
|
|
314
|
+
* disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU
|
|
315
|
+
* defense) is intentionally absent — there is no extraction root — so the
|
|
316
|
+
* archive-level name refusals carry the containment guarantee here.
|
|
317
|
+
*
|
|
318
|
+
* Trusted-stream adapter sources are refused upfront: the adversarial-safe
|
|
319
|
+
* central-directory walk requires random access. Collect the bytes into a
|
|
320
|
+
* buffer adapter, or read with `b.archive.read.zip.fromTrustedStream`
|
|
321
|
+
* directly.
|
|
322
|
+
*
|
|
323
|
+
* @opts
|
|
324
|
+
* source: b.archive.adapters.* | Buffer | string,
|
|
325
|
+
* format: "auto" | "zip" | "tar" | "tar.gz",
|
|
326
|
+
* bombPolicy: b.guardArchive.zipBombPolicy(...) | { ... },
|
|
327
|
+
* entryTypePolicy: b.guardArchive.entryTypePolicy(...) | { ... },
|
|
328
|
+
* guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
|
|
329
|
+
* recipient: { privateKey, ecPrivateKey }, // for BAWRP envelopes
|
|
330
|
+
* passphrase: string | Buffer, // for BAWPP envelopes
|
|
331
|
+
* audit: b.audit,
|
|
332
|
+
* signal: AbortSignal,
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* for await (var entry of b.safeArchive.extractToMemory({
|
|
336
|
+
* source: b.archive.adapters.fs("/var/uploads/payload.zip"),
|
|
337
|
+
* guardProfile: "strict",
|
|
338
|
+
* })) {
|
|
339
|
+
* // entry → { name, bytes, size } — never touches disk
|
|
340
|
+
* await store.put(entry.name, entry.bytes);
|
|
341
|
+
* }
|
|
342
|
+
*/
|
|
343
|
+
async function* extractToMemory(opts) {
|
|
344
|
+
opts = opts || {};
|
|
345
|
+
var resolved = await _resolveAndUnwrap(opts, "extractToMemory", true);
|
|
346
|
+
var source = resolved.source;
|
|
347
|
+
var format = resolved.format;
|
|
348
|
+
var extractOpts = { allowDangerous: opts.allowDangerous, allowEncrypted: opts.allowEncrypted };
|
|
349
|
+
try {
|
|
350
|
+
if (format === "zip") {
|
|
351
|
+
var zr = archiveRead().zip(source, {
|
|
352
|
+
bombPolicy: opts.bombPolicy,
|
|
353
|
+
entryTypePolicy: opts.entryTypePolicy,
|
|
354
|
+
guardProfile: opts.guardProfile,
|
|
355
|
+
audit: opts.audit,
|
|
356
|
+
});
|
|
357
|
+
for await (var ze of zr.extractEntries(extractOpts)) { yield ze; }
|
|
358
|
+
} else if (format === "tar") {
|
|
359
|
+
var tr = archiveTarRead().tar(source, {
|
|
360
|
+
bombPolicy: opts.bombPolicy,
|
|
361
|
+
entryTypePolicy: opts.entryTypePolicy,
|
|
362
|
+
guardProfile: opts.guardProfile,
|
|
363
|
+
audit: opts.audit,
|
|
364
|
+
});
|
|
365
|
+
for await (var te of tr.extractEntries(extractOpts)) { yield te; }
|
|
366
|
+
} else if (format === "tar.gz") {
|
|
367
|
+
// The gz reader's asTar() shim exposes inspect + extract but NOT
|
|
368
|
+
// extractEntries, so materialize the gz layer to a Buffer (the gz
|
|
369
|
+
// bomb caps still run during toBuffer()) and walk a fresh tar reader
|
|
370
|
+
// over it — the tar bomb / guard / entry-type caps run on the inner
|
|
371
|
+
// walk, so no defense is dropped.
|
|
372
|
+
var tarBytes = await archiveGz().read.gz(source, {
|
|
373
|
+
maxDecompressedBytes: opts.maxDecompressedBytes,
|
|
374
|
+
maxExpansionRatio: opts.maxExpansionRatio,
|
|
375
|
+
audit: opts.audit,
|
|
376
|
+
}).toBuffer();
|
|
377
|
+
var gtr = archiveTarRead().tar(archiveAdapters().buffer(tarBytes, { signal: opts.signal }), {
|
|
378
|
+
bombPolicy: opts.bombPolicy,
|
|
379
|
+
entryTypePolicy: opts.entryTypePolicy,
|
|
380
|
+
guardProfile: opts.guardProfile,
|
|
381
|
+
audit: opts.audit,
|
|
382
|
+
});
|
|
383
|
+
for await (var ge of gtr.extractEntries(extractOpts)) { yield ge; }
|
|
384
|
+
} else {
|
|
385
|
+
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
386
|
+
"extractToMemory: format=" + JSON.stringify(format) + " — supported formats are " +
|
|
387
|
+
"zip, tar, tar.gz; b.archive.wrap recipient/passphrase envelopes are auto-unwrapped first");
|
|
388
|
+
}
|
|
389
|
+
} finally {
|
|
390
|
+
if (typeof source.close === "function" && typeof opts.source === "string") {
|
|
391
|
+
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
294
396
|
/**
|
|
295
397
|
* @primitive b.safeArchive.inspect
|
|
296
398
|
* @signature b.safeArchive.inspect(opts)
|
|
@@ -316,53 +418,10 @@ async function extract(opts) {
|
|
|
316
418
|
*/
|
|
317
419
|
async function inspect(opts) {
|
|
318
420
|
opts = opts || {};
|
|
319
|
-
var
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
} else if (Buffer.isBuffer(source)) {
|
|
323
|
-
source = archiveAdapters().buffer(source, { signal: opts.signal });
|
|
324
|
-
} else if (!archiveAdapters().isRandomAccessAdapter(source)) {
|
|
325
|
-
throw new SafeArchiveError("safe-archive/bad-source",
|
|
326
|
-
"inspect: opts.source must be a string path, Buffer, or random-access adapter");
|
|
327
|
-
}
|
|
421
|
+
var resolved = await _resolveAndUnwrap(opts, "inspect", false);
|
|
422
|
+
var source = resolved.source;
|
|
423
|
+
var format = resolved.format;
|
|
328
424
|
try {
|
|
329
|
-
var format = opts.format || "auto";
|
|
330
|
-
if (format === "auto") {
|
|
331
|
-
var sniff = await _sniffMagic(source);
|
|
332
|
-
format = sniff.format;
|
|
333
|
-
}
|
|
334
|
-
// v0.12.16 — auto-unwrap path for inspect, parallel to the
|
|
335
|
-
// v0.12.15 extract path. Wrap envelopes (BAWRP / BAWPP) are
|
|
336
|
-
// unwrapped inline + re-sniffed so operators can enumerate
|
|
337
|
-
// entries of a sealed archive in a single inspect() call.
|
|
338
|
-
if (format === "wrap-recipient" || format === "wrap-passphrase") {
|
|
339
|
-
var sealedBytes = await _collectSourceBytes(source);
|
|
340
|
-
var inner;
|
|
341
|
-
if (format === "wrap-recipient") {
|
|
342
|
-
if (!opts.recipient) {
|
|
343
|
-
throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
|
|
344
|
-
"inspect: source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
|
|
345
|
-
"Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
|
|
346
|
-
}
|
|
347
|
-
inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
|
|
348
|
-
} else {
|
|
349
|
-
if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
|
|
350
|
-
throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
|
|
351
|
-
"inspect: source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
|
|
352
|
-
"Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
|
|
353
|
-
}
|
|
354
|
-
inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
|
|
355
|
-
}
|
|
356
|
-
// v0.12.15 P1 — close the original fs adapter (if string-
|
|
357
|
-
// backed) BEFORE replacing the source reference. v0.12.15 P2
|
|
358
|
-
// — forward opts.signal to the inner buffer adapter.
|
|
359
|
-
if (typeof source.close === "function" && typeof opts.source === "string") {
|
|
360
|
-
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
361
|
-
}
|
|
362
|
-
source = archiveAdapters().buffer(inner, { signal: opts.signal });
|
|
363
|
-
var innerSniff = await _sniffMagic(source);
|
|
364
|
-
format = innerSniff.format;
|
|
365
|
-
}
|
|
366
425
|
var reader;
|
|
367
426
|
if (format === "zip") {
|
|
368
427
|
reader = archiveRead().zip(source, {
|
|
@@ -388,7 +447,7 @@ async function inspect(opts) {
|
|
|
388
447
|
});
|
|
389
448
|
} else {
|
|
390
449
|
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
391
|
-
"inspect: format=" + JSON.stringify(format) + " —
|
|
450
|
+
"inspect: format=" + JSON.stringify(format) + " — supported formats are zip, tar, tar.gz; wrap envelopes are auto-unwrapped first");
|
|
392
451
|
}
|
|
393
452
|
var entries = await reader.inspect();
|
|
394
453
|
var totalCompressed = 0;
|
|
@@ -412,6 +471,7 @@ async function inspect(opts) {
|
|
|
412
471
|
|
|
413
472
|
module.exports = {
|
|
414
473
|
extract: extract,
|
|
474
|
+
extractToMemory: extractToMemory,
|
|
415
475
|
inspect: inspect,
|
|
416
476
|
SafeArchiveError: SafeArchiveError,
|
|
417
477
|
// Exposed for tests + sibling modules.
|
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:d4bb0c68-2752-4234-ac11-14ff95eaf273",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-31T20:49:54.998Z",
|
|
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.14.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.14.14",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.14.
|
|
25
|
+
"version": "0.14.14",
|
|
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.14.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.14.14",
|
|
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.14.
|
|
57
|
+
"ref": "@blamejs/core@0.14.14",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|