@blamejs/core 0.15.6 → 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 +4 -0
- package/MIGRATING.md +12 -0
- package/README.md +2 -0
- package/index.js +4 -0
- package/lib/audit.js +2 -0
- package/lib/auth/elevation-grant.js +6 -2
- package/lib/auth/oauth.js +13 -0
- package/lib/auth/sd-jwt-vc.js +5 -2
- package/lib/compliance.js +4 -0
- package/lib/credential-hash.js +9 -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/lib/retention.js +16 -2
- package/lib/scheduler.js +12 -0
- package/lib/ssrf-guard.js +25 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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/lib/retention.js
CHANGED
|
@@ -614,9 +614,16 @@ var COMPLIANCE_RETENTION_FLOOR_MS = Object.freeze({
|
|
|
614
614
|
* // → 220752000000 (Sarbanes-Oxley §802 — 7 years)
|
|
615
615
|
*/
|
|
616
616
|
function complianceFloor(posture, candidateTtlMs) {
|
|
617
|
+
// Optional posture: omit it to inherit the active posture recorded by
|
|
618
|
+
// applyPosture (the b.compliance.set cascade). A numeric first argument is
|
|
619
|
+
// taken as candidateTtlMs so complianceFloor(ttl) works; an explicit posture
|
|
620
|
+
// always overrides the active one.
|
|
621
|
+
if (typeof posture === "number") { candidateTtlMs = posture; posture = undefined; }
|
|
622
|
+
if (posture === undefined || posture === null) { posture = STATE.activePosture; }
|
|
617
623
|
if (typeof posture !== "string") {
|
|
618
624
|
throw new RetentionError("retention/bad-posture",
|
|
619
|
-
"complianceFloor: posture must be a string,
|
|
625
|
+
"complianceFloor: posture must be a string (pass one, or set the active " +
|
|
626
|
+
"posture via applyPosture / b.compliance.set), got " + JSON.stringify(posture));
|
|
620
627
|
}
|
|
621
628
|
var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
|
|
622
629
|
if (floor === undefined) {
|
|
@@ -660,7 +667,14 @@ function complianceFloor(posture, candidateTtlMs) {
|
|
|
660
667
|
* // → "hipaa"
|
|
661
668
|
*/
|
|
662
669
|
function applyPosture(posture) {
|
|
663
|
-
if (typeof posture !== "string" || posture.length === 0)
|
|
670
|
+
if (typeof posture !== "string" || posture.length === 0) {
|
|
671
|
+
// Clear the active posture (the inverse of a set) so b.compliance.clear
|
|
672
|
+
// and operators can reset the inherited floor; complianceFloor then falls
|
|
673
|
+
// back to requiring an explicit posture again.
|
|
674
|
+
STATE.activePosture = null;
|
|
675
|
+
STATE.activeFloorMs = null;
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
664
678
|
var floor = COMPLIANCE_RETENTION_FLOOR_MS[posture];
|
|
665
679
|
STATE.activePosture = posture;
|
|
666
680
|
STATE.activeFloorMs = (typeof floor === "number") ? floor : null;
|
package/lib/scheduler.js
CHANGED
|
@@ -488,6 +488,10 @@ function create(opts) {
|
|
|
488
488
|
lastError: null,
|
|
489
489
|
running: false,
|
|
490
490
|
runningSince: 0,
|
|
491
|
+
// Monotonic run tag. The watchdog and each fire bump it, so a run the
|
|
492
|
+
// watchdog abandoned can't clobber state / emit a stale settle event
|
|
493
|
+
// when its slow promise finally resolves.
|
|
494
|
+
runGeneration: 0,
|
|
491
495
|
fires: 0,
|
|
492
496
|
misses: 0, // skipped because previous run still in-flight
|
|
493
497
|
nonLeaderSkips: 0,
|
|
@@ -540,6 +544,8 @@ function create(opts) {
|
|
|
540
544
|
(maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
|
|
541
545
|
} catch (_e) { /* logger best-effort */ }
|
|
542
546
|
_emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
|
|
547
|
+
// Supersede the abandoned run so its late settle is ignored.
|
|
548
|
+
task.runGeneration++;
|
|
543
549
|
task.running = false;
|
|
544
550
|
} else {
|
|
545
551
|
task.misses++;
|
|
@@ -665,6 +671,10 @@ function create(opts) {
|
|
|
665
671
|
task.runningSince = Date.now();
|
|
666
672
|
task.lastRun = new Date().toISOString();
|
|
667
673
|
var startedAt = Date.now();
|
|
674
|
+
// Tag this run. The settle handlers below only write back if the tag still
|
|
675
|
+
// matches — so a run the watchdog reset (or a newer fire) can't clobber the
|
|
676
|
+
// current run's state or emit a stale success/failure when it settles late.
|
|
677
|
+
var gen = (task.runGeneration = (task.runGeneration || 0) + 1);
|
|
668
678
|
|
|
669
679
|
var promise;
|
|
670
680
|
try {
|
|
@@ -678,6 +688,7 @@ function create(opts) {
|
|
|
678
688
|
}
|
|
679
689
|
|
|
680
690
|
Promise.resolve(promise).then(function (_v) {
|
|
691
|
+
if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
|
|
681
692
|
task.running = false;
|
|
682
693
|
task.runningSince = 0;
|
|
683
694
|
task.lastFinish = new Date().toISOString();
|
|
@@ -689,6 +700,7 @@ function create(opts) {
|
|
|
689
700
|
viaJob: !!task.job,
|
|
690
701
|
});
|
|
691
702
|
}, function (e) {
|
|
703
|
+
if (task.runGeneration !== gen) return; // watchdog/newer fire superseded this run
|
|
692
704
|
task.running = false;
|
|
693
705
|
task.runningSince = 0;
|
|
694
706
|
task.lastFinish = new Date().toISOString();
|
package/lib/ssrf-guard.js
CHANGED
|
@@ -335,14 +335,32 @@ function canonicalizeHost(host) {
|
|
|
335
335
|
return bare.toLowerCase();
|
|
336
336
|
}
|
|
337
337
|
if (family === 6) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
338
|
+
var v6bytes = _ipv6ToBytes(bare);
|
|
339
|
+
// An IPv4-mapped IPv6 address (::ffff:a.b.c.d, the ::ffff:0:0/96 block) IS
|
|
340
|
+
// the IPv4 address a.b.c.d for routing / access control — classify() already
|
|
341
|
+
// re-classifies it by the embedded v4, and a dual-stack peer arriving on
|
|
342
|
+
// ::ffff:1.2.3.4 reaches the same host as 1.2.3.4. Fold it to the dotted
|
|
343
|
+
// IPv4 form so a dual-stack peer and an operator's IPv4 allowlist entry
|
|
344
|
+
// canonicalize equal. ONLY the IPv4-mapped block (::ffff:0:0/96) folds,
|
|
345
|
+
// because classify(::ffff:x) === classify(x) — its classify branch returns
|
|
346
|
+
// the embedded-v4 verdict with no reserved fallback, so folding can't change
|
|
347
|
+
// an SSRF verdict. NAT64 (64:ff9b::/96) and 6to4 (2002::/16) are NOT folded:
|
|
348
|
+
// classify treats a NAT64 literal as `classify(v4) || "reserved"`, so a
|
|
349
|
+
// public NAT64 address classifies as "reserved" while its embedded v4 is
|
|
350
|
+
// null — folding would flip a blocked verdict to an allowed public IPv4.
|
|
351
|
+
// classify still reaches the embedded v4 for the deny side; the canonical
|
|
352
|
+
// form keeps NAT64 / 6to4 as IPv6 so canonicalize-then-classify agrees with
|
|
353
|
+
// classify alone.
|
|
354
|
+
if (_ipv6PrefixMatch(IPV6_V4_MAPPED_PREFIX, C.BYTES.bytes(96), v6bytes)) {
|
|
355
|
+
return v6bytes[12] + "." + v6bytes[13] + "." + v6bytes[14] + "." + v6bytes[15];
|
|
356
|
+
}
|
|
357
|
+
return _ipv6BytesToString(v6bytes);
|
|
345
358
|
}
|
|
359
|
+
// Not an IP literal — DNS name. Lowercase + strip ALL trailing dots: a
|
|
360
|
+
// hostname's trailing-dot count is not significant for identity (the root
|
|
361
|
+
// label is empty), so host / host. / host.. must collapse to one form or a
|
|
362
|
+
// trailing-dot count bypasses a host allow/deny comparison.
|
|
363
|
+
var name = bare.toLowerCase().replace(/\.+$/, "");
|
|
346
364
|
return name;
|
|
347
365
|
}
|
|
348
366
|
|
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
|
]
|