@link-foundation/sensitive-data-sanitizer 0.14.0 → 0.16.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.
@@ -0,0 +1,167 @@
1
+ // Finite Russian declension shared by fake names and fake entities. Case order:
2
+ // nominative, genitive, dative, accusative, instrumental, prepositional.
3
+ // `|` lists a rarer spelling, accepted when reading a form, never generated.
4
+ const fleeting = { павел: 'павл', лев: 'льв', ветер: 'ветр' };
5
+ const hush = /[жчшщц]$/u;
6
+ const a = (stem) => [
7
+ 'а',
8
+ /[гкхжчшщ]$/u.test(stem) ? 'и' : 'ы',
9
+ 'е',
10
+ 'у',
11
+ /[жчшщц]$/u.test(stem) ? 'ей|ею' : 'ой|ою',
12
+ 'е',
13
+ ];
14
+ const adjectives = [
15
+ [
16
+ /[ый]й$|ой$/u,
17
+ 2,
18
+ (w) => [w.slice(-2), 'ого', 'ому', w.slice(-2), 'ым', 'ом'],
19
+ ],
20
+ [/[кгхжчшщ]ий$/u, 2, () => ['ий', 'ого', 'ому', 'ий', 'им', 'ом']],
21
+ [/ий$/u, 2, () => ['ий', 'его', 'ему', 'ий', 'им', 'ем']],
22
+ [/ая$/u, 2, () => ['ая', 'ой', 'ой', 'ую', 'ой|ою', 'ой']],
23
+ [/яя$/u, 2, () => ['яя', 'ей', 'ей', 'юю', 'ей|ею', 'ей']],
24
+ [/ое$/u, 2, () => ['ое', 'ого', 'ому', 'ое', 'ым', 'ом']],
25
+ [/ее$/u, 2, () => ['ее', 'его', 'ему', 'ее', 'им', 'ем']],
26
+ ];
27
+ function noun(word, sex, animate) {
28
+ const accusative = (genitive, nominative = '') =>
29
+ animate ? genitive : nominative;
30
+ if (fleeting[word]) {
31
+ const hard = ['а', 'у', accusative('а'), 'ом', 'е'];
32
+ return [word, ...hard.map((ending) => fleeting[word] + ending)];
33
+ }
34
+ const table = [
35
+ [/ия$/u, 2, ['ия', 'ии', 'ии', 'ию', 'ией|иею', 'ии']],
36
+ [/ье$/u, 2, ['ье', 'ья', 'ью', 'ье', 'ьем', 'ье']],
37
+ [/ие$/u, 2, ['ие', 'ия', 'ию', 'ие', 'ием', 'ии']],
38
+ [/ий$/u, 2, ['ий', 'ия', 'ию', accusative('ия', 'ий'), 'ием', 'ии']],
39
+ [/й$/u, 1, ['й', 'я', 'ю', accusative('я', 'й'), 'ем', 'е']],
40
+ [/я$/u, 1, ['я', 'и', 'е', 'ю', 'ей|ею', 'е']],
41
+ [/а$/u, 1, a(word.slice(0, -1))],
42
+ [/о$/u, 1, ['о', 'а', 'у', 'о', 'ом', 'е']],
43
+ ];
44
+ for (const [ending, remove, cases] of table) {
45
+ if (ending.test(word)) {
46
+ return cases.map((suffix) => attach(word.slice(0, -remove), suffix));
47
+ }
48
+ }
49
+ if (word.endsWith('ь')) {
50
+ const stem = word.slice(0, -1);
51
+ const feminine = ['ь', 'и', 'и', 'ь', 'ью', 'и'];
52
+ const masculine = ['ь', 'я', 'ю', accusative('я', 'ь'), 'ем', 'е'];
53
+ const cases =
54
+ sex === 'female'
55
+ ? feminine
56
+ : sex === 'male'
57
+ ? masculine
58
+ : masculine.map((suffix, i) =>
59
+ suffix === feminine[i] ? suffix : `${suffix}|${feminine[i]}`
60
+ );
61
+ return cases.map((suffix) => attach(stem, suffix));
62
+ }
63
+ if (!/[бвгджзклмнпрстфхцчшщ]$/u.test(word)) {
64
+ return Array(6).fill(word); // Indeclinable: Шевченко, Сочи.
65
+ }
66
+ return [
67
+ '',
68
+ 'а',
69
+ 'у',
70
+ accusative('а'),
71
+ hush.test(word) ? 'ем' : 'ом',
72
+ 'е',
73
+ ].map((suffix) => attach(word, suffix));
74
+ }
75
+ const attach = (stem, suffix) =>
76
+ suffix
77
+ .split('|')
78
+ .map((ending) => stem + ending)
79
+ .join('|');
80
+ function adjective(word) {
81
+ for (const [ending, remove, cases] of adjectives) {
82
+ if (ending.test(word)) {
83
+ return cases(word).map((suffix) =>
84
+ attach(word.slice(0, -remove), suffix)
85
+ );
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ function surname(word, sex) {
91
+ if (/(?:ов|ев|ёв|ин|ын)$/u.test(word)) {
92
+ return ['', 'а', 'у', 'а', 'ым|ом', 'е'].map((s) => attach(word, s));
93
+ }
94
+ if (/(?:ова|ева|ёва|ина|ына)$/u.test(word)) {
95
+ const stem = word.slice(0, -1);
96
+ return ['а', 'ой', 'ой', 'у', 'ой|ою', 'ой'].map((s) => attach(stem, s));
97
+ }
98
+ const forms = adjective(word);
99
+ if (forms) {
100
+ return forms;
101
+ }
102
+ return sex === 'female' && !/[ая]$/u.test(word)
103
+ ? Array(6).fill(word)
104
+ : noun(word, sex, true);
105
+ }
106
+ export const russianNoun = (word, options) =>
107
+ options.adjective && adjective(word)
108
+ ? adjective(word)
109
+ : noun(word, options.sex, options.animate);
110
+ // `kind` is the name slot: lastName, firstName or middleName.
111
+ export function russianNameForms(word, kind, sex) {
112
+ const lower = word.toLowerCase();
113
+ if (kind === 'lastName') {
114
+ return surname(lower, sex);
115
+ }
116
+ if (kind === 'middleName' && /ич$/u.test(lower)) {
117
+ return ['', 'а', 'у', 'а', 'ем', 'е'].map((s) => attach(lower, s));
118
+ }
119
+ return noun(lower, sex, true);
120
+ }
121
+ const fold = (word) => word.toLowerCase().replaceAll('ё', 'е');
122
+ // Every case a surface can stand for, in case order.
123
+ export function casesOf(surface, forms) {
124
+ const wanted = fold(surface);
125
+ return forms.flatMap((form, index) =>
126
+ fold(form).split('|').includes(wanted) ? [index] : []
127
+ );
128
+ }
129
+ export const formOf = (forms, index) => forms[index].split('|')[0];
130
+ // Cases governed by a preposition, in order of preference. Bare forms like
131
+ // `Ромашке` (dative or prepositional) cannot be told apart without it.
132
+ const governed = [
133
+ [/^(?:в|во|на|о|об|обо|при)$/u, [5, 3]],
134
+ [/^(?:к|ко|по)$/u, [2]],
135
+ [/^(?:с|со|над|под|перед|за)$/u, [4, 1, 3]],
136
+ [/^(?:у|от|до|из|без|для|около|после|кроме|вокруг|против)$/u, [1]],
137
+ ];
138
+ const lastWord = /(?<![\p{L}\p{M}\p{N}_-])(\p{L}{1,7})[ \t\u00a0]+([«“"]?)$/u;
139
+ // `в Лютике и Ромашке`: a conjunct takes the case of the word before it.
140
+ const conjunct =
141
+ /[\p{L}\p{M}-]{1,32}[»”"]?(?:,|[ \t\u00a0]+(?:и|или))[ \t\u00a0]+[«“"]?$/u;
142
+ export function governedCases(text, start) {
143
+ const before = text.slice(Math.max(0, start - 64), start);
144
+ const conjunction = conjunct.exec(before);
145
+ const source = conjunction ? before.slice(0, conjunction.index) : before;
146
+ const match = lastWord.exec(source.slice(-12));
147
+ if (!match) {
148
+ return { cases: [] };
149
+ }
150
+ const word = match[1].toLowerCase();
151
+ const rule = governed.find(([pattern]) => pattern.test(word));
152
+ return { cases: rule?.[1] ?? [], quoted: Boolean(match[2]) };
153
+ }
154
+ // Intersects the candidates of each word; a word that fits no case is ignored.
155
+ // A quoted name in the nominative stays there: в «Вектор».
156
+ export function commonCase(candidates, context = { cases: [] }) {
157
+ const present = candidates.filter((cases) => cases.length);
158
+ const common = [0, 1, 2, 3, 4, 5].filter((index) =>
159
+ present.every((cases) => cases.includes(index))
160
+ );
161
+ if (context.quoted && common.includes(0)) {
162
+ return 0;
163
+ }
164
+ return (
165
+ context.cases.find((index) => common.includes(index)) ?? common[0] ?? 0
166
+ );
167
+ }
@@ -0,0 +1,64 @@
1
+ import { firstNames } from './names.js';
2
+
3
+ // Finite Russian name grammar shared by known-value matching and fake mode.
4
+ const patronymic = /(?:ич|овна|евна|ична|vich|ovna|evna)$/iu;
5
+ const surname = /(?:ов|ев|ин|ын|ский|цкий|ова|ева|ина|ына|ская|цкая)$/iu;
6
+ const fold = (word) => word.toLowerCase().replaceAll('ё', 'е');
7
+ const givenNames = new Set(firstNames.ru.split(' ').map(fold));
8
+
9
+ export const isPatronymic = (word) => patronymic.test(word);
10
+ export const isGivenName = (word) => givenNames.has(fold(word));
11
+
12
+ // Only decides whether a word beside a known person belongs to that mention, so
13
+ // it stays apart from the detector's list, which would match these names anywhere.
14
+ const mentionGivenNames =
15
+ 'Роман Антон Илья Артём Никита Егор Глеб Денис Игорь Константин Кирилл Леонид Матвей Тимофей Фёдор Руслан Станислав Вячеслав Вадим Валерий Геннадий Григорий Степан Тимур Ярослав Юлия Дарья Полина Ксения Виктория Анастасия Вероника Валерия Алёна Оксана Людмила Галина Лариса Надежда Вера Любовь Кристина Софья Диана Яна Лилия Альбина Жанна Регина Инна Карина Милана Арина Варвара Елизавета Маргарита';
16
+ const anyGivenName = new Set(
17
+ [...Object.values(firstNames), mentionGivenNames]
18
+ .flatMap((list) => list.split(' '))
19
+ .map(fold)
20
+ );
21
+ const inflectedPatronymic =
22
+ /(?:[оеь]вич|[оеь]ич|ич)(?:а|у|ем|е)?$|(?:овн|евн|ичн)(?:а|ы|е|у|ой|ою)$/iu;
23
+ const nameCase = /(?:ом|ем|ой|ей|ою|ею|а|я|у|ю|е|ы|и)$/u;
24
+ // A capitalized given name or patronymic in any case, for deciding whether a
25
+ // neighbouring word belongs to the same mention. Lemmas are recovered by a
26
+ // finite ending list, so this stays a lookup and never parses the sentence.
27
+ export function isNameForm(word) {
28
+ const lower = fold(word);
29
+ if (anyGivenName.has(lower) || inflectedPatronymic.test(lower)) {
30
+ return true;
31
+ }
32
+ const stem = lower.replace(nameCase, '');
33
+ return (
34
+ stem !== lower &&
35
+ stem.length >= 2 &&
36
+ ['', 'а', 'я', 'й', 'ь'].some((ending) => anyGivenName.has(stem + ending))
37
+ );
38
+ }
39
+
40
+ // Returns word indexes by role, or undefined when the roles are not evident.
41
+ // `Имя Отчество Фамилия` is checked first so that surnames in -ич, as in
42
+ // `Шостакович Дмитрий Дмитриевич`, are not mistaken for patronymics.
43
+ export function russianNameRoles(words, known = isGivenName) {
44
+ if (words.length === 3) {
45
+ if (isPatronymic(words[1])) {
46
+ return { given: 0, patronymic: 1, surname: 2 };
47
+ }
48
+ return isPatronymic(words[2])
49
+ ? { surname: 0, given: 1, patronymic: 2 }
50
+ : undefined;
51
+ }
52
+ if (words.length !== 2) {
53
+ return undefined;
54
+ }
55
+ if (isPatronymic(words[1])) {
56
+ return { given: 0, patronymic: 1 };
57
+ }
58
+ const candidates = [0, 1].filter(
59
+ (i) => surname.test(words[i]) && !known(words[i])
60
+ );
61
+ return candidates.length === 1
62
+ ? { surname: candidates[0], given: 1 - candidates[0] }
63
+ : undefined;
64
+ }
package/src/sanitizer.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generatedValues } from './fake.js';
2
+ import { prepareFakeText } from './fake-entities.js';
2
3
  import { alignEscapes } from './escape-spans.js';
3
4
  import { actionable } from './confidence.js';
4
5
  import { propagate, confirmationOptions } from './propagation.js';
@@ -25,6 +26,7 @@ import {
25
26
  hasPlaceholders,
26
27
  placeholderValues,
27
28
  placeholderNameFindings,
29
+ coverAdjacentNames,
28
30
  nestedPlaceholderOptions,
29
31
  } from './placeholders.js';
30
32
  import {
@@ -465,7 +467,11 @@ export function reviewFindings(text, findings, options) {
465
467
  text,
466
468
  markFake(
467
469
  text,
468
- propagate(text, markFake(text, [...unique.values()], options), options),
470
+ coverAdjacentNames(
471
+ text,
472
+ propagate(text, markFake(text, [...unique.values()], options), options),
473
+ options
474
+ ),
469
475
  options
470
476
  ).sort(
471
477
  (a, b) =>
@@ -692,7 +698,7 @@ export function redactResult(
692
698
  ) {
693
699
  options = applyProfile(options);
694
700
  const spans = validatedSpans(text, findings, options);
695
- options = initializePlaceholders(options);
701
+ options = prepareFakeText(text, findings, initializePlaceholders(options));
696
702
  preparePlaceholderText(text, findings, options);
697
703
  const parts = [];
698
704
  const faked = [];
@@ -719,6 +725,11 @@ export function redactResult(
719
725
  const transform =
720
726
  options.transformations?.[span.type] ?? options.transformation;
721
727
  if (retainedReplacement(transform, 'placeholder', span, replacement)) {
728
+ // `Organization-1` followed by `99` would read as a token that does not
729
+ // exist, so a span that ends there fails the run instead.
730
+ if (/\d$/.test(replacement) && /\d/.test(text[span.end] ?? '')) {
731
+ throw failure('ERR_RESIDUAL');
732
+ }
722
733
  occurrences.push({
723
734
  start: outputLength,
724
735
  end: outputLength + replacement.length,
package/src/transforms.js CHANGED
@@ -41,6 +41,22 @@ export function validateTransforms(options) {
41
41
  function positiveInteger(value) {
42
42
  return Number.isSafeInteger(value) && value > 0;
43
43
  }
44
+ const poolTypes = new Set(['ORGANIZATION', 'LOCATION', 'EDUCATION']);
45
+ function validPools(pools) {
46
+ return (
47
+ pools === undefined ||
48
+ (Boolean(pools) &&
49
+ typeof pools === 'object' &&
50
+ !Array.isArray(pools) &&
51
+ Object.entries(pools).every(
52
+ ([type, names]) =>
53
+ poolTypes.has(type) &&
54
+ Array.isArray(names) &&
55
+ names.length > 0 &&
56
+ names.every((name) => typeof name === 'string' && name.trim())
57
+ ))
58
+ );
59
+ }
44
60
  function validateTransform(transform) {
45
61
  if (!transform || !modes.has(transform.mode)) {
46
62
  throw failure('ERR_CONFIG');
@@ -54,7 +70,8 @@ function validateTransform(transform) {
54
70
  Buffer.byteLength(transform.key) >= 16 &&
55
71
  (transform.credentials === undefined ||
56
72
  typeof transform.credentials === 'boolean') &&
57
- (transform.mrzCountry === undefined || transform.mrzCountry === 'UTO'),
73
+ (transform.mrzCountry === undefined || transform.mrzCountry === 'UTO') &&
74
+ validPools(transform.pools),
58
75
  pseudonym: () =>
59
76
  typeof transform.key === 'string' &&
60
77
  Buffer.byteLength(transform.key) >= 16,
@@ -130,7 +147,7 @@ function masked(value, finding, transform) {
130
147
  ? REDACTED
131
148
  : `${chars.slice(0, start).join('')}${transform.marker ?? '*'.repeat(chars.length - start - end)}${end ? chars.slice(-end).join('') : ''}`;
132
149
  }
133
- function transformed(value, finding, transform) {
150
+ function transformed(value, finding, transform, options) {
134
151
  if (!transform || transform.mode === 'redact') {
135
152
  return REDACTED;
136
153
  }
@@ -141,7 +158,7 @@ function transformed(value, finding, transform) {
141
158
  : REDACTED;
142
159
  }
143
160
  if (transform.mode === 'fake') {
144
- return realisticFake(value, finding, transform);
161
+ return realisticFake(value, finding, transform, options);
145
162
  }
146
163
  if (finding.category === 'credential') {
147
164
  return REDACTED;
@@ -198,6 +215,6 @@ function renderValue(text, finding, transform, options) {
198
215
  const result =
199
216
  transform?.mode === 'placeholder'
200
217
  ? placeholderReplacement(raw, finding, transform, options)
201
- : transformed(raw, finding, transform);
218
+ : transformed(raw, finding, transform, options);
202
219
  return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
203
220
  }