@link-foundation/sensitive-data-sanitizer 0.20.0 → 0.21.1
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 +14 -1
- package/bin/sensitive-data-sanitizer.js +1 -0
- package/docs/API.md +179 -16
- package/docs/COVERAGE.md +16 -6
- package/docs/RULES.md +1 -0
- package/examples/second-pass-placeholders.mjs +28 -0
- package/package.json +12 -3
- package/src/context.js +176 -25
- package/src/credential-spans.js +180 -0
- package/src/credentials-entry.d.ts +23 -0
- package/src/credentials-entry.js +62 -0
- package/src/credentials.js +8 -1
- package/src/detection.js +68 -37
- package/src/encoded.js +137 -51
- package/src/engines.js +33 -10
- package/src/exempt.js +72 -0
- package/src/fake-memory.js +32 -0
- package/src/fake.js +4 -28
- package/src/features.js +29 -0
- package/src/html-tags.js +8 -0
- package/src/identity-types.js +63 -0
- package/src/identity.js +3 -61
- package/src/index.d.ts +41 -2
- package/src/index.js +6 -0
- package/src/known.js +33 -15
- package/src/personal-features.js +39 -0
- package/src/personal.js +29 -5
- package/src/placeholder-types.js +164 -4
- package/src/placeholders.js +144 -45
- package/src/projection.js +1 -8
- package/src/proper-names.js +48 -5
- package/src/prose-words.js +116 -0
- package/src/public-technologies.js +32 -0
- package/src/public.js +3 -0
- package/src/record-worker.js +1 -0
- package/src/rule-catalog.js +2 -0
- package/src/rules.js +8 -0
- package/src/sanitizer.js +92 -21
- package/src/stream-worker.js +1 -0
- package/src/strict-attributes.js +60 -4
- package/src/strict.js +1 -1
- package/src/structured.js +2 -0
- package/src/transforms.js +38 -8
- package/src/vocabulary.js +39 -2
- package/src/worker-options.js +1 -0
package/src/sanitizer.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { generatedValues } from './fake.js';
|
|
1
|
+
import { generatedValues } from './fake-memory.js';
|
|
2
|
+
import { feature, withFeature } from './features.js';
|
|
2
3
|
import { isCompiledKnown, projectedKnown } from './compiled-known.js';
|
|
3
4
|
import { detectSync, validateDetectors } from './sync-detectors.js';
|
|
4
5
|
import { validatePolicyControls, builtInEnabled } from './policy.js';
|
|
5
6
|
import { compiledVocabulary } from './vocabulary.js';
|
|
6
|
-
import { validateProperNames } from './proper-names.js';
|
|
7
|
-
import { prepareFakeText } from './fake-entities.js';
|
|
8
7
|
import { alignEscapes } from './escape-spans.js';
|
|
9
8
|
import { actionable } from './confidence.js';
|
|
10
9
|
import { propagate, confirmationOptions } from './propagation.js';
|
|
11
|
-
import { fakeDocumentSpans } from './specimens.js';
|
|
12
10
|
import {
|
|
13
11
|
failure,
|
|
14
12
|
internalFailure,
|
|
@@ -26,10 +24,11 @@ import {
|
|
|
26
24
|
hasCustomTransforms,
|
|
27
25
|
renderCallbackAsync,
|
|
28
26
|
} from './transforms.js';
|
|
29
|
-
import {
|
|
27
|
+
import { isIdentityType } from './identity-types.js';
|
|
30
28
|
import { rejectRestoreMap } from './restore-map.js';
|
|
31
29
|
import { allowListed, validateAllowList } from './allow-list.js';
|
|
32
30
|
import { templatedCredential } from './credentials.js';
|
|
31
|
+
import { exemptFindings, validateExempt } from './exempt.js';
|
|
33
32
|
import { builtInRules } from './rule-catalog.js';
|
|
34
33
|
import {
|
|
35
34
|
initializePlaceholders,
|
|
@@ -39,6 +38,8 @@ import {
|
|
|
39
38
|
hasPlaceholders,
|
|
40
39
|
placeholderValues,
|
|
41
40
|
placeholderNameFindings,
|
|
41
|
+
keptPlaceholderSpans,
|
|
42
|
+
keptPlaceholderFinding,
|
|
42
43
|
coverAdjacentNames,
|
|
43
44
|
nestedPlaceholderOptions,
|
|
44
45
|
} from './placeholders.js';
|
|
@@ -105,6 +106,7 @@ const optionNames = new Set([
|
|
|
105
106
|
'minConfidenceByType',
|
|
106
107
|
'allowList',
|
|
107
108
|
'inlineAllow',
|
|
109
|
+
'exempt',
|
|
108
110
|
]);
|
|
109
111
|
|
|
110
112
|
export function applyProfile(options = {}) {
|
|
@@ -222,7 +224,9 @@ export function validateOptions(options = {}) {
|
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
226
|
validateConfidence(options);
|
|
225
|
-
|
|
227
|
+
if (options.properNames !== undefined || options.scripts !== undefined) {
|
|
228
|
+
feature('validateProperNames')(options);
|
|
229
|
+
}
|
|
226
230
|
validatePolicy(options);
|
|
227
231
|
if (
|
|
228
232
|
options.verifyPublic !== undefined &&
|
|
@@ -230,6 +234,7 @@ export function validateOptions(options = {}) {
|
|
|
230
234
|
) {
|
|
231
235
|
throw failure('ERR_CONFIG');
|
|
232
236
|
}
|
|
237
|
+
validateExempt(options.exempt);
|
|
233
238
|
validateCollections(options);
|
|
234
239
|
validateAllowList(options);
|
|
235
240
|
validateDetectors(options.detectors);
|
|
@@ -329,6 +334,9 @@ export function sanitizeFinding(finding, length) {
|
|
|
329
334
|
rule: finding.rule,
|
|
330
335
|
confidence: validConfidence(finding),
|
|
331
336
|
likelihood: finding.likelihood ?? likelihood(validConfidence(finding)),
|
|
337
|
+
...(finding.type === 'JSON_VALUE' && validType(finding.valueType)
|
|
338
|
+
? { valueType: finding.valueType }
|
|
339
|
+
: {}),
|
|
332
340
|
...(finding.kept === 'fake' ? { kept: 'fake' } : {}),
|
|
333
341
|
};
|
|
334
342
|
}
|
|
@@ -449,7 +457,14 @@ function locations(text, findings) {
|
|
|
449
457
|
|
|
450
458
|
export function inspect(text, options = {}) {
|
|
451
459
|
try {
|
|
452
|
-
|
|
460
|
+
const exempt = options?.exempt;
|
|
461
|
+
if (!exempt) {
|
|
462
|
+
return inspectText(text, options);
|
|
463
|
+
}
|
|
464
|
+
// Nested views (structured values, decoded runs) are screened once, at
|
|
465
|
+
// the offsets of the text the caller sees.
|
|
466
|
+
const findings = inspectText(text, { ...options, exempt: undefined });
|
|
467
|
+
return exemptFindings(text, findings, exempt);
|
|
453
468
|
} catch (error) {
|
|
454
469
|
throw internalFailure(error);
|
|
455
470
|
}
|
|
@@ -635,14 +650,41 @@ export function inspectFindingsOnly(text, findings, options) {
|
|
|
635
650
|
);
|
|
636
651
|
}
|
|
637
652
|
|
|
653
|
+
// A whole JSON value that is already a token of this run, `"Sensitive-1"`.
|
|
654
|
+
function generatedJsonValue(text, finding, options) {
|
|
655
|
+
const raw = text.slice(finding.start, finding.end);
|
|
656
|
+
return (
|
|
657
|
+
raw.startsWith('"') &&
|
|
658
|
+
placeholderValues(options).some(
|
|
659
|
+
(record) => JSON.stringify(record.value) === raw
|
|
660
|
+
)
|
|
661
|
+
);
|
|
662
|
+
}
|
|
638
663
|
function selectedFindings(findings, credentialsOnly) {
|
|
639
664
|
return credentialsOnly
|
|
640
665
|
? findings.filter((finding) => finding.category === 'credential')
|
|
641
666
|
: findings;
|
|
642
667
|
}
|
|
643
668
|
|
|
669
|
+
// Findings over the tokens of an earlier pass, recorded in `tokenKept`.
|
|
670
|
+
function withKeptPlaceholders(text, findings, options, tokenKept) {
|
|
671
|
+
const tokens = keptPlaceholderSpans(text, findings, options);
|
|
672
|
+
if (!tokens.length) {
|
|
673
|
+
return findings;
|
|
674
|
+
}
|
|
675
|
+
return findings.map((f) => {
|
|
676
|
+
const next = keptPlaceholderFinding(text, f, tokens);
|
|
677
|
+
if (next.kept === 'fake' && next !== f) {
|
|
678
|
+
tokenKept.add(next);
|
|
679
|
+
}
|
|
680
|
+
return next;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
644
684
|
export function markFake(text, findings, options) {
|
|
645
685
|
findings = selectedFindings(findings, options.credentialsOnly);
|
|
686
|
+
const tokenKept = new Set();
|
|
687
|
+
findings = withKeptPlaceholders(text, findings, options, tokenKept);
|
|
646
688
|
const generated = [
|
|
647
689
|
...generatedValues(options),
|
|
648
690
|
...placeholderValues(options),
|
|
@@ -652,9 +694,12 @@ export function markFake(text, findings, options) {
|
|
|
652
694
|
!options.fakeValues?.length &&
|
|
653
695
|
!generated.length
|
|
654
696
|
) {
|
|
655
|
-
return findings.map((f) => ({
|
|
697
|
+
return findings.map((f) => ({
|
|
698
|
+
...f,
|
|
699
|
+
kept: tokenKept.has(f) ? 'fake' : undefined,
|
|
700
|
+
}));
|
|
656
701
|
}
|
|
657
|
-
const documents = fakeDocumentSpans
|
|
702
|
+
const documents = withFeature('fakeDocumentSpans', [], text, options);
|
|
658
703
|
const trusted = [];
|
|
659
704
|
for (const record of generated) {
|
|
660
705
|
for (const value of new Set([
|
|
@@ -689,7 +734,7 @@ export function markFake(text, findings, options) {
|
|
|
689
734
|
const candidates = findings.filter(
|
|
690
735
|
(f) =>
|
|
691
736
|
f.category === 'personal' &&
|
|
692
|
-
fakeIdentityValue(text.slice(f.start, f.end), f.type, options)
|
|
737
|
+
feature('fakeIdentityValue')(text.slice(f.start, f.end), f.type, options)
|
|
693
738
|
);
|
|
694
739
|
// Tolerant MRZ matches can cross a line boundary. An automatic specimen
|
|
695
740
|
// exemption must never cover an overlapping real identity value. Exact
|
|
@@ -713,8 +758,13 @@ export function markFake(text, findings, options) {
|
|
|
713
758
|
),
|
|
714
759
|
];
|
|
715
760
|
return findings.map((f) => {
|
|
761
|
+
if (tokenKept.has(f)) {
|
|
762
|
+
return f;
|
|
763
|
+
}
|
|
716
764
|
if (f.type === 'JSON_VALUE') {
|
|
717
|
-
return
|
|
765
|
+
return generatedJsonValue(text, f, options)
|
|
766
|
+
? { ...f, kept: 'fake' }
|
|
767
|
+
: { ...f, kept: undefined };
|
|
718
768
|
}
|
|
719
769
|
if (
|
|
720
770
|
trusted.some(
|
|
@@ -775,6 +825,7 @@ function validatedSpans(text, findings, options) {
|
|
|
775
825
|
last.category = 'credential';
|
|
776
826
|
}
|
|
777
827
|
if (last.type === 'JSON_VALUE' || f.type === 'JSON_VALUE') {
|
|
828
|
+
last.valueType = jsonValueType(last, f);
|
|
778
829
|
last.type = 'JSON_VALUE';
|
|
779
830
|
} else if (credentialRun) {
|
|
780
831
|
last.type = 'SECRET';
|
|
@@ -788,6 +839,15 @@ function validatedSpans(text, findings, options) {
|
|
|
788
839
|
return spans;
|
|
789
840
|
}
|
|
790
841
|
|
|
842
|
+
// Two whole values merged into one keep a value type only when they agree.
|
|
843
|
+
function jsonValueType(last, f) {
|
|
844
|
+
if (last.type !== 'JSON_VALUE') {
|
|
845
|
+
return f.valueType;
|
|
846
|
+
}
|
|
847
|
+
return f.type !== 'JSON_VALUE' || f.valueType === last.valueType
|
|
848
|
+
? last.valueType
|
|
849
|
+
: undefined;
|
|
850
|
+
}
|
|
791
851
|
function preferredType(current, candidate, options) {
|
|
792
852
|
if (current === 'JSON_VALUE') {
|
|
793
853
|
return false;
|
|
@@ -810,11 +870,17 @@ function preferredType(current, candidate, options) {
|
|
|
810
870
|
if (options.transformations?.[candidate] || candidate === 'PASSPORT_MRZ') {
|
|
811
871
|
return true;
|
|
812
872
|
}
|
|
873
|
+
return preferredSpecificType(current, candidate);
|
|
874
|
+
}
|
|
875
|
+
function preferredSpecificType(current, candidate) {
|
|
813
876
|
const broad = (type) =>
|
|
814
877
|
['ID', 'PASSPORT'].includes(type) ||
|
|
815
878
|
(/PASSPORT/.test(type) &&
|
|
816
879
|
!['PASSPORT_MRZ', 'PASSPORT_NUMBER'].includes(type));
|
|
817
|
-
return
|
|
880
|
+
return (
|
|
881
|
+
(current === 'PROPER_NAME' && candidate === 'PERSONAL_ATTRIBUTE') ||
|
|
882
|
+
(broad(current) && isIdentityType(candidate) && !broad(candidate))
|
|
883
|
+
);
|
|
818
884
|
}
|
|
819
885
|
export function redactResult(
|
|
820
886
|
text,
|
|
@@ -824,7 +890,8 @@ export function redactResult(
|
|
|
824
890
|
) {
|
|
825
891
|
options = applyProfile(options);
|
|
826
892
|
const spans = validatedSpans(text, findings, options);
|
|
827
|
-
options =
|
|
893
|
+
options = initializePlaceholders(options);
|
|
894
|
+
options = withFeature('prepareFakeText', options, text, findings, options);
|
|
828
895
|
preparePlaceholderText(text, findings, options);
|
|
829
896
|
const parts = [];
|
|
830
897
|
const faked = [];
|
|
@@ -950,22 +1017,26 @@ export async function redactResultAsync(
|
|
|
950
1017
|
}
|
|
951
1018
|
continue;
|
|
952
1019
|
}
|
|
953
|
-
if (
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
if (!options.preserveEncoding && run.encoding !== 'json-content') {
|
|
1020
|
+
if (
|
|
1021
|
+
!run ||
|
|
1022
|
+
(!options.preserveEncoding && run.encoding !== 'json-content')
|
|
1023
|
+
) {
|
|
960
1024
|
continue;
|
|
961
1025
|
}
|
|
962
1026
|
const result = await sanitizeDecoded(
|
|
963
1027
|
run.text,
|
|
964
1028
|
nestedPlaceholderOptions(confirmationOptions(text, findings, options))
|
|
965
1029
|
);
|
|
1030
|
+
// Without a recognizable re-encoding, the encoded text itself is masked.
|
|
966
1031
|
replacements.set(
|
|
967
1032
|
`${span.start}:${span.end}`,
|
|
968
|
-
encodeRun(run, result.text === run.text ? REDACTED : result.text)
|
|
1033
|
+
encodeRun(run, result.text === run.text ? REDACTED : result.text) ??
|
|
1034
|
+
renderReplacement(
|
|
1035
|
+
text,
|
|
1036
|
+
span,
|
|
1037
|
+
{ ...options, preserveEncoding: false },
|
|
1038
|
+
() => ({ text: REDACTED })
|
|
1039
|
+
)
|
|
969
1040
|
);
|
|
970
1041
|
}
|
|
971
1042
|
}
|
package/src/stream-worker.js
CHANGED
package/src/strict-attributes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { collectMatches } from './detection.js';
|
|
2
|
+
import { compiledVocabulary } from './vocabulary.js';
|
|
2
3
|
|
|
3
4
|
const attributes =
|
|
4
5
|
/(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|male|female|married|divorced|pregnant|pregnancy|disability|christian|muslim|jewish|orthodox|hispanic|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
|
|
@@ -10,10 +11,56 @@ const additionalAttributes =
|
|
|
10
11
|
// unfamiliar diagnoses, nationalities and religions outside this lexicon.
|
|
11
12
|
const categoryForms =
|
|
12
13
|
/(?<![\p{L}\p{N}_])(?:(?:диабет|католик|католич|буддист|атеист|белорус|православн|беременн|судим)[\p{L}]{0,8}|татар(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|ы|ам|ами|ах)?|мусульман(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|е|ам|ами|ах)?|украин(?:ец|ца|цу|цем|це|цы|цев|цам|цами|цах|к[аиуе]|кой)|инвалид(?:а|у|ом|е|ы|ов|ам|ами|ах|к[аиу]|кой)?|армян(?:ин|ина|ину|ином|ине|е|ам|ами|ах|ка|ки|ке|ку|кой)|евре(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|казах(?:а|у|ом|е|и|ов|ам|ами|ах|к[аиуе]|кой)?|христиан(?:ин|ина|ину|ином|ине|е|ам|ами|ах|к[аиуе]|кой)|иуде(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|ислам[\p{L}]{0,5}|эпилепси[\p{L}]{1,5}|астм[\p{L}]{1,5}|онкологи[\p{L}]{1,5}|туберкул[её]з[\p{L}]{0,5}|психиатр[\p{L}]{0,6}|депресси[\p{L}]{1,5}|гепатит[\p{L}]{0,5}|гомосексуал[\p{L}]{0,7}|лесбиян[\p{L}]{1,5}|ге(?:й|я|ю|ем|и|ев|ям|ями|ях)|нетрадиционн[\p{L}]{2,3}[ \t]+ориентаци[\p{L}]{1,3}|отбыва(?:л[аи]?|ет|ют|вш[\p{L}]{2,3})[ \t]+наказани[\p{L}]{1,3}|ВИЧ-инфицирован[\p{L}]{0,4}|epilepsy|asthma|tuberculosis|hepatitis|depression|gay|lesbian|homosexual|bisexual|armenian)(?![\p{L}\p{N}_])/giu;
|
|
14
|
+
// Nationality nouns with their case endings and feminine forms. Exact endings
|
|
15
|
+
// keep country and region names (`Узбекистан`, `Чувашия`) out of the match.
|
|
16
|
+
const hardNoun = '(?:а|у|ом|е|ы|ов|ам|ами|ах)?';
|
|
17
|
+
const softNoun = '(?:а|у|ом|е|и|ей|ам|ами|ах)?';
|
|
18
|
+
const ecNoun = '(?:ец|ц(?:а|у|ем|е|ы|ев|ам|ами|ах))';
|
|
19
|
+
const femaleNoun = '(?:а|и|е|у|ой)';
|
|
20
|
+
const nationalities = [
|
|
21
|
+
`кабардин${ecNoun}|кабардинк${femaleNoun}`,
|
|
22
|
+
`казашк${femaleNoun}`,
|
|
23
|
+
`мордвин${hardNoun}|мордовк${femaleNoun}`,
|
|
24
|
+
`удмурт${hardNoun}|удмуртк${femaleNoun}`,
|
|
25
|
+
`молдаванин(?:а|у|ом|е)?|молдаван${hardNoun}|молдаванк${femaleNoun}`,
|
|
26
|
+
`узбек${softNoun}|узбечк${femaleNoun}`,
|
|
27
|
+
`таджик${softNoun}|таджичк${femaleNoun}`,
|
|
28
|
+
`(?:киргиз|кыргыз)${hardNoun}|(?:киргиз|кыргыз)к${femaleNoun}`,
|
|
29
|
+
`туркмен${hardNoun}|туркменк${femaleNoun}`,
|
|
30
|
+
`чечен${ecNoun}|чеченк${femaleNoun}`,
|
|
31
|
+
`ингуш${softNoun}|ингушк${femaleNoun}`,
|
|
32
|
+
`осетин${hardNoun}|осетинк${femaleNoun}`,
|
|
33
|
+
`грузин${hardNoun}|грузинк${femaleNoun}`,
|
|
34
|
+
`азербайджан${ecNoun}|азербайджанк${femaleNoun}`,
|
|
35
|
+
`цыган${hardNoun}|цыганк${femaleNoun}`,
|
|
36
|
+
`коре(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|кореянк${femaleNoun}`,
|
|
37
|
+
`кита(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|китаянк${femaleNoun}`,
|
|
38
|
+
`башкир${hardNoun}|башкирк${femaleNoun}`,
|
|
39
|
+
`чуваш${softNoun}|чувашк${femaleNoun}`,
|
|
40
|
+
`якут${hardNoun}|якутк${femaleNoun}`,
|
|
41
|
+
`бурят${hardNoun}|бурятк${femaleNoun}`,
|
|
42
|
+
`калмык${softNoun}|калмычк${femaleNoun}`,
|
|
43
|
+
`лезгин${hardNoun}|лезгинк${femaleNoun}`,
|
|
44
|
+
`авар${ecNoun}|аварк${femaleNoun}`,
|
|
45
|
+
`дагестан${ecNoun}|дагестанк${femaleNoun}`,
|
|
46
|
+
`уйгур${hardNoun}|уйгурк${femaleNoun}`,
|
|
47
|
+
].join('|');
|
|
48
|
+
const believerNoun = `(?:а|у|ом|е|ы|ов|ам|ами|ах|к${femaleNoun}|кой)?`;
|
|
49
|
+
// These terms are sensitive even without an adjacent personal cue. Keep
|
|
50
|
+
// bounded endings and whole-word boundaries, as for the existing categories.
|
|
51
|
+
const followUpVocabulary = new RegExp(
|
|
52
|
+
`(?<![\\p{L}\\p{N}_])(?:иудаизм${hardNoun}|старовер${believerNoun}|эпилептик${softNoun}|паралич${softNoun}|наркомани[\\p{L}]{1,4}|профсоюз${hardNoun}|коммунист${believerNoun}|uzbek|tajik|chechen|romani|gypsy|latino|latina|baptist|aids|bipolar|adhd|wheelchair|lgbt|convicted|felony|trade[ \\t]+union)(?![\\p{L}\\p{N}_])`,
|
|
53
|
+
'giu'
|
|
54
|
+
);
|
|
55
|
+
const specialVocabulary = new RegExp(
|
|
56
|
+
`(?<![\\p{L}\\p{N}_])(?:${nationalities}|(?:протестант|баптист|адвентист|индуист|кришнаит|иеговист|пятидесятник)${believerNoun}|лютеран(?:ин(?:а|у|ом|е)?|к${femaleNoun}|кой|е|ам|ами|ах)|старообряд${ecNoun}|старообрядк${femaleNoun}|свидетел[\\p{L}]{1,3}[ \\t]+иеговы|инсульт[\\p{L}]{0,3}|инфаркт[\\p{L}]{0,3}|шизофрени[\\p{L}]{1,4}|аутизм[\\p{L}]{0,3}|аутист(?:ка|ки|ке|ку|кой|а|у|ом|ы|ов)?|слабослышащ[\\p{L}]{1,4}|слабовидящ[\\p{L}]{1,4}|незряч[\\p{L}]{1,4}|дцп|колясочни(?:к${hardNoun}|ц${femaleNoun})|(?:передвига[\\p{L}]{1,5}|инвалидн[\\p{L}]{2,4})[ \\t]+(?:на[ \\t]+)?коляск[\\p{L}]{1,3}|болезн[\\p{L}]{1,3}[ \\t]+(?:паркинсона|альцгеймера)|деменци[\\p{L}]{1,3}|алкоголизм[\\p{L}]{0,2}|наркозависим[\\p{L}]{0,4}|биполярн[\\p{L}]{2,4}[ \\t]+расстройств[\\p{L}]{0,3}|(?:би|транс|пан|а)сексуал[\\p{L}]{0,6}|трансгендер[\\p{L}]{0,6}|(?:сидел[аи]?|отсидел[аи]?|осужд[её]н[аы]?|привлекал(?:ся|ась|ись)|отбывал[аи]?[ \\t]+срок)[ \\t]+(?:за|по)[ \\t]+(?:краж|грабе|разбо|убийств|мошенничеств|наркотик|хулиганств|изнасиловани|взятк|стать)[\\p{L}]{0,4}(?:[ \\t]+\\d{1,3}(?:\\.\\d)?)?|(?:сидел[аи]?|отсидел[аи]?)[ \\t]+(?:в[ \\t]+)?(?:тюрьм[\\p{L}]{1,2}|колони[\\p{L}]{1,2}|срок)|бывш[\\p{L}]{2,3}[ \\t]+заключ[её]нн[\\p{L}]{2,3}|член[\\p{L}]{0,3}[ \\t]+профсоюз[\\p{L}]{0,3}|состо(?:ит|ял[аи]?|ю|ят)[ \\t]+в[ \\t]+профсоюзе|профсоюзн(?:ый|ая|ого|ой)[ \\t]+(?:активист|деятел|лидер)[\\p{L}]{0,3}|autism|autistic|schizophreni[ac]|transgender|transsexual|pansexual|asexual|wheelchair[ \\t]+users?|uses[ \\t]+a[ \\t]+wheelchair|in[ \\t]+a[ \\t]+wheelchair|(?:hearing|visually)[ \\t-]+impaired|had[ \\t]+a[ \\t]+(?:stroke|heart[ \\t]+attack)|dementia|alzheimer'?s|parkinson'?s|bipolar[ \\t]+disorder|alcoholism|trade[ \\t]+union[ \\t]+member(?:ship)?|member[ \\t]+of[ \\t]+(?:a|the)[ \\t]+(?:trade|labou?r)[ \\t]+union|convicted[ \\t]+(?:of|for)[ \\t]+[\\p{L}]{3,32}|served[ \\t]+(?:time[ \\t]+(?:in|for)|a[ \\t]+(?:prison[ \\t]+)?sentence)|protestant|lutheran|mormon|hindu|sikh|jehovah'?s[ \\t]+witness)(?![\\p{L}\\p{N}_])`,
|
|
57
|
+
'giu'
|
|
58
|
+
);
|
|
13
59
|
const categoryCue =
|
|
14
|
-
/(?<![\p{L}\p{N}_])(?:по[ \t]
|
|
60
|
+
/(?<![\p{L}\p{N}_])(?:по[ \t]+(?:национальности|происхождению)|национальность|этничность|вероисповедание|религия|диагноз|исповеду(?:ет|ют|ю|ешь)|боле(?:ет|ют|ю|ешь)|страда(?:ет|ют|ю|ешь)[ \t]+от|на[ \t]+уч[её]те[ \t]+у|diagnosed[ \t]+with|suffers[ \t]+from|nationality|ethnicity|religion|diagnosis)(?:["']?[ \t]*[:=][ \t]*|[ \t]+)["']?([\p{L}][\p{L}\p{M}-]{0,63})(?![\p{L}\p{M}\p{N}_])/giu;
|
|
61
|
+
// Words that are also languages, scripts or styles need a personal context.
|
|
15
62
|
const ambiguous =
|
|
16
|
-
/(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian)(?![\p{L}\p{N}_])/giu;
|
|
63
|
+
/(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian|azerbaijani|korean|chinese|georgian|kazakh|kyrgyz|turkmen|uyghur|roma|union[ \t]+member)(?![\p{L}\p{N}_])/giu;
|
|
17
64
|
|
|
18
65
|
function personalContext(text, match) {
|
|
19
66
|
const prefix = text.slice(Math.max(0, match.index - 128), match.index);
|
|
@@ -56,8 +103,17 @@ export function detectStrictAttributes(text, emit, options) {
|
|
|
56
103
|
detected++;
|
|
57
104
|
emit(finding);
|
|
58
105
|
};
|
|
59
|
-
for (const pattern of [
|
|
60
|
-
|
|
106
|
+
for (const pattern of [
|
|
107
|
+
attributes,
|
|
108
|
+
additionalAttributes,
|
|
109
|
+
categoryForms,
|
|
110
|
+
specialVocabulary,
|
|
111
|
+
followUpVocabulary,
|
|
112
|
+
compiledVocabulary(options.vocabulary)?.attributes,
|
|
113
|
+
]) {
|
|
114
|
+
if (pattern) {
|
|
115
|
+
collectMatches(text, pattern, report, details);
|
|
116
|
+
}
|
|
61
117
|
}
|
|
62
118
|
collectMatches(text, categoryCue, report, details, 1);
|
|
63
119
|
collectMatches(text, ambiguous, report, details, 0, (_value, match) => {
|
package/src/strict.js
CHANGED
|
@@ -2,7 +2,7 @@ import { collectMatches } from './detection.js';
|
|
|
2
2
|
import { months } from './personal-context.js';
|
|
3
3
|
import { isDocumentTechnology } from './document-experience.js';
|
|
4
4
|
import { placeholderValues } from './placeholders.js';
|
|
5
|
-
import { generatedValues } from './fake.js';
|
|
5
|
+
import { generatedValues } from './fake-memory.js';
|
|
6
6
|
import { detectStrictAttributes } from './strict-attributes.js';
|
|
7
7
|
|
|
8
8
|
const boundary = '[\\p{L}\\p{N}_]';
|
package/src/structured.js
CHANGED
|
@@ -171,6 +171,7 @@ export function structuredFieldFindings(batch, options) {
|
|
|
171
171
|
start: entry.start + (denied ? 0 : 1),
|
|
172
172
|
end: entry.start + entry.raw.length - (denied ? 0 : 1),
|
|
173
173
|
type: denied ? 'JSON_VALUE' : type,
|
|
174
|
+
...(denied && type ? { valueType: type } : {}),
|
|
174
175
|
category: 'personal',
|
|
175
176
|
rule: 'known-personal',
|
|
176
177
|
confidence: 1,
|
|
@@ -516,6 +517,7 @@ function declaredValueFinding(text, token, options) {
|
|
|
516
517
|
start,
|
|
517
518
|
end,
|
|
518
519
|
type: 'JSON_VALUE',
|
|
520
|
+
...(type ? { valueType: type } : {}),
|
|
519
521
|
category: 'personal',
|
|
520
522
|
rule: 'known-personal',
|
|
521
523
|
confidence: 1,
|
package/src/transforms.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { feature } from './features.js';
|
|
2
2
|
import { createHmac } from 'node:crypto';
|
|
3
3
|
import { failure, REDACTED } from './detection.js';
|
|
4
4
|
import { encodedValue, encodeRun, decodedRuns } from './encoded.js';
|
|
5
|
-
import { isIdentityType } from './identity.js';
|
|
5
|
+
import { isIdentityType } from './identity-types.js';
|
|
6
6
|
import { validatePlaceholder, placeholderReplacement } from './placeholders.js';
|
|
7
7
|
import { projectText } from './projection.js';
|
|
8
8
|
import { encryptValue } from './encrypt.js';
|
|
@@ -262,7 +262,7 @@ function transformed(value, finding, transform, options) {
|
|
|
262
262
|
: REDACTED;
|
|
263
263
|
}
|
|
264
264
|
if (transform.mode === 'fake') {
|
|
265
|
-
return realisticFake(value, finding, transform, options);
|
|
265
|
+
return feature('realisticFake')(value, finding, transform, options);
|
|
266
266
|
}
|
|
267
267
|
if (finding.category === 'credential') {
|
|
268
268
|
return REDACTED;
|
|
@@ -277,6 +277,32 @@ function transformed(value, finding, transform, options) {
|
|
|
277
277
|
const lower = Math.floor(number / transform.size) * transform.size;
|
|
278
278
|
return `${lower}–${lower + transform.size - 1}`;
|
|
279
279
|
}
|
|
280
|
+
function jsonValueText(raw) {
|
|
281
|
+
try {
|
|
282
|
+
const value = JSON.parse(raw);
|
|
283
|
+
return typeof value === 'string' && value ? value : raw;
|
|
284
|
+
} catch {
|
|
285
|
+
return raw;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// A whole JSON value becomes one JSON string. Placeholder mode types it by the
|
|
289
|
+
// field it came from, so `{"age":34}` reads `{"age":"Age-1"}`.
|
|
290
|
+
function jsonValueReplacement(text, finding, options) {
|
|
291
|
+
const transform =
|
|
292
|
+
options.transformations?.JSON_VALUE ?? options.transformation;
|
|
293
|
+
if (transform?.mode !== 'placeholder') {
|
|
294
|
+
return JSON.stringify(REDACTED);
|
|
295
|
+
}
|
|
296
|
+
const raw = text.slice(finding.start, finding.end);
|
|
297
|
+
return JSON.stringify(
|
|
298
|
+
placeholderReplacement(
|
|
299
|
+
jsonValueText(raw),
|
|
300
|
+
{ ...finding, type: finding.valueType ?? 'SENSITIVE' },
|
|
301
|
+
transform,
|
|
302
|
+
options
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
280
306
|
export function renderReplacement(
|
|
281
307
|
text,
|
|
282
308
|
finding,
|
|
@@ -285,7 +311,7 @@ export function renderReplacement(
|
|
|
285
311
|
jsonRanges
|
|
286
312
|
) {
|
|
287
313
|
if (finding.type === 'JSON_VALUE') {
|
|
288
|
-
return
|
|
314
|
+
return jsonValueReplacement(text, finding, options);
|
|
289
315
|
}
|
|
290
316
|
const transform =
|
|
291
317
|
options.transformations?.[finding.type] ??
|
|
@@ -300,11 +326,15 @@ export function renderReplacement(
|
|
|
300
326
|
const sanitized = encodedSanitize(run.text).text;
|
|
301
327
|
// Mapped escaped fragments can lose their detecting context on decode.
|
|
302
328
|
// Redact the fragment if recursive scanning makes no progress.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
329
|
+
const encoded = encodeRun(
|
|
330
|
+
run,
|
|
331
|
+
sanitized === run.text ? REDACTED : sanitized
|
|
332
|
+
);
|
|
333
|
+
if (encoded !== undefined) {
|
|
334
|
+
return encoded;
|
|
335
|
+
}
|
|
307
336
|
}
|
|
337
|
+
// Without a recognizable re-encoding, the encoded text itself is masked.
|
|
308
338
|
}
|
|
309
339
|
return renderValue(text, finding, transform, options, jsonRanges);
|
|
310
340
|
}
|
package/src/vocabulary.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { failure, collectMatches } from './detection.js';
|
|
1
|
+
import { failure, collectMatches, escapePattern } from './detection.js';
|
|
2
2
|
import { wordPattern } from './personal-variants.js';
|
|
3
3
|
import { fieldSuffixes, normalizeFieldName } from './document-fields.js';
|
|
4
4
|
|
|
@@ -50,10 +50,12 @@ export const defaultVocabulary = Object.freeze(
|
|
|
50
50
|
'fullname',
|
|
51
51
|
'originalfilename',
|
|
52
52
|
],
|
|
53
|
+
personalAttributes: [],
|
|
53
54
|
}).map(([key, values]) => [key, Object.freeze(values)])
|
|
54
55
|
)
|
|
55
56
|
);
|
|
56
57
|
const cache = new Map();
|
|
58
|
+
const identities = new WeakMap();
|
|
57
59
|
const boundary = '[\\p{L}\\p{N}_]';
|
|
58
60
|
const name =
|
|
59
61
|
'[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}(?:[ \\t]+[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}){0,3}';
|
|
@@ -111,14 +113,44 @@ function normalizedList(input, defaults) {
|
|
|
111
113
|
),
|
|
112
114
|
];
|
|
113
115
|
}
|
|
116
|
+
// Caller stems for the strict special-category rule: each word matches at a
|
|
117
|
+
// word start and takes at most eight more letters of ending, like the built-in
|
|
118
|
+
// stems, so `узбеч` covers `узбечка` and `узбечкой`.
|
|
119
|
+
function attributePattern(values) {
|
|
120
|
+
if (!values.length) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const stems = values
|
|
124
|
+
.map((value) =>
|
|
125
|
+
value
|
|
126
|
+
.split(/\s+/u)
|
|
127
|
+
.map(
|
|
128
|
+
(word) =>
|
|
129
|
+
`${escapePattern(word).replace(/[её]/gu, '[её]')}[\\p{L}\\p{M}]{0,8}`
|
|
130
|
+
)
|
|
131
|
+
.join('[ \\t]+')
|
|
132
|
+
)
|
|
133
|
+
.sort((a, b) => b.length - a.length)
|
|
134
|
+
.join('|');
|
|
135
|
+
return new RegExp(
|
|
136
|
+
`(?<![\\p{L}\\p{N}_])(?:${stems})(?![\\p{L}\\p{N}_])`,
|
|
137
|
+
'giu'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
114
140
|
export function compiledVocabulary(vocabulary) {
|
|
115
141
|
if (vocabulary === undefined) {
|
|
116
142
|
return undefined;
|
|
117
143
|
}
|
|
144
|
+
const existing = identities.get(vocabulary);
|
|
145
|
+
if (existing) {
|
|
146
|
+
return existing;
|
|
147
|
+
}
|
|
118
148
|
const lists = normalizedLists(vocabulary);
|
|
119
149
|
const key = JSON.stringify(lists);
|
|
120
150
|
if (cache.has(key)) {
|
|
121
|
-
|
|
151
|
+
const result = cache.get(key);
|
|
152
|
+
identities.set(vocabulary, result);
|
|
153
|
+
return result;
|
|
122
154
|
}
|
|
123
155
|
const alternatives = (values) =>
|
|
124
156
|
values.length
|
|
@@ -159,10 +191,15 @@ export function compiledVocabulary(vocabulary) {
|
|
|
159
191
|
'giu'
|
|
160
192
|
);
|
|
161
193
|
result.fields = new Set(lists.personalFields.map(normalizeFieldName));
|
|
194
|
+
result.cues = new Set(
|
|
195
|
+
[...lists.roles, ...lists.personalLabels].map((s) => s.replaceAll('ё', 'е'))
|
|
196
|
+
);
|
|
197
|
+
result.attributes = attributePattern(lists.personalAttributes);
|
|
162
198
|
if (cache.size >= 64) {
|
|
163
199
|
cache.clear();
|
|
164
200
|
}
|
|
165
201
|
cache.set(key, result);
|
|
202
|
+
identities.set(vocabulary, result);
|
|
166
203
|
return result;
|
|
167
204
|
}
|
|
168
205
|
export function vocabularyField(field, options) {
|