@blamejs/core 0.14.20 → 0.14.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/auth/oid4vci.js +124 -5
- package/lib/auth/oid4vp.js +14 -4
- package/lib/break-glass.js +1 -2
- package/lib/config.js +28 -31
- package/lib/dora.js +8 -5
- package/lib/dsr.js +2 -2
- package/lib/flag-evaluation-context.js +7 -0
- package/lib/guard-html-wcag-aria.js +4 -2
- package/lib/guard-html-wcag-forms.js +4 -2
- package/lib/guard-html-wcag-tables.js +4 -2
- package/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/guard-html-wcag.js +1 -1
- package/lib/honeytoken.js +27 -20
- package/lib/mail-deploy.js +1 -1
- package/lib/mail-send-deliver.js +13 -4
- package/lib/middleware/api-encrypt.js +140 -13
- package/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/middleware/csp-report.js +13 -9
- package/lib/middleware/openapi-serve.js +3 -0
- package/lib/middleware/scim-server.js +297 -19
- package/lib/middleware/security-txt.js +1 -2
- package/lib/middleware/trace-log-correlation.js +1 -2
- package/lib/network-smtp-policy.js +4 -4
- package/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/observability-tracer.js +1 -1
- package/lib/problem-details.js +56 -11
- package/lib/pubsub-cluster.js +16 -3
- package/lib/queue-sqs.js +20 -2
- package/lib/redis-client.js +32 -4
- package/lib/safe-redirect.js +16 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.21 (2026-06-04) — **SCIM Bulk forward references, an atomic api-encrypt replay gate, OID4VCI `x5c` proofs, HEAD responses without bodies, and a sweep that makes every accepted option do what its documentation says.** This release closes correctness and conformance gaps across recently shipped standards surfaces, plus a framework-wide sweep for options that were accepted but never read. SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order; the `apiEncrypt` middleware closes a concurrent-replay window on multi-replica session stores and validates its numeric options at boot; OID4VCI accepts `x5c` holder-key binding; `problemDetails` spreads its documented `extensions` object as RFC 9457 sibling members; `openapiServe` / `asyncapiServe` answer HEAD without a body; and a batch of entry points now throw on mistyped numeric options instead of silently defaulting. Options whose documented behavior was never implemented are now wired; options that could never do anything are removed and refuse as unknown. **Removed:** *Options that could never do anything now refuse as unknown* — The sweep removed accepted-but-impossible option keys: the `securityTxt` / `traceLogCorrelation` / tracer / TLS-RPT `audit` keys (these surfaces emit no audit rows), TLS-RPT `reportingMta` (not an RFC 8460 report field), `dsr.create` `observability` and create-time `verifyContext` (the per-call `process()` option of the same name is unchanged), `breakGlass.init` `now` (a single init-time timestamp cannot coherently override later time reads), WCAG `checkAll`, mail-deploy `compliance`, and bucket-ops `ca` (TLS trust is owned by the PQC agent — use `NODE_EXTRA_CA_CERTS` or `opts.agent`). Passing one of these now throws the standard unknown-option error. **Fixed:** *SCIM `/Bulk` resolves `bulkId` cross-references regardless of operation order (RFC 7644 §3.7.2)* — Forward references (an operation referencing a resource a later operation creates — the shape Okta and Entra emit) now execute in dependency order; circular references are refused with status 409; a reference to an undeclared `bulkId`, or to an operation that failed, fails that operation with `invalidValue`. References are resolved on BOTH surfaces the spec allows: operation data (`"value": "bulkId:u1"`) and the operation path (`PATCH /Groups/bulkId:g1` targeting a group created in the same request) — path references order, substitute, and fail exactly like data references. Previously an unresolvable reference passed the literal `bulkId:<id>` token through to your resource adapter as if it were a real id. The Bulk response keeps results in original request order, and `failOnErrors` still short-circuits. · *OID4VCI `x5c` holder-key binding implemented (RFC 7515 §4.1.6; OID4VCI §8.2.1.1)* — The proof-JWT verifier named `x5c` as a valid holder-key binding in its own error message but always refused `x5c`-only proofs. The certificate chain is now shape-validated (standard base64 DER, leaf first), the leaf certificate's public key becomes the holder key at the same self-asserted trust level as an inline `jwk`, and a new optional `validateX5c(chainDerBuffers, header)` hook lets the issuer enforce chain trust (PKI anchoring, EKU checks, attestation-CA allowlists) before the key is accepted. · *OID4VCI expired `c_nonce` refuses with a typed error* — A wallet whose access token outlived the shorter `c_nonce` TTL hit an untyped `TypeError` from the nonce comparison; issuance now refuses with `auth-oid4vci/c-nonce-expired` so handlers keying on typed codes respond correctly. The refusal direction is unchanged — no credential was ever minted on this path. · *OID4VP DCQL numeric claim-path segments must be non-negative integers (OpenID4VP 1.0 §7.1.1)* — A query carrying `-1`, `1.5`, `NaN`, or `Infinity` as an array-index segment previously validated and then silently never matched; it now throws at build time, surfacing the malformed query to the verifier author instead of degrading to a silent non-match. · *`problemDetails` `extensions` spread as sibling members (RFC 9457 §3.2)* — `send` and `create` documented an `extensions` object as the way to attach extension members, but emitted it as a literal nested `extensions` member instead. The keys now land as top-level siblings; reserved fields (`type` / `title` / `status` / `detail` / `instance`) cannot be overridden by an extension key, prototype-pollution-shaped keys are dropped, and a direct top-level key wins on collision. · *`cspReport` honors `audit: false`* — The documented audit knob was accepted but never read; the `csp.violation` audit row fired unconditionally for every report. `audit: false` now suppresses the row while reports are still normalized and delivered to `onReport`. The default (audit on) is unchanged, and `maxBytes` now throws at config time on a non-positive-integer value instead of silently reverting to 64 KiB. · *`openapiServe` / `asyncapiServe` HEAD responses carry no body (RFC 9110 §9.3.2)* — Both middlewares advertised GET/HEAD but answered HEAD with the full JSON / YAML document as a body. HEAD now returns the GET headers (including `Content-Length`) with an empty body, matching the rest of the framework's document-serving middlewares. · *Config-time numeric options throw on bad input across entry points* — A mistyped numeric option now throws at `create()` instead of silently becoming the default or garbage downstream: `scimServer` `maxPageSize` (a non-number propagated `NaN` into your `impl.list({ count })` and `ServiceProviderConfig`), `mail.send.deliver` `retry.maxAttempts` / `timeouts.mxLookupMs` / `timeouts.perHostMs`, the `redis` client `db` / `connectTimeoutMs` / `commandTimeoutMs` / `maxReconnectAttempts` (a non-numeric value made the reconnect-cap check false and silently disabled the bound entirely), `pubsub` cluster `pollIntervalMs` / `retentionMs` / `pruneEveryMs`, and SQS queue `visibilityTimeoutSec` / `waitTimeSec` (`0` short-polling stays valid). · *Accepted-but-unread options now do what their documentation says* — The db-backed `config` reloader's `audit` knob gates its `config.reload.*` rows; `honeytoken` honors the documented injectable audit sink (`{ audit: yourSink }`) instead of always emitting to the global sink; `dora`'s `observability` knob gates its report counter, and that counter now actually emits (it previously called a method the observability module doesn't export, and the failure was swallowed); flag evaluation-context `tenantKey` sets the tenant axis; the WCAG `aria` / `forms` / `tables` sub-scanners stamp `scopeUrl` on every finding so direct callers can correlate findings to a source document; `safeRedirect`'s documented `base` lets a same-origin absolute URL pass without an explicit allowlist (cross-origin still refused); and object-store bucket operations honor a per-call `actor` override on audit rows. **Security:** *api-encrypt concurrent-replay window closed (CWE-367)* — On a multi-replica session store, two concurrent requests carrying the same valid counter could both pass the monotonic replay check and execute twice — an attacker who captured one encrypted request could replay it concurrently and have a non-idempotent route run twice. The per-session path now claims each `(session, counter)` tuple through the same atomic nonce store the bootstrap path uses; exactly one concurrent request wins and the loser is refused with the standard rejection shape. The claim lives until the session expires (not just the staleness window), so a failed best-effort session write cannot re-open the tuple for late replay. The bootstrap response counter is also persisted correctly on serializing session stores, fixing a response-replay false positive on the second request of a session. · *api-encrypt envelope metadata is authenticated (AEAD-bound)* — The envelope's plaintext fields — `_ts`, `_nonce`, `_sid`, `_ctr` — were not bound into the ciphertext, so a captured request could be replayed past the staleness window with a rewritten `_ts`, and a captured response could be replayed to the client under a bumped `_ctr` (the client's monotonic check reads the plaintext field). Every request and response envelope now binds its metadata as AEAD associated data on both protocol halves; any rewrite fails authenticated decryption and is refused (server: standard rejection; client: typed `CLIENT_RESPONSE_TAMPERED`). The client also advances its response counter only after authenticated decryption, so a refused forgery cannot poison the monotonic check and block subsequent genuine responses. · *api-encrypt numeric options validated at boot* — A mistyped `replayWindowMs` (for example the string `"5m"`) made the timestamp-staleness comparison always false and silently disabled that replay defense. `replayWindowMs`, `maxDecryptedBytes`, and `pruneIntervalMs` now throw at config time across `create`, `client`, and `httpClient.encrypted`. **Detectors:** *Three new codebase-pattern gates* — An option key accepted by a validation allowlist must be read by the file that accepts it (an accepted-but-never-read key is an advertised knob with no implementation); entry-point numeric options must validate rather than coerce-or-default (`Number(opts.x) || DEFAULT` swallows exactly the typo the config-time tier exists to surface); and a dispatcher that admits HEAD must suppress the response body somewhere in the same file. **Migration:** *Delete removed option keys; everything else is a behavioral fix or additive* — If you pass one of the removed keys listed under Removed, delete it — it did nothing before and now throws the standard unknown-option error. Callers passing valid options see conforming behavior with no code change; the new `validateX5c` hook and the per-finding `scopeUrl` stamp are additive. · *apiEncrypt middleware and client must upgrade together* — Binding the envelope metadata into the AEAD changes what the ciphertext authenticates, so a pre-0.14.21 client cannot talk to a 0.14.21 middleware or vice versa — mixed-version peers fail authenticated decryption and are refused. Both halves ship in this package; a single service upgrading normally is unaffected. If separate services pin different framework versions and speak apiEncrypt to each other, upgrade them together.
|
|
12
|
+
|
|
11
13
|
- v0.14.20 (2026-06-02) — **OAuth Rich Authorization Requests and client attestation, a sealed-field unseal rate cap, DMARC forensic-report parsing, monitor-mode browser-isolation headers, and FedCM / Storage-Access fetch-metadata.** This release extends several standards surfaces the framework already covered in part. The OAuth client gains RFC 9396 Rich Authorization Requests: a typed `authorizationDetails` array is validated before the request and the granted details in the token response are cross-checked, refusing a grant the OP broadened beyond what was asked. The client also gains the OAuth 2.0 Attestation-Based Client Authentication primitives — it can build the `OAuth-Client-Attestation` / `-PoP` JWT pair and verify an inbound attestation. Sealed-field reads gain an opt-in unseal-failure rate cap that throttles a decryption-oracle / brute-force burst against attacker-written sealed columns (CWE-307). The inbound mail stack gains a DMARC forensic (RUF) report parser, the inverse of the aggregate-report parser. On the browser side, the security-headers middleware adds report-only COOP / COEP / Document-Policy variants for safe cross-origin-isolation rollout plus the embedder Require-Document-Policy and Service-Worker-Allowed headers, and the fetch-metadata middleware recognizes the FedCM `webidentity` destination and the Storage-Access request headers first-class. Observability adds a batch of current OpenTelemetry semantic-convention attributes. Every addition is additive or opt-in: an operator who sets no new option, and configures no rate cap, sees prior behavior unchanged. **Added:** *OAuth client: RFC 9396 Rich Authorization Requests (`authorizationDetails`)* — The OAuth / OIDC client accepts an `authorizationDetails` array (RFC 9396 §2 — each element a typed object) on the authorization and pushed-authorization-request paths; it is validated at config time (every element must be an object carrying a string `type`) and serialized as the `authorization_details` parameter, with a cap on the serialized payload. When `verifyAuthorizationDetails` is set, the granted `authorization_details` returned in the token response (RFC 9396 §7) is cross-checked against the request, and a grant that exceeds what was requested is refused — defending against an upstream broadened-grant privilege escalation. Without `authorizationDetails`, the request is unchanged. · *OAuth 2.0 Attestation-Based Client Authentication* — `b.auth.oauth.buildClientAttestation` and `buildClientAttestationPop` mint the `OAuth-Client-Attestation` JWT (a client-backend-signed assertion binding a per-instance key, `typ: oauth-client-attestation+jwt`) and its per-instance `OAuth-Client-Attestation-PoP` proof; `verifyClientAttestation` validates the pair, including the alg allowlist (`ATTESTATION_ALGS`), the audience, and a constant-time `jti` check. This is the wallet / per-device client-authentication shape used in OpenID4VCI and the EUDI Wallet, an alternative to a shared `client_secret`. · *`b.cryptoField.configureUnsealRateCap` — sealed-field decryption-oracle throttle* — An opt-in per-(actor, table, column) sliding-window cap on sealed-column unseal failures. A DB-write attacker who can place crafted `vault:` / `vault.aad:` bytes in sealed columns can force KEM decapsulation / AEAD verification on attacker-controlled input on every read; past `threshold` failures within `windowMs`, further unseal attempts for that tuple are refused for `cooldownMs` with a typed `CryptoFieldRateError` and a distinct `system.crypto.unseal_rate_exceeded` audit event (CWE-307; OWASP ASVS v5 §2.2.1; NIST SP 800-63B §5.2.2). Default off — with no cap configured, `unsealRow` behaves exactly as before (null the field, emit `system.crypto.unseal_failed`); the cap is count-based and lazily pruned, with no background timer. · *`b.mail.dmarc.parseForensicReport` — RFC 6591 forensic (RUF) report parser* — Parses a DMARC failure (forensic) report: a `multipart/report; report-type=feedback-report` message (RFC 6591 §3) whose `message/feedback-report` subpart carries the `Feedback-Type: auth-failure` fields, with the required-field set validated (`Auth-Failure` and the other §3.1 fields) and a part-count and byte cap bounding a hostile report. It is the inbound inverse of the aggregate-report path, so an operator ingesting RUF mail has a parser symmetric with the existing aggregate parser and the v0.14.19 aggregate builder. · *Monitor-mode cross-origin-isolation and embedder headers* — `b.middleware.securityHeaders` gains `coopReportOnly`, `coepReportOnly`, and `documentPolicyReportOnly` — set a policy string to emit the matching `*-Report-Only` header so a UA evaluates and reports violations without enforcing, the safe way to stage a COOP / COEP / Document-Policy rollout (WHATWG HTML cross-origin isolation; W3C Document Policy). `requireDocumentPolicy` emits the embedder `Require-Document-Policy` a parent demands of subframes, and `serviceWorkerAllowed` emits `Service-Worker-Allowed` to broaden a service worker's registration scope (W3C Service Workers). All default off. · *fetch-metadata: FedCM `webidentity` and Storage-Access headers* — `b.middleware.fetchMetadata` recognizes the `webidentity` `Sec-Fetch-Dest` (a FedCM credentialed request) first-class and adds `deniedDest` — a list of destinations refused outright on the gated methods regardless of site, so a `webidentity` request hitting a route that is not an identity endpoint is refused. `allowStorageAccess` (default true) governs whether a request carrying `Sec-Fetch-Storage-Access: active` / `inactive` (the Storage Access API headers) is allowed, and `strictDest` throws at config time on an `allowedDest` / `deniedDest` value outside the known `Sec-Fetch-Dest` vocabulary, catching a typo at boot. · *OpenTelemetry semantic-convention attributes* — The observability semantic-convention map gains a batch of current stable attributes: `peer.service`, the `faas.*` function attributes (`name` / `version` / `instance` / `trigger`), `deployment.environment.name`, `telemetry.distro.*`, `otel.scope.*`, and a Kubernetes subset (`k8s.cluster.name` and the node / container / cronjob / daemonset / job / replicaset / statefulset names), so a span or metric carrying these keys is recognized and emitted under the canonical name. **Fixed:** *`b.auth.sdJwtVc.holder` signed the key-binding JWT with a non-key-derived algorithm* — The holder helper defaulted the key-binding JWT (KB-JWT) signing algorithm to a fixed `ES256` regardless of the holder key type, so a non-EC-P-256 holder key produced a presentation that either could not be built (an Ed25519 or P-384 key) or whose KB-JWT header advertised `ES256` while the signature used the actual key — a self-invalid token a verifier rejects. The algorithm is now derived from the holder key: ES256 / ES384 by EC curve, EdDSA for Ed25519 / Ed448, and ML-DSA-87 / ML-DSA-65 for an ML-DSA key. An RSA holder key, which has no supported KB-JWT algorithm, is refused at `holder.create` with a clear error instead of producing a broken presentation. An EC P-256 holder key, and any explicit `algorithm`, behave exactly as before. **Security:** *Throttle a sealed-column decryption oracle (opt-in)* — `b.cryptoField.configureUnsealRateCap` lets an operator bound repeated unseal failures against sealed columns, so an attacker who can write crafted bytes into a sealed field cannot hammer the KEM / AEAD verify path indefinitely while only an off-band alert rule notices the burst (CWE-307). It is opt-in because a sensible threshold and window depend on a deployment's legitimate sealed-read volume; the always-on defense (null the field plus an audit event on every unseal failure) is unchanged. · *Refuse a broadened OAuth grant* — With `verifyAuthorizationDetails` enabled, the OAuth client refuses a token response whose granted `authorization_details` exceed the requested set (RFC 9396 §7), so a compromised or misbehaving authorization server cannot silently widen a client's authorization beyond what the request asked for. **Migration:** *No action required; additions are additive or opt-in* — The OAuth `authorizationDetails` request parameter, the granted-details cross-check, the client-attestation builders / verifier, the sealed-field unseal rate cap, the DMARC forensic-report parser, the monitor-mode and embedder browser headers, the fetch-metadata FedCM / Storage-Access options, and the new OpenTelemetry attribute keys are all additive or opt-in. A client that passes no `authorizationDetails`, an operator who calls no `configureUnsealRateCap`, and a security-headers / fetch-metadata configuration that sets none of the new options all see prior behavior byte-for-byte unchanged. The one behavior change is the SD-JWT VC holder fix: a `b.auth.sdJwtVc.holder` built with an RSA holder key is now refused at `holder.create` (RSA has no supported KB-JWT algorithm; it previously produced a self-invalid presentation) — switch such a holder to an EC P-256 / P-384, Ed25519, or ML-DSA key. EC P-256 holders and any explicit `algorithm` are unaffected.
|
|
12
14
|
|
|
13
15
|
- v0.14.19 (2026-06-02) — **ZIP64 write completes large-archive support, plus a PKCE-downgrade defense, SCIM Bulk, OID4VCI kid-resolution, SPF macros, DMARC report building, and OpenAPI 3.2.** This release fills out several standards the framework previously read but could not write, or advertised but did not fully implement. The ZIP writer now emits ZIP64, so archives larger than 65535 entries or 4 GiB are produced instead of refused (the reader gained ZIP64 in the previous release; the two now round-trip). The OAuth client refuses an OP whose discovery metadata advertises PKCE methods without S256 — closing a stripped-S256 downgrade. The SCIM server gains opt-in Bulk operations, the OID4VCI issuer accepts a kid-resolution hook for EUDI-Wallet attested-key proofs (and a latent cache-cleanup crash on the issuance path is fixed), the inbound SPF check expands RFC 7208 macros and evaluates the exists mechanism, a DMARC aggregate-report builder is the inverse of the existing parser, and the OpenAPI emitter supports 3.2 with webhooks and a JSON Schema dialect. Every behavior-changing addition is opt-in or additive: classic archives emit byte-for-byte unchanged, OpenAPI still defaults to 3.1.0, SCIM Bulk stays disabled unless configured, and the PKCE refusal only fires for an OP that explicitly advertises a non-S256 method set. **Added:** *`b.archive.zip` writes ZIP64* — When an archive exceeds 65535 entries, or any entry's compressed/uncompressed size or local-header offset exceeds 4 GiB, the writer now emits ZIP64 — the classic field carries the `0xFFFF`/`0xFFFFFFFF` sentinel and a ZIP64 extended-information extra field (header id `0x0001`) plus a ZIP64 EOCD record and locator are written (APPNOTE 6.3.10 §4.3.14 / §4.3.15 / §4.4.8 / §4.5.3). Archives within the classic limits emit byte-for-byte unchanged, and `b.archive.read.zip` reads the ZIP64 output transparently. Previously the writer refused past 65535 entries. · *`b.middleware.scimServer` opt-in SCIM 2.0 Bulk operations* — A `/Bulk` POST endpoint (RFC 7644 §3.7) with `maxOperations` / `maxPayloadSize` caps, `bulkId` cross-reference resolution (§3.7.2), and `failOnErrors` short-circuiting, advertised in `ServiceProviderConfig` when enabled via `opts.bulk`. The request body is read through the bounded stream reader. Bulk stays disabled (and `/Bulk` returns 501) unless `opts.bulk` is set. · *`b.auth.oid4vci` accepts a `resolveKid` proof-key hook* — An OID4VCI proof JWT that carries a header `kid` without an inline `jwk` (the EUDI-Wallet attested-key shape) can now be verified by supplying `create({ resolveKid(kid, header) })`, which maps the key id to a holder key. The resolved key passes the same alg/key-type cross-check (CVE-2026-22817) as the inline path before import and becomes the credential's `cnf` binding. Without a resolver, a kid-only proof is still refused, exactly as before. · *SPF macro expansion + `exists`; DMARC aggregate-report builder* — `b.mail.spf.verify` now expands RFC 7208 §7 macros (`%{i}`/`%{s}`/`%{l}`/`%{d}`/`%{o}`/`%{h}`/`%{v}` with the reverse / digit-count / delimiter transformers and `%%`/`%_`/`%-` escapes) and evaluates the `exists` mechanism (§5.7); the §4.6.4 DNS-lookup and void-lookup ceilings still bound every macro-driven query. New `b.mail.dmarc.buildAggregateReport` serializes a DMARC aggregate (RUA) report to RFC 7489 Appendix C XML — the inverse of `parseAggregateReport` — with optional gzip and XML-metacharacter escaping of observed identifiers. · *OpenAPI 3.2 — webhooks and `jsonSchemaDialect`* — `b.openapi.create({ openapi: "3.2.0" })` opts into OpenAPI 3.2; a `webhook(name, method, opts)` builder registers top-level `webhooks` (API-initiated out-of-band operations), and `jsonSchemaDialect` declares the document's default JSON Schema dialect. `parse()` accepts 3.1.x or 3.2.x and validates webhook operations (including dangling-security references) with the same rules as paths. 3.1.0 stays the default emitted version and existing 3.1 documents are unchanged. **Fixed:** *OID4VCI issuance crashed on cache cleanup* — The pre-authorized-code exchange and credential-issuance paths called `.delete()` on their internal `b.cache` stores, which expose `del()` — so the cleanup step threw and the issuance flow could not complete end-to-end. The three call sites now use `del()`. **Security:** *PKCE-downgrade defense for OAuth / OIDC clients* — The OAuth client already mandates PKCE-S256 on its own side but never inspected the OP's published `code_challenge_methods_supported`. It now refuses, at `authorizationUrl` / `pushAuthorizationRequest`, an OP whose discovery metadata advertises that field without `"S256"` (plain-only / empty) — blocking a stripped-S256 downgrade that would otherwise drive the client into an authorization request the OP claims it cannot verify (RFC 9700 §4.13, RFC 7636). An OP that does not publish the field, and static-endpoint clients that perform no discovery, are unaffected. **Migration:** *No action required; additions are additive or opt-in* — ZIP64 write is additive — archives within the classic 65535-entry / 4 GiB limits emit identical bytes, and the writer simply no longer refuses larger ones. SCIM Bulk, the OID4VCI `resolveKid` hook, the DMARC report builder, and OpenAPI 3.2 are all opt-in (OpenAPI still defaults to 3.1.0). The SPF `exists` mechanism, previously returning permerror, now evaluates per RFC 7208 — `b.mail.spf.verify` returns a verdict and refuses nothing. The PKCE-downgrade refusal only fires for an OP that explicitly advertises a non-S256 PKCE method set.
|
package/lib/auth/oid4vci.js
CHANGED
|
@@ -79,15 +79,68 @@ function _b64uDecodeStr(s) {
|
|
|
79
79
|
return Buffer.from(s, "base64url").toString("utf8");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Linear trailing-`=` strip (charCodeAt + slice) — a regex-based
|
|
83
|
+
// padding strip is polynomial-ReDoS-shaped per CodeQL
|
|
84
|
+
// js/polynomial-redos; mirrors lib/argon2-builtin.js. The comparison
|
|
85
|
+
// below is standard base64 (RFC 7515 §4.1.6), so b.crypto.toBase64Url
|
|
86
|
+
// would produce the wrong alphabet.
|
|
87
|
+
function _stripBase64Pad(s) {
|
|
88
|
+
var end = s.length;
|
|
89
|
+
while (end > 0 && s.charCodeAt(end - 1) === 61) end--; // 61 = "="
|
|
90
|
+
return s.slice(0, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// RFC 7515 §4.1.6 — x5c is an array of base64 (NOT base64url) DER
|
|
94
|
+
// certificate strings, leaf first. Parse + shape-validate the chain into
|
|
95
|
+
// node:crypto X509Certificate objects; refuse a malformed array (empty,
|
|
96
|
+
// non-string entries, non-base64, or a leaf that won't parse) with a
|
|
97
|
+
// typed AuthError matching the module error style.
|
|
98
|
+
function _parseX5cChain(x5c) {
|
|
99
|
+
if (!Array.isArray(x5c) || x5c.length === 0) {
|
|
100
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
101
|
+
"credential issuance: proof JWT `x5c` must be a non-empty array of base64 DER certificate strings (RFC 7515 §4.1.6)");
|
|
102
|
+
}
|
|
103
|
+
var derBuffers = [];
|
|
104
|
+
var certs = [];
|
|
105
|
+
for (var i = 0; i < x5c.length; i++) {
|
|
106
|
+
var entry = x5c[i];
|
|
107
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
108
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
109
|
+
"credential issuance: proof JWT `x5c[" + i + "]` must be a non-empty base64 string");
|
|
110
|
+
}
|
|
111
|
+
// Standard base64 (not base64url) per RFC 7515 §4.1.6. Reject
|
|
112
|
+
// entries carrying base64url-only chars or that don't round-trip.
|
|
113
|
+
if (/[^A-Za-z0-9+/=]/.test(entry)) {
|
|
114
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
115
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6 mandates standard base64, not base64url)");
|
|
116
|
+
}
|
|
117
|
+
var der = Buffer.from(entry, "base64");
|
|
118
|
+
if (der.length === 0 || _stripBase64Pad(der.toString("base64")) !== _stripBase64Pad(entry)) {
|
|
119
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
120
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6)");
|
|
121
|
+
}
|
|
122
|
+
var cert;
|
|
123
|
+
try { cert = new nodeCrypto.X509Certificate(der); }
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
126
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not a parseable DER certificate: " + ((e && e.message) || String(e)));
|
|
127
|
+
}
|
|
128
|
+
derBuffers.push(der);
|
|
129
|
+
certs.push(cert);
|
|
130
|
+
}
|
|
131
|
+
return { derBuffers: derBuffers, certs: certs };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs, proofMaxAgeMs, resolveKid, validateX5c) {
|
|
83
135
|
// OID4VCI §7.2.1.1: the proof JWT MUST:
|
|
84
136
|
// - typ = "openid4vci-proof+jwt"
|
|
85
137
|
// - alg in supported list (issuer publishes these)
|
|
86
138
|
// - aud = credential issuer URL (this issuer's `credential_issuer`)
|
|
87
139
|
// - iat = recent
|
|
88
140
|
// - nonce = c_nonce previously issued to the wallet
|
|
89
|
-
// - jwk (inline)
|
|
90
|
-
// pointing at the holder key to bind cnf to
|
|
141
|
+
// - jwk (inline), kid (resolved via resolveKid), OR x5c (leaf-cert
|
|
142
|
+
// SPKI) in the header pointing at the holder key to bind cnf to
|
|
143
|
+
// (RFC 7515 §4.1.3 / §4.1.4 / §4.1.6; OID4VCI §8.2.1.1)
|
|
91
144
|
if (typeof proofJwt !== "string" || proofJwt.length === 0 || proofJwt.length > MAX_PROOF_BYTES) {
|
|
92
145
|
throw new AuthError("auth-oid4vci/bad-proof",
|
|
93
146
|
"credential issuance: proof JWT is empty or exceeds " + MAX_PROOF_BYTES + " bytes");
|
|
@@ -135,6 +188,18 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
135
188
|
throw new AuthError("auth-oid4vci/wrong-proof-aud",
|
|
136
189
|
"credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
|
|
137
190
|
}
|
|
191
|
+
// c_nonce expectation has three states the caller distinguishes:
|
|
192
|
+
// null → no nonce check expected (caller deliberately skips it).
|
|
193
|
+
// string → the c_nonce the wallet must echo (compared below).
|
|
194
|
+
// undefined → a nonce WAS expected but the store missed/expired it
|
|
195
|
+
// (cNonceStore.get returns undefined on miss/expiry, and
|
|
196
|
+
// the c_nonce TTL is shorter than the access token's).
|
|
197
|
+
// Refuse with a typed code — comparing against undefined
|
|
198
|
+
// would otherwise throw a raw TypeError from timingSafeEqual.
|
|
199
|
+
if (expectedCNonce === undefined) {
|
|
200
|
+
throw new AuthError("auth-oid4vci/c-nonce-expired",
|
|
201
|
+
"credential issuance: c_nonce expected but missing/expired — wallet must request a fresh c_nonce (the /token response's c_nonce TTL elapsed before /credential was called)");
|
|
202
|
+
}
|
|
138
203
|
if (expectedCNonce !== null) {
|
|
139
204
|
// Constant-time c_nonce compare — secret-shaped value vs
|
|
140
205
|
// attacker-controlled wallet payload.
|
|
@@ -172,7 +237,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
172
237
|
"credential issuance: proof JWT iss does not match the access-token client_id");
|
|
173
238
|
}
|
|
174
239
|
|
|
175
|
-
// Resolve the holder key the proof is signed with.
|
|
240
|
+
// Resolve the holder key the proof is signed with. Three paths:
|
|
176
241
|
// - inline `jwk` (RFC 7515 §4.1.3) — the wallet ships the public
|
|
177
242
|
// key in the header; bind `cnf` to it directly.
|
|
178
243
|
// - `kid` (RFC 7515 §4.1.4) without inline `jwk` — the wallet
|
|
@@ -181,6 +246,15 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
181
246
|
// supplies `resolveKid(kid, header)` to map the kid → public key.
|
|
182
247
|
// With no resolver configured the issuer keeps the clear refusal
|
|
183
248
|
// (back-compat): a kid-only proof can't be verified without one.
|
|
249
|
+
// - `x5c` (RFC 7515 §4.1.6) without inline `jwk`/`kid` — the wallet
|
|
250
|
+
// ships a base64 DER certificate chain; the LEAF cert's SPKI is
|
|
251
|
+
// the holder key (OID4VCI §8.2.1.1). Like inline `jwk`, the chain
|
|
252
|
+
// is self-asserted, so leaf-SPKI extraction at the same trust
|
|
253
|
+
// level is the correct parity — the proof signature check binds
|
|
254
|
+
// the key. Chain trust beyond that is operator policy: an optional
|
|
255
|
+
// `validateX5c(chainDerBuffers, header)` callback may throw to
|
|
256
|
+
// refuse (PKI anchoring, EKU checks, revocation, attestation-CA
|
|
257
|
+
// allowlist) before the SPKI is trusted.
|
|
184
258
|
var holderKeyJwk = header.jwk || null;
|
|
185
259
|
var keyObj;
|
|
186
260
|
if (!holderKeyJwk && header.kid) {
|
|
@@ -228,6 +302,42 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
228
302
|
throw new AuthError("auth-oid4vci/bad-resolved-key",
|
|
229
303
|
"credential issuance: resolveKid-returned key is not importable as a public key: " + ((e && e.message) || String(e)));
|
|
230
304
|
}
|
|
305
|
+
} else if (!holderKeyJwk && header.x5c) {
|
|
306
|
+
// RFC 7515 §4.1.6 / OID4VCI §8.2.1.1 — the wallet ships a base64 DER
|
|
307
|
+
// certificate chain; the LEAF (first) cert's SPKI is the holder key.
|
|
308
|
+
var chain = _parseX5cChain(header.x5c);
|
|
309
|
+
// Operator chain-trust policy runs BEFORE the SPKI is trusted. A
|
|
310
|
+
// throw refuses the proof (wrapped in a stable AuthError code so the
|
|
311
|
+
// /credential handler returns a typed refusal rather than an
|
|
312
|
+
// unhandled rejection; the callback is operator code, so its own
|
|
313
|
+
// message is allowed through for operator-side debugging).
|
|
314
|
+
if (typeof validateX5c === "function") {
|
|
315
|
+
try {
|
|
316
|
+
await validateX5c(chain.derBuffers.slice(), header);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (e instanceof AuthError) throw e;
|
|
319
|
+
throw new AuthError("auth-oid4vci/x5c-rejected",
|
|
320
|
+
"credential issuance: validateX5c rejected the proof JWT certificate chain: " + ((e && e.message) || String(e)));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Extract the leaf SPKI as a JWK to use as the holder key, exactly
|
|
324
|
+
// parallel to the inline-jwk path. publicKey is a node:crypto
|
|
325
|
+
// KeyObject; export to JWK for the cnf binding sdJwtIssuer.issue
|
|
326
|
+
// expects.
|
|
327
|
+
try { holderKeyJwk = chain.certs[0].publicKey.export({ format: "jwk" }); }
|
|
328
|
+
catch (e) {
|
|
329
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
330
|
+
"credential issuance: proof JWT `x5c` leaf certificate public key does not export to JWK: " + ((e && e.message) || String(e)));
|
|
331
|
+
}
|
|
332
|
+
// CVE-2026-22817 — same alg/kty cross-check the inline path applies.
|
|
333
|
+
// A leaf cert holding an RSA key against a proof declaring an HMAC
|
|
334
|
+
// alg would otherwise be verified as an HMAC secret.
|
|
335
|
+
jwtExternal._assertAlgKtyMatch(header.alg, holderKeyJwk);
|
|
336
|
+
try { keyObj = nodeCrypto.createPublicKey({ key: holderKeyJwk, format: "jwk" }); }
|
|
337
|
+
catch (e) {
|
|
338
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
339
|
+
"credential issuance: proof JWT `x5c` leaf public key is not importable: " + ((e && e.message) || String(e)));
|
|
340
|
+
}
|
|
231
341
|
} else {
|
|
232
342
|
if (!holderKeyJwk) {
|
|
233
343
|
throw new AuthError("auth-oid4vci/no-jwk-in-header",
|
|
@@ -292,6 +402,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
292
402
|
* supportedCredentials: { [id]: { format, vct, claims, ... } },
|
|
293
403
|
* proofAlgorithms: string[], // default ["ES256", "ES384", "EdDSA"]
|
|
294
404
|
* resolveKid?: function(kid, header), // resolve a kid-only proof's holder key (JWK | KeyObject); without it, kid-only proofs are refused
|
|
405
|
+
* validateX5c?: function(chainDerBuffers, header), // x5c (RFC 7515 §4.1.6) chain-trust policy; throw to refuse. Absent → leaf-cert SPKI binds at the same self-asserted trust as inline `jwk`
|
|
295
406
|
* preAuthCodeTtlMs?: number, // default 5m
|
|
296
407
|
* accessTokenTtlMs?: number, // default 15m
|
|
297
408
|
* cNonceTtlMs?: number, // default 5m
|
|
@@ -358,6 +469,14 @@ function create(opts) {
|
|
|
358
469
|
var resolveKid = validateOpts.optionalFunction(opts.resolveKid,
|
|
359
470
|
"issuer.create: resolveKid", AuthError, "auth-oid4vci/bad-resolve-kid");
|
|
360
471
|
|
|
472
|
+
// Optional x5c chain-trust policy for x5c proofs (RFC 7515 §4.1.6 /
|
|
473
|
+
// OID4VCI §8.2.1.1). Config-time throw if supplied but not a function.
|
|
474
|
+
// Absent → the leaf-cert SPKI binds at the same self-asserted trust
|
|
475
|
+
// level as an inline `jwk` (the proof signature binds the key); chain
|
|
476
|
+
// anchoring beyond that is the operator's to enforce via this callback.
|
|
477
|
+
var validateX5c = validateOpts.optionalFunction(opts.validateX5c,
|
|
478
|
+
"issuer.create: validateX5c", AuthError, "auth-oid4vci/bad-validate-x5c");
|
|
479
|
+
|
|
361
480
|
var preAuthTtl = opts.preAuthCodeTtlMs || DEFAULT_PRE_AUTH_TTL_MS;
|
|
362
481
|
var accessTokenTtl = opts.accessTokenTtlMs || DEFAULT_ACCESS_TOKEN_TTL;
|
|
363
482
|
var cNonceTtl = opts.cNonceTtlMs || DEFAULT_C_NONCE_TTL_MS;
|
|
@@ -612,7 +731,7 @@ function create(opts) {
|
|
|
612
731
|
}
|
|
613
732
|
|
|
614
733
|
var expectedCNonce = await cNonceStore.get(iopts.accessToken);
|
|
615
|
-
var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid);
|
|
734
|
+
var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid, validateX5c);
|
|
616
735
|
|
|
617
736
|
if (!iopts.claims || typeof iopts.claims !== "object") {
|
|
618
737
|
throw new AuthError("auth-oid4vci/no-claims",
|
package/lib/auth/oid4vp.js
CHANGED
|
@@ -65,10 +65,11 @@ var _emitMetric = emit.metric;
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Validate a DCQL query against the spec shape. Refuses unknown
|
|
68
|
-
* top-level keys, missing credential id, missing claim paths,
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* top-level keys, missing credential id, missing claim paths,
|
|
69
|
+
* numeric claim-path segments that are not non-negative integer
|
|
70
|
+
* array indices (OpenID4VP 1.0 §7.1.1), or malformed credential_sets
|
|
71
|
+
* options. Throws AuthError on first failure (config-time validation
|
|
72
|
+
* — the verifier author is the one who needs to see the error).
|
|
72
73
|
*/
|
|
73
74
|
function _validateDcql(dcql) {
|
|
74
75
|
if (!dcql || typeof dcql !== "object" || Array.isArray(dcql)) {
|
|
@@ -113,6 +114,15 @@ function _validateDcql(dcql) {
|
|
|
113
114
|
throw new AuthError("auth-oid4vp/bad-claim-segment",
|
|
114
115
|
"DCQL: claim path segments must be string|number|null");
|
|
115
116
|
}
|
|
117
|
+
// OpenID4VP 1.0 §7.1.1 — a numeric segment is an array index;
|
|
118
|
+
// it MUST be a non-negative integer. Reject -1 / 1.5 / NaN /
|
|
119
|
+
// Infinity here (config-time / entry-point tier) so the
|
|
120
|
+
// verifier author sees the typo at build, not a silent
|
|
121
|
+
// non-match at verify time.
|
|
122
|
+
if (typeof segment === "number" && (!Number.isInteger(segment) || segment < 0)) {
|
|
123
|
+
throw new AuthError("auth-oid4vp/bad-claim-segment",
|
|
124
|
+
"DCQL: numeric claim path segment must be a non-negative integer (OpenID4VP 1.0 §7.1.1)");
|
|
125
|
+
}
|
|
116
126
|
});
|
|
117
127
|
if (claim.values !== undefined && !Array.isArray(claim.values)) {
|
|
118
128
|
throw new AuthError("auth-oid4vp/bad-claim-values",
|
package/lib/break-glass.js
CHANGED
|
@@ -425,7 +425,6 @@ async function migrate(table, opts) {
|
|
|
425
425
|
* has run.
|
|
426
426
|
*
|
|
427
427
|
* @opts
|
|
428
|
-
* now: number, // testing-only override of Date.now (fixtures)
|
|
429
428
|
* trustProxy: boolean, // honor X-Forwarded-For when populating grant.ip (default false)
|
|
430
429
|
*
|
|
431
430
|
* @example
|
|
@@ -434,7 +433,7 @@ async function migrate(table, opts) {
|
|
|
434
433
|
*/
|
|
435
434
|
function init(opts) {
|
|
436
435
|
opts = opts || {};
|
|
437
|
-
validateOpts(opts, ["
|
|
436
|
+
validateOpts(opts, ["trustProxy"], "breakGlass.init");
|
|
438
437
|
initialized = true;
|
|
439
438
|
policyCache.clear();
|
|
440
439
|
_factorLockout = null;
|
package/lib/config.js
CHANGED
|
@@ -258,7 +258,8 @@ function create(opts) {
|
|
|
258
258
|
* Rows whose transform throws or returns a non-string
|
|
259
259
|
* are skipped with a `config.reload.failed` audit so a
|
|
260
260
|
* single bad row never crashes the poller),
|
|
261
|
-
* audit: boolean (default true;
|
|
261
|
+
* audit: boolean (default true; set false to silence the
|
|
262
|
+
* per-poll config.reload.* audit emissions),
|
|
262
263
|
*
|
|
263
264
|
* @example
|
|
264
265
|
* var s = b.safeSchema;
|
|
@@ -322,6 +323,12 @@ function loadDbBacked(opts) {
|
|
|
322
323
|
var transformValue = validateOpts.optionalFunction(
|
|
323
324
|
opts.transformValue, "loadDbBacked: opts.transformValue",
|
|
324
325
|
ConfigError, "config/bad-transform-value") || null;
|
|
326
|
+
var auditOn = opts.audit !== false;
|
|
327
|
+
function _emitReloadAudit(record) {
|
|
328
|
+
if (!auditOn) return;
|
|
329
|
+
try { audit().safeEmit(record); }
|
|
330
|
+
catch (_e) { /* audit best-effort */ }
|
|
331
|
+
}
|
|
325
332
|
var cfg = create({ schema: opts.schema, env: opts.env, redactKeys: opts.redactKeys });
|
|
326
333
|
var stopped = false;
|
|
327
334
|
// Concurrency guard. _tick() runs `await opts.fetchRows()` + per-row
|
|
@@ -348,12 +355,10 @@ function loadDbBacked(opts) {
|
|
|
348
355
|
var rows;
|
|
349
356
|
try { rows = await opts.fetchRows(); }
|
|
350
357
|
catch (e) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
} catch (_e) { /* audit best-effort */ }
|
|
358
|
+
_emitReloadAudit({
|
|
359
|
+
action: "config.reload.failed", outcome: "failure",
|
|
360
|
+
metadata: { phase: "fetch", reason: e && e.message },
|
|
361
|
+
});
|
|
357
362
|
return;
|
|
358
363
|
}
|
|
359
364
|
if (!Array.isArray(rows)) return;
|
|
@@ -366,21 +371,17 @@ function loadDbBacked(opts) {
|
|
|
366
371
|
try {
|
|
367
372
|
value = await transformValue(row);
|
|
368
373
|
} catch (e) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
});
|
|
374
|
-
} catch (_e) { /* audit best-effort */ }
|
|
374
|
+
_emitReloadAudit({
|
|
375
|
+
action: "config.reload.failed", outcome: "failure",
|
|
376
|
+
metadata: { phase: "transform", key: row.key, reason: e && e.message },
|
|
377
|
+
});
|
|
375
378
|
continue;
|
|
376
379
|
}
|
|
377
380
|
if (typeof value !== "string") {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
} catch (_e) { /* audit best-effort */ }
|
|
381
|
+
_emitReloadAudit({
|
|
382
|
+
action: "config.reload.failed", outcome: "failure",
|
|
383
|
+
metadata: { phase: "transform", key: row.key, reason: "transformValue did not return a string" },
|
|
384
|
+
});
|
|
384
385
|
continue;
|
|
385
386
|
}
|
|
386
387
|
}
|
|
@@ -389,12 +390,10 @@ function loadDbBacked(opts) {
|
|
|
389
390
|
// Drop-stale: a tick that started after me has already finished and
|
|
390
391
|
// applied its newer fetch — my overlay would clobber fresher data.
|
|
391
392
|
if (mySeq <= ticksAppliedMax) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
});
|
|
397
|
-
} catch (_e) { /* audit best-effort */ }
|
|
393
|
+
_emitReloadAudit({
|
|
394
|
+
action: "config.reload.skipped", outcome: "success",
|
|
395
|
+
metadata: { phase: "stale-tick", mySeq: mySeq, appliedMax: ticksAppliedMax },
|
|
396
|
+
});
|
|
398
397
|
return;
|
|
399
398
|
}
|
|
400
399
|
// Advance the watermark ONLY after a successful reload. A newer
|
|
@@ -407,12 +406,10 @@ function loadDbBacked(opts) {
|
|
|
407
406
|
ticksAppliedMax = mySeq;
|
|
408
407
|
}
|
|
409
408
|
catch (e) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
});
|
|
415
|
-
} catch (_e) { /* audit best-effort */ }
|
|
409
|
+
_emitReloadAudit({
|
|
410
|
+
action: "config.reload.failed", outcome: "failure",
|
|
411
|
+
metadata: { phase: "validate", reason: e && e.message },
|
|
412
|
+
});
|
|
416
413
|
}
|
|
417
414
|
}
|
|
418
415
|
// Fire one immediate hydration before the interval kicks in so
|
package/lib/dora.js
CHANGED
|
@@ -248,8 +248,8 @@ function _validateReportInput(input) {
|
|
|
248
248
|
*
|
|
249
249
|
* @opts
|
|
250
250
|
* audit: boolean (default true; set false to skip audit emits),
|
|
251
|
-
* observability: boolean (
|
|
252
|
-
* best-effort
|
|
251
|
+
* observability: boolean (default true; set false to skip the
|
|
252
|
+
* best-effort observability counter on report),
|
|
253
253
|
*
|
|
254
254
|
* @example
|
|
255
255
|
* var dora = b.dora.create({ audit: true });
|
|
@@ -276,6 +276,7 @@ function create(opts) {
|
|
|
276
276
|
opts = opts || {};
|
|
277
277
|
validateOpts(opts, ["audit", "observability"], "dora.create");
|
|
278
278
|
var auditOn = opts.audit !== false;
|
|
279
|
+
var obsOn = opts.observability !== false;
|
|
279
280
|
|
|
280
281
|
function _emit(action, info) {
|
|
281
282
|
if (!auditOn) return;
|
|
@@ -354,9 +355,11 @@ function create(opts) {
|
|
|
354
355
|
stage: record.stage,
|
|
355
356
|
},
|
|
356
357
|
});
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
358
|
+
if (obsOn) {
|
|
359
|
+
observability().safeEvent("dora.incident.reported", 1, {
|
|
360
|
+
classification: record.classification, stage: record.stage,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
360
363
|
return record;
|
|
361
364
|
}
|
|
362
365
|
|
package/lib/dsr.js
CHANGED
|
@@ -279,8 +279,8 @@ function create(opts) {
|
|
|
279
279
|
validateOpts(opts, [
|
|
280
280
|
"ticketStore", "posture", "identityResolver",
|
|
281
281
|
"sources", "audit", "retentionFloorMs",
|
|
282
|
-
"deadlineMs",
|
|
283
|
-
"verificationLevel",
|
|
282
|
+
"deadlineMs",
|
|
283
|
+
"verificationLevel",
|
|
284
284
|
"receiptSigner", "minVerificationByType",
|
|
285
285
|
], "dsr.create");
|
|
286
286
|
|
|
@@ -77,6 +77,13 @@ function fromRequest(req, opts) {
|
|
|
77
77
|
if (typeof req.user.email === "string") ctx.email = req.user.email;
|
|
78
78
|
if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
|
|
79
79
|
}
|
|
80
|
+
// Explicit tenantKey overrides the tenant id derived from req.user —
|
|
81
|
+
// the sibling of userKey for the tenant axis. Operators behind a
|
|
82
|
+
// gateway that resolves tenancy out-of-band (subdomain, mTLS SAN,
|
|
83
|
+
// signed header) pass it directly rather than depending on req.user.
|
|
84
|
+
if (typeof opts.tenantKey === "string" && opts.tenantKey.length > 0) {
|
|
85
|
+
ctx.tenantId = opts.tenantKey;
|
|
86
|
+
}
|
|
80
87
|
var headers = req.headers || {};
|
|
81
88
|
if (typeof headers["accept-language"] === "string") {
|
|
82
89
|
ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
|
|
@@ -58,8 +58,10 @@ function audit(html, opts) {
|
|
|
58
58
|
? KNOWN_ROLES.concat(opts.allowedRoles)
|
|
59
59
|
: KNOWN_ROLES;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
62
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
63
|
+
var findings = collector.findings;
|
|
64
|
+
var _add = collector.add;
|
|
63
65
|
|
|
64
66
|
var declaredIds = Object.create(null);
|
|
65
67
|
var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
|
|
@@ -55,8 +55,10 @@ function audit(html, opts) {
|
|
|
55
55
|
? AUTOCOMPLETE_TOKENS.concat(opts.allowedAutocomplete)
|
|
56
56
|
: AUTOCOMPLETE_TOKENS;
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
59
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
60
|
+
var findings = collector.findings;
|
|
61
|
+
var _add = collector.add;
|
|
60
62
|
|
|
61
63
|
// Pre-scan: is there a <legend> inside any <fieldset>?
|
|
62
64
|
// We track fieldset → has-legend by forward-scanning each fieldset.
|
|
@@ -29,8 +29,10 @@ function audit(html, opts) {
|
|
|
29
29
|
throw new TypeError("tables.audit: html must be a string");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
33
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
34
|
+
var findings = collector.findings;
|
|
35
|
+
var _add = collector.add;
|
|
34
36
|
|
|
35
37
|
// Walk the tag stream, tracking nesting state for tables + their
|
|
36
38
|
// children. We don't build a full DOM; we track the open-tag stack
|
|
@@ -36,9 +36,29 @@ function lineColAt(html, offset) {
|
|
|
36
36
|
return { line: line, column: offset - lastNl };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Shared findings collector for the sub-scanners' audit(html, opts)
|
|
40
|
+
// entry points. scopeUrl annotates every finding with the page it came
|
|
41
|
+
// from so a direct caller of a sub-scanner (aria/forms/tables) can
|
|
42
|
+
// correlate a finding back to its source document; the parent
|
|
43
|
+
// wcag.audit also records scopeUrl at report level, but stamping
|
|
44
|
+
// per-finding keeps the value useful when a sub-scanner is invoked on
|
|
45
|
+
// its own. Returns { findings, add } — push findings through add() so
|
|
46
|
+
// the stamp applies uniformly.
|
|
47
|
+
function makeScopedFindings(scopeUrlOpt) {
|
|
48
|
+
var scopeUrl = (typeof scopeUrlOpt === "string" && scopeUrlOpt.length > 0)
|
|
49
|
+
? scopeUrlOpt : null;
|
|
50
|
+
var findings = [];
|
|
51
|
+
function add(f) {
|
|
52
|
+
if (scopeUrl !== null) f.scopeUrl = scopeUrl;
|
|
53
|
+
findings.push(f);
|
|
54
|
+
}
|
|
55
|
+
return { findings: findings, add: add };
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
module.exports = {
|
|
40
59
|
TAG_RE: TAG_RE,
|
|
41
60
|
ATTR_RE: ATTR_RE,
|
|
42
61
|
parseAttrs: parseAttrs,
|
|
43
62
|
lineColAt: lineColAt,
|
|
63
|
+
makeScopedFindings: makeScopedFindings,
|
|
44
64
|
};
|
package/lib/guard-html-wcag.js
CHANGED
|
@@ -345,7 +345,7 @@ function _checkAnchors(html, scheduled, report) {
|
|
|
345
345
|
function audit(html, opts) {
|
|
346
346
|
opts = opts || {};
|
|
347
347
|
validateOpts(opts, [
|
|
348
|
-
"level", "ignore", "
|
|
348
|
+
"level", "ignore", "scopeUrl",
|
|
349
349
|
"skipAria", "allowedRoles", "skipTables",
|
|
350
350
|
"skipForms", "allowedAutocomplete",
|
|
351
351
|
], "guardHtml.wcag.audit");
|
package/lib/honeytoken.js
CHANGED
|
@@ -89,6 +89,17 @@ function create(opts) {
|
|
|
89
89
|
opts = opts || {};
|
|
90
90
|
validateOpts(opts, ["audit"], "honeytoken.create");
|
|
91
91
|
|
|
92
|
+
// Honor the operator-supplied audit sink when present (the documented
|
|
93
|
+
// `audit: b.audit` injection); fall back to the module's lazyRequire so
|
|
94
|
+
// a caller that omits the sink still emits to the default audit log.
|
|
95
|
+
var auditSink = (opts.audit && typeof opts.audit.safeEmit === "function")
|
|
96
|
+
? opts.audit : null;
|
|
97
|
+
function _emit(record) {
|
|
98
|
+
var sink = auditSink || audit();
|
|
99
|
+
try { sink.safeEmit(record); }
|
|
100
|
+
catch (_e) { /* audit best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
var registry = new Map(); // value → { id, kind, metadata, issuedAt }
|
|
93
104
|
|
|
94
105
|
function issue(spec) {
|
|
@@ -110,13 +121,11 @@ function create(opts) {
|
|
|
110
121
|
issuedAt: Date.now(),
|
|
111
122
|
});
|
|
112
123
|
registry.set(value, record);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
} catch (_e) { /* audit best-effort */ }
|
|
124
|
+
_emit({
|
|
125
|
+
action: "honeytoken.issued",
|
|
126
|
+
outcome: "success",
|
|
127
|
+
metadata: { id: id, kind: kind },
|
|
128
|
+
});
|
|
120
129
|
return { id: id, value: value };
|
|
121
130
|
}
|
|
122
131
|
|
|
@@ -124,19 +133,17 @@ function create(opts) {
|
|
|
124
133
|
if (typeof value !== "string" || value.length === 0) return null;
|
|
125
134
|
var record = registry.get(value);
|
|
126
135
|
if (!record) return null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
139
|
-
} catch (_e) { /* audit best-effort */ }
|
|
136
|
+
_emit({
|
|
137
|
+
action: "honeytoken.tripped",
|
|
138
|
+
outcome: "failure",
|
|
139
|
+
metadata: {
|
|
140
|
+
id: record.id,
|
|
141
|
+
kind: record.kind,
|
|
142
|
+
metadata: record.metadata,
|
|
143
|
+
observedAt: Date.now(),
|
|
144
|
+
observedActor: observedActor || null,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
140
147
|
return record;
|
|
141
148
|
}
|
|
142
149
|
|
package/lib/mail-deploy.js
CHANGED
|
@@ -919,7 +919,7 @@ function tlsRptIngestHttp(opts) {
|
|
|
919
919
|
opts = opts || {};
|
|
920
920
|
validateOpts(opts, ["authenticate", "trustedReporters", "maxCompressedBytes",
|
|
921
921
|
"maxDecompressedBytes", "maxRatio", "onAccept", "onRefuse",
|
|
922
|
-
"audit"
|
|
922
|
+
"audit"],
|
|
923
923
|
"mail.deploy.tlsRptIngestHttp");
|
|
924
924
|
validateOpts.optionalFunction(opts.authenticate, "tlsRptIngestHttp: opts.authenticate",
|
|
925
925
|
MailDeployError, "mail-tlsrpt/bad-opts");
|