mongo-data-anonymizer 0.2.1 → 0.3.0

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.
Files changed (55) hide show
  1. package/README.md +268 -18
  2. package/dist/anonymization/anonymize.d.ts +50 -8
  3. package/dist/anonymization/anonymize.js +747 -96
  4. package/dist/anonymization/anonymize.js.map +1 -1
  5. package/dist/anonymization/collections.d.ts +12 -0
  6. package/dist/anonymization/collections.js +14 -0
  7. package/dist/anonymization/collections.js.map +1 -0
  8. package/dist/anonymization/database.d.ts +69 -9
  9. package/dist/anonymization/database.js +233 -61
  10. package/dist/anonymization/database.js.map +1 -1
  11. package/dist/anonymization/randomizer.d.ts +8 -0
  12. package/dist/anonymization/randomizer.js +32 -0
  13. package/dist/anonymization/randomizer.js.map +1 -0
  14. package/dist/anonymization/rules.d.ts +60 -0
  15. package/dist/anonymization/rules.js +193 -0
  16. package/dist/anonymization/rules.js.map +1 -0
  17. package/dist/cli.js +11 -4
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config.d.ts +7 -0
  20. package/dist/config.js +182 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +6 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/logger.d.ts +7 -0
  26. package/dist/logger.js +14 -0
  27. package/dist/logger.js.map +1 -0
  28. package/dist/progress.d.ts +25 -0
  29. package/dist/progress.js +74 -0
  30. package/dist/progress.js.map +1 -0
  31. package/dist/run.d.ts +62 -0
  32. package/dist/run.js +365 -0
  33. package/dist/run.js.map +1 -0
  34. package/package.json +43 -29
  35. package/.eslintignore +0 -1
  36. package/.eslintrc.js +0 -25
  37. package/.github/workflows/release.yml +0 -37
  38. package/.github/workflows/test.yml +0 -22
  39. package/.nvmrc +0 -1
  40. package/.prettierrc +0 -7
  41. package/dist/main.d.ts +0 -1
  42. package/dist/main.js +0 -95
  43. package/dist/main.js.map +0 -1
  44. package/dist/utils/field-utils.d.ts +0 -1
  45. package/dist/utils/field-utils.js +0 -18
  46. package/dist/utils/field-utils.js.map +0 -1
  47. package/jest.config.js +0 -8
  48. package/src/anonymization/anonymize.ts +0 -116
  49. package/src/anonymization/database.ts +0 -50
  50. package/src/cli.ts +0 -4
  51. package/src/main.ts +0 -98
  52. package/src/utils/field-utils.ts +0 -16
  53. package/test/anonymizer/anonymizer.test.ts +0 -164
  54. package/test/utils/field-utils.test.ts +0 -31
  55. package/tsconfig.json +0 -14
@@ -1,112 +1,763 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Anonymize = void 0;
4
- const faker_1 = require("@faker-js/faker");
5
- class Anonymize {
6
- anonymizeBatch(batch, collectionName, list) {
7
- const keysToAnonymize = this.getKeysToAnonymize(list, collectionName);
8
- const fieldsToAnonymize = keysToAnonymize.map((item) => item.field);
9
- return batch.map((document) => this.anonymizeDocument(document, fieldsToAnonymize, keysToAnonymize));
10
- }
11
- getKeysToAnonymize(list, collectionName) {
12
- return list
13
- .filter((item) => !item.match(/^[a-z_]+\./gi) || item.startsWith(`${collectionName}.`))
14
- .map((item) => ({
15
- field: item.replace(`${collectionName}.`, "").replace(/:(?:.*)$/, "").toLowerCase(),
16
- replacement: item.includes(":") ? item.replace(/^(?:.*):/, "") : null,
17
- }));
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { Faker, base, en } from '@faker-js/faker';
3
+ import { CollectionRules, normalizeKey } from "./rules.js";
4
+ import { sfc32Randomizer } from "./randomizer.js";
5
+ /**
6
+ * Fixed reference date for faker's date generators, so generated dates don't
7
+ * drift with the wall clock and output stays identical across runs.
8
+ */
9
+ const REFERENCE_DATE = new Date('2025-01-01T00:00:00.000Z');
10
+ // The hash suffix keeps distinct originals distinct (unique indexes), and
11
+ // example.com is reserved, so a stray email can never reach a real person.
12
+ const emailGenerator = (faker, seedHex) => `${faker.person.firstName()}.${faker.person.lastName()}.${seedHex.slice(0, 8)}@example.com`
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9.@-]/g, '');
15
+ /** `Jane Doe <jane@x.com>`, `"Doe, Jane" <jane@x.com>` or `<jane@x.com>`. */
16
+ const NAMED_EMAIL = /^\s*"?([^"<>]*?)"?\s*<([^<>\s]+)>\s*$/;
17
+ /** `<jane@x.com>` and `jane@x.com.` are the address `jane@x.com`. */
18
+ function normalizeEmail(value) {
19
+ return value.trim().replace(/^<|>$/g, '').replace(/\.+$/, '');
20
+ }
21
+ /** A value that is a single email address, whatever its field is called. */
22
+ function isEmailAddress(value) {
23
+ return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim());
24
+ }
25
+ /**
26
+ * Generators picked from the (lower-cased) field name. Order matters: the
27
+ * first match wins, so more specific names come first.
28
+ */
29
+ const endsWith = (...suffixes) => (key) => suffixes.some((suffix) => key.endsWith(suffix));
30
+ const isLatitudeKey = (key) => key === 'lat' || key.endsWith('latitude');
31
+ const isLongitudeKey = (key) => ['lng', 'lon', 'long'].includes(key) || key.endsWith('longitude');
32
+ /** Values that describe the shape of a subdocument, not the person: kept as they are. */
33
+ const STRUCTURAL_KEYS = new Set(['type', 'kind', '__typename']);
34
+ const GEOJSON_TYPES = new Set([
35
+ 'Point',
36
+ 'MultiPoint',
37
+ 'LineString',
38
+ 'MultiLineString',
39
+ 'Polygon',
40
+ 'MultiPolygon',
41
+ ]);
42
+ function isGeoJson(value) {
43
+ return (typeof value.type === 'string' &&
44
+ GEOJSON_TYPES.has(value.type) &&
45
+ Array.isArray(value.coordinates));
46
+ }
47
+ /** Keys under which a pair of numbers is a position (`loc`, `coordinates`, `geoPoint`...). */
48
+ const GEO_KEY = /(coord|loc|geo|position|point|latlng|lnglat)/;
49
+ /** A legacy `[longitude, latitude]` pair. */
50
+ function isCoordinatePair(value) {
51
+ const [lng, lat] = value;
52
+ return (value.length === 2 &&
53
+ typeof lng === 'number' &&
54
+ typeof lat === 'number' &&
55
+ Math.abs(lng) <= 180 &&
56
+ Math.abs(lat) <= 90);
57
+ }
58
+ const GENERATORS = [
59
+ [isLatitudeKey, (faker) => faker.location.latitude()],
60
+ [isLongitudeKey, (faker) => faker.location.longitude()],
61
+ [(key) => key.includes('email'), emailGenerator],
62
+ [(key) => key.includes('firstname'), (faker) => faker.person.firstName()],
63
+ [
64
+ (key) => key.includes('lastname') || key.endsWith('surname'),
65
+ (faker) => faker.person.lastName(),
66
+ ],
67
+ [
68
+ (key) => key.endsWith('username') || key === 'login',
69
+ // The hash suffix keeps usernames unique, like emails.
70
+ (faker, seedHex) => `${faker.internet.username()}_${seedHex.slice(0, 6)}`,
71
+ ],
72
+ [
73
+ endsWith('description', 'comment', 'comments', 'note', 'notes', 'message', 'bio', 'body'),
74
+ (faker) => faker.lorem.sentence(),
75
+ ],
76
+ [
77
+ (key) => key.includes('password') ||
78
+ endsWith('token', 'secret', 'hash', 'digest', 'salt', 'apikey', 'secretkey', 'privatekey')(key),
79
+ (faker) => faker.string.alphanumeric(32),
80
+ ],
81
+ [
82
+ (key) => key === 'ip' || key.endsWith('ipaddress'),
83
+ (faker) => faker.internet.ipv4(),
84
+ ],
85
+ [endsWith('street'), (faker) => faker.location.street()],
86
+ [endsWith('address'), (faker) => faker.location.streetAddress()],
87
+ [endsWith('city'), (faker) => faker.location.city()],
88
+ [endsWith('country'), (faker) => faker.location.country()],
89
+ [
90
+ endsWith('zip', 'zipcode', 'postcode', 'postalcode'),
91
+ (faker) => faker.location.zipCode(),
92
+ ],
93
+ [
94
+ (key) => ['phone', 'mobile', 'fax'].some((word) => key.includes(word)),
95
+ (faker) => faker.phone.number(),
96
+ ],
97
+ [
98
+ (key) => key.includes('birth') || key === 'dob',
99
+ (faker) => faker.date.birthdate({ refDate: REFERENCE_DATE }),
100
+ ],
101
+ [endsWith('date'), (faker) => faker.date.past({ refDate: REFERENCE_DATE })],
102
+ [endsWith('company'), (faker) => faker.company.name()],
103
+ [endsWith('iban'), (faker) => faker.finance.iban()],
104
+ [
105
+ endsWith('ssn', 'passport'),
106
+ (faker) => faker.string.alphanumeric({ length: 9, casing: 'upper' }),
107
+ ],
108
+ [
109
+ endsWith('name', 'recipient', 'recipients', 'sender'),
110
+ (faker) => faker.person.fullName(),
111
+ ],
112
+ ];
113
+ function generatorFor(key) {
114
+ return GENERATORS.find(([matches]) => matches(key))?.[1];
115
+ }
116
+ const fallbackGenerator = (faker) => faker.word.sample();
117
+ /** Documents coming from the driver are plain objects; BSON values, Dates and buffers are not. */
118
+ function isPlainObject(value) {
119
+ if (typeof value !== 'object' || value === null)
120
+ return false;
121
+ const proto = Object.getPrototypeOf(value);
122
+ return proto === Object.prototype || proto === null;
123
+ }
124
+ function canonical(value) {
125
+ if (value instanceof Date) {
126
+ return Number.isNaN(value.getTime())
127
+ ? 'date:invalid'
128
+ : `date:${value.toISOString()}`;
129
+ }
130
+ return `${typeof value}:${String(value)}`;
131
+ }
132
+ function sameMagnitudeNumber(faker, original) {
133
+ const digits = Math.max(1, Math.floor(Math.log10(Math.abs(original) || 1)) + 1);
134
+ const max = 10 ** digits - 1;
135
+ const min = digits === 1 ? 0 : 10 ** (digits - 1);
136
+ const sign = original < 0 ? -1 : 1;
137
+ if (Math.abs(original) < 1 && !Number.isInteger(original)) {
138
+ return sign * faker.number.float({ min: 0, max: 0.99, fractionDigits: 2 });
18
139
  }
19
- anonymizeDocument(document, fieldsToAnonymize, keysToAnonymize) {
20
- var _a;
21
- const anonymizedDocument = {};
22
- for (const key in document) {
23
- if (!document.hasOwnProperty(key))
140
+ if (Number.isInteger(original)) {
141
+ return sign * faker.number.int({ min, max });
142
+ }
143
+ return sign * faker.number.float({ min, max, fractionDigits: 2 });
144
+ }
145
+ /**
146
+ * Key names that usually hold personal data. Used to point out fields that
147
+ * no rule matches (see `findUnmatchedPersonalKeys`); never to anonymize.
148
+ */
149
+ const PERSONAL_WORDS = new Set([
150
+ 'email',
151
+ 'emails',
152
+ 'mail',
153
+ 'phone',
154
+ 'phones',
155
+ 'telephone',
156
+ 'tel',
157
+ 'mobile',
158
+ 'fax',
159
+ 'phonenumber',
160
+ 'firstname',
161
+ 'lastname',
162
+ 'fullname',
163
+ 'surname',
164
+ 'username',
165
+ 'nickname',
166
+ 'address',
167
+ 'addresses',
168
+ 'street',
169
+ 'city',
170
+ 'zip',
171
+ 'zipcode',
172
+ 'postcode',
173
+ 'postal',
174
+ 'birth',
175
+ 'birthdate',
176
+ 'birthday',
177
+ 'dob',
178
+ 'ssn',
179
+ 'passport',
180
+ 'iban',
181
+ 'password',
182
+ 'token',
183
+ 'secret',
184
+ 'ip',
185
+ 'gender',
186
+ 'nationality',
187
+ 'recipient',
188
+ 'recipients',
189
+ 'sender',
190
+ 'latitude',
191
+ 'longitude',
192
+ 'lat',
193
+ 'lng',
194
+ ]);
195
+ /** Words that turn a following `name` into a person's name (`firstName`, `display_name`). */
196
+ const NAME_QUALIFIERS = new Set([
197
+ 'first',
198
+ 'last',
199
+ 'full',
200
+ 'middle',
201
+ 'maiden',
202
+ 'user',
203
+ 'nick',
204
+ 'display',
205
+ 'given',
206
+ 'family',
207
+ 'sur',
208
+ 'contact',
209
+ 'customer',
210
+ ]);
211
+ const EMAIL_LOCAL_CHAR = /[\p{L}\p{N}._%+'-]/u;
212
+ const EMAIL_AFTER_AT = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/;
213
+ /**
214
+ * Start and end offsets of the email addresses in a string. Scans from each
215
+ * `@`, so it stays linear on large text (a plain regex can take seconds on a
216
+ * big HTML string).
217
+ */
218
+ function emailSpans(text, limit = Infinity) {
219
+ const spans = [];
220
+ for (let at = text.indexOf('@'); at !== -1 && spans.length < limit; at = text.indexOf('@', at + 1)) {
221
+ let start = at;
222
+ while (start > 0 &&
223
+ at - start < 64 &&
224
+ EMAIL_LOCAL_CHAR.test(text[start - 1] ?? '')) {
225
+ start--;
226
+ }
227
+ while (start < at && (text[start] === '.' || text[start] === "'"))
228
+ start++;
229
+ const domain = EMAIL_AFTER_AT.exec(text.slice(at + 1, at + 256));
230
+ if (start >= at || !domain)
231
+ continue;
232
+ const end = at + 1 + domain[0].length;
233
+ // Not an email: `git@host:repo`, or the user info of a URL
234
+ // (`https://user@host/path`, `mongodb://u:p@host`).
235
+ const next = text[end];
236
+ // `git@github.com:org/repo`, but not `jane@x.com: urgent`.
237
+ const isHost = next === ':' && /[\w~/.]/.test(text[end + 1] ?? '');
238
+ const isUserInfo = /\/\/[^\s/]*$/.test(text.slice(Math.max(0, start - 256), start));
239
+ if (!isHost && !isUserInfo) {
240
+ spans.push([start, end]);
241
+ }
242
+ }
243
+ return spans;
244
+ }
245
+ function containsEmail(text) {
246
+ return emailSpans(text, 1).length > 0;
247
+ }
248
+ function holdsEmail(value) {
249
+ if (typeof value === 'string')
250
+ return containsEmail(value);
251
+ return (Array.isArray(value) &&
252
+ value.some((item) => typeof item === 'string' && containsEmail(item)));
253
+ }
254
+ /** Words that may follow a personal word without changing its meaning (`phoneNo`, `emailList`). */
255
+ const TRAILING_WORDS = new Set([
256
+ 'no',
257
+ 'nr',
258
+ 'num',
259
+ 'number',
260
+ 'list',
261
+ 'addr',
262
+ // A hashed password or token is still a credential.
263
+ 'hash',
264
+ 'digest',
265
+ 'salt',
266
+ ]);
267
+ /** Words that turn a following `key` into a credential (`apiKey`, `private_key`). */
268
+ const KEY_QUALIFIERS = new Set([
269
+ 'api',
270
+ 'secret',
271
+ 'private',
272
+ 'access',
273
+ 'signing',
274
+ 'encryption',
275
+ ]);
276
+ /** `homeCity` → home, city; `IPAddress` → ip, address; `date_of_birth` → date, of, birth. */
277
+ function words(key) {
278
+ return key
279
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
280
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
281
+ .toLowerCase()
282
+ .split(/[^a-z0-9]+/)
283
+ .filter(Boolean);
284
+ }
285
+ /**
286
+ * The key names a personal value: a personal word that is the last word, or
287
+ * is only followed by words like `no` or `list`. So `orderEmail` and
288
+ * `phoneNo` count, `emailTemplate` and `emailVerified` don't.
289
+ */
290
+ function looksPersonal(key) {
291
+ const parts = words(key);
292
+ let end = parts.length;
293
+ while (end > 1 && TRAILING_WORDS.has(parts[end - 1] ?? ''))
294
+ end--;
295
+ const last = parts[end - 1] ?? '';
296
+ const previous = parts[end - 2] ?? '';
297
+ return (PERSONAL_WORDS.has(last) ||
298
+ (last === 'name' && (end === 1 || NAME_QUALIFIERS.has(previous))) ||
299
+ (last === 'key' && KEY_QUALIFIERS.has(previous)) ||
300
+ (last === 'salt' && end === 1));
301
+ }
302
+ /** Replacement that keeps a field unchanged, e.g. `images.name:keep` to override a global `name` rule. */
303
+ export const KEEP = 'keep';
304
+ const NO_RULES = new CollectionRules([]);
305
+ const RESERVED_REPLACEMENTS = new Set([KEEP, 'null']);
306
+ /** Keys whose values may be lists of addresses (`to: 'Jane <j@x.com>, bob@y.com'`). */
307
+ const ADDRESS_KEY = /(recipients?|sender|^to|^cc|^bcc|^from|^replyto)$/;
308
+ export class Anonymizer {
309
+ #secret;
310
+ #strict;
311
+ #scrubEmails;
312
+ #faker = new Faker({
313
+ locale: [en, base],
314
+ randomizer: sfc32Randomizer(),
315
+ });
316
+ #warnedUnsupported = new Set();
317
+ #onWarning = () => { };
318
+ constructor(options = {}) {
319
+ // An empty secret would be a known key: treat it like no secret at all.
320
+ this.#secret = options.secret || randomBytes(32).toString('hex');
321
+ this.#strict = options.strict ?? false;
322
+ this.#scrubEmails = options.scrubEmails ?? true;
323
+ // Also covers faker.date.* replacements, which get no explicit refDate.
324
+ this.#faker.setDefaultRefDate(REFERENCE_DATE);
325
+ }
326
+ onWarning(handler) {
327
+ this.#onWarning = handler;
328
+ return this;
329
+ }
330
+ /**
331
+ * Throws for rules whose replacement can never be applied, before any data
332
+ * is written. Faker replacements are called once, since some methods only
333
+ * fail when called without arguments.
334
+ */
335
+ validateRules(rules) {
336
+ for (const rule of rules) {
337
+ if (rule.replacement === null || rule.replacement === KEEP)
24
338
  continue;
25
- if (fieldsToAnonymize.includes(key.toLowerCase())) {
26
- if (typeof document[key] === 'object') {
27
- if (Array.isArray(document[key]) && document[key].length > 0) {
28
- anonymizedDocument[key] = document[key].map((item) => this.anonymizeDocument(item, fieldsToAnonymize, keysToAnonymize));
29
- continue;
30
- }
31
- anonymizedDocument[key] = this.anonymizeDocument(document[key], fieldsToAnonymize, keysToAnonymize);
339
+ const lower = rule.replacement.trim().toLowerCase();
340
+ if (RESERVED_REPLACEMENTS.has(lower) && rule.replacement !== lower) {
341
+ throw new Error(`Replacement "${rule.replacement}" for ${rule.field}: did you mean "${lower}"? Reserved words are lower-case; anything else is used as literal text.`);
342
+ }
343
+ const replacement = this.#literalOrGenerator(rule.replacement);
344
+ if (replacement.kind === 'generator') {
345
+ try {
346
+ replacement.generator(this.#faker, this.#seed('validation'));
347
+ }
348
+ catch (error) {
349
+ throw new Error(`Replacement "${rule.replacement}" fails when called without arguments: ${error.message}`, { cause: error });
350
+ }
351
+ }
352
+ }
353
+ }
354
+ /**
355
+ * Anonymizes every document in the batch. `rules` are the rules that apply
356
+ * to the batch's collection, keyed by lower-cased field name.
357
+ */
358
+ anonymizeBatch(batch, rules) {
359
+ return batch.map((document) => this.anonymizeDocument(document, rules));
360
+ }
361
+ /**
362
+ * Anonymizes one document. `_id` is never matched as a whole, but values
363
+ * inside a compound `_id` are anonymized like any other subdocument.
364
+ */
365
+ anonymizeDocument(document, rules) {
366
+ return this.#walk(document, rules);
367
+ }
368
+ /**
369
+ * Dotted paths of keys that no rule covers but that look like personal
370
+ * data, either by name (`profile.phoneNumber`) or because their text
371
+ * contains an email address (`notes`, `message`). Array indexes are left out.
372
+ */
373
+ findUnmatchedPersonalKeys(document, rules) {
374
+ const found = new Set();
375
+ const visit = (value, path, rules) => {
376
+ if (Array.isArray(value)) {
377
+ value.forEach((item) => visit(item, path, rules));
378
+ return;
379
+ }
380
+ if (!isPlainObject(value))
381
+ return;
382
+ for (const [key, child] of Object.entries(value)) {
383
+ const childPath = path ? `${path}.${key}` : key;
384
+ const rule = rules.match(key);
385
+ if (rule) {
386
+ // A kept field is written as it is, so what's inside still counts.
387
+ if (rule.replacement === KEEP)
388
+ visit(child, childPath, NO_RULES);
389
+ continue;
390
+ }
391
+ // Booleans and numbers (`sendEmail: true`) are flags or counts, not personal data.
392
+ const isFlag = typeof child === 'boolean' || typeof child === 'number';
393
+ if (key !== '_id' &&
394
+ !isFlag &&
395
+ (looksPersonal(key) || (!this.#scrubEmails && holdsEmail(child)))) {
396
+ found.add(childPath);
397
+ }
398
+ visit(child, childPath, rules);
399
+ }
400
+ };
401
+ visit(document, '', rules);
402
+ return found;
403
+ }
404
+ /** Looks for matching keys at any depth; everything else is kept as-is. */
405
+ #walk(value, rules) {
406
+ if (Array.isArray(value)) {
407
+ return value.map((item) => this.#walk(item, rules));
408
+ }
409
+ if (typeof value === 'string' && this.#scrubEmails) {
410
+ // `Jane Doe <jane@x.com>` (e.g. in `to` or `headers.From`): fake the name too.
411
+ if (value.includes('<') && containsEmail(value)) {
412
+ const list = this.#fakeAddressList(value);
413
+ if (list !== null)
414
+ return list;
415
+ }
416
+ return this.#replaceEmails(value);
417
+ }
418
+ if (!isPlainObject(value)) {
419
+ return value;
420
+ }
421
+ const result = {};
422
+ for (const [key, child] of Object.entries(value)) {
423
+ const rule = key === '_id' ? undefined : rules.match(key);
424
+ result[key] = rule
425
+ ? this.#anonymizeMatched(child, key, rule, rules)
426
+ : this.#walk(child, rules);
427
+ }
428
+ return result;
429
+ }
430
+ #anonymizeMatched(value, key, rule, rules) {
431
+ if (rule.replacement === KEEP) {
432
+ // The whole value is kept, subdocuments included; only emails inside
433
+ // are still scrubbed.
434
+ return this.#walk(value, NO_RULES);
435
+ }
436
+ if (rule.replacement !== null) {
437
+ const replacement = this.#literalOrGenerator(rule.replacement);
438
+ if (replacement.kind === 'literal') {
439
+ return replacement.value;
440
+ }
441
+ const leaf = { generator: replacement.generator, explicit: true };
442
+ return this.#mapLeaves(value, key, rules, () => leaf);
443
+ }
444
+ const parentGenerator = generatorFor(normalizeKey(key)) ?? fallbackGenerator;
445
+ return this.#mapLeaves(value, key, rules, (leafKey) => ({
446
+ generator: generatorFor(normalizeKey(leafKey)) ?? parentGenerator,
447
+ explicit: false,
448
+ }));
449
+ }
450
+ /**
451
+ * Replaces every leaf under a matched field. A nested key with a rule of
452
+ * its own follows that rule (e.g. `city:REDACTED` inside a matched
453
+ * `address`). Other leaves use the generator for their own key when it is
454
+ * recognised, and the matched field's generator otherwise.
455
+ */
456
+ #mapLeaves(value, key, rules, pickGenerator) {
457
+ if (Array.isArray(value)) {
458
+ if (isCoordinatePair(value) &&
459
+ GEO_KEY.test(normalizeKey(key)) &&
460
+ !pickGenerator(key).explicit) {
461
+ return this.#fakePosition(value);
462
+ }
463
+ return value.map((item) => this.#mapLeaves(item, key, rules, pickGenerator));
464
+ }
465
+ if (isPlainObject(value)) {
466
+ if (isGeoJson(value)) {
467
+ return this.#fakeGeoJson(value, key, rules, pickGenerator);
468
+ }
469
+ const result = {};
470
+ for (const [childKey, child] of Object.entries(value)) {
471
+ if (childKey === '_id') {
472
+ // Subdocument ids (e.g. Mongoose's) are kept like the document's own.
473
+ result[childKey] = this.#walk(child, rules);
32
474
  continue;
33
475
  }
34
- anonymizedDocument[key] = this.anonymizeValue(key.toLowerCase(), (_a = keysToAnonymize.find((item) => item.field === key.toLowerCase())) === null || _a === void 0 ? void 0 : _a.replacement);
476
+ const rule = rules.match(childKey);
477
+ if (rule) {
478
+ result[childKey] = this.#anonymizeMatched(child, childKey, rule, rules);
479
+ }
480
+ else if (STRUCTURAL_KEYS.has(childKey.toLowerCase()) &&
481
+ typeof child === 'string') {
482
+ // Kept, like an unmatched value: only emails in it are replaced.
483
+ result[childKey] = this.#walk(child, NO_RULES);
484
+ }
485
+ else {
486
+ result[childKey] = this.#mapLeaves(child, childKey, rules, pickGenerator);
487
+ }
488
+ }
489
+ return result;
490
+ }
491
+ return this.#fakeLeaf(value, key, pickGenerator(key));
492
+ }
493
+ #fakeLeaf(value, key, { generator, explicit }) {
494
+ if (value === null ||
495
+ value === undefined ||
496
+ value === '' ||
497
+ typeof value === 'boolean' ||
498
+ (typeof value === 'number' && !Number.isFinite(value))) {
499
+ return value;
500
+ }
501
+ const seedHex = this.#seed(value);
502
+ if (explicit) {
503
+ return generator(this.#faker, seedHex);
504
+ }
505
+ if (value instanceof Date && Number.isNaN(value.getTime())) {
506
+ return this.#unsupported(value, key);
507
+ }
508
+ if (typeof value === 'number') {
509
+ const normalized = normalizeKey(key);
510
+ return isLatitudeKey(normalized) || isLongitudeKey(normalized)
511
+ ? generator(this.#faker, seedHex)
512
+ : sameMagnitudeNumber(this.#faker, value);
513
+ }
514
+ if (value instanceof Date) {
515
+ const generated = generator(this.#faker, seedHex);
516
+ return generated instanceof Date
517
+ ? generated
518
+ : this.#faker.date.past({ refDate: REFERENCE_DATE });
519
+ }
520
+ if (typeof value === 'string') {
521
+ // An email stays an email (and the same one) under any field name, e.g. `recipient`.
522
+ const address = this.#fakeAddress(value.trim());
523
+ if (address !== null)
524
+ return address;
525
+ if ((generator === emailGenerator || ADDRESS_KEY.test(normalizeKey(key))) &&
526
+ containsEmail(value)) {
527
+ const list = this.#fakeAddressList(value);
528
+ if (list !== null)
529
+ return list;
530
+ }
531
+ const generated = generator(this.#faker, seedHex);
532
+ if (generated instanceof Date)
533
+ return generated.toISOString();
534
+ return typeof generated === 'string'
535
+ ? generated
536
+ : JSON.stringify(generated);
537
+ }
538
+ return this.#unsupported(value, key);
539
+ }
540
+ /** ObjectId, Binary, Decimal128, invalid dates, ...: there is no sensible fake of the same type. */
541
+ #unsupported(value, key) {
542
+ const type = value instanceof Date
543
+ ? 'invalid Date'
544
+ : (value?.constructor?.name ?? typeof value);
545
+ const hint = `If the field holds references rather than personal data, remove its rule (e.g. --fieldList -${key}); otherwise overwrite it with a replacement such as "${key}:null".`;
546
+ if (this.#strict) {
547
+ throw new Error(`Field "${key}" holds a value of unsupported type ${type} (--strict). ${hint}`);
548
+ }
549
+ if (!this.#warnedUnsupported.has(key)) {
550
+ this.#warnedUnsupported.add(key);
551
+ this.#onWarning(`Field "${key}" holds a value of unsupported type ${type}; it was left unchanged. ${hint}`);
552
+ }
553
+ return value;
554
+ }
555
+ /**
556
+ * A GeoJSON object under a matched field keeps its type, a Point gets a
557
+ * fake but valid position (so geo indexes still build), other geometries
558
+ * keep their coordinates, and every other key goes through the usual rules.
559
+ */
560
+ #fakeGeoJson(value, key, rules, pickGenerator) {
561
+ const { type, coordinates, ...others } = value;
562
+ const rest = this.#mapLeaves(others, key, rules, pickGenerator);
563
+ const result = {};
564
+ for (const field of Object.keys(value)) {
565
+ if (field === 'type') {
566
+ result.type = type;
567
+ }
568
+ else if (field === 'coordinates') {
569
+ result.coordinates = this.#fakeGeometry(type, coordinates);
570
+ }
571
+ else {
572
+ result[field] = rest[field];
573
+ }
574
+ }
575
+ return result;
576
+ }
577
+ /**
578
+ * Point: a fake position. Other geometries (a GPS track, a home area...):
579
+ * a small valid shape of the same type around a fake position, so the real
580
+ * coordinates don't reach the target and geo indexes still build.
581
+ */
582
+ #fakeGeometry(type, coordinates) {
583
+ if (type === 'Point') {
584
+ const [lng, lat, ...extra] = coordinates;
585
+ return typeof lng === 'number' && typeof lat === 'number'
586
+ ? [...this.#fakePosition([lng, lat]), ...extra]
587
+ : coordinates;
588
+ }
589
+ this.#seed(JSON.stringify(coordinates));
590
+ const [lng, lat] = this.#randomPosition();
591
+ const d = 0.001;
592
+ const line = [
593
+ [lng, lat],
594
+ [lng + d, lat + d],
595
+ ];
596
+ const ring = [
597
+ [lng, lat],
598
+ [lng + d, lat],
599
+ [lng + d, lat + d],
600
+ [lng, lat],
601
+ ];
602
+ switch (type) {
603
+ case 'MultiPoint':
604
+ return [[lng, lat]];
605
+ case 'LineString':
606
+ return line;
607
+ case 'MultiLineString':
608
+ return [line];
609
+ case 'Polygon':
610
+ return [ring];
611
+ case 'MultiPolygon':
612
+ return [[ring]];
613
+ default:
614
+ return coordinates;
615
+ }
616
+ }
617
+ /** A fake `[longitude, latitude]` for a position, the same for the same position. */
618
+ #fakePosition([lng, lat]) {
619
+ this.#seed(`${lng},${lat}`);
620
+ return this.#randomPosition();
621
+ }
622
+ /** Kept a little away from the poles and the antimeridian, so small shapes stay valid. */
623
+ #randomPosition() {
624
+ return [
625
+ this.#faker.location.longitude({ min: -179, max: 179 }),
626
+ this.#faker.location.latitude({ min: -89, max: 89 }),
627
+ ];
628
+ }
629
+ /**
630
+ * `Jane <jane@x.com>, bob@y.com; mailto:rick@z.com`: every address gets the
631
+ * same fake email as anywhere else and every name a fake name; separators
632
+ * are kept. Null when some item isn't an address, so the caller replaces
633
+ * the whole value instead.
634
+ */
635
+ #fakeAddressList(value) {
636
+ const items = [];
637
+ const separators = [];
638
+ let current = '';
639
+ let quoted = false;
640
+ for (let i = 0; i < value.length; i++) {
641
+ const char = value[i] ?? '';
642
+ if (char === '"')
643
+ quoted = !quoted;
644
+ if (!quoted && (char === ',' || char === ';')) {
645
+ let separator = char;
646
+ while (value[i + 1] === ' ')
647
+ separator += value[++i];
648
+ items.push(current);
649
+ separators.push(separator);
650
+ current = '';
35
651
  }
36
652
  else {
37
- anonymizedDocument[key] = document[key];
653
+ current += char;
38
654
  }
39
655
  }
40
- return anonymizedDocument;
656
+ items.push(current);
657
+ const faked = [];
658
+ for (const item of items) {
659
+ const address = this.#fakeAddress(item.trim());
660
+ if (address === null)
661
+ return null;
662
+ faked.push(address);
663
+ }
664
+ return faked.map((item, i) => item + (separators[i] ?? '')).join('');
41
665
  }
42
- anonymizeValue(key, replacement) {
43
- if (replacement) {
44
- return this.applyReplacement(replacement);
666
+ /** One address: `jane@x.com`, `<jane@x.com>`, `Jane <jane@x.com>`, `mailto:jane@x.com`. */
667
+ #fakeAddress(item) {
668
+ const mailto = /^mailto:/i.exec(item);
669
+ if (mailto) {
670
+ const rest = this.#fakeAddress(item.slice(mailto[0].length));
671
+ return rest === null ? null : `${mailto[0]}${rest}`;
45
672
  }
46
- return this.getFakerValueForField(key);
673
+ const named = NAMED_EMAIL.exec(item);
674
+ if (named?.[2] && isEmailAddress(named[2])) {
675
+ const email = this.#fakeEmail(normalizeEmail(named[2]));
676
+ const name = named[1]?.trim();
677
+ if (!name)
678
+ return `<${email}>`;
679
+ this.#seed(name);
680
+ return `${this.#faker.person.fullName()} <${email}>`;
681
+ }
682
+ return isEmailAddress(item) ? this.#fakeEmail(normalizeEmail(item)) : null;
683
+ }
684
+ /** The fake email for an email address: the same wherever the address appears. */
685
+ #fakeEmail(email) {
686
+ return emailGenerator(this.#faker, this.#seed(email));
47
687
  }
48
- applyReplacement(replacement) {
49
- if (replacement.startsWith("faker")) {
50
- return this.getFakerValue(replacement);
688
+ /** Replaces every email address inside a string, keeping the text around it. */
689
+ #replaceEmails(text) {
690
+ const spans = emailSpans(text);
691
+ if (spans.length === 0)
692
+ return text;
693
+ let result = '';
694
+ let last = 0;
695
+ for (const [start, end] of spans) {
696
+ result +=
697
+ text.slice(last, start) + this.#fakeEmail(text.slice(start, end));
698
+ last = end;
699
+ }
700
+ return result + text.slice(last);
701
+ }
702
+ /** Seeds faker from HMAC(secret, value) and returns the digest as hex. */
703
+ #seed(value) {
704
+ const digest = createHmac('sha256', this.#secret)
705
+ .update(canonical(value))
706
+ .digest();
707
+ this.#faker.seed([
708
+ digest.readUInt32BE(0),
709
+ digest.readUInt32BE(4),
710
+ digest.readUInt32BE(8),
711
+ digest.readUInt32BE(12),
712
+ ]);
713
+ return digest.toString('hex');
714
+ }
715
+ #literalOrGenerator(replacement) {
716
+ if (replacement.startsWith('faker.')) {
717
+ return {
718
+ kind: 'generator',
719
+ generator: fakerMethod(this.#faker, replacement),
720
+ };
51
721
  }
52
722
  switch (replacement) {
53
- case "[]": return [];
54
- case "{}": return {};
55
- case "null": return null;
56
- default: {
57
- if (replacement.startsWith("[") || replacement.startsWith("{")) {
58
- try {
59
- return JSON.parse(decodeURIComponent(replacement));
60
- }
61
- catch (error) {
62
- throw new Error(`Failed to parse replacement JSON: ${error === null || error === void 0 ? void 0 : error.message}`);
63
- }
64
- }
65
- return replacement;
66
- }
67
- }
68
- }
69
- getFakerValue(replacement) {
70
- const parts = replacement.split(".");
71
- if (parts.length !== 3) {
72
- throw new Error(`Invalid format for replacement: ${replacement}. Expected format 'faker.category.method'`);
73
- }
74
- const [, category, method] = parts;
75
- const fakerCategory = faker_1.faker[category];
76
- if (!fakerCategory) {
77
- throw new Error(`Invalid faker category: ${category}`);
78
- }
79
- const fakerMethod = fakerCategory[method];
80
- if (typeof fakerMethod !== 'function') {
81
- throw new Error(`Invalid faker method: ${method} in category ${category}`);
82
- }
83
- return fakerMethod();
84
- }
85
- getFakerValueForField(key) {
86
- if (key.includes("email"))
87
- return faker_1.faker.internet.email().toLowerCase();
88
- if (key.includes("firstname"))
89
- return faker_1.faker.person.firstName();
90
- if (key.includes("lastname"))
91
- return faker_1.faker.person.lastName();
92
- if (key === "description")
93
- return faker_1.faker.lorem.sentence();
94
- if (key.endsWith("address"))
95
- return faker_1.faker.location.streetAddress();
96
- if (key.endsWith("city"))
97
- return faker_1.faker.location.city();
98
- if (key.endsWith("country"))
99
- return faker_1.faker.location.country();
100
- if (key.endsWith("phone"))
101
- return faker_1.faker.phone.number();
102
- if (key.endsWith("comment"))
103
- return faker_1.faker.lorem.sentence();
104
- if (key.endsWith("date"))
105
- return faker_1.faker.date.past();
106
- if (key.endsWith("name"))
107
- return faker_1.faker.person.fullName();
108
- return faker_1.faker.word.sample();
723
+ case '[]':
724
+ return { kind: 'literal', value: [] };
725
+ case '{}':
726
+ return { kind: 'literal', value: {} };
727
+ case 'null':
728
+ return { kind: 'literal', value: null };
729
+ }
730
+ if (replacement.startsWith('[') || replacement.startsWith('{')) {
731
+ try {
732
+ return {
733
+ kind: 'literal',
734
+ value: JSON.parse(decodeURIComponent(replacement)),
735
+ };
736
+ }
737
+ catch (error) {
738
+ throw new Error(`Failed to parse replacement JSON: ${error.message}`, { cause: error });
739
+ }
740
+ }
741
+ return { kind: 'literal', value: replacement };
742
+ }
743
+ }
744
+ function fakerMethod(faker, replacement) {
745
+ const parts = replacement.split('.');
746
+ if (parts.length !== 3) {
747
+ throw new Error(`Invalid format for replacement: ${replacement}. Expected format 'faker.category.method'`);
748
+ }
749
+ const [, category = '', method = ''] = parts;
750
+ const fakerCategory = faker[category];
751
+ if (!fakerCategory || typeof fakerCategory !== 'object') {
752
+ throw new Error(`Invalid faker category: ${category}`);
753
+ }
754
+ const fn = fakerCategory[method];
755
+ if (typeof fn !== 'function') {
756
+ throw new Error(`Invalid faker method: ${method} in category ${category}`);
109
757
  }
758
+ return (instance) => {
759
+ const target = instance[category];
760
+ return (target?.[method]).call(target);
761
+ };
110
762
  }
111
- exports.Anonymize = Anonymize;
112
763
  //# sourceMappingURL=anonymize.js.map