@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.21.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 +22 -2
- package/bin/sensitive-data-sanitizer.js +50 -28
- package/docs/API.md +355 -16
- package/docs/COVERAGE.md +19 -9
- package/docs/RULES.md +218 -0
- package/examples/second-pass-placeholders.mjs +28 -0
- package/package.json +12 -3
- package/src/allow-list.js +93 -0
- package/src/confidence.js +24 -9
- package/src/context.js +287 -40
- 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 +51 -0
- package/src/crypto-addresses.js +74 -0
- package/src/detection.js +87 -41
- package/src/document-person-cues.js +1 -1
- package/src/encoded.js +137 -51
- package/src/encrypt.js +95 -0
- package/src/engines.js +33 -10
- package/src/entities.js +4 -3
- package/src/exempt.js +72 -0
- package/src/fake-memory.js +32 -0
- package/src/fake.js +33 -29
- package/src/features.js +29 -0
- package/src/files.js +57 -12
- package/src/gitleaks.js +82 -27
- package/src/html-tags.js +8 -0
- package/src/identifier-detector.js +36 -0
- package/src/identity-types.js +63 -0
- package/src/identity.js +3 -47
- package/src/index.d.ts +157 -3
- package/src/index.js +13 -0
- package/src/known.js +33 -15
- package/src/national-checks.js +209 -0
- package/src/national.js +310 -92
- package/src/pattern-detector.js +219 -0
- package/src/personal-context.js +3 -2
- package/src/personal-evidence.js +233 -0
- package/src/personal-features.js +39 -0
- package/src/personal.js +66 -10
- package/src/placeholder-types.js +154 -4
- package/src/placeholders.js +127 -45
- package/src/projection.js +78 -26
- package/src/proper-names.js +48 -5
- package/src/prose-words.js +120 -0
- package/src/record-worker.js +1 -0
- package/src/rule-catalog.js +426 -0
- package/src/rules.js +8 -0
- package/src/sanitizer.js +133 -23
- package/src/sarif.js +127 -0
- package/src/stream-worker.js +1 -0
- package/src/strict-attributes.js +48 -4
- package/src/strict.js +1 -1
- package/src/structured.js +2 -0
- package/src/token-checksum.js +49 -0
- package/src/transforms.js +146 -33
- package/src/vendor/betterleaks/LICENSE +21 -0
- package/src/vendor/betterleaks/provenance.json +11 -0
- package/src/vendor/betterleaks/rules-0.json +463 -0
- package/src/vendor/betterleaks/rules-1.json +454 -0
- package/src/vendor/betterleaks/rules-2.json +461 -0
- package/src/vendor/betterleaks/rules-3.json +455 -0
- package/src/vendor/betterleaks/rules-4.json +483 -0
- package/src/vendor/betterleaks/rules-5.json +35 -0
- package/src/vendor/entities/aws.json +1 -1
- package/src/vendor/entities/azure.json +6 -2
- package/src/vendor/entities/google.json +2 -1
- package/src/vocabulary.js +27 -1
- package/src/worker-options.js +1 -0
package/src/placeholder-types.js
CHANGED
|
@@ -1,34 +1,184 @@
|
|
|
1
1
|
import { catalogTypes } from './entities.js';
|
|
2
|
+
import { escapePattern, failure } from './detection.js';
|
|
3
|
+
import { jsonStringLiterals } from './json-string-runs.js';
|
|
4
|
+
import { placeholderToken, validatePlaceholderFormat } from './restore-map.js';
|
|
2
5
|
|
|
3
|
-
// Native
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
// Native types that the shared vendor-label catalog does not already list. The
|
|
7
|
+
// national identifier types are spelled out here so that this module does not
|
|
8
|
+
// load the national rules.
|
|
9
|
+
const nativeTypes = [
|
|
10
|
+
'ABA_ROUTING_NUMBER',
|
|
6
11
|
'ADDRESS',
|
|
12
|
+
'AE_EMIRATES_ID',
|
|
13
|
+
'AGE',
|
|
14
|
+
'AU_ABN',
|
|
15
|
+
'AU_ACN',
|
|
16
|
+
'AU_MEDICARE',
|
|
7
17
|
'AU_TFN',
|
|
8
18
|
'BRAZIL_CPF_NUMBER',
|
|
19
|
+
'CA_SIN',
|
|
9
20
|
'CREDENTIAL',
|
|
10
21
|
'CREDIT_CARD',
|
|
11
22
|
'DATE',
|
|
23
|
+
'DE_TAX_ID',
|
|
12
24
|
'EDUCATION',
|
|
13
25
|
'EMAIL',
|
|
14
26
|
'ENCODED_SENSITIVE',
|
|
27
|
+
'ES_NIE',
|
|
28
|
+
'EU_VAT_NUMBER',
|
|
29
|
+
'FI_PERSONAL_IDENTITY_CODE',
|
|
30
|
+
'GENDER',
|
|
15
31
|
'IBAN',
|
|
16
32
|
'ID',
|
|
33
|
+
'IL_ID_NUMBER',
|
|
34
|
+
'IMEI',
|
|
35
|
+
'IN_AADHAAR',
|
|
36
|
+
'IN_GSTIN',
|
|
37
|
+
'IN_PASSPORT',
|
|
38
|
+
'IN_VOTER',
|
|
17
39
|
'IP_ADDRESS',
|
|
40
|
+
'IT_FISCAL_CODE',
|
|
41
|
+
'IT_VAT_CODE',
|
|
18
42
|
'KR_RRN',
|
|
19
43
|
'LOCATION',
|
|
20
44
|
'MAC_ADDRESS',
|
|
45
|
+
'MEDICAL_LICENSE',
|
|
21
46
|
'ORGANIZATION',
|
|
22
47
|
'PASSPORT_MRZ',
|
|
23
48
|
'PASSPORT_NUMBER',
|
|
24
49
|
'PERSON',
|
|
25
|
-
'
|
|
50
|
+
'PERSONAL_ATTRIBUTE',
|
|
26
51
|
'PHONE',
|
|
27
52
|
'PL_PESEL',
|
|
53
|
+
'POSTAL_CODE',
|
|
54
|
+
'PROPER_NAME',
|
|
28
55
|
'RU_INN',
|
|
29
56
|
'RU_SNILS',
|
|
57
|
+
'SA_NATIONAL_ID',
|
|
58
|
+
'SENSITIVE',
|
|
59
|
+
'SE_PERSONNUMMER',
|
|
30
60
|
'SG_NRIC_FIN',
|
|
61
|
+
'SG_UEN',
|
|
31
62
|
'STATE',
|
|
63
|
+
'STEAM_ID',
|
|
64
|
+
'SWIFT_CODE',
|
|
65
|
+
'TH_TNIN',
|
|
66
|
+
'TRACKING_NUMBER',
|
|
67
|
+
'UK_DRIVING_LICENCE',
|
|
68
|
+
'UK_NATIONAL_INSURANCE_NUMBER',
|
|
69
|
+
'UK_NHS',
|
|
70
|
+
'UK_POSTCODE',
|
|
71
|
+
'URL',
|
|
32
72
|
'USERNAME',
|
|
73
|
+
'US_BANK_NUMBER',
|
|
74
|
+
'US_DRIVER_LICENSE',
|
|
33
75
|
'US_HEALTHCARE_NPI',
|
|
76
|
+
'US_ITIN',
|
|
77
|
+
'VIN',
|
|
78
|
+
'ZA_ID_NUMBER',
|
|
34
79
|
];
|
|
80
|
+
let reserved;
|
|
81
|
+
// Every type that a built-in rule can turn into a `Type-n` token.
|
|
82
|
+
export function reservedPlaceholderTypes() {
|
|
83
|
+
reserved ??= Object.freeze(
|
|
84
|
+
[...new Set([...catalogTypes(), ...nativeTypes])].sort()
|
|
85
|
+
);
|
|
86
|
+
return reserved;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const defaultFormat = '{Type}-{n}';
|
|
90
|
+
const patterns = new Map();
|
|
91
|
+
const patternCacheSize = 64;
|
|
92
|
+
// The namespace pattern depends only on the format and on the types beyond the
|
|
93
|
+
// reserved list, so it is compiled once per format and cached.
|
|
94
|
+
export function namespacePattern(format = defaultFormat, extraTypes = []) {
|
|
95
|
+
const base = new Set(reservedPlaceholderTypes());
|
|
96
|
+
const extra = [...new Set(extraTypes)].filter((type) => !base.has(type));
|
|
97
|
+
const key = `${format}\0${extra.sort().join('\0')}`;
|
|
98
|
+
const cached = patterns.get(key);
|
|
99
|
+
if (cached) {
|
|
100
|
+
return cached;
|
|
101
|
+
}
|
|
102
|
+
const [before, after] = format.split('{n}');
|
|
103
|
+
const typed = before.match(/\{(?:Type|TYPE|type)\}/) ? before : after;
|
|
104
|
+
const types = new Map();
|
|
105
|
+
for (const type of [...base, ...extra]) {
|
|
106
|
+
const part = placeholderToken(type, 1, typed);
|
|
107
|
+
types.set(part, [...(types.get(part) ?? []), type]);
|
|
108
|
+
}
|
|
109
|
+
const alternation = [...types.keys()]
|
|
110
|
+
.sort((a, b) => b.length - a.length || a.localeCompare(b))
|
|
111
|
+
.map(escapePattern)
|
|
112
|
+
.join('|');
|
|
113
|
+
const number = '([1-9][0-9]*)';
|
|
114
|
+
const source =
|
|
115
|
+
typed === before
|
|
116
|
+
? `(${alternation})${number}${escapePattern(after)}`
|
|
117
|
+
: `${escapePattern(before)}${number}(${alternation})`;
|
|
118
|
+
const pattern = {
|
|
119
|
+
source: `(?<![\\p{L}\\p{M}\\p{N}_])(?:${source})(?![\\p{L}\\p{M}\\p{N}_])`,
|
|
120
|
+
typedFirst: typed === before,
|
|
121
|
+
types,
|
|
122
|
+
};
|
|
123
|
+
if (patterns.size >= patternCacheSize) {
|
|
124
|
+
patterns.delete(patterns.keys().next().value);
|
|
125
|
+
}
|
|
126
|
+
patterns.set(key, pattern);
|
|
127
|
+
return pattern;
|
|
128
|
+
}
|
|
129
|
+
// Tokens of the namespace with the types each spelling can stand for.
|
|
130
|
+
export function placeholderTokens(text, pattern) {
|
|
131
|
+
const tokens = [];
|
|
132
|
+
for (const match of text.matchAll(new RegExp(pattern.source, 'gu'))) {
|
|
133
|
+
const [part, number] = pattern.typedFirst
|
|
134
|
+
? [match[1], match[2]]
|
|
135
|
+
: [match[2], match[1]];
|
|
136
|
+
tokens.push({
|
|
137
|
+
start: match.index,
|
|
138
|
+
end: match.index + match[0].length,
|
|
139
|
+
number: Number(number),
|
|
140
|
+
types: pattern.types.get(part),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return tokens;
|
|
144
|
+
}
|
|
145
|
+
export function containsPlaceholder(text, pattern) {
|
|
146
|
+
return new RegExp(pattern.source, 'u').test(text);
|
|
147
|
+
}
|
|
148
|
+
// Tokens spelled with JSON escapes inside string literals, such as
|
|
149
|
+
// `Person-1`, decoded so that they cannot hide from the namespace check.
|
|
150
|
+
export function escapedPlaceholderTokens(text, pattern) {
|
|
151
|
+
const tokens = [];
|
|
152
|
+
for (const literal of jsonStringLiterals(text)) {
|
|
153
|
+
try {
|
|
154
|
+
tokens.push(...placeholderTokens(JSON.parse(literal), pattern));
|
|
155
|
+
} catch {
|
|
156
|
+
// Ordinary free text may include strings that are not JSON literals.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return tokens;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Public check for a whole `Type-n` token of the library namespace.
|
|
163
|
+
export function isPlaceholderToken(text, options = {}) {
|
|
164
|
+
if (
|
|
165
|
+
options === null ||
|
|
166
|
+
typeof options !== 'object' ||
|
|
167
|
+
(options.types !== undefined &&
|
|
168
|
+
(!Array.isArray(options.types) ||
|
|
169
|
+
options.types.some(
|
|
170
|
+
(type) =>
|
|
171
|
+
typeof type !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(type)
|
|
172
|
+
)))
|
|
173
|
+
) {
|
|
174
|
+
throw failure('ERR_CONFIG');
|
|
175
|
+
}
|
|
176
|
+
if (typeof text !== 'string') {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const pattern = namespacePattern(
|
|
180
|
+
validatePlaceholderFormat(options.format ?? defaultFormat),
|
|
181
|
+
options.types ?? []
|
|
182
|
+
);
|
|
183
|
+
return new RegExp(`^(?:${pattern.source})$`, 'u').test(text);
|
|
184
|
+
}
|
package/src/placeholders.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { failure, REDACTED, escapePattern } from './detection.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
namespacePattern,
|
|
4
|
+
placeholderTokens,
|
|
5
|
+
containsPlaceholder,
|
|
6
|
+
escapedPlaceholderTokens,
|
|
7
|
+
} from './placeholder-types.js';
|
|
3
8
|
import { samePersonal } from './personal-variants.js';
|
|
4
9
|
import { isNameForm } from './russian-names.js';
|
|
5
10
|
import { nameAliases } from './transliteration.js';
|
|
6
11
|
import { createHash } from 'node:crypto';
|
|
7
|
-
import { jsonStringLiterals } from './json-string-runs.js';
|
|
8
12
|
import {
|
|
9
13
|
createRestoreMap,
|
|
10
14
|
validateRestoreMap,
|
|
@@ -35,8 +39,15 @@ const transforms = (options) =>
|
|
|
35
39
|
...Object.values(options.transformations ?? {}),
|
|
36
40
|
].filter((t) => t?.mode === 'placeholder');
|
|
37
41
|
|
|
42
|
+
const existingModes = new Set(['error', 'keep']);
|
|
38
43
|
export function validatePlaceholder(transform) {
|
|
39
44
|
validatePlaceholderFormat(transform.format ?? transform.map?.format);
|
|
45
|
+
if (
|
|
46
|
+
transform.existing !== undefined &&
|
|
47
|
+
!existingModes.has(transform.existing)
|
|
48
|
+
) {
|
|
49
|
+
throw failure('ERR_CONFIG');
|
|
50
|
+
}
|
|
40
51
|
if (
|
|
41
52
|
transform.credentials !== undefined &&
|
|
42
53
|
typeof transform.credentials !== 'boolean'
|
|
@@ -67,12 +78,21 @@ export function initializePlaceholders(options) {
|
|
|
67
78
|
selected.some(
|
|
68
79
|
(t) =>
|
|
69
80
|
(t.map !== undefined && t.map !== first.map) ||
|
|
70
|
-
(t.format !== undefined && t.format !== map.format)
|
|
81
|
+
(t.format !== undefined && t.format !== map.format) ||
|
|
82
|
+
(t.existing ?? 'error') !== (first.existing ?? 'error')
|
|
71
83
|
)
|
|
72
84
|
) {
|
|
73
85
|
throw failure('ERR_CONFIG');
|
|
74
86
|
}
|
|
75
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
...options,
|
|
89
|
+
[placeholderState]: {
|
|
90
|
+
map,
|
|
91
|
+
keep: first.map !== false,
|
|
92
|
+
existing: first.existing ?? 'error',
|
|
93
|
+
floors: new Map(),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
76
96
|
}
|
|
77
97
|
export function placeholderKnown(options) {
|
|
78
98
|
const map = options[placeholderState]?.map;
|
|
@@ -305,31 +325,95 @@ function addNamePatterns(person, patterns, bareForms) {
|
|
|
305
325
|
}
|
|
306
326
|
}
|
|
307
327
|
}
|
|
328
|
+
const placeholderType = (finding) => {
|
|
329
|
+
if (finding.category === 'credential') {
|
|
330
|
+
return 'CREDENTIAL';
|
|
331
|
+
}
|
|
332
|
+
const type =
|
|
333
|
+
finding.type === 'JSON_VALUE'
|
|
334
|
+
? (finding.valueType ?? 'SENSITIVE')
|
|
335
|
+
: finding.type;
|
|
336
|
+
return type === 'DATE_OF_BIRTH' ? 'DATE' : type;
|
|
337
|
+
};
|
|
338
|
+
// Types beyond the reserved list that this call can turn into tokens.
|
|
339
|
+
function callPattern(findings, options) {
|
|
340
|
+
const transform = transforms(options)[0];
|
|
341
|
+
const map = options[placeholderState]?.map;
|
|
342
|
+
return namespacePattern(
|
|
343
|
+
map?.format ?? transform?.format ?? transform?.map?.format,
|
|
344
|
+
[
|
|
345
|
+
...findings.map(placeholderType),
|
|
346
|
+
...(options.knownPersonal ?? []).map((e) => e.type),
|
|
347
|
+
...(map?.entries ?? []).map((e) => e.type),
|
|
348
|
+
]
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const keepsExisting = (options) => transforms(options)[0]?.existing === 'keep';
|
|
352
|
+
// With `existing: 'keep'` the tokens of an earlier pass stay as they are, so
|
|
353
|
+
// findings made only of those tokens are not replaced again.
|
|
354
|
+
export function keptPlaceholderSpans(text, findings, options) {
|
|
355
|
+
if (!keepsExisting(options)) {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
return placeholderTokens(text, callPattern(findings, options));
|
|
359
|
+
}
|
|
360
|
+
const significant = /[\p{L}\p{N}]/u;
|
|
361
|
+
// A finding made only of kept tokens is kept, and one that starts or ends
|
|
362
|
+
// inside a kept token is trimmed to the text beside it.
|
|
363
|
+
function trimmedToLoose(finding, inside, loose) {
|
|
364
|
+
let { start, end } = finding;
|
|
365
|
+
if (inside(start)) {
|
|
366
|
+
while (start < end && !loose(start)) {
|
|
367
|
+
start = inside(start)?.end ?? start + 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (inside(end - 1)) {
|
|
371
|
+
while (end > start && !loose(end - 1)) {
|
|
372
|
+
end = inside(end - 1)?.start ?? end - 1;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return start === finding.start && end === finding.end
|
|
376
|
+
? finding
|
|
377
|
+
: { ...finding, start, end };
|
|
378
|
+
}
|
|
379
|
+
export function keptPlaceholderFinding(text, finding, tokens) {
|
|
380
|
+
const overlapping = tokens.filter(
|
|
381
|
+
(token) => token.start < finding.end && token.end > finding.start
|
|
382
|
+
);
|
|
383
|
+
if (finding.category !== 'personal' || !overlapping.length) {
|
|
384
|
+
return finding;
|
|
385
|
+
}
|
|
386
|
+
const inside = (i) =>
|
|
387
|
+
overlapping.find((token) => token.start <= i && token.end > i);
|
|
388
|
+
const loose = (i) => !inside(i) && significant.test(text[i]);
|
|
389
|
+
const { start, end } = finding;
|
|
390
|
+
if (!Array.from({ length: end - start }, (_, i) => start + i).some(loose)) {
|
|
391
|
+
return { ...finding, kept: 'fake' };
|
|
392
|
+
}
|
|
393
|
+
return finding.type === 'JSON_VALUE'
|
|
394
|
+
? finding
|
|
395
|
+
: trimmedToLoose(finding, inside, loose);
|
|
396
|
+
}
|
|
308
397
|
export function preparePlaceholderText(text, findings, options) {
|
|
309
398
|
const state = options[placeholderState];
|
|
310
399
|
if (!state || !hasPlaceholders(options)) {
|
|
311
400
|
return;
|
|
312
401
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
const pattern = new RegExp(
|
|
329
|
-
`(?<![\\p{L}\\p{M}\\p{N}_])(?:${tokens.join('|')})(?![\\p{L}\\p{M}\\p{N}_])`,
|
|
330
|
-
'u'
|
|
331
|
-
);
|
|
332
|
-
if (namespaceCollision(text, pattern)) {
|
|
402
|
+
const pattern = callPattern(findings, options);
|
|
403
|
+
if (state.existing === 'keep') {
|
|
404
|
+
// New values are numbered after the highest kept number of their type.
|
|
405
|
+
for (const token of [
|
|
406
|
+
...placeholderTokens(text, pattern),
|
|
407
|
+
...escapedPlaceholderTokens(text, pattern),
|
|
408
|
+
]) {
|
|
409
|
+
for (const type of token.types) {
|
|
410
|
+
state.floors.set(
|
|
411
|
+
type,
|
|
412
|
+
Math.max(state.floors.get(type) ?? 0, token.number)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} else if (namespaceCollision(text, pattern)) {
|
|
333
417
|
throw failure('ERR_PLACEHOLDER_COLLISION');
|
|
334
418
|
}
|
|
335
419
|
state.people = [
|
|
@@ -345,20 +429,11 @@ export function preparePlaceholderText(text, findings, options) {
|
|
|
345
429
|
];
|
|
346
430
|
}
|
|
347
431
|
function namespaceCollision(text, pattern) {
|
|
348
|
-
if (pattern.test(text)) {
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
432
|
// JSON escape spellings must not conceal existing tokens in keys or values.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
} catch {
|
|
358
|
-
// Ordinary free text may include strings that are not JSON literals.
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
return false;
|
|
433
|
+
return (
|
|
434
|
+
containsPlaceholder(text, pattern) ||
|
|
435
|
+
escapedPlaceholderTokens(text, pattern).length > 0
|
|
436
|
+
);
|
|
362
437
|
}
|
|
363
438
|
|
|
364
439
|
export function canonicalPerson(value, people) {
|
|
@@ -381,7 +456,18 @@ export function canonicalPerson(value, people) {
|
|
|
381
456
|
person,
|
|
382
457
|
])
|
|
383
458
|
);
|
|
384
|
-
|
|
459
|
+
if (distinct.size === 1) {
|
|
460
|
+
return [...distinct.values()][0];
|
|
461
|
+
}
|
|
462
|
+
// `Иванов Иван` and `Иванов Иван Петрович` are one mention in two lengths:
|
|
463
|
+
// the unique name that holds the words of every other match is the person.
|
|
464
|
+
const words = (person) =>
|
|
465
|
+
new Set((person.match(/\p{L}+/gu) ?? []).map((w) => w.toLowerCase()));
|
|
466
|
+
const fullest = [...distinct.values()].filter((person) => {
|
|
467
|
+
const own = words(person);
|
|
468
|
+
return matches.every((other) => [...words(other)].every((w) => own.has(w)));
|
|
469
|
+
});
|
|
470
|
+
return fullest.length === 1 ? fullest[0] : value;
|
|
385
471
|
}
|
|
386
472
|
function canonicalKnown(value, type, options, state) {
|
|
387
473
|
const known = (options.knownPersonal ?? []).filter(
|
|
@@ -427,12 +513,7 @@ export function placeholderReplacement(value, finding, transform, options) {
|
|
|
427
513
|
throw failure('ERR_CONFIG');
|
|
428
514
|
}
|
|
429
515
|
const { map } = state;
|
|
430
|
-
const type =
|
|
431
|
-
finding.category === 'credential'
|
|
432
|
-
? 'CREDENTIAL'
|
|
433
|
-
: finding.type === 'DATE_OF_BIRTH'
|
|
434
|
-
? 'DATE'
|
|
435
|
-
: finding.type;
|
|
516
|
+
const type = placeholderType(finding);
|
|
436
517
|
const canonical = canonicalKnown(value, type, options, state);
|
|
437
518
|
const candidates = map.entries.filter(
|
|
438
519
|
(entry) => entry.type === type && related(canonical, entry)
|
|
@@ -451,7 +532,7 @@ export function placeholderReplacement(value, finding, transform, options) {
|
|
|
451
532
|
1 +
|
|
452
533
|
map.entries.reduce(
|
|
453
534
|
(n, e) => (e.type === type ? Math.max(n, e.number) : n),
|
|
454
|
-
0
|
|
535
|
+
state.floors?.get(type) ?? 0
|
|
455
536
|
);
|
|
456
537
|
const token = placeholderToken(type, number, map.format);
|
|
457
538
|
if (
|
|
@@ -543,5 +624,6 @@ export function discardPlaceholderState(options) {
|
|
|
543
624
|
state.map.entries.length = 0;
|
|
544
625
|
state.map.documents.length = 0;
|
|
545
626
|
state.people = [];
|
|
627
|
+
state.floors?.clear();
|
|
546
628
|
}
|
|
547
629
|
}
|
package/src/projection.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Map a detection view back to the original UTF-16 offsets. Normalization
|
|
2
2
|
// never writes to the original text, and offsets remain valid after emoji.
|
|
3
3
|
import { failure } from './detection.js';
|
|
4
|
+
import { htmlTag, inlineTags } from './html-tags.js';
|
|
4
5
|
|
|
5
6
|
// Unicode Nd zero codepoints. NFKC already maps mathematical/fullwidth digits;
|
|
6
7
|
// other numeral systems need explicit conversion with original-source spans.
|
|
@@ -21,12 +22,83 @@ function asciiDigits(value) {
|
|
|
21
22
|
});
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
// Invisible format characters and NUL (UTF-16 text read as bytes) never
|
|
26
|
+
// carry meaning inside a token, so they are removed from the detection view.
|
|
27
|
+
const invisible =
|
|
28
|
+
/^[\0\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u;
|
|
29
|
+
const inlineTag = new RegExp(String.raw`^<\/?(?:${inlineTags})[\s/>]`);
|
|
30
|
+
// TruffleHog-style escape runs: `U+0074 U+006F ...` and CSS `\74 \6f ...`.
|
|
31
|
+
// Runs need four or more units so prose such as "U+200B" stays untouched.
|
|
32
|
+
// The space after the last unit stays in the view as a word boundary.
|
|
33
|
+
const codeRun = String.raw`(?:U\+[\da-fA-F]{4,6} ?){3,}U\+[\da-fA-F]{4,6}`;
|
|
34
|
+
const cssRun = String.raw`(?:\\[\da-fA-F]{1,6} ){3,}\\[\da-fA-F]{1,6}`;
|
|
35
|
+
const projectionTrigger = new RegExp(
|
|
36
|
+
String.raw`\x1b\[|\0|\\u[\da-fA-F{]|\\x\{[\da-fA-F]|\\U[\da-fA-F]{8}|%u[\da-fA-F]{4}|U\+[\da-fA-F]{4}|\\[\da-fA-F]{1,6} \\|[\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]|${htmlTag}`,
|
|
37
|
+
'u'
|
|
38
|
+
);
|
|
39
|
+
const projectionUnits = new RegExp(
|
|
40
|
+
String.raw`\x1b\[[0-?]*[ -/]*[@-~]|\\u[\da-fA-F]{4}|\\[ux]\{[\da-fA-F]{1,6}\}|\\U[\da-fA-F]{8}|%u[\da-fA-F]{4}|${codeRun}|${cssRun}|${htmlTag}|[\s\S]\p{M}*`,
|
|
41
|
+
'gu'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function decodeEscape(raw) {
|
|
45
|
+
const escape = /^(?:\\[ux]\{?|\\U|%u)([\da-fA-F]+)\}?$/.exec(raw);
|
|
46
|
+
if (!escape) {
|
|
47
|
+
return raw;
|
|
48
|
+
}
|
|
49
|
+
const code = parseInt(escape[1], 16);
|
|
50
|
+
// `\uXXXX` keeps UTF-16 surrogate halves; wider forms are code points.
|
|
51
|
+
return raw.length === 6 && raw[0] === '\\'
|
|
52
|
+
? String.fromCharCode(code)
|
|
53
|
+
: code <= 0x10ffff
|
|
54
|
+
? String.fromCodePoint(code)
|
|
55
|
+
: raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Every character decoded from an escape run maps to the whole run. Redacting
|
|
59
|
+
// only some units would leave a short run, such as a lone closing quote, that
|
|
60
|
+
// the next view no longer decodes, so a finding inside a run covers all of it.
|
|
61
|
+
function appendRun(writer, raw, offset) {
|
|
62
|
+
const unit = raw.startsWith('U+')
|
|
63
|
+
? /U\+([\da-fA-F]{4,6}) ?/g
|
|
64
|
+
: /\\([\da-fA-F]{1,6}) ?/g;
|
|
65
|
+
const end = offset + raw.length;
|
|
66
|
+
for (const match of raw.matchAll(unit)) {
|
|
67
|
+
const code = parseInt(match[1], 16);
|
|
68
|
+
const value = code <= 0x10ffff ? String.fromCodePoint(code) : match[0];
|
|
69
|
+
if (!invisible.test(value)) {
|
|
70
|
+
writer.append(value, offset, end);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function projectUnit(writer, raw, offset) {
|
|
76
|
+
const end = offset + raw.length;
|
|
77
|
+
if (raw.startsWith('\x1b[') || invisible.test(raw)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (raw[0] === '<' && raw.length > 2 && raw.endsWith('>')) {
|
|
81
|
+
if (!inlineTag.test(raw)) {
|
|
82
|
+
writer.append('\n', offset, end);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (raw.length > 8 && /^(?:U\+|\\[\da-fA-F])/.test(raw)) {
|
|
87
|
+
appendRun(writer, raw, offset);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const decoded = decodeEscape(raw);
|
|
91
|
+
if (invisible.test(decoded)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const value = asciiDigits(decoded.normalize('NFKC'));
|
|
95
|
+
writer.append(value, offset, end);
|
|
96
|
+
return value !== raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
24
99
|
export function projectText(input) {
|
|
25
100
|
if (
|
|
26
|
-
|
|
27
|
-
!/\x1b\[|\\u[\da-fA-F]{4}|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/u.test(
|
|
28
|
-
input
|
|
29
|
-
) &&
|
|
101
|
+
!projectionTrigger.test(input) &&
|
|
30
102
|
// ASCII already has normalized letters and digits. Avoid constructing a
|
|
31
103
|
// second multi-megabyte string just to check its decimal characters.
|
|
32
104
|
// eslint-disable-next-line no-control-regex -- Check the ASCII range.
|
|
@@ -37,29 +109,9 @@ export function projectText(input) {
|
|
|
37
109
|
return { text: input, changed: false };
|
|
38
110
|
}
|
|
39
111
|
const writer = projectionWriter(input.length);
|
|
40
|
-
// eslint-disable-next-line no-control-regex -- Tokenize ANSI sequences with their original offsets.
|
|
41
|
-
const units = /\x1b\[[0-?]*[ -/]*[@-~]|\\u[\da-fA-F]{4}|[\s\S]\p{M}*/gu;
|
|
42
112
|
let changed = false;
|
|
43
|
-
for (const match of input.matchAll(
|
|
44
|
-
|
|
45
|
-
let value = raw;
|
|
46
|
-
if (
|
|
47
|
-
raw.startsWith('\x1b[') ||
|
|
48
|
-
/^[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u.test(raw)
|
|
49
|
-
) {
|
|
50
|
-
changed = true;
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (/^\\u[\da-fA-F]{4}$/.test(raw)) {
|
|
54
|
-
value = String.fromCharCode(parseInt(raw.slice(2), 16));
|
|
55
|
-
}
|
|
56
|
-
if (/^[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u.test(value)) {
|
|
57
|
-
changed = true;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
value = asciiDigits(value.normalize('NFKC'));
|
|
61
|
-
changed ||= value !== raw;
|
|
62
|
-
writer.append(value, match.index, match.index + raw.length);
|
|
113
|
+
for (const match of input.matchAll(projectionUnits)) {
|
|
114
|
+
changed = projectUnit(writer, match[0], match.index) || changed;
|
|
63
115
|
}
|
|
64
116
|
return { ...writer.finish(), changed };
|
|
65
117
|
}
|
package/src/proper-names.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { failure } from './detection.js';
|
|
2
2
|
import { documentDateRange } from './document-dates.js';
|
|
3
|
+
import { keepsProseWord } from './prose-words.js';
|
|
3
4
|
|
|
4
5
|
const scriptPatterns = new Map();
|
|
5
6
|
const dateRange = new RegExp(`\\(${documentDateRange}\\)`, 'u');
|
|
@@ -113,6 +114,21 @@ function quotedSpans(text, emit) {
|
|
|
113
114
|
function pathToken(value) {
|
|
114
115
|
return value.includes('/') || value.includes('\\');
|
|
115
116
|
}
|
|
117
|
+
// Compact typed document findings, such as `ООО «Ромашка»` after `работал в`
|
|
118
|
+
// or `Казани` after `живу в`, name what the prose tokens inside them are.
|
|
119
|
+
const typedDocumentTypes = new Set([
|
|
120
|
+
'ORGANIZATION',
|
|
121
|
+
'LOCATION',
|
|
122
|
+
'EDUCATION',
|
|
123
|
+
'PERSON',
|
|
124
|
+
]);
|
|
125
|
+
function compactTyped(text, finding) {
|
|
126
|
+
return (
|
|
127
|
+
typedDocumentTypes.has(finding.type) &&
|
|
128
|
+
finding.end - finding.start <= 160 &&
|
|
129
|
+
!/[\t\r\n]/u.test(text.slice(finding.start, finding.end))
|
|
130
|
+
);
|
|
131
|
+
}
|
|
116
132
|
// Keep existing document coverage outside the new replacements. Two naturally
|
|
117
133
|
// ordered runs (quotes, then tokens) provide an interval index without sorting.
|
|
118
134
|
// Overlapping broad document spans must not merge separate prose replacements.
|
|
@@ -131,28 +147,48 @@ export function detectProseAndDocuments(text, emit, options, detectDocuments) {
|
|
|
131
147
|
runs.push([]);
|
|
132
148
|
}
|
|
133
149
|
runs.at(-1).push(finding);
|
|
134
|
-
emit(finding);
|
|
135
150
|
},
|
|
136
151
|
options
|
|
137
152
|
);
|
|
153
|
+
const covered = new Set();
|
|
138
154
|
detectDocuments(
|
|
139
155
|
text,
|
|
140
156
|
(finding) => {
|
|
157
|
+
const overlapping = runs.flatMap((run) => overlapsRun(run, finding));
|
|
141
158
|
if (completeTypes.has(finding.type)) {
|
|
142
159
|
// Contacts and identifiers are indivisible: a domain or series alone
|
|
143
160
|
// cannot safely replace the complete value detected by document rules.
|
|
144
161
|
emit(finding);
|
|
145
162
|
complete++;
|
|
146
|
-
} else if (!
|
|
163
|
+
} else if (!overlapping.length) {
|
|
147
164
|
emit(finding);
|
|
165
|
+
} else if (
|
|
166
|
+
compactTyped(text, finding) &&
|
|
167
|
+
overlapping.every(
|
|
168
|
+
(prose) => prose.start >= finding.start && prose.end <= finding.end
|
|
169
|
+
)
|
|
170
|
+
) {
|
|
171
|
+
emit(finding);
|
|
172
|
+
overlapping.forEach((prose) => covered.add(prose));
|
|
148
173
|
} else {
|
|
149
174
|
suppressed++;
|
|
150
175
|
}
|
|
151
176
|
},
|
|
152
177
|
options
|
|
153
178
|
);
|
|
154
|
-
|
|
179
|
+
for (const finding of runs.flat()) {
|
|
180
|
+
if (!covered.has(finding)) {
|
|
181
|
+
emit(finding);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
options.debug?.({
|
|
185
|
+
event: 'document-prose-overlap',
|
|
186
|
+
complete,
|
|
187
|
+
suppressed,
|
|
188
|
+
typed: covered.size,
|
|
189
|
+
});
|
|
155
190
|
}
|
|
191
|
+
// The prose findings of one ordered run that overlap a document finding.
|
|
156
192
|
function overlapsRun(run, finding) {
|
|
157
193
|
let low = 0,
|
|
158
194
|
high = run.length;
|
|
@@ -164,7 +200,11 @@ function overlapsRun(run, finding) {
|
|
|
164
200
|
high = mid;
|
|
165
201
|
}
|
|
166
202
|
}
|
|
167
|
-
|
|
203
|
+
const overlapping = [];
|
|
204
|
+
for (let i = low; i < run.length && run[i].start < finding.end; i++) {
|
|
205
|
+
overlapping.push(run[i]);
|
|
206
|
+
}
|
|
207
|
+
return overlapping;
|
|
168
208
|
}
|
|
169
209
|
export function detectProperNames(text, emit, options) {
|
|
170
210
|
if (options.properNames !== 'capitalized' || !proseData(text, options)) {
|
|
@@ -199,7 +239,10 @@ export function detectProperNames(text, emit, options) {
|
|
|
199
239
|
while (range && range.index + range[0].length <= start) {
|
|
200
240
|
range = ranges.next().value;
|
|
201
241
|
}
|
|
202
|
-
if (
|
|
242
|
+
if (
|
|
243
|
+
preserveToken(text, raw, value, start, end, range) ||
|
|
244
|
+
keepsProseWord(text, value, start, end, options)
|
|
245
|
+
) {
|
|
203
246
|
continue;
|
|
204
247
|
}
|
|
205
248
|
const type = tokenType(value);
|