@link-foundation/sensitive-data-sanitizer 0.20.0 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +14 -1
  3. package/bin/sensitive-data-sanitizer.js +1 -0
  4. package/docs/API.md +163 -15
  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 +154 -4
  30. package/src/placeholders.js +127 -45
  31. package/src/projection.js +1 -8
  32. package/src/proper-names.js +48 -5
  33. package/src/prose-words.js +120 -0
  34. package/src/record-worker.js +1 -0
  35. package/src/rule-catalog.js +2 -0
  36. package/src/rules.js +8 -0
  37. package/src/sanitizer.js +85 -20
  38. package/src/stream-worker.js +1 -0
  39. package/src/strict-attributes.js +48 -4
  40. package/src/strict.js +1 -1
  41. package/src/structured.js +2 -0
  42. package/src/transforms.js +38 -8
  43. package/src/vocabulary.js +27 -1
  44. package/src/worker-options.js +1 -0
package/src/sanitizer.js CHANGED
@@ -1,14 +1,12 @@
1
- import { generatedValues } from './fake.js';
1
+ import { generatedValues } from './fake-memory.js';
2
+ import { feature, withFeature } from './features.js';
2
3
  import { isCompiledKnown, projectedKnown } from './compiled-known.js';
3
4
  import { detectSync, validateDetectors } from './sync-detectors.js';
4
5
  import { validatePolicyControls, builtInEnabled } from './policy.js';
5
6
  import { compiledVocabulary } from './vocabulary.js';
6
- import { validateProperNames } from './proper-names.js';
7
- import { prepareFakeText } from './fake-entities.js';
8
7
  import { alignEscapes } from './escape-spans.js';
9
8
  import { actionable } from './confidence.js';
10
9
  import { propagate, confirmationOptions } from './propagation.js';
11
- import { fakeDocumentSpans } from './specimens.js';
12
10
  import {
13
11
  failure,
14
12
  internalFailure,
@@ -26,10 +24,11 @@ import {
26
24
  hasCustomTransforms,
27
25
  renderCallbackAsync,
28
26
  } from './transforms.js';
29
- import { fakeIdentityValue, isIdentityType } from './identity.js';
27
+ import { isIdentityType } from './identity-types.js';
30
28
  import { rejectRestoreMap } from './restore-map.js';
31
29
  import { allowListed, validateAllowList } from './allow-list.js';
32
30
  import { templatedCredential } from './credentials.js';
31
+ import { exemptFindings, validateExempt } from './exempt.js';
33
32
  import { builtInRules } from './rule-catalog.js';
34
33
  import {
35
34
  initializePlaceholders,
@@ -39,6 +38,8 @@ import {
39
38
  hasPlaceholders,
40
39
  placeholderValues,
41
40
  placeholderNameFindings,
41
+ keptPlaceholderSpans,
42
+ keptPlaceholderFinding,
42
43
  coverAdjacentNames,
43
44
  nestedPlaceholderOptions,
44
45
  } from './placeholders.js';
@@ -105,6 +106,7 @@ const optionNames = new Set([
105
106
  'minConfidenceByType',
106
107
  'allowList',
107
108
  'inlineAllow',
109
+ 'exempt',
108
110
  ]);
109
111
 
110
112
  export function applyProfile(options = {}) {
@@ -222,7 +224,9 @@ export function validateOptions(options = {}) {
222
224
  }
223
225
  }
224
226
  validateConfidence(options);
225
- validateProperNames(options);
227
+ if (options.properNames !== undefined || options.scripts !== undefined) {
228
+ feature('validateProperNames')(options);
229
+ }
226
230
  validatePolicy(options);
227
231
  if (
228
232
  options.verifyPublic !== undefined &&
@@ -230,6 +234,7 @@ export function validateOptions(options = {}) {
230
234
  ) {
231
235
  throw failure('ERR_CONFIG');
232
236
  }
237
+ validateExempt(options.exempt);
233
238
  validateCollections(options);
234
239
  validateAllowList(options);
235
240
  validateDetectors(options.detectors);
@@ -329,6 +334,9 @@ export function sanitizeFinding(finding, length) {
329
334
  rule: finding.rule,
330
335
  confidence: validConfidence(finding),
331
336
  likelihood: finding.likelihood ?? likelihood(validConfidence(finding)),
337
+ ...(finding.type === 'JSON_VALUE' && validType(finding.valueType)
338
+ ? { valueType: finding.valueType }
339
+ : {}),
332
340
  ...(finding.kept === 'fake' ? { kept: 'fake' } : {}),
333
341
  };
334
342
  }
@@ -449,7 +457,14 @@ function locations(text, findings) {
449
457
 
450
458
  export function inspect(text, options = {}) {
451
459
  try {
452
- return inspectText(text, options);
460
+ const exempt = options?.exempt;
461
+ if (!exempt) {
462
+ return inspectText(text, options);
463
+ }
464
+ // Nested views (structured values, decoded runs) are screened once, at
465
+ // the offsets of the text the caller sees.
466
+ const findings = inspectText(text, { ...options, exempt: undefined });
467
+ return exemptFindings(text, findings, exempt);
453
468
  } catch (error) {
454
469
  throw internalFailure(error);
455
470
  }
@@ -635,14 +650,41 @@ export function inspectFindingsOnly(text, findings, options) {
635
650
  );
636
651
  }
637
652
 
653
+ // A whole JSON value that is already a token of this run, `"Sensitive-1"`.
654
+ function generatedJsonValue(text, finding, options) {
655
+ const raw = text.slice(finding.start, finding.end);
656
+ return (
657
+ raw.startsWith('"') &&
658
+ placeholderValues(options).some(
659
+ (record) => JSON.stringify(record.value) === raw
660
+ )
661
+ );
662
+ }
638
663
  function selectedFindings(findings, credentialsOnly) {
639
664
  return credentialsOnly
640
665
  ? findings.filter((finding) => finding.category === 'credential')
641
666
  : findings;
642
667
  }
643
668
 
669
+ // Findings over the tokens of an earlier pass, recorded in `tokenKept`.
670
+ function withKeptPlaceholders(text, findings, options, tokenKept) {
671
+ const tokens = keptPlaceholderSpans(text, findings, options);
672
+ if (!tokens.length) {
673
+ return findings;
674
+ }
675
+ return findings.map((f) => {
676
+ const next = keptPlaceholderFinding(text, f, tokens);
677
+ if (next.kept === 'fake' && next !== f) {
678
+ tokenKept.add(next);
679
+ }
680
+ return next;
681
+ });
682
+ }
683
+
644
684
  export function markFake(text, findings, options) {
645
685
  findings = selectedFindings(findings, options.credentialsOnly);
686
+ const tokenKept = new Set();
687
+ findings = withKeptPlaceholders(text, findings, options, tokenKept);
646
688
  const generated = [
647
689
  ...generatedValues(options),
648
690
  ...placeholderValues(options),
@@ -652,9 +694,12 @@ export function markFake(text, findings, options) {
652
694
  !options.fakeValues?.length &&
653
695
  !generated.length
654
696
  ) {
655
- return findings.map((f) => ({ ...f, kept: undefined }));
697
+ return findings.map((f) => ({
698
+ ...f,
699
+ kept: tokenKept.has(f) ? 'fake' : undefined,
700
+ }));
656
701
  }
657
- const documents = fakeDocumentSpans(text, options);
702
+ const documents = withFeature('fakeDocumentSpans', [], text, options);
658
703
  const trusted = [];
659
704
  for (const record of generated) {
660
705
  for (const value of new Set([
@@ -689,7 +734,7 @@ export function markFake(text, findings, options) {
689
734
  const candidates = findings.filter(
690
735
  (f) =>
691
736
  f.category === 'personal' &&
692
- fakeIdentityValue(text.slice(f.start, f.end), f.type, options)
737
+ feature('fakeIdentityValue')(text.slice(f.start, f.end), f.type, options)
693
738
  );
694
739
  // Tolerant MRZ matches can cross a line boundary. An automatic specimen
695
740
  // exemption must never cover an overlapping real identity value. Exact
@@ -713,8 +758,13 @@ export function markFake(text, findings, options) {
713
758
  ),
714
759
  ];
715
760
  return findings.map((f) => {
761
+ if (tokenKept.has(f)) {
762
+ return f;
763
+ }
716
764
  if (f.type === 'JSON_VALUE') {
717
- return { ...f, kept: undefined };
765
+ return generatedJsonValue(text, f, options)
766
+ ? { ...f, kept: 'fake' }
767
+ : { ...f, kept: undefined };
718
768
  }
719
769
  if (
720
770
  trusted.some(
@@ -775,6 +825,7 @@ function validatedSpans(text, findings, options) {
775
825
  last.category = 'credential';
776
826
  }
777
827
  if (last.type === 'JSON_VALUE' || f.type === 'JSON_VALUE') {
828
+ last.valueType = jsonValueType(last, f);
778
829
  last.type = 'JSON_VALUE';
779
830
  } else if (credentialRun) {
780
831
  last.type = 'SECRET';
@@ -788,6 +839,15 @@ function validatedSpans(text, findings, options) {
788
839
  return spans;
789
840
  }
790
841
 
842
+ // Two whole values merged into one keep a value type only when they agree.
843
+ function jsonValueType(last, f) {
844
+ if (last.type !== 'JSON_VALUE') {
845
+ return f.valueType;
846
+ }
847
+ return f.type !== 'JSON_VALUE' || f.valueType === last.valueType
848
+ ? last.valueType
849
+ : undefined;
850
+ }
791
851
  function preferredType(current, candidate, options) {
792
852
  if (current === 'JSON_VALUE') {
793
853
  return false;
@@ -824,7 +884,8 @@ export function redactResult(
824
884
  ) {
825
885
  options = applyProfile(options);
826
886
  const spans = validatedSpans(text, findings, options);
827
- options = prepareFakeText(text, findings, initializePlaceholders(options));
887
+ options = initializePlaceholders(options);
888
+ options = withFeature('prepareFakeText', options, text, findings, options);
828
889
  preparePlaceholderText(text, findings, options);
829
890
  const parts = [];
830
891
  const faked = [];
@@ -950,22 +1011,26 @@ export async function redactResultAsync(
950
1011
  }
951
1012
  continue;
952
1013
  }
953
- if (!run) {
954
- if (options.preserveEncoding) {
955
- throw failure('ERR_ENCODING');
956
- }
957
- continue;
958
- }
959
- if (!options.preserveEncoding && run.encoding !== 'json-content') {
1014
+ if (
1015
+ !run ||
1016
+ (!options.preserveEncoding && run.encoding !== 'json-content')
1017
+ ) {
960
1018
  continue;
961
1019
  }
962
1020
  const result = await sanitizeDecoded(
963
1021
  run.text,
964
1022
  nestedPlaceholderOptions(confirmationOptions(text, findings, options))
965
1023
  );
1024
+ // Without a recognizable re-encoding, the encoded text itself is masked.
966
1025
  replacements.set(
967
1026
  `${span.start}:${span.end}`,
968
- encodeRun(run, result.text === run.text ? REDACTED : result.text)
1027
+ encodeRun(run, result.text === run.text ? REDACTED : result.text) ??
1028
+ renderReplacement(
1029
+ text,
1030
+ span,
1031
+ { ...options, preserveEncoding: false },
1032
+ () => ({ text: REDACTED })
1033
+ )
969
1034
  );
970
1035
  }
971
1036
  }
@@ -1,4 +1,5 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
+ import './personal-features.js';
2
3
  import { sanitizeFileToFile } from './stream.js';
3
4
  import { compileKnown } from './compiled-known.js';
4
5
  try {
@@ -1,4 +1,5 @@
1
1
  import { collectMatches } from './detection.js';
2
+ import { compiledVocabulary } from './vocabulary.js';
2
3
 
3
4
  const attributes =
4
5
  /(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|male|female|married|divorced|pregnant|pregnancy|disability|christian|muslim|jewish|orthodox|hispanic|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
@@ -10,10 +11,45 @@ const additionalAttributes =
10
11
  // unfamiliar diagnoses, nationalities and religions outside this lexicon.
11
12
  const categoryForms =
12
13
  /(?<![\p{L}\p{N}_])(?:(?:диабет|католик|католич|буддист|атеист|белорус|православн|беременн|судим)[\p{L}]{0,8}|татар(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|ы|ам|ами|ах)?|мусульман(?:ин(?:а|у|ом|е)?|к[аиуе]|кой|е|ам|ами|ах)?|украин(?:ец|ца|цу|цем|це|цы|цев|цам|цами|цах|к[аиуе]|кой)|инвалид(?:а|у|ом|е|ы|ов|ам|ами|ах|к[аиу]|кой)?|армян(?:ин|ина|ину|ином|ине|е|ам|ами|ах|ка|ки|ке|ку|кой)|евре(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|казах(?:а|у|ом|е|и|ов|ам|ами|ах|к[аиуе]|кой)?|христиан(?:ин|ина|ину|ином|ине|е|ам|ами|ах|к[аиуе]|кой)|иуде(?:й|я|ю|ем|и|ев|ям|ями|ях|йк[аиуе]|йкой)|ислам[\p{L}]{0,5}|эпилепси[\p{L}]{1,5}|астм[\p{L}]{1,5}|онкологи[\p{L}]{1,5}|туберкул[её]з[\p{L}]{0,5}|психиатр[\p{L}]{0,6}|депресси[\p{L}]{1,5}|гепатит[\p{L}]{0,5}|гомосексуал[\p{L}]{0,7}|лесбиян[\p{L}]{1,5}|ге(?:й|я|ю|ем|и|ев|ям|ями|ях)|нетрадиционн[\p{L}]{2,3}[ \t]+ориентаци[\p{L}]{1,3}|отбыва(?:л[аи]?|ет|ют|вш[\p{L}]{2,3})[ \t]+наказани[\p{L}]{1,3}|ВИЧ-инфицирован[\p{L}]{0,4}|epilepsy|asthma|tuberculosis|hepatitis|depression|gay|lesbian|homosexual|bisexual|armenian)(?![\p{L}\p{N}_])/giu;
14
+ // Nationality nouns with their case endings and feminine forms. Exact endings
15
+ // keep country and region names (`Узбекистан`, `Чувашия`) out of the match.
16
+ const hardNoun = '(?:а|у|ом|е|ы|ов|ам|ами|ах)?';
17
+ const softNoun = '(?:а|у|ом|е|и|ей|ам|ами|ах)?';
18
+ const ecNoun = '(?:ец|ц(?:а|у|ем|е|ы|ев|ам|ами|ах))';
19
+ const femaleNoun = '(?:а|и|е|у|ой)';
20
+ const nationalities = [
21
+ `узбек${softNoun}|узбечк${femaleNoun}`,
22
+ `таджик${softNoun}|таджичк${femaleNoun}`,
23
+ `(?:киргиз|кыргыз)${hardNoun}|(?:киргиз|кыргыз)к${femaleNoun}`,
24
+ `туркмен${hardNoun}|туркменк${femaleNoun}`,
25
+ `чечен${ecNoun}|чеченк${femaleNoun}`,
26
+ `ингуш${softNoun}|ингушк${femaleNoun}`,
27
+ `осетин${hardNoun}|осетинк${femaleNoun}`,
28
+ `грузин${hardNoun}|грузинк${femaleNoun}`,
29
+ `азербайджан${ecNoun}|азербайджанк${femaleNoun}`,
30
+ `цыган${hardNoun}|цыганк${femaleNoun}`,
31
+ `коре(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|кореянк${femaleNoun}`,
32
+ `кита(?:ец|йц(?:а|у|ем|е|ы|ев|ам|ами|ах))|китаянк${femaleNoun}`,
33
+ `башкир${hardNoun}|башкирк${femaleNoun}`,
34
+ `чуваш${softNoun}|чувашк${femaleNoun}`,
35
+ `якут${hardNoun}|якутк${femaleNoun}`,
36
+ `бурят${hardNoun}|бурятк${femaleNoun}`,
37
+ `калмык${softNoun}|калмычк${femaleNoun}`,
38
+ `лезгин${hardNoun}|лезгинк${femaleNoun}`,
39
+ `авар${ecNoun}|аварк${femaleNoun}`,
40
+ `дагестан${ecNoun}|дагестанк${femaleNoun}`,
41
+ `уйгур${hardNoun}|уйгурк${femaleNoun}`,
42
+ ].join('|');
43
+ const believerNoun = `(?:а|у|ом|е|ы|ов|ам|ами|ах|к${femaleNoun}|кой)?`;
44
+ const specialVocabulary = new RegExp(
45
+ `(?<![\\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
+ 'giu'
47
+ );
13
48
  const categoryCue =
14
- /(?<![\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;
49
+ /(?<![\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
+ // Words that are also languages, scripts or styles need a personal context.
15
51
  const ambiguous =
16
- /(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian)(?![\p{L}\p{N}_])/giu;
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;
17
53
 
18
54
  function personalContext(text, match) {
19
55
  const prefix = text.slice(Math.max(0, match.index - 128), match.index);
@@ -56,8 +92,16 @@ export function detectStrictAttributes(text, emit, options) {
56
92
  detected++;
57
93
  emit(finding);
58
94
  };
59
- for (const pattern of [attributes, additionalAttributes, categoryForms]) {
60
- collectMatches(text, pattern, report, details);
95
+ for (const pattern of [
96
+ attributes,
97
+ additionalAttributes,
98
+ categoryForms,
99
+ specialVocabulary,
100
+ compiledVocabulary(options.vocabulary)?.attributes,
101
+ ]) {
102
+ if (pattern) {
103
+ collectMatches(text, pattern, report, details);
104
+ }
61
105
  }
62
106
  collectMatches(text, categoryCue, report, details, 1);
63
107
  collectMatches(text, ambiguous, report, details, 0, (_value, match) => {
package/src/strict.js CHANGED
@@ -2,7 +2,7 @@ import { collectMatches } from './detection.js';
2
2
  import { months } from './personal-context.js';
3
3
  import { isDocumentTechnology } from './document-experience.js';
4
4
  import { placeholderValues } from './placeholders.js';
5
- import { generatedValues } from './fake.js';
5
+ import { generatedValues } from './fake-memory.js';
6
6
  import { detectStrictAttributes } from './strict-attributes.js';
7
7
 
8
8
  const boundary = '[\\p{L}\\p{N}_]';
package/src/structured.js CHANGED
@@ -171,6 +171,7 @@ export function structuredFieldFindings(batch, options) {
171
171
  start: entry.start + (denied ? 0 : 1),
172
172
  end: entry.start + entry.raw.length - (denied ? 0 : 1),
173
173
  type: denied ? 'JSON_VALUE' : type,
174
+ ...(denied && type ? { valueType: type } : {}),
174
175
  category: 'personal',
175
176
  rule: 'known-personal',
176
177
  confidence: 1,
@@ -516,6 +517,7 @@ function declaredValueFinding(text, token, options) {
516
517
  start,
517
518
  end,
518
519
  type: 'JSON_VALUE',
520
+ ...(type ? { valueType: type } : {}),
519
521
  category: 'personal',
520
522
  rule: 'known-personal',
521
523
  confidence: 1,
package/src/transforms.js CHANGED
@@ -1,8 +1,8 @@
1
- import { realisticFake } from './fake.js';
1
+ import { feature } from './features.js';
2
2
  import { createHmac } from 'node:crypto';
3
3
  import { failure, REDACTED } from './detection.js';
4
4
  import { encodedValue, encodeRun, decodedRuns } from './encoded.js';
5
- import { isIdentityType } from './identity.js';
5
+ import { isIdentityType } from './identity-types.js';
6
6
  import { validatePlaceholder, placeholderReplacement } from './placeholders.js';
7
7
  import { projectText } from './projection.js';
8
8
  import { encryptValue } from './encrypt.js';
@@ -262,7 +262,7 @@ function transformed(value, finding, transform, options) {
262
262
  : REDACTED;
263
263
  }
264
264
  if (transform.mode === 'fake') {
265
- return realisticFake(value, finding, transform, options);
265
+ return feature('realisticFake')(value, finding, transform, options);
266
266
  }
267
267
  if (finding.category === 'credential') {
268
268
  return REDACTED;
@@ -277,6 +277,32 @@ function transformed(value, finding, transform, options) {
277
277
  const lower = Math.floor(number / transform.size) * transform.size;
278
278
  return `${lower}–${lower + transform.size - 1}`;
279
279
  }
280
+ function jsonValueText(raw) {
281
+ try {
282
+ const value = JSON.parse(raw);
283
+ return typeof value === 'string' && value ? value : raw;
284
+ } catch {
285
+ return raw;
286
+ }
287
+ }
288
+ // A whole JSON value becomes one JSON string. Placeholder mode types it by the
289
+ // field it came from, so `{"age":34}` reads `{"age":"Age-1"}`.
290
+ function jsonValueReplacement(text, finding, options) {
291
+ const transform =
292
+ options.transformations?.JSON_VALUE ?? options.transformation;
293
+ if (transform?.mode !== 'placeholder') {
294
+ return JSON.stringify(REDACTED);
295
+ }
296
+ const raw = text.slice(finding.start, finding.end);
297
+ return JSON.stringify(
298
+ placeholderReplacement(
299
+ jsonValueText(raw),
300
+ { ...finding, type: finding.valueType ?? 'SENSITIVE' },
301
+ transform,
302
+ options
303
+ )
304
+ );
305
+ }
280
306
  export function renderReplacement(
281
307
  text,
282
308
  finding,
@@ -285,7 +311,7 @@ export function renderReplacement(
285
311
  jsonRanges
286
312
  ) {
287
313
  if (finding.type === 'JSON_VALUE') {
288
- return JSON.stringify(REDACTED);
314
+ return jsonValueReplacement(text, finding, options);
289
315
  }
290
316
  const transform =
291
317
  options.transformations?.[finding.type] ??
@@ -300,11 +326,15 @@ export function renderReplacement(
300
326
  const sanitized = encodedSanitize(run.text).text;
301
327
  // Mapped escaped fragments can lose their detecting context on decode.
302
328
  // Redact the fragment if recursive scanning makes no progress.
303
- return encodeRun(run, sanitized === run.text ? REDACTED : sanitized);
304
- }
305
- if (options.preserveEncoding) {
306
- throw failure('ERR_ENCODING');
329
+ const encoded = encodeRun(
330
+ run,
331
+ sanitized === run.text ? REDACTED : sanitized
332
+ );
333
+ if (encoded !== undefined) {
334
+ return encoded;
335
+ }
307
336
  }
337
+ // Without a recognizable re-encoding, the encoded text itself is masked.
308
338
  }
309
339
  return renderValue(text, finding, transform, options, jsonRanges);
310
340
  }
package/src/vocabulary.js CHANGED
@@ -1,4 +1,4 @@
1
- import { failure, collectMatches } from './detection.js';
1
+ import { failure, collectMatches, escapePattern } from './detection.js';
2
2
  import { wordPattern } from './personal-variants.js';
3
3
  import { fieldSuffixes, normalizeFieldName } from './document-fields.js';
4
4
 
@@ -50,6 +50,7 @@ export const defaultVocabulary = Object.freeze(
50
50
  'fullname',
51
51
  'originalfilename',
52
52
  ],
53
+ personalAttributes: [],
53
54
  }).map(([key, values]) => [key, Object.freeze(values)])
54
55
  )
55
56
  );
@@ -111,6 +112,30 @@ function normalizedList(input, defaults) {
111
112
  ),
112
113
  ];
113
114
  }
115
+ // Caller stems for the strict special-category rule: each word matches at a
116
+ // word start and takes at most eight more letters of ending, like the built-in
117
+ // stems, so `узбеч` covers `узбечка` and `узбечкой`.
118
+ function attributePattern(values) {
119
+ if (!values.length) {
120
+ return undefined;
121
+ }
122
+ const stems = values
123
+ .map((value) =>
124
+ value
125
+ .split(/\s+/u)
126
+ .map(
127
+ (word) =>
128
+ `${escapePattern(word).replace(/[её]/gu, '[её]')}[\\p{L}\\p{M}]{0,8}`
129
+ )
130
+ .join('[ \\t]+')
131
+ )
132
+ .sort((a, b) => b.length - a.length)
133
+ .join('|');
134
+ return new RegExp(
135
+ `(?<![\\p{L}\\p{N}_])(?:${stems})(?![\\p{L}\\p{N}_])`,
136
+ 'giu'
137
+ );
138
+ }
114
139
  export function compiledVocabulary(vocabulary) {
115
140
  if (vocabulary === undefined) {
116
141
  return undefined;
@@ -159,6 +184,7 @@ export function compiledVocabulary(vocabulary) {
159
184
  'giu'
160
185
  );
161
186
  result.fields = new Set(lists.personalFields.map(normalizeFieldName));
187
+ result.attributes = attributePattern(lists.personalAttributes);
162
188
  if (cache.size >= 64) {
163
189
  cache.clear();
164
190
  }
@@ -3,6 +3,7 @@ export function hasWorkerCallbacks(options) {
3
3
  return Boolean(
4
4
  options.debug ||
5
5
  options.verifyPublic ||
6
+ options.exempt ||
6
7
  options.detectors?.length ||
7
8
  [
8
9
  options.transformation,