@link-foundation/sensitive-data-sanitizer 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d5b2aac: Add known-only reversible sanitization, Unicode known-value boundaries and bounded Russian entity case matching. Correct placeholder namespace collisions, Russian document entities and fake-name roles, and credential prose false positives. Report irreversible replacements in placeholder results.
8
+
3
9
  ## 0.13.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -55,6 +55,11 @@ sensitive-data-sanitizer restore safe.txt --map private.lino --output restored.t
55
55
  sensitive-data-sanitizer redact cv.txt --profile documents --placeholder --no-map
56
56
  ```
57
57
 
58
+ `knownOnly: true` (CLI `--known-only`) limits replacement to `knownPersonal`,
59
+ `knownSecrets` and reused map identities, preserving fictional examples.
60
+ Placeholder results report `irreversibleRedactions`; a nonzero count means some
61
+ replacements, such as credentials excluded from the map, cannot be restored.
62
+
58
63
  For irreversible output, choose `transformation: { mode: 'placeholder', map:
59
64
  false }`: no restore map is returned or written. Detection limits still apply
60
65
  in either mode. See the [placeholder API](docs/API.md#reversible-placeholders),
@@ -42,6 +42,7 @@ history rewrite SOURCE --output DIRECTORY [--apply]
42
42
  --in-place Atomically replace the input file (redact only)
43
43
  --native-only Disable Secretlint; selected local detectors remain required
44
44
  --credentials-only Preserve personal/account details; redact credentials only
45
+ --known-only Replace only knownPersonal/knownSecrets and reused map values
45
46
  --env-secrets Include values of all credential-named environment variables
46
47
  --gh-config Include tokens from the local gh hosts.yml credential file
47
48
  --paranoid Add entropy detection (can redact innocent identifiers)
@@ -88,6 +89,7 @@ function parseArgs(argv) {
88
89
  ['--in-place', 'inPlace'],
89
90
  ['--native-only', 'nativeOnly'],
90
91
  ['--credentials-only', 'credentialsOnly'],
92
+ ['--known-only', 'knownOnly'],
91
93
  ['--env-secrets', 'envSecrets'],
92
94
  ['--gh-config', 'ghConfig'],
93
95
  ['--paranoid', 'paranoid'],
@@ -339,6 +341,9 @@ async function loadOptions(config) {
339
341
 
340
342
  async function featureOptions(config, options) {
341
343
  await localCredentials(config, options);
344
+ if (config.knownOnly) {
345
+ options.knownOnly = true;
346
+ }
342
347
  if (config.credentialsOnly) {
343
348
  options.credentialsOnly = true;
344
349
  }
package/docs/API.md CHANGED
@@ -19,6 +19,24 @@ Import the ESM root entry; complete declarations are in `src/index.d.ts`. Inputs
19
19
 
20
20
  ## Options
21
21
 
22
+ ### Replace only known values
23
+
24
+ Use `knownOnly: true` to preserve fictional examples while replacing selected
25
+ private values. This applies to sync/async, JSON/JSONL, streams and fake mode.
26
+ It skips automatic credential detection too; supply `knownSecrets` explicitly
27
+ for credentials that must be removed. `credentialsOnly` can further exclude
28
+ personal values. CLI: `--known-only` with a `--config` containing the known lists.
29
+
30
+ ```js
31
+ const result = sanitize('Анна Смирнова; example Jane Doe', {
32
+ knownOnly: true,
33
+ knownPersonal: [{ type: 'PERSON', value: 'Анна Смирнова' }],
34
+ transformation: { mode: 'placeholder' },
35
+ });
36
+ // result.text: 'Person-1; example Jane Doe'
37
+ restore(result.text, result.map); // exact original
38
+ ```
39
+
22
40
  ### Reversible placeholders
23
41
 
24
42
  `transformation: {mode: 'placeholder'}` emits typed tokens such as `Person-1`,
@@ -42,7 +60,9 @@ are left alone.
42
60
  `format` is a template with exactly one type field (`{Type}`, `{TYPE}` or
43
61
  `{type}`) and one `{n}` field. Examples: `{Type}-{n}`, `[{TYPE}_{n}]`,
44
62
  `{{{type}_{n}}}`. Sanitization refuses input containing any token in the chosen
45
- namespace, including numbers not allocated yet. The map's format cannot change
63
+ namespace for built-in and supplied type labels, including numbers not allocated yet.
64
+ Unicode letters, marks, digits and underscores bound those tokens. Ordinary
65
+ technical names such as GPT-4, ISO-9001 and Windows-10 are not reserved. The map's format cannot change
46
66
  on reuse. A malformed map or ambiguous exact restoration blocks output.
47
67
 
48
68
  `createRestoreMap({format?})` creates an empty reusable map. Pass a map as
@@ -68,13 +88,20 @@ is plaintext personal data, not encrypted storage; protect it as the original.
68
88
 
69
89
  `credentials: true` explicitly opts credentials into `Credential-n` entries.
70
90
  Otherwise they remain `[REDACTED]` and cannot be recovered, including through
71
- occurrence metadata. `map: false` returns only the anonymized text and ordinary
91
+ occurrence metadata. Placeholder results include `irreversibleRedactions`, the
92
+ number of replacements excluded from the restore map (including mixed policies).
93
+ Check that this is zero before relying on exact restoration. A positive count
94
+ means `restore()` cannot reconstruct all of the input. `map: false` returns only the anonymized text and ordinary
72
95
  value-free finding metadata. The temporary numbering registry is discarded
73
96
  after an operation (or the complete stream); no map file is created. Residual verification
74
97
  still runs, including every required engine for `createSanitizer`.
75
98
 
76
99
  Name components, unique initial-plus-surname forms, existing transliteration
77
100
  aliases and selected Russian `-ов`/`-ев` inflections share an entity token.
101
+ Explicit `knownPersonal` and reused map entries also match bounded Russian noun,
102
+ adjective, feminine surname and given-name case endings, two-part person names
103
+ in reverse order, and organization cores without legal forms/quotes. Inflected
104
+ forms consume whole Unicode words; minimum stem lengths limit overmatching.
78
105
  This is conservative heuristic identity linking, not general coreference:
79
106
  ambiguous surnames/initials and unrelated people are not intentionally merged.
80
107
  Every replaced occurrence retains its exact original surface privately. With
@@ -99,28 +126,29 @@ derived model answer. Input, output and map paths must be separate. Maps are
99
126
  saved separately before stdout publication. `--profile documents` enables the
100
127
  new document recognizers; `documentEntities: false` turns them off explicitly.
101
128
 
102
- | Option | Default / meaning |
103
- | ------------------ | ------------------------------------------------------------------------------------------------------- |
104
- | `documentEntities` | Off; bounded organization, education and location rules; enabled by the documents profile |
105
- | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
106
- | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
107
- | `knownPersonal` | `[]`; `{type, value}` literals with Unicode case-insensitive matching |
108
- | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
109
- | `findings` | `[]`; validated detections for the original input, supplied by the caller |
110
- | `detectors` | `[]`; additional required callbacks in the asynchronous engine |
111
- | `secretlint` | `true` in the asynchronous engine |
112
- | `decode` | `true`; bounded base64/base64url, hex and percent decoding |
113
- | `paranoid` | `false`; entropy-based supplemental credential heuristic |
114
- | `maxInputLength` | 33,554,432 UTF-16 units |
115
- | `maxFindings` | 100,000 detector hits before deduplication |
116
- | `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
117
- | `threshold` | Alias for minConfidence; both options must agree when supplied |
118
- | `debug` | Optional trusted callback for value-free counts, timing and event metadata |
119
- | `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
120
- | `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
121
- | `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
122
- | `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
123
- | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
129
+ | Option | Default / meaning |
130
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `documentEntities` | Off; bounded organization, education and location rules; enabled by the documents profile |
132
+ | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
133
+ | `knownOnly` | `false`; disable all automatic detection (including credentials, Secretlint and custom detectors); use only explicit known values and reused map identities |
134
+ | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
135
+ | `knownPersonal` | `[]`; `{type, value}` values with whole-word Unicode case-insensitive matching and bounded Russian case forms |
136
+ | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
137
+ | `findings` | `[]`; validated detections for the original input, supplied by the caller |
138
+ | `detectors` | `[]`; additional required callbacks in the asynchronous engine |
139
+ | `secretlint` | `true` in the asynchronous engine |
140
+ | `decode` | `true`; bounded base64/base64url, hex and percent decoding |
141
+ | `paranoid` | `false`; entropy-based supplemental credential heuristic |
142
+ | `maxInputLength` | 33,554,432 UTF-16 units |
143
+ | `maxFindings` | 100,000 detector hits before deduplication |
144
+ | `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
145
+ | `threshold` | Alias for minConfidence; both options must agree when supplied |
146
+ | `debug` | Optional trusted callback for value-free counts, timing and event metadata |
147
+ | `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
148
+ | `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
149
+ | `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
150
+ | `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
151
+ | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
124
152
 
125
153
  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.
126
154
 
package/docs/COVERAGE.md CHANGED
@@ -29,6 +29,15 @@ remains available under those profiles.
29
29
  | `LOCATION` | Exact city/region entries from a bounded offline vocabulary | 0.60 |
30
30
  | `LOCATION` | Bounded proper names after location labels, including based in, located in, relocated from, г., город and locale equivalents | 0.75 |
31
31
 
32
+ Russian document rules also accept legal-form quoted names with lowercase words,
33
+ digits and hyphens (up to 160 characters), organization Group/Групп/Holding/Холдинг
34
+ markers, adjectival institution names with lowercase connectors and optional
35
+ `имени` tails, education acronyms in context, residence labels and city/country
36
+ phrasing. A known given name after `Меня зовут` or `С уважением` scores 0.70.
37
+ Detected organization cores propagate within a document and across confirmed
38
+ stream records. Russian regional cities include Ярославль and Кострома; the
39
+ vocabulary remains finite. Default profiles do not enable these document rules.
40
+
32
41
  These confidence values indicate heuristic strength, not calibrated
33
42
  probabilities. `minConfidence` still controls replacement. Findings retain
34
43
  their confidence even below threshold. `publicEntities` supports exact reviewed
@@ -41,7 +50,7 @@ Spanish, French, German, Portuguese, Russian, Japanese, Simplified/Traditional
41
50
  Chinese, Korean, Arabic, Hebrew, Hindi, Turkish, Indonesian, Vietnamese and
42
51
  Thai each have list-form employer, school and city fixtures. Proper-name
43
52
  rules are finite (up to six capitalized words, or four words in scripts without
44
- case); they do not implement grammatical parsing. Common lowercase connectors,
53
+ case); they do not implement grammatical parsing. Other lowercase connectors,
45
54
  unusual typography, unsupported legal forms and unfamiliar institutions can be
46
55
  missed. Legal-form words and uncased scripts can also yield false positives.
47
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
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",
package/src/context.js CHANGED
@@ -8,6 +8,7 @@ import { plausiblePerson } from './names.js';
8
8
  // unrelated words such as passport. Verb forms cover the supported locales.
9
9
  export const credentialVerbs = [
10
10
  'is',
11
+ 'are',
11
12
  'was',
12
13
  'equals',
13
14
  'es',
@@ -45,12 +46,11 @@ export const credentialVerbs = [
45
46
  'คือ',
46
47
  ];
47
48
  const verbs = credentialVerbs.map(escapePattern).join('|');
48
- const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization)(?:[_.-][\\p{L}\\p{N}]+)*|[\\p{L}\\p{N}_.-]{0,32}(?:${labels.credential
49
+ const label = labels.credential
49
50
  .filter((word) => !['auth', 'authorization'].includes(word))
50
51
  .map(escapePattern)
51
- .join(
52
- '|'
53
- )})[\\p{L}\\p{N}_.-]{0,32}|(?:[\\p{L}\\p{N}_.-]{1,32}[_-])?(?:pass|pw)(?:[_-][\\p{L}\\p{N}_.-]{1,32})?`;
52
+ .join('|');
53
+ const key = `(?:[\\p{L}\\p{N}]+[_.-])*(?:auth|authorization|PGPASSWORD|${label}|pass|pw)(?:[_.-][\\p{L}\\p{N}]+)*`;
54
54
  // A qualifier such as "for staging" stays on one logical line: it never
55
55
  // crosses an escaped line break, an escape sequence or an enclosing quote.
56
56
  // Verbs are whole words, so "request" does not end in the verb "est".
@@ -60,8 +60,6 @@ const credentialPrefix = new RegExp(
60
60
  );
61
61
  // Bare generic labels are common English nouns. After a verb they need a
62
62
  // value that looks generated; "the token is valid" is prose, not a secret.
63
- const genericLabel =
64
- /^(?:token|sig|sas|secret|auth|authorization|cookie|секрет|токен)$/iu;
65
63
  function generatedWord(text, range) {
66
64
  const word = /^[^\s]+/u
67
65
  .exec(text.slice(range.start, range.end))?.[0]
@@ -170,7 +168,7 @@ export function authorizationRange(text, start) {
170
168
  for (; end < text.length; end++) {
171
169
  const char = text[end];
172
170
  const code = text.charCodeAt(end);
173
- if (code < 32 || code === 127 || /[\s"'\\,]/.test(char)) {
171
+ if (authorizationDelimiter(char, code)) {
174
172
  break;
175
173
  }
176
174
  if (char === '<' && end !== tokenStart && !brackets.length) {
@@ -185,7 +183,13 @@ export function authorizationRange(text, start) {
185
183
  }
186
184
  }
187
185
  const token = text.slice(tokenStart, end);
188
- return !token || credentialPlaceholder(token) ? undefined : { start, end };
186
+ return usableAuthorization(token) ? { start, end } : undefined;
187
+ }
188
+ function usableAuthorization(token) {
189
+ return Boolean(token) && !credentialPlaceholder(token);
190
+ }
191
+ function authorizationDelimiter(char, code) {
192
+ return code < 32 || code === 127 || /[\s"'\\,]/.test(char);
189
193
  }
190
194
  function quotedRange(text, start, opening, maxLength) {
191
195
  const depth = opening[1].length,
@@ -323,6 +327,13 @@ function backslashes(text, index) {
323
327
  return count;
324
328
  }
325
329
 
330
+ function sourceReference(label, tail) {
331
+ return (
332
+ /\.(?:[cm]?[jt]sx?|py|go|rs|c|cpp|h|java|cs|rb|sh)$/iu.test(label) &&
333
+ /^\d+(?:[):]|\b)/u.test(tail)
334
+ );
335
+ }
336
+
326
337
  function detectCredentials(text, emit) {
327
338
  const details = { category: 'credential', type: 'SECRET', rule: 'context' };
328
339
  for (const match of text.matchAll(credentialPrefix)) {
@@ -341,9 +352,12 @@ function detectCredentials(text, emit) {
341
352
  let range = /^(?:proxy-)?authorization$/i.test(match[1])
342
353
  ? authorizationRange(text, start)
343
354
  : valueRange(text, start, mode);
344
- if (range && mode === 'prose' && genericLabel.test(match[1])) {
355
+ if (range && mode === 'prose') {
345
356
  range = generatedWord(text, range);
346
357
  }
358
+ if (sourceReference(match[1], text.slice(start))) {
359
+ continue;
360
+ }
347
361
  if (range) {
348
362
  emitValue(text, range.start, range.end, emit, details, match[1]);
349
363
  }
package/src/detection.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  credentialPlaceholder,
14
14
  } from './credentials.js';
15
15
  import { localAddress } from './public.js';
16
+ import { detectKnownPersonal } from './personal-variants.js';
16
17
  import { detectDocumentEntities } from './document-entities.js';
17
18
 
18
19
  export const REDACTED = '[REDACTED]';
@@ -133,6 +134,29 @@ function detectCookies(text, emit) {
133
134
 
134
135
  export function nativeDetect(text, options, emit) {
135
136
  const credential = { type: 'SECRET', category: 'credential' };
137
+ for (const value of options.knownSecrets ?? []) {
138
+ collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
139
+ ...credential,
140
+ rule: 'known-secret',
141
+ });
142
+ }
143
+ detectKnownPersonal(
144
+ text,
145
+ options.knownPersonal,
146
+ 'known-personal',
147
+ emit,
148
+ options
149
+ );
150
+ detectKnownPersonal(
151
+ text,
152
+ options.confirmedPersonal,
153
+ 'confirmed-personal',
154
+ emit,
155
+ options
156
+ );
157
+ if (options.knownOnly) {
158
+ return;
159
+ }
136
160
  detectGitleaks(
137
161
  text,
138
162
  emit,
@@ -143,31 +167,6 @@ export function nativeDetect(text, options, emit) {
143
167
  for (const [rule, pattern] of serviceRules) {
144
168
  collectMatches(text, pattern, emit, { ...credential, rule });
145
169
  }
146
- for (const value of options.knownSecrets ?? []) {
147
- collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
148
- ...credential,
149
- rule: 'known-secret',
150
- });
151
- }
152
- for (const entry of options.knownPersonal ?? []) {
153
- collectMatches(text, new RegExp(escapePattern(entry.value), 'giu'), emit, {
154
- type: entry.type,
155
- category: 'personal',
156
- rule: 'known-personal',
157
- });
158
- }
159
- for (const entry of options.confirmedPersonal ?? []) {
160
- const pattern = new RegExp(
161
- `(?<![\\p{L}\\p{N}])${personalPattern(entry.value)}(?![\\p{L}\\p{N}])`,
162
- 'giu'
163
- );
164
- collectMatches(text, pattern, emit, {
165
- type: entry.type,
166
- category: 'personal',
167
- rule: 'confirmed-personal',
168
- confidence: 0.9,
169
- });
170
- }
171
170
  detectContext(text, emit, options.credentialsOnly);
172
171
  if (!options.credentialsOnly) {
173
172
  detectPersonal(text, emit);
@@ -1,4 +1,6 @@
1
1
  import { collectMatches, escapePattern } from './detection.js';
2
+ import { firstNames } from './names.js';
3
+ import { organizationCore, personalRegex } from './personal-variants.js';
2
4
  import { documentVocabulary } from './document-vocabulary.js';
3
5
 
4
6
  const words = (column) =>
@@ -14,7 +16,7 @@ const latinName = `${proper}(?:[ \\t]+${proper}){0,5}`;
14
16
  const uncased =
15
17
  '[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}\\p{Script=Arabic}\\p{Script=Hebrew}\\p{Script=Devanagari}\\p{Script=Thai}\\p{M}]{2,48}';
16
18
  const name = `(?:${latinName}|${uncased}(?:[ \\t]+${uncased}){0,3})`;
17
- const quoted = `(?:[«“「『"]${name}[»”」』"]|${name})`;
19
+ const quoted = `(?:«[^«»\\r\\n]{1,160}»|“[^“”\\r\\n]{1,160}”|"[^"\\r\\n]{1,160}"|「[^「」\\r\\n]{1,160}」|『[^『』\\r\\n]{1,160}』|${name})`;
18
20
  const boundary = '[\\p{L}\\p{M}\\p{N}_]';
19
21
  const organizations = new RegExp(
20
22
  `(?<!${boundary})(?:(?:${legal})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${legal}))(?!${boundary})`,
@@ -33,11 +35,38 @@ const labelledPlace = new RegExp(
33
35
  'gu'
34
36
  );
35
37
 
38
+ const ruWord = '[А-ЯЁа-яё][А-ЯЁа-яё-]{1,47}';
39
+ const ruSchool = new RegExp(
40
+ `(?<!${boundary})[А-ЯЁ][а-яё]{2,30}(?:-[А-ЯЁа-яё][а-яё]{1,30})?(?:[ \t]+[а-яё-]{2,40}){0,4}[ \t]+(?:университет|институт|колледж|школа|академия)(?:[ \t]+имени[ \t]+${ruWord}(?:[ \t]+[А-ЯЁ][а-яё-]{2,40}){0,2})?(?!${boundary})`,
41
+ 'gu'
42
+ );
43
+ const ruAcronym =
44
+ /(?<![\p{L}\p{N}_])(?:НИУ[ \t]+[А-ЯЁ]{2,12}|(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)(?=[, \t]+(?:магистратура|бакалавриат|университет|обучение)))(?![\p{L}\p{N}_])/gu;
45
+ const ruResidence =
46
+ /(?:[Гг]ород|[Пп]роживаю|[Пп]роживание|[Мм]есто жительства)[ \t]*:[ \t]*([А-ЯЁ][а-яё-]{2,47})|([А-ЯЁ][а-яё-]{2,47})(?=,[ \t]*(?:готов[а]?[ \t]+к[ \t]+переезду|Россия|Беларусь|Казахстан|Украина))/gu;
47
+ const ruIntroduction = new RegExp(
48
+ `(?:[Мм]еня зовут|[Сс] уважением)[, \t]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
49
+ 'gu'
50
+ );
51
+ const groupOrganization = new RegExp(
52
+ `(?<!${boundary})${latinName}[ \t]+(?:Групп|Group|Холдинг|Holding)(?!${boundary})`,
53
+ 'gu'
54
+ );
55
+
36
56
  export function detectDocumentEntities(text, emit, options) {
37
57
  if (!options.documentEntities) {
38
58
  return;
39
59
  }
60
+ const found = [];
61
+ const capture = (finding) => {
62
+ found.push(finding);
63
+ emit(finding);
64
+ };
40
65
  for (const [type, rule, pattern, group, confidence] of [
66
+ ['ORGANIZATION', 'organization-group', groupOrganization, 0, 0.7],
67
+ ['EDUCATION', 'education-russian', ruSchool, 0, 0.65],
68
+ ['EDUCATION', 'education-acronym', ruAcronym, 0, 0.65],
69
+ ['PERSON', 'document-introduction', ruIntroduction, 1, 0.7],
41
70
  ['ORGANIZATION', 'organization-legal-form', organizations, 0, 0.7],
42
71
  ['EDUCATION', 'education-type', schools, 0, 0.65],
43
72
  ['LOCATION', 'location-gazetteer', places, 0, 0.6],
@@ -46,9 +75,36 @@ export function detectDocumentEntities(text, emit, options) {
46
75
  collectMatches(
47
76
  text,
48
77
  pattern,
49
- emit,
78
+ capture,
50
79
  { type, rule, category: 'personal', confidence },
51
80
  group
52
81
  );
53
82
  }
83
+ for (const match of text.matchAll(ruResidence)) {
84
+ const value = match[1] ?? match[2];
85
+ const start = match.index + match[0].lastIndexOf(value);
86
+ emit({
87
+ start,
88
+ end: start + value.length,
89
+ type: 'LOCATION',
90
+ rule: 'location-residence',
91
+ category: 'personal',
92
+ confidence: 0.75,
93
+ });
94
+ }
95
+ // Organization cores are confirmed only by a bounded legal-form finding.
96
+ for (const finding of found.filter((f) => f.type === 'ORGANIZATION')) {
97
+ const core = organizationCore(text.slice(finding.start, finding.end));
98
+ if (core.length < 3 || core.length > 256) {
99
+ continue;
100
+ }
101
+ for (const match of text.matchAll(personalRegex(core, 'ORGANIZATION'))) {
102
+ emit({
103
+ ...finding,
104
+ start: match.index,
105
+ end: match.index + match[0].length,
106
+ rule: 'organization-core',
107
+ });
108
+ }
109
+ }
54
110
  }
@@ -35,7 +35,7 @@ export const documentVocabulary = [
35
35
  'ru',
36
36
  'ООО|АО|ПАО|ИП|ЗАО',
37
37
  'Университет|университет|Институт|институт|Школа|школа',
38
- 'Москва|Санкт-Петербург|Казань|Татарстан',
38
+ 'Москва|Санкт-Петербург|Казань|Татарстан|Ярославль|Кострома|Новосибирск|Екатеринбург|Нижний Новгород|Самара|Омск|Челябинск|Ростов-на-Дону|Уфа|Красноярск|Пермь|Воронеж|Волгоград|Саратов|Тюмень|Тольятти|Ижевск|Барнаул|Иркутск|Хабаровск|Владивосток|Махачкала|Томск|Оренбург|Кемерово|Рязань|Астрахань|Пенза|Липецк|Киров|Чебоксары|Тула|Калининград|Курск|Улан-Удэ|Ставрополь|Сочи|Тверь|Брянск|Белгород|Владимир|Архангельск|Калуга|Смоленск|Вологда|Саранск|Орёл',
39
39
  ],
40
40
  ['ja', '株式会社|有限会社|合同会社', '大学|学院|学校', '東京|大阪|北海道'],
41
41
  ['zh', '有限公司|股份有限公司', '大学|学院|学校', '北京|上海|广东'],
package/src/engines.js CHANGED
@@ -162,8 +162,12 @@ export function createSanitizer(options = {}) {
162
162
  }
163
163
  }
164
164
  const engines =
165
- options.secretlint === false ? [] : [{ detect: secretlintDetect }];
166
- engines.push(...(options.detectors ?? []));
165
+ options.secretlint === false || options.knownOnly
166
+ ? []
167
+ : [{ detect: secretlintDetect }];
168
+ if (!options.knownOnly) {
169
+ engines.push(...(options.detectors ?? []));
170
+ }
167
171
  let prepared;
168
172
  async function analyze(
169
173
  text,
package/src/entities.js CHANGED
@@ -36,6 +36,13 @@ for (const [vendor, catalog] of Object.entries(entityCatalogs)) {
36
36
  entries.set(name.toUpperCase(), entry);
37
37
  }
38
38
  }
39
+ export const catalogTypes = () => [
40
+ ...new Set(
41
+ [...entries.values()]
42
+ .filter((entry) => entry.category === 'personal')
43
+ .map((entry) => (entry.type === 'DATE_OF_BIRTH' ? 'DATE' : entry.type))
44
+ ),
45
+ ];
39
46
  // All documented names are native labelled recognizers; automatic format
40
47
  // recognizers are separate and do not claim the vendors' model behavior.
41
48
  const prefix = new RegExp(
package/src/fake-names.js CHANGED
@@ -146,6 +146,58 @@ function scriptLocale(value) {
146
146
  }
147
147
  export function fakeName(value, key) {
148
148
  indexNames();
149
+ const words = value.match(/[\p{L}\p{M}]+/gu) ?? [];
150
+ const roles = new Map();
151
+ let nameSex;
152
+ if (
153
+ /\p{Script=Cyrillic}/u.test(value) &&
154
+ words.length >= 2 &&
155
+ words.length <= 3
156
+ ) {
157
+ const middle = words.findIndex((w) =>
158
+ /(?:ович|евич|ична|овна|евна)$/iu.test(w)
159
+ );
160
+ const surname = words.findIndex(
161
+ (w, i) =>
162
+ i !== middle &&
163
+ /(?:ов|ев|ин|ский|ова|ева|ина|ская)$/iu.test(w) &&
164
+ !given.has(latinName(w))
165
+ );
166
+ const genderWord = words[middle >= 0 ? middle : surname];
167
+ if (genderWord) {
168
+ nameSex = /(?:ова|ева|ина|ская|овна|евна|ична)$/iu.test(genderWord)
169
+ ? 'female'
170
+ : 'male';
171
+ }
172
+ if (middle > 0) {
173
+ words.forEach((w, i) =>
174
+ roles.set(
175
+ w,
176
+ i === middle
177
+ ? 'middleName'
178
+ : i === middle - 1
179
+ ? 'firstName'
180
+ : 'lastName'
181
+ )
182
+ );
183
+ } else if (surname >= 0) {
184
+ words.forEach((w, i) =>
185
+ roles.set(w, i === surname ? 'lastName' : 'firstName')
186
+ );
187
+ } else {
188
+ // Ambiguous spans receive a complete name with the same token count.
189
+ words.forEach((w, i) =>
190
+ roles.set(
191
+ w,
192
+ i === 0
193
+ ? 'firstName'
194
+ : i === words.length - 1
195
+ ? 'lastName'
196
+ : 'middleName'
197
+ )
198
+ );
199
+ }
200
+ }
149
201
  return value.replace(/[\p{L}\p{M}]+/gu, (word) => {
150
202
  for (const locale of ['ja', 'zh', 'ko']) {
151
203
  const family = firstNames[locale]
@@ -158,7 +210,7 @@ export function fakeName(value, key) {
158
210
  );
159
211
  }
160
212
  }
161
- return fakeWord(word, key);
213
+ return fakeWord(word, key, roles.get(word), undefined, nameSex);
162
214
  });
163
215
  }
164
216
  function wordProfile(word, forcedRole, forcedLocale) {
@@ -188,12 +240,14 @@ function wordProfile(word, forcedRole, forcedLocale) {
188
240
  : 'lastName');
189
241
  return { canonical, locale, sex, role };
190
242
  }
191
- function fakeWord(word, key, forcedRole, forcedLocale) {
192
- const { canonical, locale, sex, role } = wordProfile(
193
- word,
194
- forcedRole,
195
- forcedLocale
196
- );
243
+ function fakeWord(word, key, forcedRole, forcedLocale, forcedSex) {
244
+ const {
245
+ canonical,
246
+ locale,
247
+ sex: detectedSex,
248
+ role,
249
+ } = wordProfile(word, forcedRole, forcedLocale);
250
+ const sex = forcedSex ?? detectedSex;
197
251
  const faker = new Faker({ locale: [locales[locale] ?? en, en] });
198
252
  faker.seed(
199
253
  createHmac('sha256', key)
package/src/index.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface Detector {
42
42
  export interface SanitizerOptions {
43
43
  /** Preserve all personal/account data; credential findings still redact. */
44
44
  credentialsOnly?: boolean;
45
+ /** Replace only knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
46
+ knownOnly?: boolean;
45
47
  profile?: 'publication' | 'documents';
46
48
  /** Opt in to bounded employer, school, city/region heuristics. */
47
49
  documentEntities?: boolean;
@@ -97,6 +99,8 @@ export interface SanitizedText {
97
99
  findings: Finding[];
98
100
  /** Number of disjoint replacements after overlapping detections are merged. */
99
101
  redactions: number;
102
+ /** Placeholder-mode replacements absent from the restore map (e.g. credentials). */
103
+ irreversibleRedactions?: number;
100
104
  /** Private personal data. Absent for map:false and non-placeholder modes. */
101
105
  map?: RestoreMap;
102
106
  }
package/src/keywords.js CHANGED
@@ -27,10 +27,27 @@ export function keywordMatcher(words) {
27
27
  queue.push(child);
28
28
  }
29
29
  }
30
+ // At the root, characters that cannot start any keyword need no state
31
+ // transitions. A native search skips large padding runs without Map work
32
+ // per character. Escape UTF-16 units so metacharacters retain literal meaning.
33
+ const starters = new RegExp(
34
+ `[${[...nodes[0].next.keys()]
35
+ .map((char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
36
+ .join('')}]`,
37
+ 'g'
38
+ );
30
39
  return (text) => {
31
40
  const found = new Set();
32
41
  let state = 0;
33
42
  for (let i = 0; i < text.length; i++) {
43
+ if (!state && !nodes[0].output.length && !nodes[0].next.has(text[i])) {
44
+ starters.lastIndex = i + 1;
45
+ const next = starters.exec(text);
46
+ if (!next) {
47
+ break;
48
+ }
49
+ i = next.index;
50
+ }
34
51
  const char = text[i];
35
52
  while (state && !nodes[state].next.has(char)) {
36
53
  state = nodes[state].fail;
package/src/names.js CHANGED
@@ -12,7 +12,7 @@ export const firstNames = {
12
12
  jaLatn: 'Yamada Sato Suzuki Takahashi Tanaka Ito Watanabe Nakamura Kobayashi',
13
13
  zhLatn:
14
14
  'Zhang Wang Li Zhao Liu Chen Yang Huang Zhou Wu Xu Sun Hu Zhu Gao Lin',
15
- ru: 'Иван Пётр Петр Сергей Александр Алексей Дмитрий Михаил Николай Андрей Владимир Павел Виктор Олег Борис Евгений Василий Юрий Марина Анна Мария Елена Ольга Наталья Ирина Светлана Татьяна Екатерина Анастасия Юлия Ивану Ивана Иваном Сергея Сергею Александра Александру Анне Анну Елены',
15
+ ru: 'Данил Максим Алина Иван Пётр Петр Сергей Александр Алексей Дмитрий Михаил Николай Андрей Владимир Павел Виктор Олег Борис Евгений Василий Юрий Марина Анна Мария Елена Ольга Наталья Ирина Светлана Татьяна Екатерина Анастасия Юлия Ивану Ивана Иваном Сергея Сергею Александра Александру Анне Анну Елены',
16
16
  ja: '山田 佐藤 鈴木 高橋 田中 伊藤 渡辺 渡邊 中村 小林 加藤 吉田 松本 山本 井上 木村 林 清水 斎藤 斉藤',
17
17
  zh: '张 王 李 赵 刘 陈 杨 黄 周 吴 徐 孙 胡 朱 高 林 何 郭 马 罗 張 趙 劉 陳 楊 黃 吳 孫 馬 羅',
18
18
  ko: '김 이 박 최 정 강 조 윤 장 임 한 오 서 신 권 황 안 송 전 홍 유 고 문 양 손 배 백',
@@ -0,0 +1,94 @@
1
+ import { escapePattern, failure } from './detection.js';
2
+
3
+ const boundary = '[\\p{L}\\p{M}\\p{N}_]';
4
+ export function organizationCore(value) {
5
+ return value
6
+ .replace(/^(?:ООО|АО|ПАО|ИП|ЗАО)[ \t]+/u, '')
7
+ .replace(/^[«“"]|[»”"]$/gu, '')
8
+ .trim();
9
+ }
10
+ // Only caller-confirmed Russian words receive finite case alternatives. A
11
+ // minimum three-letter stem avoids turning short names into broad matches.
12
+ function wordPattern(word) {
13
+ const endings = [
14
+ [/(?:ова|ева|ина)$/u, 1, '(?:а|ой|ою|у|е|ы)'],
15
+ [/(?:ая)$/u, 2, '(?:ая|ой|ою|ую)'],
16
+ [
17
+ /(?:ий|ый|ой)$/u,
18
+ 2,
19
+ '(?:ий|ый|ой|ого|его|ому|ему|им|ым|ом|ем|ия|ию|ием|ии)',
20
+ ],
21
+ [/(?:ов|ев|ин)$/u, 0, '(?:а|у|ым|ом|е|ой)?'],
22
+ [/а$/u, 1, '(?:а|ы|и|е|у|ой|ою)'],
23
+ [/я$/u, 1, '(?:я|и|е|ю|ей|ею)'],
24
+ [/ь$/u, 1, '(?:ь|я|ю|ем|и)'],
25
+ ];
26
+ const lower = word.toLowerCase();
27
+ if (/^[а-яё]+$/u.test(lower)) {
28
+ for (const [ending, remove, suffix] of endings) {
29
+ if (ending.test(lower) && word.length - remove >= 3) {
30
+ return escapePattern(remove ? word.slice(0, -remove) : word) + suffix;
31
+ }
32
+ }
33
+ }
34
+ if (/^[а-яё]{4,}[бвгджзклмнпрстфхцчшщ]$/u.test(lower)) {
35
+ return `${escapePattern(word)}(?:а|у|ом|е|ы|ов|ам|ами|ах)?`;
36
+ }
37
+ return escapePattern(word);
38
+ }
39
+ export function personalPatterns(value, type) {
40
+ if (value.length > 256) {
41
+ // Large explicit values still support literal matching without expansion.
42
+ return [escapePattern(value)];
43
+ }
44
+ const values = new Set([value]);
45
+ if (type === 'ORGANIZATION') {
46
+ const core = organizationCore(value);
47
+ if (core.length >= 3) {
48
+ values.add(core);
49
+ }
50
+ }
51
+ const patterns = new Set();
52
+ for (const surface of values) {
53
+ const parts = surface.split(/([ \t]+)/u);
54
+ const pattern = parts
55
+ .map((part, i) => (i % 2 ? '[ \\t]+' : wordPattern(part)))
56
+ .join('');
57
+ patterns.add(pattern);
58
+ if (type === 'PERSON' && parts.length === 3) {
59
+ patterns.add(
60
+ [wordPattern(parts[2]), wordPattern(parts[0])].join('[ \\t]+')
61
+ );
62
+ }
63
+ }
64
+ return [...patterns];
65
+ }
66
+ export function personalRegex(value, type) {
67
+ return new RegExp(
68
+ `(?<!${boundary})(?:${personalPatterns(value, type).join('|')})(?!${boundary})`,
69
+ 'giu'
70
+ );
71
+ }
72
+ export function samePersonal(value, original, type) {
73
+ return personalPatterns(original, type).some((pattern) =>
74
+ new RegExp(`^(?:${pattern})$`, 'iu').test(value)
75
+ );
76
+ }
77
+ export function detectKnownPersonal(text, entries, rule, emit, options) {
78
+ let count = 0;
79
+ for (const entry of entries ?? []) {
80
+ for (const match of text.matchAll(personalRegex(entry.value, entry.type))) {
81
+ if (++count > (options.maxFindings ?? 100000)) {
82
+ throw failure('ERR_LIMIT');
83
+ }
84
+ emit({
85
+ start: match.index,
86
+ end: match.index + match[0].length,
87
+ type: entry.type,
88
+ category: 'personal',
89
+ rule,
90
+ confidence: rule === 'known-personal' ? 0.85 : 0.9,
91
+ });
92
+ }
93
+ }
94
+ }
package/src/personal.js CHANGED
@@ -179,7 +179,12 @@ const russianFull =
179
179
  /(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}[А-ЯЁ][а-яё]{2,30}(?:вич|вна|ична)|(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+){2}(?:оглы|кызы)(?![\p{L}])/gu;
180
180
  export function detectPersonal(text, emit) {
181
181
  detectDocumentNames(text, emit);
182
- for (const pattern of [latinPairs, slavicPairs, russianFull]) {
182
+ for (const pattern of [
183
+ latinPairs,
184
+ slavicPairs,
185
+ russianFull,
186
+ /(?<![\p{L}])(?:[А-ЯЁ][а-яё]{2,30}[ \t]+)[А-ЯЁ][а-яё]{2,30}(?:вич|вна|ична)[ \t]+[А-ЯЁ][а-яё]{2,30}(?![\p{L}])/gu,
187
+ ]) {
183
188
  collectMatches(
184
189
  text,
185
190
  pattern,
@@ -0,0 +1,33 @@
1
+ import { catalogTypes } from './entities.js';
2
+
3
+ // Native unlabelled types supplement the shared vendor-label catalog.
4
+ export const reservedPlaceholderTypes = () => [
5
+ ...catalogTypes(),
6
+ 'ADDRESS',
7
+ 'AU_TFN',
8
+ 'BRAZIL_CPF_NUMBER',
9
+ 'CREDENTIAL',
10
+ 'CREDIT_CARD',
11
+ 'DATE',
12
+ 'EDUCATION',
13
+ 'EMAIL',
14
+ 'ENCODED_SENSITIVE',
15
+ 'IBAN',
16
+ 'ID',
17
+ 'IP_ADDRESS',
18
+ 'KR_RRN',
19
+ 'LOCATION',
20
+ 'MAC_ADDRESS',
21
+ 'ORGANIZATION',
22
+ 'PASSPORT_MRZ',
23
+ 'PASSPORT_NUMBER',
24
+ 'PERSON',
25
+ 'PHONE',
26
+ 'PL_PESEL',
27
+ 'RU_INN',
28
+ 'RU_SNILS',
29
+ 'SG_NRIC_FIN',
30
+ 'STATE',
31
+ 'USERNAME',
32
+ 'US_HEALTHCARE_NPI',
33
+ ];
@@ -1,4 +1,6 @@
1
1
  import { failure, REDACTED, escapePattern } from './detection.js';
2
+ import { reservedPlaceholderTypes } from './placeholder-types.js';
3
+ import { samePersonal } from './personal-variants.js';
2
4
  import { nameAliases } from './transliteration.js';
3
5
  import { createHash } from 'node:crypto';
4
6
  import { jsonStringLiterals } from './json-string-runs.js';
@@ -106,6 +108,13 @@ function aliases(value, type) {
106
108
  ];
107
109
  }
108
110
  function related(value, entry) {
111
+ if (
112
+ (entry.values ?? []).some((original) =>
113
+ samePersonal(value, original, entry.type)
114
+ )
115
+ ) {
116
+ return true;
117
+ }
109
118
  const candidates = aliases(value, entry.type);
110
119
  if (candidates.some((alias) => entry.aliases.includes(alias))) {
111
120
  return true;
@@ -139,7 +148,7 @@ function related(value, entry) {
139
148
  return false;
140
149
  }
141
150
  export function placeholderNameFindings(text, findings, options) {
142
- if (!hasPlaceholders(options)) {
151
+ if (!hasPlaceholders(options) || options.knownOnly) {
143
152
  return findings;
144
153
  }
145
154
  const people = [
@@ -199,12 +208,23 @@ export function preparePlaceholderText(text, findings, options) {
199
208
  if (!state || !hasPlaceholders(options)) {
200
209
  return;
201
210
  }
202
- // Reserve the entire namespace, not only the numbers about to be allocated.
203
- const template = state.map.format;
211
+ const types = new Set([
212
+ ...reservedPlaceholderTypes(),
213
+ ...findings.map((f) =>
214
+ f.category === 'credential' ? 'CREDENTIAL' : f.type
215
+ ),
216
+ ...(options.knownPersonal ?? []).map((e) => e.type),
217
+ ...state.map.entries.map((e) => e.type),
218
+ ]);
219
+ const [before, after] = state.map.format.split('{n}');
220
+ const tokens = [...types].map(
221
+ (type) =>
222
+ `${escapePattern(
223
+ placeholderToken(type, 1, before)
224
+ )}[1-9][0-9]*${escapePattern(placeholderToken(type, 1, after))}`
225
+ );
204
226
  const pattern = new RegExp(
205
- escapePattern(template)
206
- .replace(/\\\{(?:Type|TYPE|type)\\\}/, '[A-Za-z][A-Za-z0-9_]*')
207
- .replace('\\{n\\}', '[1-9][0-9]*'),
227
+ `(?<![\\p{L}\\p{M}\\p{N}_])(?:${tokens.join('|')})(?![\\p{L}\\p{M}\\p{N}_])`,
208
228
  'u'
209
229
  );
210
230
  if (namespaceCollision(text, pattern)) {
@@ -255,6 +275,15 @@ function canonicalPerson(value, people) {
255
275
  );
256
276
  return distinct.size === 1 ? [...distinct.values()][0] : value;
257
277
  }
278
+ function canonicalKnown(value, type, options, state) {
279
+ const known = (options.knownPersonal ?? []).filter(
280
+ (e) => e.type === type && samePersonal(value, e.value, type)
281
+ );
282
+ if (known.length === 1) {
283
+ return known[0].value;
284
+ }
285
+ return type === 'PERSON' ? canonicalPerson(value, state.people ?? []) : value;
286
+ }
258
287
  export function placeholderReplacement(value, finding, transform, options) {
259
288
  if (finding.category === 'credential' && !transform.credentials) {
260
289
  return REDACTED;
@@ -270,8 +299,7 @@ export function placeholderReplacement(value, finding, transform, options) {
270
299
  : finding.type === 'DATE_OF_BIRTH'
271
300
  ? 'DATE'
272
301
  : finding.type;
273
- const canonical =
274
- type === 'PERSON' ? canonicalPerson(value, state.people ?? []) : value;
302
+ const canonical = canonicalKnown(value, type, options, state);
275
303
  const candidates = map.entries.filter(
276
304
  (entry) => entry.type === type && related(canonical, entry)
277
305
  );
package/src/projection.js CHANGED
@@ -27,8 +27,12 @@ export function projectText(input) {
27
27
  !/\x1b\[|\\u[\da-fA-F]{4}|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/u.test(
28
28
  input
29
29
  ) &&
30
- input.normalize('NFKC') === input &&
31
- !/[^0-9]/.test(input.replace(/[^\p{Nd}]/gu, ''))
30
+ // ASCII already has normalized letters and digits. Avoid constructing a
31
+ // second multi-megabyte string just to check its decimal characters.
32
+ // eslint-disable-next-line no-control-regex -- Check the ASCII range.
33
+ (!/[^\x00-\x7f]/.test(input) ||
34
+ (input.normalize('NFKC') === input &&
35
+ !/[^0-9]/.test(input.replace(/[^\p{Nd}]/gu, ''))))
32
36
  ) {
33
37
  return { text: input, changed: false };
34
38
  }
@@ -1,5 +1,6 @@
1
1
  import { personalPattern, maxPropagationLength, failure } from './detection.js';
2
2
  import { actionable } from './confidence.js';
3
+ import { organizationCore } from './personal-variants.js';
3
4
  import { nameAliases } from './transliteration.js';
4
5
 
5
6
  function mrzSeeds(value, finding, seeds) {
@@ -32,11 +33,13 @@ function decodedValue(value) {
32
33
 
33
34
  function eligibleSeed(finding, options) {
34
35
  return (
36
+ !options.knownOnly &&
35
37
  finding.category === 'personal' &&
36
38
  finding.type !== 'ENCODED_SENSITIVE' &&
37
39
  !['confirmed-personal', 'document-propagation'].includes(finding.rule) &&
38
40
  actionable(finding, options) &&
39
- (finding.confidence >= 0.85 ||
41
+ ((options.documentEntities && finding.type === 'ORGANIZATION') ||
42
+ finding.confidence >= 0.85 ||
40
43
  [
41
44
  'PERSON',
42
45
  'PASSPORT_NUMBER',
@@ -47,6 +50,14 @@ function eligibleSeed(finding, options) {
47
50
  ].includes(finding.type))
48
51
  );
49
52
  }
53
+ function organizationSeed(value, finding, seeds, options) {
54
+ if (options.documentEntities && finding.type === 'ORGANIZATION') {
55
+ const core = organizationCore(value);
56
+ if (core.length >= 3) {
57
+ seeds.set(core, finding);
58
+ }
59
+ }
60
+ }
50
61
  // Only confirmed values seed propagation. A low-score numeric coincidence
51
62
  // must never turn every log occurrence into a high-score identity.
52
63
  export function confirmedSeeds(text, findings, options) {
@@ -71,6 +82,7 @@ export function confirmedSeeds(text, findings, options) {
71
82
  mrzSeeds(value, finding, seeds);
72
83
  } else if (value.length >= 2) {
73
84
  seeds.set(value, finding);
85
+ organizationSeed(value, finding, seeds, options);
74
86
  if (finding.type === 'PERSON') {
75
87
  for (const name of value.match(/\p{L}{2,}/gu) ?? []) {
76
88
  seeds.set(name, finding);
package/src/sanitizer.js CHANGED
@@ -45,6 +45,7 @@ const publicTypes = new Set([
45
45
  ]);
46
46
  const optionNames = new Set([
47
47
  'credentialsOnly',
48
+ 'knownOnly',
48
49
  'knownSecrets',
49
50
  'knownPersonal',
50
51
  'confirmedPersonal',
@@ -166,6 +167,7 @@ export function validateOptions(options = {}) {
166
167
  for (const key of [
167
168
  'paranoid',
168
169
  'credentialsOnly',
170
+ 'knownOnly',
169
171
  'decode',
170
172
  'secretlint',
171
173
  'publicKnowledge',
@@ -695,6 +697,7 @@ export function redactResult(
695
697
  const parts = [];
696
698
  const faked = [];
697
699
  const occurrences = [];
700
+ let irreversibleRedactions = 0;
698
701
  let outputLength = 0;
699
702
  let cursor = 0;
700
703
  for (const span of spans) {
@@ -723,6 +726,12 @@ export function redactResult(
723
726
  raw: text.slice(span.start, span.end),
724
727
  });
725
728
  }
729
+ if (
730
+ hasPlaceholders(options) &&
731
+ !retainedReplacement(transform, 'placeholder', span, replacement)
732
+ ) {
733
+ irreversibleRedactions++;
734
+ }
726
735
  outputLength += replacement.length;
727
736
  if (retainedReplacement(transform, 'fake', span, replacement)) {
728
737
  faked.push(span);
@@ -731,7 +740,11 @@ export function redactResult(
731
740
  }
732
741
  parts.push(text.slice(cursor));
733
742
  const result = finishPlaceholders(
734
- { text: parts.join(''), redactions: spans.length },
743
+ {
744
+ text: parts.join(''),
745
+ redactions: spans.length,
746
+ ...(hasPlaceholders(options) ? { irreversibleRedactions } : {}),
747
+ },
735
748
  occurrences,
736
749
  options
737
750
  );
@@ -861,6 +874,13 @@ export function redactAgain(result, findings, options, round, rerun) {
861
874
  const merge = (next) => ({
862
875
  ...next,
863
876
  redactions: result.redactions + next.redactions,
877
+ ...(hasPlaceholders(options)
878
+ ? {
879
+ irreversibleRedactions:
880
+ (result.irreversibleRedactions ?? 0) +
881
+ (next.irreversibleRedactions ?? 0),
882
+ }
883
+ : {}),
864
884
  findings: auditFaked(findings, options, result),
865
885
  });
866
886
  const next = rerun({ ...options, findings: [] }, round + 1);