mongo-data-anonymizer 0.2.0 → 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.
- package/README.md +268 -18
- package/dist/anonymization/anonymize.d.ts +50 -8
- package/dist/anonymization/anonymize.js +747 -96
- package/dist/anonymization/anonymize.js.map +1 -1
- package/dist/anonymization/collections.d.ts +12 -0
- package/dist/anonymization/collections.js +14 -0
- package/dist/anonymization/collections.js.map +1 -0
- package/dist/anonymization/database.d.ts +69 -9
- package/dist/anonymization/database.js +233 -61
- package/dist/anonymization/database.js.map +1 -1
- package/dist/anonymization/randomizer.d.ts +8 -0
- package/dist/anonymization/randomizer.js +32 -0
- package/dist/anonymization/randomizer.js.map +1 -0
- package/dist/anonymization/rules.d.ts +60 -0
- package/dist/anonymization/rules.js +193 -0
- package/dist/anonymization/rules.js.map +1 -0
- package/dist/cli.js +11 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +182 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/progress.d.ts +25 -0
- package/dist/progress.js +74 -0
- package/dist/progress.js.map +1 -0
- package/dist/run.d.ts +62 -0
- package/dist/run.js +365 -0
- package/dist/run.js.map +1 -0
- package/package.json +48 -30
- package/.eslintignore +0 -1
- package/.eslintrc.js +0 -25
- package/.github/workflows/release.yml +0 -37
- package/.github/workflows/test.yml +0 -22
- package/.nvmrc +0 -1
- package/.prettierrc +0 -7
- package/dist/main.d.ts +0 -1
- package/dist/main.js +0 -95
- package/dist/main.js.map +0 -1
- package/dist/utils/field-utils.d.ts +0 -1
- package/dist/utils/field-utils.js +0 -18
- package/dist/utils/field-utils.js.map +0 -1
- package/jest.config.js +0 -8
- package/src/anonymization/anonymize.ts +0 -116
- package/src/anonymization/database.ts +0 -50
- package/src/cli.ts +0 -4
- package/src/main.ts +0 -98
- package/src/utils/field-utils.ts +0 -16
- package/test/anonymizer/anonymizer.test.ts +0 -164
- package/test/utils/field-utils.test.ts +0 -31
- package/tsconfig.json +0 -14
|
@@ -1,112 +1,763 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
+
current += char;
|
|
38
654
|
}
|
|
39
655
|
}
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
case
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|