@link-foundation/sensitive-data-sanitizer 0.16.0 → 0.16.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Make CI security and validation gates fail on incomplete evidence, recover transient release push failures, preserve validated release dependencies, and prevent quadratic scanning of PuTTY headers, travel identifiers and postal-address whitespace.
8
+
9
+ Preserve durations, stack frames, empty document fields and local bind addresses; protect personal URL paths and additional document forms. Reduce repeated label-pattern construction and document structured JSON per-call costs.
10
+
11
+ ## 0.16.1
12
+
13
+ ### Patch Changes
14
+
15
+ - 4dfe2b7: Scan standalone and embedded JSON string values with their own line boundaries. Extend the documents profile's contact, identity, name, attribute, employer, school and residence rules, and preserve ordinary relative URLs, operational timestamps and context-free SHA-1-shaped values consistently.
16
+
3
17
  ## 0.16.0
4
18
 
5
19
  ### Minor Changes
package/docs/COVERAGE.md CHANGED
@@ -202,3 +202,138 @@ Structured JSONL streaming supports 8 MiB records and ordered record workers;
202
202
  and reproducible throughput measurement. Realistic fake mode preserves supported
203
203
  scripts/formats/checksums and uses reserved contact ranges where available.
204
204
  Generated identities are pseudonyms; valid arithmetic does not prove non-issuance.
205
+
206
+ ## Document forms reviewed in issues 77–83
207
+
208
+ Structured JSON/JSONL scans each decoded string at its own line start as well as
209
+ with its field label. This includes array elements, multiline strings and up to
210
+ two embedded JSON container layers. Both the synchronous API and asynchronous
211
+ engine union use these views. Source offsets include original JSON escapes;
212
+ unchanged values and whitespace remain byte-identical. Numeric primitives still
213
+ require a caller schema policy. `debug` can observe `structured-value` events
214
+ with finding counts, without logging the string contents.
215
+
216
+ The `documents` profile additionally recognizes:
217
+
218
+ - Russian mobile numbers with dashed, dotted, spaced or parenthesized forms,
219
+ including ten-digit `9xx` forms and domestic `8` prefixes; `тел.`, `моб.` and
220
+ WhatsApp labels; `wa.me` contact links.
221
+ - Obfuscated email separators `(at)`, `[at]`, `собака`, and English `dot`/`at`.
222
+ This is a finite spelling grammar, not arbitrary email deobfuscation.
223
+ - Personal paths on LinkedIn (`/in/`), GitHub, VK, Instagram and Telegram,
224
+ including scheme-less links; Telegram/телеграм/tg/skype/ник labels without `@`.
225
+ A provider root such as `https://github.com/` remains unchanged. These rules
226
+ cannot distinguish a person from an organization account on the same service.
227
+ - Russian passport series/number phrases, ОГРНИП, subdivision codes, dated
228
+ ОВД/УВД/МВД issuer phrases, Russian/English driving licence labels, ОМС/полис,
229
+ Russian account numbers and numbered diplomas. These contextual patterns do
230
+ not validate issuance; digits are protected even with invalid checksums.
231
+ - Numbered schools, gymnasiums, lyceums and colleges; school acronyms after
232
+ Образование/ВУЗ/Университет/окончил/окончила, with optional `им. Баумана`-style
233
+ suffixes. Acronym detection is contextual rather than a complete school list.
234
+ - Surname/initial shapes in Cyrillic and selected Latin Slavic forms, known
235
+ given-name plus initial, Russian surname/given-name pairs using finite case
236
+ forms and lower case, and surnames after г-жа/господин/госпожа.
237
+ - Birth years with д.р./г.р./год рождения/родился/родилась/Born in context;
238
+ Russian age expressions and Пол labels, English Sex, and gender beside age.
239
+ Bare age-in-years expressions are ambiguous and remain unchanged without
240
+ affirmative personal-age context; durations are preserved.
241
+ - Labelled citizenship, marital status, children, nationality, religion,
242
+ disability and criminal record, including the reported English labels.
243
+ - Company/employer/workplace labels, capitalized company/holding/bank names,
244
+ employment prose and year-range employer lines; residence/metro/district
245
+ labels and contextual six-digit postal codes.
246
+ - Russian street-plus-house/flat structures at line start or after
247
+ `проживает по адресу`. Public-place prose such as
248
+ `Музей находится на ул. Ленина, д. 5, кв. 12` stays unchanged. Public addresses
249
+ presented alone have the same shape as personal addresses and may be flagged;
250
+ inspect and review standalone public addresses before publication.
251
+
252
+ Explicit limits accepted for the ambiguous examples in #82/#83:
253
+ `Спасибо, Анна!`, `Анна, добрый день!`, `Менеджер Анна` and `Лютик, менеджер`
254
+ remain unchanged without known values or additional context. Bare given names
255
+ and unmarked employer/title fragments cannot reliably establish identity.
256
+ `зарплата 150 000 руб.` and `Salary: 150000` also remain unchanged: salary is
257
+ outside the native document rules. Supply `knownPersonal` or an additional
258
+ recognizer when these fields must be removed. Initial/name rules do not cover
259
+ all surnames, languages, cases or arbitrary lower-case names.
260
+
261
+ Operational value policy (#79): a single-slash relative URL with no query,
262
+ fragment, userinfo or explicit u/in/user/profile/member/home/people path stays unchanged
263
+ under `url`; other detectors and known values still apply. An ISO-style
264
+ `YYYY-MM-DDTHH:mm:ss[.fraction](Z|±HH:mm)` timestamp under `time` stays unchanged.
265
+ This exemption is syntactic, not date validation. Userinfo, token-bearing URLs
266
+ and personal paths still redact. Context-free 40-character hexadecimal values
267
+ are treated as SHA-1-shaped identifiers in both text and structured modes,
268
+ including quoted values. This does not prove they are harmless: actual bare
269
+ 40-hex secrets require `knownSecrets` or credential context. Credential labels,
270
+ provider-specific rules and required external scanners remain authoritative.
271
+
272
+ The [complete requirement inventory and alternatives](case-studies/issue-84/README.md)
273
+ links every reported text row to its regression case.
274
+
275
+ ## Document context refinements (issues 87–88)
276
+
277
+ Age requires a label (`возраст 41`, `Age 41`), personal pronoun (`мне 29 лет`),
278
+ birthday verb (`исполнилось 34`) or gender followed by age (`Женщина, 34 года`).
279
+ Bare `34 года` remains unchanged as an ambiguous limit. Work experience, tenure,
280
+ contract lengths and elapsed/future durations stay unchanged. `Дети: нет` and
281
+ `Гражданство: не указано` are treated as empty fields; negative disability and
282
+ criminal-record statements remain sensitive attributes.
283
+
284
+ Additional document forms include `контактный телефон` and `сот.` phone labels,
285
+ parenthesized `(собака)` email separators, `телега`/`Zoom` messenger labels,
286
+ dated `ТП УФМС`/`УФМС` passport issuers, `р/с` account numbers, `г-н` surname
287
+ titles, `1985 года рождения` and `г.р. 1985`, abbreviated/single-letter gender
288
+ values (`Пол: Ж`, `Пол: М`, `пол: жен.`, `пол муж.`), `гражданин РФ`,
289
+ `есть двое детей`, `проживаю в Перми`, and English `Employer`, `Company` and
290
+ `Education` labels. A capitalized Cyrillic word followed by two initials is
291
+ recognized regardless of surname ending (`Цой В. Р.`, `Ким Д. С.`,
292
+ `Шевченко Т. Г.`). This structural rule can flag non-name text with the same
293
+ shape; it does not provide a complete surname dictionary.
294
+
295
+ JSON escape boundaries cannot start an obfuscated-email local part, preserving
296
+ stack newlines and frames after IP:port. Relative `/u/<name>` and `/in/<name>`
297
+ URLs are personal paths under `url`, like the existing user/profile paths.
298
+ Exact `0.0.0.0` is an operational unspecified bind address, like loopback/local
299
+ addresses. `publicKnowledge: false` and explicit private known values still
300
+ redact it.
301
+
302
+ ## Structured JSON cost (issue 86)
303
+
304
+ Structured sanitization scans labelled raw strings and each decoded string with
305
+ its own line boundaries, then verifies sanitized output. This preserves detection
306
+ in arrays, CV fragments and embedded JSON, but adds fixed work per string/key.
307
+ Costs depend on string count, enabled engines, content, runtime and hardware;
308
+ line length alone cannot predict synchronous main-thread delay.
309
+
310
+ A finite serial measurement on Linux x64, Node 26.13.0, using the documents
311
+ profile and structured JSON, produced these median milliseconds per `sanitize`
312
+ call (100 warm-ups, three 100-call trials):
313
+
314
+ | Fixture | 0.16.0 | 0.16.1 | This change |
315
+ | ----------------------------------------- | -----: | -----: | ----------: |
316
+ | Short JSON (89 characters) | 1.21 | 8.44 | 4.67 |
317
+ | Exact issue reproduction (264 characters) | 1.81 | 19.71 | 11.15 |
318
+ | Longer synthetic JSON (9,396 characters) | 10.04 | 37.88 | 28.32 |
319
+
320
+ The short and longer synthetic rows approximate the issue's unpublished
321
+ 100/9,453-character inputs; only the 264-character input was provided in full.
322
+ These results are evidence for this container, not a universal latency guarantee
323
+ or an Apple Silicon comparison. The issue reporter measured 0.16.1 at
324
+ 2.31/5.61/34.85 ms on their short/264-character/9 KiB rows and 1.10 ms for the
325
+ 264-character text-mode control. The remaining structured overhead is **above
326
+ 2x** of 0.16.0 here; documenting that cost fulfills the issue's alternative
327
+ acceptance criterion. No detection view is removed to meet a timing target.
328
+
329
+ Personal-label patterns are now constructed once instead of for every scan, and
330
+ redundant explicit RegExp cloning is avoided when `matchAll` supplies isolation.
331
+ Reproduce with `node --max-old-space-size=256 experiments/issue-89/benchmark.mjs`
332
+ and optionally pass a released checkout's `src/index.js`. For latency-sensitive
333
+ logging, measure the real workload before synchronously sanitizing every line;
334
+ the existing JSONL record-worker API/CLI can move record scanning off the main
335
+ thread (see [API.md](API.md)). External engines and more sensitive values can add
336
+ further work. These numbers do not describe `createSanitizer` with Secretlint.
337
+
338
+ The [complete issue 89 requirement inventory](case-studies/issue-89/README.md)
339
+ records every row, root cause, alternative and regression mapping.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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",
@@ -92,7 +92,7 @@
92
92
  },
93
93
  "devDependencies": {
94
94
  "@changesets/cli": "^3.0.3",
95
- "eslint": "^9.38.0",
95
+ "eslint": "^10.13.0",
96
96
  "eslint-config-prettier": "^10.1.8",
97
97
  "eslint-plugin-prettier": "^5.5.4",
98
98
  "husky": "^9.1.7",
@@ -101,7 +101,10 @@
101
101
  "mrz": "^5.0.2",
102
102
  "prettier": "^3.6.2",
103
103
  "secretlint": "13.0.7",
104
- "test-anywhere": "^0.8.48"
104
+ "test-anywhere": "^0.8.48",
105
+ "@eslint/js": "^10.0.1",
106
+ "command-stream": "2.4.0",
107
+ "lino-arguments": "0.4.0"
105
108
  },
106
109
  "lint-staged": {
107
110
  "*.{js,mjs,cjs}": [
@@ -112,6 +115,12 @@
112
115
  "*.md": [
113
116
  "prettier --write",
114
117
  "prettier --check"
118
+ ],
119
+ "*.sh": [
120
+ "pipx run --spec shellcheck-py==0.11.0.1 shellcheck"
121
+ ],
122
+ ".husky/*": [
123
+ "pipx run --spec shellcheck-py==0.11.0.1 shellcheck"
115
124
  ]
116
125
  },
117
126
  "dependencies": {
@@ -124,5 +133,11 @@
124
133
  "re2-wasm": "1.0.2",
125
134
  "re2js": "^2.9.0",
126
135
  "yaml": "^2.9.1"
136
+ },
137
+ "allowScripts": {
138
+ "node-pty": false
139
+ },
140
+ "overrides": {
141
+ "minimatch@>=7 <11": "^11.0.4"
127
142
  }
128
143
  }
package/src/context.js CHANGED
@@ -228,6 +228,9 @@ function quotedRange(text, start, opening, maxLength) {
228
228
  }
229
229
 
230
230
  function plausibleLabel(value, type) {
231
+ if (operationalValue(value, type)) {
232
+ return false;
233
+ }
231
234
  if (type === 'PERSON') {
232
235
  return plausiblePerson(value.replace(/[,;\d].*$/s, '').trim());
233
236
  }
@@ -240,6 +243,20 @@ function plausibleLabel(value, type) {
240
243
  return true;
241
244
  }
242
245
 
246
+ function operationalValue(value, type) {
247
+ return (
248
+ (type === 'URL' &&
249
+ /^\/(?!\/)[^\s?@#]*$/.test(value) &&
250
+ !/(?:^|\/)(?:u|in|users?|profiles?|people|members?|home)\/[^/]+/i.test(
251
+ value
252
+ )) ||
253
+ (type === 'TIME' &&
254
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
255
+ value
256
+ ))
257
+ );
258
+ }
259
+
243
260
  // Shared by native labels and the vendor catalogs. Code expressions and
244
261
  // line escapes are not personal field values, even when a key is familiar.
245
262
  export function personalValueAllowed(text, range, type, labelStart) {
@@ -376,19 +393,24 @@ function detectCredentials(text, emit) {
376
393
  }
377
394
  }
378
395
 
379
- function detectPersonalLabels(text, emit) {
380
- for (const [type, words] of Object.entries(labels)) {
381
- if (type === 'credential') {
382
- continue;
383
- }
396
+ const personalPrefixes = Object.entries(labels)
397
+ .filter(([type]) => type !== 'credential')
398
+ .map(([type, words]) => {
384
399
  const separator =
385
400
  type === 'ID'
386
401
  ? `(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))?[ \\t]+|[:=:][ \\t]*`
387
402
  : `(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`;
388
- const prefix = new RegExp(
389
- `(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:${words.map(escapePattern).join('|')})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?[ \\t]*(?:${separator})`,
390
- 'giu'
391
- );
403
+ return [
404
+ type,
405
+ new RegExp(
406
+ `(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:${words.map(escapePattern).join('|')})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?[ \\t]*(?:${separator})`,
407
+ 'giu'
408
+ ),
409
+ ];
410
+ });
411
+
412
+ function detectPersonalLabels(text, emit) {
413
+ for (const [type, prefix] of personalPrefixes) {
392
414
  for (const match of text.matchAll(prefix)) {
393
415
  const identity = type === 'ID';
394
416
  const suffix = identity
@@ -45,10 +45,26 @@ export function detectCredentialFormats(text, emit) {
45
45
  (value) =>
46
46
  !credentialPlaceholder(value) && value !== 'put your unique phrase here'
47
47
  );
48
- collectMatches(
49
- text,
50
- /PuTTY-User-Key-File-[23]:[^\r\n]*(?:\r\n|\r|\n)[\s\S]*?(?:Private-MAC:[ \t]*[a-fA-F0-9]+|$)/g,
51
- emit,
52
- details('putty-private-key')
53
- );
48
+ detectPuttyPrivateKeys(text, emit);
49
+ }
50
+
51
+ // Scan each consumed range once. A regex combining an unbounded header line
52
+ // and a lazy body rescans every repeated header when the newline is missing.
53
+ function detectPuttyPrivateKeys(text, emit) {
54
+ const headers = /PuTTY-User-Key-File-[23]:/g;
55
+ const lineEnd = /[\r\n]/g;
56
+ const mac = /Private-MAC:[ \t]*[a-fA-F0-9]+/g;
57
+ let header;
58
+ while ((header = headers.exec(text))) {
59
+ lineEnd.lastIndex = headers.lastIndex;
60
+ const newline = lineEnd.exec(text);
61
+ if (!newline) {
62
+ break;
63
+ }
64
+ mac.lastIndex = newline.index + 1;
65
+ const terminator = mac.exec(text);
66
+ const end = terminator ? mac.lastIndex : text.length;
67
+ emit({ start: header.index, end, ...details('putty-private-key') });
68
+ headers.lastIndex = end;
69
+ }
54
70
  }
package/src/detection.js CHANGED
@@ -58,11 +58,14 @@ export function collectMatches(
58
58
  group = 0,
59
59
  accept = () => true
60
60
  ) {
61
- // Clone expressions so concurrent callers never share lastIndex.
62
- const pattern = new RegExp(
63
- regex.source,
64
- regex.flags.includes('g') ? regex.flags : `${regex.flags}g`
65
- );
61
+ // matchAll clones global expressions and leaves their lastIndex untouched.
62
+ const pattern =
63
+ regex.global && regex.lastIndex === 0
64
+ ? regex
65
+ : new RegExp(
66
+ regex.source,
67
+ regex.global ? regex.flags : `${regex.flags}g`
68
+ );
66
69
  for (const match of text.matchAll(pattern)) {
67
70
  const raw = match[group];
68
71
  const value = details.category === 'personal' ? raw?.trim() : raw;
@@ -185,7 +188,9 @@ export function nativeDetect(text, options, emit) {
185
188
  emit,
186
189
  { ...credential, rule: 'quoted-entropy' },
187
190
  1,
188
- (value) => entropy(value) >= (/^[a-f0-9]+$/i.test(value) ? 3 : 4.5)
191
+ (value) =>
192
+ !/^[a-f0-9]{40}$/i.test(value) &&
193
+ entropy(value) >= (/^[a-f0-9]+$/i.test(value) ? 3 : 4.5)
189
194
  );
190
195
  collectMatches(
191
196
  text,
@@ -3,6 +3,7 @@ import { firstNames } from './names.js';
3
3
  import { organizationCore, personalRegex } from './personal-variants.js';
4
4
  import { documentVocabulary } from './document-vocabulary.js';
5
5
  import { months } from './personal-context.js';
6
+ import { detectDocumentPatterns } from './document-patterns.js';
6
7
 
7
8
  const words = (column) =>
8
9
  [...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
@@ -87,6 +88,7 @@ export function detectDocumentEntities(text, emit, options) {
87
88
  if (!options.documentEntities) {
88
89
  return;
89
90
  }
91
+ detectDocumentPatterns(text, emit);
90
92
  const found = [];
91
93
  const capture = (finding) => {
92
94
  found.push(finding);
@@ -0,0 +1,245 @@
1
+ import { firstNames } from './names.js';
2
+ import { isNameForm } from './russian-names.js';
3
+
4
+ const boundary = '[\\p{L}\\p{N}_]';
5
+ const word = '[А-ЯЁ][а-яё-]{1,47}';
6
+ const surname =
7
+ '[А-ЯЁа-яё][А-ЯЁа-яё-]{1,28}(?:ов|ев|ин|ын|ск|цк)(?:а|ой|ою|у|ым|ом|е|ы|и|их|ий|ая|ого|ому|ую|ие)?';
8
+ const initials = '(?:[А-ЯЁA-Z]\\.[ \\t]*){1,2}';
9
+ const latinSurname = '[A-Z][a-z]{1,28}(?:ova|eva|ov|ev|in|ina|sky|ski|skaya)';
10
+ const given = [
11
+ ...new Set(Object.values(firstNames).flatMap((s) => s.split(' '))),
12
+ ].join('|');
13
+ const nameInitials = new RegExp(
14
+ `(?<!${boundary})(?:${word}[ \\t]+(?:[А-ЯЁ]\\.[ \\t]*){2}|${surname}[ \\t]+${initials}|${initials}(?:${surname}|${latinSurname})|${latinSurname}[ \\t]+${initials}|(?:${given})[ \\t]+[А-ЯЁA-Z]\\.)(?!${boundary})`,
15
+ 'gu'
16
+ );
17
+ const namePairs = new RegExp(
18
+ `(?<!${boundary})(${surname})[ \\t]+([А-ЯЁа-яё]{2,32})|(?<!${boundary})([А-ЯЁа-яё]{2,32})[ \\t]+(${surname})(?!${boundary})`,
19
+ 'gu'
20
+ );
21
+ // Every rule is finite and requires either an identifying structure or a label.
22
+ // Public locations/ambiguous bare words are not inferred from arbitrary prose.
23
+ const rules = [
24
+ [
25
+ 'PHONE',
26
+ 'document-phone',
27
+ /(?<![\p{L}\p{N}_])(?:[78][ .-]?)?9\d{2}(?:[ .-]\d{3}[ .-]\d{2}[ .-]\d{2})(?!\d)|(?<![\p{L}\p{N}_])8[ .-]?\(9\d{2}\)[ .-]?\d{3}[ .-]?\d{2}[ .-]?\d{2}(?!\d)/gu,
28
+ 0,
29
+ ],
30
+ [
31
+ 'PHONE',
32
+ 'document-phone-label',
33
+ /(?<![\p{L}\p{N}_])(?:(?:контактный[ \t]+)?телефон|тел\.?|моб\.?|сот\.?|WhatsApp)(?:[ \t]*:[ \t]*|[ \t]+)((?:[78][ () .-]*)?9\d{2}[ () .-]*\d{3}[ .-]*\d{2}[ .-]*\d{2})(?!\d)/giu,
34
+ 1,
35
+ ],
36
+ [
37
+ 'EMAIL',
38
+ 'obfuscated-email',
39
+ /(?<![\p{L}\p{N}_.+\\-])[\p{L}\p{N}_+-]{1,64}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}_+-]{1,64}){0,4}(?:[ \t]{0,8}\((?:at|собака)\)[ \t]{0,8}|[ \t]{0,8}\[at\][ \t]{0,8}|[ \t]{1,8}(?:at|собака)[ \t]{1,8})[\p{L}\p{N}-]{1,63}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}-]{1,63}){1,8}(?![\p{L}\p{N}_])/giu,
40
+ 0,
41
+ ],
42
+ [
43
+ 'USERNAME',
44
+ 'personal-profile-url',
45
+ /(?<![\p{L}\p{N}_./-])(?:https?:\/\/)?(?:www\.)?(?:linkedin\.com\/in|github\.com|vk\.com|instagram\.com|t\.me|wa\.me)\/[\p{L}\p{N}_][\p{L}\p{N}_.-]{1,100}(?:\/[\p{L}\p{N}_.-]{1,100}){0,4}(?![\p{L}\p{N}_])/giu,
46
+ 0,
47
+ ],
48
+ [
49
+ 'USERNAME',
50
+ 'messenger-label',
51
+ /(?<![\p{L}\p{N}_])(?:Telegram|телеграм|телега|tg|skype|Zoom|ник)(?:[ \t]*:[ \t]*|[ \t]+)(@?[a-z][a-z\d_.-]{2,63})(?![\p{L}\p{N}_])/giu,
52
+ 1,
53
+ ],
54
+ [
55
+ 'PASSPORT_NUMBER',
56
+ 'passport-series',
57
+ /(?<![\p{L}\p{N}_])(?:серия|series)[ \t]+(\d{2}[ \t]?\d{2}[ \t]+(?:№|No|номер|number|#)[ \t]*\d{6})(?!\d)/giu,
58
+ 1,
59
+ ],
60
+ [
61
+ 'ID',
62
+ 'identity-number',
63
+ /(?<![\p{L}\p{N}_])(?:ОГРНИП[ \t]+(\d{15})|код подразделения[ \t]+(\d{3}-\d{3})|(?:водительское удостоверение|в\/у|Driver's licen[cs]e)[ \t]+(\d{2}[ \t]?\d{2}[ \t]?\d{6})|(?:ОМС|полис)[ \t]+(\d{16})|(?:сч[её]т|р\/с)[ \t]+(\d{20})|диплом[ \t]+(?:№|No|#)[ \t]*(\d{6}))(?!\d)/giu,
64
+ -1,
65
+ ],
66
+ [
67
+ 'ID',
68
+ 'passport-issuer',
69
+ /(?<![\p{L}\p{N}_])выдан[ \t]+((?:ОВД|УВД|МВД|(?:ТП[ \t]+)?УФМС)[ \t]+[^\r\n";|]{1,120}?\d{2}\.\d{2}\.\d{4})(?!\d)/giu,
70
+ 1,
71
+ ],
72
+ [
73
+ 'EDUCATION',
74
+ 'numbered-school',
75
+ /(?<![\p{L}\p{N}_])(?:школа|гимназия|лицей|колледж)[ \t]*(?:№|No|#|:)[ \t]*\d{1,4}(?!\d)/giu,
76
+ 0,
77
+ ],
78
+ [
79
+ 'EDUCATION',
80
+ 'school-acronym-context',
81
+ /(?<![\p{L}\p{N}_])(?:образование|ВУЗ|университет|окончил[аи]?)(?:[ \t]*:[ \t]*|[ \t]+)([А-ЯЁ]{2,12}(?:[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{2,40})?)(?![\p{L}\p{N}_])/giu,
82
+ 1,
83
+ ],
84
+ [
85
+ 'EDUCATION',
86
+ 'education-label',
87
+ /(?<![\p{L}\p{N}_])Education[ \t]*:[ \t]*([A-Z][A-Za-z.-]{1,47})(?![\p{L}\p{N}_])/gu,
88
+ 1,
89
+ ],
90
+ [
91
+ 'PERSONAL_ATTRIBUTE',
92
+ 'profile-prose',
93
+ /(?<![\p{L}\p{N}_])(?:граждан(?:ин|ка)[ \t]+([А-ЯЁ]{2,4})|есть[ \t]+((?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)))(?![\p{L}\p{N}_])/gu,
94
+ -1,
95
+ ],
96
+ ['PERSON', 'name-initials', nameInitials, 0],
97
+ [
98
+ 'PERSON',
99
+ 'name-title',
100
+ new RegExp(
101
+ `(?<!${boundary})(?:г-жа|г-н|господин|госпожа)[ \\t]+(${word})(?!${boundary})`,
102
+ 'gu'
103
+ ),
104
+ 1,
105
+ ],
106
+ [
107
+ 'DATE_OF_BIRTH',
108
+ 'birth-year',
109
+ /(?<![\p{L}\p{N}_])(?:д\.р\.|г\.р\.|год рождения|родил(?:ся|ась)(?:[ \t]+в)?|Born(?:[ \t]+in)?)[ \t]*:?[ \t]*((?:19|20)\d{2})(?!\d)|(?<!\d)((?:19|20)\d{2})(?=[ \t]+(?:г\.р\.|года[ \t]+рождения)(?![\p{L}\p{N}_]))/giu,
110
+ -1,
111
+ ],
112
+ [
113
+ 'AGE',
114
+ 'age-context',
115
+ /(?<![\p{L}\p{N}_])(?:(?:возраст|Age)(?:[ \t]*:[ \t]*|[ \t]+)|(?:мне|исполнилось)[ \t]+)(\d{1,3})(?!\d)|(?<![\p{L}\p{N}_])(?:Женщина|Мужчина),[ \t]*(\d{1,3})(?=[ \t]+(?:года|год|лет)(?![\p{L}\p{N}_]))/giu,
116
+ -1,
117
+ ],
118
+ [
119
+ 'GENDER',
120
+ 'gender-context',
121
+ /(?<![\p{L}\p{N}_])(?:Пол|Sex)(?:[ \t]*:[ \t]*|[ \t]+)(женский|мужской|жен\.|муж\.|Ж|М|male|female)(?![\p{L}\p{N}_])|(?<![\p{L}\p{N}_])(Женщина|Мужчина)(?=,[ \t]*\d{1,3}[ \t]+(?:года|год|лет))/giu,
122
+ -1,
123
+ ],
124
+ [
125
+ 'PERSONAL_ATTRIBUTE',
126
+ 'profile-attribute',
127
+ /(?<![\p{L}\p{N}_])(?:Гражданство|Citizenship|Семейное положение|Marital status|Дети|национальность|Вероисповедание|инвалидность|судимость)[ \t]*:[ \t]*([^\r\n";|]{1,160})/giu,
128
+ 1,
129
+ ],
130
+ [
131
+ 'ORGANIZATION',
132
+ 'employer-label',
133
+ new RegExp(
134
+ `(?<!${boundary})(?:[Кк]омпания|[Рр]аботодатель|[Мм]есто работы|[Cc]ompany|[Ee]mployer)[ \\t]*:[ \\t]*([\\p{Lu}][\\p{L}\\p{M}.-]{1,47})(?!${boundary})`,
135
+ 'gu'
136
+ ),
137
+ 1,
138
+ ],
139
+ [
140
+ 'ORGANIZATION',
141
+ 'employer-prose',
142
+ new RegExp(
143
+ `(?<!${boundary})(?:компания|работал[аи]?[ \\t]+в|холдинг|[Бб]анк)[ \\t]+(«[^«»\\r\\n]{1,120}»|${word})(?!${boundary})`,
144
+ 'gu'
145
+ ),
146
+ 1,
147
+ ],
148
+ [
149
+ 'ORGANIZATION',
150
+ 'employer-years',
151
+ new RegExp(
152
+ `(?:^|\\n)[ \\t]*(?:19|20)\\d{2}[–—-](?:19|20)\\d{2}[ \\t]+(${word})(?!${boundary})`,
153
+ 'gu'
154
+ ),
155
+ 1,
156
+ ],
157
+ [
158
+ 'LOCATION',
159
+ 'residence-prose',
160
+ new RegExp(
161
+ `(?<!${boundary})(?:(?:живу|проживаю)[ \\t]+в|из|м\\.|метро|район)[ \\t]+(${word})(?!${boundary})`,
162
+ 'gu'
163
+ ),
164
+ 1,
165
+ ],
166
+ [
167
+ 'LOCATION',
168
+ 'postal-code',
169
+ /(?<![\p{L}\p{N}_])индекс[ \t]+(\d{6})(?!\d)|(?<!\d)(\d{6})(?=,[ \t]*[А-ЯЁ][а-яё-]{2,47})/gu,
170
+ -1,
171
+ ],
172
+ ];
173
+
174
+ export function detectDocumentPatterns(text, emit) {
175
+ for (const [type, rule, pattern, group] of rules) {
176
+ for (const match of text.matchAll(pattern)) {
177
+ const value =
178
+ group === -1
179
+ ? match.slice(1).find((part) => part !== undefined)
180
+ : match[group].trim();
181
+ if (
182
+ !documentValueAllowed(rule, value) ||
183
+ (rule === 'profile-attribute' &&
184
+ /^Дети[ \t]*:/iu.test(match[0]) &&
185
+ /^нет$/iu.test(value))
186
+ ) {
187
+ continue;
188
+ }
189
+ const start =
190
+ match.index + (group === 0 ? 0 : match[0].lastIndexOf(value));
191
+ emit({
192
+ start,
193
+ end: start + value.length,
194
+ type,
195
+ rule,
196
+ category: 'personal',
197
+ confidence: group === -1 ? 0.85 : 0.75,
198
+ });
199
+ }
200
+ }
201
+ for (const match of text.matchAll(namePairs)) {
202
+ if (isNameForm(match[2] ?? match[3])) {
203
+ emit({
204
+ start: match.index,
205
+ end: match.index + match[0].length,
206
+ type: 'PERSON',
207
+ rule: 'name-case',
208
+ category: 'personal',
209
+ confidence: 0.8,
210
+ });
211
+ }
212
+ }
213
+ // An address at line start or in explicit residence context is personal.
214
+ // Public-place prose with a street address does not supply that context.
215
+ const street =
216
+ /(?:^|\n)[ \t]*((?:ул\.|улица)[ \t]+[А-ЯЁ][а-яё-]{1,40},[ \t]*(?:(?:д\.|дом)[ \t]*)?\d{1,5}(?:[ \t]*,[ \t]*кв\.[ \t]*\d{1,5})?)|проживает по адресу[ \t]+((?:ул\.|улица)[ \t]+[А-ЯЁ][а-яё-]{1,40},[ \t]*(?:(?:д\.|дом)[ \t]*)?\d{1,5}(?:[ \t]*,[ \t]*кв\.[ \t]*\d{1,5})?)/gu;
217
+ for (const match of text.matchAll(street)) {
218
+ const value = match[1] ?? match[2];
219
+ const start = match.index + match[0].lastIndexOf(value);
220
+ emit({
221
+ start,
222
+ end: start + value.length,
223
+ type: 'ADDRESS',
224
+ rule: 'document-street',
225
+ category: 'personal',
226
+ confidence: 0.85,
227
+ });
228
+ }
229
+ }
230
+
231
+ function documentValueAllowed(rule, value) {
232
+ return (
233
+ Boolean(value) &&
234
+ value !== '[REDACTED]' &&
235
+ !(
236
+ rule === 'school-acronym-context' &&
237
+ !/^[А-ЯЁ]{2,12}(?![А-ЯЁа-яё])/.test(value)
238
+ ) &&
239
+ !(
240
+ rule === 'profile-attribute' &&
241
+ /^(?:не указан[оаы]?|not specified|unknown|n\/a)$/iu.test(value)
242
+ ) &&
243
+ !(rule === 'messenger-label' && /^(?:недоступен|unavailable)$/i.test(value))
244
+ );
245
+ }
package/src/engines.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from './placeholders.js';
25
25
  import {
26
26
  structuredBatches,
27
+ structuredValueViews,
27
28
  mapStructuredFindings,
28
29
  verifyStructured,
29
30
  } from './structured.js';
@@ -143,6 +144,21 @@ async function analyzeStructured(
143
144
  )) {
144
145
  findings.push(finding);
145
146
  }
147
+ for (const view of structuredValueViews(batch, options)) {
148
+ const detected = await analyze(
149
+ view.text,
150
+ [],
151
+ true,
152
+ { ...options, structured: undefined },
153
+ nativeFindings === undefined ? undefined : []
154
+ );
155
+ for (const finding of detected) {
156
+ const mapped = view.map(finding);
157
+ if (mapped) {
158
+ findings.push(mapped);
159
+ }
160
+ }
161
+ }
146
162
  }
147
163
  // Native structured inspection validates, locates and reapplies the
148
164
  // same fake-value/public policy to source offsets from all engines.
package/src/identity.js CHANGED
@@ -181,11 +181,11 @@ function detectPassports(text, emit) {
181
181
  });
182
182
  }
183
183
  const booking =
184
- /(?<![\p{L}\p{N}_])(?:booking(?:[ _-]+(?:reference|code))?|reservation(?:[ _-]+code)?|confirmation(?:[ _-]+code)?|бронь|record[ _-]+locator|PNR|код брони|номер брони|бронирование)[ \t]*[:=:]?[ \t]*[`"']?([A-Z0-9]{4,8})(?![\p{L}\p{N}_])/giu;
184
+ /(?<![\p{L}\p{N}_])(?:booking(?:[ _-]+(?:reference|code))?|reservation(?:[ _-]+code)?|confirmation(?:[ _-]+code)?|бронь|record[ _-]+locator|PNR|код брони|номер брони|бронирование)[ \t]*(?:[:=:][ \t]*)?[`"']?([A-Z0-9]{4,8})(?![\p{L}\p{N}_])/giu;
185
185
  const ticket =
186
- /(?<![\p{L}\p{N}_])(?:e[ -]?ticket|ticket[ _-]*(?:number|no)?|номер билета|электронный билет)[ \t]*[:=:]?[ \t]*[`"']?(\d{3}[ -]?\d{10})(?![\p{L}\p{N}_])/giu;
186
+ /(?<![\p{L}\p{N}_])(?:e[ -]?ticket|ticket[ _-]*(?:number|no)?|номер билета|электронный билет)[ \t]*(?:[:=:][ \t]*)?[`"']?(\d{3}[ -]?\d{10})(?![\p{L}\p{N}_])/giu;
187
187
  const visa =
188
- /(?<![\p{L}\p{N}_])(?:e[ -]?visa|visa|номер визы|виза)(?:[ \t_-]+(?:registration|application|number|no|code|номер|код)){0,3}[ \t]*[:=:]?[ \t]*[`"']?([A-Z0-9][A-Z0-9-]{4,31})(?![\p{L}\p{N}_])/giu;
188
+ /(?<![\p{L}\p{N}_])(?:e[ -]?visa|visa|номер визы|виза)(?:[ \t_-]+(?:registration|application|number|no|code|номер|код)){0,3}[ \t]*(?:[:=:][ \t]*)?[`"']?([A-Z0-9][A-Z0-9-]{4,31})(?![\p{L}\p{N}_])/giu;
189
189
  const placeholder =
190
190
  /^(?:NONE|UNKNOWN|NULL|UNDEFINED|REDACTED|EXAMPLE|SAMPLE|PENDING|HOTEL|BOOKING|REFERENCE|REGISTRATION|APPLICATION|NUMBER|CODE|FAILED|ERROR|HTTP|OK|CANCELLED|CONFIRMED|SUCCESS|READY|ACTIVE|CLOSED)$/i;
191
191
  function detectTravel(text, emit) {
package/src/personal.js CHANGED
@@ -224,7 +224,7 @@ export function detectPersonal(text, emit) {
224
224
  );
225
225
  collectMatches(
226
226
  text,
227
- /(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Straße|Strasse|Rue|Calle|Rua)\b(?:,[ \t]+(?:[^\r\n<>]{1,80}?[ \t]+(?:[A-Z]{1,2}\d[A-Z\d]?[ \t]+\d[A-Z]{2}|\d{5}(?:-\d{4})?)\b|(?:[\p{Lu}][\p{Ll}.'-]+[ \t]*){1,4}))?/gu,
227
+ /(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Straße|Strasse|Rue|Calle|Rua)\b(?:,[ \t]+(?:(?:[^ \t\r\n<>][^\r\n<>]{0,79}?[ \t]+)?(?:[A-Z]{1,2}\d[A-Z\d]?[ \t]+\d[A-Z]{2}|\d{5}(?:-\d{4})?)\b|(?:[\p{Lu}][\p{Ll}.'-]+[ \t]*){1,4}))?/gu,
228
228
  emit,
229
229
  details('ADDRESS', 'postal-address')
230
230
  );
package/src/public.js CHANGED
@@ -144,6 +144,7 @@ export function localAddress(value) {
144
144
  }
145
145
  const [a, b] = value.split('.').map(Number);
146
146
  return (
147
+ value === '0.0.0.0' ||
147
148
  a === 127 ||
148
149
  a === 10 ||
149
150
  (a === 172 && b >= 16 && b <= 31) ||
package/src/sanitizer.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from './placeholders.js';
32
32
  import {
33
33
  structuredBatches,
34
+ inspectStructuredValues,
34
35
  mapStructuredFindings,
35
36
  preserveIdentifierKeys,
36
37
  structuredValueRanges,
@@ -392,8 +393,8 @@ function inspectText(text, options = {}) {
392
393
  validateOptions(options);
393
394
  validateInput(text, options);
394
395
  if (options.structured) {
395
- const findings = structuredBatches(text, options).flatMap((batch) =>
396
- mapStructuredFindings(
396
+ const findings = structuredBatches(text, options).flatMap((batch) => [
397
+ ...mapStructuredFindings(
397
398
  batch,
398
399
  inspect(batch.text, {
399
400
  ...options,
@@ -401,8 +402,11 @@ function inspectText(text, options = {}) {
401
402
  findings: [],
402
403
  }),
403
404
  options
404
- )
405
- );
405
+ ),
406
+ ...inspectStructuredValues(batch, options, (value) =>
407
+ inspect(value, { ...options, structured: undefined, findings: [] })
408
+ ),
409
+ ]);
406
410
  return inspectFindingsOnly(text, findings, options);
407
411
  }
408
412
  const findings = [];
package/src/structured.js CHANGED
@@ -280,3 +280,87 @@ export function verifyStructured(text, options) {
280
280
  }
281
281
  }
282
282
  }
283
+
284
+ // Scan decoded strings at their own line start, retaining a map through every
285
+ // JSON escape. Both native and asynchronous engines consume these same views.
286
+ export function* structuredValueViews(
287
+ batch,
288
+ options,
289
+ depth = 0,
290
+ outerMap = (f) => f
291
+ ) {
292
+ for (const entry of batch.entries) {
293
+ const value = JSON.parse(entry.raw);
294
+ const offsets = stringOffsets(entry.raw);
295
+ const map = (finding) => {
296
+ const raw = {
297
+ ...finding,
298
+ start: offsets ? offsets.starts[finding.start] : finding.start + 1,
299
+ end: offsets ? offsets.ends[finding.end - 1] : finding.end + 1,
300
+ };
301
+ if (
302
+ preserveStructuredEntry(
303
+ entry,
304
+ {
305
+ ...raw,
306
+ start: entry.batchStart + raw.start,
307
+ end: entry.batchStart + raw.end,
308
+ },
309
+ batch.text,
310
+ options
311
+ )
312
+ ) {
313
+ return undefined;
314
+ }
315
+ return outerMap({
316
+ ...raw,
317
+ start: entry.start + raw.start,
318
+ end: entry.start + raw.end,
319
+ });
320
+ };
321
+ yield { text: value, map };
322
+ // Match the encoded detector's finite depth; arbitrary nesting is a limit.
323
+ if (depth < 2 && /^[ \t\r\n]*[[{]/.test(value)) {
324
+ let batches;
325
+ try {
326
+ batches = structuredBatches(value, { ...options, structured: 'json' });
327
+ } catch (error) {
328
+ if (error.code !== 'ERR_JSON') {
329
+ throw error;
330
+ }
331
+ batches = [];
332
+ }
333
+ for (const nested of batches) {
334
+ yield* structuredValueViews(nested, options, depth + 1, map);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ export function inspectStructuredValues(batch, options, inspectValue) {
340
+ const result = [];
341
+ for (const view of structuredValueViews(batch, options)) {
342
+ const findings = inspectValue(view.text);
343
+ options.debug?.({ event: 'structured-value', count: findings.length });
344
+ for (const finding of findings) {
345
+ const mapped = view.map(finding);
346
+ if (mapped) {
347
+ result.push(mapped);
348
+ }
349
+ }
350
+ }
351
+ return result;
352
+ }
353
+ function stringOffsets(raw) {
354
+ if (!raw.includes('\\')) {
355
+ return undefined;
356
+ }
357
+ const starts = [],
358
+ ends = [];
359
+ for (let i = 1; i < raw.length - 1; ) {
360
+ const start = i;
361
+ i += raw[i] === '\\' ? (raw[i + 1] === 'u' ? 6 : 2) : 1;
362
+ starts.push(start);
363
+ ends.push(i);
364
+ }
365
+ return { starts, ends };
366
+ }