@rehearsal-db/core 0.1.0-beta.13 → 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 +76 -0
- package/README.md +3 -1
- package/docs/configuration.md +40 -5
- package/docs/getting-started.md +28 -0
- package/docs/production-source.md +43 -7
- package/docs/releasing.md +5 -3
- package/docs/roadmap.md +3 -2
- package/docs/sanitization.md +415 -14
- package/docs/standalone-workflow.md +12 -6
- package/docs/troubleshooting.md +5 -1
- package/docs/tutorial.md +5 -0
- package/package.json +1 -1
- package/scripts/install/create_root_config.mjs +5 -22
- package/src/baseline/privacy_engine.mjs +660 -213
- package/src/baseline/privacy_structured_json.mjs +554 -0
- package/src/cli/rehearsal.mjs +3 -1
- package/src/cli/renderers.mjs +12 -0
- package/src/cli/source_commands.mjs +11 -3
- package/src/identity/claim.mjs +23 -21
- package/src/project/install_scaffold.mjs +35 -1
- package/src/project/setup.mjs +9 -1
- package/src/source/access.mjs +50 -5
- package/src/source/asset_transfer.mjs +139 -87
- package/src/source/external_reader.mjs +136 -23
- package/src/source/postgresql_access.mjs +80 -23
|
@@ -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,20 +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",
|
|
25
48
|
"path-map",
|
|
26
49
|
"json-object",
|
|
27
50
|
"json-array",
|
|
51
|
+
"json-union",
|
|
52
|
+
"json-dictionary",
|
|
28
53
|
"approved-owner",
|
|
54
|
+
"binding-substitute",
|
|
29
55
|
]);
|
|
30
56
|
const KEY_BYTES = 32;
|
|
31
|
-
const
|
|
32
|
-
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"]);
|
|
33
62
|
const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
|
|
34
63
|
const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
|
|
35
64
|
const MAXIMUM_POLICY_DEPTH = 16;
|
|
65
|
+
const MAXIMUM_ENUM_VALUES = 128;
|
|
66
|
+
const MAXIMUM_ENUM_STRING_BYTES = 256;
|
|
36
67
|
|
|
37
68
|
const isObject = (value) =>
|
|
38
69
|
value !== null &&
|
|
@@ -67,6 +98,51 @@ const positiveInteger = (value, label, maximum = Number.MAX_SAFE_INTEGER) => {
|
|
|
67
98
|
return value;
|
|
68
99
|
};
|
|
69
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
|
+
|
|
70
146
|
const validateNestedDeclaration = (
|
|
71
147
|
declaration,
|
|
72
148
|
label,
|
|
@@ -108,7 +184,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
108
184
|
throw new Error(`${label}.recipe is required for ${action}.`);
|
|
109
185
|
}
|
|
110
186
|
if (action === "PSEUDONYMIZE") {
|
|
111
|
-
knownKeys(
|
|
187
|
+
knownKeys(
|
|
188
|
+
recipe,
|
|
189
|
+
[
|
|
190
|
+
"format",
|
|
191
|
+
"namespace",
|
|
192
|
+
"maxLength",
|
|
193
|
+
"length",
|
|
194
|
+
"allowedLengths",
|
|
195
|
+
"origin",
|
|
196
|
+
],
|
|
197
|
+
`${label}.recipe`,
|
|
198
|
+
);
|
|
112
199
|
if (!PSEUDONYM_FORMATS.has(recipe.format)) {
|
|
113
200
|
throw new Error(`${label}.recipe.format is unsupported.`);
|
|
114
201
|
}
|
|
@@ -122,9 +209,74 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
122
209
|
`${label}.recipe.maxLength`,
|
|
123
210
|
1_024,
|
|
124
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
|
+
}
|
|
125
229
|
} else if (recipe.maxLength !== undefined) {
|
|
126
230
|
throw new Error(
|
|
127
|
-
`${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.`,
|
|
128
280
|
);
|
|
129
281
|
}
|
|
130
282
|
return Object.freeze(normalized);
|
|
@@ -146,9 +298,18 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
146
298
|
"kind",
|
|
147
299
|
"days",
|
|
148
300
|
"group",
|
|
301
|
+
"representation",
|
|
302
|
+
"format",
|
|
303
|
+
"length",
|
|
304
|
+
"namespace",
|
|
305
|
+
"inputs",
|
|
149
306
|
"mapping",
|
|
307
|
+
"binding",
|
|
150
308
|
"fields",
|
|
151
309
|
"items",
|
|
310
|
+
"variants",
|
|
311
|
+
"keys",
|
|
312
|
+
"values",
|
|
152
313
|
"approved",
|
|
153
314
|
"otherwise",
|
|
154
315
|
"allowNull",
|
|
@@ -161,11 +322,120 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
161
322
|
if (!DERIVATION_KINDS.has(recipe.kind)) {
|
|
162
323
|
throw new Error(`${label}.recipe.kind is unsupported.`);
|
|
163
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
|
+
}
|
|
164
434
|
if (recipe.kind === "date-shift") {
|
|
165
435
|
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
166
436
|
if (
|
|
167
437
|
Object.keys(recipe).some(
|
|
168
|
-
(key) => !["kind", "days", "group"].includes(key),
|
|
438
|
+
(key) => !["kind", "days", "group", "representation"].includes(key),
|
|
169
439
|
)
|
|
170
440
|
) {
|
|
171
441
|
throw new Error(
|
|
@@ -175,9 +445,19 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
175
445
|
if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
|
|
176
446
|
throw new Error(`${label}.recipe.group is invalid.`);
|
|
177
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
|
+
}
|
|
178
457
|
return Object.freeze({
|
|
179
458
|
kind: "date-shift",
|
|
180
459
|
days,
|
|
460
|
+
representation,
|
|
181
461
|
...(recipe.group === undefined ? {} : { group: recipe.group }),
|
|
182
462
|
});
|
|
183
463
|
}
|
|
@@ -190,6 +470,37 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
190
470
|
}
|
|
191
471
|
return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
|
|
192
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
|
+
});
|
|
503
|
+
}
|
|
193
504
|
if (recipe.kind === "approved-owner") {
|
|
194
505
|
if (
|
|
195
506
|
Object.keys(recipe).some(
|
|
@@ -214,90 +525,14 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
214
525
|
),
|
|
215
526
|
});
|
|
216
527
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
),
|
|
226
|
-
maximumDepth: positiveInteger(
|
|
227
|
-
recipe.maximumDepth ?? DEFAULT_JSON_MAXIMUM_DEPTH,
|
|
228
|
-
`${label}.recipe.maximumDepth`,
|
|
229
|
-
MAXIMUM_POLICY_DEPTH,
|
|
230
|
-
),
|
|
231
|
-
});
|
|
232
|
-
if (recipe.kind === "json-array") {
|
|
233
|
-
if (
|
|
234
|
-
Object.keys(recipe).some(
|
|
235
|
-
(key) =>
|
|
236
|
-
![
|
|
237
|
-
"kind",
|
|
238
|
-
"items",
|
|
239
|
-
"allowNull",
|
|
240
|
-
"maximumBytes",
|
|
241
|
-
"maximumDepth",
|
|
242
|
-
"maximumItems",
|
|
243
|
-
].includes(key),
|
|
244
|
-
)
|
|
245
|
-
) {
|
|
246
|
-
throw new Error(
|
|
247
|
-
`${label}.recipe contains fields not used by json-array.`,
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
return Object.freeze({
|
|
251
|
-
kind: "json-array",
|
|
252
|
-
allowNull: recipe.allowNull === true,
|
|
253
|
-
...bounds,
|
|
254
|
-
maximumItems: positiveInteger(
|
|
255
|
-
recipe.maximumItems,
|
|
256
|
-
`${label}.recipe.maximumItems`,
|
|
257
|
-
10_000,
|
|
258
|
-
),
|
|
259
|
-
items: validateNestedDeclaration(recipe.items, `${label}.recipe.items`, {
|
|
260
|
-
depth,
|
|
261
|
-
}),
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
if (!isObject(recipe.fields) || Object.keys(recipe.fields).length === 0) {
|
|
265
|
-
throw new Error(`${label}.recipe.fields must classify JSON object keys.`);
|
|
266
|
-
}
|
|
267
|
-
if (
|
|
268
|
-
Object.keys(recipe).some(
|
|
269
|
-
(key) =>
|
|
270
|
-
![
|
|
271
|
-
"kind",
|
|
272
|
-
"fields",
|
|
273
|
-
"allowNull",
|
|
274
|
-
"maximumBytes",
|
|
275
|
-
"maximumDepth",
|
|
276
|
-
].includes(key),
|
|
277
|
-
)
|
|
278
|
-
) {
|
|
279
|
-
throw new Error(`${label}.recipe contains fields not used by json-object.`);
|
|
280
|
-
}
|
|
281
|
-
const fields = Object.fromEntries(
|
|
282
|
-
Object.entries(recipe.fields).map(([key, declaration]) => {
|
|
283
|
-
if (!key) {
|
|
284
|
-
throw new Error(`${label}.recipe.fields contains an invalid key.`);
|
|
285
|
-
}
|
|
286
|
-
return [
|
|
287
|
-
key,
|
|
288
|
-
validateNestedDeclaration(
|
|
289
|
-
declaration,
|
|
290
|
-
`${label}.recipe.fields.${key}`,
|
|
291
|
-
{ allowRequired: true, depth },
|
|
292
|
-
),
|
|
293
|
-
];
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
return Object.freeze({
|
|
297
|
-
kind: "json-object",
|
|
298
|
-
allowNull: recipe.allowNull === true,
|
|
299
|
-
...bounds,
|
|
300
|
-
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),
|
|
301
536
|
});
|
|
302
537
|
};
|
|
303
538
|
|
|
@@ -491,37 +726,90 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
491
726
|
}
|
|
492
727
|
knownKeys(
|
|
493
728
|
table.ownerBinding,
|
|
494
|
-
["binding", "column"],
|
|
729
|
+
["binding", "column", "columns", "match", "nullBehavior"],
|
|
495
730
|
`${label}.ownerBinding`,
|
|
496
731
|
);
|
|
497
732
|
const binding = nonEmpty(
|
|
498
733
|
table.ownerBinding.binding,
|
|
499
734
|
`${label}.ownerBinding.binding`,
|
|
500
735
|
);
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
);
|
|
507
742
|
}
|
|
508
|
-
|
|
509
|
-
|
|
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
|
+
}
|
|
510
776
|
}
|
|
511
|
-
|
|
512
|
-
|
|
777
|
+
if (
|
|
778
|
+
table.ownerBinding.nullBehavior !== undefined &&
|
|
779
|
+
table.ownerBinding.nullBehavior !== "otherwise"
|
|
780
|
+
) {
|
|
513
781
|
throw new Error(
|
|
514
|
-
`${label}.ownerBinding
|
|
782
|
+
`${label}.ownerBinding.nullBehavior must be otherwise when declared.`,
|
|
515
783
|
);
|
|
516
784
|
}
|
|
517
|
-
|
|
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
|
+
});
|
|
518
809
|
}
|
|
519
810
|
const usesApprovedOwner = (declaration) =>
|
|
520
811
|
declaration.recipe?.kind === "approved-owner" ||
|
|
521
|
-
(declaration.recipe
|
|
522
|
-
Object.values(declaration.recipe.fields).some(usesApprovedOwner)) ||
|
|
523
|
-
(declaration.recipe?.kind === "json-array" &&
|
|
524
|
-
usesApprovedOwner(declaration.recipe.items));
|
|
812
|
+
structuredJsonDeclarations(declaration.recipe).some(usesApprovedOwner);
|
|
525
813
|
if (columns.some(usesApprovedOwner) && !ownerBinding) {
|
|
526
814
|
throw new Error(`${label} uses approved-owner without ownerBinding.`);
|
|
527
815
|
}
|
|
@@ -530,12 +818,10 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
530
818
|
if (declaration.recipe?.kind === "approved-owner") {
|
|
531
819
|
visitRecipes(declaration.recipe.approved, visit);
|
|
532
820
|
visitRecipes(declaration.recipe.otherwise, visit);
|
|
533
|
-
} else
|
|
534
|
-
|
|
821
|
+
} else {
|
|
822
|
+
structuredJsonDeclarations(declaration.recipe).forEach((nested) =>
|
|
535
823
|
visitRecipes(nested, visit),
|
|
536
824
|
);
|
|
537
|
-
} else if (declaration.recipe?.kind === "json-array") {
|
|
538
|
-
visitRecipes(declaration.recipe.items, visit);
|
|
539
825
|
}
|
|
540
826
|
};
|
|
541
827
|
for (const column of columns) {
|
|
@@ -548,8 +834,61 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
548
834
|
`${label} references unknown path mapping ${recipe.mapping}.`,
|
|
549
835
|
);
|
|
550
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
|
+
}
|
|
551
845
|
});
|
|
552
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));
|
|
553
892
|
return Object.freeze({
|
|
554
893
|
schema,
|
|
555
894
|
name,
|
|
@@ -573,10 +912,8 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
573
912
|
if (recipe?.kind === "approved-owner") {
|
|
574
913
|
visitDateShiftGroups(recipe.approved);
|
|
575
914
|
visitDateShiftGroups(recipe.otherwise);
|
|
576
|
-
} else
|
|
577
|
-
|
|
578
|
-
} else if (recipe?.kind === "json-array") {
|
|
579
|
-
visitDateShiftGroups(recipe.items);
|
|
915
|
+
} else {
|
|
916
|
+
structuredJsonDeclarations(recipe).forEach(visitDateShiftGroups);
|
|
580
917
|
}
|
|
581
918
|
};
|
|
582
919
|
tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
|
|
@@ -596,6 +933,23 @@ const digest = (key, namespace, value) =>
|
|
|
596
933
|
.update(JSON.stringify(value))
|
|
597
934
|
.digest();
|
|
598
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
|
+
|
|
599
953
|
const pseudonym = (key, recipe, value) => {
|
|
600
954
|
if (value === null) return null;
|
|
601
955
|
const bytes = digest(key, recipe.namespace, value);
|
|
@@ -612,6 +966,57 @@ const pseudonym = (key, recipe, value) => {
|
|
|
612
966
|
if (recipe.format === "integer") {
|
|
613
967
|
return Number(bytes.readBigUInt64BE(0) % 9_007_199_254_740_991n) + 1;
|
|
614
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
|
+
}
|
|
615
1020
|
const token = `rehearsal_${bytes.toString("hex")}`;
|
|
616
1021
|
return token.slice(0, recipe.maxLength);
|
|
617
1022
|
};
|
|
@@ -657,58 +1062,23 @@ const remapPath = ({ key, mapping, bindingValue, value, label }) => {
|
|
|
657
1062
|
return destination;
|
|
658
1063
|
};
|
|
659
1064
|
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
else size += 3;
|
|
678
|
-
}
|
|
679
|
-
return size;
|
|
680
|
-
};
|
|
681
|
-
const visit = (current, depth) => {
|
|
682
|
-
if (depth > recipe.maximumDepth) {
|
|
683
|
-
throw new Error(`${path} exceeds maximumDepth ${recipe.maximumDepth}.`);
|
|
684
|
-
}
|
|
685
|
-
if (typeof current === "string") bytes += jsonStringBytes(current);
|
|
686
|
-
else if (current === null) bytes += 4;
|
|
687
|
-
else if (typeof current === "number" && !Number.isFinite(current))
|
|
688
|
-
throw new Error(`${path} contains an unsupported JSON value.`);
|
|
689
|
-
else if (["number", "boolean"].includes(typeof current))
|
|
690
|
-
bytes += String(current).length;
|
|
691
|
-
else if (Array.isArray(current)) {
|
|
692
|
-
if (
|
|
693
|
-
recipe.kind === "json-array" &&
|
|
694
|
-
current.length > recipe.maximumItems
|
|
695
|
-
) {
|
|
696
|
-
throw new Error(`${path} exceeds maximumItems ${recipe.maximumItems}.`);
|
|
697
|
-
}
|
|
698
|
-
bytes += 2 + Math.max(0, current.length - 1);
|
|
699
|
-
for (const item of current) visit(item, depth + 1);
|
|
700
|
-
} else if (isObject(current)) {
|
|
701
|
-
bytes += 2 + Math.max(0, Object.keys(current).length - 1);
|
|
702
|
-
for (const [key, nested] of Object.entries(current)) {
|
|
703
|
-
bytes += jsonStringBytes(key) + 1;
|
|
704
|
-
visit(nested, depth + 1);
|
|
705
|
-
}
|
|
706
|
-
} else throw new Error(`${path} contains an unsupported JSON value.`);
|
|
707
|
-
if (bytes > recipe.maximumBytes) {
|
|
708
|
-
throw new Error(`${path} exceeds maximumBytes ${recipe.maximumBytes}.`);
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
visit(value, 1);
|
|
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;
|
|
712
1082
|
};
|
|
713
1083
|
|
|
714
1084
|
const executeDeclaration = ({
|
|
@@ -719,6 +1089,7 @@ const executeDeclaration = ({
|
|
|
719
1089
|
ownerApproved,
|
|
720
1090
|
pathMappings,
|
|
721
1091
|
bindingValues,
|
|
1092
|
+
resolveInput,
|
|
722
1093
|
}) => {
|
|
723
1094
|
if (declaration.action === "KEEP") return value;
|
|
724
1095
|
if (declaration.action === "EXCLUDE") return undefined;
|
|
@@ -729,6 +1100,12 @@ const executeDeclaration = ({
|
|
|
729
1100
|
}
|
|
730
1101
|
if (declaration.recipe.kind === "date-shift") {
|
|
731
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
|
+
}
|
|
732
1109
|
const date = new Date(value);
|
|
733
1110
|
if (!Number.isFinite(date.valueOf()))
|
|
734
1111
|
throw new Error(`${path} is not a valid date.`);
|
|
@@ -740,7 +1117,46 @@ const executeDeclaration = ({
|
|
|
740
1117
|
? -1
|
|
741
1118
|
: 1;
|
|
742
1119
|
date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
|
|
743
|
-
|
|
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);
|
|
744
1160
|
}
|
|
745
1161
|
if (declaration.recipe.kind === "path-map") {
|
|
746
1162
|
const mapping = pathMappings[declaration.recipe.mapping];
|
|
@@ -752,6 +1168,15 @@ const executeDeclaration = ({
|
|
|
752
1168
|
label: path,
|
|
753
1169
|
});
|
|
754
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
|
+
});
|
|
1179
|
+
}
|
|
755
1180
|
if (declaration.recipe.kind === "approved-owner") {
|
|
756
1181
|
if (ownerApproved === undefined) {
|
|
757
1182
|
throw new Error(`${path} has no reviewed owner condition.`);
|
|
@@ -766,51 +1191,30 @@ const executeDeclaration = ({
|
|
|
766
1191
|
ownerApproved,
|
|
767
1192
|
pathMappings,
|
|
768
1193
|
bindingValues,
|
|
1194
|
+
resolveInput,
|
|
769
1195
|
});
|
|
770
1196
|
}
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
if (declaration.recipe.kind === "json-array") {
|
|
774
|
-
if (!Array.isArray(value)) throw new Error(`${path} must be a JSON array.`);
|
|
775
|
-
return value.flatMap((item, index) => {
|
|
776
|
-
const transformed = executeDeclaration({
|
|
777
|
-
declaration: declaration.recipe.items,
|
|
778
|
-
value: item,
|
|
779
|
-
key,
|
|
780
|
-
path: `${path}[${index}]`,
|
|
781
|
-
ownerApproved,
|
|
782
|
-
pathMappings,
|
|
783
|
-
bindingValues,
|
|
784
|
-
});
|
|
785
|
-
return transformed === undefined ? [] : [transformed];
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
if (!isObject(value)) throw new Error(`${path} must be a JSON object.`);
|
|
789
|
-
const actual = Object.keys(value).sort();
|
|
790
|
-
const expected = Object.keys(declaration.recipe.fields);
|
|
791
|
-
if (actual.some((field) => !expected.includes(field))) {
|
|
792
|
-
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.`);
|
|
793
1199
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
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,
|
|
805
1210
|
key,
|
|
806
|
-
path:
|
|
1211
|
+
path: nestedPath,
|
|
807
1212
|
ownerApproved,
|
|
808
1213
|
pathMappings,
|
|
809
1214
|
bindingValues,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
);
|
|
1215
|
+
resolveInput,
|
|
1216
|
+
}),
|
|
1217
|
+
});
|
|
814
1218
|
};
|
|
815
1219
|
|
|
816
1220
|
export const createPrivacyEngine = ({
|
|
@@ -886,15 +1290,48 @@ export const createPrivacyEngine = ({
|
|
|
886
1290
|
}
|
|
887
1291
|
let ownerApproved;
|
|
888
1292
|
if (table.ownerBinding) {
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
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
|
+
}
|
|
892
1313
|
}
|
|
893
|
-
ownerApproved =
|
|
894
|
-
String(owner) ===
|
|
1314
|
+
ownerApproved = owners.some(
|
|
1315
|
+
(owner) => owner !== null && String(owner) === approvedValue,
|
|
1316
|
+
);
|
|
895
1317
|
}
|
|
896
|
-
const
|
|
897
|
-
|
|
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 {
|
|
898
1335
|
const transformed = executeDeclaration({
|
|
899
1336
|
declaration: column,
|
|
900
1337
|
value: record.row[column.name],
|
|
@@ -903,7 +1340,17 @@ export const createPrivacyEngine = ({
|
|
|
903
1340
|
ownerApproved,
|
|
904
1341
|
pathMappings: normalized.pathMappings,
|
|
905
1342
|
bindingValues,
|
|
1343
|
+
resolveInput: sanitizeColumn,
|
|
906
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);
|
|
907
1354
|
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
908
1355
|
}),
|
|
909
1356
|
);
|