@link-foundation/sensitive-data-sanitizer 0.12.2 → 0.14.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.
package/src/context.js CHANGED
@@ -8,6 +8,7 @@ import { plausiblePerson } from './names.js';
8
8
  // unrelated words such as passport. Verb forms cover the supported locales.
9
9
  export const credentialVerbs = [
10
10
  'is',
11
+ 'are',
11
12
  'was',
12
13
  'equals',
13
14
  'es',
@@ -45,12 +46,11 @@ export const credentialVerbs = [
45
46
  'คือ',
46
47
  ];
47
48
  const verbs = credentialVerbs.map(escapePattern).join('|');
48
- const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization)(?:[_.-][\\p{L}\\p{N}]+)*|[\\p{L}\\p{N}_.-]{0,32}(?:${labels.credential
49
+ const label = labels.credential
49
50
  .filter((word) => !['auth', 'authorization'].includes(word))
50
51
  .map(escapePattern)
51
- .join(
52
- '|'
53
- )})[\\p{L}\\p{N}_.-]{0,32}|(?:[\\p{L}\\p{N}_.-]{1,32}[_-])?(?:pass|pw)(?:[_-][\\p{L}\\p{N}_.-]{1,32})?`;
52
+ .join('|');
53
+ const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
54
54
  // A qualifier such as "for staging" stays on one logical line: it never
55
55
  // crosses an escaped line break, an escape sequence or an enclosing quote.
56
56
  // Verbs are whole words, so "request" does not end in the verb "est".
@@ -60,8 +60,6 @@ const credentialPrefix = new RegExp(
60
60
  );
61
61
  // Bare generic labels are common English nouns. After a verb they need a
62
62
  // value that looks generated; "the token is valid" is prose, not a secret.
63
- const genericLabel =
64
- /^(?:token|sig|sas|secret|auth|authorization|cookie|секрет|токен)$/iu;
65
63
  function generatedWord(text, range) {
66
64
  const word = /^[^\s]+/u
67
65
  .exec(text.slice(range.start, range.end))?.[0]
@@ -170,7 +168,7 @@ export function authorizationRange(text, start) {
170
168
  for (; end < text.length; end++) {
171
169
  const char = text[end];
172
170
  const code = text.charCodeAt(end);
173
- if (code < 32 || code === 127 || /[\s"'\\,]/.test(char)) {
171
+ if (authorizationDelimiter(char, code)) {
174
172
  break;
175
173
  }
176
174
  if (char === '<' && end !== tokenStart && !brackets.length) {
@@ -185,7 +183,13 @@ export function authorizationRange(text, start) {
185
183
  }
186
184
  }
187
185
  const token = text.slice(tokenStart, end);
188
- return !token || credentialPlaceholder(token) ? undefined : { start, end };
186
+ return usableAuthorization(token) ? { start, end } : undefined;
187
+ }
188
+ function usableAuthorization(token) {
189
+ return Boolean(token) && !credentialPlaceholder(token);
190
+ }
191
+ function authorizationDelimiter(char, code) {
192
+ return code < 32 || code === 127 || /[\s"'\\,]/.test(char);
189
193
  }
190
194
  function quotedRange(text, start, opening, maxLength) {
191
195
  const depth = opening[1].length,
@@ -323,6 +327,13 @@ function backslashes(text, index) {
323
327
  return count;
324
328
  }
325
329
 
330
+ function sourceReference(label, tail) {
331
+ return (
332
+ /\.(?:[cm]?[jt]sx?|py|go|rs|c|cpp|h|java|cs|rb|sh)$/iu.test(label) &&
333
+ /^\d+(?:[):]|\b)/u.test(tail)
334
+ );
335
+ }
336
+
326
337
  function detectCredentials(text, emit) {
327
338
  const details = { category: 'credential', type: 'SECRET', rule: 'context' };
328
339
  for (const match of text.matchAll(credentialPrefix)) {
@@ -341,9 +352,12 @@ function detectCredentials(text, emit) {
341
352
  let range = /^(?:proxy-)?authorization$/i.test(match[1])
342
353
  ? authorizationRange(text, start)
343
354
  : valueRange(text, start, mode);
344
- if (range && mode === 'prose' && genericLabel.test(match[1])) {
355
+ if (range && mode === 'prose') {
345
356
  range = generatedWord(text, range);
346
357
  }
358
+ if (sourceReference(match[1], text.slice(start))) {
359
+ continue;
360
+ }
347
361
  if (range) {
348
362
  emitValue(text, range.start, range.end, emit, details, match[1]);
349
363
  }
package/src/detection.js CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  credentialPlaceholder,
14
14
  } from './credentials.js';
15
15
  import { localAddress } from './public.js';
16
+ import { detectKnownPersonal } from './personal-variants.js';
17
+ import { detectDocumentEntities } from './document-entities.js';
16
18
 
17
19
  export const REDACTED = '[REDACTED]';
18
20
  export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
@@ -132,6 +134,29 @@ function detectCookies(text, emit) {
132
134
 
133
135
  export function nativeDetect(text, options, emit) {
134
136
  const credential = { type: 'SECRET', category: 'credential' };
137
+ for (const value of options.knownSecrets ?? []) {
138
+ collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
139
+ ...credential,
140
+ rule: 'known-secret',
141
+ });
142
+ }
143
+ detectKnownPersonal(
144
+ text,
145
+ options.knownPersonal,
146
+ 'known-personal',
147
+ emit,
148
+ options
149
+ );
150
+ detectKnownPersonal(
151
+ text,
152
+ options.confirmedPersonal,
153
+ 'confirmed-personal',
154
+ emit,
155
+ options
156
+ );
157
+ if (options.knownOnly) {
158
+ return;
159
+ }
135
160
  detectGitleaks(
136
161
  text,
137
162
  emit,
@@ -142,34 +167,10 @@ export function nativeDetect(text, options, emit) {
142
167
  for (const [rule, pattern] of serviceRules) {
143
168
  collectMatches(text, pattern, emit, { ...credential, rule });
144
169
  }
145
- for (const value of options.knownSecrets ?? []) {
146
- collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
147
- ...credential,
148
- rule: 'known-secret',
149
- });
150
- }
151
- for (const entry of options.knownPersonal ?? []) {
152
- collectMatches(text, new RegExp(escapePattern(entry.value), 'giu'), emit, {
153
- type: entry.type,
154
- category: 'personal',
155
- rule: 'known-personal',
156
- });
157
- }
158
- for (const entry of options.confirmedPersonal ?? []) {
159
- const pattern = new RegExp(
160
- `(?<![\\p{L}\\p{N}])${personalPattern(entry.value)}(?![\\p{L}\\p{N}])`,
161
- 'giu'
162
- );
163
- collectMatches(text, pattern, emit, {
164
- type: entry.type,
165
- category: 'personal',
166
- rule: 'confirmed-personal',
167
- confidence: 0.9,
168
- });
169
- }
170
170
  detectContext(text, emit, options.credentialsOnly);
171
171
  if (!options.credentialsOnly) {
172
172
  detectPersonal(text, emit);
173
+ detectDocumentEntities(text, emit, options);
173
174
  detectCatalog(text, emit);
174
175
  detectIdentity(text, emit);
175
176
  detectNational(text, emit);
@@ -0,0 +1,110 @@
1
+ import { collectMatches, escapePattern } from './detection.js';
2
+ import { firstNames } from './names.js';
3
+ import { organizationCore, personalRegex } from './personal-variants.js';
4
+ import { documentVocabulary } from './document-vocabulary.js';
5
+
6
+ const words = (column) =>
7
+ [...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
8
+ .sort((a, b) => b.length - a.length)
9
+ .map(escapePattern)
10
+ .join('|');
11
+ const legal = words(1),
12
+ school = words(2);
13
+ const proper = "[\\p{Lu}][\\p{L}\\p{M}’'&.-]{1,47}";
14
+ const latinName = `${proper}(?:[ \\t]+${proper}){0,5}`;
15
+ // Scripts without letter case need a bounded script-specific name instead.
16
+ const uncased =
17
+ '[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}\\p{Script=Arabic}\\p{Script=Hebrew}\\p{Script=Devanagari}\\p{Script=Thai}\\p{M}]{2,48}';
18
+ const name = `(?:${latinName}|${uncased}(?:[ \\t]+${uncased}){0,3})`;
19
+ const quoted = `(?:«[^«»\\r\\n]{1,160}»|“[^“”\\r\\n]{1,160}”|"[^"\\r\\n]{1,160}"|「[^「」\\r\\n]{1,160}」|『[^『』\\r\\n]{1,160}』|${name})`;
20
+ const boundary = '[\\p{L}\\p{M}\\p{N}_]';
21
+ const organizations = new RegExp(
22
+ `(?<!${boundary})(?:(?:${legal})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${legal}))(?!${boundary})`,
23
+ 'gu'
24
+ );
25
+ const schools = new RegExp(
26
+ `(?<!${boundary})(?:(?:${school})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${school}))(?!${boundary})`,
27
+ 'gu'
28
+ );
29
+ const places = new RegExp(
30
+ `(?<!${boundary})(?:${words(3)})(?!${boundary})`,
31
+ 'gu'
32
+ );
33
+ const labelledPlace = new RegExp(
34
+ `(?<!${boundary})(?:[Bb]ased in|[Ll]ocated in|[Rr]elocated from|город|г\\.|местоположение|ubicado en|situé à|ansässig in|localizado em|所在地|位于|位於|위치|يقيم في|ממוקם ב|स्थित|merkezi|berlokasi di|đặt tại|ตั้งอยู่ที่)[ \\t]+(${name})(?!${boundary})`,
35
+ 'gu'
36
+ );
37
+
38
+ const ruWord = '[А-ЯЁа-яё][А-ЯЁа-яё-]{1,47}';
39
+ const ruSchool = new RegExp(
40
+ `(?<!${boundary})[А-ЯЁ][а-яё]{2,30}(?:-[А-ЯЁа-яё][а-яё]{1,30})?(?:[ \t]+[а-яё-]{2,40}){0,4}[ \t]+(?:университет|институт|колледж|школа|академия)(?:[ \t]+имени[ \t]+${ruWord}(?:[ \t]+[А-ЯЁ][а-яё-]{2,40}){0,2})?(?!${boundary})`,
41
+ 'gu'
42
+ );
43
+ const ruAcronym =
44
+ /(?<![\p{L}\p{N}_])(?:НИУ[ \t]+[А-ЯЁ]{2,12}|(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)(?=[, \t]+(?:магистратура|бакалавриат|университет|обучение)))(?![\p{L}\p{N}_])/gu;
45
+ const ruResidence =
46
+ /(?:[Гг]ород|[Пп]роживаю|[Пп]роживание|[Мм]есто жительства)[ \t]*:[ \t]*([А-ЯЁ][а-яё-]{2,47})|([А-ЯЁ][а-яё-]{2,47})(?=,[ \t]*(?:готов[а]?[ \t]+к[ \t]+переезду|Россия|Беларусь|Казахстан|Украина))/gu;
47
+ const ruIntroduction = new RegExp(
48
+ `(?:[Мм]еня зовут|[Сс] уважением)[, \t]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
49
+ 'gu'
50
+ );
51
+ const groupOrganization = new RegExp(
52
+ `(?<!${boundary})${latinName}[ \t]+(?:Групп|Group|Холдинг|Holding)(?!${boundary})`,
53
+ 'gu'
54
+ );
55
+
56
+ export function detectDocumentEntities(text, emit, options) {
57
+ if (!options.documentEntities) {
58
+ return;
59
+ }
60
+ const found = [];
61
+ const capture = (finding) => {
62
+ found.push(finding);
63
+ emit(finding);
64
+ };
65
+ for (const [type, rule, pattern, group, confidence] of [
66
+ ['ORGANIZATION', 'organization-group', groupOrganization, 0, 0.7],
67
+ ['EDUCATION', 'education-russian', ruSchool, 0, 0.65],
68
+ ['EDUCATION', 'education-acronym', ruAcronym, 0, 0.65],
69
+ ['PERSON', 'document-introduction', ruIntroduction, 1, 0.7],
70
+ ['ORGANIZATION', 'organization-legal-form', organizations, 0, 0.7],
71
+ ['EDUCATION', 'education-type', schools, 0, 0.65],
72
+ ['LOCATION', 'location-gazetteer', places, 0, 0.6],
73
+ ['LOCATION', 'location-label', labelledPlace, 1, 0.75],
74
+ ]) {
75
+ collectMatches(
76
+ text,
77
+ pattern,
78
+ capture,
79
+ { type, rule, category: 'personal', confidence },
80
+ group
81
+ );
82
+ }
83
+ for (const match of text.matchAll(ruResidence)) {
84
+ const value = match[1] ?? match[2];
85
+ const start = match.index + match[0].lastIndexOf(value);
86
+ emit({
87
+ start,
88
+ end: start + value.length,
89
+ type: 'LOCATION',
90
+ rule: 'location-residence',
91
+ category: 'personal',
92
+ confidence: 0.75,
93
+ });
94
+ }
95
+ // Organization cores are confirmed only by a bounded legal-form finding.
96
+ for (const finding of found.filter((f) => f.type === 'ORGANIZATION')) {
97
+ const core = organizationCore(text.slice(finding.start, finding.end));
98
+ if (core.length < 3 || core.length > 256) {
99
+ continue;
100
+ }
101
+ for (const match of text.matchAll(personalRegex(core, 'ORGANIZATION'))) {
102
+ emit({
103
+ ...finding,
104
+ start: match.index,
105
+ end: match.index + match[0].length,
106
+ rule: 'organization-core',
107
+ });
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,71 @@
1
+ // Authored, deliberately finite vocabulary; no network or model downloads.
2
+ // Each row follows localeVocabulary: locale, legal forms, school types, places.
3
+ export const documentVocabulary = [
4
+ [
5
+ 'en',
6
+ 'Inc|LLC|Ltd|PLC|Corporation',
7
+ 'University|College|Institute|School',
8
+ 'Springfield|Riverton|London|New York|California',
9
+ ],
10
+ [
11
+ 'es',
12
+ 'S.A.|S.L.|S.L.U.',
13
+ 'Universidad|Colegio|Instituto|Escuela',
14
+ 'Madrid|Barcelona|Andalucía',
15
+ ],
16
+ [
17
+ 'fr',
18
+ 'SARL|SAS|SA|S.A.',
19
+ 'Université|Collège|Institut|École',
20
+ 'Paris|Lyon|Bretagne',
21
+ ],
22
+ [
23
+ 'de',
24
+ 'GmbH|AG|KG|UG',
25
+ 'Universität|Hochschule|Institut|Schule',
26
+ 'Berlin|München|Bayern',
27
+ ],
28
+ [
29
+ 'pt',
30
+ 'Ltda|S.A.|Unipessoal',
31
+ 'Universidade|Faculdade|Instituto|Escola',
32
+ 'Lisboa|São Paulo|Algarve',
33
+ ],
34
+ [
35
+ 'ru',
36
+ 'ООО|АО|ПАО|ИП|ЗАО',
37
+ 'Университет|университет|Институт|институт|Школа|школа',
38
+ 'Москва|Санкт-Петербург|Казань|Татарстан|Ярославль|Кострома|Новосибирск|Екатеринбург|Нижний Новгород|Самара|Омск|Челябинск|Ростов-на-Дону|Уфа|Красноярск|Пермь|Воронеж|Волгоград|Саратов|Тюмень|Тольятти|Ижевск|Барнаул|Иркутск|Хабаровск|Владивосток|Махачкала|Томск|Оренбург|Кемерово|Рязань|Астрахань|Пенза|Липецк|Киров|Чебоксары|Тула|Калининград|Курск|Улан-Удэ|Ставрополь|Сочи|Тверь|Брянск|Белгород|Владимир|Архангельск|Калуга|Смоленск|Вологда|Саранск|Орёл',
39
+ ],
40
+ ['ja', '株式会社|有限会社|合同会社', '大学|学院|学校', '東京|大阪|北海道'],
41
+ ['zh', '有限公司|股份有限公司', '大学|学院|学校', '北京|上海|广东'],
42
+ ['zh-Hant', '有限公司|股份有限公司', '大學|學院|學校', '臺北|台北|高雄|臺灣'],
43
+ ['ko', '주식회사|유한회사', '대학교|대학|학교', '서울|부산|경기도'],
44
+ ['ar', 'ذ.م.م|شركة', 'جامعة|معهد|مدرسة', 'القاهرة|دبي|الرياض'],
45
+ [
46
+ 'he',
47
+ 'בע״מ|בע"מ|חברה',
48
+ 'אוניברסיטת|אוניברסיטה|מכללת|מכון',
49
+ 'ירושלים|תל אביב|חיפה',
50
+ ],
51
+ [
52
+ 'hi',
53
+ 'प्राइवेट लिमिटेड|लिमिटेड',
54
+ 'विश्वविद्यालय|महाविद्यालय|संस्थान',
55
+ 'दिल्ली|मुंबई|महाराष्ट्र',
56
+ ],
57
+ [
58
+ 'tr',
59
+ 'A.Ş.|Ltd. Şti.',
60
+ 'Üniversitesi|Üniversite|Enstitüsü|Okulu',
61
+ 'İstanbul|Ankara|İzmir',
62
+ ],
63
+ ['id', 'PT|CV|Tbk', 'Universitas|Institut|Sekolah', 'Jakarta|Bandung|Bali'],
64
+ ['vi', 'TNHH|CTCP', 'Đại học|Trường|Học viện', 'Hà Nội|Hồ Chí Minh|Đà Nẵng'],
65
+ [
66
+ 'th',
67
+ 'บริษัท|จำกัด',
68
+ 'มหาวิทยาลัย|วิทยาลัย|โรงเรียน',
69
+ 'กรุงเทพมหานคร|เชียงใหม่|ภูเก็ต',
70
+ ],
71
+ ];
package/src/engines.js CHANGED
@@ -16,6 +16,12 @@ import {
16
16
  import { failure, internalFailure } from './detection.js';
17
17
  import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
+ import {
20
+ initializePlaceholders,
21
+ placeholderKnown,
22
+ hasPlaceholders,
23
+ discardPlaceholderState,
24
+ } from './placeholders.js';
19
25
  import {
20
26
  structuredBatches,
21
27
  mapStructuredFindings,
@@ -149,14 +155,19 @@ async function analyzeStructured(
149
155
  export function createSanitizer(options = {}) {
150
156
  options = applyProfile(options);
151
157
  validateOptions(options);
158
+ options = initializePlaceholders(options);
152
159
  for (const detector of options.detectors ?? []) {
153
160
  if (!detector || typeof detector.detect !== 'function') {
154
161
  throw failure('ERR_CONFIG');
155
162
  }
156
163
  }
157
164
  const engines =
158
- options.secretlint === false ? [] : [{ detect: secretlintDetect }];
159
- engines.push(...(options.detectors ?? []));
165
+ options.secretlint === false || options.knownOnly
166
+ ? []
167
+ : [{ detect: secretlintDetect }];
168
+ if (!options.knownOnly) {
169
+ engines.push(...(options.detectors ?? []));
170
+ }
160
171
  let prepared;
161
172
  async function analyze(
162
173
  text,
@@ -165,7 +176,7 @@ export function createSanitizer(options = {}) {
165
176
  scopedOptions = options,
166
177
  nativeFindings
167
178
  ) {
168
- const options = scopedOptions;
179
+ const options = placeholderKnown(scopedOptions);
169
180
  validateInput(text, options);
170
181
  if (options.structured && !plain) {
171
182
  return analyzeStructured(text, initial, options, nativeFindings, analyze);
@@ -203,20 +214,8 @@ export function createSanitizer(options = {}) {
203
214
  }
204
215
 
205
216
  const sanitizer = {
206
- inspect: async (...args) => {
207
- try {
208
- return await analyze(...args);
209
- } catch (error) {
210
- throw internalFailure(error);
211
- }
212
- },
213
- sanitize: async (...args) => {
214
- try {
215
- return await sanitizeText(...args);
216
- } catch (error) {
217
- throw internalFailure(error);
218
- }
219
- },
217
+ inspect: guarded(analyze),
218
+ sanitize: guarded(sanitizeText, () => discardPlaceholderState(options)),
220
219
  sanitizeJsonl: (text) =>
221
220
  createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
222
221
  };
@@ -297,15 +296,40 @@ export function createSanitizer(options = {}) {
297
296
  if (options.structured && !plain) {
298
297
  verifyStructured(result.text, options);
299
298
  }
299
+ if (await placeholderResidual(result.text, options, plain, analyze)) {
300
+ throw failure('ERR_RESIDUAL');
301
+ }
300
302
  return { ...result, findings: auditFaked(findings, options, result) };
301
303
  }
302
304
  }
303
305
 
306
+ function guarded(callback, cleanup = () => {}) {
307
+ return async (...args) => {
308
+ try {
309
+ return await callback(...args);
310
+ } catch (error) {
311
+ throw internalFailure(error);
312
+ } finally {
313
+ cleanup();
314
+ }
315
+ };
316
+ }
317
+ async function placeholderResidual(text, options, plain, analyze) {
318
+ return (
319
+ hasPlaceholders(options) &&
320
+ (await analyze(text, [], plain, options)).some((f) =>
321
+ actionable(f, options)
322
+ )
323
+ );
324
+ }
325
+
304
326
  function eligiblePublic(text, finding, findings) {
305
327
  return (
306
328
  finding.category !== 'credential' &&
307
329
  !['known-personal', 'confirmed-personal'].includes(finding.rule) &&
308
- ['PERSON', 'ORGANIZATION', 'EMAIL'].includes(finding.type) &&
330
+ ['PERSON', 'ORGANIZATION', 'EDUCATION', 'LOCATION', 'EMAIL'].includes(
331
+ finding.type
332
+ ) &&
309
333
  !findings.some(
310
334
  (f) =>
311
335
  (f.category === 'credential' ||
package/src/entities.js CHANGED
@@ -36,6 +36,13 @@ for (const [vendor, catalog] of Object.entries(entityCatalogs)) {
36
36
  entries.set(name.toUpperCase(), entry);
37
37
  }
38
38
  }
39
+ export const catalogTypes = () => [
40
+ ...new Set(
41
+ [...entries.values()]
42
+ .filter((entry) => entry.category === 'personal')
43
+ .map((entry) => (entry.type === 'DATE_OF_BIRTH' ? 'DATE' : entry.type))
44
+ ),
45
+ ];
39
46
  // All documented names are native labelled recognizers; automatic format
40
47
  // recognizers are separate and do not claim the vendors' model behavior.
41
48
  const prefix = new RegExp(
package/src/fake-names.js CHANGED
@@ -146,6 +146,58 @@ function scriptLocale(value) {
146
146
  }
147
147
  export function fakeName(value, key) {
148
148
  indexNames();
149
+ const words = value.match(/[\p{L}\p{M}]+/gu) ?? [];
150
+ const roles = new Map();
151
+ let nameSex;
152
+ if (
153
+ /\p{Script=Cyrillic}/u.test(value) &&
154
+ words.length >= 2 &&
155
+ words.length <= 3
156
+ ) {
157
+ const middle = words.findIndex((w) =>
158
+ /(?:ович|евич|ична|овна|евна)$/iu.test(w)
159
+ );
160
+ const surname = words.findIndex(
161
+ (w, i) =>
162
+ i !== middle &&
163
+ /(?:ов|ев|ин|ский|ова|ева|ина|ская)$/iu.test(w) &&
164
+ !given.has(latinName(w))
165
+ );
166
+ const genderWord = words[middle >= 0 ? middle : surname];
167
+ if (genderWord) {
168
+ nameSex = /(?:ова|ева|ина|ская|овна|евна|ична)$/iu.test(genderWord)
169
+ ? 'female'
170
+ : 'male';
171
+ }
172
+ if (middle > 0) {
173
+ words.forEach((w, i) =>
174
+ roles.set(
175
+ w,
176
+ i === middle
177
+ ? 'middleName'
178
+ : i === middle - 1
179
+ ? 'firstName'
180
+ : 'lastName'
181
+ )
182
+ );
183
+ } else if (surname >= 0) {
184
+ words.forEach((w, i) =>
185
+ roles.set(w, i === surname ? 'lastName' : 'firstName')
186
+ );
187
+ } else {
188
+ // Ambiguous spans receive a complete name with the same token count.
189
+ words.forEach((w, i) =>
190
+ roles.set(
191
+ w,
192
+ i === 0
193
+ ? 'firstName'
194
+ : i === words.length - 1
195
+ ? 'lastName'
196
+ : 'middleName'
197
+ )
198
+ );
199
+ }
200
+ }
149
201
  return value.replace(/[\p{L}\p{M}]+/gu, (word) => {
150
202
  for (const locale of ['ja', 'zh', 'ko']) {
151
203
  const family = firstNames[locale]
@@ -158,7 +210,7 @@ export function fakeName(value, key) {
158
210
  );
159
211
  }
160
212
  }
161
- return fakeWord(word, key);
213
+ return fakeWord(word, key, roles.get(word), undefined, nameSex);
162
214
  });
163
215
  }
164
216
  function wordProfile(word, forcedRole, forcedLocale) {
@@ -188,12 +240,14 @@ function wordProfile(word, forcedRole, forcedLocale) {
188
240
  : 'lastName');
189
241
  return { canonical, locale, sex, role };
190
242
  }
191
- function fakeWord(word, key, forcedRole, forcedLocale) {
192
- const { canonical, locale, sex, role } = wordProfile(
193
- word,
194
- forcedRole,
195
- forcedLocale
196
- );
243
+ function fakeWord(word, key, forcedRole, forcedLocale, forcedSex) {
244
+ const {
245
+ canonical,
246
+ locale,
247
+ sex: detectedSex,
248
+ role,
249
+ } = wordProfile(word, forcedRole, forcedLocale);
250
+ const sex = forcedSex ?? detectedSex;
197
251
  const faker = new Faker({ locale: [locales[locale] ?? en, en] });
198
252
  faker.seed(
199
253
  createHmac('sha256', key)
package/src/fake.js CHANGED
@@ -501,6 +501,13 @@ export function realisticFake(value, finding, transform) {
501
501
  .slice(0, 8)}!`;
502
502
  } else if (finding.type === 'PERSON') {
503
503
  output = fakeName(value, transform.key);
504
+ } else if (['ORGANIZATION', 'EDUCATION', 'LOCATION'].includes(finding.type)) {
505
+ const label = {
506
+ ORGANIZATION: 'Organization',
507
+ EDUCATION: 'School',
508
+ LOCATION: 'Location',
509
+ }[finding.type];
510
+ output = `Example ${label} ${digest(value, transform.key, finding.type).readUInt32BE(0)}`;
504
511
  } else if (/DATE|BIRTH/.test(finding.type)) {
505
512
  output = fakeDate(value, transform.key);
506
513
  } else if (finding.type === 'PASSPORT_MRZ') {
package/src/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export interface PersonalValue {
27
27
  value: string;
28
28
  }
29
29
  export interface PublicEntity {
30
- type: 'PERSON' | 'ORGANIZATION' | 'EMAIL';
30
+ type: 'PERSON' | 'ORGANIZATION' | 'EDUCATION' | 'LOCATION' | 'EMAIL';
31
31
  value: string;
32
32
  /** Organization-controlled HTTPS evidence, reviewed by the policy author. */
33
33
  source: string;
@@ -42,7 +42,11 @@ export interface Detector {
42
42
  export interface SanitizerOptions {
43
43
  /** Preserve all personal/account data; credential findings still redact. */
44
44
  credentialsOnly?: boolean;
45
- profile?: 'publication';
45
+ /** Replace only knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
46
+ knownOnly?: boolean;
47
+ profile?: 'publication' | 'documents';
48
+ /** Opt in to bounded employer, school, city/region heuristics. */
49
+ documentEntities?: boolean;
46
50
  confirmedPersonal?: Array<{ value: string; type: string }>;
47
51
  knownSecrets?: string[];
48
52
  knownPersonal?: PersonalValue[];
@@ -95,6 +99,10 @@ export interface SanitizedText {
95
99
  findings: Finding[];
96
100
  /** Number of disjoint replacements after overlapping detections are merged. */
97
101
  redactions: number;
102
+ /** Placeholder-mode replacements absent from the restore map (e.g. credentials). */
103
+ irreversibleRedactions?: number;
104
+ /** Private personal data. Absent for map:false and non-placeholder modes. */
105
+ map?: RestoreMap;
98
106
  }
99
107
  export interface Sanitizer {
100
108
  inspect(text: string): Promise<Finding[]>;
@@ -220,6 +228,12 @@ export declare function auditGitHistory(
220
228
  ): Promise<HistoryAudit>;
221
229
  export type Transformation =
222
230
  | { mode: 'redact' | 'hive-mask' }
231
+ | {
232
+ mode: 'placeholder';
233
+ map?: RestoreMap | false;
234
+ format?: string;
235
+ credentials?: boolean;
236
+ }
223
237
  | {
224
238
  mode: 'mask';
225
239
  keepStart?: number;
@@ -239,6 +253,45 @@ export type Transformation =
239
253
  }
240
254
  | { mode: 'date-shift'; days: number }
241
255
  | { mode: 'bucket'; size: number };
256
+
257
+ /** Contains original personal data; never publish this object or its files. */
258
+ export interface RestoreMap {
259
+ kind: 'sensitive-data-restore-map';
260
+ version: 1;
261
+ format: string;
262
+ entries: Array<{
263
+ token: string;
264
+ type: string;
265
+ number: number;
266
+ values: string[];
267
+ aliases: string[];
268
+ }>;
269
+ documents: Array<{
270
+ digest: string;
271
+ occurrences: Array<{
272
+ start: number;
273
+ end: number;
274
+ token: string;
275
+ raw: string;
276
+ }>;
277
+ }>;
278
+ }
279
+ export declare function createRestoreMap(options?: {
280
+ format?: string;
281
+ }): RestoreMap;
282
+ export declare function serializeRestoreMap(
283
+ map: RestoreMap,
284
+ format?: 'json' | 'lino'
285
+ ): string;
286
+ export declare function parseRestoreMap(text: string): RestoreMap;
287
+ export declare function readRestoreMap(path: string): Promise<RestoreMap>;
288
+ export declare function writeRestoreMap(
289
+ path: string,
290
+ map: RestoreMap,
291
+ options?: { format?: 'json' | 'lino'; replace?: boolean }
292
+ ): Promise<void>;
293
+ /** Exact original-document restoration; derived text uses canonical surfaces. */
294
+ export declare function restore(text: string, map: RestoreMap): string;
242
295
  export interface StreamOptions extends SanitizerOptions {
243
296
  /** Treat NUL as a log byte; default false. Incompatible with JSON/JSONL. */
244
297
  allowBinary?: boolean;
package/src/index.js CHANGED
@@ -38,3 +38,11 @@ export { knownSecretsFromGitHubAuth } from './known.js';
38
38
  export { knownSecretsFromGitHubConfig } from './known.js';
39
39
  export { decodeLogBytes, encodeLogText } from './log-bytes.js';
40
40
  export { rewriteGitHistory } from './history-rewrite.js';
41
+ export {
42
+ restore,
43
+ createRestoreMap,
44
+ serializeRestoreMap,
45
+ parseRestoreMap,
46
+ readRestoreMap,
47
+ writeRestoreMap,
48
+ } from './restore-map.js';