@link-foundation/sensitive-data-sanitizer 0.19.1 → 0.19.3

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 6bdaf11: Recognize additional inflected special-category attributes and unfamiliar values after explicit category cues in strict mode. Require personal context for ambiguous English attributes so operational logs and JSON statuses stay intact.
8
+
9
+ ## 0.19.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 4afc88d: Read `Mr`, `Ms` and `Dr` as person cues only when capitalized, so lowercase `ms` (milliseconds) and `dr` before a capitalized log word stay. Treat compound JSON keys ending in a personal field, such as `recruiterDisplayName`, `authorFullName` and `resumePdfOriginalFileName`, as person fields.
14
+
3
15
  ## 0.19.1
4
16
 
5
17
  ### Patch Changes
package/docs/API.md CHANGED
@@ -17,7 +17,7 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
17
17
  | `compileKnown(...lists)` | Copy, freeze, merge and precompile caller-known personal values |
18
18
  | `auditGitHistory(repository, options)` | Read-only audit of local reachable text objects |
19
19
 
20
- `redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
20
+ `redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. The optional `strict-attributes` debug event reports only detected and suppressed match counts. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
21
21
 
22
22
  ## Options
23
23
 
package/docs/COVERAGE.md CHANGED
@@ -370,6 +370,11 @@ this explicit vocabulary: `firstName`, `lastName`, `middleName`, `patronymic`,
370
370
  sensitive in full, including ISO birth timestamps. Thus `first_name` and
371
371
  `firstName`, or `birth_date` and `birthDate`, follow the same policy. These are
372
372
  schema assumptions: `contact` or `company` can hold operational data too.
373
+ Person fields also match as a word-boundary suffix of a compound key, so
374
+ `recruiterDisplayName`, `author_full_name` and `resumePdfOriginalFileName` are
375
+ person fields. The same suffix rule applies to `vocabulary.personalFields`.
376
+ Other types match only the whole key (`maxAge` and `fileName` stay), and a
377
+ bare `name` is not in the list.
373
378
  Existing native labels still apply. Unlisted URL-named fields have ordinary
374
379
  content detection; arbitrary names do not imply sensitivity. Declare them with
375
380
  [`sensitiveFields`](API.md#caller-declared-sensitive-fields) to replace complete
@@ -471,7 +476,25 @@ proper-name mode. Romashka Inc keeps its existing legal-form treatment.
471
476
  `strict: true` independently opts into personal attributes (gender, family,
472
477
  ethnicity, religion, health, criminal/political status) and bare dates, ages,
473
478
  passport/mobile/postal shapes. It is intended for stored personal prose and can
474
- remove ordinary uses of these words. It preserves ISO timestamps, work durations
479
+ remove ordinary uses of unambiguous category words. Bounded Russian case/stem
480
+ rules cover disability, ethnicity, religion, diagnoses, intimate life and criminal
481
+ status, including `инвалидом`, `армянин`, `еврейка`, `казах`, `христианин`, `иудей`,
482
+ `эпилепсией`, `астма`, `онкологию`, `туберкулез`, `психиатра`, `депрессия`,
483
+ `гепатит`, `гей` and `отбывал наказание`. English coverage includes `epilepsy`,
484
+ `gay` and `Armenian`. Explicit category cues such as `по национальности X`,
485
+ `исповедует X`, `болеет X`, `на учете у X`, `diagnosed with X` and `ethnicity: X`
486
+ protect one following word (including hyphenated words) up to 64 characters,
487
+ including unfamiliar values outside the lexicon. These cues stay on one line.
488
+ Ambiguous English `disabled`, `single`, `man`, `men`, `russian`
489
+ and `asian` require an adjacent personal cue (pronoun/person role, personal
490
+ label, or following person role). Thus `he is disabled`, `candidate is single`
491
+ and `gender: male` redact, while `the module is disabled`, `single run finished`,
492
+ `single-flight lock acquired`, `man page missing`, `locale russian selected`
493
+ and `asian region endpoint` remain intact. Bare operational JSON statuses
494
+ `disabled`, `single` and `Russian` also remain intact; explicit personal labels
495
+ and caller-declared known personal values still redact. A cue in another clause
496
+ or string value does not make an ambiguous word personal.
497
+ It preserves ISO timestamps, work durations
475
498
  (including `Стаж: 5 лет`, `работал там 5 лет`, and duration cues from issue 87),
476
499
  and parenthesized durations after known technologies such as `Kubernetes (5 лет)`
477
500
  and `Power BI (5 лет)`.
@@ -542,6 +565,9 @@ corpus, unbounded input, parallel builds or external analysis service.
542
565
 
543
566
  Document rules recognize a capitalized Latin name after `candidate`, `recruiter`,
544
567
  `applicant`, `Dear`, `Hi`, `Mr`/`Mr.`, `Ms`/`Ms.`, `Dr`/`Dr.`, or `message from`.
568
+ Honorifics count only when capitalized: lowercase `ms` (milliseconds), `mr` and
569
+ `dr` are not cues, and `Ms` right after a number is read as a unit, so
570
+ `took 30 ms Retry scheduled` and `see dr Drive mount` stay unchanged.
545
571
  Only the name is replaced; punctuation and the cue remain. These rules work
546
572
  without enabling Latin script matching. Finite generic heading nouns such as
547
573
  `Stage` and `Status` are preserved: `Candidate Stage changed` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.19.1",
3
+ "version": "0.19.3",
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",
@@ -25,7 +25,24 @@ const types = new Map(
25
25
  )
26
26
  );
27
27
  export function documentFieldType(field) {
28
- return types.get(normalizeFieldName(field));
28
+ const type = types.get(normalizeFieldName(field));
29
+ if (type) {
30
+ return type;
31
+ }
32
+ // Compound person keys (`recruiterDisplayName`, `author_full_name`) end in
33
+ // a listed person field. Only PERSON entries extend this way, so generic
34
+ // suffixes such as `maxAge` or `homeSchool` keep their exact-match meaning,
35
+ // and a bare `name` is never listed.
36
+ return fieldSuffixes(field).some((suffix) => types.get(suffix) === 'PERSON')
37
+ ? 'PERSON'
38
+ : undefined;
39
+ }
40
+ // Word-boundary suffixes of a key, normalized; the whole key comes first.
41
+ export function fieldSuffixes(field) {
42
+ const words = field
43
+ .split(/[\s_.-]+|(?<=[\p{Ll}\p{N}])(?=\p{Lu})|(?<=\p{Lu})(?=\p{Lu}\p{Ll})/u)
44
+ .filter(Boolean);
45
+ return words.map((_, i) => words.slice(i).join('').toLowerCase());
29
46
  }
30
47
  export const normalizeFieldName = (field) =>
31
48
  field.replace(/[_-]/g, '').toLowerCase();
@@ -4,8 +4,11 @@ import { collectMatches } from './detection.js';
4
4
  // capitalized-word matching for the rest of an English log or document.
5
5
  const boundary = '[\\p{L}\\p{M}\\p{N}_]';
6
6
  const name = "[A-Z][a-z]{1,47}(?:['’-][A-Z]?[a-z]{1,47})?";
7
+ // Honorifics count only when capitalized: lowercase `ms` is the millisecond
8
+ // unit in logs and lowercase `dr` is usually a word or path fragment. A
9
+ // capitalized `Ms` right after a number is still a unit (`took 30 Ms Retry`).
7
10
  const cue =
8
- '(?:[Cc]andidate|[Rr]ecruiter|[Aa]pplicant|[Dd]ear|[Hh]i|[Mm]r\\.?|[Mm]s\\.?|[Dd]r\\.?|[Mm]essage[ \\t]+from)';
11
+ '(?:[Cc]andidate|[Rr]ecruiter|[Aa]pplicant|[Dd]ear|[Hh]i|Mr\\.?|(?<!\\d[ \\t]*)Ms\\.?|Dr\\.?|[Mm]essage[ \\t]+from)';
9
12
  const pattern = new RegExp(
10
13
  `(?<!${boundary})${cue}[ \\t]+(${name})(?!${boundary})`,
11
14
  'gu'
package/src/index.d.ts CHANGED
@@ -118,6 +118,7 @@ export interface SanitizerOptions {
118
118
  }
119
119
  /** Value-free diagnostics; enabled only when a debug callback is supplied. */
120
120
  export type DebugEvent =
121
+ | { event: 'strict-attributes'; detected: number; suppressed: number }
121
122
  | { event: 'detected'; count: number }
122
123
  | { event: 'document-experience' | 'proper-name-prose'; count: number }
123
124
  | { event: 'residual-redaction'; round: number }
@@ -0,0 +1,71 @@
1
+ import { collectMatches } from './detection.js';
2
+
3
+ const attributes =
4
+ /(?<![\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;
5
+ const additionalAttributes =
6
+ /(?<![\p{L}\p{N}_])(?:диабет(?:ом|а|у|е)?|ВИЧ(?:-положительн(?:ый|ая|ого|ой))?|болен|больна|больны|католик(?:ом|а|и)?|католичк(?:а|ой|и)|буддист(?:ка|кой|ом|а|ы)?|атеист(?:ка|кой|ом|а|ы)?|украин(?:ец|ка|цы|цем|кой)|белорус(?:ка|кой|ом|а|ы)?|несудим(?:а|ы)?|судим(?:а|ы)?|член[ \t]+политической[ \t]+партии|холост(?:а|ы)?|развед[её]н(?:а|ы)?|в[ \t]+разводе|diabetes|diabetic|HIV(?:[ \t-]+positive)?|cancer|buddhist|catholic|atheist|ukrainian|belarusian|previous[ \t]+conviction|member[ \t]+of[ \t]+(?:a[ \t]+)?(?:political[ \t]+)?party|unmarried|widowed)(?![\p{L}\p{N}_])/giu;
7
+
8
+ // Bounded suffixes cover Russian cases, plurals and adjectival forms without
9
+ // turning a stem into an unbounded substring search. Category cues below cover
10
+ // unfamiliar diagnoses, nationalities and religions outside this lexicon.
11
+ const categoryForms =
12
+ /(?<![\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;
13
+ 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;
15
+ const ambiguous =
16
+ /(?<![\p{L}\p{N}_])(?:disabled|single|man|men|russian|asian)(?![\p{L}\p{N}_])/giu;
17
+
18
+ function personalContext(text, match) {
19
+ const prefix = text.slice(Math.max(0, match.index - 128), match.index);
20
+ const suffix = text.slice(
21
+ match.index + match[0].length,
22
+ match.index + match[0].length + 64
23
+ );
24
+ // These continuations remain technical even after a pronoun ("it is a
25
+ // single-flight lock"). No document-wide cue can authorize another clause.
26
+ if (
27
+ /^(?:-flight\b|[ \t]+(?:page|pages|run|worker|thread|lock|region|endpoint|locale|module|feature|flag)\b)/iu.test(
28
+ suffix
29
+ )
30
+ ) {
31
+ return false;
32
+ }
33
+ return (
34
+ /(?<![\p{L}\p{N}_])(?:i|he|she|we|they|candidate|applicant|patient|person|employee)(?:[ \t]+(?:am|is|are|was|were|identifies[ \t]+as))?(?:[ \t]+(?:a|an))?[ \t]+$/iu.test(
35
+ prefix
36
+ ) ||
37
+ /(?<![\p{L}\p{N}_])(?:gender|sex|nationality|ethnicity|marital[ \t_-]*status|disability)(?:["'][ \t]*)?[ \t]*[:=][ \t]*["']?$/iu.test(
38
+ prefix
39
+ ) ||
40
+ /^[ \t]+(?:candidate|applicant|patient|person|people|employee)(?![\p{L}\p{N}_])/iu.test(
41
+ suffix
42
+ )
43
+ );
44
+ }
45
+
46
+ export function detectStrictAttributes(text, emit, options) {
47
+ let detected = 0,
48
+ suppressed = 0;
49
+ const details = {
50
+ type: 'PERSONAL_ATTRIBUTE',
51
+ rule: 'strict-attribute',
52
+ category: 'personal',
53
+ confidence: 0.9,
54
+ };
55
+ const report = (finding) => {
56
+ detected++;
57
+ emit(finding);
58
+ };
59
+ for (const pattern of [attributes, additionalAttributes, categoryForms]) {
60
+ collectMatches(text, pattern, report, details);
61
+ }
62
+ collectMatches(text, categoryCue, report, details, 1);
63
+ collectMatches(text, ambiguous, report, details, 0, (_value, match) => {
64
+ const accepted = personalContext(text, match);
65
+ if (!accepted) {
66
+ suppressed++;
67
+ }
68
+ return accepted;
69
+ });
70
+ options.debug?.({ event: 'strict-attributes', detected, suppressed });
71
+ }
package/src/strict.js CHANGED
@@ -3,12 +3,9 @@ import { months } from './personal-context.js';
3
3
  import { isDocumentTechnology } from './document-experience.js';
4
4
  import { placeholderValues } from './placeholders.js';
5
5
  import { generatedValues } from './fake.js';
6
+ import { detectStrictAttributes } from './strict-attributes.js';
6
7
 
7
8
  const boundary = '[\\p{L}\\p{N}_]';
8
- const attributes =
9
- /(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|man|men|male|female|married|divorced|pregnant|pregnancy|disabled|disability|christian|muslim|jewish|orthodox|russian|hispanic|asian|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
10
- const additionalAttributes =
11
- /(?<![\p{L}\p{N}_])(?:диабет(?:ом|а|у|е)?|ВИЧ(?:-положительн(?:ый|ая|ого|ой))?|болен|больна|больны|католик(?:ом|а|и)?|католичк(?:а|ой|и)|буддист(?:ка|кой|ом|а|ы)?|атеист(?:ка|кой|ом|а|ы)?|украин(?:ец|ка|цы|цем|кой)|белорус(?:ка|кой|ом|а|ы)?|несудим(?:а|ы)?|судим(?:а|ы)?|член[ \t]+политической[ \t]+партии|холост(?:а|ы)?|развед[её]н(?:а|ы)?|в[ \t]+разводе|diabetes|diabetic|HIV(?:[ \t-]+positive)?|cancer|buddhist|catholic|atheist|ukrainian|belarusian|previous[ \t]+conviction|member[ \t]+of[ \t]+(?:a[ \t]+)?(?:political[ \t]+)?party|single|unmarried|widowed)(?![\p{L}\p{N}_])/giu;
12
9
 
13
10
  function isDuration(text, match, replacements) {
14
11
  // Keep cues on the same clause; an age on the next line is independent.
@@ -57,14 +54,7 @@ export function detectStrict(text, emit, options) {
57
54
  if (!options.strict) {
58
55
  return;
59
56
  }
60
- for (const pattern of [attributes, additionalAttributes]) {
61
- collectMatches(text, pattern, emit, {
62
- type: 'PERSONAL_ATTRIBUTE',
63
- rule: 'strict-attribute',
64
- category: 'personal',
65
- confidence: 0.9,
66
- });
67
- }
57
+ detectStrictAttributes(text, emit, options);
68
58
  const date = new RegExp(
69
59
  `(?<!${boundary})(?:\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[./]\\d{1,2}[./]\\d{4}|\\d{1,2}[ \\t]+(?:${months})[ \\t]+\\d{4})(?!${boundary}|[Tt]\\d)`,
70
60
  'giu'
package/src/vocabulary.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { failure, collectMatches } from './detection.js';
2
2
  import { wordPattern } from './personal-variants.js';
3
- import { normalizeFieldName } from './document-fields.js';
3
+ import { fieldSuffixes, normalizeFieldName } from './document-fields.js';
4
4
 
5
5
  export const defaultVocabulary = Object.freeze(
6
6
  Object.fromEntries(
@@ -170,7 +170,8 @@ export function vocabularyField(field, options) {
170
170
  if (!vocabulary) {
171
171
  return undefined;
172
172
  }
173
- return vocabulary.fields.has(normalizeFieldName(field))
173
+ return vocabulary.fields.has(normalizeFieldName(field)) ||
174
+ fieldSuffixes(field).some((suffix) => vocabulary.fields.has(suffix))
174
175
  ? true
175
176
  : options.vocabulary.personalFields?.replace
176
177
  ? false