@link-foundation/sensitive-data-sanitizer 0.20.0 → 0.21.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -1
  3. package/bin/sensitive-data-sanitizer.js +1 -0
  4. package/docs/API.md +179 -16
  5. package/docs/COVERAGE.md +16 -6
  6. package/docs/RULES.md +1 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/context.js +176 -25
  10. package/src/credential-spans.js +180 -0
  11. package/src/credentials-entry.d.ts +23 -0
  12. package/src/credentials-entry.js +62 -0
  13. package/src/credentials.js +8 -1
  14. package/src/detection.js +68 -37
  15. package/src/encoded.js +137 -51
  16. package/src/engines.js +33 -10
  17. package/src/exempt.js +72 -0
  18. package/src/fake-memory.js +32 -0
  19. package/src/fake.js +4 -28
  20. package/src/features.js +29 -0
  21. package/src/html-tags.js +8 -0
  22. package/src/identity-types.js +63 -0
  23. package/src/identity.js +3 -61
  24. package/src/index.d.ts +41 -2
  25. package/src/index.js +6 -0
  26. package/src/known.js +33 -15
  27. package/src/personal-features.js +39 -0
  28. package/src/personal.js +29 -5
  29. package/src/placeholder-types.js +164 -4
  30. package/src/placeholders.js +144 -45
  31. package/src/projection.js +1 -8
  32. package/src/proper-names.js +48 -5
  33. package/src/prose-words.js +116 -0
  34. package/src/public-technologies.js +32 -0
  35. package/src/public.js +3 -0
  36. package/src/record-worker.js +1 -0
  37. package/src/rule-catalog.js +2 -0
  38. package/src/rules.js +8 -0
  39. package/src/sanitizer.js +92 -21
  40. package/src/stream-worker.js +1 -0
  41. package/src/strict-attributes.js +60 -4
  42. package/src/strict.js +1 -1
  43. package/src/structured.js +2 -0
  44. package/src/transforms.js +38 -8
  45. package/src/vocabulary.js +39 -2
  46. package/src/worker-options.js +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e0e05a4: Cover 34 missing strict personal-attribute terms, preserve their typed placeholders over generic prose names, cache caller vocabulary and cue sets, avoid unnecessary placeholder scans and JSON parsing, and preserve exact public technology names in Cyrillic prose.
8
+
9
+ ## 0.21.0
10
+
11
+ ### Minor Changes
12
+
13
+ - d1d7a22: Resolve the sanitizer requests of issues 130–141. Placeholder mode accepts `transformation.existing: 'keep'` for a second pass over sanitized text, types whole JSON values (`Sensitive-1`, `Age-1`), and keeps pronouns, cue words and sentence-initial greetings and verbs readable beside `Person-n`; compact organization and location spans become one typed token. `reservedPlaceholderTypes()` and `isPlaceholderToken()` expose the namespace. `strict` covers more nationality, religion, health, sexuality, conviction and trade-union wording, and `vocabulary.personalAttributes` adds caller stems. A credentials-only entry (`@link-foundation/sensitive-data-sanitizer/credentials`) starts about three times faster, and both entries support `require`. Credential context stays readable and masks stay stable; configuration settings are kept and an `exempt(finding, text)` hook can keep findings by policy. New detections cover CI credential markers, XML attributes, hash rockets, markup values and plural credential flags. Wrapped base64 and hex keep their layout; candidates are bounded per 64 KiB window; `knownSecrets` also match their hex and base64 spellings. Environment secret discovery selects `PGPASSWORD`-style names and cookies, skips more configuration names, and accepts `minLength`.
14
+
3
15
  ## 0.20.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -20,6 +20,12 @@ two-pass publication:
20
20
  sensitive-data-sanitizer redact session.jsonl --stream --jsonl --two-pass --output safe.jsonl
21
21
  ```
22
22
 
23
+ For loggers that only mask credentials, import the
24
+ [credentials-only entry](docs/API.md#credentials-only-entry)
25
+ `@link-foundation/sensitive-data-sanitizer/credentials`: it skips the
26
+ personal-data modules and starts about three times faster. Both entries also
27
+ support `require`.
28
+
23
29
  For synchronous log hooks, `createSyncSanitizer` supports custom detectors and
24
30
  verified callback transformations. Rule/type controls, caller vocabulary,
25
31
  structured field selectors and `compileKnown` configure reusable policies; see
@@ -89,6 +95,13 @@ Known Russian names match in all six cases and in any word order, with `ё`/`е`
89
95
  spelling variants and initials (`с Петровым Данилом Сергеевичем`, `Петров Д. С.`).
90
96
  A part of a longer name is matched alone only when it is listed itself.
91
97
 
98
+ With `properNames: 'capitalized'`, placeholder output keeps the sentence around a
99
+ name (`Кандидат Person-1`, `Меня зовут Person-1`, `Благодарим Вас, Person-1`), and
100
+ `transformation.existing: 'keep'` lets a second pass over sanitized text keep its
101
+ `Type-n` tokens and number new values after them. `isPlaceholderToken` and
102
+ `reservedPlaceholderTypes` expose the namespace; see the
103
+ [second-pass example](examples/second-pass-placeholders.mjs).
104
+
92
105
  For irreversible output, choose `transformation: { mode: 'placeholder', map:
93
106
  false }`: no restore map is returned or written. Detection limits still apply
94
107
  in either mode. See the [placeholder API](docs/API.md#reversible-placeholders),
@@ -248,7 +261,7 @@ See [contributing](docs/CONTRIBUTING.md). `add`, `multiply` and `delay` live in
248
261
 
249
262
  The universal app's preview images regenerate on pushes to the default branch. Run `npm run example:web:preview-images` to regenerate them locally; see [the template guide](docs/TEMPLATE-PIPELINE.md) for setup.
250
263
 
251
- Automatic `--env-secrets` discovery skips authentication configuration names such as `GIT_AUTH_TRANSPORT`, `AUTH_MODE` and `AUTH_METHOD`. Explicit `knownSecretsFromEnv(env, names)` selections still include their exact values.
264
+ Automatic `--env-secrets` discovery also selects `PGPASSWORD`-style suffixes and cookies, and skips configuration names such as `GIT_AUTH_TRANSPORT`, `AUTH_MODE`, `*_AUTH_GUARD`, `MAX_TOKEN_COUNT`, `SSH_KEY_PATH`, `SECRET_NAME`, `AWS_ACCESS_KEY_ID` and `GIT_CONFIG_KEY_<n>`; `knownSecretsFromEnv(env, undefined, { minLength: 8 })` also skips short values. Explicit `knownSecretsFromEnv(env, names)` selections still include their exact values.
252
265
 
253
266
  For shared tool logs, `--credentials-only --env-secrets --gh-config` preserves
254
267
  public account details while redacting credential patterns and exact local
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import '../src/personal-features.js';
2
3
  import { readFileSync, realpathSync } from 'node:fs';
3
4
  import { fileURLToPath, URL } from 'node:url';
4
5
  import { resolve } from 'node:path';
package/docs/API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Library and detector contract
2
2
 
3
- Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs are strings, offsets are UTF-16 code units, starts are zero-based, and ends are exclusive. Returned line and column numbers are one-based; columns also count UTF-16 units.
3
+ Import the root entry with `import` or `require`; complete declarations are in `src/index.d.ts`. Inputs are strings, offsets are UTF-16 code units, starts are zero-based, and ends are exclusive. Returned line and column numbers are one-based; columns also count UTF-16 units.
4
4
 
5
5
  ## Methods
6
6
 
@@ -23,6 +23,31 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
23
23
 
24
24
  `redactions` counts merged replacement spans. `findings` can contain several detector hits for one replacement. Findings have `{start, end, type, category, rule, line, column}`; no text, quote, match, entropy score, provider details, or raw diagnostic is copied. The optional `strict-attributes` debug event reports only detected and suppressed match counts. Debugging is off by default; `debug(event)` receives value-free counts, rule timings, residual rounds and registry event metadata (see `DebugEvent` in the TypeScript declarations).
25
25
 
26
+ ### Credentials-only entry
27
+
28
+ `@link-foundation/sensitive-data-sanitizer/credentials` exports `inspect`,
29
+ `sanitize`, `redact`, `createSanitizer`, `createSyncSanitizer`, `REDACTED`,
30
+ `entropy`, `builtInRules`, `tokenChecksum`, `tokenWithChecksum` and the
31
+ `knownSecretsFrom*` helpers. It always runs with `credentialsOnly: true` and
32
+ does not load the personal-data detectors, faker or libphonenumber, so it starts
33
+ about three times faster than the root entry (about 0.25 s against 0.75 s on
34
+ Node.js 26). Use it for loggers that must mask every line from the first write:
35
+
36
+ ```js
37
+ const {
38
+ createSyncSanitizer,
39
+ knownSecretsFromEnv,
40
+ } = require('@link-foundation/sensitive-data-sanitizer/credentials');
41
+ const mask = createSyncSanitizer({ knownSecrets: knownSecretsFromEnv() });
42
+ console.log(mask.sanitize('Authorization: Bearer abc123def456ghi789').text);
43
+ // Authorization: Bearer [REDACTED]
44
+ ```
45
+
46
+ Options that need the personal-data modules, `credentialsOnly: false`,
47
+ `mode: 'fake'` and `properNames`, fail with `ERR_CONFIG`. Known personal values
48
+ (`knownPersonal`) still work. Both entries offer `import`, `require` (Node.js
49
+ 22.12 or later) and `default` export conditions.
50
+
26
51
  ## Options
27
52
 
28
53
  ### Replace only known values
@@ -71,6 +96,31 @@ Unicode letters, marks, digits and underscores bound those tokens. Ordinary
71
96
  technical names such as GPT-4, ISO-9001 and Windows-10 are not reserved. The map's format cannot change
72
97
  on reuse. A malformed map or ambiguous exact restoration blocks output.
73
98
 
99
+ `existing: 'keep'` on the placeholder transformation accepts input that already
100
+ contains tokens of the chosen namespace, for example the output of an earlier
101
+ pass or a model answer that quotes tokens. Those tokens stay as they are and are
102
+ never replaced, faked or treated as names; new values of a type are numbered
103
+ after the highest kept number of that type:
104
+
105
+ ```js
106
+ const policy = { transformation: { mode: 'placeholder', existing: 'keep' } };
107
+ sanitize('Person-1 встретил Анну Смирнову', policy).text;
108
+ // Person-1 встретил Person-2
109
+ ```
110
+
111
+ A second pass over sanitized text is then a fixed point. The default
112
+ `existing: 'error'` keeps the `ERR_PLACEHOLDER_COLLISION` check; other values and
113
+ a mix with per-type `transformations` are `ERR_CONFIG`. A kept token restores to
114
+ itself, so the map still reconstructs every newly replaced value exactly.
115
+
116
+ `reservedPlaceholderTypes()` returns the sorted, frozen list of every type a
117
+ built-in rule can turn into a token. `isPlaceholderToken(text, {format?, types?})`
118
+ checks whether `text` is one whole token of the namespace (`Person-1`,
119
+ `UsBankNumber-12`, `<PERSON_3>` with `format: '<{TYPE}_{n}>'`); `types` adds
120
+ custom types such as those of `createPatternDetector`. Both are also exported by
121
+ the credentials-only entry. The namespace pattern is compiled once per format and
122
+ cached.
123
+
74
124
  `createRestoreMap({format?})` creates an empty reusable map. Pass a map as
75
125
  `transformation.map` to continue a corpus; the operation updates that same
76
126
  object. A `createSanitizer()` engine also keeps its registry across calls.
@@ -151,6 +201,13 @@ JSON keys remain unchanged by automatic personal detection in all transformation
151
201
  modes. Explicit known values and credentials retain priority. Parseability and
152
202
  duplicate-key checks remain enforced. Plain redact/fake policies retain their
153
203
  existing metadata/key behavior. Use `structured` for valid structured input.
204
+ A whole JSON value replaced by a structured rule (`sensitiveFields`, or a
205
+ personal field such as `age` replaced with its JSON type) becomes one JSON
206
+ string token typed by its field: `{"notes":"любой текст"}` reads
207
+ `{"notes":"Sensitive-1"}` and `{"age":34}` reads `{"age":"Age-1"}`. Such findings
208
+ carry `valueType` when the field has a personal type. `restore()` brings back
209
+ the original JSON value, including numbers and objects. Other modes keep
210
+ `"[REDACTED]"`.
154
211
  The normal encoding limits and credential redaction policy continue to apply.
155
212
 
156
213
  CLI: `redact FILE --placeholder --map private.json` creates or continues a map;
@@ -169,7 +226,7 @@ new document recognizers; `documentEntities: false` turns them off explicitly.
169
226
  | `scripts` | `['Cyrillic']`; Unicode Script aliases that activate the prose rule; `[]` disables only script activation |
170
227
  | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
171
228
  | `knownOnly` | `false`; disable all automatic detection (including credentials, Secretlint and custom detectors); use explicit findings, known values and reused map identities |
172
- | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
229
+ | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format, plus their standalone hex (either case) and base64/base64url spellings |
173
230
  | `knownPersonal` | `[]`; `{type, value}` values with whole-word Unicode case-insensitive matching and bounded Russian forms; list parts to match them alone |
174
231
  | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
175
232
  | `findings` | `[]`; validated detections for the original input, supplied by the caller |
@@ -196,7 +253,7 @@ new document recognizers; `documentEntities: false` turns them off explicitly.
196
253
  | `strict` | `false`; aggressive stored-log personal attributes and bare values |
197
254
  | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
198
255
 
199
- 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.
256
+ Unknown options and invalid limits are errors. `knownSecretsFromEnv(env, names?, {minLength?})` discovers credential-named variables (excluding configuration names) when names are omitted, or uses an explicit selection, deduplicating nonempty values (or values of at least `minLength` characters). 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.
200
257
 
201
258
  ## Required detectors
202
259
 
@@ -260,7 +317,7 @@ Generated replacements carry `faked: true`. A private bounded registry recognize
260
317
 
261
318
  Faking is pseudonymisation: a private key and retained ages, relations and structure can permit linking. Keep the key out of published data. See [the realistic fake example](../examples/realistic-fakes.mjs) and [the complete requirement/research record](case-studies/issue-24/REQUIREMENTS.md).
262
319
 
263
- `preserveEncoding: true` sanitizes decoded payloads with the same required engine union, re-encodes them and verifies decoding reproduces the sanitized content. Supported formats include base64/base64url, hex, percent, numeric HTML and UTF-8 byte escapes. Escaped JSON string content is sanitized and re-encoded even under default redaction so nested session JSON stays parseable. Malformed or unsupported encodings block opted-in preservation. Known encoding candidates and nesting are bounded.
320
+ `preserveEncoding: true` sanitizes decoded payloads with the same required engine union, re-encodes them and verifies decoding reproduces the sanitized content. Supported formats include base64/base64url, hex, percent, numeric HTML and UTF-8 byte escapes. Escaped JSON string content is sanitized and re-encoded even under default redaction so nested session JSON stays parseable. Wrapped base64 and hex keep their line width and line separator (`\n` or `\r\n`), and a wrapped run is reported as one finding. When the sanitized payload cannot be re-encoded in a form the decoder recognizes (for example hex shorter than 32 digits), the encoded text is masked as it stands rather than blocking the input. Known encoding candidates and nesting are bounded.
264
321
 
265
322
  ## Identity policy and structured sessions
266
323
 
@@ -308,9 +365,15 @@ status identifiers. Explicit `knownPersonal` values override these heuristics.
308
365
 
309
366
  Oversized encoded runs receive a conservative complete-run `ENCODED_LIMIT`
310
367
  redaction rather than aborting the record. Decoding probes are capped at 4,096
311
- characters; full decoding stays capped at 8,192 characters. Whole-input,
312
- candidate-count, record and worker limits still apply. This fallback can remove
313
- large non-sensitive encoded outputs; its finding makes that decision auditable.
368
+ characters; full decoding stays capped at 8,192 characters. A multi-line run is
369
+ treated as one wrapped payload only when its probe decodes as text or its lines
370
+ share one base64 width (a multiple of four, standard alphabet, not plain hex);
371
+ lists of identifiers or commit hashes are scanned line by line. Up to 4,096
372
+ textually decodable candidates are scanned per 64 KiB window of the input;
373
+ further candidates in a denser window receive `ENCODED_LIMIT` redactions rather
374
+ than an `ERR_LIMIT` for the whole text. Whole-input, record and worker limits
375
+ still apply. This fallback can remove large non-sensitive encoded outputs; its
376
+ finding makes that decision auditable.
314
377
 
315
378
  ## Streaming and worker publication
316
379
 
@@ -451,12 +514,28 @@ findings retain the normal engine union, confidence rules and verification.
451
514
  This keeps EMAIL/account-ID values without per-value `publicEntities` entries.
452
515
  The default policy continues to protect personal data.
453
516
 
454
- `knownSecretsFromEnv(env = process.env, names?)` discovers nonempty exact values
455
- when `names` is omitted, using
456
- `/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i`.
457
- This covers `MY_SERVICE_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `CODEX_API_KEY`,
458
- `OPENCODE_API_KEY`, `DASHSCOPE_API_KEY`, `HF_TOKEN` and `TELEGRAM_BOT_TOKEN`.
459
- Automatic discovery excludes names ending in `AUTH_TRANSPORT`, `AUTH_MODE`, `AUTH_METHOD`, `AUTH_TYPE`, `AUTH_URL`, `AUTH_URI`, `AUTH_HOST`, `AUTH_HOSTNAME`, `AUTH_PATH`, `AUTH_ENABLED`, `AUTH_DISABLED`, `AUTH_PORT`, `AUTH_PROTOCOL` or `AUTH_SCHEME` (including prefixed forms such as `HIVE_MIND_GIT_AUTH_TRANSPORT`). Credential names such as `AUTH_TOKEN` still contribute their exact values, including short/low-entropy values; no value-shape filter can reliably distinguish passwords from configuration. An explicit array bypasses the name exclusions; `[]` selects nothing.
517
+ `knownSecretsFromEnv(env = process.env, names?, { minLength = 1 }?)` discovers
518
+ nonempty exact values when `names` is omitted. A name is selected when it has a
519
+ credential keyword between `_` boundaries
520
+ (`/(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|O?AUTH|PAT|COOKIE)(?:_|$)/i`)
521
+ or ends in `PASSWORD`, `PASSWD`, `TOKEN` or `COOKIE` without one. This covers
522
+ `MY_SERVICE_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `CODEX_API_KEY`,
523
+ `OPENCODE_API_KEY`, `DASHSCOPE_API_KEY`, `HF_TOKEN`, `TELEGRAM_BOT_TOKEN`,
524
+ `PGPASSWORD`, `GITHUBTOKEN`, `OAUTH` and `SESSION_COOKIE`.
525
+ Automatic discovery excludes names whose last segment describes configuration
526
+ rather than a credential: `_TRANSPORT`, `_MODE`, `_METHOD`, `_TYPE`, `_URL`,
527
+ `_URI`, `_HOST`, `_HOSTNAME`, `_PATH`, `_ENABLED`, `_DISABLED`, `_PORT`,
528
+ `_PROTOCOL`, `_SCHEME`, `_GUARD`, `_COUNT`, `_LIMIT`, `_FILE`, `_FILENAME`,
529
+ `_DIR`, `_DIRECTORY`, `_NAME` and `_ID` (for example `HIVE_MIND_GIT_AUTH_TRANSPORT`,
530
+ `HIVE_MIND_ROUTER_AUTH_GUARD`, `MAX_TOKEN_COUNT`, `SSH_KEY_PATH`, `SECRET_NAME`
531
+ and `AWS_ACCESS_KEY_ID`), plus `GOOGLE_APPLICATION_CREDENTIALS` (a file path) and
532
+ `GIT_CONFIG_KEY_<n>` (the name of a git setting). `GIT_CONFIG_VALUE_<n>` is
533
+ selected when its `GIT_CONFIG_KEY_<n>` names a header, password, token, secret,
534
+ cookie file or auth setting, such as `http.extraheader`. Credential names such as
535
+ `AUTH_TOKEN` and `AUTH_MODE_TOKEN` still contribute their exact values, including
536
+ short/low-entropy values; no value-shape filter can reliably distinguish passwords
537
+ from configuration. Pass `minLength` to skip shorter values. An explicit array
538
+ bypasses the name exclusions; `[]` selects nothing.
460
539
  These values are loaded only when the helper or CLI `--env-secrets` is requested.
461
540
 
462
541
  `await knownSecretsFromGitHubConfig({path?})` reads `oauth_token` and `api_token`
@@ -467,6 +546,9 @@ symlinks, aliases, files above 1 MiB and read failures produce value-free
467
546
  `ERR_AUTH`. YAML quoting is parsed with the `yaml` package, with alias expansion
468
547
  disabled. CLI `--gh-config` adds these exact values to `knownSecrets`.
469
548
  `knownSecretsFromGitHubAuth` / `--gh-auth` remain available independently.
549
+ A `hosts.yml` that uses YAML aliases or is a symlink stays a hard `ERR_AUTH`
550
+ failure, so a caller relying on `--gh-config` never runs with a silently
551
+ incomplete list.
470
552
 
471
553
  Plain log streams and CLI file/stdin/output/in-place paths preserve invalid
472
554
  UTF-8 bytes exactly. Valid Unicode keeps its normal meaning for detection.
@@ -559,9 +641,33 @@ also applies independently within serialized JSON strings, to the existing
559
641
  bounded decoding depth. Findings do not seed document-wide name propagation.
560
642
  Repeated redaction is stable. Reusing a placeholder map for the same original
561
643
  produces stable tokens and exact restoration; the existing placeholder-namespace
562
- collision checks still reject input that already contains typed placeholders.
644
+ collision checks still reject input that already contains typed placeholders
645
+ unless the transformation sets `existing: 'keep'`.
646
+
647
+ Capitalized words that identify nobody stay readable, so placeholder output keeps
648
+ its sentence: pronouns (`Вас`, `Я`), role and label cues (`Кандидат`, `Сотрудник`,
649
+ the default and configured `roles` and `personalLabels`) anywhere, and greetings,
650
+ function words and verbs (past tense `Звонила`, `Перенёс`, `Работал` before a
651
+ lowercase word; present `Работаю`, `Рекомендует`) at the start of a sentence:
563
652
 
564
- This is a structural heuristic, so capitalized ordinary words also disappear.
653
+ ```js
654
+ sanitize('Кандидат Петров Иван Сергеевич. Звонила Анна Смирнова', {
655
+ properNames: 'capitalized',
656
+ transformation: { mode: 'placeholder', map: false },
657
+ }).text;
658
+ // Кандидат Person-1. Звонила Person-2
659
+ ```
660
+
661
+ A word that is also a given-name form (`Вера`, `Любовь`) never counts as such a
662
+ word, and a bare masculine `-л` form before a capitalized word may be a surname
663
+ (`Шагал Марк`), so both are still replaced. A compact organization, location,
664
+ school or person span found by the document rules (`ООО «Ромашка»`, `в Казани`)
665
+ replaces the proper-name fragments inside it as one typed value
666
+ (`Organization-1`, `Location-1`); spans with tabs or line breaks, or longer than
667
+ 160 characters, keep the separate fragments. A bare oblique surname (`Иванову`)
668
+ links to the one person whose name contains it.
669
+
670
+ Other capitalized ordinary words still disappear; this is a structural heuristic.
565
671
  Lowercase organizations without employment context stay; the document rules
566
672
  replace a lowercase employer after `работал в` / `работала в`. Complete contact
567
673
  and identifier findings take priority over overlapping proper-name fragments.
@@ -644,7 +750,29 @@ Cues ignore case and support bounded Russian noun/adjective forms; personal fiel
644
750
  use normalized field names. Capitalized proper values are required after prose
645
751
  cues. Inputs allow at most 256 nonempty strings of 128 code units per list, with
646
752
  no line breaks/NUL. Unknown lists and malformed replacement records fail with
647
- `ERR_CONFIG`. Compilation uses a bounded 64-configuration cache.
753
+ `ERR_CONFIG`. Compilation uses a bounded 64-configuration cache and a weak
754
+ object-identity cache. Treat a vocabulary object as immutable after first use;
755
+ pass a new object to change its lists. Repeated lookups reuse compiled patterns
756
+ and a cue-word Set without normalizing or serializing the lists again.
757
+
758
+ `personalAttributes: ['вепс', 'старовер']` adds special-category stems to the
759
+ `strict` rule (it has no effect without `strict: true`). Each word matches as a
760
+ stem followed by up to eight letters, so `вепска` and `староверами` match, and the
761
+ words of a multi-word entry may be separated by spaces or tabs. Matches are
762
+ reported as `PERSONAL_ATTRIBUTE` with the `strict-attribute` rule. The default
763
+ list is empty.
764
+
765
+ Typed special-category attributes take precedence over generic `ProperName`
766
+ guesses, including capitalized caller stems in Cyrillic prose. Strict coverage
767
+ includes the 34 additional nationality, religion, health, criminal-record,
768
+ political and union terms reported in issue 144, even as standalone words.
769
+
770
+ With default `publicKnowledge`, exact public technology spellings such as Python,
771
+ SQL, PostgreSQL, Docker, Kubernetes, React, AWS and Azure remain readable when
772
+ the capitalized prose detector flags them. The offline catalog includes source
773
+ URLs in `src/public-technologies.js`. `publicKnowledge: false` disables this
774
+ exception; known private values and credentials still redact, and an adjacent
775
+ personal name never inherits a technology's exemption.
648
776
 
649
777
  ## Callback transformations and stages
650
778
 
@@ -787,6 +915,41 @@ A mixed value such as `alice:${DB_PASSWORD}` or `<x>abc123…` is still redacted
787
915
  Findings from custom detectors are never dropped this way. The value-free debug
788
916
  event `{event: 'template-value', rule}` reports each skipped match.
789
917
 
918
+ ### Exempting findings with a hook
919
+
920
+ `exempt(finding, text) => boolean` keeps findings that the caller's own policy
921
+ knows are not sensitive: a configuration flag, a token counter, a version
922
+ range, a value another tool already masked. It runs after detection and before
923
+ transformation, once per finding. The finding carries `value` and the offsets
924
+ into `text`. Return `true` to keep the span unchanged and `false` to transform
925
+ it. Any other result, including `undefined` from a forgotten `return`, is
926
+ `ERR_CONFIG`.
927
+
928
+ ```js
929
+ createSyncSanitizer({
930
+ credentialsOnly: true,
931
+ // The key before the value decides; offsets point into `text`.
932
+ exempt: (finding, text) =>
933
+ /FEATURE_TOKEN=$/.test(text.slice(0, finding.start)),
934
+ }).sanitize(
935
+ 'FEATURE_TOKEN=keep-this-value-123 other_token=keep-this-value-123'
936
+ );
937
+ // → 'FEATURE_TOKEN=keep-this-value-123 other_token=[REDACTED]'
938
+ ```
939
+
940
+ Unlike a callback transformation that returns the original value, which fails
941
+ with `ERR_RESIDUAL`, an exempted span is excluded from residual verification
942
+ by span, not by value. Verification of the published text calls the hook again
943
+ for every finding in the output, so the same value elsewhere is still caught
944
+ unless the hook keeps that span too. The hook is explicit code, so it can keep
945
+ credential findings without `allowCredentialDisable`. Declared
946
+ `knownSecrets`/`knownPersonal` values, confirmed personal values and structured
947
+ `JSON_VALUE` denials are never offered to it.
948
+
949
+ `createSanitizer` awaits a hook that returns a `Promise`.
950
+ `createSyncSanitizer` and `sanitize()` need a synchronous hook. A `Promise`
951
+ there is `ERR_CONFIG`.
952
+
790
953
  ## Per-type thresholds
791
954
 
792
955
  `minConfidenceByType` maps finding types to a threshold that overrides
package/docs/COVERAGE.md CHANGED
@@ -8,7 +8,7 @@ The native provider supplements in `src/rules.js` cover GitHub, GitLab, OpenAI,
8
8
 
9
9
  The default native engine bundles 221 text rules from Gitleaks 8.30.1 (222 upstream rules; the path-only PKCS12 rule is outside text detection), compiled unchanged with RE2/WASM or RE2JS for inputs above 1 MiB (re2-wasm has a fixed 16 MiB heap shared by every compiled rule and copied subject). Dense rule matches switch to RE2JS after at most eight WASM matches per view, bounding repeated whole-input copies without truncating secrets or changing anchors. The MIT license, commit and sync script are retained in `src/vendor/gitleaks`. Because Gitleaks is no longer updated, 203 further rules from its maintained successor Betterleaks 1.10.0 (ids absent from the Gitleaks set, such as Groq, Cerebras, xAI, Vercel, Supabase and Cloudflare v2 tokens) run the same way as `betterleaks/<id>` findings, with their MIT license, provenance and `scripts/sync-betterleaks.py` in `src/vendor/betterleaks`. Their entropy floors and secret-only `matchesAny`/`containsAny` filters are honoured; BPE token-ratio and nearby-line filters, live validation, validation-only components and the context-dependent `generic-*` rules are not, so these rules can only report more than Betterleaks, not less. Keyword prefilters and configured entropy thresholds run natively; upstream comment/path allowlists cannot suppress a sanitizer finding. Quoted hex/base64 entropy detection supplements those rules. The default asynchronous engine additionally uses the pinned Secretlint recommended scanners, excluding its comment-filter rule. Embedded disable directives in untrusted text cannot suppress findings. Optional Gitleaks and TruffleHog invoke their installed detector sets, and custom detectors can add formats. Service counts are not a measure of comparative recall.
10
10
 
11
- Context rules recognize credential labels with bounded key affixes, JSON and nested escaped JSON, YAML/env/INI assignments, shell flags, XML values, URL userinfo, authorization headers, cookies, webhook paths, and complete or truncated private-key PEM blocks. Low-entropy contextual values such as `password=1234` are protected. Generic token counters, booleans/null/language literals and ordinary Git hashes are excluded to retain useful logs. Supply literal known values for ambiguous credentials or unusual key names.
11
+ Context rules recognize credential labels with bounded key affixes, JSON and nested escaped JSON, YAML/env/INI assignments, Ruby/Perl/PHP hash rockets before a quoted literal (`'password' => '…'`), shell flags including plural credential names (`--access-tokens`, but not `--max-tokens`), XML values (also with attributes and camelCase element names), values that start with markup (`token: <script>…`; a bare `<placeholder>` stays), all-caps CI/test credential markers that contain a digit (`SYNTHETIC_TOKEN_ALPHA_123456`, rule `marker-token`), URL userinfo, authorization headers, cookies, webhook paths, and complete or truncated private-key PEM blocks. Low-entropy contextual values such as `password=1234` are protected; a number or boolean under a configuration name (`AUTH_MODE=2`, `ROUTER_AUTH_GUARD=0`) is a setting. Generic token counters, booleans/null/language literals and ordinary Git hashes are excluded to retain useful logs. Supply literal known values for ambiguous credentials or unusual key names.
12
12
 
13
13
  Broader unquoted entropy scanning is opt-in (`paranoid: true`), uses Shannon entropy ≥4.2 on bounded candidates, and exempts ordinary hex hashes and common tool identifiers. Quoted hex/base64 and entropy checks belonging to bundled provider rules run by default. Entropy has both false positives and false negatives; contextual detection and known-secret dictionaries remain necessary.
14
14
 
@@ -480,17 +480,27 @@ remove ordinary uses of unambiguous category words. Bounded Russian case/stem
480
480
  rules cover disability, ethnicity, religion, diagnoses, intimate life and criminal
481
481
  status, including `инвалидом`, `армянин`, `еврейка`, `казах`, `христианин`, `иудей`,
482
482
  `эпилепсией`, `астма`, `онкологию`, `туберкулез`, `психиатра`, `депрессия`,
483
- `гепатит`, `гей` and `отбывал наказание`. English coverage includes `epilepsy`,
484
- `gay` and `Armenian`. Explicit category cues such as `по национальности X`,
483
+ `гепатит`, `гей` and `отбывал наказание`, plus nationalities (`узбечка`,
484
+ `таджик`, `чеченец`, `грузинка`, `азербайджанец`, `цыганка`, `кореянка`),
485
+ religions (`протестант`), health (`инсульт`, `шизофрения`, `слабослышащий`,
486
+ `колясочник`, `передвигается на коляске`, `аутизм`), sexuality (`бисексуал`,
487
+ `трансгендер`), convictions (`сидел за кражу`) and trade-union membership
488
+ (`член профсоюза`). English coverage includes `epilepsy`, `gay`, `Armenian`,
489
+ `autism`, `transgender`, `wheelchair user`, `trade union member` and
490
+ `served time for`. Country, region and language words (`Узбекистан`, `Чувашия`,
491
+ `грузинский язык`) are not attributes. `vocabulary.personalAttributes` adds caller
492
+ stems. Explicit category cues such as `по национальности X`,
485
493
  `исповедует X`, `болеет X`, `на учете у X`, `diagnosed with X` and `ethnicity: X`
486
494
  protect one following word (including hyphenated words) up to 64 characters,
487
495
  including unfamiliar values outside the lexicon. These cues stay on one line.
488
- Ambiguous English `disabled`, `single`, `man`, `men`, `russian`
489
- and `asian` require an adjacent personal cue (pronoun/person role, personal
496
+ Ambiguous English `disabled`, `single`, `man`, `men`, `russian`,
497
+ `asian`, `uzbek`, `korean`, `chinese`, `georgian`, `kazakh`, `roma` and
498
+ `union member` require an adjacent personal cue (pronoun/person role, personal
490
499
  label, or following person role). Thus `he is disabled`, `candidate is single`
491
500
  and `gender: male` redact, while `the module is disabled`, `single run finished`,
492
501
  `single-flight lock acquired`, `man page missing`, `locale russian selected`
493
- and `asian region endpoint` remain intact. Bare operational JSON statuses
502
+ `asian region endpoint`, `Korean locale enabled` and `union member list`
503
+ remain intact. Bare operational JSON statuses
494
504
  `disabled`, `single` and `Russian` also remain intact; explicit personal labels
495
505
  and caller-declared known personal values still redact. A cue in another clause
496
506
  or string value does not make an ambiguous word personal.
package/docs/RULES.md CHANGED
@@ -540,6 +540,7 @@ Regenerate with `node experiments/issue-118/generate-rule-catalog.mjs`.
540
540
  - `mac`
541
541
  - `mail-display-name`
542
542
  - `mailchimp`
543
+ - `marker-token`
543
544
  - `messenger-handle`
544
545
  - `messenger-label`
545
546
  - `mrz`
@@ -0,0 +1,28 @@
1
+ // Sanitizes a CV note into readable placeholders, then sanitizes the result
2
+ // again after a model added text, keeping the tokens already in it.
3
+ import {
4
+ createSyncSanitizer,
5
+ isPlaceholderToken,
6
+ } from '@link-foundation/sensitive-data-sanitizer';
7
+
8
+ const policy = (existing) => ({
9
+ profile: 'documents',
10
+ properNames: 'capitalized',
11
+ strict: true,
12
+ transformation: { mode: 'placeholder', map: false, existing },
13
+ });
14
+
15
+ const first = createSyncSanitizer(policy()).sanitize(
16
+ 'Кандидат Петров Иван Сергеевич, тел. +7 916 123-45-67. Работал в ООО «Ромашка», живу в Казани. Перенёс инсульт.'
17
+ ).text;
18
+ console.log(first);
19
+ // Кандидат Person-1, тел. Phone-1. Работал в Organization-1, живу в Location-1. Перенёс PersonalAttribute-1.
20
+
21
+ const answer = `${first} Рекомендует Анна Смирнова.`;
22
+ const second = createSyncSanitizer(policy('keep')).sanitize(answer).text;
23
+ console.log(second);
24
+ // ... Перенёс PersonalAttribute-1. Рекомендует Person-2.
25
+
26
+ console.log(
27
+ second.split(/[\s,.]+/u).filter((word) => isPlaceholderToken(word))
28
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
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",
@@ -27,12 +27,21 @@
27
27
  "examples/reversible-placeholders.mjs",
28
28
  "examples/prose-logs.mjs",
29
29
  "docs/RULES.md",
30
- "examples/custom-log-policy.mjs"
30
+ "examples/custom-log-policy.mjs",
31
+ "examples/second-pass-placeholders.mjs"
31
32
  ],
32
33
  "exports": {
33
34
  ".": {
34
35
  "types": "./src/index.d.ts",
35
- "import": "./src/index.js"
36
+ "import": "./src/index.js",
37
+ "require": "./src/index.js",
38
+ "default": "./src/index.js"
39
+ },
40
+ "./credentials": {
41
+ "types": "./src/credentials-entry.d.ts",
42
+ "import": "./src/credentials-entry.js",
43
+ "require": "./src/credentials-entry.js",
44
+ "default": "./src/credentials-entry.js"
36
45
  },
37
46
  "./eslint-plugin": {
38
47
  "types": "./src/eslint-plugin.d.ts",