@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.
- package/CHANGELOG.md +12 -0
- package/README.md +7 -1
- package/docs/API.md +31 -1
- package/docs/COVERAGE.md +19 -1
- package/package.json +1 -1
- package/src/detection.js +19 -1
- package/src/document-entities.js +32 -0
- package/src/fake-entities.js +304 -0
- package/src/fake-names.js +3 -2
- package/src/fake.js +4 -8
- package/src/index.d.ts +6 -0
- package/src/personal-context.js +1 -1
- package/src/personal-variants.js +119 -25
- package/src/personal.js +12 -6
- package/src/placeholders.js +192 -50
- package/src/propagation.js +7 -3
- package/src/restore-map.js +2 -1
- package/src/russian-declension.js +167 -0
- package/src/russian-names.js +64 -0
- package/src/sanitizer.js +13 -2
- package/src/transforms.js +21 -4
|
@@ -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
|
-
|
|
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
|
}
|