@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 +12 -0
- package/README.md +12 -1
- package/bin/sensitive-data-sanitizer.js +5 -0
- package/docs/API.md +64 -24
- package/docs/COVERAGE.md +23 -2
- package/package.json +1 -1
- package/src/context.js +23 -9
- package/src/detection.js +43 -26
- package/src/document-entities.js +87 -2
- package/src/document-vocabulary.js +1 -1
- package/src/engines.js +6 -2
- package/src/entities.js +7 -0
- package/src/fake-entities.js +304 -0
- package/src/fake-names.js +63 -8
- package/src/fake.js +4 -8
- package/src/index.d.ts +8 -0
- package/src/keywords.js +17 -0
- package/src/names.js +1 -1
- package/src/personal-context.js +1 -1
- package/src/personal-variants.js +184 -0
- package/src/personal.js +18 -7
- package/src/placeholder-types.js +33 -0
- package/src/placeholders.js +57 -10
- package/src/projection.js +6 -2
- package/src/propagation.js +17 -1
- package/src/russian-declension.js +167 -0
- package/src/russian-names.js +36 -0
- package/src/sanitizer.js +28 -2
- package/src/transforms.js +21 -4
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.
|
|
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.
|
|
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.
|
|
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
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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'
|
|
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(
|
package/src/document-entities.js
CHANGED
|
@@ -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 = `(
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
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(
|