@link-foundation/sensitive-data-sanitizer 0.13.0 → 0.15.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7e977ac: Match known Russian names in all six cases and both word orders, with `ё`/`е` variants, initials and surname-plus-given forms. Give all case forms of one known person, organization, school or location one declined fake identity with natural names and an optional `pools` override. Keep `@handle/email` and `Name-99` organization spans whole so placeholders stay restorable and are never glued to digits. Detect employers in CV experience lines of the `documents` profile.
8
+
9
+ ## 0.14.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.13.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -55,6 +55,15 @@ 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
+
63
+ Known Russian names match in all six cases and in any word order, with `ё`/`е`
64
+ spelling variants and initials (`с Петровым Данилом Сергеевичем`, `Петров Д. С.`).
65
+ A part of a longer name is matched alone only when it is listed itself.
66
+
58
67
  For irreversible output, choose `transformation: { mode: 'placeholder', map:
59
68
  false }`: no restore map is returned or written. Detection limits still apply
60
69
  in either mode. See the [placeholder API](docs/API.md#reversible-placeholders),
@@ -139,7 +148,9 @@ See [the API](docs/API.md) and [all issue 13 requirements and research](docs/cas
139
148
  `transformation: {mode: 'fake', key}` generates deterministic names, valid
140
149
  dates/MRZs/checksummed IDs and reserved email/domain destinations. Credentials
141
150
  remain fully redacted by default. Keep the key private: this preserves relations
142
- and structure and is pseudonymisation. See [realistic fakes](examples/realistic-fakes.mjs)
151
+ and structure and is pseudonymisation. Russian mentions of one known person or
152
+ entity share one fake identity declined to match the source (`Петров … С Петровым`
153
+ becomes `Баранов … С Барановым`), and `pools` overrides the entity names. See [realistic fakes](examples/realistic-fakes.mjs)
143
154
  and [the complete issue 24 requirements, research and solution plans](docs/case-studies/issue-24/REQUIREMENTS.md).
144
155
 
145
156
  Keep policies containing real private values outside Git. Built-in exact public knowledge and narrowly scoped role/domain heuristics are enabled offline. Explicit private literals override public exemptions. Keep custom public policies reviewed against evidence; `--verify-public` separately opts into Wikidata name lookup:
@@ -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,15 +88,32 @@ 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.
107
+
108
+ A listed Russian PERSON value also matches all six grammatical cases, including
109
+ the instrumental (`Петровым Данилом Сергеевичем`), every word order, `ё`/`е`
110
+ spelling differences in either direction, upper case, and initials with or
111
+ without spaces in both positions and all cases (`Петрову Д. С.`, `Д.С. Петрова`).
112
+ The contiguous name parts of a listed three-part name match together
113
+ (`Петров Данил`, `Данил Сергеевич`, `с Петровым Данилом`). A part on its own does
114
+ not: a bare `Данил` or `Сергеевич` is too ambiguous to imply from a longer value,
115
+ so list each part you want matched alone as its own `knownPersonal` entry. Listing
116
+ parts next to the full name keeps one token and one fake identity.
80
117
  Every replaced occurrence retains its exact original surface privately. With
81
118
  one identical token, a derived answer cannot encode the original grammatical
82
119
  case; it receives the canonical spelling. Two distinct originals that produce
@@ -99,28 +136,29 @@ derived model answer. Input, output and map paths must be separate. Maps are
99
136
  saved separately before stdout publication. `--profile documents` enables the
100
137
  new document recognizers; `documentEntities: false` turns them off explicitly.
101
138
 
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 |
139
+ | Option | Default / meaning |
140
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `documentEntities` | Off; bounded organization, education and location rules; enabled by the documents profile |
142
+ | `profile` | Off; `'publication'` sets a 0.3 threshold and identity masking; `'documents'` enables document entities |
143
+ | `knownOnly` | `false`; disable all automatic detection (including credentials, Secretlint and custom detectors); use only explicit known values and reused map identities |
144
+ | `knownSecrets` | `[]`; exact literal credential values, regardless of length or format |
145
+ | `knownPersonal` | `[]`; `{type, value}` values with whole-word Unicode case-insensitive matching and bounded Russian forms; list parts to match them alone |
146
+ | `publicEntities` | `[]`; exact PERSON/ORGANIZATION/EDUCATION/LOCATION/EMAIL exception with HTTPS evidence and review date |
147
+ | `findings` | `[]`; validated detections for the original input, supplied by the caller |
148
+ | `detectors` | `[]`; additional required callbacks in the asynchronous engine |
149
+ | `secretlint` | `true` in the asynchronous engine |
150
+ | `decode` | `true`; bounded base64/base64url, hex and percent decoding |
151
+ | `paranoid` | `false`; entropy-based supplemental credential heuristic |
152
+ | `maxInputLength` | 33,554,432 UTF-16 units |
153
+ | `maxFindings` | 100,000 detector hits before deduplication |
154
+ | `minConfidence` | 0.5; personal findings below this score remain visible to inspect but are not replaced |
155
+ | `threshold` | Alias for minConfidence; both options must agree when supplied |
156
+ | `debug` | Optional trusted callback for value-free counts, timing and event metadata |
157
+ | `identityMask` | `false`; identity types keep 2+2 Unicode characters with fixed `***`, or redact below five |
158
+ | `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
159
+ | `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
160
+ | `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
161
+ | `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
124
162
 
125
163
  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
164
 
@@ -177,6 +215,8 @@ All keyed modes require a key of at least 16 UTF-8 bytes. `format-preserving` is
177
215
 
178
216
  `{mode: 'fake', key}` infers name locale, script and gender from the supported gazetteer/datasets and recognizable surname/patronymic endings. Independent HMAC-seeded Faker instances make results independent of processing order. Russian Cyrillic/transliterated spelling, name order and filename separators share component identities. Ambiguous names can use a fallback locale; this is not universal name/gender inference. Dates use one keyed backward offset of 1–365 days and preserve ISO, slash and dot formats. Complete TD1/TD2/TD3 MRZs regenerate individual/composite checks and match separately faked name/date fields. Issuer/nationality remain original unless `mrzCountry: 'UTO'` is supplied. Unsupported/damaged formats fall back to full redaction.
179
217
 
218
+ Russian mentions of one person, organization, school or location share one fake identity. The fake follows the source word order, initials, capitalization, grammatical case and, for people, sex: `Петров пришёл. Позвонили Петрову. С Петровым.` becomes `Баранов пришёл. Позвонили Баранову. С Барановым.`, and `ООО «Ромашка» … в Ромашке` becomes `ООО «Импульс» … в Импульсе`. Declension uses finite offline rules for the generated vocabulary. The grammatical case comes from the source surface and the preceding preposition, so a source preposition stays as written even where the fake would call for another spelling (`о Осипове`). Organizations, schools and locations draw from an authored Russian or Latin vocabulary according to the script of the original, skip any name that is itself private in the text, and take a numeric suffix (`Заря-2`) once the vocabulary is exhausted. Each transformation object remembers its entity names so that two entities never share one, which makes a name depend on first appearance when two entities start on the same vocabulary entry; reuse the transformation object across documents to keep names stable, and note that this memory is not shared across worker threads or processes. `pools: {ORGANIZATION: [...], LOCATION: [...], EDUCATION: [...]}` on the fake transformation replaces a vocabulary with caller-reviewed non-empty names. Autodetected mentions are linked as in placeholder mode: a mention that appears only in oblique forms can remain a separate entity unless the value is listed in `knownPersonal`.
219
+
180
220
  Implemented national checksums, IBANs and payment-card Luhn checks remain valid. Phones retain calling code, length and separators; NANP uses 555-0100–0199 and UK mobile uses 07700 900000–900999. Email/domain outputs use example.com/.test. Other national identities and phone numbers have no universal reserved range: generated arithmetic does not prove that a value is unassigned.
181
221
 
182
222
  Generated replacements carry `faked: true`. A private bounded registry recognizes values produced by the same transformation context as `kept: 'fake'` during later inspection/verification. Caller-supplied flags and arbitrary FAKE-looking strings grant no exemption. A fresh sanitizer does not inherit that registry; reviewed persisted fake identity values can be configured through `fakeValues`. Credentials remain fully redacted unless `credentials: true` requests provider-shaped values containing FAKE and punctuation that violates token grammar. Generated personal values cannot exempt credential spans.
package/docs/COVERAGE.md CHANGED
@@ -29,22 +29,43 @@ 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
35
44
  exceptions for all three types; `knownPersonal` always forces removal of a
36
45
  listed private value. All types support placeholder mode; fake mode uses stable,
37
- visibly synthetic organization/school/location labels.
46
+ plausible organization/school/location names from an authored vocabulary or
47
+ caller-supplied `pools`, declined to match Russian source mentions. These names
48
+ are plausible and can coincide with real entities that the text does not mention.
38
49
 
39
50
  `src/document-vocabulary.js` is the complete authored vocabulary: English,
40
51
  Spanish, French, German, Portuguese, Russian, Japanese, Simplified/Traditional
41
52
  Chinese, Korean, Arabic, Hebrew, Hindi, Turkish, Indonesian, Vietnamese and
42
53
  Thai each have list-form employer, school and city fixtures. Proper-name
43
54
  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,
55
+ case); they do not implement grammatical parsing. Other lowercase connectors,
45
56
  unusual typography, unsupported legal forms and unfamiliar institutions can be
46
57
  missed. Legal-form words and uncased scripts can also yield false positives.
47
58
 
59
+ The `documents` profile also reads employers from CV experience lines anchored on
60
+ a date range: `Title, Employer (2019-05 – 2021-03)`, `Title — Employer, 2019–2021`
61
+ and `Title (Employer), 2019–2021`. Ranges use years, `YYYY-MM`, `MM.YYYY`, full
62
+ dates or `month YYYY`, with `по настоящее время`, `н.в.`, `present` and similar
63
+ open ends. Confidence is 0.60, and the employer then propagates through the
64
+ document like other organizations. The default profile ignores these lines. Lines
65
+ with more fields (`Title, Employer, City (dates)`) and titles containing a comma
66
+ are not targeted and can leave the employer in place; date-anchored prose with
67
+ the same shape can be flagged.
68
+
48
69
  The gazetteer has only the cities/regions explicitly listed in that module,
49
70
  including Springfield and Riverton. It is not a worldwide geocoder. Location
50
71
  labels can find unlisted proper names; unlabelled places outside the vocabulary
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.15.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);
@@ -241,11 +240,29 @@ function detectAuthorization(text, emit, credential) {
241
240
  }
242
241
  }
243
242
 
243
+ // `/` and `|` are valid local-part characters, so `@handle/jane@example.com`
244
+ // reads as one address that starts after `@`. A mention keeps its own finding;
245
+ // any other leading `@` joins the address so that it cannot survive as a handle.
246
+ function emailAfterMention(text, finding) {
247
+ if (
248
+ text[finding.start - 1] !== '@' ||
249
+ /[\p{L}\p{N}_.\]]/u.test(text[finding.start - 2] ?? '')
250
+ ) {
251
+ return finding;
252
+ }
253
+ const handle = /^[\p{L}\p{N}_]{2,32}[/|]/u.exec(
254
+ text.slice(finding.start, finding.end)
255
+ );
256
+ return handle
257
+ ? { ...finding, start: finding.start + handle[0].length }
258
+ : { ...finding, start: finding.start - 1 };
259
+ }
260
+
244
261
  function detectPersonalFormats(text, options, emit) {
245
262
  collectMatches(
246
263
  text,
247
264
  /(?<![\p{L}\p{N}_.!#$%&'*+/?^`{|}~-])[\p{L}\p{N}_.!#$%&'*+/?^`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{N}.-]{0,252})\.[\p{L}]{2,63}(?![\p{L}\p{N}_-])/gu,
248
- emit,
265
+ (finding) => emit(emailAfterMention(text, finding)),
249
266
  { type: 'EMAIL', category: 'personal', rule: 'email' }
250
267
  );
251
268
  collectMatches(
@@ -1,5 +1,8 @@
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';
5
+ import { months } from './personal-context.js';
3
6
 
4
7
  const words = (column) =>
5
8
  [...new Set(documentVocabulary.flatMap((row) => row[column].split('|')))]
@@ -14,7 +17,7 @@ const latinName = `${proper}(?:[ \\t]+${proper}){0,5}`;
14
17
  const uncased =
15
18
  '[\\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
19
  const name = `(?:${latinName}|${uncased}(?:[ \\t]+${uncased}){0,3})`;
17
- const quoted = `(?:[«“「『"]${name}[»”」』"]|${name})`;
20
+ const quoted = `(?:«[^«»\\r\\n]{1,160}»|“[^“”\\r\\n]{1,160}”|"[^"\\r\\n]{1,160}"|「[^「」\\r\\n]{1,160}」|『[^『』\\r\\n]{1,160}』|${name})`;
18
21
  const boundary = '[\\p{L}\\p{M}\\p{N}_]';
19
22
  const organizations = new RegExp(
20
23
  `(?<!${boundary})(?:(?:${legal})[ \\t]*${quoted}|${quoted}[ \\t]*(?:${legal}))(?!${boundary})`,
@@ -33,22 +36,104 @@ const labelledPlace = new RegExp(
33
36
  'gu'
34
37
  );
35
38
 
39
+ const ruWord = '[А-ЯЁа-яё][А-ЯЁа-яё-]{1,47}';
40
+ const ruSchool = new RegExp(
41
+ `(?<!${boundary})[А-ЯЁ][а-яё]{2,30}(?:-[А-ЯЁа-яё][а-яё]{1,30})?(?:[ \t]+[а-яё-]{2,40}){0,4}[ \t]+(?:университет|институт|колледж|школа|академия)(?:[ \t]+имени[ \t]+${ruWord}(?:[ \t]+[А-ЯЁ][а-яё-]{2,40}){0,2})?(?!${boundary})`,
42
+ 'gu'
43
+ );
44
+ const ruAcronym =
45
+ /(?<![\p{L}\p{N}_])(?:НИУ[ \t]+[А-ЯЁ]{2,12}|(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)(?=[, \t]+(?:магистратура|бакалавриат|университет|обучение)))(?![\p{L}\p{N}_])/gu;
46
+ const ruResidence =
47
+ /(?:[Гг]ород|[Пп]роживаю|[Пп]роживание|[Мм]есто жительства)[ \t]*:[ \t]*([А-ЯЁ][а-яё-]{2,47})|([А-ЯЁ][а-яё-]{2,47})(?=,[ \t]*(?:готов[а]?[ \t]+к[ \t]+переезду|Россия|Беларусь|Казахстан|Украина))/gu;
48
+ const ruIntroduction = new RegExp(
49
+ `(?:[Мм]еня зовут|[Сс] уважением)[, \t]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
50
+ 'gu'
51
+ );
52
+ const groupOrganization = new RegExp(
53
+ `(?<!${boundary})${latinName}[ \t]+(?:Групп|Group|Холдинг|Holding)(?!${boundary})`,
54
+ 'gu'
55
+ );
56
+ // CV experience lines: the date range anchors the employer slot, so prose with
57
+ // a comma is untouched. Titles are not required to be lowercase because English
58
+ // ones are often title case (`Senior QA Engineer`).
59
+ const capitalized = (word) => word[0].toUpperCase() + word.slice(1);
60
+ const month = [
61
+ ...new Set(months.split('|').flatMap((word) => [word, capitalized(word)])),
62
+ ].join('|');
63
+ const rangeStart = `(?:(?:19|20)\\d{2}(?:-\\d{2}){0,2}|\\d{2}[./](?:\\d{2}\\.)?\\d{4}|(?:${month})\\.?[ \\t]+(?:19|20)\\d{2})`;
64
+ const rangeOpen = ['по настоящее время', 'настоящее время', 'сейчас', 'н.в.']
65
+ .concat(['н/в', 'present', 'now', 'current'])
66
+ .flatMap((word) => [word, capitalized(word)])
67
+ .map(escapePattern)
68
+ .join('|');
69
+ const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen})(?!${boundary})`;
70
+ const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
71
+ const lineTitle = `^[ \\t]*(?:[-–—•*·][ \\t]+|\\d{1,2}[.)][ \\t]+)?${titleWord}(?:[ \\t]+${titleWord}){0,5}`;
72
+ const employerWord = "[\\p{L}\\p{M}\\p{N}&'’.+-]{1,40}";
73
+ const employer = `«[^«»\\r\\n]{1,160}»|"[^"\\r\\n]{1,160}"|“[^“”\\r\\n]{1,160}”|(?:\\p{Lu}|\\d{1,4}-?\\p{L})${employerWord}?(?:[ \\t]+${employerWord}){0,5}`;
74
+ const experienceLine = new RegExp(
75
+ `${lineTitle}(?:,[ \\t]+|[ \\t]+[-–—][ \\t]+)(${employer})(?:[ \\t]*\\(${range}\\)|[ \\t]*[,–—-][ \\t]*${range})`,
76
+ 'gmu'
77
+ );
78
+ const experienceParenthesis = new RegExp(
79
+ `${lineTitle}[ \\t]*\\((${employer})\\)(?:,?[ \\t]*${range}|[ \\t]*\\(${range}\\))`,
80
+ 'gmu'
81
+ );
82
+
36
83
  export function detectDocumentEntities(text, emit, options) {
37
84
  if (!options.documentEntities) {
38
85
  return;
39
86
  }
87
+ const found = [];
88
+ const capture = (finding) => {
89
+ found.push(finding);
90
+ emit(finding);
91
+ };
40
92
  for (const [type, rule, pattern, group, confidence] of [
93
+ ['ORGANIZATION', 'organization-group', groupOrganization, 0, 0.7],
94
+ ['EDUCATION', 'education-russian', ruSchool, 0, 0.65],
95
+ ['EDUCATION', 'education-acronym', ruAcronym, 0, 0.65],
96
+ ['PERSON', 'document-introduction', ruIntroduction, 1, 0.7],
41
97
  ['ORGANIZATION', 'organization-legal-form', organizations, 0, 0.7],
42
98
  ['EDUCATION', 'education-type', schools, 0, 0.65],
43
99
  ['LOCATION', 'location-gazetteer', places, 0, 0.6],
44
100
  ['LOCATION', 'location-label', labelledPlace, 1, 0.75],
101
+ ['ORGANIZATION', 'organization-experience', experienceLine, 1, 0.6],
102
+ ['ORGANIZATION', 'organization-experience', experienceParenthesis, 1, 0.6],
45
103
  ]) {
46
104
  collectMatches(
47
105
  text,
48
106
  pattern,
49
- emit,
107
+ capture,
50
108
  { type, rule, category: 'personal', confidence },
51
109
  group
52
110
  );
53
111
  }
112
+ for (const match of text.matchAll(ruResidence)) {
113
+ const value = match[1] ?? match[2];
114
+ const start = match.index + match[0].lastIndexOf(value);
115
+ emit({
116
+ start,
117
+ end: start + value.length,
118
+ type: 'LOCATION',
119
+ rule: 'location-residence',
120
+ category: 'personal',
121
+ confidence: 0.75,
122
+ });
123
+ }
124
+ // Organization cores are confirmed only by a bounded legal-form finding.
125
+ for (const finding of found.filter((f) => f.type === 'ORGANIZATION')) {
126
+ const core = organizationCore(text.slice(finding.start, finding.end));
127
+ if (core.length < 3 || core.length > 256) {
128
+ continue;
129
+ }
130
+ for (const match of text.matchAll(personalRegex(core, 'ORGANIZATION'))) {
131
+ emit({
132
+ ...finding,
133
+ start: match.index,
134
+ end: match.index + match[0].length,
135
+ rule: 'organization-core',
136
+ });
137
+ }
138
+ }
54
139
  }
@@ -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(