@link-foundation/sensitive-data-sanitizer 0.21.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e0e05a4: Cover 34 missing strict personal-attribute terms, preserve their typed placeholders over generic prose names, cache caller vocabulary and cue sets, avoid unnecessary placeholder scans and JSON parsing, and preserve exact public technology names in Cyrillic prose.
8
+
3
9
  ## 0.21.0
4
10
 
5
11
  ### Minor Changes
package/docs/API.md CHANGED
@@ -750,7 +750,10 @@ Cues ignore case and support bounded Russian noun/adjective forms; personal fiel
750
750
  use normalized field names. Capitalized proper values are required after prose
751
751
  cues. Inputs allow at most 256 nonempty strings of 128 code units per list, with
752
752
  no line breaks/NUL. Unknown lists and malformed replacement records fail with
753
- `ERR_CONFIG`. Compilation uses a bounded 64-configuration cache.
753
+ `ERR_CONFIG`. Compilation uses a bounded 64-configuration cache and a weak
754
+ object-identity cache. Treat a vocabulary object as immutable after first use;
755
+ pass a new object to change its lists. Repeated lookups reuse compiled patterns
756
+ and a cue-word Set without normalizing or serializing the lists again.
754
757
 
755
758
  `personalAttributes: ['вепс', 'старовер']` adds special-category stems to the
756
759
  `strict` rule (it has no effect without `strict: true`). Each word matches as a
@@ -759,6 +762,18 @@ words of a multi-word entry may be separated by spaces or tabs. Matches are
759
762
  reported as `PERSONAL_ATTRIBUTE` with the `strict-attribute` rule. The default
760
763
  list is empty.
761
764
 
765
+ Typed special-category attributes take precedence over generic `ProperName`
766
+ guesses, including capitalized caller stems in Cyrillic prose. Strict coverage
767
+ includes the 34 additional nationality, religion, health, criminal-record,
768
+ political and union terms reported in issue 144, even as standalone words.
769
+
770
+ With default `publicKnowledge`, exact public technology spellings such as Python,
771
+ SQL, PostgreSQL, Docker, Kubernetes, React, AWS and Azure remain readable when
772
+ the capitalized prose detector flags them. The offline catalog includes source
773
+ URLs in `src/public-technologies.js`. `publicKnowledge: false` disables this
774
+ exception; known private values and credentials still redact, and an adjacent
775
+ personal name never inherits a technology's exemption.
776
+
762
777
  ## Callback transformations and stages
763
778
 
764
779
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -129,6 +129,9 @@ export function namespacePattern(format = defaultFormat, extraTypes = []) {
129
129
  // Tokens of the namespace with the types each spelling can stand for.
130
130
  export function placeholderTokens(text, pattern) {
131
131
  const tokens = [];
132
+ if (!/[1-9]/u.test(text)) {
133
+ return tokens;
134
+ }
132
135
  for (const match of text.matchAll(new RegExp(pattern.source, 'gu'))) {
133
136
  const [part, number] = pattern.typedFirst
134
137
  ? [match[1], match[2]]
@@ -143,13 +146,20 @@ export function placeholderTokens(text, pattern) {
143
146
  return tokens;
144
147
  }
145
148
  export function containsPlaceholder(text, pattern) {
146
- return new RegExp(pattern.source, 'u').test(text);
149
+ return /[1-9]/u.test(text) && new RegExp(pattern.source, 'u').test(text);
147
150
  }
148
151
  // Tokens spelled with JSON escapes inside string literals, such as
149
152
  // `Person-1`, decoded so that they cannot hide from the namespace check.
150
153
  export function escapedPlaceholderTokens(text, pattern) {
151
154
  const tokens = [];
155
+ // Without an escape every literal has already been checked in the raw text.
156
+ if (!text.includes('\\')) {
157
+ return tokens;
158
+ }
152
159
  for (const literal of jsonStringLiterals(text)) {
160
+ if (!literal.includes('\\')) {
161
+ continue;
162
+ }
153
163
  try {
154
164
  tokens.push(...placeholderTokens(JSON.parse(literal), pattern));
155
165
  } catch {
@@ -349,10 +349,25 @@ function callPattern(findings, options) {
349
349
  );
350
350
  }
351
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
+ }
352
367
  // With `existing: 'keep'` the tokens of an earlier pass stay as they are, so
353
368
  // findings made only of those tokens are not replaced again.
354
369
  export function keptPlaceholderSpans(text, findings, options) {
355
- if (!keepsExisting(options)) {
370
+ if (!keepsExisting(options) || !mayContainPlaceholder(text, options)) {
356
371
  return [];
357
372
  }
358
373
  return placeholderTokens(text, callPattern(findings, options));
@@ -399,8 +414,10 @@ export function preparePlaceholderText(text, findings, options) {
399
414
  if (!state || !hasPlaceholders(options)) {
400
415
  return;
401
416
  }
402
- const pattern = callPattern(findings, options);
403
- if (state.existing === 'keep') {
417
+ const pattern = mayContainPlaceholder(text, options, true)
418
+ ? callPattern(findings, options)
419
+ : undefined;
420
+ if (pattern && state.existing === 'keep') {
404
421
  // New values are numbered after the highest kept number of their type.
405
422
  for (const token of [
406
423
  ...placeholderTokens(text, pattern),
@@ -413,7 +430,7 @@ export function preparePlaceholderText(text, findings, options) {
413
430
  );
414
431
  }
415
432
  }
416
- } else if (namespaceCollision(text, pattern)) {
433
+ } else if (pattern && namespaceCollision(text, pattern)) {
417
434
  throw failure('ERR_PLACEHOLDER_COLLISION');
418
435
  }
419
436
  state.people = [
@@ -74,10 +74,6 @@ function sentenceStart(text, start) {
74
74
  function nextWordLowercase(text, end) {
75
75
  return /^[ \t]+\p{Ll}/u.test(text.slice(end, end + 4));
76
76
  }
77
- function vocabularyCues(options) {
78
- const lists = compiledVocabulary(options?.vocabulary)?.lists;
79
- return lists ? [...lists.roles, ...lists.personalLabels] : [];
80
- }
81
77
  const defaultCues = new Set(
82
78
  [...defaultVocabulary.roles, ...defaultVocabulary.personalLabels].map(fold)
83
79
  );
@@ -89,7 +85,7 @@ export function isProseCue(word, options) {
89
85
  return (
90
86
  (cues.has(lower) ||
91
87
  defaultCues.has(lower) ||
92
- vocabularyCues(options).includes(lower)) &&
88
+ compiledVocabulary(options?.vocabulary)?.cues.has(lower)) &&
93
89
  !isNameForm(word)
94
90
  );
95
91
  }
@@ -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/sanitizer.js CHANGED
@@ -870,11 +870,17 @@ function preferredType(current, candidate, options) {
870
870
  if (options.transformations?.[candidate] || candidate === 'PASSPORT_MRZ') {
871
871
  return true;
872
872
  }
873
+ return preferredSpecificType(current, candidate);
874
+ }
875
+ function preferredSpecificType(current, candidate) {
873
876
  const broad = (type) =>
874
877
  ['ID', 'PASSPORT'].includes(type) ||
875
878
  (/PASSPORT/.test(type) &&
876
879
  !['PASSPORT_MRZ', 'PASSPORT_NUMBER'].includes(type));
877
- return broad(current) && isIdentityType(candidate) && !broad(candidate);
880
+ return (
881
+ (current === 'PROPER_NAME' && candidate === 'PERSONAL_ATTRIBUTE') ||
882
+ (broad(current) && isIdentityType(candidate) && !broad(candidate))
883
+ );
878
884
  }
879
885
  export function redactResult(
880
886
  text,
@@ -18,6 +18,11 @@ const softNoun = '(?:а|у|ом|е|и|ей|ам|ами|ах)?';
18
18
  const ecNoun = '(?:ец|ц(?:а|у|ем|е|ы|ев|ам|ами|ах))';
19
19
  const femaleNoun = '(?:а|и|е|у|ой)';
20
20
  const nationalities = [
21
+ `кабардин${ecNoun}|кабардинк${femaleNoun}`,
22
+ `казашк${femaleNoun}`,
23
+ `мордвин${hardNoun}|мордовк${femaleNoun}`,
24
+ `удмурт${hardNoun}|удмуртк${femaleNoun}`,
25
+ `молдаванин(?:а|у|ом|е)?|молдаван${hardNoun}|молдаванк${femaleNoun}`,
21
26
  `узбек${softNoun}|узбечк${femaleNoun}`,
22
27
  `таджик${softNoun}|таджичк${femaleNoun}`,
23
28
  `(?:киргиз|кыргыз)${hardNoun}|(?:киргиз|кыргыз)к${femaleNoun}`,
@@ -41,6 +46,12 @@ const nationalities = [
41
46
  `уйгур${hardNoun}|уйгурк${femaleNoun}`,
42
47
  ].join('|');
43
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
+ );
44
55
  const specialVocabulary = new RegExp(
45
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}_])`,
46
57
  'giu'
@@ -49,7 +60,7 @@ const categoryCue =
49
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;
50
61
  // Words that are also languages, scripts or styles need a personal context.
51
62
  const ambiguous =
52
- /(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian|uzbek|tajik|chechen|azerbaijani|korean|chinese|georgian|kazakh|kyrgyz|turkmen|uyghur|roma|baptist|union[ \t]+member)(?![\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;
53
64
 
54
65
  function personalContext(text, match) {
55
66
  const prefix = text.slice(Math.max(0, match.index - 128), match.index);
@@ -97,6 +108,7 @@ export function detectStrictAttributes(text, emit, options) {
97
108
  additionalAttributes,
98
109
  categoryForms,
99
110
  specialVocabulary,
111
+ followUpVocabulary,
100
112
  compiledVocabulary(options.vocabulary)?.attributes,
101
113
  ]) {
102
114
  if (pattern) {
package/src/vocabulary.js CHANGED
@@ -55,6 +55,7 @@ export const defaultVocabulary = Object.freeze(
55
55
  )
56
56
  );
57
57
  const cache = new Map();
58
+ const identities = new WeakMap();
58
59
  const boundary = '[\\p{L}\\p{N}_]';
59
60
  const name =
60
61
  '[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}(?:[ \\t]+[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}){0,3}';
@@ -140,10 +141,16 @@ export function compiledVocabulary(vocabulary) {
140
141
  if (vocabulary === undefined) {
141
142
  return undefined;
142
143
  }
144
+ const existing = identities.get(vocabulary);
145
+ if (existing) {
146
+ return existing;
147
+ }
143
148
  const lists = normalizedLists(vocabulary);
144
149
  const key = JSON.stringify(lists);
145
150
  if (cache.has(key)) {
146
- return cache.get(key);
151
+ const result = cache.get(key);
152
+ identities.set(vocabulary, result);
153
+ return result;
147
154
  }
148
155
  const alternatives = (values) =>
149
156
  values.length
@@ -184,11 +191,15 @@ export function compiledVocabulary(vocabulary) {
184
191
  'giu'
185
192
  );
186
193
  result.fields = new Set(lists.personalFields.map(normalizeFieldName));
194
+ result.cues = new Set(
195
+ [...lists.roles, ...lists.personalLabels].map((s) => s.replaceAll('ё', 'е'))
196
+ );
187
197
  result.attributes = attributePattern(lists.personalAttributes);
188
198
  if (cache.size >= 64) {
189
199
  cache.clear();
190
200
  }
191
201
  cache.set(key, result);
202
+ identities.set(vocabulary, result);
192
203
  return result;
193
204
  }
194
205
  export function vocabularyField(field, options) {