@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +5 -3
- package/docs/commands.md +15 -11
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +32 -3
- package/docs/production-source.md +175 -17
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +479 -12
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +34 -13
- package/docs/troubleshooting.md +17 -6
- package/docs/tutorial.md +7 -1
- package/package.json +2 -2
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +865 -207
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +23 -3
- package/src/cli/renderers.mjs +29 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +37 -8
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +14 -24
- package/src/runtime/plan.mjs +7 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +271 -51
- package/src/source/asset_transfer.mjs +156 -74
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +368 -0
- package/src/source/postgresql_access.mjs +213 -55
- package/src/source/privacy_paths.mjs +38 -0
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
8
8
|
import { chmod, mkdir, open, readFile, stat } from "node:fs/promises";
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
STRUCTURED_JSON_KINDS,
|
|
12
|
+
executeStructuredJsonDeclaration,
|
|
13
|
+
structuredJsonDeclarations,
|
|
14
|
+
validateStructuredJsonRecipe,
|
|
15
|
+
} from "./privacy_structured_json.mjs";
|
|
10
16
|
|
|
11
17
|
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
12
18
|
const BINDING_NAME = /^[a-z][a-z0-9-]{0,62}$/u;
|
|
13
19
|
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
14
20
|
const HEX_64 = /^[a-f0-9]{64}$/u;
|
|
21
|
+
const UUID =
|
|
22
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/iu;
|
|
23
|
+
const PORTABLE_CODE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
|
|
15
24
|
const ACTIONS = new Set([
|
|
16
25
|
"KEEP",
|
|
17
26
|
"PSEUDONYMIZE",
|
|
@@ -19,17 +28,42 @@ const ACTIONS = new Set([
|
|
|
19
28
|
"EXCLUDE",
|
|
20
29
|
"DERIVE",
|
|
21
30
|
]);
|
|
22
|
-
const PSEUDONYM_FORMATS = new Set([
|
|
31
|
+
const PSEUDONYM_FORMATS = new Set([
|
|
32
|
+
"uuid",
|
|
33
|
+
"email",
|
|
34
|
+
"text",
|
|
35
|
+
"integer",
|
|
36
|
+
"hex",
|
|
37
|
+
"gtin",
|
|
38
|
+
"url",
|
|
39
|
+
]);
|
|
40
|
+
const HEX_LENGTHS = new Set([32, 64]);
|
|
41
|
+
const GTIN_LENGTHS = new Set([8, 12, 13, 14]);
|
|
42
|
+
const DATE_REPRESENTATIONS = new Set(["iso-string", "epoch-milliseconds"]);
|
|
23
43
|
const DERIVATION_KINDS = new Set([
|
|
24
44
|
"date-shift",
|
|
45
|
+
"digest",
|
|
46
|
+
"enum",
|
|
47
|
+
"validated-string",
|
|
48
|
+
"path-map",
|
|
25
49
|
"json-object",
|
|
26
50
|
"json-array",
|
|
51
|
+
"json-union",
|
|
52
|
+
"json-dictionary",
|
|
27
53
|
"approved-owner",
|
|
54
|
+
"binding-substitute",
|
|
28
55
|
]);
|
|
29
56
|
const KEY_BYTES = 32;
|
|
30
|
-
const
|
|
31
|
-
const
|
|
57
|
+
const URL_PATH_PREFIX = "/rehearsal/";
|
|
58
|
+
const URL_TOKEN_LENGTH = 32;
|
|
59
|
+
const MAXIMUM_URL_LENGTH = 2_048;
|
|
60
|
+
const MAXIMUM_TEXT_SUBSTITUTION_BYTES = 65_536;
|
|
61
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
|
|
62
|
+
const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
|
|
63
|
+
const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
|
|
32
64
|
const MAXIMUM_POLICY_DEPTH = 16;
|
|
65
|
+
const MAXIMUM_ENUM_VALUES = 128;
|
|
66
|
+
const MAXIMUM_ENUM_STRING_BYTES = 256;
|
|
33
67
|
|
|
34
68
|
const isObject = (value) =>
|
|
35
69
|
value !== null &&
|
|
@@ -64,6 +98,51 @@ const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
|
|
|
64
98
|
return value;
|
|
65
99
|
};
|
|
66
100
|
|
|
101
|
+
const reviewedScalar = (value, label) => {
|
|
102
|
+
if (typeof value === "string") {
|
|
103
|
+
if (Buffer.byteLength(value) > MAXIMUM_ENUM_STRING_BYTES) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`${label} must contain at most ${MAXIMUM_ENUM_STRING_BYTES} UTF-8 bytes.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "boolean") return value;
|
|
111
|
+
if (
|
|
112
|
+
typeof value === "number" &&
|
|
113
|
+
Number.isFinite(value) &&
|
|
114
|
+
!Object.is(value, -0)
|
|
115
|
+
) {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
throw new Error(`${label} must be a JSON string, finite number, or boolean.`);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const httpsLoopbackOrigin = (value, label) => {
|
|
122
|
+
if (typeof value !== "string" || value.trim() !== value || !value) {
|
|
123
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
124
|
+
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = new URL(value);
|
|
128
|
+
} catch {
|
|
129
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
parsed.protocol !== "https:" ||
|
|
133
|
+
!LOOPBACK_HOSTNAMES.has(parsed.hostname) ||
|
|
134
|
+
![parsed.origin, `${parsed.origin}/`].includes(value) ||
|
|
135
|
+
parsed.username ||
|
|
136
|
+
parsed.password ||
|
|
137
|
+
(parsed.pathname !== "/" && parsed.pathname !== "") ||
|
|
138
|
+
parsed.search ||
|
|
139
|
+
parsed.hash
|
|
140
|
+
) {
|
|
141
|
+
throw new Error(`${label} must be a safe HTTPS loopback origin.`);
|
|
142
|
+
}
|
|
143
|
+
return parsed.origin;
|
|
144
|
+
};
|
|
145
|
+
|
|
67
146
|
const validateNestedDeclaration = (
|
|
68
147
|
declaration,
|
|
69
148
|
label,
|
|
@@ -105,7 +184,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
105
184
|
throw new Error(`${label}.recipe is required for ${action}.`);
|
|
106
185
|
}
|
|
107
186
|
if (action === "PSEUDONYMIZE") {
|
|
108
|
-
knownKeys(
|
|
187
|
+
knownKeys(
|
|
188
|
+
recipe,
|
|
189
|
+
[
|
|
190
|
+
"format",
|
|
191
|
+
"namespace",
|
|
192
|
+
"maxLength",
|
|
193
|
+
"length",
|
|
194
|
+
"allowedLengths",
|
|
195
|
+
"origin",
|
|
196
|
+
],
|
|
197
|
+
`${label}.recipe`,
|
|
198
|
+
);
|
|
109
199
|
if (!PSEUDONYM_FORMATS.has(recipe.format)) {
|
|
110
200
|
throw new Error(`${label}.recipe.format is unsupported.`);
|
|
111
201
|
}
|
|
@@ -119,9 +209,74 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
119
209
|
`${label}.recipe.maxLength`,
|
|
120
210
|
1_024,
|
|
121
211
|
);
|
|
212
|
+
} else if (recipe.format === "url") {
|
|
213
|
+
normalized.origin = httpsLoopbackOrigin(
|
|
214
|
+
recipe.origin,
|
|
215
|
+
`${label}.recipe.origin`,
|
|
216
|
+
);
|
|
217
|
+
normalized.maxLength = positiveInteger(
|
|
218
|
+
recipe.maxLength,
|
|
219
|
+
`${label}.recipe.maxLength`,
|
|
220
|
+
MAXIMUM_URL_LENGTH,
|
|
221
|
+
);
|
|
222
|
+
const outputLength =
|
|
223
|
+
normalized.origin.length + URL_PATH_PREFIX.length + URL_TOKEN_LENGTH;
|
|
224
|
+
if (normalized.maxLength < outputLength) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`${label}.recipe.maxLength must allow the complete pseudonymous URL (${outputLength} characters).`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
122
229
|
} else if (recipe.maxLength !== undefined) {
|
|
123
230
|
throw new Error(
|
|
124
|
-
`${label}.recipe.maxLength is supported only for text pseudonyms.`,
|
|
231
|
+
`${label}.recipe.maxLength is supported only for text and url pseudonyms.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (recipe.format === "hex") {
|
|
235
|
+
if (!HEX_LENGTHS.has(recipe.length)) {
|
|
236
|
+
throw new Error(`${label}.recipe.length must be 32 or 64 for hex.`);
|
|
237
|
+
}
|
|
238
|
+
normalized.length = recipe.length;
|
|
239
|
+
} else if (recipe.format === "gtin") {
|
|
240
|
+
const hasLength = Object.hasOwn(recipe, "length");
|
|
241
|
+
const hasAllowedLengths = Object.hasOwn(recipe, "allowedLengths");
|
|
242
|
+
if (hasLength === hasAllowedLengths) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`${label}.recipe must declare exactly one of length or allowedLengths for gtin.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (hasLength) {
|
|
248
|
+
if (!GTIN_LENGTHS.has(recipe.length)) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${label}.recipe.length must be 8, 12, 13, or 14 for gtin.`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
normalized.length = recipe.length;
|
|
254
|
+
} else {
|
|
255
|
+
if (
|
|
256
|
+
!Array.isArray(recipe.allowedLengths) ||
|
|
257
|
+
recipe.allowedLengths.length === 0 ||
|
|
258
|
+
recipe.allowedLengths.some((length) => !GTIN_LENGTHS.has(length))
|
|
259
|
+
) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`${label}.recipe.allowedLengths must contain reviewed GTIN lengths 8, 12, 13, or 14.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (
|
|
265
|
+
new Set(recipe.allowedLengths).size !== recipe.allowedLengths.length
|
|
266
|
+
) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`${label}.recipe.allowedLengths must not contain duplicates.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
normalized.allowedLengths = Object.freeze([...recipe.allowedLengths]);
|
|
272
|
+
}
|
|
273
|
+
} else if (
|
|
274
|
+
recipe.length !== undefined ||
|
|
275
|
+
recipe.allowedLengths !== undefined ||
|
|
276
|
+
(recipe.origin !== undefined && recipe.format !== "url")
|
|
277
|
+
) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`${label}.recipe shape controls do not match its pseudonym format.`,
|
|
125
280
|
);
|
|
126
281
|
}
|
|
127
282
|
return Object.freeze(normalized);
|
|
@@ -142,8 +297,19 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
142
297
|
[
|
|
143
298
|
"kind",
|
|
144
299
|
"days",
|
|
300
|
+
"group",
|
|
301
|
+
"representation",
|
|
302
|
+
"format",
|
|
303
|
+
"length",
|
|
304
|
+
"namespace",
|
|
305
|
+
"inputs",
|
|
306
|
+
"mapping",
|
|
307
|
+
"binding",
|
|
145
308
|
"fields",
|
|
146
309
|
"items",
|
|
310
|
+
"variants",
|
|
311
|
+
"keys",
|
|
312
|
+
"values",
|
|
147
313
|
"approved",
|
|
148
314
|
"otherwise",
|
|
149
315
|
"allowNull",
|
|
@@ -156,14 +322,184 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
156
322
|
if (!DERIVATION_KINDS.has(recipe.kind)) {
|
|
157
323
|
throw new Error(`${label}.recipe.kind is unsupported.`);
|
|
158
324
|
}
|
|
325
|
+
if (recipe.kind === "enum") {
|
|
326
|
+
if (
|
|
327
|
+
Object.keys(recipe).some(
|
|
328
|
+
(key) => !["kind", "values", "allowNull"].includes(key),
|
|
329
|
+
)
|
|
330
|
+
) {
|
|
331
|
+
throw new Error(`${label}.recipe contains fields not used by enum.`);
|
|
332
|
+
}
|
|
333
|
+
if (
|
|
334
|
+
!Array.isArray(recipe.values) ||
|
|
335
|
+
recipe.values.length === 0 ||
|
|
336
|
+
recipe.values.length > MAXIMUM_ENUM_VALUES
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`${label}.recipe.values must contain 1 through ${MAXIMUM_ENUM_VALUES} reviewed scalars.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (
|
|
343
|
+
recipe.allowNull !== undefined &&
|
|
344
|
+
typeof recipe.allowNull !== "boolean"
|
|
345
|
+
) {
|
|
346
|
+
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
347
|
+
}
|
|
348
|
+
const values = Array.from(recipe.values, (value, index) =>
|
|
349
|
+
reviewedScalar(value, `${label}.recipe.values[${index}]`),
|
|
350
|
+
);
|
|
351
|
+
const identities = values.map((value) =>
|
|
352
|
+
JSON.stringify([typeof value, value]),
|
|
353
|
+
);
|
|
354
|
+
if (new Set(identities).size !== identities.length) {
|
|
355
|
+
throw new Error(`${label}.recipe.values must not contain duplicates.`);
|
|
356
|
+
}
|
|
357
|
+
return Object.freeze({
|
|
358
|
+
kind: "enum",
|
|
359
|
+
allowNull: recipe.allowNull === true,
|
|
360
|
+
values: Object.freeze(values),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (recipe.kind === "validated-string") {
|
|
364
|
+
if (
|
|
365
|
+
Object.keys(recipe).some(
|
|
366
|
+
(key) => !["kind", "format", "maximumBytes", "allowNull"].includes(key),
|
|
367
|
+
)
|
|
368
|
+
) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`${label}.recipe contains fields not used by validated-string.`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (recipe.format !== "portable-code") {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`${label}.recipe.format must be portable-code for validated-string.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const maximumBytes = positiveInteger(
|
|
379
|
+
recipe.maximumBytes,
|
|
380
|
+
`${label}.recipe.maximumBytes`,
|
|
381
|
+
1_024,
|
|
382
|
+
);
|
|
383
|
+
if (
|
|
384
|
+
recipe.allowNull !== undefined &&
|
|
385
|
+
typeof recipe.allowNull !== "boolean"
|
|
386
|
+
) {
|
|
387
|
+
throw new Error(`${label}.recipe.allowNull must be true or false.`);
|
|
388
|
+
}
|
|
389
|
+
return Object.freeze({
|
|
390
|
+
kind: "validated-string",
|
|
391
|
+
format: "portable-code",
|
|
392
|
+
maximumBytes,
|
|
393
|
+
allowNull: recipe.allowNull === true,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (recipe.kind === "digest") {
|
|
397
|
+
if (
|
|
398
|
+
Object.keys(recipe).some(
|
|
399
|
+
(key) =>
|
|
400
|
+
!["kind", "format", "length", "namespace", "inputs"].includes(key),
|
|
401
|
+
)
|
|
402
|
+
) {
|
|
403
|
+
throw new Error(`${label}.recipe contains fields not used by digest.`);
|
|
404
|
+
}
|
|
405
|
+
if (recipe.format !== "hex") {
|
|
406
|
+
throw new Error(`${label}.recipe.format must be hex for digest.`);
|
|
407
|
+
}
|
|
408
|
+
if (!HEX_LENGTHS.has(recipe.length)) {
|
|
409
|
+
throw new Error(`${label}.recipe.length must be 32 or 64 for digest.`);
|
|
410
|
+
}
|
|
411
|
+
if (
|
|
412
|
+
!Array.isArray(recipe.inputs) ||
|
|
413
|
+
recipe.inputs.length === 0 ||
|
|
414
|
+
recipe.inputs.length > 32
|
|
415
|
+
) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`${label}.recipe.inputs must contain 1 through 32 columns.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const inputs = recipe.inputs.map((input, index) =>
|
|
421
|
+
identifier(input, `${label}.recipe.inputs[${index}]`),
|
|
422
|
+
);
|
|
423
|
+
if (new Set(inputs).size !== inputs.length) {
|
|
424
|
+
throw new Error(`${label}.recipe.inputs must not contain duplicates.`);
|
|
425
|
+
}
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
kind: "digest",
|
|
428
|
+
format: "hex",
|
|
429
|
+
length: recipe.length,
|
|
430
|
+
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
431
|
+
inputs: Object.freeze(inputs),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
159
434
|
if (recipe.kind === "date-shift") {
|
|
160
435
|
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
161
|
-
if (
|
|
436
|
+
if (
|
|
437
|
+
Object.keys(recipe).some(
|
|
438
|
+
(key) => !["kind", "days", "group", "representation"].includes(key),
|
|
439
|
+
)
|
|
440
|
+
) {
|
|
162
441
|
throw new Error(
|
|
163
442
|
`${label}.recipe contains fields not used by date-shift.`,
|
|
164
443
|
);
|
|
165
444
|
}
|
|
166
|
-
|
|
445
|
+
if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
|
|
446
|
+
throw new Error(`${label}.recipe.group is invalid.`);
|
|
447
|
+
}
|
|
448
|
+
const representation =
|
|
449
|
+
recipe.representation === undefined
|
|
450
|
+
? "iso-string"
|
|
451
|
+
: recipe.representation;
|
|
452
|
+
if (!DATE_REPRESENTATIONS.has(representation)) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`${label}.recipe.representation must be iso-string or epoch-milliseconds.`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return Object.freeze({
|
|
458
|
+
kind: "date-shift",
|
|
459
|
+
days,
|
|
460
|
+
representation,
|
|
461
|
+
...(recipe.group === undefined ? {} : { group: recipe.group }),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (recipe.kind === "path-map") {
|
|
465
|
+
if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
|
|
466
|
+
throw new Error(`${label}.recipe contains fields not used by path-map.`);
|
|
467
|
+
}
|
|
468
|
+
if (!BINDING_NAME.test(recipe.mapping ?? "")) {
|
|
469
|
+
throw new Error(`${label}.recipe.mapping is invalid.`);
|
|
470
|
+
}
|
|
471
|
+
return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
|
|
472
|
+
}
|
|
473
|
+
if (recipe.kind === "binding-substitute") {
|
|
474
|
+
if (
|
|
475
|
+
Object.keys(recipe).some(
|
|
476
|
+
(key) =>
|
|
477
|
+
!["kind", "binding", "format", "namespace", "maximumBytes"].includes(
|
|
478
|
+
key,
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`${label}.recipe contains fields not used by binding-substitute.`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (!BINDING_NAME.test(recipe.binding ?? "")) {
|
|
487
|
+
throw new Error(`${label}.recipe.binding is invalid.`);
|
|
488
|
+
}
|
|
489
|
+
if (recipe.format !== "uuid") {
|
|
490
|
+
throw new Error(`${label}.recipe.format must be uuid.`);
|
|
491
|
+
}
|
|
492
|
+
return Object.freeze({
|
|
493
|
+
kind: "binding-substitute",
|
|
494
|
+
binding: recipe.binding,
|
|
495
|
+
format: "uuid",
|
|
496
|
+
namespace: nonEmpty(recipe.namespace, `${label}.recipe.namespace`),
|
|
497
|
+
maximumBytes: positiveInteger(
|
|
498
|
+
recipe.maximumBytes,
|
|
499
|
+
`${label}.recipe.maximumBytes`,
|
|
500
|
+
MAXIMUM_TEXT_SUBSTITUTION_BYTES,
|
|
501
|
+
),
|
|
502
|
+
});
|
|
167
503
|
}
|
|
168
504
|
if (recipe.kind === "approved-owner") {
|
|
169
505
|
if (
|
|
@@ -189,90 +525,14 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
189
525
|
),
|
|
190
526
|
});
|
|
191
527
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
),
|
|
201
|
-
maximumDepth: positiveInteger(
|
|
202
|
-
recipe.maximumDepth ?? DEFAULT_JSON_MAXIMUM_DEPTH,
|
|
203
|
-
`${label}.recipe.maximumDepth`,
|
|
204
|
-
MAXIMUM_POLICY_DEPTH,
|
|
205
|
-
),
|
|
206
|
-
});
|
|
207
|
-
if (recipe.kind === "json-array") {
|
|
208
|
-
if (
|
|
209
|
-
Object.keys(recipe).some(
|
|
210
|
-
(key) =>
|
|
211
|
-
![
|
|
212
|
-
"kind",
|
|
213
|
-
"items",
|
|
214
|
-
"allowNull",
|
|
215
|
-
"maximumBytes",
|
|
216
|
-
"maximumDepth",
|
|
217
|
-
"maximumItems",
|
|
218
|
-
].includes(key),
|
|
219
|
-
)
|
|
220
|
-
) {
|
|
221
|
-
throw new Error(
|
|
222
|
-
`${label}.recipe contains fields not used by json-array.`,
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
return Object.freeze({
|
|
226
|
-
kind: "json-array",
|
|
227
|
-
allowNull: recipe.allowNull === true,
|
|
228
|
-
...bounds,
|
|
229
|
-
maximumItems: positiveInteger(
|
|
230
|
-
recipe.maximumItems,
|
|
231
|
-
`${label}.recipe.maximumItems`,
|
|
232
|
-
10_000,
|
|
233
|
-
),
|
|
234
|
-
items: validateNestedDeclaration(recipe.items, `${label}.recipe.items`, {
|
|
235
|
-
depth,
|
|
236
|
-
}),
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
|
|
240
|
-
throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
|
|
241
|
-
}
|
|
242
|
-
if (
|
|
243
|
-
Object.keys(recipe).some(
|
|
244
|
-
(key) =>
|
|
245
|
-
![
|
|
246
|
-
"kind",
|
|
247
|
-
"fields",
|
|
248
|
-
"allowNull",
|
|
249
|
-
"maximumBytes",
|
|
250
|
-
"maximumDepth",
|
|
251
|
-
].includes(key),
|
|
252
|
-
)
|
|
253
|
-
) {
|
|
254
|
-
throw new Error(`${label}.recipe contains fields not used by json-object.`);
|
|
255
|
-
}
|
|
256
|
-
const fields = Object.fromEntries(
|
|
257
|
-
Object.entries(recipe.fields).map(([key, declaration]) => {
|
|
258
|
-
if (!key) {
|
|
259
|
-
throw new Error(`${label}.recipe.fields contains an invalid key.`);
|
|
260
|
-
}
|
|
261
|
-
return [
|
|
262
|
-
key,
|
|
263
|
-
validateNestedDeclaration(
|
|
264
|
-
declaration,
|
|
265
|
-
`${label}.recipe.fields.${key}`,
|
|
266
|
-
{ allowRequired: true, depth },
|
|
267
|
-
),
|
|
268
|
-
];
|
|
269
|
-
}),
|
|
270
|
-
);
|
|
271
|
-
return Object.freeze({
|
|
272
|
-
kind: "json-object",
|
|
273
|
-
allowNull: recipe.allowNull === true,
|
|
274
|
-
...bounds,
|
|
275
|
-
fields: Object.freeze(fields),
|
|
528
|
+
return validateStructuredJsonRecipe({
|
|
529
|
+
recipe,
|
|
530
|
+
label,
|
|
531
|
+
depth,
|
|
532
|
+
maximumPolicyDepth: MAXIMUM_POLICY_DEPTH,
|
|
533
|
+
validateDeclaration: validateNestedDeclaration,
|
|
534
|
+
validatePseudonymRecipe: (nestedRecipe, nestedLabel, nestedDepth) =>
|
|
535
|
+
validateRecipe("PSEUDONYMIZE", nestedRecipe, nestedLabel, nestedDepth),
|
|
276
536
|
});
|
|
277
537
|
};
|
|
278
538
|
|
|
@@ -280,7 +540,14 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
280
540
|
if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
|
|
281
541
|
knownKeys(
|
|
282
542
|
policy,
|
|
283
|
-
[
|
|
543
|
+
[
|
|
544
|
+
"policyVersion",
|
|
545
|
+
"draft",
|
|
546
|
+
"migrationCutoff",
|
|
547
|
+
"bindings",
|
|
548
|
+
"pathMappings",
|
|
549
|
+
"tables",
|
|
550
|
+
],
|
|
284
551
|
"policy",
|
|
285
552
|
);
|
|
286
553
|
if (policy.policyVersion !== 2) {
|
|
@@ -323,6 +590,60 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
323
590
|
return [name, Object.freeze({ ...binding })];
|
|
324
591
|
}),
|
|
325
592
|
);
|
|
593
|
+
if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
|
|
594
|
+
throw new Error("policy.pathMappings must be an object.");
|
|
595
|
+
}
|
|
596
|
+
const pathMappings = Object.fromEntries(
|
|
597
|
+
Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
|
|
598
|
+
const label = `policy.pathMappings.${name}`;
|
|
599
|
+
if (!BINDING_NAME.test(name) || !isObject(mapping)) {
|
|
600
|
+
throw new Error("policy.pathMappings contains an invalid mapping.");
|
|
601
|
+
}
|
|
602
|
+
knownKeys(
|
|
603
|
+
mapping,
|
|
604
|
+
[
|
|
605
|
+
"binding",
|
|
606
|
+
"format",
|
|
607
|
+
"namespace",
|
|
608
|
+
"maxLength",
|
|
609
|
+
"maximumBytes",
|
|
610
|
+
"maximumSegments",
|
|
611
|
+
],
|
|
612
|
+
label,
|
|
613
|
+
);
|
|
614
|
+
if (!Object.hasOwn(bindings, mapping.binding ?? "")) {
|
|
615
|
+
throw new Error(`${label}.binding references an unknown binding.`);
|
|
616
|
+
}
|
|
617
|
+
if (!["uuid", "text"].includes(mapping.format)) {
|
|
618
|
+
throw new Error(`${label}.format must be uuid or text.`);
|
|
619
|
+
}
|
|
620
|
+
const normalized = {
|
|
621
|
+
binding: mapping.binding,
|
|
622
|
+
format: mapping.format,
|
|
623
|
+
namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
|
|
624
|
+
maximumBytes: positiveInteger(
|
|
625
|
+
mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES,
|
|
626
|
+
`${label}.maximumBytes`,
|
|
627
|
+
16_384,
|
|
628
|
+
),
|
|
629
|
+
maximumSegments: positiveInteger(
|
|
630
|
+
mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS,
|
|
631
|
+
`${label}.maximumSegments`,
|
|
632
|
+
256,
|
|
633
|
+
),
|
|
634
|
+
};
|
|
635
|
+
if (mapping.format === "text") {
|
|
636
|
+
normalized.maxLength = positiveInteger(
|
|
637
|
+
mapping.maxLength,
|
|
638
|
+
`${label}.maxLength`,
|
|
639
|
+
1_024,
|
|
640
|
+
);
|
|
641
|
+
} else if (mapping.maxLength !== undefined) {
|
|
642
|
+
throw new Error(`${label}.maxLength is supported only for text.`);
|
|
643
|
+
}
|
|
644
|
+
return [name, Object.freeze(normalized)];
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
326
647
|
const names = new Set();
|
|
327
648
|
const tables = policy.tables.map((table, tableIndex) => {
|
|
328
649
|
const label = `policy.tables[${tableIndex}]`;
|
|
@@ -405,43 +726,169 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
405
726
|
}
|
|
406
727
|
knownKeys(
|
|
407
728
|
table.ownerBinding,
|
|
408
|
-
["binding", "column"],
|
|
729
|
+
["binding", "column", "columns", "match", "nullBehavior"],
|
|
409
730
|
`${label}.ownerBinding`,
|
|
410
731
|
);
|
|
411
732
|
const binding = nonEmpty(
|
|
412
733
|
table.ownerBinding.binding,
|
|
413
734
|
`${label}.ownerBinding.binding`,
|
|
414
735
|
);
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
736
|
+
const hasColumn = table.ownerBinding.column !== undefined;
|
|
737
|
+
const hasColumns = table.ownerBinding.columns !== undefined;
|
|
738
|
+
if (hasColumn === hasColumns) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
`${label}.ownerBinding must declare exactly one of column or columns.`,
|
|
741
|
+
);
|
|
421
742
|
}
|
|
422
|
-
|
|
423
|
-
|
|
743
|
+
let ownerColumns;
|
|
744
|
+
if (hasColumn) {
|
|
745
|
+
if (table.ownerBinding.match !== undefined) {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`${label}.ownerBinding.match is supported only with columns.`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
ownerColumns = [
|
|
751
|
+
identifier(table.ownerBinding.column, `${label}.ownerBinding.column`),
|
|
752
|
+
];
|
|
753
|
+
} else {
|
|
754
|
+
if (
|
|
755
|
+
!Array.isArray(table.ownerBinding.columns) ||
|
|
756
|
+
table.ownerBinding.columns.length === 0 ||
|
|
757
|
+
table.ownerBinding.columns.length > 16
|
|
758
|
+
) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`${label}.ownerBinding.columns must contain 1 through 16 columns.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (table.ownerBinding.match !== "any") {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`${label}.ownerBinding.match must be any when columns are used.`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
ownerColumns = table.ownerBinding.columns.map((column, index) =>
|
|
769
|
+
identifier(column, `${label}.ownerBinding.columns[${index}]`),
|
|
770
|
+
);
|
|
771
|
+
if (new Set(ownerColumns).size !== ownerColumns.length) {
|
|
772
|
+
throw new Error(
|
|
773
|
+
`${label}.ownerBinding.columns must not contain duplicates.`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
424
776
|
}
|
|
425
|
-
const ownerColumn = columns.find((entry) => entry.name === column);
|
|
426
777
|
if (
|
|
427
|
-
|
|
428
|
-
|
|
778
|
+
table.ownerBinding.nullBehavior !== undefined &&
|
|
779
|
+
table.ownerBinding.nullBehavior !== "otherwise"
|
|
429
780
|
) {
|
|
430
781
|
throw new Error(
|
|
431
|
-
`${label}.ownerBinding
|
|
782
|
+
`${label}.ownerBinding.nullBehavior must be otherwise when declared.`,
|
|
432
783
|
);
|
|
433
784
|
}
|
|
434
|
-
|
|
785
|
+
if (!Object.hasOwn(bindings, binding)) {
|
|
786
|
+
throw new Error(`${label}.ownerBinding references an unknown binding.`);
|
|
787
|
+
}
|
|
788
|
+
for (const column of ownerColumns) {
|
|
789
|
+
if (!columnNames.has(column)) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`${label}.ownerBinding references an unknown column.`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
const ownerColumn = columns.find((entry) => entry.name === column);
|
|
795
|
+
if (ownerColumn.action !== "PSEUDONYMIZE") {
|
|
796
|
+
throw new Error(
|
|
797
|
+
`${label}.ownerBinding columns must be explicitly pseudonymized.`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
ownerBinding = Object.freeze({
|
|
802
|
+
binding,
|
|
803
|
+
columns: Object.freeze(ownerColumns),
|
|
804
|
+
match: "any",
|
|
805
|
+
...(table.ownerBinding.nullBehavior === undefined
|
|
806
|
+
? {}
|
|
807
|
+
: { nullBehavior: table.ownerBinding.nullBehavior }),
|
|
808
|
+
});
|
|
435
809
|
}
|
|
436
810
|
const usesApprovedOwner = (declaration) =>
|
|
437
811
|
declaration.recipe?.kind === "approved-owner" ||
|
|
438
|
-
(declaration.recipe
|
|
439
|
-
Object.values(declaration.recipe.fields).some(usesApprovedOwner)) ||
|
|
440
|
-
(declaration.recipe?.kind === "json-array" &&
|
|
441
|
-
usesApprovedOwner(declaration.recipe.items));
|
|
812
|
+
structuredJsonDeclarations(declaration.recipe).some(usesApprovedOwner);
|
|
442
813
|
if (columns.some(usesApprovedOwner) && !ownerBinding) {
|
|
443
814
|
throw new Error(`${label} uses approved-owner without ownerBinding.`);
|
|
444
815
|
}
|
|
816
|
+
const visitRecipes = (declaration, visit) => {
|
|
817
|
+
visit(declaration.recipe);
|
|
818
|
+
if (declaration.recipe?.kind === "approved-owner") {
|
|
819
|
+
visitRecipes(declaration.recipe.approved, visit);
|
|
820
|
+
visitRecipes(declaration.recipe.otherwise, visit);
|
|
821
|
+
} else {
|
|
822
|
+
structuredJsonDeclarations(declaration.recipe).forEach((nested) =>
|
|
823
|
+
visitRecipes(nested, visit),
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
for (const column of columns) {
|
|
828
|
+
visitRecipes(column, (recipe) => {
|
|
829
|
+
if (
|
|
830
|
+
recipe?.kind === "path-map" &&
|
|
831
|
+
!Object.hasOwn(pathMappings, recipe.mapping)
|
|
832
|
+
) {
|
|
833
|
+
throw new Error(
|
|
834
|
+
`${label} references unknown path mapping ${recipe.mapping}.`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
if (
|
|
838
|
+
recipe?.kind === "binding-substitute" &&
|
|
839
|
+
!Object.hasOwn(bindings, recipe.binding)
|
|
840
|
+
) {
|
|
841
|
+
throw new Error(
|
|
842
|
+
`${label} references unknown privacy binding ${recipe.binding}.`,
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
const dependencies = new Map(columns.map((column) => [column.name, []]));
|
|
848
|
+
const visitDigestInputs = (declaration) => {
|
|
849
|
+
if (declaration.recipe?.kind === "digest") {
|
|
850
|
+
return [...declaration.recipe.inputs];
|
|
851
|
+
}
|
|
852
|
+
if (declaration.recipe?.kind === "approved-owner") {
|
|
853
|
+
return [
|
|
854
|
+
...visitDigestInputs(declaration.recipe.approved),
|
|
855
|
+
...visitDigestInputs(declaration.recipe.otherwise),
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
return structuredJsonDeclarations(declaration.recipe).flatMap(
|
|
859
|
+
visitDigestInputs,
|
|
860
|
+
);
|
|
861
|
+
};
|
|
862
|
+
for (const column of columns) {
|
|
863
|
+
const inputs = [...new Set(visitDigestInputs(column))];
|
|
864
|
+
for (const input of inputs) {
|
|
865
|
+
const inputColumn = columns.find((entry) => entry.name === input);
|
|
866
|
+
if (!inputColumn) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`${label}.${column.name} digest references unknown column ${input}.`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
if (inputColumn.action === "EXCLUDE") {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`${label}.${column.name} digest input ${input} is excluded instead of sanitized.`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
dependencies.set(column.name, inputs);
|
|
878
|
+
}
|
|
879
|
+
const resolvedDependencies = new Set();
|
|
880
|
+
const resolvingDependencies = new Set();
|
|
881
|
+
const visitDependency = (columnName) => {
|
|
882
|
+
if (resolvedDependencies.has(columnName)) return;
|
|
883
|
+
if (resolvingDependencies.has(columnName)) {
|
|
884
|
+
throw new Error(`${label} contains a cyclic digest dependency.`);
|
|
885
|
+
}
|
|
886
|
+
resolvingDependencies.add(columnName);
|
|
887
|
+
for (const input of dependencies.get(columnName)) visitDependency(input);
|
|
888
|
+
resolvingDependencies.delete(columnName);
|
|
889
|
+
resolvedDependencies.add(columnName);
|
|
890
|
+
};
|
|
891
|
+
columns.forEach((column) => visitDependency(column.name));
|
|
445
892
|
return Object.freeze({
|
|
446
893
|
schema,
|
|
447
894
|
name,
|
|
@@ -450,10 +897,31 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
450
897
|
columns: Object.freeze(columns),
|
|
451
898
|
});
|
|
452
899
|
});
|
|
900
|
+
const dateShiftGroups = new Map();
|
|
901
|
+
const visitDateShiftGroups = (declaration) => {
|
|
902
|
+
const recipe = declaration.recipe;
|
|
903
|
+
if (recipe?.kind === "date-shift" && recipe.group) {
|
|
904
|
+
const existing = dateShiftGroups.get(recipe.group);
|
|
905
|
+
if (existing !== undefined && existing !== recipe.days) {
|
|
906
|
+
throw new Error(
|
|
907
|
+
`Privacy date-shift group ${recipe.group} must use one days value.`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
dateShiftGroups.set(recipe.group, recipe.days);
|
|
911
|
+
}
|
|
912
|
+
if (recipe?.kind === "approved-owner") {
|
|
913
|
+
visitDateShiftGroups(recipe.approved);
|
|
914
|
+
visitDateShiftGroups(recipe.otherwise);
|
|
915
|
+
} else {
|
|
916
|
+
structuredJsonDeclarations(recipe).forEach(visitDateShiftGroups);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
|
|
453
920
|
return Object.freeze({
|
|
454
921
|
policyVersion: 2,
|
|
455
922
|
migrationCutoff: policy.migrationCutoff,
|
|
456
923
|
bindings: Object.freeze(bindings),
|
|
924
|
+
pathMappings: Object.freeze(pathMappings),
|
|
457
925
|
tables: Object.freeze(tables),
|
|
458
926
|
});
|
|
459
927
|
};
|
|
@@ -465,6 +933,23 @@ const digest = (key, namespace, value) =>
|
|
|
465
933
|
.update(JSON.stringify(value))
|
|
466
934
|
.digest();
|
|
467
935
|
|
|
936
|
+
const gtinCheckDigit = (payload) => {
|
|
937
|
+
const sum = [...payload]
|
|
938
|
+
.reverse()
|
|
939
|
+
.reduce(
|
|
940
|
+
(total, digit, index) =>
|
|
941
|
+
total + Number(digit) * (index % 2 === 0 ? 3 : 1),
|
|
942
|
+
0,
|
|
943
|
+
);
|
|
944
|
+
return String((10 - (sum % 10)) % 10);
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const isValidGtin = (value, length) =>
|
|
948
|
+
typeof value === "string" &&
|
|
949
|
+
value.length === length &&
|
|
950
|
+
/^\d+$/u.test(value) &&
|
|
951
|
+
value.at(-1) === gtinCheckDigit(value.slice(0, -1));
|
|
952
|
+
|
|
468
953
|
const pseudonym = (key, recipe, value) => {
|
|
469
954
|
if (value === null) return null;
|
|
470
955
|
const bytes = digest(key, recipe.namespace, value);
|
|
@@ -481,62 +966,119 @@ const pseudonym = (key, recipe, value) => {
|
|
|
481
966
|
if (recipe.format === "integer") {
|
|
482
967
|
return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
|
|
483
968
|
}
|
|
969
|
+
if (recipe.format === "hex") {
|
|
970
|
+
return bytes.toString("hex").slice(0, recipe.length);
|
|
971
|
+
}
|
|
972
|
+
if (recipe.format === "gtin") {
|
|
973
|
+
const allowedLengths = recipe.allowedLengths ?? [recipe.length];
|
|
974
|
+
const sourceLength = typeof value === "string" ? value.length : null;
|
|
975
|
+
if (
|
|
976
|
+
sourceLength === null ||
|
|
977
|
+
!allowedLengths.includes(sourceLength) ||
|
|
978
|
+
!isValidGtin(value, sourceLength)
|
|
979
|
+
) {
|
|
980
|
+
if (recipe.length !== undefined) {
|
|
981
|
+
throw new Error(
|
|
982
|
+
`A gtin pseudonym source must be a valid normalized GTIN-${recipe.length} string.`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
throw new Error(
|
|
986
|
+
`A gtin pseudonym source must be a valid normalized GTIN string with an allowed length (${allowedLengths.join(", ")}).`,
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
const modulus = 10n ** BigInt(sourceLength - 1);
|
|
990
|
+
const payload = (BigInt(`0x${bytes.toString("hex")}`) % modulus)
|
|
991
|
+
.toString()
|
|
992
|
+
.padStart(sourceLength - 1, "0");
|
|
993
|
+
return `${payload}${gtinCheckDigit(payload)}`;
|
|
994
|
+
}
|
|
995
|
+
if (recipe.format === "url") {
|
|
996
|
+
if (
|
|
997
|
+
typeof value !== "string" ||
|
|
998
|
+
value.trim() !== value ||
|
|
999
|
+
/\s/u.test(value)
|
|
1000
|
+
) {
|
|
1001
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
1002
|
+
}
|
|
1003
|
+
let source;
|
|
1004
|
+
try {
|
|
1005
|
+
source = new URL(value);
|
|
1006
|
+
} catch {
|
|
1007
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
1008
|
+
}
|
|
1009
|
+
if (source.protocol !== "https:") {
|
|
1010
|
+
throw new Error("A url pseudonym source must be a valid HTTPS URL.");
|
|
1011
|
+
}
|
|
1012
|
+
const output = `${recipe.origin}${URL_PATH_PREFIX}${bytes
|
|
1013
|
+
.toString("hex")
|
|
1014
|
+
.slice(0, URL_TOKEN_LENGTH)}`;
|
|
1015
|
+
if (output.length > recipe.maxLength) {
|
|
1016
|
+
throw new Error("A url pseudonym exceeded its reviewed maximum length.");
|
|
1017
|
+
}
|
|
1018
|
+
return output;
|
|
1019
|
+
}
|
|
484
1020
|
const token = `rehearsal_${bytes.toString("hex")}`;
|
|
485
1021
|
return token.slice(0, recipe.maxLength);
|
|
486
1022
|
};
|
|
487
1023
|
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1024
|
+
const remapPath = ({ key, mapping, bindingValue, value, label }) => {
|
|
1025
|
+
if (typeof value !== "string") {
|
|
1026
|
+
throw new Error(`${label} must be a Storage-style path string.`);
|
|
1027
|
+
}
|
|
1028
|
+
if (Buffer.byteLength(value) > mapping.maximumBytes) {
|
|
1029
|
+
throw new Error(`${label} exceeds its reviewed byte limit.`);
|
|
1030
|
+
}
|
|
1031
|
+
const segments = value.split("/");
|
|
1032
|
+
if (
|
|
1033
|
+
!value ||
|
|
1034
|
+
value.startsWith("/") ||
|
|
1035
|
+
value.endsWith("/") ||
|
|
1036
|
+
segments.length > mapping.maximumSegments ||
|
|
1037
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
1038
|
+
) {
|
|
1039
|
+
throw new Error(`${label} is not a safe bounded Storage path.`);
|
|
1040
|
+
}
|
|
1041
|
+
if (
|
|
1042
|
+
typeof bindingValue !== "string" ||
|
|
1043
|
+
!bindingValue ||
|
|
1044
|
+
bindingValue.includes("/") ||
|
|
1045
|
+
bindingValue === "." ||
|
|
1046
|
+
bindingValue === ".."
|
|
1047
|
+
) {
|
|
1048
|
+
throw new Error(`${label} has an invalid reviewed identity binding.`);
|
|
1049
|
+
}
|
|
1050
|
+
if (segments[0] !== bindingValue) {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
`${label} does not begin with its exact reviewed identity.`,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
const destination = [
|
|
1056
|
+
String(pseudonym(key, mapping, bindingValue)),
|
|
1057
|
+
...segments.slice(1),
|
|
1058
|
+
].join("/");
|
|
1059
|
+
if (Buffer.byteLength(destination) > mapping.maximumBytes) {
|
|
1060
|
+
throw new Error(`${label} destination exceeds its reviewed byte limit.`);
|
|
1061
|
+
}
|
|
1062
|
+
return destination;
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
const substituteBinding = ({ key, recipe, bindingValue, value, label }) => {
|
|
1066
|
+
if (typeof bindingValue !== "string" || !UUID.test(bindingValue)) {
|
|
1067
|
+
throw new Error(`${label} has an invalid reviewed UUID binding.`);
|
|
1068
|
+
}
|
|
1069
|
+
if (value === null) return null;
|
|
1070
|
+
if (typeof value !== "string") {
|
|
1071
|
+
throw new Error(`${label} must be text or null for binding substitution.`);
|
|
1072
|
+
}
|
|
1073
|
+
if (Buffer.byteLength(value) > recipe.maximumBytes) {
|
|
1074
|
+
throw new Error(`${label} exceeds its reviewed byte limit.`);
|
|
1075
|
+
}
|
|
1076
|
+
const replacement = String(pseudonym(key, recipe, bindingValue));
|
|
1077
|
+
const destination = value.replaceAll(bindingValue, replacement);
|
|
1078
|
+
if (Buffer.byteLength(destination) > recipe.maximumBytes) {
|
|
1079
|
+
throw new Error(`${label} destination exceeds its reviewed byte limit.`);
|
|
1080
|
+
}
|
|
1081
|
+
return destination;
|
|
540
1082
|
};
|
|
541
1083
|
|
|
542
1084
|
const executeDeclaration = ({
|
|
@@ -545,6 +1087,9 @@ const executeDeclaration = ({
|
|
|
545
1087
|
key,
|
|
546
1088
|
path,
|
|
547
1089
|
ownerApproved,
|
|
1090
|
+
pathMappings,
|
|
1091
|
+
bindingValues,
|
|
1092
|
+
resolveInput,
|
|
548
1093
|
}) => {
|
|
549
1094
|
if (declaration.action === "KEEP") return value;
|
|
550
1095
|
if (declaration.action === "EXCLUDE") return undefined;
|
|
@@ -555,12 +1100,82 @@ const executeDeclaration = ({
|
|
|
555
1100
|
}
|
|
556
1101
|
if (declaration.recipe.kind === "date-shift") {
|
|
557
1102
|
if (value === null) return null;
|
|
1103
|
+
if (
|
|
1104
|
+
declaration.recipe.representation === "epoch-milliseconds" &&
|
|
1105
|
+
(typeof value !== "number" || !Number.isSafeInteger(value))
|
|
1106
|
+
) {
|
|
1107
|
+
throw new Error(`${path} must be a safe epoch-millisecond integer.`);
|
|
1108
|
+
}
|
|
558
1109
|
const date = new Date(value);
|
|
559
1110
|
if (!Number.isFinite(date.valueOf()))
|
|
560
1111
|
throw new Error(`${path} is not a valid date.`);
|
|
561
|
-
const direction =
|
|
1112
|
+
const direction = declaration.recipe.group
|
|
1113
|
+
? digest(key, "date-shift-group", declaration.recipe.group)[0] % 2 === 0
|
|
1114
|
+
? -1
|
|
1115
|
+
: 1
|
|
1116
|
+
: digest(key, path, value)[0] % 2 === 0
|
|
1117
|
+
? -1
|
|
1118
|
+
: 1;
|
|
562
1119
|
date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
|
|
563
|
-
|
|
1120
|
+
if (!Number.isFinite(date.valueOf())) {
|
|
1121
|
+
throw new Error(`${path} shifts outside the supported date range.`);
|
|
1122
|
+
}
|
|
1123
|
+
return declaration.recipe.representation === "epoch-milliseconds"
|
|
1124
|
+
? date.valueOf()
|
|
1125
|
+
: date.toISOString();
|
|
1126
|
+
}
|
|
1127
|
+
if (declaration.recipe.kind === "enum") {
|
|
1128
|
+
if (value === null && declaration.recipe.allowNull) return null;
|
|
1129
|
+
if (
|
|
1130
|
+
!declaration.recipe.values.some((candidate) =>
|
|
1131
|
+
Object.is(candidate, value),
|
|
1132
|
+
)
|
|
1133
|
+
) {
|
|
1134
|
+
throw new Error(`${path} is outside its reviewed scalar domain.`);
|
|
1135
|
+
}
|
|
1136
|
+
return value;
|
|
1137
|
+
}
|
|
1138
|
+
if (declaration.recipe.kind === "validated-string") {
|
|
1139
|
+
if (value === null && declaration.recipe.allowNull) return null;
|
|
1140
|
+
if (
|
|
1141
|
+
typeof value !== "string" ||
|
|
1142
|
+
Buffer.byteLength(value) > declaration.recipe.maximumBytes ||
|
|
1143
|
+
!PORTABLE_CODE.test(value)
|
|
1144
|
+
) {
|
|
1145
|
+
throw new Error(`${path} is not a valid reviewed portable code.`);
|
|
1146
|
+
}
|
|
1147
|
+
return value;
|
|
1148
|
+
}
|
|
1149
|
+
if (declaration.recipe.kind === "digest") {
|
|
1150
|
+
const inputs = declaration.recipe.inputs.map((name) => {
|
|
1151
|
+
const sanitized = resolveInput(name);
|
|
1152
|
+
if (sanitized === undefined) {
|
|
1153
|
+
throw new Error(`${path} digest input ${name} was not sanitized.`);
|
|
1154
|
+
}
|
|
1155
|
+
return { name, value: sanitized };
|
|
1156
|
+
});
|
|
1157
|
+
return digest(key, declaration.recipe.namespace, inputs)
|
|
1158
|
+
.toString("hex")
|
|
1159
|
+
.slice(0, declaration.recipe.length);
|
|
1160
|
+
}
|
|
1161
|
+
if (declaration.recipe.kind === "path-map") {
|
|
1162
|
+
const mapping = pathMappings[declaration.recipe.mapping];
|
|
1163
|
+
return remapPath({
|
|
1164
|
+
key,
|
|
1165
|
+
mapping,
|
|
1166
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
1167
|
+
value,
|
|
1168
|
+
label: path,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
if (declaration.recipe.kind === "binding-substitute") {
|
|
1172
|
+
return substituteBinding({
|
|
1173
|
+
key,
|
|
1174
|
+
recipe: declaration.recipe,
|
|
1175
|
+
bindingValue: bindingValues.get(declaration.recipe.binding),
|
|
1176
|
+
value,
|
|
1177
|
+
label: path,
|
|
1178
|
+
});
|
|
564
1179
|
}
|
|
565
1180
|
if (declaration.recipe.kind === "approved-owner") {
|
|
566
1181
|
if (ownerApproved === undefined) {
|
|
@@ -574,47 +1189,32 @@ const executeDeclaration = ({
|
|
|
574
1189
|
key,
|
|
575
1190
|
path,
|
|
576
1191
|
ownerApproved,
|
|
1192
|
+
pathMappings,
|
|
1193
|
+
bindingValues,
|
|
1194
|
+
resolveInput,
|
|
577
1195
|
});
|
|
578
1196
|
}
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
if (declaration.recipe.kind === "json-array") {
|
|
582
|
-
if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
|
|
583
|
-
return value.flatMap((item, index) => {
|
|
584
|
-
const transformed = executeDeclaration({
|
|
585
|
-
declaration: declaration.recipe.items,
|
|
586
|
-
value: item,
|
|
587
|
-
key,
|
|
588
|
-
path: `${path}[${index}]`,
|
|
589
|
-
ownerApproved,
|
|
590
|
-
});
|
|
591
|
-
return transformed === undefined ? [] : [transformed];
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
|
|
595
|
-
const actual = Object.keys(value).sort();
|
|
596
|
-
const expected = Object.keys(declaration.recipe.fields);
|
|
597
|
-
if (actual.some((field) => !expected.includes(field))) {
|
|
598
|
-
throw new Error(`${path} contains an unclassified JSON key.`);
|
|
1197
|
+
if (!STRUCTURED_JSON_KINDS.has(declaration.recipe.kind)) {
|
|
1198
|
+
throw new Error(`${path} uses an unsupported privacy recipe.`);
|
|
599
1199
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
value: value[field],
|
|
1200
|
+
return executeStructuredJsonDeclaration({
|
|
1201
|
+
declaration,
|
|
1202
|
+
value,
|
|
1203
|
+
key,
|
|
1204
|
+
path,
|
|
1205
|
+
pseudonymize: pseudonym,
|
|
1206
|
+
execute: (nestedDeclaration, nestedValue, nestedPath) =>
|
|
1207
|
+
executeDeclaration({
|
|
1208
|
+
declaration: nestedDeclaration,
|
|
1209
|
+
value: nestedValue,
|
|
611
1210
|
key,
|
|
612
|
-
path:
|
|
1211
|
+
path: nestedPath,
|
|
613
1212
|
ownerApproved,
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1213
|
+
pathMappings,
|
|
1214
|
+
bindingValues,
|
|
1215
|
+
resolveInput,
|
|
1216
|
+
}),
|
|
1217
|
+
});
|
|
618
1218
|
};
|
|
619
1219
|
|
|
620
1220
|
export const createPrivacyEngine = ({
|
|
@@ -652,6 +1252,19 @@ export const createPrivacyEngine = ({
|
|
|
652
1252
|
);
|
|
653
1253
|
return Object.freeze({
|
|
654
1254
|
keyFingerprint: createHash("sha256").update(secret).digest("hex"),
|
|
1255
|
+
remapPath({ mapping: mappingName, value }) {
|
|
1256
|
+
const mapping = normalized.pathMappings[mappingName];
|
|
1257
|
+
if (!mapping) {
|
|
1258
|
+
throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
|
|
1259
|
+
}
|
|
1260
|
+
return remapPath({
|
|
1261
|
+
key: secret,
|
|
1262
|
+
mapping,
|
|
1263
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
1264
|
+
value,
|
|
1265
|
+
label: `Privacy path mapping ${mappingName}`,
|
|
1266
|
+
});
|
|
1267
|
+
},
|
|
655
1268
|
sanitize(record) {
|
|
656
1269
|
if (
|
|
657
1270
|
!isObject(record) ||
|
|
@@ -677,22 +1290,67 @@ export const createPrivacyEngine = ({
|
|
|
677
1290
|
}
|
|
678
1291
|
let ownerApproved;
|
|
679
1292
|
if (table.ownerBinding) {
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
1293
|
+
const approvedValue = bindingValues.get(table.ownerBinding.binding);
|
|
1294
|
+
const owners = table.ownerBinding.columns.map(
|
|
1295
|
+
(column) => record.row[column],
|
|
1296
|
+
);
|
|
1297
|
+
for (const owner of owners) {
|
|
1298
|
+
if (owner === null) {
|
|
1299
|
+
if (table.ownerBinding.nullBehavior !== "otherwise") {
|
|
1300
|
+
throw new Error(
|
|
1301
|
+
`${relation} has an invalid owner binding value.`,
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (
|
|
1307
|
+
!["string", "number"].includes(typeof owner) ||
|
|
1308
|
+
owner === "" ||
|
|
1309
|
+
(typeof owner === "number" && !Number.isFinite(owner))
|
|
1310
|
+
) {
|
|
1311
|
+
throw new Error(`${relation} has an invalid owner binding value.`);
|
|
1312
|
+
}
|
|
683
1313
|
}
|
|
684
|
-
ownerApproved =
|
|
685
|
-
String(owner) ===
|
|
1314
|
+
ownerApproved = owners.some(
|
|
1315
|
+
(owner) => owner !== null && String(owner) === approvedValue,
|
|
1316
|
+
);
|
|
686
1317
|
}
|
|
687
|
-
const
|
|
688
|
-
|
|
1318
|
+
const sanitizedColumns = new Map();
|
|
1319
|
+
const resolvingColumns = new Set();
|
|
1320
|
+
const sanitizeColumn = (columnName) => {
|
|
1321
|
+
if (sanitizedColumns.has(columnName)) {
|
|
1322
|
+
return sanitizedColumns.get(columnName);
|
|
1323
|
+
}
|
|
1324
|
+
if (resolvingColumns.has(columnName)) {
|
|
1325
|
+
throw new Error(`${relation} contains a cyclic digest dependency.`);
|
|
1326
|
+
}
|
|
1327
|
+
const column = table.columns.find((entry) => entry.name === columnName);
|
|
1328
|
+
if (!column) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
`${relation} digest references unknown column ${columnName}.`,
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
resolvingColumns.add(columnName);
|
|
1334
|
+
try {
|
|
689
1335
|
const transformed = executeDeclaration({
|
|
690
1336
|
declaration: column,
|
|
691
1337
|
value: record.row[column.name],
|
|
692
1338
|
key: secret,
|
|
693
1339
|
path: `${relation}.${column.name}`,
|
|
694
1340
|
ownerApproved,
|
|
1341
|
+
pathMappings: normalized.pathMappings,
|
|
1342
|
+
bindingValues,
|
|
1343
|
+
resolveInput: sanitizeColumn,
|
|
695
1344
|
});
|
|
1345
|
+
sanitizedColumns.set(columnName, transformed);
|
|
1346
|
+
return transformed;
|
|
1347
|
+
} finally {
|
|
1348
|
+
resolvingColumns.delete(columnName);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
const row = Object.fromEntries(
|
|
1352
|
+
table.columns.flatMap((column) => {
|
|
1353
|
+
const transformed = sanitizeColumn(column.name);
|
|
696
1354
|
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
697
1355
|
}),
|
|
698
1356
|
);
|