@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -1
  3. package/bin/sensitive-data-sanitizer.js +1 -0
  4. package/docs/API.md +179 -16
  5. package/docs/COVERAGE.md +16 -6
  6. package/docs/RULES.md +1 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/context.js +176 -25
  10. package/src/credential-spans.js +180 -0
  11. package/src/credentials-entry.d.ts +23 -0
  12. package/src/credentials-entry.js +62 -0
  13. package/src/credentials.js +8 -1
  14. package/src/detection.js +68 -37
  15. package/src/encoded.js +137 -51
  16. package/src/engines.js +33 -10
  17. package/src/exempt.js +72 -0
  18. package/src/fake-memory.js +32 -0
  19. package/src/fake.js +4 -28
  20. package/src/features.js +29 -0
  21. package/src/html-tags.js +8 -0
  22. package/src/identity-types.js +63 -0
  23. package/src/identity.js +3 -61
  24. package/src/index.d.ts +41 -2
  25. package/src/index.js +6 -0
  26. package/src/known.js +33 -15
  27. package/src/personal-features.js +39 -0
  28. package/src/personal.js +29 -5
  29. package/src/placeholder-types.js +164 -4
  30. package/src/placeholders.js +144 -45
  31. package/src/projection.js +1 -8
  32. package/src/proper-names.js +48 -5
  33. package/src/prose-words.js +116 -0
  34. package/src/public-technologies.js +32 -0
  35. package/src/public.js +3 -0
  36. package/src/record-worker.js +1 -0
  37. package/src/rule-catalog.js +2 -0
  38. package/src/rules.js +8 -0
  39. package/src/sanitizer.js +92 -21
  40. package/src/stream-worker.js +1 -0
  41. package/src/strict-attributes.js +60 -4
  42. package/src/strict.js +1 -1
  43. package/src/structured.js +2 -0
  44. package/src/transforms.js +38 -8
  45. package/src/vocabulary.js +39 -2
  46. package/src/worker-options.js +1 -0
@@ -1,10 +1,14 @@
1
1
  import { failure, REDACTED, escapePattern } from './detection.js';
2
- import { reservedPlaceholderTypes } from './placeholder-types.js';
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 { ...options, [placeholderState]: { map, keep: first.map !== false } };
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 types = new Set([
314
- ...reservedPlaceholderTypes(),
315
- ...findings.map((f) =>
316
- f.category === 'credential' ? 'CREDENTIAL' : f.type
317
- ),
318
- ...(options.knownPersonal ?? []).map((e) => e.type),
319
- ...state.map.entries.map((e) => e.type),
320
- ]);
321
- const [before, after] = state.map.format.split('{n}');
322
- const tokens = [...types].map(
323
- (type) =>
324
- `${escapePattern(
325
- placeholderToken(type, 1, before)
326
- )}[1-9][0-9]*${escapePattern(placeholderToken(type, 1, after))}`
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)) {
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
- for (const literal of jsonStringLiterals(text)) {
353
- try {
354
- if (pattern.test(JSON.parse(literal))) {
355
- return true;
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
- return distinct.size === 1 ? [...distinct.values()][0] : value;
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.
@@ -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 (!runs.some((run) => overlapsRun(run, finding))) {
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
- options.debug?.({ event: 'document-prose-overlap', complete, suppressed });
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
- return low < run.length && run[low].start < finding.end;
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 (preserveToken(text, raw, value, start, end, range)) {
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'],
@@ -1,4 +1,5 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
+ import './personal-features.js';
2
3
  import { createSanitizer, prepareStreamEngine } from './engines.js';
3
4
  import { compileKnown } from './compiled-known.js';
4
5
 
@@ -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],