@link-foundation/sensitive-data-sanitizer 0.16.1 → 0.17.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 +18 -0
- package/docs/API.md +31 -0
- package/docs/COVERAGE.md +134 -2
- package/examples/presidio-bridge.py +15 -9
- package/package.json +23 -3
- package/src/context.js +20 -11
- package/src/credentials.js +22 -6
- package/src/detection.js +21 -6
- package/src/document-entities.js +4 -2
- package/src/document-extras.js +170 -0
- package/src/document-fields.js +19 -0
- package/src/document-patterns.js +36 -18
- package/src/engines.js +15 -4
- package/src/identity.js +3 -3
- package/src/index.d.ts +2 -0
- package/src/personal.js +1 -1
- package/src/public.js +1 -0
- package/src/sanitizer.js +29 -4
- package/src/structured.js +95 -0
- package/src/transforms.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add synchronous sensitiveFields schema redaction and consistent document field aliases. Protect percent-encoded URL contacts, personal paths, opaque share links, CV employers and contextual company names; preserve multiline empty attributes and redact escaped fragments without losing structured records.
|
|
8
|
+
|
|
9
|
+
Install and verify a checksum-pinned GitHub CLI in the preview screenshot container, and exercise the same publishing tools on pull requests before generated images reach main.
|
|
10
|
+
|
|
11
|
+
Check maintained Python helpers with Ruff and CodeQL, and audit every tracked Python requirements file, including transitive benchmark dependencies, without installing NLP packages.
|
|
12
|
+
|
|
13
|
+
## 0.16.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Make CI security and validation gates fail on incomplete evidence, recover transient release push failures, preserve validated release dependencies, and prevent quadratic scanning of PuTTY headers, travel identifiers and postal-address whitespace.
|
|
18
|
+
|
|
19
|
+
Preserve durations, stack frames, empty document fields and local bind addresses; protect personal URL paths and additional document forms. Reduce repeated label-pattern construction and document structured JSON per-call costs.
|
|
20
|
+
|
|
3
21
|
## 0.16.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/docs/API.md
CHANGED
|
@@ -176,6 +176,7 @@ new document recognizers; `documentEntities: false` turns them off explicitly.
|
|
|
176
176
|
| `fakeIdentity` | Off; `'specimen-and-synthetic'` retains explicitly fake identity values |
|
|
177
177
|
| `fakeValues` | `[]`; exact caller-reviewed identity values to retain, regardless of fakeIdentity |
|
|
178
178
|
| `structured` | Off; `'json'` or `'jsonl'` validates and sanitizes string keys/values separately |
|
|
179
|
+
| `sensitiveFields` | `[]`; exact JSON/JSONL property names whose complete values become a redaction string; supports synchronous calls |
|
|
179
180
|
| `structuralFields` | `[]`; declared property names suppress weak name/ID/entropy heuristics in their string values |
|
|
180
181
|
|
|
181
182
|
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.
|
|
@@ -474,3 +475,33 @@ restores IP detection; explicit `knownSecrets` and `knownPersonal` retain
|
|
|
474
475
|
priority. No shell command or workflow expression is executed.
|
|
475
476
|
|
|
476
477
|
Authorization headers (including Proxy-Authorization) redact the scheme and one credential token. Whitespace, C0 controls (including NUL), DEL, quotes, backslashes and commas end the value, preserving subsequent log text in shell and escaped JSON prompts. Matching brackets opened within a token are included in its redaction; unmatched closing brackets remain part of the surrounding log. Already-redacted tokens and known credential references remain exempt after an optional scheme, including `<token>` placeholders. The debug callback receives value-free `gitleaks-rule` events with rule ID, input length and elapsed milliseconds; tracing is off by default.
|
|
478
|
+
|
|
479
|
+
## Caller-declared sensitive fields
|
|
480
|
+
|
|
481
|
+
In structured JSON/JSONL, `sensitiveFields: string[]` matches exact,
|
|
482
|
+
case-sensitive property names at any depth. The complete property value becomes
|
|
483
|
+
`"[REDACTED]"`, including empty strings, numbers, booleans, null, arrays and
|
|
484
|
+
objects. This deliberate schema policy can change value types. It takes
|
|
485
|
+
priority over `structuralFields` and transformations, preserves property names
|
|
486
|
+
and surrounding whitespace, and works in both `sanitize` and `createSanitizer`.
|
|
487
|
+
It has no effect without `structured`. The personal-data switch
|
|
488
|
+
`credentialsOnly` still suppresses personal findings.
|
|
489
|
+
|
|
490
|
+
```js
|
|
491
|
+
sanitize('{"privateNote":"arbitrary free text","count":2}', {
|
|
492
|
+
structured: 'json',
|
|
493
|
+
sensitiveFields: ['privateNote'],
|
|
494
|
+
}).text;
|
|
495
|
+
// {"privateNote":"[REDACTED]","count":2}
|
|
496
|
+
|
|
497
|
+
sanitize('Lyutik работал', {
|
|
498
|
+
profile: 'documents',
|
|
499
|
+
knownPersonal: [{ type: 'ORGANIZATION', value: 'Lyutik' }],
|
|
500
|
+
}).text;
|
|
501
|
+
// [REDACTED] работал
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`knownPersonal` is the synchronous custom-value list, including names without
|
|
505
|
+
context. For computed synchronous detection, callers can supply validated
|
|
506
|
+
`findings` for the exact input. `detectors` remains an asynchronous-engine
|
|
507
|
+
option; `sanitize` does not invoke these callbacks.
|
package/docs/COVERAGE.md
CHANGED
|
@@ -236,7 +236,8 @@ The `documents` profile additionally recognizes:
|
|
|
236
236
|
forms and lower case, and surnames after г-жа/господин/госпожа.
|
|
237
237
|
- Birth years with д.р./г.р./год рождения/родился/родилась/Born in context;
|
|
238
238
|
Russian age expressions and Пол labels, English Sex, and gender beside age.
|
|
239
|
-
|
|
239
|
+
Bare age-in-years expressions are ambiguous and remain unchanged without
|
|
240
|
+
affirmative personal-age context; durations are preserved.
|
|
240
241
|
- Labelled citizenship, marital status, children, nationality, religion,
|
|
241
242
|
disability and criminal record, including the reported English labels.
|
|
242
243
|
- Company/employer/workplace labels, capitalized company/holding/bank names,
|
|
@@ -258,7 +259,7 @@ recognizer when these fields must be removed. Initial/name rules do not cover
|
|
|
258
259
|
all surnames, languages, cases or arbitrary lower-case names.
|
|
259
260
|
|
|
260
261
|
Operational value policy (#79): a single-slash relative URL with no query,
|
|
261
|
-
fragment, userinfo or explicit user/profile/member/home path stays unchanged
|
|
262
|
+
fragment, userinfo or explicit u/in/user/profile/member/home/people path stays unchanged
|
|
262
263
|
under `url`; other detectors and known values still apply. An ISO-style
|
|
263
264
|
`YYYY-MM-DDTHH:mm:ss[.fraction](Z|±HH:mm)` timestamp under `time` stays unchanged.
|
|
264
265
|
This exemption is syntactic, not date validation. Userinfo, token-bearing URLs
|
|
@@ -270,3 +271,134 @@ provider-specific rules and required external scanners remain authoritative.
|
|
|
270
271
|
|
|
271
272
|
The [complete requirement inventory and alternatives](case-studies/issue-84/README.md)
|
|
272
273
|
links every reported text row to its regression case.
|
|
274
|
+
|
|
275
|
+
## Document context refinements (issues 87–88)
|
|
276
|
+
|
|
277
|
+
Age requires a label (`возраст 41`, `Age 41`), personal pronoun (`мне 29 лет`),
|
|
278
|
+
birthday verb (`исполнилось 34`) or gender followed by age (`Женщина, 34 года`).
|
|
279
|
+
Bare `34 года` remains unchanged as an ambiguous limit. Work experience, tenure,
|
|
280
|
+
contract lengths and elapsed/future durations stay unchanged. `Дети: нет` and
|
|
281
|
+
`Гражданство: не указано` are treated as empty fields; negative disability and
|
|
282
|
+
criminal-record statements remain sensitive attributes.
|
|
283
|
+
|
|
284
|
+
Additional document forms include `контактный телефон` and `сот.` phone labels,
|
|
285
|
+
parenthesized `(собака)` email separators, `телега`/`Zoom` messenger labels,
|
|
286
|
+
dated `ТП УФМС`/`УФМС` passport issuers, `р/с` account numbers, `г-н` surname
|
|
287
|
+
titles, `1985 года рождения` and `г.р. 1985`, abbreviated/single-letter gender
|
|
288
|
+
values (`Пол: Ж`, `Пол: М`, `пол: жен.`, `пол муж.`), `гражданин РФ`,
|
|
289
|
+
`есть двое детей`, `проживаю в Перми`, and English `Employer`, `Company` and
|
|
290
|
+
`Education` labels. A capitalized Cyrillic word followed by two initials is
|
|
291
|
+
recognized regardless of surname ending (`Цой В. Р.`, `Ким Д. С.`,
|
|
292
|
+
`Шевченко Т. Г.`). This structural rule can flag non-name text with the same
|
|
293
|
+
shape; it does not provide a complete surname dictionary.
|
|
294
|
+
|
|
295
|
+
JSON escape boundaries cannot start an obfuscated-email local part, preserving
|
|
296
|
+
stack newlines and frames after IP:port. Relative `/u/<name>` and `/in/<name>`
|
|
297
|
+
URLs are personal paths under `url`, like the existing user/profile paths.
|
|
298
|
+
Exact `0.0.0.0` is an operational unspecified bind address, like loopback/local
|
|
299
|
+
addresses. `publicKnowledge: false` and explicit private known values still
|
|
300
|
+
redact it.
|
|
301
|
+
|
|
302
|
+
## Structured JSON cost (issue 86)
|
|
303
|
+
|
|
304
|
+
Structured sanitization scans labelled raw strings and each decoded string with
|
|
305
|
+
its own line boundaries, then verifies sanitized output. This preserves detection
|
|
306
|
+
in arrays, CV fragments and embedded JSON, but adds fixed work per string/key.
|
|
307
|
+
Costs depend on string count, enabled engines, content, runtime and hardware;
|
|
308
|
+
line length alone cannot predict synchronous main-thread delay.
|
|
309
|
+
|
|
310
|
+
A finite serial measurement on Linux x64, Node 26.13.0, using the documents
|
|
311
|
+
profile and structured JSON, produced these median milliseconds per `sanitize`
|
|
312
|
+
call (100 warm-ups, three 100-call trials):
|
|
313
|
+
|
|
314
|
+
| Fixture | 0.16.0 | 0.16.1 | This change |
|
|
315
|
+
| ----------------------------------------- | -----: | -----: | ----------: |
|
|
316
|
+
| Short JSON (89 characters) | 1.21 | 8.44 | 4.67 |
|
|
317
|
+
| Exact issue reproduction (264 characters) | 1.81 | 19.71 | 11.15 |
|
|
318
|
+
| Longer synthetic JSON (9,396 characters) | 10.04 | 37.88 | 28.32 |
|
|
319
|
+
|
|
320
|
+
The short and longer synthetic rows approximate the issue's unpublished
|
|
321
|
+
100/9,453-character inputs; only the 264-character input was provided in full.
|
|
322
|
+
These results are evidence for this container, not a universal latency guarantee
|
|
323
|
+
or an Apple Silicon comparison. The issue reporter measured 0.16.1 at
|
|
324
|
+
2.31/5.61/34.85 ms on their short/264-character/9 KiB rows and 1.10 ms for the
|
|
325
|
+
264-character text-mode control. The remaining structured overhead is **above
|
|
326
|
+
2x** of 0.16.0 here; documenting that cost fulfills the issue's alternative
|
|
327
|
+
acceptance criterion. No detection view is removed to meet a timing target.
|
|
328
|
+
|
|
329
|
+
Personal-label patterns are now constructed once instead of for every scan, and
|
|
330
|
+
redundant explicit RegExp cloning is avoided when `matchAll` supplies isolation.
|
|
331
|
+
Reproduce with `node --max-old-space-size=256 experiments/issue-89/benchmark.mjs`
|
|
332
|
+
and optionally pass a released checkout's `src/index.js`. For latency-sensitive
|
|
333
|
+
logging, measure the real workload before synchronously sanitizing every line;
|
|
334
|
+
the existing JSONL record-worker API/CLI can move record scanning off the main
|
|
335
|
+
thread (see [API.md](API.md)). External engines and more sensitive values can add
|
|
336
|
+
further work. These numbers do not describe `createSanitizer` with Secretlint.
|
|
337
|
+
|
|
338
|
+
The [complete issue 89 requirement inventory](case-studies/issue-89/README.md)
|
|
339
|
+
records every row, root cause, alternative and regression mapping.
|
|
340
|
+
|
|
341
|
+
## Document URL and field refinements (issues 91 and 94)
|
|
342
|
+
|
|
343
|
+
The document profile detects `email=` and `phone=` query values in absolute and
|
|
344
|
+
relative URLs and access-log text, retaining the label while percent-decoding.
|
|
345
|
+
It replaces the same complete value for plain and percent-encoded contacts,
|
|
346
|
+
without reserializing unaffected URL bytes. Invalid percent escapes do not
|
|
347
|
+
qualify through this decoding rule. Personal `/u/`, `/in/`, `/user(s)/`,
|
|
348
|
+
`/profile(s)/`, `/people/`, `/member(s)/` and `/home/` paths apply independently
|
|
349
|
+
of the containing field label. These paths can also describe public accounts;
|
|
350
|
+
the rule cannot establish identity or access permissions.
|
|
351
|
+
|
|
352
|
+
An absolute HTTP(S) URL with a 24–128-character ASCII alphanumeric/underscore/
|
|
353
|
+
hyphen path segment is treated as a possible share link and replaced, including
|
|
354
|
+
Google Drive/Docs and generic `/s/<opaque-id>` links. This is a conservative
|
|
355
|
+
shape rule, not a check of sharing permissions or entropy; public URLs with
|
|
356
|
+
long segments may also match. Ordinary documentation paths and provider roots
|
|
357
|
+
remain unchanged. Personal site roots such as
|
|
358
|
+
`портфолио https://ivan-petrov.example.org/` remain a documented limit: a root
|
|
359
|
+
alone cannot establish that the site belongs to a person.
|
|
360
|
+
|
|
361
|
+
Structured document field aliases remove `_`/`-` and ignore case before matching
|
|
362
|
+
this explicit vocabulary: `firstName`, `lastName`, `middleName`, `patronymic`,
|
|
363
|
+
`birthday`, `birthDate`, `sex`, `citizenship`, `nationality`, `company`,
|
|
364
|
+
`companyName`, `employer`, `university`, `school`, `contact`, `resumeUrl`,
|
|
365
|
+
`alternateUrl`, `profileUrl`, `companyUrl`. Their nonempty string values are
|
|
366
|
+
sensitive in full, including ISO birth timestamps. Thus `first_name` and
|
|
367
|
+
`firstName`, or `birth_date` and `birthDate`, follow the same policy. These are
|
|
368
|
+
schema assumptions: `contact` or `company` can hold operational data too.
|
|
369
|
+
Existing native labels still apply. Unlisted URL-named fields have ordinary
|
|
370
|
+
content detection; arbitrary names do not imply sensitivity. Declare them with
|
|
371
|
+
[`sensitiveFields`](API.md#caller-declared-sensitive-fields) to replace complete
|
|
372
|
+
values, including non-string values. Generic numeric primitives otherwise keep
|
|
373
|
+
the existing type-preservation policy.
|
|
374
|
+
|
|
375
|
+
Employer labels consume multi-word values to a comma or line/quote boundary.
|
|
376
|
+
Dated CV entries additionally support compound titles, title parentheses/slashes,
|
|
377
|
+
quoted group names, legal-form/location suffixes, empty/`null` range ends,
|
|
378
|
+
multiple entries, employer-first role entries and pipe-separated entries.
|
|
379
|
+
The supplemental grammar uses finite title cues and bounded entries. Undated
|
|
380
|
+
`Менеджер, Ромашка Лютик`, `- Ромашка Лютик 5 лет` and
|
|
381
|
+
`Последнее место работы Ромашка Лютик` remain explicit ambiguous limits, as
|
|
382
|
+
permitted by #94. Use known values or schema declarations for them.
|
|
383
|
+
|
|
384
|
+
Company prose recognizes `компании «…»`, `опыт в`, `Роль в`, `проекты для`,
|
|
385
|
+
capitalized lists after those cues, `опыт (Company Name)`, and names followed
|
|
386
|
+
by `(2 года)`/`(3 года)`-style durations. Arbitrary uncued company words remain
|
|
387
|
+
ambiguous; synchronous callers can supply `knownPersonal` lists or precomputed
|
|
388
|
+
`findings`. Custom `detectors` callbacks are still used by `createSanitizer`
|
|
389
|
+
only. No model or network service is required for these native rules.
|
|
390
|
+
|
|
391
|
+
`контакт:` and `пишите,` introduce domestic mobile numbers. A complete line
|
|
392
|
+
consisting of an 11-digit `79…` or `89…` mobile shape is also sensitive. Arbitrary
|
|
393
|
+
prose numbers such as `order 89161234567 shipped` and `id 79161234567` remain
|
|
394
|
+
unchanged. This shape rule does not prove that a number is assigned.
|
|
395
|
+
|
|
396
|
+
Additional forms cover Cyrillic acronyms after `Education:`, `учился в`,
|
|
397
|
+
`Соискатель:`/`Сотрудник:` names and split-line `С уважением,` signatures.
|
|
398
|
+
`Дети: нет` remains an empty attribute with later JSON string lines preserved.
|
|
399
|
+
When mapped escaped fragments lose detecting context on recursive decoding,
|
|
400
|
+
redaction replaces the fragment instead of repeating an unchanged result until
|
|
401
|
+
`ERR_RESIDUAL`; the surrounding JSON remains parseable.
|
|
402
|
+
|
|
403
|
+
The [requirement inventory and research](case-studies/issue-100/README.md)
|
|
404
|
+
records the complete scope and regression evidence.
|
|
@@ -5,28 +5,34 @@ Example: python -m pip install presidio-analyzer
|
|
|
5
5
|
Configure NlpEngineProvider here for other installed language models.
|
|
6
6
|
This script does not download models or contact a remote analysis service.
|
|
7
7
|
"""
|
|
8
|
+
|
|
8
9
|
import argparse
|
|
9
10
|
import json
|
|
10
11
|
import sys
|
|
11
12
|
|
|
13
|
+
import spacy
|
|
12
14
|
from presidio_analyzer import AnalyzerEngine
|
|
13
15
|
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
|
14
|
-
import spacy
|
|
15
16
|
|
|
16
17
|
parser = argparse.ArgumentParser()
|
|
17
|
-
parser.add_argument(
|
|
18
|
-
parser.add_argument(
|
|
18
|
+
parser.add_argument("--language", default="en")
|
|
19
|
+
parser.add_argument("--model", default="en_core_web_lg")
|
|
19
20
|
options = parser.parse_args()
|
|
20
21
|
if not spacy.util.is_package(options.model):
|
|
21
|
-
raise SystemExit(
|
|
22
|
+
raise SystemExit("Install the selected model before running this offline bridge.")
|
|
22
23
|
|
|
23
24
|
text = sys.stdin.read(10 * 1024 * 1024 + 1)
|
|
24
25
|
if len(text) > 10 * 1024 * 1024:
|
|
25
26
|
raise SystemExit(2)
|
|
26
|
-
nlp = NlpEngineProvider(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
27
|
+
nlp = NlpEngineProvider(
|
|
28
|
+
nlp_configuration={
|
|
29
|
+
"nlp_engine_name": "spacy",
|
|
30
|
+
"models": [{"lang_code": options.language, "model_name": options.model}],
|
|
31
|
+
}
|
|
32
|
+
).create_engine()
|
|
30
33
|
analyzer = AnalyzerEngine(nlp_engine=nlp, supported_languages=[options.language])
|
|
31
34
|
results = analyzer.analyze(text=text, language=options.language)
|
|
32
|
-
json.dump(
|
|
35
|
+
json.dump(
|
|
36
|
+
[{"start": r.start, "end": r.end, "entity_type": r.entity_type} for r in results],
|
|
37
|
+
sys.stdout,
|
|
38
|
+
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-foundation/sensitive-data-sanitizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
94
|
"@changesets/cli": "^3.0.3",
|
|
95
|
-
"eslint": "^
|
|
95
|
+
"eslint": "^10.13.0",
|
|
96
96
|
"eslint-config-prettier": "^10.1.8",
|
|
97
97
|
"eslint-plugin-prettier": "^5.5.4",
|
|
98
98
|
"husky": "^9.1.7",
|
|
@@ -101,7 +101,10 @@
|
|
|
101
101
|
"mrz": "^5.0.2",
|
|
102
102
|
"prettier": "^3.6.2",
|
|
103
103
|
"secretlint": "13.0.7",
|
|
104
|
-
"test-anywhere": "^0.8.48"
|
|
104
|
+
"test-anywhere": "^0.8.48",
|
|
105
|
+
"@eslint/js": "^10.0.1",
|
|
106
|
+
"command-stream": "2.4.0",
|
|
107
|
+
"lino-arguments": "0.4.0"
|
|
105
108
|
},
|
|
106
109
|
"lint-staged": {
|
|
107
110
|
"*.{js,mjs,cjs}": [
|
|
@@ -112,6 +115,17 @@
|
|
|
112
115
|
"*.md": [
|
|
113
116
|
"prettier --write",
|
|
114
117
|
"prettier --check"
|
|
118
|
+
],
|
|
119
|
+
"*.sh": [
|
|
120
|
+
"pipx run --spec shellcheck-py==0.11.0.1 shellcheck"
|
|
121
|
+
],
|
|
122
|
+
"*.py": [
|
|
123
|
+
"pipx run --spec ruff==0.17.3 ruff check --fix --force-exclude",
|
|
124
|
+
"pipx run --spec ruff==0.17.3 ruff format --force-exclude",
|
|
125
|
+
"pipx run --spec ruff==0.17.3 ruff format --check --force-exclude"
|
|
126
|
+
],
|
|
127
|
+
".husky/*": [
|
|
128
|
+
"pipx run --spec shellcheck-py==0.11.0.1 shellcheck"
|
|
115
129
|
]
|
|
116
130
|
},
|
|
117
131
|
"dependencies": {
|
|
@@ -124,5 +138,11 @@
|
|
|
124
138
|
"re2-wasm": "1.0.2",
|
|
125
139
|
"re2js": "^2.9.0",
|
|
126
140
|
"yaml": "^2.9.1"
|
|
141
|
+
},
|
|
142
|
+
"allowScripts": {
|
|
143
|
+
"node-pty": false
|
|
144
|
+
},
|
|
145
|
+
"overrides": {
|
|
146
|
+
"minimatch@>=7 <11": "^11.0.4"
|
|
127
147
|
}
|
|
128
148
|
}
|
package/src/context.js
CHANGED
|
@@ -247,7 +247,7 @@ function operationalValue(value, type) {
|
|
|
247
247
|
return (
|
|
248
248
|
(type === 'URL' &&
|
|
249
249
|
/^\/(?!\/)[^\s?@#]*$/.test(value) &&
|
|
250
|
-
!/(?:^|\/)(?:users?|profiles?|people|members?|home)\/[^/]+/i.test(
|
|
250
|
+
!/(?:^|\/)(?:u|in|users?|profiles?|people|members?|home)\/[^/]+/i.test(
|
|
251
251
|
value
|
|
252
252
|
)) ||
|
|
253
253
|
(type === 'TIME' &&
|
|
@@ -393,19 +393,24 @@ function detectCredentials(text, emit) {
|
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
396
|
+
const personalPrefixes = Object.entries(labels)
|
|
397
|
+
.filter(([type]) => type !== 'credential')
|
|
398
|
+
.map(([type, words]) => {
|
|
401
399
|
const separator =
|
|
402
400
|
type === 'ID'
|
|
403
401
|
? `(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))?[ \\t]+|[:=:][ \\t]*`
|
|
404
402
|
: `(?:[:=:]|(?:${verbs})(?![\\p{L}\\p{N}_]))[ \\t]*`;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
403
|
+
return [
|
|
404
|
+
type,
|
|
405
|
+
new RegExp(
|
|
406
|
+
`(?<![\\p{L}\\p{N}_])(?:\\\\*["'])?(?:${words.map(escapePattern).join('|')})(?![\\p{L}\\p{N}_])(?:\\\\*["'])?[ \\t]*(?:${separator})`,
|
|
407
|
+
'giu'
|
|
408
|
+
),
|
|
409
|
+
];
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
function detectPersonalLabels(text, emit) {
|
|
413
|
+
for (const [type, prefix] of personalPrefixes) {
|
|
409
414
|
for (const match of text.matchAll(prefix)) {
|
|
410
415
|
const identity = type === 'ID';
|
|
411
416
|
const suffix = identity
|
|
@@ -416,7 +421,11 @@ function detectPersonalLabels(text, emit) {
|
|
|
416
421
|
const range = valueRange(
|
|
417
422
|
text,
|
|
418
423
|
match.index + match[0].length + suffix.length,
|
|
419
|
-
|
|
424
|
+
/[?&]$/.test(text.slice(0, match.index))
|
|
425
|
+
? 'query'
|
|
426
|
+
: identity
|
|
427
|
+
? 'token'
|
|
428
|
+
: 'line',
|
|
420
429
|
512
|
|
421
430
|
);
|
|
422
431
|
if (range && personalValueAllowed(text, range, type, match.index)) {
|
package/src/credentials.js
CHANGED
|
@@ -45,10 +45,26 @@ export function detectCredentialFormats(text, emit) {
|
|
|
45
45
|
(value) =>
|
|
46
46
|
!credentialPlaceholder(value) && value !== 'put your unique phrase here'
|
|
47
47
|
);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
detectPuttyPrivateKeys(text, emit);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scan each consumed range once. A regex combining an unbounded header line
|
|
52
|
+
// and a lazy body rescans every repeated header when the newline is missing.
|
|
53
|
+
function detectPuttyPrivateKeys(text, emit) {
|
|
54
|
+
const headers = /PuTTY-User-Key-File-[23]:/g;
|
|
55
|
+
const lineEnd = /[\r\n]/g;
|
|
56
|
+
const mac = /Private-MAC:[ \t]*[a-fA-F0-9]+/g;
|
|
57
|
+
let header;
|
|
58
|
+
while ((header = headers.exec(text))) {
|
|
59
|
+
lineEnd.lastIndex = headers.lastIndex;
|
|
60
|
+
const newline = lineEnd.exec(text);
|
|
61
|
+
if (!newline) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
mac.lastIndex = newline.index + 1;
|
|
65
|
+
const terminator = mac.exec(text);
|
|
66
|
+
const end = terminator ? mac.lastIndex : text.length;
|
|
67
|
+
emit({ start: header.index, end, ...details('putty-private-key') });
|
|
68
|
+
headers.lastIndex = end;
|
|
69
|
+
}
|
|
54
70
|
}
|
package/src/detection.js
CHANGED
|
@@ -58,11 +58,14 @@ export function collectMatches(
|
|
|
58
58
|
group = 0,
|
|
59
59
|
accept = () => true
|
|
60
60
|
) {
|
|
61
|
-
//
|
|
62
|
-
const pattern =
|
|
63
|
-
regex.
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// matchAll clones global expressions and leaves their lastIndex untouched.
|
|
62
|
+
const pattern =
|
|
63
|
+
regex.global && regex.lastIndex === 0
|
|
64
|
+
? regex
|
|
65
|
+
: new RegExp(
|
|
66
|
+
regex.source,
|
|
67
|
+
regex.global ? regex.flags : `${regex.flags}g`
|
|
68
|
+
);
|
|
66
69
|
for (const match of text.matchAll(pattern)) {
|
|
67
70
|
const raw = match[group];
|
|
68
71
|
const value = details.category === 'personal' ? raw?.trim() : raw;
|
|
@@ -270,7 +273,19 @@ function detectPersonalFormats(text, options, emit) {
|
|
|
270
273
|
collectMatches(
|
|
271
274
|
text,
|
|
272
275
|
/(?<!\d)\+\d[\d ()-]{6,24}\d(?!\d)/g,
|
|
273
|
-
|
|
276
|
+
(finding) => {
|
|
277
|
+
if (
|
|
278
|
+
/[?&]phone=$/i.test(
|
|
279
|
+
text.slice(Math.max(0, finding.start - 8), finding.start)
|
|
280
|
+
)
|
|
281
|
+
) {
|
|
282
|
+
const token = /^[^\s&#]+/.exec(
|
|
283
|
+
text.slice(finding.start, finding.end)
|
|
284
|
+
)[0];
|
|
285
|
+
finding.end = finding.start + token.length;
|
|
286
|
+
}
|
|
287
|
+
emit(finding);
|
|
288
|
+
},
|
|
274
289
|
{ type: 'PHONE', category: 'personal', rule: 'international-phone' },
|
|
275
290
|
0,
|
|
276
291
|
(v) => {
|
package/src/document-entities.js
CHANGED
|
@@ -3,6 +3,7 @@ import { firstNames } from './names.js';
|
|
|
3
3
|
import { organizationCore, personalRegex } from './personal-variants.js';
|
|
4
4
|
import { documentVocabulary } from './document-vocabulary.js';
|
|
5
5
|
import { months } from './personal-context.js';
|
|
6
|
+
import { detectDocumentExtras } from './document-extras.js';
|
|
6
7
|
import { detectDocumentPatterns } from './document-patterns.js';
|
|
7
8
|
|
|
8
9
|
const words = (column) =>
|
|
@@ -47,7 +48,7 @@ const ruAcronym =
|
|
|
47
48
|
const ruResidence =
|
|
48
49
|
/(?:[Гг]ород|[Пп]роживаю|[Пп]роживание|[Мм]есто жительства)[ \t]*:[ \t]*([А-ЯЁ][а-яё-]{2,47})|([А-ЯЁ][а-яё-]{2,47})(?=,[ \t]*(?:готов[а]?[ \t]+к[ \t]+переезду|Россия|Беларусь|Казахстан|Украина))/gu;
|
|
49
50
|
const ruIntroduction = new RegExp(
|
|
50
|
-
`(?:[Мм]еня зовут|[Сс] уважением)[, \t]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
|
|
51
|
+
`(?:[Мм]еня зовут|[Сс] уважением)[, \t\r\n]+(${firstNames.ru.split(' ').join('|')})(?!${boundary})`,
|
|
51
52
|
'gu'
|
|
52
53
|
);
|
|
53
54
|
const groupOrganization = new RegExp(
|
|
@@ -67,7 +68,7 @@ const rangeOpen = ['по настоящее время', 'настоящее в
|
|
|
67
68
|
.flatMap((word) => [word, capitalized(word)])
|
|
68
69
|
.map(escapePattern)
|
|
69
70
|
.join('|');
|
|
70
|
-
const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen})(?!${boundary})`;
|
|
71
|
+
const range = `${rangeStart}(?:[ \\t]*[-–—][ \\t]*|[ \\t]+(?:по|to)[ \\t]+)(?:${rangeStart}|${rangeOpen}|null|(?=\\)))(?!${boundary})`;
|
|
71
72
|
const titleWord = '[\\p{L}\\p{N}][\\p{L}\\p{N}/+#.&-]{0,39}';
|
|
72
73
|
// A segment starts a line or follows `|`. `Опыт:` or `Опыт -` before the title
|
|
73
74
|
// reads like a list marker: the date range remains the anchor of the rule.
|
|
@@ -89,6 +90,7 @@ export function detectDocumentEntities(text, emit, options) {
|
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
91
92
|
detectDocumentPatterns(text, emit);
|
|
93
|
+
detectDocumentExtras(text, emit, range);
|
|
92
94
|
const found = [];
|
|
93
95
|
const capture = (finding) => {
|
|
94
96
|
found.push(finding);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { collectMatches } from './detection.js';
|
|
2
|
+
|
|
3
|
+
const boundary = '[\\p{L}\\p{N}_]';
|
|
4
|
+
const proper = '[\\p{Lu}][\\p{L}\\p{M}\\p{N}.-]{0,46}[\\p{L}\\p{M}\\p{N}]';
|
|
5
|
+
const name = `${proper}(?:[ \\t]+${proper}){0,5}`;
|
|
6
|
+
const orgList = `(?:«[^«»\\r\\n]{1,160}»|${name})(?:[ \\t]*(?:,|и|and)[ \\t]+${name}){0,5}`;
|
|
7
|
+
const cues = new RegExp(
|
|
8
|
+
`(?<!${boundary})(?:компании|опыт[ \\t]+в|проекты[ \\t]+для|[Рр]оль[ \\t]+в)[ \\t]+(${orgList})(?!${boundary})`,
|
|
9
|
+
'gu'
|
|
10
|
+
);
|
|
11
|
+
const duration = new RegExp(
|
|
12
|
+
`(?<!${boundary})(${name})(?=[ \\t]*\\(\\d{1,2}[ \\t]+(?:год|года|лет|years?)\\))`,
|
|
13
|
+
'gu'
|
|
14
|
+
);
|
|
15
|
+
// JavaScript's \b is ASCII-only; Cyrillic titles use a Unicode lookahead.
|
|
16
|
+
const role =
|
|
17
|
+
/^(?:менеджер|директор|руководитель|инженер|аналитик|разработчик)(?![\p{L}])|^(?:manager|director|engineer|developer|senior|qa)\b/iu;
|
|
18
|
+
|
|
19
|
+
export function detectDocumentExtras(text, emit, range) {
|
|
20
|
+
for (const [type, rule, pattern, group] of [
|
|
21
|
+
['PHONE', 'document-mobile-bare', /^[78]9\d{9}$/gm, 0],
|
|
22
|
+
[
|
|
23
|
+
'PHONE',
|
|
24
|
+
'document-mobile-context',
|
|
25
|
+
/(?<![\p{L}\p{N}_])(?:пишите|контакт)[:,]?[ \t]+([78]9\d{9})(?!\d)/giu,
|
|
26
|
+
1,
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
'ORGANIZATION',
|
|
30
|
+
'organization-experience-prose',
|
|
31
|
+
new RegExp(`опыт[ \\t]*\\((${name})\\)`, 'gu'),
|
|
32
|
+
1,
|
|
33
|
+
],
|
|
34
|
+
['ORGANIZATION', 'organization-duration', duration, 1],
|
|
35
|
+
[
|
|
36
|
+
'PERSON',
|
|
37
|
+
'document-role',
|
|
38
|
+
/(?<![\p{L}\p{N}_])(?:Соискатель|Сотрудник)[ \t]*:[ \t]*([\p{Lu}][\p{L}-]{1,47}(?:[ \t]+[\p{Lu}][\p{L}-]{1,47}){0,4})/gu,
|
|
39
|
+
1,
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
'URL',
|
|
43
|
+
'document-personal-path',
|
|
44
|
+
/(?<![\p{L}\p{N}_])(?:https?:\/\/[^\s/"<>]+)?\/(?:u|in|users?|profiles?|people|members?|home)\/[^\s?&#"<>\\]{1,128}/giu,
|
|
45
|
+
0,
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'URL',
|
|
49
|
+
'document-share-link',
|
|
50
|
+
/https?:\/\/[^\s/"<>]+\/(?:[^\s/?#"<>]{1,64}\/){0,5}[A-Za-z0-9_-]{24,128}(?:\/[^\s?&#"<>\\]{0,64})?/gu,
|
|
51
|
+
0,
|
|
52
|
+
],
|
|
53
|
+
]) {
|
|
54
|
+
collectMatches(
|
|
55
|
+
text,
|
|
56
|
+
pattern,
|
|
57
|
+
emit,
|
|
58
|
+
{ type, rule, category: 'personal', confidence: 0.85 },
|
|
59
|
+
group
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
for (const match of text.matchAll(cues)) {
|
|
63
|
+
const offset = match.index + match[0].lastIndexOf(match[1]);
|
|
64
|
+
for (const company of match[1].matchAll(
|
|
65
|
+
new RegExp(`«[^«»\\r\\n]{1,160}»|${name}`, 'gu')
|
|
66
|
+
)) {
|
|
67
|
+
emit({
|
|
68
|
+
start: offset + company.index,
|
|
69
|
+
end: offset + company.index + company[0].length,
|
|
70
|
+
type: 'ORGANIZATION',
|
|
71
|
+
category: 'personal',
|
|
72
|
+
rule: 'organization-cue',
|
|
73
|
+
confidence: 0.85,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
detectQueryContacts(text, emit);
|
|
78
|
+
detectExperience(text, emit, range);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function detectQueryContacts(text, emit) {
|
|
82
|
+
// Retain query context while decoding; standalone contacts may be public specimens.
|
|
83
|
+
for (const match of text.matchAll(
|
|
84
|
+
/[?&](email|phone)=([^\s&#"<>\\]{1,512})/giu
|
|
85
|
+
)) {
|
|
86
|
+
let value;
|
|
87
|
+
try {
|
|
88
|
+
value = decodeURIComponent(match[2]);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const email = match[1].toLowerCase() === 'email';
|
|
93
|
+
if (
|
|
94
|
+
!(
|
|
95
|
+
email ? /^[^\s@]+@[^\s@]+\.[\p{L}]{2,63}$/u : /^\+?[\d () .-]{7,40}$/
|
|
96
|
+
).test(value)
|
|
97
|
+
) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (value === '[REDACTED]') {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const start = match.index + match[0].length - match[2].length;
|
|
104
|
+
emit({
|
|
105
|
+
start,
|
|
106
|
+
end: start + match[2].length,
|
|
107
|
+
type: email ? 'EMAIL' : 'PHONE',
|
|
108
|
+
category: 'personal',
|
|
109
|
+
rule: 'label',
|
|
110
|
+
confidence: 0.95,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function detectExperience(text, emit, range) {
|
|
116
|
+
// Date anchors bound each entry; do not guess at undated employer/title fragments.
|
|
117
|
+
collectMatches(
|
|
118
|
+
text,
|
|
119
|
+
new RegExp(
|
|
120
|
+
`(?:^|[\\r\\n])[ \\t]*[^|\\r\\n]{1,120}\\|[ \\t]*(${name})[ \\t]*\\|[ \\t]*${range}`,
|
|
121
|
+
'gu'
|
|
122
|
+
),
|
|
123
|
+
emit,
|
|
124
|
+
{
|
|
125
|
+
type: 'ORGANIZATION',
|
|
126
|
+
rule: 'organization-experience',
|
|
127
|
+
category: 'personal',
|
|
128
|
+
confidence: 0.85,
|
|
129
|
+
},
|
|
130
|
+
1
|
|
131
|
+
);
|
|
132
|
+
const dated = new RegExp(`([^\\r\\n;|]{1,320}?)\\((${range})\\)`, 'gu');
|
|
133
|
+
for (const match of text.matchAll(dated)) {
|
|
134
|
+
const prefix = match[1];
|
|
135
|
+
const content = prefix
|
|
136
|
+
.replace(/^.*\(\d[^)]*\)[, \t]*/, '')
|
|
137
|
+
.replace(/^[, \t]+/, '');
|
|
138
|
+
const chunks = [...content.matchAll(/[^,]+/g)];
|
|
139
|
+
const parts = chunks.map((part) => part[0].trim());
|
|
140
|
+
let value;
|
|
141
|
+
if (parts.length > 1 && role.test(parts[0])) {
|
|
142
|
+
let index = 1;
|
|
143
|
+
while (index < parts.length - 1 && /^[\p{Ll}]/u.test(parts[index])) {
|
|
144
|
+
index++;
|
|
145
|
+
}
|
|
146
|
+
value = content.slice(chunks[index].index).trim();
|
|
147
|
+
} else if (parts.length === 2 && role.test(parts[1])) {
|
|
148
|
+
value = parts[0].trim();
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
!value ||
|
|
152
|
+
!/^[\p{Lu}«“"]/u.test(value) ||
|
|
153
|
+
value.includes('[REDACTED]')
|
|
154
|
+
) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const start = match.index + prefix.lastIndexOf(value);
|
|
158
|
+
if (start < match.index) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
emit({
|
|
162
|
+
start,
|
|
163
|
+
end: start + value.length,
|
|
164
|
+
type: 'ORGANIZATION',
|
|
165
|
+
category: 'personal',
|
|
166
|
+
rule: 'organization-experience',
|
|
167
|
+
confidence: 0.85,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// One explicit field vocabulary; camelCase, snake_case and kebab-case normalize alike.
|
|
2
|
+
const groups = {
|
|
3
|
+
PERSON: ['firstname', 'lastname', 'middlename', 'patronymic'],
|
|
4
|
+
DATE_OF_BIRTH: ['birthday', 'birthdate'],
|
|
5
|
+
GENDER: ['sex'],
|
|
6
|
+
PERSONAL_ATTRIBUTE: ['citizenship', 'nationality'],
|
|
7
|
+
ORGANIZATION: ['company', 'companyname', 'employer'],
|
|
8
|
+
EDUCATION: ['university', 'school'],
|
|
9
|
+
PHONE: ['contact'],
|
|
10
|
+
URL: ['resumeurl', 'alternateurl', 'profileurl', 'companyurl'],
|
|
11
|
+
};
|
|
12
|
+
const types = new Map(
|
|
13
|
+
Object.entries(groups).flatMap(([type, fields]) =>
|
|
14
|
+
fields.map((field) => [field, type])
|
|
15
|
+
)
|
|
16
|
+
);
|
|
17
|
+
export function documentFieldType(field) {
|
|
18
|
+
return types.get(field.replace(/[_-]/g, '').toLowerCase());
|
|
19
|
+
}
|
package/src/document-patterns.js
CHANGED
|
@@ -11,7 +11,7 @@ const given = [
|
|
|
11
11
|
...new Set(Object.values(firstNames).flatMap((s) => s.split(' '))),
|
|
12
12
|
].join('|');
|
|
13
13
|
const nameInitials = new RegExp(
|
|
14
|
-
`(?<!${boundary})(?:${surname}[ \\t]+${initials}|${initials}(?:${surname}|${latinSurname})|${latinSurname}[ \\t]+${initials}|(?:${given})[ \\t]+[А-ЯЁA-Z]\\.)(?!${boundary})`,
|
|
14
|
+
`(?<!${boundary})(?:${word}[ \\t]+(?:[А-ЯЁ]\\.[ \\t]*){2}|${surname}[ \\t]+${initials}|${initials}(?:${surname}|${latinSurname})|${latinSurname}[ \\t]+${initials}|(?:${given})[ \\t]+[А-ЯЁA-Z]\\.)(?!${boundary})`,
|
|
15
15
|
'gu'
|
|
16
16
|
);
|
|
17
17
|
const namePairs = new RegExp(
|
|
@@ -30,13 +30,13 @@ const rules = [
|
|
|
30
30
|
[
|
|
31
31
|
'PHONE',
|
|
32
32
|
'document-phone-label',
|
|
33
|
-
/(?<![\p{L}\p{N}_])(
|
|
33
|
+
/(?<![\p{L}\p{N}_])(?:(?:контактный[ \t]+)?телефон|контакт|тел\.?|моб\.?|сот\.?|WhatsApp)(?:[ \t]*:[ \t]*|[ \t]+)((?:[78][ () .-]*)?9\d{2}[ () .-]*\d{3}[ .-]*\d{2}[ .-]*\d{2})(?!\d)/giu,
|
|
34
34
|
1,
|
|
35
35
|
],
|
|
36
36
|
[
|
|
37
37
|
'EMAIL',
|
|
38
38
|
'obfuscated-email',
|
|
39
|
-
/(?<![\p{L}\p{N}_
|
|
39
|
+
/(?<![\p{L}\p{N}_.+\\-])[\p{L}\p{N}_+-]{1,64}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}_+-]{1,64}){0,4}(?:[ \t]{0,8}\((?:at|собака)\)[ \t]{0,8}|[ \t]{0,8}\[at\][ \t]{0,8}|[ \t]{1,8}(?:at|собака)[ \t]{1,8})[\p{L}\p{N}-]{1,63}(?:(?:\.|[ \t]{1,8}dot[ \t]{1,8})[\p{L}\p{N}-]{1,63}){1,8}(?![\p{L}\p{N}_])/giu,
|
|
40
40
|
0,
|
|
41
41
|
],
|
|
42
42
|
[
|
|
@@ -48,7 +48,7 @@ const rules = [
|
|
|
48
48
|
[
|
|
49
49
|
'USERNAME',
|
|
50
50
|
'messenger-label',
|
|
51
|
-
/(?<![\p{L}\p{N}_])(?:Telegram
|
|
51
|
+
/(?<![\p{L}\p{N}_])(?:Telegram|телеграм|телега|tg|skype|Zoom|ник)(?:[ \t]*:[ \t]*|[ \t]+)(@?[a-z][a-z\d_.-]{2,63})(?![\p{L}\p{N}_])/giu,
|
|
52
52
|
1,
|
|
53
53
|
],
|
|
54
54
|
[
|
|
@@ -60,13 +60,13 @@ const rules = [
|
|
|
60
60
|
[
|
|
61
61
|
'ID',
|
|
62
62
|
'identity-number',
|
|
63
|
-
/(?<![\p{L}\p{N}_])(?:ОГРНИП[ \t]+(\d{15})|код подразделения[ \t]+(\d{3}-\d{3})|(?:водительское удостоверение|в\/у|Driver's licen[cs]e)[ \t]+(\d{2}[ \t]?\d{2}[ \t]?\d{6})|(?:ОМС|полис)[ \t]+(\d{16})
|
|
63
|
+
/(?<![\p{L}\p{N}_])(?:ОГРНИП[ \t]+(\d{15})|код подразделения[ \t]+(\d{3}-\d{3})|(?:водительское удостоверение|в\/у|Driver's licen[cs]e)[ \t]+(\d{2}[ \t]?\d{2}[ \t]?\d{6})|(?:ОМС|полис)[ \t]+(\d{16})|(?:сч[её]т|р\/с)[ \t]+(\d{20})|диплом[ \t]+(?:№|No|#)[ \t]*(\d{6}))(?!\d)/giu,
|
|
64
64
|
-1,
|
|
65
65
|
],
|
|
66
66
|
[
|
|
67
67
|
'ID',
|
|
68
68
|
'passport-issuer',
|
|
69
|
-
/(?<![\p{L}\p{N}_])выдан[ \t]+((
|
|
69
|
+
/(?<![\p{L}\p{N}_])выдан[ \t]+((?:ОВД|УВД|МВД|(?:ТП[ \t]+)?УФМС)[ \t]+[^\r\n";|]{1,120}?\d{2}\.\d{2}\.\d{4})(?!\d)/giu,
|
|
70
70
|
1,
|
|
71
71
|
],
|
|
72
72
|
[
|
|
@@ -78,15 +78,27 @@ const rules = [
|
|
|
78
78
|
[
|
|
79
79
|
'EDUCATION',
|
|
80
80
|
'school-acronym-context',
|
|
81
|
-
/(?<![\p{L}\p{N}_])(
|
|
81
|
+
/(?<![\p{L}\p{N}_])(?:образование|ВУЗ|университет|учил(?:ся|ась)[ \t]+в|окончил[аи]?)(?:[ \t]*:[ \t]*|[ \t]+)([А-ЯЁ]{2,12}(?:[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{2,40})?)(?![\p{L}\p{N}_])/giu,
|
|
82
82
|
1,
|
|
83
83
|
],
|
|
84
|
+
[
|
|
85
|
+
'EDUCATION',
|
|
86
|
+
'education-label',
|
|
87
|
+
/(?<![\p{L}\p{N}_])Education[ \t]*:[ \t]*([\p{Lu}][\p{L}.-]{1,47})(?![\p{L}\p{N}_])/gu,
|
|
88
|
+
1,
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
'PERSONAL_ATTRIBUTE',
|
|
92
|
+
'profile-prose',
|
|
93
|
+
/(?<![\p{L}\p{N}_])(?:граждан(?:ин|ка)[ \t]+([А-ЯЁ]{2,4})|есть[ \t]+((?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)))(?![\p{L}\p{N}_])/gu,
|
|
94
|
+
-1,
|
|
95
|
+
],
|
|
84
96
|
['PERSON', 'name-initials', nameInitials, 0],
|
|
85
97
|
[
|
|
86
98
|
'PERSON',
|
|
87
99
|
'name-title',
|
|
88
100
|
new RegExp(
|
|
89
|
-
`(?<!${boundary})(
|
|
101
|
+
`(?<!${boundary})(?:г-жа|г-н|господин|госпожа)[ \\t]+(${word})(?!${boundary})`,
|
|
90
102
|
'gu'
|
|
91
103
|
),
|
|
92
104
|
1,
|
|
@@ -94,34 +106,31 @@ const rules = [
|
|
|
94
106
|
[
|
|
95
107
|
'DATE_OF_BIRTH',
|
|
96
108
|
'birth-year',
|
|
97
|
-
/(?<![\p{L}\p{N}_])(
|
|
109
|
+
/(?<![\p{L}\p{N}_])(?:д\.р\.|г\.р\.|год рождения|родил(?:ся|ась)(?:[ \t]+в)?|Born(?:[ \t]+in)?)[ \t]*:?[ \t]*((?:19|20)\d{2})(?!\d)|(?<!\d)((?:19|20)\d{2})(?=[ \t]+(?:г\.р\.|года[ \t]+рождения)(?![\p{L}\p{N}_]))/giu,
|
|
98
110
|
-1,
|
|
99
111
|
],
|
|
100
112
|
[
|
|
101
113
|
'AGE',
|
|
102
114
|
'age-context',
|
|
103
|
-
/(?<![\p{L}\p{N}_])(
|
|
115
|
+
/(?<![\p{L}\p{N}_])(?:(?:возраст|Age)(?:[ \t]*:[ \t]*|[ \t]+)|(?:мне|исполнилось)[ \t]+)(\d{1,3})(?!\d)|(?<![\p{L}\p{N}_])(?:Женщина|Мужчина),[ \t]*(\d{1,3})(?=[ \t]+(?:года|год|лет)(?![\p{L}\p{N}_]))/giu,
|
|
104
116
|
-1,
|
|
105
117
|
],
|
|
106
118
|
[
|
|
107
119
|
'GENDER',
|
|
108
120
|
'gender-context',
|
|
109
|
-
/(?<![\p{L}\p{N}_])(?:Пол|Sex)[ \t]*:[ \t]
|
|
121
|
+
/(?<![\p{L}\p{N}_])(?:Пол|Sex)(?:[ \t]*:[ \t]*|[ \t]+)(женский|мужской|жен\.|муж\.|Ж|М|male|female)(?![\p{L}\p{N}_])|(?<![\p{L}\p{N}_])(Женщина|Мужчина)(?=,[ \t]*\d{1,3}[ \t]+(?:года|год|лет))/giu,
|
|
110
122
|
-1,
|
|
111
123
|
],
|
|
112
124
|
[
|
|
113
125
|
'PERSONAL_ATTRIBUTE',
|
|
114
126
|
'profile-attribute',
|
|
115
|
-
/(?<![\p{L}\p{N}_])(?:Гражданство|Citizenship|Семейное положение|Marital status|Дети|национальность|Вероисповедание|инвалидность|судимость)[ \t]*:[ \t]*([
|
|
127
|
+
/(?<![\p{L}\p{N}_])(?:Гражданство|Citizenship|Семейное положение|Marital status|Дети|национальность|Вероисповедание|инвалидность|судимость)[ \t]*:[ \t]*([^\\\r\n";|]{1,160})/giu,
|
|
116
128
|
1,
|
|
117
129
|
],
|
|
118
130
|
[
|
|
119
131
|
'ORGANIZATION',
|
|
120
132
|
'employer-label',
|
|
121
|
-
|
|
122
|
-
`(?<!${boundary})(?:[Кк]омпания|[Рр]аботодатель|[Мм]есто работы)[ \\t]*:[ \\t]*(${word})(?!${boundary})`,
|
|
123
|
-
'gu'
|
|
124
|
-
),
|
|
133
|
+
/(?<![\p{L}\p{N}_])(?:Компания|Работодатель|Место работы|Company|Employer)[ \t]*:[ \t]*([\p{Lu}][^,;|\\\r\n":]{0,159})/giu,
|
|
125
134
|
1,
|
|
126
135
|
],
|
|
127
136
|
[
|
|
@@ -146,7 +155,7 @@ const rules = [
|
|
|
146
155
|
'LOCATION',
|
|
147
156
|
'residence-prose',
|
|
148
157
|
new RegExp(
|
|
149
|
-
`(?<!${boundary})(
|
|
158
|
+
`(?<!${boundary})(?:(?:живу|проживаю)[ \\t]+в|из|м\\.|метро|район)[ \\t]+(${word})(?!${boundary})`,
|
|
150
159
|
'gu'
|
|
151
160
|
),
|
|
152
161
|
1,
|
|
@@ -166,7 +175,12 @@ export function detectDocumentPatterns(text, emit) {
|
|
|
166
175
|
group === -1
|
|
167
176
|
? match.slice(1).find((part) => part !== undefined)
|
|
168
177
|
: match[group].trim();
|
|
169
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
!documentValueAllowed(rule, value) ||
|
|
180
|
+
(rule === 'profile-attribute' &&
|
|
181
|
+
/^Дети[ \t]*:/iu.test(match[0]) &&
|
|
182
|
+
/^нет$/iu.test(value))
|
|
183
|
+
) {
|
|
170
184
|
continue;
|
|
171
185
|
}
|
|
172
186
|
const start =
|
|
@@ -219,6 +233,10 @@ function documentValueAllowed(rule, value) {
|
|
|
219
233
|
rule === 'school-acronym-context' &&
|
|
220
234
|
!/^[А-ЯЁ]{2,12}(?![А-ЯЁа-яё])/.test(value)
|
|
221
235
|
) &&
|
|
236
|
+
!(
|
|
237
|
+
rule === 'profile-attribute' &&
|
|
238
|
+
/^(?:не указан[оаы]?|not specified|unknown|n\/a)$/iu.test(value)
|
|
239
|
+
) &&
|
|
222
240
|
!(rule === 'messenger-label' && /^(?:недоступен|unavailable)$/i.test(value))
|
|
223
241
|
);
|
|
224
242
|
}
|
package/src/engines.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
} from './placeholders.js';
|
|
25
25
|
import {
|
|
26
26
|
structuredBatches,
|
|
27
|
+
structuredFieldFindings,
|
|
28
|
+
declaredFieldFindings,
|
|
27
29
|
structuredValueViews,
|
|
28
30
|
mapStructuredFindings,
|
|
29
31
|
verifyStructured,
|
|
@@ -131,6 +133,7 @@ async function analyzeStructured(
|
|
|
131
133
|
) {
|
|
132
134
|
const findings = [];
|
|
133
135
|
for (const batch of structuredBatches(text, options)) {
|
|
136
|
+
findings.push(...structuredFieldFindings(batch, options));
|
|
134
137
|
for (const finding of mapStructuredFindings(
|
|
135
138
|
batch,
|
|
136
139
|
await analyze(
|
|
@@ -162,10 +165,18 @@ async function analyzeStructured(
|
|
|
162
165
|
}
|
|
163
166
|
// Native structured inspection validates, locates and reapplies the
|
|
164
167
|
// same fake-value/public policy to source offsets from all engines.
|
|
165
|
-
return inspectFindingsOnly(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
return inspectFindingsOnly(
|
|
169
|
+
text,
|
|
170
|
+
[
|
|
171
|
+
...findings,
|
|
172
|
+
...declaredFieldFindings(text, options),
|
|
173
|
+
...(nativeFindings ?? []),
|
|
174
|
+
],
|
|
175
|
+
{
|
|
176
|
+
...options,
|
|
177
|
+
findings: initial,
|
|
178
|
+
}
|
|
179
|
+
);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
export function createSanitizer(options = {}) {
|
package/src/identity.js
CHANGED
|
@@ -181,11 +181,11 @@ function detectPassports(text, emit) {
|
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
const booking =
|
|
184
|
-
/(?<![\p{L}\p{N}_])(?:booking(?:[ _-]+(?:reference|code))?|reservation(?:[ _-]+code)?|confirmation(?:[ _-]+code)?|бронь|record[ _-]+locator|PNR|код брони|номер брони|бронирование)[ \t]*[:=:]
|
|
184
|
+
/(?<![\p{L}\p{N}_])(?:booking(?:[ _-]+(?:reference|code))?|reservation(?:[ _-]+code)?|confirmation(?:[ _-]+code)?|бронь|record[ _-]+locator|PNR|код брони|номер брони|бронирование)[ \t]*(?:[:=:][ \t]*)?[`"']?([A-Z0-9]{4,8})(?![\p{L}\p{N}_])/giu;
|
|
185
185
|
const ticket =
|
|
186
|
-
/(?<![\p{L}\p{N}_])(?:e[ -]?ticket|ticket[ _-]*(?:number|no)?|номер билета|электронный билет)[ \t]*[:=:]
|
|
186
|
+
/(?<![\p{L}\p{N}_])(?:e[ -]?ticket|ticket[ _-]*(?:number|no)?|номер билета|электронный билет)[ \t]*(?:[:=:][ \t]*)?[`"']?(\d{3}[ -]?\d{10})(?![\p{L}\p{N}_])/giu;
|
|
187
187
|
const visa =
|
|
188
|
-
/(?<![\p{L}\p{N}_])(?:e[ -]?visa|visa|номер визы|виза)(?:[ \t_-]+(?:registration|application|number|no|code|номер|код)){0,3}[ \t]*[:=:]
|
|
188
|
+
/(?<![\p{L}\p{N}_])(?:e[ -]?visa|visa|номер визы|виза)(?:[ \t_-]+(?:registration|application|number|no|code|номер|код)){0,3}[ \t]*(?:[:=:][ \t]*)?[`"']?([A-Z0-9][A-Z0-9-]{4,31})(?![\p{L}\p{N}_])/giu;
|
|
189
189
|
const placeholder =
|
|
190
190
|
/^(?:NONE|UNKNOWN|NULL|UNDEFINED|REDACTED|EXAMPLE|SAMPLE|PENDING|HOTEL|BOOKING|REFERENCE|REGISTRATION|APPLICATION|NUMBER|CODE|FAILED|ERROR|HTTP|OK|CANCELLED|CONFIRMED|SUCCESS|READY|ACTIVE|CLOSED)$/i;
|
|
191
191
|
function detectTravel(text, emit) {
|
package/src/index.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface SanitizerOptions {
|
|
|
67
67
|
structured?: 'json' | 'jsonl';
|
|
68
68
|
/** Property names whose metadata heuristics may be suppressed. */
|
|
69
69
|
structuralFields?: string[];
|
|
70
|
+
/** Exact, case-sensitive property names whose entire values are redacted in JSON/JSONL. */
|
|
71
|
+
sensitiveFields?: string[];
|
|
70
72
|
/** Personal replacement threshold, default 0.5. Inspect retains lower scores. */
|
|
71
73
|
minConfidence?: number;
|
|
72
74
|
/** Alias for minConfidence; specifying both requires equal values. */
|
package/src/personal.js
CHANGED
|
@@ -224,7 +224,7 @@ export function detectPersonal(text, emit) {
|
|
|
224
224
|
);
|
|
225
225
|
collectMatches(
|
|
226
226
|
text,
|
|
227
|
-
/(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Straße|Strasse|Rue|Calle|Rua)\b(?:,[ \t]+(?:[^\r\n<>]{
|
|
227
|
+
/(?<![\p{L}\p{N}])\d{1,6}[A-Za-z]?[ \t]+(?:[\p{Lu}][\p{L}.'-]*[ \t]+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Straße|Strasse|Rue|Calle|Rua)\b(?:,[ \t]+(?:(?:[^ \t\r\n<>][^\r\n<>]{0,79}?[ \t]+)?(?:[A-Z]{1,2}\d[A-Z\d]?[ \t]+\d[A-Z]{2}|\d{5}(?:-\d{4})?)\b|(?:[\p{Lu}][\p{Ll}.'-]+[ \t]*){1,4}))?/gu,
|
|
228
228
|
emit,
|
|
229
229
|
details('ADDRESS', 'postal-address')
|
|
230
230
|
);
|
package/src/public.js
CHANGED
package/src/sanitizer.js
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from './placeholders.js';
|
|
32
32
|
import {
|
|
33
33
|
structuredBatches,
|
|
34
|
+
structuredFieldFindings,
|
|
35
|
+
declaredFieldFindings,
|
|
34
36
|
inspectStructuredValues,
|
|
35
37
|
mapStructuredFindings,
|
|
36
38
|
preserveIdentifierKeys,
|
|
@@ -71,6 +73,7 @@ const optionNames = new Set([
|
|
|
71
73
|
'fakeValues',
|
|
72
74
|
'structured',
|
|
73
75
|
'structuralFields',
|
|
76
|
+
'sensitiveFields',
|
|
74
77
|
'minConfidence',
|
|
75
78
|
'threshold',
|
|
76
79
|
'profile',
|
|
@@ -101,12 +104,18 @@ function validateCollections(options) {
|
|
|
101
104
|
'detectors',
|
|
102
105
|
'fakeValues',
|
|
103
106
|
'structuralFields',
|
|
107
|
+
'sensitiveFields',
|
|
104
108
|
]) {
|
|
105
109
|
if (options[key] !== undefined && !Array.isArray(options[key])) {
|
|
106
110
|
throw failure('ERR_CONFIG');
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
|
-
for (const key of [
|
|
113
|
+
for (const key of [
|
|
114
|
+
'knownSecrets',
|
|
115
|
+
'fakeValues',
|
|
116
|
+
'structuralFields',
|
|
117
|
+
'sensitiveFields',
|
|
118
|
+
]) {
|
|
110
119
|
if (
|
|
111
120
|
(options[key] ?? []).some((value) => typeof value !== 'string' || !value)
|
|
112
121
|
) {
|
|
@@ -394,6 +403,7 @@ function inspectText(text, options = {}) {
|
|
|
394
403
|
validateInput(text, options);
|
|
395
404
|
if (options.structured) {
|
|
396
405
|
const findings = structuredBatches(text, options).flatMap((batch) => [
|
|
406
|
+
...structuredFieldFindings(batch, options),
|
|
397
407
|
...mapStructuredFindings(
|
|
398
408
|
batch,
|
|
399
409
|
inspect(batch.text, {
|
|
@@ -407,7 +417,11 @@ function inspectText(text, options = {}) {
|
|
|
407
417
|
inspect(value, { ...options, structured: undefined, findings: [] })
|
|
408
418
|
),
|
|
409
419
|
]);
|
|
410
|
-
return inspectFindingsOnly(
|
|
420
|
+
return inspectFindingsOnly(
|
|
421
|
+
text,
|
|
422
|
+
[...findings, ...declaredFieldFindings(text, options)],
|
|
423
|
+
options
|
|
424
|
+
);
|
|
411
425
|
}
|
|
412
426
|
const findings = [];
|
|
413
427
|
const emit = (f) => {
|
|
@@ -602,6 +616,9 @@ export function markFake(text, findings, options) {
|
|
|
602
616
|
),
|
|
603
617
|
];
|
|
604
618
|
return findings.map((f) => {
|
|
619
|
+
if (f.type === 'JSON_VALUE') {
|
|
620
|
+
return { ...f, kept: undefined };
|
|
621
|
+
}
|
|
605
622
|
if (
|
|
606
623
|
trusted.some(
|
|
607
624
|
(span) =>
|
|
@@ -660,7 +677,9 @@ function validatedSpans(text, findings, options) {
|
|
|
660
677
|
if (f.category === 'credential') {
|
|
661
678
|
last.category = 'credential';
|
|
662
679
|
}
|
|
663
|
-
if (
|
|
680
|
+
if (last.type === 'JSON_VALUE' || f.type === 'JSON_VALUE') {
|
|
681
|
+
last.type = 'JSON_VALUE';
|
|
682
|
+
} else if (credentialRun) {
|
|
664
683
|
last.type = 'SECRET';
|
|
665
684
|
} else if (preferredType(last.type, f.type, options)) {
|
|
666
685
|
last.type = f.type;
|
|
@@ -673,6 +692,12 @@ function validatedSpans(text, findings, options) {
|
|
|
673
692
|
}
|
|
674
693
|
|
|
675
694
|
function preferredType(current, candidate, options) {
|
|
695
|
+
if (current === 'JSON_VALUE') {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
if (candidate === 'JSON_VALUE') {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
676
701
|
if (current === 'ENCODED_SENSITIVE') {
|
|
677
702
|
return false;
|
|
678
703
|
}
|
|
@@ -810,7 +835,7 @@ export async function redactResultAsync(
|
|
|
810
835
|
);
|
|
811
836
|
replacements.set(
|
|
812
837
|
`${span.start}:${span.end}`,
|
|
813
|
-
encodeRun(run, result.text)
|
|
838
|
+
encodeRun(run, result.text === run.text ? REDACTED : result.text)
|
|
814
839
|
);
|
|
815
840
|
}
|
|
816
841
|
}
|
package/src/structured.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { failure } from './detection.js';
|
|
2
|
+
import { documentFieldType } from './document-fields.js';
|
|
2
3
|
import { hasPlaceholders } from './placeholders.js';
|
|
3
4
|
|
|
4
5
|
// Session metadata is opt-in at the CLI; library callers choose their own
|
|
@@ -114,6 +115,33 @@ export function structuredBatches(text, options) {
|
|
|
114
115
|
return batches;
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// Schema declarations take priority over metadata/public/fake heuristics.
|
|
119
|
+
export function structuredFieldFindings(batch, options) {
|
|
120
|
+
return batch.entries.flatMap((entry) => {
|
|
121
|
+
const type = options.documentEntities
|
|
122
|
+
? documentFieldType(entry.field)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (
|
|
125
|
+
entry.key ||
|
|
126
|
+
!type ||
|
|
127
|
+
entry.raw.length <= 2 ||
|
|
128
|
+
JSON.parse(entry.raw) === '[REDACTED]'
|
|
129
|
+
) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
start: entry.start + 1,
|
|
135
|
+
end: entry.start + entry.raw.length - 1,
|
|
136
|
+
type,
|
|
137
|
+
category: 'personal',
|
|
138
|
+
rule: 'known-personal',
|
|
139
|
+
confidence: 1,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
export function structuredValueRanges(text, options) {
|
|
118
146
|
return structuredBatches(text, options).flatMap((batch) =>
|
|
119
147
|
batch.entries
|
|
@@ -364,3 +392,70 @@ function stringOffsets(raw) {
|
|
|
364
392
|
}
|
|
365
393
|
return { starts, ends };
|
|
366
394
|
}
|
|
395
|
+
|
|
396
|
+
// A caller-declared property replaces its entire JSON value, including containers
|
|
397
|
+
// and primitives. Source token offsets preserve the surrounding record.
|
|
398
|
+
export function declaredFieldFindings(text, options) {
|
|
399
|
+
if (!options.sensitiveFields?.length) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const fields = new Set(options.sensitiveFields);
|
|
403
|
+
const findings = [];
|
|
404
|
+
for (const token of lexemes(text)) {
|
|
405
|
+
if (
|
|
406
|
+
token.index < (findings.at(-1)?.end ?? 0) ||
|
|
407
|
+
!token.key ||
|
|
408
|
+
!fields.has(JSON.parse(token.raw))
|
|
409
|
+
) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
let start = token.index + token.raw.length;
|
|
413
|
+
while (/[\s:]/.test(text[start] ?? '') && start < text.length) {
|
|
414
|
+
start++;
|
|
415
|
+
}
|
|
416
|
+
const end = jsonValueEnd(text, start);
|
|
417
|
+
if (text.slice(start, end) === '"[REDACTED]"') {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
findings.push({
|
|
421
|
+
start,
|
|
422
|
+
end,
|
|
423
|
+
type: 'JSON_VALUE',
|
|
424
|
+
category: 'personal',
|
|
425
|
+
rule: 'known-personal',
|
|
426
|
+
confidence: 1,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return findings;
|
|
430
|
+
}
|
|
431
|
+
function jsonValueEnd(text, start) {
|
|
432
|
+
let depth = 0,
|
|
433
|
+
quoted = false;
|
|
434
|
+
for (let i = start; i < text.length; i++) {
|
|
435
|
+
const char = text[i];
|
|
436
|
+
if (quoted) {
|
|
437
|
+
if (char === '\\') {
|
|
438
|
+
i++;
|
|
439
|
+
} else if (char === '"') {
|
|
440
|
+
quoted = false;
|
|
441
|
+
if (!depth) {
|
|
442
|
+
return i + 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (char === '"') {
|
|
446
|
+
quoted = true;
|
|
447
|
+
} else if (char === '[' || char === '{') {
|
|
448
|
+
depth++;
|
|
449
|
+
} else if (char === ']' || char === '}') {
|
|
450
|
+
if (!depth) {
|
|
451
|
+
return i;
|
|
452
|
+
}
|
|
453
|
+
if (!--depth) {
|
|
454
|
+
return i + 1;
|
|
455
|
+
}
|
|
456
|
+
} else if (!depth && /[\s,]/.test(char)) {
|
|
457
|
+
return i;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return text.length;
|
|
461
|
+
}
|
package/src/transforms.js
CHANGED
|
@@ -183,6 +183,9 @@ function transformed(value, finding, transform, options) {
|
|
|
183
183
|
return `${lower}–${lower + transform.size - 1}`;
|
|
184
184
|
}
|
|
185
185
|
export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
186
|
+
if (finding.type === 'JSON_VALUE') {
|
|
187
|
+
return JSON.stringify(REDACTED);
|
|
188
|
+
}
|
|
186
189
|
const transform =
|
|
187
190
|
options.transformations?.[finding.type] ??
|
|
188
191
|
options.transformation ??
|
|
@@ -193,7 +196,10 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
|
193
196
|
const raw = text.slice(finding.start, finding.end);
|
|
194
197
|
const run = encodedValue(raw);
|
|
195
198
|
if (run && (options.preserveEncoding || run.encoding === 'json-content')) {
|
|
196
|
-
|
|
199
|
+
const sanitized = encodedSanitize(run.text).text;
|
|
200
|
+
// Mapped escaped fragments can lose their detecting context on decode.
|
|
201
|
+
// Redact the fragment if recursive scanning makes no progress.
|
|
202
|
+
return encodeRun(run, sanitized === run.text ? REDACTED : sanitized);
|
|
197
203
|
}
|
|
198
204
|
if (options.preserveEncoding) {
|
|
199
205
|
throw failure('ERR_ENCODING');
|