@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/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,112 @@ 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
|
+
// Default tokens always contain a hyphen followed by a positive number.
|
|
353
|
+
// Escapes may conceal either character, and custom formats need the full scan.
|
|
354
|
+
function mayContainPlaceholder(text, options, escaped = false) {
|
|
355
|
+
const transform = transforms(options)[0];
|
|
356
|
+
const format =
|
|
357
|
+
options[placeholderState]?.map.format ??
|
|
358
|
+
transform?.format ??
|
|
359
|
+
transform?.map?.format ??
|
|
360
|
+
'{Type}-{n}';
|
|
361
|
+
return (
|
|
362
|
+
format !== '{Type}-{n}' ||
|
|
363
|
+
/-[1-9]/u.test(text) ||
|
|
364
|
+
(escaped && text.includes('\\'))
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// With `existing: 'keep'` the tokens of an earlier pass stay as they are, so
|
|
368
|
+
// findings made only of those tokens are not replaced again.
|
|
369
|
+
export function keptPlaceholderSpans(text, findings, options) {
|
|
370
|
+
if (!keepsExisting(options) || !mayContainPlaceholder(text, options)) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
return placeholderTokens(text, callPattern(findings, options));
|
|
374
|
+
}
|
|
375
|
+
const significant = /[\p{L}\p{N}]/u;
|
|
376
|
+
// A finding made only of kept tokens is kept, and one that starts or ends
|
|
377
|
+
// inside a kept token is trimmed to the text beside it.
|
|
378
|
+
function trimmedToLoose(finding, inside, loose) {
|
|
379
|
+
let { start, end } = finding;
|
|
380
|
+
if (inside(start)) {
|
|
381
|
+
while (start < end && !loose(start)) {
|
|
382
|
+
start = inside(start)?.end ?? start + 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (inside(end - 1)) {
|
|
386
|
+
while (end > start && !loose(end - 1)) {
|
|
387
|
+
end = inside(end - 1)?.start ?? end - 1;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return start === finding.start && end === finding.end
|
|
391
|
+
? finding
|
|
392
|
+
: { ...finding, start, end };
|
|
393
|
+
}
|
|
394
|
+
export function keptPlaceholderFinding(text, finding, tokens) {
|
|
395
|
+
const overlapping = tokens.filter(
|
|
396
|
+
(token) => token.start < finding.end && token.end > finding.start
|
|
397
|
+
);
|
|
398
|
+
if (finding.category !== 'personal' || !overlapping.length) {
|
|
399
|
+
return finding;
|
|
400
|
+
}
|
|
401
|
+
const inside = (i) =>
|
|
402
|
+
overlapping.find((token) => token.start <= i && token.end > i);
|
|
403
|
+
const loose = (i) => !inside(i) && significant.test(text[i]);
|
|
404
|
+
const { start, end } = finding;
|
|
405
|
+
if (!Array.from({ length: end - start }, (_, i) => start + i).some(loose)) {
|
|
406
|
+
return { ...finding, kept: 'fake' };
|
|
407
|
+
}
|
|
408
|
+
return finding.type === 'JSON_VALUE'
|
|
409
|
+
? finding
|
|
410
|
+
: trimmedToLoose(finding, inside, loose);
|
|
411
|
+
}
|
|
308
412
|
export function preparePlaceholderText(text, findings, options) {
|
|
309
413
|
const state = options[placeholderState];
|
|
310
414
|
if (!state || !hasPlaceholders(options)) {
|
|
311
415
|
return;
|
|
312
416
|
}
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
'u'
|
|
331
|
-
);
|
|
332
|
-
if (namespaceCollision(text, pattern)) {
|
|
417
|
+
const pattern = mayContainPlaceholder(text, options, true)
|
|
418
|
+
? callPattern(findings, options)
|
|
419
|
+
: undefined;
|
|
420
|
+
if (pattern && state.existing === 'keep') {
|
|
421
|
+
// New values are numbered after the highest kept number of their type.
|
|
422
|
+
for (const token of [
|
|
423
|
+
...placeholderTokens(text, pattern),
|
|
424
|
+
...escapedPlaceholderTokens(text, pattern),
|
|
425
|
+
]) {
|
|
426
|
+
for (const type of token.types) {
|
|
427
|
+
state.floors.set(
|
|
428
|
+
type,
|
|
429
|
+
Math.max(state.floors.get(type) ?? 0, token.number)
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} else if (pattern && namespaceCollision(text, pattern)) {
|
|
333
434
|
throw failure('ERR_PLACEHOLDER_COLLISION');
|
|
334
435
|
}
|
|
335
436
|
state.people = [
|
|
@@ -345,20 +446,11 @@ export function preparePlaceholderText(text, findings, options) {
|
|
|
345
446
|
];
|
|
346
447
|
}
|
|
347
448
|
function namespaceCollision(text, pattern) {
|
|
348
|
-
if (pattern.test(text)) {
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
449
|
// 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;
|
|
450
|
+
return (
|
|
451
|
+
containsPlaceholder(text, pattern) ||
|
|
452
|
+
escapedPlaceholderTokens(text, pattern).length > 0
|
|
453
|
+
);
|
|
362
454
|
}
|
|
363
455
|
|
|
364
456
|
export function canonicalPerson(value, people) {
|
|
@@ -381,7 +473,18 @@ export function canonicalPerson(value, people) {
|
|
|
381
473
|
person,
|
|
382
474
|
])
|
|
383
475
|
);
|
|
384
|
-
|
|
476
|
+
if (distinct.size === 1) {
|
|
477
|
+
return [...distinct.values()][0];
|
|
478
|
+
}
|
|
479
|
+
// `Иванов Иван` and `Иванов Иван Петрович` are one mention in two lengths:
|
|
480
|
+
// the unique name that holds the words of every other match is the person.
|
|
481
|
+
const words = (person) =>
|
|
482
|
+
new Set((person.match(/\p{L}+/gu) ?? []).map((w) => w.toLowerCase()));
|
|
483
|
+
const fullest = [...distinct.values()].filter((person) => {
|
|
484
|
+
const own = words(person);
|
|
485
|
+
return matches.every((other) => [...words(other)].every((w) => own.has(w)));
|
|
486
|
+
});
|
|
487
|
+
return fullest.length === 1 ? fullest[0] : value;
|
|
385
488
|
}
|
|
386
489
|
function canonicalKnown(value, type, options, state) {
|
|
387
490
|
const known = (options.knownPersonal ?? []).filter(
|
|
@@ -427,12 +530,7 @@ export function placeholderReplacement(value, finding, transform, options) {
|
|
|
427
530
|
throw failure('ERR_CONFIG');
|
|
428
531
|
}
|
|
429
532
|
const { map } = state;
|
|
430
|
-
const type =
|
|
431
|
-
finding.category === 'credential'
|
|
432
|
-
? 'CREDENTIAL'
|
|
433
|
-
: finding.type === 'DATE_OF_BIRTH'
|
|
434
|
-
? 'DATE'
|
|
435
|
-
: finding.type;
|
|
533
|
+
const type = placeholderType(finding);
|
|
436
534
|
const canonical = canonicalKnown(value, type, options, state);
|
|
437
535
|
const candidates = map.entries.filter(
|
|
438
536
|
(entry) => entry.type === type && related(canonical, entry)
|
|
@@ -451,7 +549,7 @@ export function placeholderReplacement(value, finding, transform, options) {
|
|
|
451
549
|
1 +
|
|
452
550
|
map.entries.reduce(
|
|
453
551
|
(n, e) => (e.type === type ? Math.max(n, e.number) : n),
|
|
454
|
-
0
|
|
552
|
+
state.floors?.get(type) ?? 0
|
|
455
553
|
);
|
|
456
554
|
const token = placeholderToken(type, number, map.format);
|
|
457
555
|
if (
|
|
@@ -543,5 +641,6 @@ export function discardPlaceholderState(options) {
|
|
|
543
641
|
state.map.entries.length = 0;
|
|
544
642
|
state.map.documents.length = 0;
|
|
545
643
|
state.people = [];
|
|
644
|
+
state.floors?.clear();
|
|
546
645
|
}
|
|
547
646
|
}
|
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.
|
|
@@ -25,14 +26,6 @@ function asciiDigits(value) {
|
|
|
25
26
|
// carry meaning inside a token, so they are removed from the detection view.
|
|
26
27
|
const invisible =
|
|
27
28
|
/^[\0\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]$/u;
|
|
28
|
-
// HTML formatting tags split tokens (`SECRET_</span><span>REST`). Inline tags
|
|
29
|
-
// are dropped and block tags become line breaks. Only presentation attributes
|
|
30
|
-
// are allowed, so other attribute values (URLs, data-*) stay in the view.
|
|
31
|
-
const inlineTags =
|
|
32
|
-
'abbr|b|cite|code|del|em|font|i|ins|kbd|mark|q|s|samp|small|span|strong|sub|sup|tt|u|var';
|
|
33
|
-
const blockTags =
|
|
34
|
-
'blockquote|br|dd|div|dt|h[1-6]|hr|li|ol|p|pre|table|tbody|td|th|thead|tr|ul';
|
|
35
|
-
const htmlTag = String.raw`<\/?(?:${inlineTags}|${blockTags})(?:\s+(?:class|dir|lang|style)="[^"<>=]*")*\s*\/?>`;
|
|
36
29
|
const inlineTag = new RegExp(String.raw`^<\/?(?:${inlineTags})[\s/>]`);
|
|
37
30
|
// TruffleHog-style escape runs: `U+0074 U+006F ...` and CSS `\74 \6f ...`.
|
|
38
31
|
// Runs need four or more units so prose such as "U+200B" stays untouched.
|
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);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isNameForm } from './russian-names.js';
|
|
2
|
+
import { compiledVocabulary, defaultVocabulary } from './vocabulary.js';
|
|
3
|
+
|
|
4
|
+
// Words that are capitalized by position or politeness in prose but identify
|
|
5
|
+
// nobody. `properNames: 'capitalized'` leaves them readable. Any word that is
|
|
6
|
+
// also a given name form (`Вера`, `Любовь`, `Will`) is removed from the lists,
|
|
7
|
+
// so a list entry can never keep a name.
|
|
8
|
+
const fold = (word) => word.toLowerCase().replaceAll('ё', 'е');
|
|
9
|
+
const words = (list) =>
|
|
10
|
+
new Set(
|
|
11
|
+
list
|
|
12
|
+
.split(/\s+/u)
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map(fold)
|
|
15
|
+
.filter((word) => !isNameForm(word))
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
// Pronouns are capitalized anywhere in polite Russian (`благодарим Вас`).
|
|
19
|
+
const pronouns = words(`
|
|
20
|
+
я меня мне мной мы нас нам нами ты тебя тебе тобой вы вас вам вами
|
|
21
|
+
ваш ваша ваше вашего вашей вашему вашим ваши ваших
|
|
22
|
+
наш наша наше нашего нашей нашему нашим наши наших
|
|
23
|
+
мой моя мое моего моей моему моим мои моих
|
|
24
|
+
твой твоя твое твои он она оно они его ее ему ей им ими их них нее него
|
|
25
|
+
i me my mine we us our you your he him his she her they them their
|
|
26
|
+
`);
|
|
27
|
+
// Role and label words stay beside the name they introduce
|
|
28
|
+
// (`Кандидат Петров Иван Сергеевич` -> `Кандидат Person-1`).
|
|
29
|
+
const cues = words(`
|
|
30
|
+
кандидат кандидатка кандидата кандидату соискатель соискательница соискателя
|
|
31
|
+
сотрудник сотрудница сотрудника клиент клиентка клиента пациент пациентка
|
|
32
|
+
пациента абонент абонента заявитель заявительница заявителя студент
|
|
33
|
+
студентка гражданин гражданка господин госпожа коллега коллеги менеджер
|
|
34
|
+
директор руководитель инженер аналитик разработчик специалист водитель врач
|
|
35
|
+
candidate employee client customer patient applicant student citizen
|
|
36
|
+
colleague manager director engineer developer analyst
|
|
37
|
+
`);
|
|
38
|
+
// Greetings and function words are left alone only at the start of a
|
|
39
|
+
// sentence, where capitalization says nothing about a name.
|
|
40
|
+
const sentenceWords = words(`
|
|
41
|
+
здравствуйте здравствуй привет приветствую добрый доброе доброго добрая
|
|
42
|
+
уважаемый уважаемая уважаемые дорогой дорогая дорогие спасибо благодарю
|
|
43
|
+
благодарим пожалуйста извините простите скажите подскажите прошу просьба
|
|
44
|
+
это этот эта эти тот та те то там тут здесь где когда как что кто чем
|
|
45
|
+
почему зачем если но и а или да нет не ни в во на по с со к ко у о об от до
|
|
46
|
+
из за для при без про после перед также тоже еще уже вот так все весь вся
|
|
47
|
+
каждый сейчас сегодня вчера завтра потом затем далее итак кроме однако
|
|
48
|
+
поэтому хотя возможно конечно хорошо ладно ок буду будет был была были было
|
|
49
|
+
есть могу можно нужно надо готов готова готовы имею хочу работаю живу
|
|
50
|
+
учусь зовут звать занимаюсь звоню пишу
|
|
51
|
+
нёс перенёс принёс вёз привёз мог смог помог умер рос вырос лёг шёл пришёл
|
|
52
|
+
ушёл перешёл нашёл
|
|
53
|
+
hello hi hey dear thanks thank please regards sincerely best the this that
|
|
54
|
+
these those a an it is are was were in on at for from to with and but or if
|
|
55
|
+
when where what who why how yes no not also
|
|
56
|
+
`);
|
|
57
|
+
// Past tense (`Работал`, `Звонила`, `Переехали`), first person present
|
|
58
|
+
// (`Работаю`, `Занимаюсь`) and third person present. A bare masculine `-л` form is also a surname shape
|
|
59
|
+
// (`Шагал`), so it is kept only before a lowercase word.
|
|
60
|
+
const pastTense = /^[а-яе]{2,}[аеиоуыя]л(?:а|о|и|ся|ась|ось|ись)$/u;
|
|
61
|
+
const masculinePast = /^[а-яе]{3,}[аеиуыя]л$/u;
|
|
62
|
+
const firstPerson = /^[а-яе]{3,}[аеуя]ю(?:сь)?$/u;
|
|
63
|
+
// Third person present (`Рекомендует`, `Работает`, `Говорит`, `Учатся`).
|
|
64
|
+
const thirdPerson =
|
|
65
|
+
/^[а-яе]{2,}(?:[аеиоуюя]ет|[аеиоуюя]ют|ит|ят|[аеиуя]ется|[аеиуя]ются|ится|атся|ятся)$/u;
|
|
66
|
+
|
|
67
|
+
function sentenceStart(text, start) {
|
|
68
|
+
let i = start - 1;
|
|
69
|
+
while (i >= 0 && /[ \t"'«„“(—–-]/u.test(text[i])) {
|
|
70
|
+
i--;
|
|
71
|
+
}
|
|
72
|
+
return i < 0 || /[.!?…\r\n:;]/u.test(text[i]);
|
|
73
|
+
}
|
|
74
|
+
function nextWordLowercase(text, end) {
|
|
75
|
+
return /^[ \t]+\p{Ll}/u.test(text.slice(end, end + 4));
|
|
76
|
+
}
|
|
77
|
+
const defaultCues = new Set(
|
|
78
|
+
[...defaultVocabulary.roles, ...defaultVocabulary.personalLabels].map(fold)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// True when a word of the cue lists, including the configured `roles` and
|
|
82
|
+
// `personalLabels` vocabulary, is not part of a name.
|
|
83
|
+
export function isProseCue(word, options) {
|
|
84
|
+
const lower = fold(word);
|
|
85
|
+
return (
|
|
86
|
+
(cues.has(lower) ||
|
|
87
|
+
defaultCues.has(lower) ||
|
|
88
|
+
compiledVocabulary(options?.vocabulary)?.cues.has(lower)) &&
|
|
89
|
+
!isNameForm(word)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
// True for a pronoun or cue word, which identify nobody wherever they stand.
|
|
93
|
+
export function isCommonProseWord(word, options) {
|
|
94
|
+
return pronouns.has(fold(word)) || isProseCue(word, options);
|
|
95
|
+
}
|
|
96
|
+
// Decides whether a capitalized prose token is an ordinary word of the
|
|
97
|
+
// sentence and not a name.
|
|
98
|
+
export function keepsProseWord(text, value, start, end, options) {
|
|
99
|
+
if (!/^\p{L}+$/u.test(value) || isNameForm(value)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (isCommonProseWord(value, options)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!sentenceStart(text, start)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const lower = fold(value);
|
|
109
|
+
return (
|
|
110
|
+
sentenceWords.has(lower) ||
|
|
111
|
+
pastTense.test(lower) ||
|
|
112
|
+
firstPerson.test(lower) ||
|
|
113
|
+
thirdPerson.test(lower) ||
|
|
114
|
+
(masculinePast.test(lower) && nextWordLowercase(text, end))
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Exact spellings only: a technology token never exempts a surrounding name,
|
|
2
|
+
// caller-specified private value, credential, URL or identifier.
|
|
3
|
+
export const publicTechnologies = [
|
|
4
|
+
['Python', 'https://www.python.org/'],
|
|
5
|
+
['SQL', 'https://www.iso.org/standard/76583.html'],
|
|
6
|
+
['JavaScript', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript'],
|
|
7
|
+
['TypeScript', 'https://www.typescriptlang.org/'],
|
|
8
|
+
['Java', 'https://dev.java/'],
|
|
9
|
+
['C++', 'https://isocpp.org/'],
|
|
10
|
+
['C#', 'https://learn.microsoft.com/en-us/dotnet/csharp/'],
|
|
11
|
+
['PHP', 'https://www.php.net/'],
|
|
12
|
+
['Kotlin', 'https://kotlinlang.org/'],
|
|
13
|
+
['PostgreSQL', 'https://www.postgresql.org/'],
|
|
14
|
+
['MySQL', 'https://www.mysql.com/'],
|
|
15
|
+
['SQLite', 'https://sqlite.org/'],
|
|
16
|
+
['MongoDB', 'https://www.mongodb.com/'],
|
|
17
|
+
['Redis', 'https://redis.io/'],
|
|
18
|
+
['Elasticsearch', 'https://www.elastic.co/elasticsearch'],
|
|
19
|
+
['Docker', 'https://www.docker.com/'],
|
|
20
|
+
['Kubernetes', 'https://kubernetes.io/'],
|
|
21
|
+
['React', 'https://react.dev/'],
|
|
22
|
+
['Vue', 'https://vuejs.org/'],
|
|
23
|
+
['Angular', 'https://angular.dev/'],
|
|
24
|
+
['Django', 'https://www.djangoproject.com/'],
|
|
25
|
+
['Flask', 'https://flask.palletsprojects.com/'],
|
|
26
|
+
['Node.js', 'https://nodejs.org/'],
|
|
27
|
+
['TensorFlow', 'https://www.tensorflow.org/'],
|
|
28
|
+
['PyTorch', 'https://pytorch.org/'],
|
|
29
|
+
['AWS', 'https://aws.amazon.com/'],
|
|
30
|
+
['Azure', 'https://azure.microsoft.com/'],
|
|
31
|
+
['GCP', 'https://cloud.google.com/'],
|
|
32
|
+
].map(([value, source]) => ({ value, source }));
|
package/src/public.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { isIP } from 'node:net';
|
|
2
|
+
import { publicTechnologies } from './public-technologies.js';
|
|
2
3
|
|
|
3
4
|
// Exact public knowledge and narrowly scoped role-mail heuristics. A role name
|
|
4
5
|
// alone is never an exemption on an unknown/personal domain. Credentials and
|
|
5
6
|
// caller-specified private values always outrank these automatic exceptions.
|
|
6
7
|
export const publicKnowledge = {
|
|
8
|
+
PROPER_NAME: publicTechnologies,
|
|
9
|
+
URL: publicTechnologies.filter((entry) => entry.value === 'Node.js'),
|
|
7
10
|
PERSON: [
|
|
8
11
|
['Albert Einstein', 'Q937'],
|
|
9
12
|
['Marie Curie', 'Q7186'],
|
package/src/record-worker.js
CHANGED
package/src/rule-catalog.js
CHANGED
|
@@ -532,6 +532,7 @@ export const builtInRules = Object.freeze([
|
|
|
532
532
|
'mac',
|
|
533
533
|
'mail-display-name',
|
|
534
534
|
'mailchimp',
|
|
535
|
+
'marker-token',
|
|
535
536
|
'messenger-handle',
|
|
536
537
|
'messenger-label',
|
|
537
538
|
'mrz',
|
|
@@ -1068,6 +1069,7 @@ export const credentialRules = new Set([
|
|
|
1068
1069
|
'linear',
|
|
1069
1070
|
'login-call',
|
|
1070
1071
|
'mailchimp',
|
|
1072
|
+
'marker-token',
|
|
1071
1073
|
'named-value',
|
|
1072
1074
|
'netrc',
|
|
1073
1075
|
'notion',
|
package/src/rules.js
CHANGED
|
@@ -50,6 +50,14 @@ export const serviceRules = [
|
|
|
50
50
|
/[MN][A-Za-z0-9_-]{23}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*/g,
|
|
51
51
|
],
|
|
52
52
|
['huggingface', /hf_[A-Za-z0-9]{20}[A-Za-z0-9]*/g],
|
|
53
|
+
// All-caps CI/test credentials (`SYNTHETIC_TOKEN_ALPHA_123456`) carry only
|
|
54
|
+
// a semantic marker. A digit separates them from constant names
|
|
55
|
+
// (`KNOWN_LOCAL_TOKEN_ENV_VARS`); a key (`NAME_TOKEN_2=`) or a reference
|
|
56
|
+
// (`$NAME_TOKEN_2`, `secrets.NAME_TOKEN_2`) is a name, not a value.
|
|
57
|
+
[
|
|
58
|
+
'marker-token',
|
|
59
|
+
/(?<![A-Za-z0-9_$.{%-])(?=[A-Z_-]{0,200}\d)(?:[A-Z0-9]{1,32}[_-]){1,8}(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API[_-]?KEY)(?:[_-][A-Z0-9]{1,32}){1,8}(?![A-Za-z0-9_$-])(?![ \t]*(?:[:=]|\}))/g,
|
|
60
|
+
],
|
|
53
61
|
['shopify', /shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}/g],
|
|
54
62
|
['databricks', /dapi[a-fA-F0-9]{32}/g],
|
|
55
63
|
['square', /sq0(?:atp|csp)-[A-Za-z0-9_-]{20}[A-Za-z0-9_-]*/g],
|