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