@blamejs/core 0.15.7 → 0.15.8
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/README.md +2 -0
- package/index.js +4 -0
- package/lib/audit.js +2 -0
- package/lib/dsa.js +482 -0
- package/lib/framework-error.js +14 -0
- package/lib/log-stream-otlp-grpc.js +9 -2
- package/lib/log-stream-otlp.js +16 -7
- package/lib/observability.js +3 -2
- package/lib/pipl-cn.js +377 -0
- 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.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.8 (2026-06-13) — **Redacts OTLP log-sink attributes to close a secret/PII egress the span fix missed, adds EU DSA and China PIPL cross-border compliance record-builders, ships an SSDF producer self-attestation with every release, and makes the published tarball reproducible.** This release closes a telemetry egress hole, adds two compliance record-builder namespaces, and hardens the release supply chain. The OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's meta attributes and the resource attributes to the collector unredacted — a log line carrying a bearer token, password, or API key reached the wire verbatim (CWE-532). The 0.15.4 fix wired the telemetry redactor into the span and metric exporters but the log sinks were missed; both now run record and resource attributes through the same redactor before serialization. New b.dsa builds the EU Digital Services Act (Reg 2022/2065) records an intermediary or platform must keep — Art. 16 notice-and-action, Art. 17 statement of reasons, and the Art. 15 / 24(3) transparency report. New b.pipl builds the China PIPL cross-border transfer records — an Art. 38/40 assessment that determines whether a CAC security assessment is mandatory (CIIO, important data, or the volume / sensitive-PI thresholds), and an Art. 40 security-assessment certificate. On the supply-chain side, every release now ships ssdf-attestation.json, a machine-readable NIST SP 800-218 / OMB M-22-18 producer self-attestation mapping each secure-development practice to its implementing control, and the published tarball is now packed with SOURCE_DATE_EPOCH so an operator can rebuild it byte-for-byte from the release commit. **Added:** *b.dsa — EU Digital Services Act compliance record-builders* — `b.dsa` builds the dated, frozen records the EU Digital Services Act (Regulation (EU) 2022/2065) requires an online intermediary or platform to keep: `b.dsa.noticeAndAction` (Art. 16) records a notice against a piece of content and computes the action-due window; `b.dsa.statementOfReasons` (Art. 17) records a moderation decision with its legal or contractual ground (exactly one is required), the facts, whether it was automated, and the redress routes offered; `b.dsa.transparencyReport` (Art. 15 / 24(3)) aggregates the period counts into a report with the next-due date. The builders perform no network I/O and emit a best-effort audit event; they map to the `dsa` compliance posture. · *b.pipl — China PIPL cross-border transfer record-builders* — `b.pipl.sccFilingAssessment` builds a PIPL Art. 38/40/55 cross-border transfer assessment and determines the lawful mechanism: it forces a CAC security assessment (over a self-selected standard contract or certification) when the exporter is a critical-information-infrastructure operator, exports important data, handles personal information of more than 1,000,000 individuals, or crosses the cumulative volume / sensitive-PI thresholds. `b.pipl.securityAssessmentCertificate` records an Art. 40 security-assessment self-declaration with a 3-year validity clock. Both return frozen dated records and map to the `pipl-cn` posture. · *SSDF producer self-attestation shipped with every release* — Every release now attaches `ssdf-attestation.json` — a machine-readable NIST SP 800-218 (SSDF v1.1) / OMB M-22-18 producer self-attestation. It maps each secure-software-development practice to its implementing control in the tree (SLSA L3 provenance, SSH-signed tags, vendored zero-runtime-dep supply chain, OSV-Scanner gating, coordinated disclosure) and is deterministic from the release commit. Its sha256 is a subject of the SLSA L3 provenance, so verifying the provenance verifies the attestation has not been tampered with. Downstream consumers who require SSDF supplier-compliance evidence can download it from the release page. **Security:** *OTLP log sinks redact record and resource attributes before export* — `b.logStream`'s OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's `meta` attributes and the sink's resource attributes to the collector UNREDACTED, so a log line whose meta carried a bearer token, password, or API key — or a credential placed in a resource attribute — reached the OTLP wire verbatim (CWE-532). The 0.15.4 change baked the telemetry redactor into the span and metric exporters but its detector was anchored on the span/metric encoder function names, leaving the log sinks uncovered. Both log sinks now run record and resource attributes through `b.observability.redactAttrs` before serialization, the same egress contract the span and metric exporters already hold. · *Reproducible published tarball (SOURCE_DATE_EPOCH)* — The release workflow now exports `SOURCE_DATE_EPOCH` (derived from the tagged commit's author date) before `npm pack`, so the mtime stamped into every tar header is deterministic. An operator can re-pack the package from the same commit and match the published tarball's sha256 byte-for-byte, strengthening the source-to-artifact verification path alongside the existing SLSA L3 provenance and PQC signatures. **Detectors:** *otlp-log-sink-encodes-attrs-without-redactor* — Fires when an OTLP log-sink encoder hands a raw `record.meta` or resource-attribute map to serialization without routing it through `observability.redactAttrs` — the class the span/metric detector could not see because the log sinks carry the OTLP-logs schema function names.
|
|
12
|
+
|
|
11
13
|
- v0.15.7 (2026-06-13) — **Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding.** This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time. **Fixed:** *retention.complianceFloor honors the active compliance posture* — `b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it). · *credentialHash.needsRehash drives the SHAKE256 length-rotation* — `b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention). · *The scheduler no longer lets a watchdog-abandoned run corrupt the next run* — `b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored. **Security:** *The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4* — `b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form. · *verifyIdToken enforces the OIDC azp (authorized party) check* — `b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected. · *SD-JWT key-binding audience and nonce compare in constant time* — `b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper.
|
|
12
14
|
|
|
13
15
|
- v0.15.6 (2026-06-12) — **Closes SAML and OIDC assertion-replay windows, bounds SSE memory under a slow client, restores at-least-once delivery for a crashed outbox publisher, makes sealed-column membership queries work, ships JOSE-conformant SD-JWT signatures, and adds a URL canonicalizer for SSRF-safe comparison.** A security and correctness release. On the identity surface: a SAML Response whose Bearer (or Holder-of-Key) SubjectConfirmation omits NotOnOrAfter is now rejected instead of accepted as fresh-forever, and the OIDC ID-token verifier no longer lets a caller disable expiry validation on a normal token - the exp bypass is restricted to back-channel-logout tokens and bounded by an issued-at freshness floor. SD-JWT-VC ES256/ES384 signatures are now emitted as JOSE raw r||s, so credentials this issuer signs verify in conformant wallets and verifiers. A new b.safeUrl.canonicalize (and b.ssrfGuard.canonicalizeHost) collapses obfuscated host and IP forms to one canonical string so allowlist and SSRF comparisons can't be bypassed by encoding tricks. On the reliability side: server-sent-event channels now cap their per-connection outbound buffer and evict a stalled client instead of growing the heap without bound; the outbox reclaims a job left in-flight by a publisher that crashed mid-delivery; the background worker pool no longer drops a task queued behind one that timed out; a retention preview no longer rewrites the whole database file; an equality / membership query on an encrypted column now hashes each candidate (membership queries previously failed outright); and the on-read re-hash of a legacy lookup digest now runs on Postgres and MySQL handles, not only SQLite. **Fixed:** *SD-JWT-VC ES256 / ES384 signatures are JOSE-conformant* — `b.auth.sdJwtVc` signed and verified ES256 / ES384 credentials with node:crypto's default DER ECDSA encoding instead of the raw r||s (`ieee-p1363`) form JOSE and EUDI wallets require, so a credential this issuer signed was rejected by conformant verifiers and the library rejected conformant holders' key-binding JWTs. The issuer JWT and the holder KB-JWT now both sign and verify with `ieee-p1363`, matching the rest of the framework's JOSE signers. · *Membership queries on an encrypted column now work* — Querying an encrypted (sealed) column with `IN` / `$in` - `b.db.from(table).whereIn("email", [...])` or `b.db.collection(table).find({ email: { $in: [...] } })` - threw, because the sealed-field-to-derived-hash rewrite passed the whole candidate array to the hash lookup as a single value. Each candidate is now hashed individually and matched against both its active keyed digest and its legacy digest, so membership queries on an encrypted column return the right rows, including rows written before the lookup-hash default changed. · *A timing-out background task no longer drops the task queued behind it* — When a `b.workerPool` task timed out or its worker errored, the slot was returned to the idle pool and drained one moment before it was marked for recycling, so a task queued behind it could be dispatched to the worker about to be terminated - and came back as `workerpool/worker-exit` (or hung) instead of running. The slot is now marked recycling before the queue is drained, so the queued task waits for the replacement worker. · *The outbox recovers a job stranded by a crashed publisher* — `b.outbox` claims a row by flipping it to in-flight, but the claim scan only selected pending rows, so a publisher that crashed between claiming a row and recording delivery left that row in-flight forever - the event was silently dropped, breaking at-least-once delivery. The outbox now stamps each claim with a timestamp and, at the start of every poll, returns any in-flight row older than the claim lease (`claimReclaimMs`, default 5 minutes) to the pending pool so it is delivered. An existing outbox table gains the new column automatically. · *A retention preview no longer rewrites the whole database* — Previewing a retention rule with `b.retention` (`run(name, { dryRun: true })` or the `retention preview` command) ran a full database VACUUM for every candidate row under a regulated posture (gdpr / hipaa / and similar), because the per-row erase - which schedules the vacuum - ran before the dry-run check. A preview now computes what it would erase without touching the database; the vacuum runs only on a real erase. · *On-read lookup-digest upgrade runs on Postgres and MySQL* — When `b.cryptoField.unsealRow` re-hashed a legacy lookup digest to the current keyed form and persisted it, the UPDATE was always built for SQLite, so on a Postgres or MySQL handle the durable rewrite quoted identifiers for the wrong dialect and silently no-opped - the legacy digest stayed on disk and the migration never completed off SQLite. The rewrite now uses the handle's own dialect. **Security:** *SAML SubjectConfirmation without NotOnOrAfter is rejected* — `b.auth.saml` SP response verification treated the `NotOnOrAfter` attribute on a Bearer SubjectConfirmationData as optional: a confirmation that omitted it was accepted with no upper bound on the assertion's freshness - a captured assertion replayable indefinitely. SAML 2.0 Web Browser SSO Profile §4.1.4.2 requires Bearer confirmations to carry NotOnOrAfter. `verifyResponse` now rejects a confirmation that is missing NotOnOrAfter, has an unparseable value, or is expired; the Holder-of-Key path (Profile §3.1) is hardened the same way, including the previously-accepted unparseable-value case. · *ID-token expiry can no longer be disabled on a normal token* — `b.auth.oauth`'s `verifyIdToken` honored a `skipExpCheck` option with no constraint, so any caller could verify an expired - or replayed - ID token. That option exists only for OIDC Back-Channel Logout tokens, which carry no `exp`. It is now self-guarding: `verifyIdToken` rejects `skipExpCheck` (`auth-oauth/skip-exp-check-not-allowed`) on any token that lacks the back-channel-logout event claim, and even for a logout token it enforces an `iat` freshness floor (`auth-oauth/logout-token-stale`). The internal logout path is unaffected. · *Server-sent-event channels bound their outbound buffer* — An SSE channel wrote to the response with no regard for backpressure and no cap on buffered bytes, so a single stalled client could make the server buffer events until the heap was exhausted (a memory-exhaustion denial of service). Each channel now tracks its unflushed-byte count and, past a per-channel cap (`maxBufferedBytes`, default 1 MiB), closes the connection and throws `sse/backpressure` - evicting the slow consumer instead of buffering without limit. A client that keeps up is never affected. · *URL and host canonicalizer for SSRF-safe comparison* — New `b.safeUrl.canonicalize(url, opts?)` and `b.ssrfGuard.canonicalizeHost(host)` return the canonical, comparable form of a URL or host: scheme and host lowercased, IDN hosts emitted as their punycode A-label (a confusable / mixed-script host is rejected), every base of an IP literal (decimal, octal, hex, dotted, IPv4-mapped and zero-compressed IPv6) collapsed to one canonical address, default ports stripped, trailing-dot hosts normalized, and path percent-encoding normalized per RFC 3986. Use it to build host allowlists, deduplicate URLs, or compare a fetch target so an allowlist check can't be bypassed by encoding the same address a different way.
|
package/README.md
CHANGED
|
@@ -194,6 +194,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
194
194
|
- **AEDT bias audit** — NYC Local Law 144 bias-audit figures (`b.ai.aedtBiasAudit`): selection / scoring rates and EEOC four-fifths-rule impact ratios across sex, race/ethnicity, and their intersection, with the most-selected group and adverse-impact flags (impact ratio < 0.8) for the annual published summary; sub-2% categories excludable per DCWP §5-301
|
|
195
195
|
- **Frontier AI protocol** — California SB 53 (Transparency in Frontier AI Act) obligations (`b.ai.frontierModelProtocol`): classify the frontier-model (>10²⁶ training FLOPs) and large-frontier-developer (>$500M revenue) thresholds, enumerate the resulting obligations, check a safety framework for required elements, and build a critical-safety-incident report with the 15-day / 24-hour California OES notification deadline (`.incidentReport`)
|
|
196
196
|
- **GPAI Code-of-Practice adherence** — signed, tamper-evident EU AI Act Art. 53 / 55 adherence declarations with a regulation-derived obligation set (a systemic-risk model omitting the Art. 55 chapter is refused) and SHA3-512 evidence binding, emitted inside an ML-DSA-87-signed CycloneDX 1.6 ML-BOM and replay-checked on verify (`b.compliance.aiAct.gpai.declareAdherence` / `verifyAdherence`)
|
|
197
|
+
- **EU Digital Services Act records** — dated, frozen builders for the content-governance loop the DSA (Reg 2022/2065) requires: Art. 16 notice-and-action with the action-due window, Art. 17 statement of reasons (exactly one legal-or-contractual ground, redress routes, automated-decision flag), and the Art. 15 / 24(3) transparency report (`b.dsa.noticeAndAction` / `statementOfReasons` / `transparencyReport`)
|
|
198
|
+
- **China PIPL cross-border transfer records** — Art. 38/40/55 assessment that forces a CAC security assessment over a self-selected standard contract when a critical-information-infrastructure operator, important data, or the volume / sensitive-PI thresholds are in scope, plus an Art. 40 security-assessment certificate with a 3-year validity clock (`b.pipl.sccFilingAssessment` / `securityAssessmentCertificate`)
|
|
197
199
|
|
|
198
200
|
### Compliance regimes
|
|
199
201
|
|
package/index.js
CHANGED
|
@@ -89,6 +89,8 @@ audit.export = function (opts) {
|
|
|
89
89
|
var auditChain = require("./lib/audit-chain");
|
|
90
90
|
var consent = require("./lib/consent");
|
|
91
91
|
var privacy = require("./lib/privacy");
|
|
92
|
+
var dsa = require("./lib/dsa");
|
|
93
|
+
var pipl = require("./lib/pipl-cn");
|
|
92
94
|
var subject = require("./lib/subject");
|
|
93
95
|
var session = require("./lib/session");
|
|
94
96
|
var storage = require("./lib/storage");
|
|
@@ -467,6 +469,8 @@ module.exports = {
|
|
|
467
469
|
events: events,
|
|
468
470
|
consent: consent,
|
|
469
471
|
privacy: privacy,
|
|
472
|
+
dsa: dsa,
|
|
473
|
+
pipl: pipl,
|
|
470
474
|
subject: subject,
|
|
471
475
|
session: session,
|
|
472
476
|
storage: storage,
|
package/lib/audit.js
CHANGED
|
@@ -343,6 +343,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
343
343
|
// (role-switching, RLS-shaped events)
|
|
344
344
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
345
345
|
"dora", // b.dora (DORA Article 17: dora.incident.classified / reported / draftFinal)
|
|
346
|
+
"dsa", // b.dsa (EU Digital Services Act: dsa.notice.recorded / dsa.sor.recorded / dsa.transparency_report.generated)
|
|
346
347
|
"dsr", // b.dsr (Data Subject Rights workflow: dsr.ticket.* / dsr.source.*)
|
|
347
348
|
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
348
349
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
@@ -357,6 +358,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
357
358
|
"inbox", // b.inbox (inbox.received / handled / handle_failed / swept)
|
|
358
359
|
"flag", // b.flag (flag.evaluated / flag.evaluation.error / flag.cache.bust)
|
|
359
360
|
"permissions", // b.permissions
|
|
361
|
+
"pipl", // b.pipl (China PIPL cross-border: pipl.transfer.assessed / pipl.security_assessment.recorded)
|
|
360
362
|
"pqcagent", // b.pqcAgent (pqcagent.operator_group.accepted)
|
|
361
363
|
"privacy", // b.privacy (privacy.vendor_review.recorded)
|
|
362
364
|
"restore", // b.restore
|
package/lib/dsa.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.dsa
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title Digital Services Act
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Record-builders for the operator workflows the EU Digital Services
|
|
9
|
+
* Act (Regulation (EU) 2022/2065) requires an online intermediary or
|
|
10
|
+
* platform to keep on file. Three dated, frozen attestation records
|
|
11
|
+
* cover the regulation's core content-governance loop:
|
|
12
|
+
*
|
|
13
|
+
* - `noticeAndAction` (Art. 16) records a notice a third party
|
|
14
|
+
* submits against a piece of content and computes the window
|
|
15
|
+
* inside which the provider must act on it.
|
|
16
|
+
* - `statementOfReasons` (Art. 17) records the moderation decision
|
|
17
|
+
* taken on a piece of content, its legal or contractual ground,
|
|
18
|
+
* the facts relied on, whether it was automated, and the redress
|
|
19
|
+
* routes offered to the affected recipient.
|
|
20
|
+
* - `transparencyReport` (Art. 15 / Art. 24(3)) aggregates the
|
|
21
|
+
* period counts a provider must publish — notices received,
|
|
22
|
+
* actions taken, automated decisions, appeals — into a report
|
|
23
|
+
* record with the next due date.
|
|
24
|
+
*
|
|
25
|
+
* The builders follow the operator-feeds-metadata pattern: the
|
|
26
|
+
* operator supplies the facts and each function returns a frozen,
|
|
27
|
+
* timestamped record that composes into the operator's own retention /
|
|
28
|
+
* audit / export sink. None of them persist to the framework or touch
|
|
29
|
+
* the network. A best-effort `dsa.*` audit event fires when an audit
|
|
30
|
+
* sink is wired. They map to the `dsa` compliance posture, which
|
|
31
|
+
* cascades ML-DSA-87 audit-chain signing and a TLS 1.3 floor.
|
|
32
|
+
*
|
|
33
|
+
* @card
|
|
34
|
+
* EU Digital Services Act (Reg 2022/2065) record-builders — Art. 16 notice-and-action, Art. 17 statement of reasons, Art. 15/24(3) transparency report.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var validateOpts = require("./validate-opts");
|
|
38
|
+
var lazyRequire = require("./lazy-require");
|
|
39
|
+
var C = require("./constants");
|
|
40
|
+
var { DsaError } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
43
|
+
|
|
44
|
+
// ---- Art. 16 notice-and-action ----
|
|
45
|
+
|
|
46
|
+
// The notice categories Art. 16(2) expects a notice-and-action
|
|
47
|
+
// mechanism to distinguish. A notice that alleges illegal content
|
|
48
|
+
// (Art. 16(2)(a)-(d)) starts the diligent-and-timely action clock and
|
|
49
|
+
// MUST be answered with an Art. 17 statement of reasons when the
|
|
50
|
+
// provider acts on it; a terms-of-service notice need not be.
|
|
51
|
+
var NOTICE_TYPES = Object.freeze({
|
|
52
|
+
"illegal-content": { statementOfReasonsRequired: true, description: "Notice alleges the content is illegal under Union or member-state law (Art. 16(2))." },
|
|
53
|
+
"terms-violation": { statementOfReasonsRequired: false, description: "Notice alleges the content breaches the provider's terms and conditions." },
|
|
54
|
+
"ip-infringement": { statementOfReasonsRequired: true, description: "Notice alleges intellectual-property infringement (a sub-case of illegal content)." },
|
|
55
|
+
"other": { statementOfReasonsRequired: false, description: "Any other notice category the provider's mechanism accepts." },
|
|
56
|
+
});
|
|
57
|
+
var NOTICE_TYPE_IDS = Object.keys(NOTICE_TYPES);
|
|
58
|
+
|
|
59
|
+
// Who submitted the notice. A trusted flagger (Art. 22) is processed
|
|
60
|
+
// with priority; the field is recorded so the provider can evidence
|
|
61
|
+
// the Art. 22(1) priority-handling obligation.
|
|
62
|
+
var SUBMITTER_TYPES = Object.freeze(["individual", "trusted-flagger", "authority", "rights-holder", "other"]);
|
|
63
|
+
|
|
64
|
+
// Default action window. Art. 16(6) requires action "in a timely,
|
|
65
|
+
// diligent, non-arbitrary and objective manner"; it sets no fixed
|
|
66
|
+
// hour count, so the framework default is a conservative 24h SLA that
|
|
67
|
+
// operators override per their own published policy via actionWindowMs.
|
|
68
|
+
var DEFAULT_ACTION_WINDOW_MS = C.TIME.hours(24);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @primitive b.dsa.noticeAndAction
|
|
72
|
+
* @signature b.dsa.noticeAndAction(opts)
|
|
73
|
+
* @since 0.15.8
|
|
74
|
+
* @status stable
|
|
75
|
+
* @compliance dsa
|
|
76
|
+
* @related b.dsa.statementOfReasons, b.dsa.transparencyReport, b.compliance.describe
|
|
77
|
+
*
|
|
78
|
+
* Record an Art. 16 notice-and-action notice and compute the window
|
|
79
|
+
* inside which the provider must act on it. The operator supplies the
|
|
80
|
+
* notice facts — the content it targets, the alleged category, the
|
|
81
|
+
* substantiating reason, when it was submitted, and who submitted it —
|
|
82
|
+
* and `noticeAndAction` validates the shape, stamps `recordedAt`,
|
|
83
|
+
* derives `actionDueBy` from the submission time plus the action
|
|
84
|
+
* window, and flags whether acting on the notice will require an
|
|
85
|
+
* Art. 17 statement of reasons (true for illegal-content / IP notices).
|
|
86
|
+
* The returned record is frozen and is NOT framework-persisted —
|
|
87
|
+
* compose it into your retention / audit / export sink. A best-effort
|
|
88
|
+
* `dsa.notice.recorded` audit event fires when an audit sink is wired.
|
|
89
|
+
*
|
|
90
|
+
* @opts
|
|
91
|
+
* contentId: string, // required — the content the notice targets
|
|
92
|
+
* noticeType: string, // required — illegal-content | terms-violation | ip-infringement | other
|
|
93
|
+
* reason: string, // required — the notice's substantiation (Art. 16(2)(a))
|
|
94
|
+
* submittedAt: number, // required — epoch ms the notice was submitted
|
|
95
|
+
* submitterType: string, // required — individual | trusted-flagger | authority | rights-holder | other
|
|
96
|
+
* noticeId: string, // optional — operator notice id; defaults to "dsa-notice-<submittedAt>"
|
|
97
|
+
* actionWindowMs: number, // optional — SLA window; default 24h (Art. 16(6) "timely")
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* var n = b.dsa.noticeAndAction({
|
|
101
|
+
* contentId: "post-9931",
|
|
102
|
+
* noticeType: "illegal-content",
|
|
103
|
+
* reason: "Depicts a sale prohibited under national law.",
|
|
104
|
+
* submittedAt: Date.now(),
|
|
105
|
+
* submitterType: "trusted-flagger",
|
|
106
|
+
* });
|
|
107
|
+
* // → { noticeId, contentId, noticeType, status: "recorded",
|
|
108
|
+
* // recordedAt, actionDueBy, statementOfReasonsRequired: true }
|
|
109
|
+
*/
|
|
110
|
+
function noticeAndAction(opts) {
|
|
111
|
+
validateOpts.requireObject(opts, "b.dsa.noticeAndAction: opts", DsaError, "dsa/bad-opts");
|
|
112
|
+
validateOpts(opts, [
|
|
113
|
+
"contentId", "noticeType", "reason", "submittedAt", "submitterType",
|
|
114
|
+
"noticeId", "actionWindowMs",
|
|
115
|
+
], "b.dsa.noticeAndAction");
|
|
116
|
+
validateOpts.requireNonEmptyString(opts.contentId, "b.dsa.noticeAndAction: opts.contentId", DsaError, "dsa/bad-content-id");
|
|
117
|
+
validateOpts.requireNonEmptyString(opts.noticeType, "b.dsa.noticeAndAction: opts.noticeType", DsaError, "dsa/bad-notice-type");
|
|
118
|
+
if (NOTICE_TYPE_IDS.indexOf(opts.noticeType) === -1) {
|
|
119
|
+
throw new DsaError("dsa/unknown-notice-type",
|
|
120
|
+
"b.dsa.noticeAndAction: unknown noticeType '" + opts.noticeType +
|
|
121
|
+
"' (allowed: " + NOTICE_TYPE_IDS.join(", ") + ")");
|
|
122
|
+
}
|
|
123
|
+
validateOpts.requireNonEmptyString(opts.reason, "b.dsa.noticeAndAction: opts.reason", DsaError, "dsa/bad-reason");
|
|
124
|
+
if (typeof opts.submittedAt !== "number" || !isFinite(opts.submittedAt) || opts.submittedAt <= 0) {
|
|
125
|
+
throw new DsaError("dsa/bad-submitted-at",
|
|
126
|
+
"b.dsa.noticeAndAction: opts.submittedAt must be a positive epoch-ms number");
|
|
127
|
+
}
|
|
128
|
+
validateOpts.requireNonEmptyString(opts.submitterType, "b.dsa.noticeAndAction: opts.submitterType", DsaError, "dsa/bad-submitter-type");
|
|
129
|
+
if (SUBMITTER_TYPES.indexOf(opts.submitterType) === -1) {
|
|
130
|
+
throw new DsaError("dsa/unknown-submitter-type",
|
|
131
|
+
"b.dsa.noticeAndAction: unknown submitterType '" + opts.submitterType +
|
|
132
|
+
"' (allowed: " + SUBMITTER_TYPES.join(", ") + ")");
|
|
133
|
+
}
|
|
134
|
+
validateOpts.optionalNonEmptyString(opts.noticeId, "b.dsa.noticeAndAction: opts.noticeId", DsaError, "dsa/bad-notice-id");
|
|
135
|
+
var actionWindowMs = opts.actionWindowMs === undefined
|
|
136
|
+
? DEFAULT_ACTION_WINDOW_MS
|
|
137
|
+
: validateOpts.optionalPositiveFinite(opts.actionWindowMs, "b.dsa.noticeAndAction: opts.actionWindowMs", DsaError, "dsa/bad-action-window");
|
|
138
|
+
|
|
139
|
+
var recordedAt = Date.now();
|
|
140
|
+
var sorRequired = NOTICE_TYPES[opts.noticeType].statementOfReasonsRequired;
|
|
141
|
+
var record = Object.freeze({
|
|
142
|
+
noticeId: opts.noticeId || ("dsa-notice-" + opts.submittedAt),
|
|
143
|
+
contentId: opts.contentId,
|
|
144
|
+
noticeType: opts.noticeType,
|
|
145
|
+
submitterType: opts.submitterType,
|
|
146
|
+
reason: opts.reason,
|
|
147
|
+
submittedAt: opts.submittedAt,
|
|
148
|
+
status: "recorded",
|
|
149
|
+
recordedAt: recordedAt,
|
|
150
|
+
actionDueBy: opts.submittedAt + actionWindowMs,
|
|
151
|
+
statementOfReasonsRequired: sorRequired,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
audit().safeEmit({
|
|
155
|
+
action: "dsa.notice.recorded",
|
|
156
|
+
outcome: "success",
|
|
157
|
+
metadata: {
|
|
158
|
+
noticeId: record.noticeId,
|
|
159
|
+
contentId: record.contentId,
|
|
160
|
+
noticeType: record.noticeType,
|
|
161
|
+
submitterType: record.submitterType,
|
|
162
|
+
actionDueBy: record.actionDueBy,
|
|
163
|
+
statementOfReasonsRequired: record.statementOfReasonsRequired,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Art. 17 statement of reasons ----
|
|
171
|
+
|
|
172
|
+
// The moderation decisions Art. 17(1) covers. Each restricts the
|
|
173
|
+
// content or the recipient's account; the statement of reasons must
|
|
174
|
+
// state which (Art. 17(3)(a)).
|
|
175
|
+
var DECISIONS = Object.freeze([
|
|
176
|
+
"content-removed",
|
|
177
|
+
"content-disabled",
|
|
178
|
+
"content-demoted",
|
|
179
|
+
"age-restricted",
|
|
180
|
+
"monetisation-removed",
|
|
181
|
+
"account-suspended",
|
|
182
|
+
"account-terminated",
|
|
183
|
+
"no-action",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
// The redress routes Art. 17(3)(f) requires the statement to point the
|
|
187
|
+
// recipient to. At least one must be offered for a restrictive
|
|
188
|
+
// decision.
|
|
189
|
+
var REDRESS_OPTIONS = Object.freeze([
|
|
190
|
+
"internal-complaint", // Art. 20 internal complaint-handling system
|
|
191
|
+
"out-of-court-settlement", // Art. 21 out-of-court dispute settlement
|
|
192
|
+
"judicial-redress", // Art. 17(3)(f) — judicial remedy
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @primitive b.dsa.statementOfReasons
|
|
197
|
+
* @signature b.dsa.statementOfReasons(opts)
|
|
198
|
+
* @since 0.15.8
|
|
199
|
+
* @status stable
|
|
200
|
+
* @compliance dsa
|
|
201
|
+
* @related b.dsa.noticeAndAction, b.dsa.transparencyReport, b.compliance.describe
|
|
202
|
+
*
|
|
203
|
+
* Record an Art. 17 statement of reasons for a content-moderation
|
|
204
|
+
* decision. Whenever a provider restricts content (or a recipient's
|
|
205
|
+
* account) it must give the affected recipient a clear, specific
|
|
206
|
+
* statement of reasons; this builder records that statement as a frozen
|
|
207
|
+
* dated record. The operator supplies the decision, the legal ground
|
|
208
|
+
* (Art. 17(3)(d)) or the contractual ground (Art. 17(3)(e)) it rests
|
|
209
|
+
* on, the facts relied on (Art. 17(3)(c)), whether the decision was
|
|
210
|
+
* taken by automated means (Art. 17(3)(c)), and the redress routes
|
|
211
|
+
* offered (Art. 17(3)(f)). Exactly one of `legalGround` /
|
|
212
|
+
* `contractualGround` is required so the ground is never left implicit.
|
|
213
|
+
* The returned record is frozen and is NOT framework-persisted — also
|
|
214
|
+
* submit it to the Commission's DSA Transparency Database per Art. 24(5)
|
|
215
|
+
* from your own pipeline. A best-effort `dsa.sor.recorded` audit event
|
|
216
|
+
* fires when an audit sink is wired.
|
|
217
|
+
*
|
|
218
|
+
* @opts
|
|
219
|
+
* contentId: string, // required — the content the decision concerns
|
|
220
|
+
* decision: string, // required — content-removed | content-disabled | ... | no-action
|
|
221
|
+
* facts: string, // required — the facts and circumstances relied on (Art. 17(3)(c))
|
|
222
|
+
* automated: boolean, // required — was the decision taken by automated means (Art. 17(3)(c))
|
|
223
|
+
* redressOptions: string[], // required — internal-complaint | out-of-court-settlement | judicial-redress
|
|
224
|
+
* legalGround: string, // one-of-two — the legal ground when the decision rests on illegality (Art. 17(3)(d))
|
|
225
|
+
* contractualGround: string, // one-of-two — the T&C clause when the decision rests on the contract (Art. 17(3)(e))
|
|
226
|
+
* sorId: string, // optional — operator id; defaults to "dsa-sor-<recordedAt>"
|
|
227
|
+
* noticeId: string, // optional — the Art. 16 notice this answers, if any
|
|
228
|
+
* territorialScope: string, // optional — geographic scope of the restriction (Art. 17(3)(b))
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* var s = b.dsa.statementOfReasons({
|
|
232
|
+
* contentId: "post-9931",
|
|
233
|
+
* decision: "content-removed",
|
|
234
|
+
* legalGround: "National law prohibiting the depicted sale.",
|
|
235
|
+
* facts: "Listing offered a prohibited item for sale.",
|
|
236
|
+
* automated: false,
|
|
237
|
+
* redressOptions: ["internal-complaint", "judicial-redress"],
|
|
238
|
+
* });
|
|
239
|
+
* // → { sorId, contentId, decision, recordedAt, groundType, automated, ... }
|
|
240
|
+
*/
|
|
241
|
+
function statementOfReasons(opts) {
|
|
242
|
+
validateOpts.requireObject(opts, "b.dsa.statementOfReasons: opts", DsaError, "dsa/bad-opts");
|
|
243
|
+
validateOpts(opts, [
|
|
244
|
+
"contentId", "decision", "facts", "automated", "redressOptions",
|
|
245
|
+
"legalGround", "contractualGround", "sorId", "noticeId", "territorialScope",
|
|
246
|
+
], "b.dsa.statementOfReasons");
|
|
247
|
+
validateOpts.requireNonEmptyString(opts.contentId, "b.dsa.statementOfReasons: opts.contentId", DsaError, "dsa/bad-content-id");
|
|
248
|
+
validateOpts.requireNonEmptyString(opts.decision, "b.dsa.statementOfReasons: opts.decision", DsaError, "dsa/bad-decision");
|
|
249
|
+
if (DECISIONS.indexOf(opts.decision) === -1) {
|
|
250
|
+
throw new DsaError("dsa/unknown-decision",
|
|
251
|
+
"b.dsa.statementOfReasons: unknown decision '" + opts.decision +
|
|
252
|
+
"' (allowed: " + DECISIONS.join(", ") + ")");
|
|
253
|
+
}
|
|
254
|
+
validateOpts.requireNonEmptyString(opts.facts, "b.dsa.statementOfReasons: opts.facts", DsaError, "dsa/bad-facts");
|
|
255
|
+
if (typeof opts.automated !== "boolean") {
|
|
256
|
+
throw new DsaError("dsa/bad-automated",
|
|
257
|
+
"b.dsa.statementOfReasons: opts.automated must be a boolean (Art. 17(3)(c) — was the decision automated)");
|
|
258
|
+
}
|
|
259
|
+
// Exactly one ground — never both, never neither. Art. 17(3)(d)/(e)
|
|
260
|
+
// require the statement to state the specific ground; leaving it
|
|
261
|
+
// implicit or asserting two grounds at once is the compliance-theater
|
|
262
|
+
// shape this refuses.
|
|
263
|
+
validateOpts.optionalNonEmptyString(opts.legalGround, "b.dsa.statementOfReasons: opts.legalGround", DsaError, "dsa/bad-legal-ground");
|
|
264
|
+
validateOpts.optionalNonEmptyString(opts.contractualGround, "b.dsa.statementOfReasons: opts.contractualGround", DsaError, "dsa/bad-contractual-ground");
|
|
265
|
+
var hasLegal = typeof opts.legalGround === "string" && opts.legalGround.length > 0;
|
|
266
|
+
var hasContractual = typeof opts.contractualGround === "string" && opts.contractualGround.length > 0;
|
|
267
|
+
if (hasLegal === hasContractual) {
|
|
268
|
+
throw new DsaError("dsa/ground-required",
|
|
269
|
+
"b.dsa.statementOfReasons: supply exactly one of legalGround (Art. 17(3)(d)) or " +
|
|
270
|
+
"contractualGround (Art. 17(3)(e)) — got " + (hasLegal ? "both" : "neither"));
|
|
271
|
+
}
|
|
272
|
+
if (!Array.isArray(opts.redressOptions) || opts.redressOptions.length === 0) {
|
|
273
|
+
throw new DsaError("dsa/redress-required",
|
|
274
|
+
"b.dsa.statementOfReasons: opts.redressOptions must be a non-empty array (Art. 17(3)(f)) — " +
|
|
275
|
+
"allowed: " + REDRESS_OPTIONS.join(", "));
|
|
276
|
+
}
|
|
277
|
+
opts.redressOptions.forEach(function (r, i) {
|
|
278
|
+
if (typeof r !== "string" || REDRESS_OPTIONS.indexOf(r) === -1) {
|
|
279
|
+
throw new DsaError("dsa/unknown-redress-option",
|
|
280
|
+
"b.dsa.statementOfReasons: redressOptions[" + i + "] '" + r +
|
|
281
|
+
"' is not a recognised redress route (allowed: " + REDRESS_OPTIONS.join(", ") + ")");
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
validateOpts.optionalNonEmptyString(opts.sorId, "b.dsa.statementOfReasons: opts.sorId", DsaError, "dsa/bad-sor-id");
|
|
285
|
+
validateOpts.optionalNonEmptyString(opts.noticeId, "b.dsa.statementOfReasons: opts.noticeId", DsaError, "dsa/bad-notice-id");
|
|
286
|
+
validateOpts.optionalNonEmptyString(opts.territorialScope, "b.dsa.statementOfReasons: opts.territorialScope", DsaError, "dsa/bad-territorial-scope");
|
|
287
|
+
|
|
288
|
+
var recordedAt = Date.now();
|
|
289
|
+
var record = Object.freeze({
|
|
290
|
+
sorId: opts.sorId || ("dsa-sor-" + recordedAt),
|
|
291
|
+
contentId: opts.contentId,
|
|
292
|
+
noticeId: opts.noticeId || null,
|
|
293
|
+
decision: opts.decision,
|
|
294
|
+
groundType: hasLegal ? "legal" : "contractual",
|
|
295
|
+
legalGround: hasLegal ? opts.legalGround : null,
|
|
296
|
+
contractualGround: hasContractual ? opts.contractualGround : null,
|
|
297
|
+
facts: opts.facts,
|
|
298
|
+
automated: opts.automated,
|
|
299
|
+
redressOptions: Object.freeze(opts.redressOptions.slice()),
|
|
300
|
+
territorialScope: opts.territorialScope || null,
|
|
301
|
+
recordedAt: recordedAt,
|
|
302
|
+
});
|
|
303
|
+
try {
|
|
304
|
+
audit().safeEmit({
|
|
305
|
+
action: "dsa.sor.recorded",
|
|
306
|
+
outcome: "success",
|
|
307
|
+
metadata: {
|
|
308
|
+
sorId: record.sorId,
|
|
309
|
+
contentId: record.contentId,
|
|
310
|
+
decision: record.decision,
|
|
311
|
+
groundType: record.groundType,
|
|
312
|
+
automated: record.automated,
|
|
313
|
+
noticeId: record.noticeId,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
317
|
+
return record;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- Art. 15 / Art. 24(3) transparency report ----
|
|
321
|
+
|
|
322
|
+
// The metric fields Art. 15(1) + Art. 24 expect a transparency report
|
|
323
|
+
// to carry. Every metric is a non-negative integer count over the
|
|
324
|
+
// reporting period; an omitted metric defaults to 0 so a partial
|
|
325
|
+
// report still produces a complete, comparable shape.
|
|
326
|
+
var METRIC_FIELDS = Object.freeze([
|
|
327
|
+
"noticesReceived", // Art. 15(1)(b) — notices submitted under Art. 16
|
|
328
|
+
"actionsTaken", // Art. 15(1)(b) — actions taken on those notices
|
|
329
|
+
"automatedDecisions", // Art. 15(1)(e) — content moderation by automated means
|
|
330
|
+
"ownInitiativeActions", // Art. 15(1)(c) — own-initiative content moderation
|
|
331
|
+
"statementsOfReasons", // Art. 24(1) — statements of reasons issued
|
|
332
|
+
"appeals", // Art. 24(1)(a) — Art. 20 internal complaints received
|
|
333
|
+
"appealsUpheld", // Art. 24(1)(a) — complaints decided in the recipient's favour
|
|
334
|
+
"outOfCourtDisputes", // Art. 24(1)(b) — Art. 21 out-of-court settlements
|
|
335
|
+
"accountSuspensions", // Art. 23 — suspensions for misuse
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
// The annual re-report clock. Art. 15(1) requires reporting "at least
|
|
339
|
+
// once a year"; the next-due default is one year after the period end.
|
|
340
|
+
var REPORT_PERIOD_MS = C.TIME.days(365);
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @primitive b.dsa.transparencyReport
|
|
344
|
+
* @signature b.dsa.transparencyReport(opts)
|
|
345
|
+
* @since 0.15.8
|
|
346
|
+
* @status stable
|
|
347
|
+
* @compliance dsa
|
|
348
|
+
* @related b.dsa.noticeAndAction, b.dsa.statementOfReasons, b.compliance.describe
|
|
349
|
+
*
|
|
350
|
+
* Build an Art. 15 (all intermediary services) / Art. 24(3) (online
|
|
351
|
+
* platforms) transparency report. The operator supplies the reporting
|
|
352
|
+
* period and the period counts — notices received, actions taken,
|
|
353
|
+
* automated decisions, appeals, and so on — and `transparencyReport`
|
|
354
|
+
* validates the shape, normalises every metric to a non-negative
|
|
355
|
+
* integer (omitted metrics default to 0 so a partial report still has a
|
|
356
|
+
* complete, comparable shape), stamps `generatedAt`, and computes
|
|
357
|
+
* `nextReportDueBy` one year after the period end (Art. 15(1) "at least
|
|
358
|
+
* once a year"). The returned report is frozen and is NOT
|
|
359
|
+
* framework-persisted — publish it from your own pipeline. A
|
|
360
|
+
* best-effort `dsa.transparency_report.generated` audit event fires
|
|
361
|
+
* when an audit sink is wired.
|
|
362
|
+
*
|
|
363
|
+
* @opts
|
|
364
|
+
* period: object, // required — { from: number, to: number } epoch-ms window (from < to)
|
|
365
|
+
* metrics: object, // optional — { <metric>: number } period counts; see b.dsa.listTransparencyMetrics()
|
|
366
|
+
* reportId: string, // optional — operator id; defaults to "dsa-transparency-<to>"
|
|
367
|
+
* service: string, // optional — the service the report covers
|
|
368
|
+
*
|
|
369
|
+
* @example
|
|
370
|
+
* var r = b.dsa.transparencyReport({
|
|
371
|
+
* period: { from: Date.UTC(2025, 0, 1), to: Date.UTC(2025, 11, 31) },
|
|
372
|
+
* metrics: { noticesReceived: 1200, actionsTaken: 940, automatedDecisions: 610, appeals: 75 },
|
|
373
|
+
* });
|
|
374
|
+
* // → { reportId, period, metrics: {...all 9 normalised...}, generatedAt, nextReportDueBy }
|
|
375
|
+
*/
|
|
376
|
+
function transparencyReport(opts) {
|
|
377
|
+
validateOpts.requireObject(opts, "b.dsa.transparencyReport: opts", DsaError, "dsa/bad-opts");
|
|
378
|
+
validateOpts(opts, ["period", "metrics", "reportId", "service"], "b.dsa.transparencyReport");
|
|
379
|
+
if (!opts.period || typeof opts.period !== "object" || Array.isArray(opts.period)) {
|
|
380
|
+
throw new DsaError("dsa/bad-period",
|
|
381
|
+
"b.dsa.transparencyReport: opts.period must be a { from, to } object of epoch-ms numbers");
|
|
382
|
+
}
|
|
383
|
+
var from = opts.period.from;
|
|
384
|
+
var to = opts.period.to;
|
|
385
|
+
if (typeof from !== "number" || !isFinite(from) || from <= 0 ||
|
|
386
|
+
typeof to !== "number" || !isFinite(to) || to <= 0) {
|
|
387
|
+
throw new DsaError("dsa/bad-period",
|
|
388
|
+
"b.dsa.transparencyReport: opts.period.from and opts.period.to must be positive epoch-ms numbers");
|
|
389
|
+
}
|
|
390
|
+
if (from >= to) {
|
|
391
|
+
throw new DsaError("dsa/bad-period-order",
|
|
392
|
+
"b.dsa.transparencyReport: opts.period.from must be strictly before opts.period.to");
|
|
393
|
+
}
|
|
394
|
+
validateOpts.optionalNonEmptyString(opts.reportId, "b.dsa.transparencyReport: opts.reportId", DsaError, "dsa/bad-report-id");
|
|
395
|
+
validateOpts.optionalNonEmptyString(opts.service, "b.dsa.transparencyReport: opts.service", DsaError, "dsa/bad-service");
|
|
396
|
+
|
|
397
|
+
var supplied = opts.metrics;
|
|
398
|
+
if (supplied !== undefined && supplied !== null &&
|
|
399
|
+
(typeof supplied !== "object" || Array.isArray(supplied))) {
|
|
400
|
+
throw new DsaError("dsa/bad-metrics",
|
|
401
|
+
"b.dsa.transparencyReport: opts.metrics must be a plain object of metric counts");
|
|
402
|
+
}
|
|
403
|
+
supplied = supplied || {};
|
|
404
|
+
// Reject unknown metric keys — a misspelled metric would otherwise
|
|
405
|
+
// silently drop out of the published report.
|
|
406
|
+
Object.keys(supplied).forEach(function (k) {
|
|
407
|
+
if (METRIC_FIELDS.indexOf(k) === -1) {
|
|
408
|
+
throw new DsaError("dsa/unknown-metric",
|
|
409
|
+
"b.dsa.transparencyReport: unknown metric '" + k +
|
|
410
|
+
"' (see b.dsa.listTransparencyMetrics())");
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
var metrics = {};
|
|
414
|
+
METRIC_FIELDS.forEach(function (field) {
|
|
415
|
+
var v = supplied[field];
|
|
416
|
+
if (v === undefined || v === null) { metrics[field] = 0; return; }
|
|
417
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0 || Math.floor(v) !== v) {
|
|
418
|
+
throw new DsaError("dsa/bad-metric-value",
|
|
419
|
+
"b.dsa.transparencyReport: metrics." + field +
|
|
420
|
+
" must be a non-negative integer, got " +
|
|
421
|
+
(typeof v === "number" ? String(v) : typeof v));
|
|
422
|
+
}
|
|
423
|
+
metrics[field] = v;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
var generatedAt = Date.now();
|
|
427
|
+
var report = Object.freeze({
|
|
428
|
+
reportId: opts.reportId || ("dsa-transparency-" + to),
|
|
429
|
+
service: opts.service || null,
|
|
430
|
+
period: Object.freeze({ from: from, to: to }),
|
|
431
|
+
metrics: Object.freeze(metrics),
|
|
432
|
+
generatedAt: generatedAt,
|
|
433
|
+
nextReportDueBy: to + REPORT_PERIOD_MS,
|
|
434
|
+
});
|
|
435
|
+
try {
|
|
436
|
+
audit().safeEmit({
|
|
437
|
+
action: "dsa.transparency_report.generated",
|
|
438
|
+
outcome: "success",
|
|
439
|
+
metadata: {
|
|
440
|
+
reportId: report.reportId,
|
|
441
|
+
service: report.service,
|
|
442
|
+
periodFrom: from,
|
|
443
|
+
periodTo: to,
|
|
444
|
+
noticesReceived: metrics.noticesReceived,
|
|
445
|
+
actionsTaken: metrics.actionsTaken,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
449
|
+
return report;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @primitive b.dsa.listTransparencyMetrics
|
|
454
|
+
* @signature b.dsa.listTransparencyMetrics()
|
|
455
|
+
* @since 0.15.8
|
|
456
|
+
* @status stable
|
|
457
|
+
* @related b.dsa.transparencyReport
|
|
458
|
+
*
|
|
459
|
+
* Return the frozen list of metric field names a `transparencyReport`
|
|
460
|
+
* aggregates — each maps to an Art. 15 / Art. 24 reporting obligation.
|
|
461
|
+
* Use it to render a data-entry form or to enumerate the counts the
|
|
462
|
+
* report normalises.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* b.dsa.listTransparencyMetrics();
|
|
466
|
+
* // → ["noticesReceived", "actionsTaken", "automatedDecisions", ...]
|
|
467
|
+
*/
|
|
468
|
+
function listTransparencyMetrics() {
|
|
469
|
+
return METRIC_FIELDS;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
module.exports = {
|
|
473
|
+
noticeAndAction: noticeAndAction,
|
|
474
|
+
statementOfReasons: statementOfReasons,
|
|
475
|
+
transparencyReport: transparencyReport,
|
|
476
|
+
listTransparencyMetrics: listTransparencyMetrics,
|
|
477
|
+
NOTICE_TYPES: NOTICE_TYPES,
|
|
478
|
+
DECISIONS: DECISIONS,
|
|
479
|
+
REDRESS_OPTIONS: REDRESS_OPTIONS,
|
|
480
|
+
METRIC_FIELDS: METRIC_FIELDS,
|
|
481
|
+
DsaError: DsaError,
|
|
482
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -389,6 +389,18 @@ var ComplianceError = defineClass("ComplianceError", { alwaysPermane
|
|
|
389
389
|
// vendorReview opts object, a non-boolean clause attestation, or an
|
|
390
390
|
// unknown clause key. Permanent — operator configuration, not transient.
|
|
391
391
|
var PrivacyError = defineClass("PrivacyError", { alwaysPermanent: true });
|
|
392
|
+
// DsaError covers b.dsa config-time misuse (EU Digital Services Act,
|
|
393
|
+
// Reg 2022/2065): malformed noticeAndAction / statementOfReasons /
|
|
394
|
+
// transparencyReport opts, an unknown notice-type / decision / redress /
|
|
395
|
+
// metric key, a statement of reasons with neither or both grounds, an
|
|
396
|
+
// out-of-order reporting period. Permanent — operator-supplied record shape.
|
|
397
|
+
var DsaError = defineClass("DsaError", { alwaysPermanent: true });
|
|
398
|
+
// PiplError covers b.pipl config-time misuse (China PIPL cross-border
|
|
399
|
+
// transfer): a malformed sccFilingAssessment / securityAssessmentCertificate
|
|
400
|
+
// opts object, an unknown legalBasis / riskRating enum, an empty required
|
|
401
|
+
// array, a bad recordedAt clock, or a malformed injected audit sink.
|
|
402
|
+
// Permanent — operator configuration, not transient.
|
|
403
|
+
var PiplError = defineClass("PiplError", { alwaysPermanent: true });
|
|
392
404
|
// SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
|
|
393
405
|
// shape, fetch failures, TLSA-record format errors, missing records.
|
|
394
406
|
// Permanent — these are policy / DNS configuration errors, not
|
|
@@ -722,6 +734,8 @@ module.exports = {
|
|
|
722
734
|
DoraError: DoraError,
|
|
723
735
|
ComplianceError: ComplianceError,
|
|
724
736
|
PrivacyError: PrivacyError,
|
|
737
|
+
DsaError: DsaError,
|
|
738
|
+
PiplError: PiplError,
|
|
725
739
|
SmtpPolicyError: SmtpPolicyError,
|
|
726
740
|
MailAuthError: MailAuthError,
|
|
727
741
|
MailArfError: MailArfError,
|
|
@@ -33,6 +33,11 @@ var safeAsync = require("./safe-async");
|
|
|
33
33
|
var safeUrl = require("./safe-url");
|
|
34
34
|
var { tearDownH2Session } = require("./http2-teardown");
|
|
35
35
|
var { LogStreamError } = require("./framework-error");
|
|
36
|
+
var lazyRequire = require("./lazy-require");
|
|
37
|
+
// Lazy to break the observability <-> log-stream require cycle. Used only to
|
|
38
|
+
// scrub attribute values through the telemetry redactor before they cross the
|
|
39
|
+
// OTLP egress boundary (CWE-532).
|
|
40
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
36
41
|
|
|
37
42
|
var _err = LogStreamError.factory;
|
|
38
43
|
var _log = boot("log-stream-otlp-grpc");
|
|
@@ -133,7 +138,9 @@ function _encodeLogRecord(record) {
|
|
|
133
138
|
// operators emitting > year-2255 timestamps. For ms-resolution
|
|
134
139
|
// records the LSB nanos are 0; we still send fixed64.
|
|
135
140
|
var tsNs = BigInt(tsMs) * 1000000n;
|
|
136
|
-
|
|
141
|
+
// Scrub meta values through the telemetry redactor before the wire (CWE-532),
|
|
142
|
+
// matching the span/metric exporters' egress contract.
|
|
143
|
+
var attrPieces = _encodeAttributes(observability().redactAttrs(record.meta)).map(function (kvBody) {
|
|
137
144
|
return pb.embeddedMessage(6, kvBody);
|
|
138
145
|
});
|
|
139
146
|
var msg = (record.message != null ? String(record.message) : "");
|
|
@@ -162,7 +169,7 @@ function _encodeScopeLogs(records, scopeName, scopeVersion) {
|
|
|
162
169
|
// ResourceLogs (logs.proto): resource=1 (Resource),
|
|
163
170
|
// scope_logs=2 (repeated ScopeLogs), schema_url=3
|
|
164
171
|
function _encodeResourceLogs(records, cfg) {
|
|
165
|
-
var resourceBody = _encodeResource(_resourceAttrs(cfg));
|
|
172
|
+
var resourceBody = _encodeResource(observability().redactAttrs(_resourceAttrs(cfg)));
|
|
166
173
|
var scopeLogsBody = _encodeScopeLogs(records, cfg.scopeName, cfg.scopeVersion);
|
|
167
174
|
return Buffer.concat([
|
|
168
175
|
pb.embeddedMessage(1, resourceBody),
|
package/lib/log-stream-otlp.js
CHANGED
|
@@ -58,6 +58,11 @@ var httpClient = require("./http-client");
|
|
|
58
58
|
var safeAsync = require("./safe-async");
|
|
59
59
|
var safeUrl = require("./safe-url");
|
|
60
60
|
var authHeader = require("./auth-header");
|
|
61
|
+
var lazyRequire = require("./lazy-require");
|
|
62
|
+
// Lazy to break the observability <-> log-stream require cycle (observability's
|
|
63
|
+
// log path can reach a log-stream sink). Used only to scrub attribute values
|
|
64
|
+
// through the telemetry redactor before they cross the OTLP egress boundary.
|
|
65
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
61
66
|
|
|
62
67
|
var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
|
|
63
68
|
var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
|
|
@@ -141,7 +146,10 @@ function _toLogRecord(record) {
|
|
|
141
146
|
var sev = SEVERITY[record.level] || SEVERITY.info;
|
|
142
147
|
// OTel timeUnixNano is a string (JSON can't safely represent 64-bit ints).
|
|
143
148
|
var nanos = String(BigInt(record.ts) * 1000000n);
|
|
144
|
-
|
|
149
|
+
// Telemetry is a first-class EGRESS sink: scrub every meta value through the
|
|
150
|
+
// redactor before it reaches the collector wire (CWE-532), the same contract
|
|
151
|
+
// the span/metric exporters hold.
|
|
152
|
+
var attrs = record.meta ? _encodeAttrs(observability().redactAttrs(record.meta)) : [];
|
|
145
153
|
return {
|
|
146
154
|
timeUnixNano: nanos,
|
|
147
155
|
observedTimeUnixNano: nanos,
|
|
@@ -158,7 +166,7 @@ function _serializeBatch(records, cfg, scopeVersion) {
|
|
|
158
166
|
resourceLogs: [
|
|
159
167
|
{
|
|
160
168
|
resource: {
|
|
161
|
-
attributes: _encodeAttrs(resourceAttrs),
|
|
169
|
+
attributes: _encodeAttrs(observability().redactAttrs(resourceAttrs)),
|
|
162
170
|
},
|
|
163
171
|
scopeLogs: [
|
|
164
172
|
{
|
|
@@ -291,10 +299,11 @@ function create(config) {
|
|
|
291
299
|
}
|
|
292
300
|
|
|
293
301
|
module.exports = {
|
|
294
|
-
create:
|
|
302
|
+
create: create,
|
|
295
303
|
// Exposed for tests + advanced operator wiring.
|
|
296
|
-
_resolveUrl:
|
|
297
|
-
_encodeAttrs:
|
|
298
|
-
_toLogRecord:
|
|
299
|
-
|
|
304
|
+
_resolveUrl: _resolveUrl,
|
|
305
|
+
_encodeAttrs: _encodeAttrs,
|
|
306
|
+
_toLogRecord: _toLogRecord,
|
|
307
|
+
_serializeBatch: _serializeBatch,
|
|
308
|
+
SEVERITY: SEVERITY,
|
|
300
309
|
};
|
package/lib/observability.js
CHANGED
|
@@ -199,8 +199,9 @@ function getRedactor() {
|
|
|
199
199
|
*
|
|
200
200
|
* Run every value of a telemetry attribute map through the active redactor and
|
|
201
201
|
* return a NEW `{ key: redactedValue }` object. The OTLP exporters call this on
|
|
202
|
-
* span, span-event, metric, and resource attributes before
|
|
203
|
-
* attribute value crosses the egress boundary unscrubbed
|
|
202
|
+
* span, span-event, metric, log-record, and resource attributes before
|
|
203
|
+
* serialization so no attribute value crosses the egress boundary unscrubbed
|
|
204
|
+
* (the HTTP-JSON and gRPC log sinks included) (CWE-532: insertion of
|
|
204
205
|
* sensitive information into an externally-shipped sink). A key whose redactor
|
|
205
206
|
* throws is DROPPED — failing toward dropping, never exporting the raw value;
|
|
206
207
|
* `null` / `undefined` values pass through for the type-encoder to handle.
|
package/lib/pipl-cn.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.pipl
|
|
4
|
+
* @nav Compliance
|
|
5
|
+
* @title PIPL (China)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* China PIPL (Personal Information Protection Law) cross-border
|
|
9
|
+
* transfer record-builders. PIPL Art. 38 sets three lawful bases for
|
|
10
|
+
* transferring personal information outside the PRC: a CAC security
|
|
11
|
+
* assessment (Art. 40), the CAC standard contract (SCC), or
|
|
12
|
+
* certification by a CAC-accredited body. The CAC security assessment
|
|
13
|
+
* is MANDATORY — the operator may not self-select the standard
|
|
14
|
+
* contract — when the exporter is a critical-information-infrastructure
|
|
15
|
+
* operator (CIIO), handles "important data", or crosses the volume /
|
|
16
|
+
* sensitive-PI thresholds in the Measures for Security Assessment of
|
|
17
|
+
* Outbound Data Transfers.
|
|
18
|
+
*
|
|
19
|
+
* These primitives follow the operator-feeds-metadata pattern: the
|
|
20
|
+
* operator supplies the transfer's facts and the builder returns a
|
|
21
|
+
* frozen, dated record (plus a best-effort audit event) that composes
|
|
22
|
+
* into the operator's own retention / export sink. They perform NO
|
|
23
|
+
* network I/O and do NOT file anything with the CAC — they document
|
|
24
|
+
* the legal basis the operator must be able to produce on inspection.
|
|
25
|
+
*
|
|
26
|
+
* @card
|
|
27
|
+
* China PIPL cross-border transfer records — Art. 38/40/55 SCC + CAC security-assessment basis (sccFilingAssessment, securityAssessmentCertificate).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
var lazyRequire = require("./lazy-require");
|
|
31
|
+
var validateOpts = require("./validate-opts");
|
|
32
|
+
var C = require("./constants");
|
|
33
|
+
var { PiplError } = require("./framework-error");
|
|
34
|
+
|
|
35
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
36
|
+
|
|
37
|
+
// PIPL Art. 38(1) lawful cross-border mechanisms. A standard contract or
|
|
38
|
+
// certification is a self-selectable basis; a security assessment is the
|
|
39
|
+
// mechanism the CAC Measures impose when a mandatory trigger is present.
|
|
40
|
+
var LEGAL_BASES = Object.freeze(["standard-contract", "security-assessment", "certification"]);
|
|
41
|
+
|
|
42
|
+
// Mandatory-security-assessment thresholds from the CAC 2024 Provisions on
|
|
43
|
+
// Promoting and Regulating Cross-Border Data Flows (Art. 7/8), which relaxed
|
|
44
|
+
// the original 2022 Measures. Crossing ANY of these forces the
|
|
45
|
+
// security-assessment mechanism — the operator cannot fall back to the
|
|
46
|
+
// standard contract or certification.
|
|
47
|
+
// - CIIO exporter, or "important data" in scope: always mandatory.
|
|
48
|
+
// - cumulative outbound NON-sensitive PI of MORE THAN 1,000,000 individuals
|
|
49
|
+
// since 1 January of the current year (the 100,000–1,000,000 band is the
|
|
50
|
+
// standard-contract / certification tier, NOT a security-assessment
|
|
51
|
+
// trigger).
|
|
52
|
+
// - cumulative outbound SENSITIVE PI of MORE THAN 10,000 individuals in that
|
|
53
|
+
// window.
|
|
54
|
+
// The thresholds are CUMULATIVE since 1 January and THIS transfer counts toward
|
|
55
|
+
// them — the transfer's own `volume` is sorted into the sensitive or
|
|
56
|
+
// non-sensitive bucket by `sensitivePI` and added to the running cumulative
|
|
57
|
+
// before the comparison.
|
|
58
|
+
var SECURITY_ASSESSMENT_NONSENSITIVE_PI_THRESHOLD = 1000000;
|
|
59
|
+
var SECURITY_ASSESSMENT_SENSITIVE_PI_THRESHOLD = 10000;
|
|
60
|
+
|
|
61
|
+
// Re-assessment / re-filing cadence. The CAC security assessment result
|
|
62
|
+
// is valid for 3 years (Measures Art. 14); the standard-contract +
|
|
63
|
+
// certification bases carry a PIPIA (Art. 55) that should be refreshed
|
|
64
|
+
// at least annually or on any material change. We stamp the longer 3-year
|
|
65
|
+
// clock for a mandated security assessment and a 1-year clock otherwise.
|
|
66
|
+
var SECURITY_ASSESSMENT_VALIDITY_DAYS = 365 * 3;
|
|
67
|
+
var STANDARD_REVIEW_DAYS = 365;
|
|
68
|
+
|
|
69
|
+
var SCC_ASSESSMENT_ALLOWED_KEYS = [
|
|
70
|
+
"assessmentId", "transferType", "recipientJurisdiction", "dataCategories",
|
|
71
|
+
"legalBasis", "volume", "sensitivePI", "ciio", "importantData",
|
|
72
|
+
"cumulativePI", "cumulativeSensitivePI", "recordedAt", "audit",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
var RISK_RATINGS = Object.freeze(["low", "medium", "high"]);
|
|
76
|
+
|
|
77
|
+
var SECURITY_CERT_ALLOWED_KEYS = [
|
|
78
|
+
"certId", "assessmentScope", "dataExporter", "overseasRecipient",
|
|
79
|
+
"riskRating", "safeguards", "filingRef", "recordedAt", "audit",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Resolve the audit sink: an operator-supplied b.audit-shaped object wins
|
|
83
|
+
// (so the call is captured even without a DB-backed global handler);
|
|
84
|
+
// otherwise fall back to the framework's global audit. Validated for shape
|
|
85
|
+
// at the call site so a malformed sink throws rather than silently no-ops.
|
|
86
|
+
function _resolveAudit(optsAudit, label) {
|
|
87
|
+
if (optsAudit === undefined || optsAudit === null) return audit();
|
|
88
|
+
return validateOpts.auditShape(optsAudit, label, PiplError, "pipl/bad-audit");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _requireRecordedAt(value, label) {
|
|
92
|
+
if (typeof value !== "number" || !isFinite(value) || value <= 0) {
|
|
93
|
+
throw new PiplError("pipl/bad-recorded-at",
|
|
94
|
+
label + " must be a positive epoch-ms number");
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @primitive b.pipl.sccFilingAssessment
|
|
101
|
+
* @signature b.pipl.sccFilingAssessment(opts)
|
|
102
|
+
* @since 0.15.8
|
|
103
|
+
* @status stable
|
|
104
|
+
* @compliance pipl-cn
|
|
105
|
+
* @related b.pipl.securityAssessmentCertificate, b.compliance.isCrossBorderRegulated, b.privacy.vendorReview
|
|
106
|
+
*
|
|
107
|
+
* Build a dated PIPL Art. 38 / Art. 55 cross-border transfer assessment
|
|
108
|
+
* and determine the lawful mechanism the transfer requires. PIPL Art. 38(1)
|
|
109
|
+
* permits three bases for moving personal information out of the PRC — the
|
|
110
|
+
* CAC standard contract (SCC), a CAC security assessment (Art. 40), or
|
|
111
|
+
* certification by a CAC-accredited body — but the Measures for Security
|
|
112
|
+
* Assessment of Outbound Data Transfers make the security assessment
|
|
113
|
+
* MANDATORY (the operator may NOT self-select the standard contract or
|
|
114
|
+
* certification) when the exporter is a critical-information-infrastructure
|
|
115
|
+
* operator (CIIO), exports "important data", handles personal information
|
|
116
|
+
* of more than 1,000,000 individuals, or has cumulatively exported PI of
|
|
117
|
+
* more than 100,000 individuals or sensitive PI of more than 10,000
|
|
118
|
+
* individuals since 1 January of the preceding year.
|
|
119
|
+
*
|
|
120
|
+
* The builder validates the operator-supplied facts, computes
|
|
121
|
+
* `securityAssessmentRequired` against those thresholds, resolves the
|
|
122
|
+
* `mechanismRequired` (forcing `security-assessment` when any trigger is
|
|
123
|
+
* present, otherwise honoring the operator's declared `legalBasis`), and
|
|
124
|
+
* stamps `recordedAt` plus a `nextReviewDueBy` re-assessment clock (3 years
|
|
125
|
+
* for a mandated security assessment per Measures Art. 14, otherwise the
|
|
126
|
+
* annual PIPIA refresh under Art. 55). The returned record is frozen and
|
|
127
|
+
* is NOT framework-persisted — compose it into your retention / audit /
|
|
128
|
+
* export sink. A best-effort `pipl.transfer.assessed` audit event fires.
|
|
129
|
+
*
|
|
130
|
+
* @opts
|
|
131
|
+
* assessmentId: string, // required — operator's identifier for this assessment
|
|
132
|
+
* transferType: string, // required — e.g. "intra-group", "processor", "controller-to-controller"
|
|
133
|
+
* recipientJurisdiction: string, // required — destination jurisdiction (e.g. "US", "EU", "SG")
|
|
134
|
+
* dataCategories: string[], // required — non-empty list of PI categories transferred
|
|
135
|
+
* legalBasis: string, // required — "standard-contract" | "security-assessment" | "certification"
|
|
136
|
+
* volume: number, // required — count of data subjects in this transfer (>= 0)
|
|
137
|
+
* sensitivePI: boolean, // required — whether the transfer includes sensitive PI (Art. 28)
|
|
138
|
+
* ciio: boolean, // optional — exporter is a CIIO (forces security assessment); default false
|
|
139
|
+
* importantData: boolean, // optional — transfer includes "important data" (forces it); default false
|
|
140
|
+
* cumulativePI: number, // optional — cumulative PI subjects exported since 1 Jan prior year; default 0
|
|
141
|
+
* cumulativeSensitivePI: number, // optional — cumulative sensitive-PI subjects exported in that window; default 0
|
|
142
|
+
* recordedAt: number, // required — epoch ms of this assessment
|
|
143
|
+
* audit: object, // optional — b.audit-shaped sink; default global b.audit
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* var rec = b.pipl.sccFilingAssessment({
|
|
147
|
+
* assessmentId: "xfer-2026-001",
|
|
148
|
+
* transferType: "processor",
|
|
149
|
+
* recipientJurisdiction: "US",
|
|
150
|
+
* dataCategories: ["contact", "billing"],
|
|
151
|
+
* legalBasis: "standard-contract",
|
|
152
|
+
* volume: 5000,
|
|
153
|
+
* sensitivePI: false,
|
|
154
|
+
* recordedAt: Date.now(),
|
|
155
|
+
* });
|
|
156
|
+
* // → { assessmentId, mechanismRequired: "standard-contract",
|
|
157
|
+
* // securityAssessmentRequired: false, recordedAt, nextReviewDueBy, ... }
|
|
158
|
+
*/
|
|
159
|
+
function sccFilingAssessment(opts) {
|
|
160
|
+
validateOpts.requireObject(opts, "b.pipl.sccFilingAssessment: opts", PiplError, "pipl/bad-opts");
|
|
161
|
+
validateOpts(opts, SCC_ASSESSMENT_ALLOWED_KEYS, "b.pipl.sccFilingAssessment");
|
|
162
|
+
validateOpts.requireNonEmptyString(opts.assessmentId,
|
|
163
|
+
"b.pipl.sccFilingAssessment: opts.assessmentId", PiplError, "pipl/bad-assessment-id");
|
|
164
|
+
validateOpts.requireNonEmptyString(opts.transferType,
|
|
165
|
+
"b.pipl.sccFilingAssessment: opts.transferType", PiplError, "pipl/bad-transfer-type");
|
|
166
|
+
validateOpts.requireNonEmptyString(opts.recipientJurisdiction,
|
|
167
|
+
"b.pipl.sccFilingAssessment: opts.recipientJurisdiction", PiplError, "pipl/bad-recipient");
|
|
168
|
+
|
|
169
|
+
if (!Array.isArray(opts.dataCategories) || opts.dataCategories.length === 0) {
|
|
170
|
+
throw new PiplError("pipl/bad-data-categories",
|
|
171
|
+
"b.pipl.sccFilingAssessment: opts.dataCategories must be a non-empty array of strings");
|
|
172
|
+
}
|
|
173
|
+
validateOpts.optionalNonEmptyStringArray(opts.dataCategories,
|
|
174
|
+
"b.pipl.sccFilingAssessment: opts.dataCategories", PiplError, "pipl/bad-data-categories");
|
|
175
|
+
|
|
176
|
+
if (LEGAL_BASES.indexOf(opts.legalBasis) === -1) {
|
|
177
|
+
throw new PiplError("pipl/bad-legal-basis",
|
|
178
|
+
"b.pipl.sccFilingAssessment: opts.legalBasis must be one of " +
|
|
179
|
+
LEGAL_BASES.join(" | ") + " (PIPL Art. 38(1)) — got " + JSON.stringify(opts.legalBasis));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (typeof opts.volume !== "number" || !isFinite(opts.volume) || opts.volume < 0) {
|
|
183
|
+
throw new PiplError("pipl/bad-volume",
|
|
184
|
+
"b.pipl.sccFilingAssessment: opts.volume must be a non-negative finite number (data-subject count)");
|
|
185
|
+
}
|
|
186
|
+
if (typeof opts.sensitivePI !== "boolean") {
|
|
187
|
+
throw new PiplError("pipl/bad-sensitive-pi",
|
|
188
|
+
"b.pipl.sccFilingAssessment: opts.sensitivePI must be a boolean");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
var ciio = opts.ciio === undefined ? false
|
|
192
|
+
: validateOpts.optionalBoolean(opts.ciio, "b.pipl.sccFilingAssessment: opts.ciio", PiplError, "pipl/bad-ciio");
|
|
193
|
+
var importantData = opts.importantData === undefined ? false
|
|
194
|
+
: validateOpts.optionalBoolean(opts.importantData, "b.pipl.sccFilingAssessment: opts.importantData", PiplError, "pipl/bad-important-data");
|
|
195
|
+
var cumulativePI = opts.cumulativePI === undefined ? 0
|
|
196
|
+
: validateOpts.optionalFiniteNonNegative(opts.cumulativePI, "b.pipl.sccFilingAssessment: opts.cumulativePI", PiplError, "pipl/bad-cumulative-pi");
|
|
197
|
+
var cumulativeSensitivePI = opts.cumulativeSensitivePI === undefined ? 0
|
|
198
|
+
: validateOpts.optionalFiniteNonNegative(opts.cumulativeSensitivePI, "b.pipl.sccFilingAssessment: opts.cumulativeSensitivePI", PiplError, "pipl/bad-cumulative-sensitive-pi");
|
|
199
|
+
|
|
200
|
+
var recordedAt = _requireRecordedAt(opts.recordedAt, "b.pipl.sccFilingAssessment: opts.recordedAt");
|
|
201
|
+
// Resolve + shape-validate the audit sink at the entry-point tier (THROWS
|
|
202
|
+
// on a malformed sink) — NOT inside the drop-silent emission try/catch
|
|
203
|
+
// below, which would swallow the config error.
|
|
204
|
+
var auditSink = _resolveAudit(opts.audit, "b.pipl.sccFilingAssessment: opts.audit");
|
|
205
|
+
|
|
206
|
+
// Mandatory-security-assessment determination (CAC 2024 Provisions, Art. 7/8).
|
|
207
|
+
// Crossing ANY trigger forces the security-assessment mechanism regardless of
|
|
208
|
+
// the operator's declared legalBasis — the operator cannot self-downgrade to
|
|
209
|
+
// the standard contract or certification once a trigger is present. The
|
|
210
|
+
// thresholds are cumulative since 1 January and THIS transfer counts: sort its
|
|
211
|
+
// own volume into the sensitive or non-sensitive bucket and add it to the
|
|
212
|
+
// running cumulative before comparing, so a first/planned transfer that alone
|
|
213
|
+
// crosses a threshold is classified correctly without the caller having to
|
|
214
|
+
// pre-add it to the cumulative field.
|
|
215
|
+
var effectiveSensitivePI = cumulativeSensitivePI + (opts.sensitivePI ? opts.volume : 0);
|
|
216
|
+
var effectiveNonSensitivePI = cumulativePI + (opts.sensitivePI ? 0 : opts.volume);
|
|
217
|
+
var triggers = [];
|
|
218
|
+
if (ciio) triggers.push("ciio");
|
|
219
|
+
if (importantData) triggers.push("important-data");
|
|
220
|
+
if (effectiveNonSensitivePI > SECURITY_ASSESSMENT_NONSENSITIVE_PI_THRESHOLD) triggers.push("non-sensitive-pi-volume");
|
|
221
|
+
if (effectiveSensitivePI > SECURITY_ASSESSMENT_SENSITIVE_PI_THRESHOLD) triggers.push("sensitive-pi-volume");
|
|
222
|
+
|
|
223
|
+
var securityAssessmentRequired = triggers.length > 0;
|
|
224
|
+
var mechanismRequired = securityAssessmentRequired ? "security-assessment" : opts.legalBasis;
|
|
225
|
+
var nextReviewDays = securityAssessmentRequired ? SECURITY_ASSESSMENT_VALIDITY_DAYS : STANDARD_REVIEW_DAYS;
|
|
226
|
+
|
|
227
|
+
var record = Object.freeze({
|
|
228
|
+
assessmentId: opts.assessmentId,
|
|
229
|
+
transferType: opts.transferType,
|
|
230
|
+
recipientJurisdiction: opts.recipientJurisdiction,
|
|
231
|
+
dataCategories: Object.freeze(opts.dataCategories.slice()),
|
|
232
|
+
legalBasis: opts.legalBasis,
|
|
233
|
+
volume: opts.volume,
|
|
234
|
+
sensitivePI: opts.sensitivePI,
|
|
235
|
+
mechanismRequired: mechanismRequired,
|
|
236
|
+
securityAssessmentRequired: securityAssessmentRequired,
|
|
237
|
+
securityAssessmentTriggers: Object.freeze(triggers),
|
|
238
|
+
legalReference: "PIPL Art. 38 / Art. 40 / Art. 55",
|
|
239
|
+
recordedAt: recordedAt,
|
|
240
|
+
nextReviewDueBy: recordedAt + C.TIME.days(nextReviewDays),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
auditSink.safeEmit({
|
|
245
|
+
action: "pipl.transfer.assessed",
|
|
246
|
+
outcome: "success",
|
|
247
|
+
resource: { kind: "pipl-cross-border-transfer", id: opts.assessmentId },
|
|
248
|
+
metadata: {
|
|
249
|
+
transferType: opts.transferType,
|
|
250
|
+
recipientJurisdiction: opts.recipientJurisdiction,
|
|
251
|
+
mechanismRequired: mechanismRequired,
|
|
252
|
+
securityAssessmentRequired: securityAssessmentRequired,
|
|
253
|
+
triggers: triggers,
|
|
254
|
+
recordedAt: recordedAt,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
258
|
+
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @primitive b.pipl.securityAssessmentCertificate
|
|
264
|
+
* @signature b.pipl.securityAssessmentCertificate(opts)
|
|
265
|
+
* @since 0.15.8
|
|
266
|
+
* @status stable
|
|
267
|
+
* @compliance pipl-cn
|
|
268
|
+
* @related b.pipl.sccFilingAssessment, b.compliance.isCrossBorderRegulated
|
|
269
|
+
*
|
|
270
|
+
* Record a dated PIPL Art. 40 / CAC security-assessment self-declaration
|
|
271
|
+
* for an outbound data transfer. PIPL Art. 40 and the Measures for
|
|
272
|
+
* Security Assessment of Outbound Data Transfers require an operator who
|
|
273
|
+
* must pass (or has passed) the CAC security assessment to document the
|
|
274
|
+
* assessment scope, the data exporter, the overseas recipient, a risk
|
|
275
|
+
* rating, and the safeguards relied on — the evidence the operator must be
|
|
276
|
+
* able to produce on CAC inspection. This builder validates the supplied
|
|
277
|
+
* facts and returns a frozen, dated certificate record stamped with a
|
|
278
|
+
* 3-year `validUntil` clock (the CAC security-assessment result validity
|
|
279
|
+
* period, Measures Art. 14). It performs NO network I/O and files nothing
|
|
280
|
+
* with the CAC — it documents the assessment the operator conducted. A
|
|
281
|
+
* best-effort `pipl.security_assessment.recorded` audit event fires.
|
|
282
|
+
*
|
|
283
|
+
* @opts
|
|
284
|
+
* certId: string, // required — operator's identifier for this certificate
|
|
285
|
+
* assessmentScope: string, // required — scope of the security assessment (systems / data flows covered)
|
|
286
|
+
* dataExporter: string, // required — the PRC data exporter (controller / processor)
|
|
287
|
+
* overseasRecipient: string, // required — the overseas recipient receiving the PI
|
|
288
|
+
* riskRating: string, // required — "low" | "medium" | "high"
|
|
289
|
+
* safeguards: string[], // required — non-empty list of safeguards relied on (encryption, DPA, etc.)
|
|
290
|
+
* filingRef: string, // optional — CAC filing / acceptance reference number
|
|
291
|
+
* recordedAt: number, // required — epoch ms of this declaration
|
|
292
|
+
* audit: object, // optional — b.audit-shaped sink; default global b.audit
|
|
293
|
+
*
|
|
294
|
+
* @example
|
|
295
|
+
* var cert = b.pipl.securityAssessmentCertificate({
|
|
296
|
+
* certId: "sa-2026-014",
|
|
297
|
+
* assessmentScope: "CRM outbound replication to US region",
|
|
298
|
+
* dataExporter: "Acme (Shanghai) Co., Ltd.",
|
|
299
|
+
* overseasRecipient: "Acme Inc. (Delaware)",
|
|
300
|
+
* riskRating: "medium",
|
|
301
|
+
* safeguards: ["XChaCha20 at rest", "standard contractual clauses", "data minimization"],
|
|
302
|
+
* recordedAt: Date.now(),
|
|
303
|
+
* });
|
|
304
|
+
* // → { certId, assessmentScope, riskRating, recordedAt, validUntil }
|
|
305
|
+
*/
|
|
306
|
+
function securityAssessmentCertificate(opts) {
|
|
307
|
+
validateOpts.requireObject(opts, "b.pipl.securityAssessmentCertificate: opts", PiplError, "pipl/bad-opts");
|
|
308
|
+
validateOpts(opts, SECURITY_CERT_ALLOWED_KEYS, "b.pipl.securityAssessmentCertificate");
|
|
309
|
+
validateOpts.requireNonEmptyString(opts.certId,
|
|
310
|
+
"b.pipl.securityAssessmentCertificate: opts.certId", PiplError, "pipl/bad-cert-id");
|
|
311
|
+
validateOpts.requireNonEmptyString(opts.assessmentScope,
|
|
312
|
+
"b.pipl.securityAssessmentCertificate: opts.assessmentScope", PiplError, "pipl/bad-scope");
|
|
313
|
+
validateOpts.requireNonEmptyString(opts.dataExporter,
|
|
314
|
+
"b.pipl.securityAssessmentCertificate: opts.dataExporter", PiplError, "pipl/bad-exporter");
|
|
315
|
+
validateOpts.requireNonEmptyString(opts.overseasRecipient,
|
|
316
|
+
"b.pipl.securityAssessmentCertificate: opts.overseasRecipient", PiplError, "pipl/bad-recipient");
|
|
317
|
+
|
|
318
|
+
if (RISK_RATINGS.indexOf(opts.riskRating) === -1) {
|
|
319
|
+
throw new PiplError("pipl/bad-risk-rating",
|
|
320
|
+
"b.pipl.securityAssessmentCertificate: opts.riskRating must be one of " +
|
|
321
|
+
RISK_RATINGS.join(" | ") + " — got " + JSON.stringify(opts.riskRating));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!Array.isArray(opts.safeguards) || opts.safeguards.length === 0) {
|
|
325
|
+
throw new PiplError("pipl/bad-safeguards",
|
|
326
|
+
"b.pipl.securityAssessmentCertificate: opts.safeguards must be a non-empty array of strings");
|
|
327
|
+
}
|
|
328
|
+
validateOpts.optionalNonEmptyStringArray(opts.safeguards,
|
|
329
|
+
"b.pipl.securityAssessmentCertificate: opts.safeguards", PiplError, "pipl/bad-safeguards");
|
|
330
|
+
|
|
331
|
+
var filingRef = validateOpts.optionalNonEmptyString(opts.filingRef,
|
|
332
|
+
"b.pipl.securityAssessmentCertificate: opts.filingRef", PiplError, "pipl/bad-filing-ref");
|
|
333
|
+
|
|
334
|
+
var recordedAt = _requireRecordedAt(opts.recordedAt, "b.pipl.securityAssessmentCertificate: opts.recordedAt");
|
|
335
|
+
// Entry-point shape-validate the audit sink (THROWS) before the drop-silent
|
|
336
|
+
// emission try/catch below.
|
|
337
|
+
var auditSink = _resolveAudit(opts.audit, "b.pipl.securityAssessmentCertificate: opts.audit");
|
|
338
|
+
|
|
339
|
+
var record = Object.freeze({
|
|
340
|
+
certId: opts.certId,
|
|
341
|
+
assessmentScope: opts.assessmentScope,
|
|
342
|
+
dataExporter: opts.dataExporter,
|
|
343
|
+
overseasRecipient: opts.overseasRecipient,
|
|
344
|
+
riskRating: opts.riskRating,
|
|
345
|
+
safeguards: Object.freeze(opts.safeguards.slice()),
|
|
346
|
+
filingRef: filingRef || null,
|
|
347
|
+
legalReference: "PIPL Art. 40 / CAC Measures for Security Assessment of Outbound Data Transfers",
|
|
348
|
+
recordedAt: recordedAt,
|
|
349
|
+
validUntil: recordedAt + C.TIME.days(SECURITY_ASSESSMENT_VALIDITY_DAYS),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
auditSink.safeEmit({
|
|
354
|
+
action: "pipl.security_assessment.recorded",
|
|
355
|
+
outcome: "success",
|
|
356
|
+
resource: { kind: "pipl-security-assessment", id: opts.certId },
|
|
357
|
+
metadata: {
|
|
358
|
+
assessmentScope: opts.assessmentScope,
|
|
359
|
+
dataExporter: opts.dataExporter,
|
|
360
|
+
overseasRecipient: opts.overseasRecipient,
|
|
361
|
+
riskRating: opts.riskRating,
|
|
362
|
+
filingRef: filingRef || null,
|
|
363
|
+
recordedAt: recordedAt,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
} catch (_e) { /* drop-silent — audit is best-effort, never block the builder */ }
|
|
367
|
+
|
|
368
|
+
return record;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
sccFilingAssessment: sccFilingAssessment,
|
|
373
|
+
securityAssessmentCertificate: securityAssessmentCertificate,
|
|
374
|
+
LEGAL_BASES: LEGAL_BASES,
|
|
375
|
+
RISK_RATINGS: RISK_RATINGS,
|
|
376
|
+
PiplError: PiplError,
|
|
377
|
+
};
|
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:d7b8bc62-0d13-40fc-86ee-e6263eb44d87",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-13T19:33:49.247Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.8",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.8",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.8",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.8",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|