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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Redact complete obfuscated email addresses and military IDs with capitalized
8
+ proper-name detection, recognize lowercase employers after employment cues and
9
+ unknown Cyrillic names before colons, and preserve work and skill durations in
10
+ strict mode while keeping ages sensitive.
11
+
12
+ Detect bare Yandex Cloud credentials, Latin names and filenames in explicit personal JSON fields, numeric ages and birth years, and single Latin names after English person cues while preserving workflow headings.
13
+
14
+ ## 0.19.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 54c8095: Add synchronous custom detectors, rule/type controls, configurable document vocabulary, structured field selectors, verified callback transforms and reusable compiled known values. Expand document coverage and add opt-in strict stored-log rules plus a separately loaded offline Russian/English recognizer.
19
+
3
20
  ## 0.18.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -20,6 +20,14 @@ two-pass publication:
20
20
  sensitive-data-sanitizer redact session.jsonl --stream --jsonl --two-pass --output safe.jsonl
21
21
  ```
22
22
 
23
+ For synchronous log hooks, `createSyncSanitizer` supports custom detectors and
24
+ verified callback transformations. Rule/type controls, caller vocabulary,
25
+ structured field selectors and `compileKnown` configure reusable policies; see
26
+ [API examples](docs/API.md#rule-type-and-field-policy) and the
27
+ [runnable log policy](examples/custom-log-policy.mjs). The optional `/ner` entry
28
+ adds offline Russian/English symbolic recognition with
29
+ [published coverage and cost](docs/COVERAGE.md#optional-offline-recognition-measurements).
30
+
23
31
  ## Quick Start
24
32
 
25
33
  For stored prose that can tolerate extra redaction, opt into structural proper
package/docs/API.md CHANGED
@@ -10,9 +10,11 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
10
10
  | `engine.inspect(text)` | Detect, validate, deduplicate, apply public policy, and return metadata |
11
11
  | `engine.sanitize(text)` | Detect, union overlapping spans, mask completely, verify every engine, return `{text, findings, redactions}` |
12
12
  | `engine.sanitizeJsonl(text)` | Batched structured JSONL sanitization using the same options and required engines |
13
- | `inspect(text, options)` | Synchronous native detection and caller findings |
14
- | `sanitize(text, options)` | Synchronous native redaction and native residual verification |
13
+ | `inspect(text, options)` | Synchronous native + array-returning detectors and caller findings |
14
+ | `sanitize(text, options)` | Synchronous native + array-returning detectors and residual verification |
15
15
  | `redact(text, findings, options)` | Apply validated ranges without automatic detection/verification |
16
+ | `createSyncSanitizer(options)` | Reusable synchronous `inspect` / `sanitize` with the same policies |
17
+ | `compileKnown(...lists)` | Copy, freeze, merge and precompile caller-known personal values |
16
18
  | `auditGitHistory(repository, options)` | Read-only audit of local reachable text objects |
17
19
 
18
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).
@@ -156,38 +158,45 @@ derived model answer. Input, output and map paths must be separate. Maps are
156
158
  saved separately before stdout publication. `--profile documents` enables the
157
159
  new document recognizers; `documentEntities: false` turns them off explicitly.
158
160
 
159
- | Option | Default / meaning |
160
- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
- | `documentEntities` | Off; bounded organization, education and location rules; enabled by the documents profile |
162
- | `properNames` | Off; `'capitalized'` enables structural name redaction in activated prose strings |
163
- | `scripts` | `['Cyrillic']`; Unicode Script aliases that activate the prose rule; `[]` disables only script activation |
164
- | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
165
- | `knownOnly` | `false`; disable all automatic detection (including credentials, Secretlint and custom detectors); use only explicit known values and reused map identities |
166
- | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
167
- | `knownPersonal` | `[]`; `{type, value}` values with whole-word Unicode case-insensitive matching and bounded Russian forms; list parts to match them alone |
168
- | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
169
- | `findings` | `[]`; validated detections for the original input, supplied by the caller |
170
- | `detectors` | `[]`; additional required callbacks in the asynchronous engine |
171
- | `secretlint` | `true` in the asynchronous engine |
172
- | `decode` | `true`; bounded base64/base64url, hex and percent decoding |
173
- | `paranoid` | `false`; entropy-based supplemental credential heuristic |
174
- | `maxInputLength` | 33,554,432 UTF-16 units |
175
- | `maxFindings` | 100,000 detector hits before deduplication |
176
- | `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
177
- | `threshold` | Alias for minConfidence; both options must agree when supplied |
178
- | `debug` | Optional trusted callback for value-free counts, timing and event metadata |
179
- | `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
180
- | `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
181
- | `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
182
- | `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
183
- | `sensitiveFields` | `[]`; JSON/JSONL names normalized for case and `_`/`-`; complete values become a redaction string; supports synchronous calls |
184
- | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
161
+ | Option | Default / meaning |
162
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
163
+ | `documentEntities` | Off; bounded organization, education and location rules; enabled by the documents profile |
164
+ | `properNames` | Off; `'capitalized'` enables structural name redaction in activated prose strings |
165
+ | `scripts` | `['Cyrillic']`; Unicode Script aliases that activate the prose rule; `[]` disables only script activation |
166
+ | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
167
+ | `knownOnly` | `false`; disable all automatic detection (including credentials, Secretlint and custom detectors); use explicit findings, known values and reused map identities |
168
+ | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
169
+ | `knownPersonal` | `[]`; `{type, value}` values with whole-word Unicode case-insensitive matching and bounded Russian forms; list parts to match them alone |
170
+ | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
171
+ | `findings` | `[]`; validated detections for the original input, supplied by the caller |
172
+ | `detectors` | `[]`; required callbacks in both engines; sync methods require arrays |
173
+ | `secretlint` | `true` in the asynchronous engine |
174
+ | `decode` | `true`; bounded base64/base64url, hex and percent decoding |
175
+ | `paranoid` | `false`; entropy-based supplemental credential heuristic |
176
+ | `maxInputLength` | 33,554,432 UTF-16 units |
177
+ | `maxFindings` | 100,000 detector hits before deduplication |
178
+ | `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
179
+ | `threshold` | Alias for minConfidence; both options must agree when supplied |
180
+ | `debug` | Optional trusted callback for value-free counts, timing and event metadata |
181
+ | `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
182
+ | `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
183
+ | `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
184
+ | `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
185
+ | `sensitiveFields` | `[]`; JSON/JSONL names normalized for case and `_`/`-`; complete values become a redaction string; supports synchronous calls |
186
+ | `exactFields` | `false`; sensitive/allowed selectors use exact case/punctuation when true |
187
+ | `allowedFields` | Off; redact unlisted JSON/JSONL string leaves, scan listed leaves normally |
188
+ | `rules` / `types` | Per-rule / per-type booleans for automatic built-in findings |
189
+ | `fields` | `{location: 'scan'}` skips whole-field inference and still scans content |
190
+ | `allowCredentialDisable` | `false`; explicit permission required to suppress credential rules/types |
191
+ | `vocabulary` | Optional additive document cue lists; see below |
192
+ | `strict` | `false`; aggressive stored-log personal attributes and bare values |
193
+ | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
185
194
 
186
195
  Unknown options and invalid limits are errors. `knownSecretsFromEnv(env, names?)` discovers credential-named variables (excluding auth configuration names) when names are omitted, or uses an explicit selection, deduplicating nonempty values. It never invokes shell commands or reads auth files. `personalVariants(entries)` generates numeric and ISO/day-first date spellings; name case variants are already handled by detection. Neither helper invents transliterations.
187
196
 
188
197
  ## Required detectors
189
198
 
190
- A detector supplies `detect(text)`, returning an array of `{start, end, type, category, rule}` or a Promise of that array. Every range must refer to the exact input passed to that invocation. `category` is `credential` or `personal`; types are uppercase identifiers, rules are bounded lowercase identifiers. Throw if analysis is incomplete. The framework invokes engines on original input, normalized input when changed, bounded decoded views, and finally the sanitized output. Decoded matches redact the entire original encoded run. Malformed findings and engine errors block publication with generic error codes.
199
+ A detector supplies `detect(text)`, returning an array of `{start, end, type, category, rule}` or, in `createSanitizer`, a Promise of that array. Sync `inspect`, `sanitize` and `createSyncSanitizer` require an array; returning a thenable throws `ERR_CONFIG` (its rejection is observed). Every range must refer to the exact input passed to that invocation. `category` is `credential` or `personal`; types are uppercase identifiers, rules are bounded lowercase identifiers. Throw if analysis is incomplete. The framework invokes engines on original input, normalized input when changed, bounded decoded views, and finally the sanitized output. Structured callbacks receive each decoded string separately, including escaped Unicode and nested serialized strings, with mapped original offsets. Decoded matches redact the entire original encoded run. Malformed findings and engine errors block publication with generic error codes.
191
200
 
192
201
  Callbacks, policies, binaries, model packages, and their rule identifiers are trusted code/configuration. A malicious adapter can omit findings, leak input through its own logging/network calls, or never resolve. Local subprocess helpers use 30-second termination and 10 MiB output caps; arbitrary callback time budgets belong to the embedding application. Pin binary/model versions in deployments. Do not convert a failed scan into an empty result.
193
202
 
@@ -486,10 +495,10 @@ In structured JSON/JSONL, `sensitiveFields: string[]` ignores case and removes
486
495
  underscores and hyphens at any depth, like built-in document fields. For example,
487
496
  `fooBar` also matches `foo_bar`, `FooBar` and `foo-bar`. The complete property value becomes
488
497
  `"[REDACTED]"`, including empty strings, numbers, booleans, null, arrays and
489
- objects. This deliberate schema policy can change value types. It takes
498
+ objects. `exactFields: true` preserves exact case and punctuation in selectors. A single name matches at any depth; dotted paths such as `a.b` match from the root, and `items.*.name` matches array elements (or one object segment). This deliberate schema policy can change value types. It takes
490
499
  priority over `structuralFields` and transformations, preserves property names
491
500
  and surrounding whitespace, and works in both `sanitize` and `createSanitizer`.
492
- It has no effect without `structured`. The personal-data switch
501
+ `allowedFields: ['msg', 'level']` instead replaces every unlisted **string leaf**, including empty strings, while retaining keys, numeric/boolean/null primitives and containers. Listed strings still undergo normal detection. It supports the same paths, wildcard and exact matching; `sensitiveFields` takes priority on overlap. These selectors have no effect without `structured`. The personal-data switch
493
502
  `credentialsOnly` still suppresses personal findings.
494
503
 
495
504
  ```js
@@ -508,8 +517,7 @@ sanitize('Lyutik работал', {
508
517
 
509
518
  `knownPersonal` is the synchronous custom-value list, including names without
510
519
  context. For computed synchronous detection, callers can supply validated
511
- `findings` for the exact input. `detectors` remains an asynchronous-engine
512
- option; `sanitize` does not invoke these callbacks.
520
+ `findings` for the exact input. `detectors` is supported in both engines; synchronous methods reject Promise-returning callbacks.
513
521
 
514
522
  ## Structural prose redaction
515
523
 
@@ -536,9 +544,11 @@ are case-sensitive runtime Unicode property aliases, not language codes.
536
544
  Inside activated values, the rule replaces words containing an uppercase letter
537
545
  anywhere (`iPhone`, `A.B`, `Санкт-Петербург`), typographic quoted spans (including
538
546
  lowercase `«ромашка лютик»`), links including `example.org/path`, and bare domestic
539
- mobile shapes. Labels immediately before a colon (also `Label :`), lowercase
540
- words, numbers, date ranges and path tokens stay. A path can still match existing
541
- personal-path or credential rules. Numeric primitives and JSON keys stay; string
547
+ mobile shapes. Known Cyrillic labels before a colon (`Опыт:`, `Стаж:`,
548
+ `Навыки:`) and non-Cyrillic labels (also `Label :`) stay. Unknown capitalized
549
+ Cyrillic words such as `Ромашка: менеджер` are replaced before the colon.
550
+ Lowercase words, numbers, date ranges and path tokens stay. A path can still match
551
+ existing personal-path or credential rules. Numeric primitives and JSON keys stay; string
542
552
  escapes and surrounding source formatting are retained. JSON/JSONL activation
543
553
  also applies independently within serialized JSON strings, to the existing
544
554
  bounded decoding depth. Findings do not seed document-wide name propagation.
@@ -547,8 +557,10 @@ produces stable tokens and exact restoration; the existing placeholder-namespace
547
557
  collision checks still reject input that already contains typed placeholders.
548
558
 
549
559
  This is a structural heuristic, so capitalized ordinary words also disappear.
550
- Lowercase organizations and `Ромашка: менеджер` stay. English prose without a
551
- trigger (`Worked at Romashka for three years.`), isolated neutral-key values
560
+ Lowercase organizations without employment context stay; the document rules
561
+ replace a lowercase employer after `работал в` / `работала в`. Complete contact
562
+ and identifier findings take priority over overlapping proper-name fragments.
563
+ English prose without a trigger (`Worked at Romashka for three years.`), isolated neutral-key values
552
564
  `{"note":"Romashka"}` / `{"note":"1991-03-12"}`, and a personal website root
553
565
  without a trigger stay under this rule. The existing detectors and explicit
554
566
  known/sensitive-field policies still run: an unactivated value is byte-for-byte
@@ -559,9 +571,174 @@ The new scanner uses a fixed number of forward passes over maximal tokens,
559
571
  including long tokens without spaces. This does not impose a linear-time bound
560
572
  on optional external engines or every preexisting detection rule. Debugging is
561
573
  off by default; `debug` receives value-free `proper-name-prose` and
562
- `document-experience` hit counts when those scans run.
574
+ `document-experience` hit counts when those scans run. `document-prose-overlap`
575
+ reports complete-contact/identifier and suppressed broad-span counts.
563
576
 
564
577
  CLI equivalents: `--proper-names capitalized --scripts Cyrillic,Greek`.
565
578
  Configuration files accept the same `properNames` and `scripts` API options;
566
579
  use a configuration file for `scripts: []`. See the runnable
567
580
  [prose-log example](../examples/prose-logs.mjs).
581
+
582
+ ## Rule, type and field policy
583
+
584
+ `builtInRules` is a frozen list of accepted stable `Finding.rule` ids; the complete
585
+ [rule reference](RULES.md) is generated from the bundled catalog. Unknown ids
586
+ throw `ERR_CONFIG`. For example:
587
+
588
+ ```js
589
+ sanitize('Работала в компании Ромашка', {
590
+ rules: { 'organization-context': false },
591
+ });
592
+ sanitize('{"location":"apps/api"}', {
593
+ structured: 'json',
594
+ fields: { location: 'scan' },
595
+ });
596
+ ```
597
+
598
+ `types: { ORGANIZATION: false }` suppresses automatic built-in organization
599
+ findings. Independently matching rules can overlap: disabling one rule does not
600
+ suppress another rule's evidence. Explicit `findings` and custom detectors are
601
+ caller policy and remain active. `fields` skips built-in whole-field inference,
602
+ then scans the value normally, so a credential or email inside `location` still
603
+ gets replaced. Field names use normal case/underscore/hyphen normalization.
604
+
605
+ Credential types and rules require `allowCredentialDisable: true` before accepting
606
+ `false`. Credential findings cannot be suppressed through personal type controls
607
+ without that permission. This includes mixed encoded rules and Secretlint. The
608
+ permission intentionally reduces verification coverage; `true` enables an
609
+ already active recognizer and does not load optional detectors.
610
+
611
+ `documentEntities: false` turns off the additional document recognizers. The older
612
+ `organization-context`, `organization-cue`, personal-name/contact and multilingual
613
+ label recognizers still run. Thus `Работала в компании Ромашка` still redacts
614
+ `Ромашка`; use its rule or type control for that policy. `strict` is independent
615
+ of `documentEntities`. Defaults retain the existing policy, with the additional
616
+ catalog formats documented in COVERAGE.
617
+
618
+ ## Caller document vocabulary
619
+
620
+ ```js
621
+ const vocabulary = {
622
+ roles: ['маркетолог'],
623
+ organizationCues: ['артель'],
624
+ educationCues: ['альма-матер'],
625
+ personalLabels: ['личный контакт'],
626
+ personalFields: ['contactPerson'],
627
+ };
628
+ sanitize('работаю в артели Лютик', { profile: 'documents', vocabulary }).text;
629
+ // работаю в артели [REDACTED]
630
+ ```
631
+
632
+ Arrays extend the corresponding list. `{values: [...], replace: true}` replaces
633
+ one list, including an empty list. Other lists retain defaults. Replacement
634
+ changes that list's cue-based recognizers; independent legal forms, dated work
635
+ history, full names, identifiers and known values still count as evidence.
636
+ `defaultVocabulary` exposes frozen default cue lists; `documentVocabulary` exposes
637
+ the frozen multilingual document rows. Neither is modified by an operation.
638
+ Cues ignore case and support bounded Russian noun/adjective forms; personal fields
639
+ use normalized field names. Capitalized proper values are required after prose
640
+ cues. Inputs allow at most 256 nonempty strings of 128 code units per list, with
641
+ no line breaks/NUL. Unknown lists and malformed replacement records fail with
642
+ `ERR_CONFIG`. Compilation uses a bounded 64-configuration cache.
643
+
644
+ ## Callback transformations and stages
645
+
646
+ ```js
647
+ const sync = createSyncSanitizer({
648
+ transformations: { PERSON: (finding) => `Person-${finding.start}` },
649
+ });
650
+ sync.sanitize('Анна Смирнова').text; // Person-0
651
+ ```
652
+
653
+ `transformation(finding)` applies globally; `transformations[type](finding)` takes
654
+ precedence for that type. Each callback receives a frozen finding plus `value`,
655
+ the matched surface (decoded JSON string content when applicable), and must
656
+ return a string. Callbacks receive private data: keep it out of diagnostics.
657
+ JSON replacements are escaped, including partial string spans. Overlapping spans
658
+ are unioned and receive one callback for the selected type. Schema-level
659
+ `JSON_VALUE` denials always produce a complete redaction string.
660
+
661
+ Sync methods reject thenables with `ERR_CONFIG`; the asynchronous engine awaits
662
+ callbacks. Throws block publication. Returning the original, its normalized or
663
+ bounded encoded representation fails with `ERR_RESIDUAL`. The emitted text also
664
+ passes all configured residual detectors, so returning a new email/secret blocks.
665
+ This is bounded heuristic verification, not proof against arbitrary encodings.
666
+ Unlike built-in partial transforms, a callback deliberately controls credential
667
+ replacement too; the same checks apply. `preserveEncoding` recursively transforms
668
+ decoded content then re-encodes it.
669
+
670
+ The stages are detection → validated finding union/policy → transformation →
671
+ residual verification. `redact` exposes only the transformation stage and does not
672
+ perform residual scanning. To replace automatic detection while keeping the
673
+ sanitizer's verification step:
674
+
675
+ ```js
676
+ const input = 'private customer name';
677
+ const findings = myDetector.detect(input); // exact UTF-16 offsets
678
+ const result = sanitize(input, {
679
+ knownOnly: true,
680
+ findings,
681
+ knownSecrets: ['explicit-private-token'],
682
+ });
683
+ ```
684
+
685
+ `knownOnly` disables automatic detectors during both detection and verification;
686
+ only configured known values and findings remain. For custom detector verification,
687
+ pass it in `detectors` with automatic detection enabled, and tune built-in rules
688
+ as needed. `createSyncSanitizer(options)` implements the exported `SyncSanitizer`
689
+ interface (`inspect` / `sanitize`); Promise-returning detectors/transforms need
690
+ `createSanitizer` instead.
691
+
692
+ ## Compiled caller-known values
693
+
694
+ ```js
695
+ const known = compileKnown(
696
+ [{ type: 'ORGANIZATION', value: 'Ромашка' }],
697
+ [{ type: 'URL', value: 'https://example.org/anna' }]
698
+ );
699
+ const merged = compileKnown(known, [{ type: 'LOCATION', value: 'Тверь' }]);
700
+ sanitize('Живу в Твери', { knownPersonal: merged });
701
+ ```
702
+
703
+ Compilation copies only `{type, value, properCase?}`, deduplicates and freezes the
704
+ entries/array, and precompiles normalized projections and matching expressions.
705
+ There is no process-wide registry of caller values; weak metadata is released
706
+ with the compiled list. Mutating the input cannot change it. Compiled arrays
707
+ work with the same APIs as plain arrays, with identical output.
708
+
709
+ | Type | Matching behavior |
710
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
711
+ | `PERSON` | Unicode whole-word, case-insensitive; Russian cases, ё/е, reversed two-part names, supported full-name orders/initials and contiguous parts; list a part separately to match it alone |
712
+ | `ORGANIZATION` | Russian word forms, legal-form/quote-free core for names of at least three characters |
713
+ | `EDUCATION`, `LOCATION` | Whole-word case-insensitive with bounded Russian noun/adjective forms (e.g. МГУ / мгу, Тверь / Твери) |
714
+ | `URL` | Optional HTTP(S) scheme; query, fragment and trailing slash are consumed with the known path |
715
+ | `DATE`, `DATE_OF_BIRTH`, `PHONE` | Literal spellings by default; use `personalVariants(entries)` before compilation for ISO/day-first date and numeric phone variants |
716
+ | Other uppercase type ids | Same whole-word case-insensitive and bounded Russian word forms; type labels the finding/transform and does not activate a new parser |
717
+
718
+ No helper invents transliterations. Values longer than 256 code units use literal
719
+ matching instead of word-form expansion. `properCase: true` rejects a lower-case
720
+ first character (used by reused map identities). Explicit private values remain
721
+ private despite public exceptions. The 500-entry measurement and reproduction
722
+ script are linked from COVERAGE; matching remains linear in list size.
723
+
724
+ ## Optional offline recognition
725
+
726
+ Install `@nlpjs/ner@4.27.0` alongside this package, then load the separate entry:
727
+
728
+ ```js
729
+ import { createNerDetector } from '@link-foundation/sensitive-data-sanitizer/ner';
730
+ const detector = await createNerDetector({ languages: ['ru', 'en'] });
731
+ const sync = createSyncSanitizer({ detectors: [detector] });
732
+ sync.sanitize('Worked at Romashka for three years.');
733
+ ```
734
+
735
+ The factory loads the optional peer dependency once. Detection is synchronous,
736
+ in process and offline, with no model download or subprocess. This recognizer
737
+ uses NLP.js regex extraction with finite dictionaries and context rules; it is
738
+ not a neural model. It supports PERSON, ORGANIZATION, EDUCATION and LOCATION in
739
+ Russian/English. Unknown cue-free names can remain; see the published exact-span
740
+ precision/recall, challenge misses and per-KiB cost in COVERAGE. `languages` selects
741
+ one or both languages. Each invocation is limited to 1 Mi UTF-16 units; larger
742
+ inputs throw `ERR_LIMIT`. `debug` is off by default and emits counts only. Core
743
+ imports never load the optional entry or NLP.js; leaving it off adds no optional
744
+ runtime/module cost.
package/docs/COVERAGE.md CHANGED
@@ -218,7 +218,9 @@ The `documents` profile additionally recognizes:
218
218
  - Russian mobile numbers with dashed, dotted, spaced or parenthesized forms,
219
219
  including ten-digit `9xx` forms and domestic `8` prefixes; `тел.`, `моб.` and
220
220
  WhatsApp labels; `wa.me` contact links.
221
- - Obfuscated email separators `(at)`, `[at]`, `собака`, and English `dot`/`at`.
221
+ - Obfuscated email separators `(at)`, `[at]`, `собака`, and English `dot`/`at`,
222
+ including `[dot]`, `(dot)`, `(точка)` and `[точка]` with optional spaces.
223
+ Complete addresses remain protected alongside capitalized proper-name matches.
222
224
  This is a finite spelling grammar, not arbitrary email deobfuscation.
223
225
  - Personal paths on LinkedIn (`/in/`), GitHub, VK, Instagram and Telegram,
224
226
  including scheme-less links; Telegram/телеграм/tg/skype/ник labels without `@`.
@@ -243,16 +245,17 @@ The `documents` profile additionally recognizes:
243
245
  - Company/employer/workplace labels, capitalized company/holding/bank names,
244
246
  employment prose and year-range employer lines; residence/metro/district
245
247
  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.
248
+ - Russian street-plus-house/flat structures, including inline addresses added
249
+ by #117. `Музей находится на ул. Ленина, д. 5, кв. 12` now removes the full
250
+ street/house/apartment span. Public addresses have the same shape as personal
251
+ addresses and may be flagged; inspect them before publication.
251
252
 
252
253
  Explicit limits accepted for the ambiguous examples in #82/#83:
253
- `Спасибо, Анна!`, `Анна, добрый день!`, `Менеджер Анна` and `Лютик, менеджер`
254
+ `Спасибо, Анна!`, `Анна, добрый день!` and `Менеджер Анна`
254
255
  remain unchanged without known values or additional context. Bare given names
255
256
  and unmarked employer/title fragments cannot reliably establish identity.
257
+ The reversed employer cue `Лютик, менеджер` now redacts `Лютик`, as requested
258
+ by #117. English `Worked at Romashka` now redacts the employer under #115.
256
259
  `зарплата 150 000 руб.` and `Salary: 150000` also remain unchanged: salary is
257
260
  outside the native document rules. Supply `knownPersonal` or an additional
258
261
  recognizer when these fields must be removed. Initial/name rules do not cover
@@ -360,7 +363,8 @@ alone cannot establish that the site belongs to a person.
360
363
 
361
364
  Structured document field aliases remove `_`/`-` and ignore case before matching
362
365
  this explicit vocabulary: `firstName`, `lastName`, `middleName`, `patronymic`,
363
- `birthday`, `birthDate`, `sex`, `citizenship`, `nationality`, `company`,
366
+ `candidateName`, `displayName`, `fullName`, `originalFileName`,
367
+ `age`, `birthYear`, `birthday`, `birthDate`, `sex`, `citizenship`, `nationality`, `company`,
364
368
  `companyName`, `employer`, `university`, `school`, `contact`, `resumeUrl`,
365
369
  `alternateUrl`, `profileUrl`, `companyUrl`. Their nonempty string values are
366
370
  sensitive in full, including ISO birth timestamps. Thus `first_name` and
@@ -369,8 +373,9 @@ schema assumptions: `contact` or `company` can hold operational data too.
369
373
  Existing native labels still apply. Unlisted URL-named fields have ordinary
370
374
  content detection; arbitrary names do not imply sensitivity. Declare them with
371
375
  [`sensitiveFields`](API.md#caller-declared-sensitive-fields) to replace complete
372
- values, including non-string values. Generic numeric primitives otherwise keep
373
- the existing type-preservation policy.
376
+ values, including non-string values. Numeric `age` and `birthYear` values are replaced by the JSON string
377
+ `"[REDACTED]"`; unrelated numeric primitives retain their types. Bare `name`,
378
+ `jobName` and `productName` remain ambiguous and use content detection.
374
379
 
375
380
  Employer labels consume multi-word values to a comma or line/quote boundary.
376
381
  Dated CV entries additionally support compound titles, title parentheses/slashes,
@@ -386,8 +391,7 @@ Company prose recognizes `компании «…»`, `опыт в`, `Роль в
386
391
  capitalized lists after those cues, `опыт (Company Name)`, and names followed
387
392
  by `(2 года)`/`(3 года)`-style durations. Arbitrary uncued company words remain
388
393
  ambiguous; synchronous callers can supply `knownPersonal` lists or precomputed
389
- `findings`. Custom `detectors` callbacks are still used by `createSanitizer`
390
- only. No model or network service is required for these native rules.
394
+ `findings`. Array-returning custom `detectors` also run in synchronous methods; Promise-returning callbacks require `createSanitizer`. No model or network service is required for these native rules.
391
395
 
392
396
  `контакт:` and `пишите,` introduce domestic mobile numbers. A complete line
393
397
  consisting of an 11-digit `79…` or `89…` mobile shape is also sensitive. Arbitrary
@@ -438,9 +442,11 @@ now use the same case/underscore/hyphen normalization as built-in document names
438
442
  For no-cue prose, enable [`properNames: 'capitalized'`](API.md#structural-prose-redaction).
439
443
  It activates per value, defaults to Cyrillic script letters or structural data
440
444
  triggers, and supports configurable Unicode scripts. It intentionally loses
441
- capitalized ordinary words. Labels, lowercase words, dates, paths, JSON keys and
442
- untriggered values are outside this additional rule. English untriggered prose,
443
- lowercase names and a name immediately before a colon remain documented limits.
445
+ capitalized ordinary words. Known Cyrillic labels, non-Cyrillic labels, lowercase
446
+ words, dates, paths, JSON keys and untriggered values are outside this additional
447
+ rule. Unknown capitalized Cyrillic words before colons are protected. English
448
+ untriggered prose and lowercase names without context remain documented limits;
449
+ an explicit `работал в` / `работала в` cue also protects lowercase employers.
444
450
  The API describes the interaction with existing detectors and placeholder modes.
445
451
 
446
452
  The date-anchor scan visits each preceding entry once; the optional prose scanner
@@ -448,3 +454,104 @@ classifies maximal tokens in fixed passes, avoiding repeated suffix searches on
448
454
  long unspaced input. This is not a latency guarantee for the whole pipeline.
449
455
  [The requirement inventory](case-studies/issue-106/README.md) records alternatives,
450
456
  research, regression evidence and reproducible bounded performance measurements.
457
+
458
+ ## Stored-log policy and expanded document catalog
459
+
460
+ The documents profile now covers the exact positive/control rows of issues
461
+ 115 and 117: spaced/obfuscated contacts, Discord/MAX handles, additional
462
+ personal-link routes, birthday/age cues, military/VIN identifiers, employer and
463
+ school layouts, patronymics/initials/mixed scripts, complete street/house spans,
464
+ regions, citizenship and plates. Regression tests compare complete text output
465
+ and JSON/JSONL output in both engines. `ОГРНИП` without a value, operational logs,
466
+ work durations, weekdays and technologies remain unchanged. Bare `Сергей`,
467
+ `Смирнова`, `Смирновой`, `СПб`, `Питер` and `Вчера Анна написала` deliberately remain
468
+ ambiguous without explicit known values, the optional recognizer or aggressive
469
+ proper-name mode. Romashka Inc keeps its existing legal-form treatment.
470
+
471
+ `strict: true` independently opts into personal attributes (gender, family,
472
+ ethnicity, religion, health, criminal/political status) and bare dates, ages,
473
+ 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
475
+ (including `Стаж: 5 лет`, `работал там 5 лет`, and duration cues from issue 87),
476
+ and parenthesized durations after known technologies such as `Kubernetes (5 лет)`
477
+ and `Power BI (5 лет)`.
478
+ Explicit ages (`кандидату 34 года`, `мне 34 года`, `возраст 41`) and bare strict
479
+ ages remain sensitive. Work and skill duration context is retained through
480
+ redaction, placeholders and generated fake values for stable verification.
481
+ It also preserves
482
+ unit-bearing request timings and `Дети: нет`. In JSON/JSONL, numeric primitives
483
+ stay numeric, and `structuralFields` suppresses strict findings in declared
484
+ string metadata. Credentials/known values still take priority. Strict is off by
485
+ default and does not claim to identify every sensitive attribute or validate
486
+ that every date/number belongs to a real person.
487
+
488
+ ## Optional offline recognition measurements
489
+
490
+ The `/ner` entry uses the separately installed optional
491
+ [NLP.js](https://github.com/axa-group/nlp.js) regex extractor, finite multilingual
492
+ name/place dictionaries and bounded contextual rules. It has synchronous
493
+ inference after `await createNerDetector(...)`. There is no analysis-time network,
494
+ external service, Python process or model download. The core import graph never
495
+ imports `/ner` or NLP.js, so the optional component has no runtime or loading cost
496
+ when disabled. This is symbolic recognition, not general statistical NER; unknown
497
+ cue-free surnames, schools, employers and locations can be missed.
498
+
499
+ [Test set](../experiments/issue-118/ner-corpus.json): 32 synthetic sentences,
500
+ including all six requested positives, eight benign controls and eight challenge
501
+ misses. Exact type and UTF-16 start/end must all match. **All** rows, including
502
+ challenge rows, contribute to these scores. Each language/type has two true
503
+ positives, zero false positives and one false negative:
504
+
505
+ | Language | Type | Precision | Recall | TP / FP / FN |
506
+ | -------- | ------------ | --------- | ------ | ------------ |
507
+ | ru | PERSON | 100% | 66.7% | 2 / 0 / 1 |
508
+ | ru | ORGANIZATION | 100% | 66.7% | 2 / 0 / 1 |
509
+ | ru | EDUCATION | 100% | 66.7% | 2 / 0 / 1 |
510
+ | ru | LOCATION | 100% | 66.7% | 2 / 0 / 1 |
511
+ | en | PERSON | 100% | 66.7% | 2 / 0 / 1 |
512
+ | en | ORGANIZATION | 100% | 66.7% | 2 / 0 / 1 |
513
+ | en | EDUCATION | 100% | 66.7% | 2 / 0 / 1 |
514
+ | en | LOCATION | 100% | 66.7% | 2 / 0 / 1 |
515
+
516
+ These small authored regression scores do not estimate accuracy on arbitrary
517
+ user documents. The test includes explicit misses such as `Zephyr Quill`,
518
+ `Asterion expanded rapidly`, `ETHZ` and `Reykjavik` so dictionary limits remain
519
+ visible. Acceptance rows also test both sanitizers and unchanged controls.
520
+
521
+ On Node v26.13.0, Linux x64, five bounded groups, the median warm detector cost
522
+ was **0.0434 ms per 1 KiB** of ASCII employer prose; module loading plus factory
523
+ initialization took **48.6 ms** once. Cost excludes the sanitizer's other
524
+ recognizers and varies with text and hardware. No latency guarantee is implied.
525
+
526
+ ## Compiled known-value measurements
527
+
528
+ On the same host and 1 KiB operational record, median complete `sanitize` cost
529
+ was **2.80 ms** without a list, **3.30 ms** with 500 compiled values and **8.56 ms**
530
+ with a plain array. A direct 500-value matching pass cost **0.73 ms**. Validation
531
+ cost was **0.00416 ms** without a list and **0.00356 ms** compiled (the difference
532
+ is measurement noise), below the allowed 10% of the no-list call, excluding
533
+ matching. Compilation skips per-record entry validation, normalization and regex
534
+ construction; matching itself still scales with list length and pipeline passes.
535
+
536
+ Reproduce with `node --max-old-space-size=384 experiments/issue-118/measure.mjs`.
537
+ The [raw results](case-studies/issue-118/measurements.json) include environment,
538
+ exact-span counts and measurement method. This fixed experiment uses no large
539
+ corpus, unbounded input, parallel builds or external analysis service.
540
+
541
+ ## Single Latin names and Yandex Cloud credentials
542
+
543
+ Document rules recognize a capitalized Latin name after `candidate`, `recruiter`,
544
+ `applicant`, `Dear`, `Hi`, `Mr`/`Mr.`, `Ms`/`Ms.`, `Dr`/`Dr.`, or `message from`.
545
+ Only the name is replaced; punctuation and the cue remain. These rules work
546
+ without enabling Latin script matching. Finite generic heading nouns such as
547
+ `Stage` and `Status` are preserved: `Candidate Stage changed` and
548
+ `Request Failed: POST /api` stay unchanged. This is contextual recognition,
549
+ not universal English NER; unknown cue-free single names can remain.
550
+ Use `rules: { 'document-person-cue': false }` to disable this recognizer.
551
+
552
+ Native credential detection recognizes bare Yandex Cloud API keys (`AQVN`),
553
+ IAM tokens (`t1.` including optional base64 padding), static access key IDs
554
+ (`YCAJE`) and static secrets (`YC`). These provider rules apply in every profile
555
+ and do not require an assignment label. See the official
556
+ [token grammar](https://yandex.cloud/en/docs/iam/concepts/authorization/iam-token)
557
+ and [credential formats](https://yandex.cloud/en/docs/security/standard/authentication).