@link-foundation/sensitive-data-sanitizer 0.18.0 → 0.19.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 +8 -0
- package/docs/API.md +205 -33
- package/docs/COVERAGE.md +84 -8
- package/docs/RULES.md +403 -0
- package/examples/custom-log-policy.mjs +23 -0
- package/package.json +17 -2
- package/src/compiled-known.js +55 -0
- package/src/detection.js +9 -0
- package/src/document-catalog.js +246 -0
- package/src/document-entities.js +8 -2
- package/src/document-experience.js +9 -4
- package/src/document-extras.js +2 -2
- package/src/document-vocabulary.js +80 -68
- package/src/engines.js +81 -34
- package/src/index.d.ts +52 -4
- package/src/index.js +5 -0
- package/src/jsonl-stream.js +2 -5
- package/src/ner.d.ts +5 -0
- package/src/ner.js +96 -0
- package/src/personal-variants.js +29 -8
- package/src/policy.js +107 -0
- package/src/record-worker.js +6 -0
- package/src/rule-catalog.js +669 -0
- package/src/sanitizer.js +99 -19
- package/src/stream-worker.js +5 -0
- package/src/stream.js +11 -6
- package/src/strict.js +66 -0
- package/src/structured.js +111 -47
- package/src/sync-detectors.js +38 -0
- package/src/sync-sanitizer.js +16 -0
- package/src/transforms.js +85 -7
- package/src/vocabulary.js +217 -0
- package/src/worker-options.js +12 -0
package/src/transforms.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { realisticFake } from './fake.js';
|
|
2
2
|
import { createHmac } from 'node:crypto';
|
|
3
3
|
import { failure, REDACTED } from './detection.js';
|
|
4
|
-
import { encodedValue, encodeRun } from './encoded.js';
|
|
4
|
+
import { encodedValue, encodeRun, decodedRuns } from './encoded.js';
|
|
5
5
|
import { isIdentityType } from './identity.js';
|
|
6
6
|
import { validatePlaceholder, placeholderReplacement } from './placeholders.js';
|
|
7
|
+
import { projectText } from './projection.js';
|
|
7
8
|
|
|
8
9
|
const modes = new Set([
|
|
9
10
|
'redact',
|
|
@@ -58,6 +59,9 @@ function validPools(pools) {
|
|
|
58
59
|
);
|
|
59
60
|
}
|
|
60
61
|
function validateTransform(transform) {
|
|
62
|
+
if (typeof transform === 'function') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
61
65
|
if (!transform || !modes.has(transform.mode)) {
|
|
62
66
|
throw failure('ERR_CONFIG');
|
|
63
67
|
}
|
|
@@ -148,6 +152,14 @@ function masked(value, finding, transform) {
|
|
|
148
152
|
: `${chars.slice(0, start).join('')}${transform.marker ?? '*'.repeat(chars.length - start - end)}${end ? chars.slice(-end).join('') : ''}`;
|
|
149
153
|
}
|
|
150
154
|
function transformed(value, finding, transform, options) {
|
|
155
|
+
if (typeof transform === 'function') {
|
|
156
|
+
const result = transform(Object.freeze({ ...finding, value }));
|
|
157
|
+
if (result && typeof result.then === 'function') {
|
|
158
|
+
Promise.resolve(result).catch(() => {});
|
|
159
|
+
throw failure('ERR_CONFIG');
|
|
160
|
+
}
|
|
161
|
+
return verifiedCallback(value, result);
|
|
162
|
+
}
|
|
151
163
|
if (!transform || transform.mode === 'redact') {
|
|
152
164
|
return REDACTED;
|
|
153
165
|
}
|
|
@@ -182,7 +194,13 @@ function transformed(value, finding, transform, options) {
|
|
|
182
194
|
const lower = Math.floor(number / transform.size) * transform.size;
|
|
183
195
|
return `${lower}–${lower + transform.size - 1}`;
|
|
184
196
|
}
|
|
185
|
-
export function renderReplacement(
|
|
197
|
+
export function renderReplacement(
|
|
198
|
+
text,
|
|
199
|
+
finding,
|
|
200
|
+
options,
|
|
201
|
+
encodedSanitize,
|
|
202
|
+
jsonRanges
|
|
203
|
+
) {
|
|
186
204
|
if (finding.type === 'JSON_VALUE') {
|
|
187
205
|
return JSON.stringify(REDACTED);
|
|
188
206
|
}
|
|
@@ -205,22 +223,82 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
|
|
|
205
223
|
throw failure('ERR_ENCODING');
|
|
206
224
|
}
|
|
207
225
|
}
|
|
208
|
-
return renderValue(text, finding, transform, options);
|
|
226
|
+
return renderValue(text, finding, transform, options, jsonRanges);
|
|
209
227
|
}
|
|
210
|
-
function
|
|
228
|
+
function replacementInput(
|
|
229
|
+
text,
|
|
230
|
+
finding,
|
|
231
|
+
jsonRanges = [],
|
|
232
|
+
decodeQuoted = false
|
|
233
|
+
) {
|
|
211
234
|
let raw = text.slice(finding.start, finding.end);
|
|
235
|
+
const inJsonString = jsonRanges.some(
|
|
236
|
+
(range) => range.start <= finding.start && range.end >= finding.end
|
|
237
|
+
);
|
|
212
238
|
const jsonQuoted =
|
|
213
|
-
text[finding.start - 1] === '"' && text[finding.end] === '"'
|
|
214
|
-
|
|
239
|
+
(text[finding.start - 1] === '"' && text[finding.end] === '"') ||
|
|
240
|
+
inJsonString;
|
|
241
|
+
// Quotes can also surround ordinary decoded prose containing literal tabs.
|
|
242
|
+
// Decode escapes only when the structured walker confirmed a JSON string.
|
|
243
|
+
if (inJsonString || (decodeQuoted && jsonQuoted)) {
|
|
215
244
|
try {
|
|
216
245
|
raw = JSON.parse(`"${raw}"`);
|
|
217
246
|
} catch {
|
|
218
|
-
|
|
247
|
+
if (decodeQuoted) {
|
|
248
|
+
return { invalid: true };
|
|
249
|
+
}
|
|
250
|
+
throw failure('ERR_JSON');
|
|
219
251
|
}
|
|
220
252
|
}
|
|
253
|
+
return { raw, jsonQuoted };
|
|
254
|
+
}
|
|
255
|
+
function renderValue(text, finding, transform, options, jsonRanges) {
|
|
256
|
+
const { raw, jsonQuoted, invalid } = replacementInput(
|
|
257
|
+
text,
|
|
258
|
+
finding,
|
|
259
|
+
typeof transform === 'function' ? jsonRanges : undefined,
|
|
260
|
+
typeof transform !== 'function'
|
|
261
|
+
);
|
|
262
|
+
if (invalid) {
|
|
263
|
+
return REDACTED;
|
|
264
|
+
}
|
|
221
265
|
const result =
|
|
222
266
|
transform?.mode === 'placeholder'
|
|
223
267
|
? placeholderReplacement(raw, finding, transform, options)
|
|
224
268
|
: transformed(raw, finding, transform, options);
|
|
225
269
|
return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
|
|
226
270
|
}
|
|
271
|
+
|
|
272
|
+
export const hasCustomTransforms = (options) =>
|
|
273
|
+
[
|
|
274
|
+
options.transformation,
|
|
275
|
+
...Object.values(options.transformations ?? {}),
|
|
276
|
+
].some((t) => typeof t === 'function');
|
|
277
|
+
function verifiedCallback(value, result) {
|
|
278
|
+
if (typeof result !== 'string') {
|
|
279
|
+
throw failure('ERR_CONFIG');
|
|
280
|
+
}
|
|
281
|
+
const normalize = (s) => projectText(s).text.toLowerCase();
|
|
282
|
+
const originals = [
|
|
283
|
+
value,
|
|
284
|
+
...Array.from(decodedRuns(value), (run) => run.text),
|
|
285
|
+
]
|
|
286
|
+
.map(normalize)
|
|
287
|
+
.filter(Boolean);
|
|
288
|
+
if (
|
|
289
|
+
[result, ...Array.from(decodedRuns(result), (run) => run.text)].some(
|
|
290
|
+
(view) => originals.some((original) => normalize(view).includes(original))
|
|
291
|
+
)
|
|
292
|
+
) {
|
|
293
|
+
throw failure('ERR_RESIDUAL');
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
export async function renderCallbackAsync(text, finding, callback, jsonRanges) {
|
|
298
|
+
const { raw, jsonQuoted } = replacementInput(text, finding, jsonRanges);
|
|
299
|
+
const result = verifiedCallback(
|
|
300
|
+
raw,
|
|
301
|
+
await callback(Object.freeze({ ...finding, value: raw }))
|
|
302
|
+
);
|
|
303
|
+
return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
|
|
304
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { failure, collectMatches } from './detection.js';
|
|
2
|
+
import { wordPattern } from './personal-variants.js';
|
|
3
|
+
import { normalizeFieldName } from './document-fields.js';
|
|
4
|
+
|
|
5
|
+
export const defaultVocabulary = Object.freeze(
|
|
6
|
+
Object.fromEntries(
|
|
7
|
+
Object.entries({
|
|
8
|
+
roles: [
|
|
9
|
+
'менеджер',
|
|
10
|
+
'директор',
|
|
11
|
+
'руководитель',
|
|
12
|
+
'инженер',
|
|
13
|
+
'аналитик',
|
|
14
|
+
'разработчик',
|
|
15
|
+
'manager',
|
|
16
|
+
'director',
|
|
17
|
+
'engineer',
|
|
18
|
+
'developer',
|
|
19
|
+
'senior',
|
|
20
|
+
'qa',
|
|
21
|
+
],
|
|
22
|
+
organizationCues: [
|
|
23
|
+
'компания',
|
|
24
|
+
'компании',
|
|
25
|
+
'холдинг',
|
|
26
|
+
'банк',
|
|
27
|
+
'группа компаний',
|
|
28
|
+
'стартап',
|
|
29
|
+
],
|
|
30
|
+
educationCues: [
|
|
31
|
+
'образование',
|
|
32
|
+
'вуз',
|
|
33
|
+
'университет',
|
|
34
|
+
'школа',
|
|
35
|
+
'обучение',
|
|
36
|
+
'колледж',
|
|
37
|
+
'курсы',
|
|
38
|
+
'диплом',
|
|
39
|
+
'бакалавр',
|
|
40
|
+
'graduated from',
|
|
41
|
+
],
|
|
42
|
+
personalLabels: ['соискатель', 'сотрудник', 'кандидат'],
|
|
43
|
+
personalFields: ['firstname', 'lastname', 'middlename', 'patronymic'],
|
|
44
|
+
}).map(([key, values]) => [key, Object.freeze(values)])
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
const cache = new Map();
|
|
48
|
+
const boundary = '[\\p{L}\\p{N}_]';
|
|
49
|
+
const name =
|
|
50
|
+
'[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}(?:[ \\t]+[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}){0,3}';
|
|
51
|
+
const casedName = new RegExp(`^${name}`, 'u');
|
|
52
|
+
function normalizedLists(vocabulary) {
|
|
53
|
+
if (vocabulary === undefined) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
!vocabulary ||
|
|
58
|
+
typeof vocabulary !== 'object' ||
|
|
59
|
+
Array.isArray(vocabulary) ||
|
|
60
|
+
Object.keys(vocabulary).some(
|
|
61
|
+
(key) => !Object.hasOwn(defaultVocabulary, key)
|
|
62
|
+
)
|
|
63
|
+
) {
|
|
64
|
+
throw failure('ERR_CONFIG');
|
|
65
|
+
}
|
|
66
|
+
const lists = {};
|
|
67
|
+
for (const [key, defaults] of Object.entries(defaultVocabulary)) {
|
|
68
|
+
const input = vocabulary[key];
|
|
69
|
+
if (input === undefined) {
|
|
70
|
+
lists[key] = defaults;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
lists[key] = normalizedList(input, defaults);
|
|
74
|
+
}
|
|
75
|
+
return lists;
|
|
76
|
+
}
|
|
77
|
+
function normalizedList(input, defaults) {
|
|
78
|
+
const values = Array.isArray(input) ? input : input?.values;
|
|
79
|
+
const invalid =
|
|
80
|
+
!Array.isArray(input) &&
|
|
81
|
+
(input?.replace !== true ||
|
|
82
|
+
Object.keys(input).some((k) => !['values', 'replace'].includes(k)));
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(values) ||
|
|
85
|
+
values.length > 256 ||
|
|
86
|
+
invalid ||
|
|
87
|
+
values.some(
|
|
88
|
+
(s) =>
|
|
89
|
+
typeof s !== 'string' ||
|
|
90
|
+
!s.trim() ||
|
|
91
|
+
s.length > 128 ||
|
|
92
|
+
/[\r\n\0]/.test(s)
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
throw failure('ERR_CONFIG');
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
...new Set(
|
|
99
|
+
[...(input.replace ? [] : defaults), ...values].map((s) =>
|
|
100
|
+
s.normalize('NFC').trim().toLowerCase()
|
|
101
|
+
)
|
|
102
|
+
),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
export function compiledVocabulary(vocabulary) {
|
|
106
|
+
if (vocabulary === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const lists = normalizedLists(vocabulary);
|
|
110
|
+
const key = JSON.stringify(lists);
|
|
111
|
+
if (cache.has(key)) {
|
|
112
|
+
return cache.get(key);
|
|
113
|
+
}
|
|
114
|
+
const alternatives = (values) =>
|
|
115
|
+
values.length
|
|
116
|
+
? values
|
|
117
|
+
.map((s) =>
|
|
118
|
+
s
|
|
119
|
+
.split(/\s+/u)
|
|
120
|
+
.map((w) => wordPattern(w, 'ORGANIZATION'))
|
|
121
|
+
.join('[ \\t]+')
|
|
122
|
+
)
|
|
123
|
+
.sort((a, b) => b.length - a.length)
|
|
124
|
+
.join('|')
|
|
125
|
+
: '(?!)';
|
|
126
|
+
const result = { lists, patterns: [] };
|
|
127
|
+
for (const [list, type] of [
|
|
128
|
+
['organizationCues', 'ORGANIZATION'],
|
|
129
|
+
['educationCues', 'EDUCATION'],
|
|
130
|
+
['personalLabels', 'PERSON'],
|
|
131
|
+
]) {
|
|
132
|
+
const cue = alternatives(lists[list]);
|
|
133
|
+
// Fold cues here and validate the captured name's casing at emission.
|
|
134
|
+
const pattern = new RegExp(
|
|
135
|
+
`(?<!${boundary})(${cue})[ \\t]*(?::|[-–—])?[ \\t]+(${name})(?!${boundary})`,
|
|
136
|
+
'giu'
|
|
137
|
+
);
|
|
138
|
+
result.patterns.push({ type, pattern });
|
|
139
|
+
}
|
|
140
|
+
result.roles = new RegExp(
|
|
141
|
+
`^(?:${alternatives(lists.roles)})(?!\\p{L})`,
|
|
142
|
+
'iu'
|
|
143
|
+
);
|
|
144
|
+
result.rolePattern = new RegExp(
|
|
145
|
+
`(?<!${boundary})(?:${alternatives(lists.roles)})[ \\t]+(?:в[ \\t]+)?(${name})(?!${boundary})`,
|
|
146
|
+
'giu'
|
|
147
|
+
);
|
|
148
|
+
result.reverseRolePattern = new RegExp(
|
|
149
|
+
`(?<!${boundary})(${name})[ \\t]*[:,–—-][ \\t]*(?:${alternatives(lists.roles)})(?!${boundary})`,
|
|
150
|
+
'giu'
|
|
151
|
+
);
|
|
152
|
+
result.fields = new Set(lists.personalFields.map(normalizeFieldName));
|
|
153
|
+
if (cache.size >= 64) {
|
|
154
|
+
cache.clear();
|
|
155
|
+
}
|
|
156
|
+
cache.set(key, result);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
export function vocabularyField(field, options) {
|
|
160
|
+
const vocabulary = compiledVocabulary(options.vocabulary);
|
|
161
|
+
if (!vocabulary) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return vocabulary.fields.has(normalizeFieldName(field))
|
|
165
|
+
? true
|
|
166
|
+
: options.vocabulary.personalFields?.replace
|
|
167
|
+
? false
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|
|
170
|
+
export function detectVocabulary(text, emit, options) {
|
|
171
|
+
const vocabulary = compiledVocabulary(options.vocabulary);
|
|
172
|
+
if (!vocabulary) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const emitName = (finding) => {
|
|
176
|
+
const value = text.slice(finding.start, finding.end).match(casedName)?.[0];
|
|
177
|
+
if (value && value !== 'REDACTED') {
|
|
178
|
+
emit({ ...finding, end: finding.start + value.length });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
for (const { type, pattern } of vocabulary.patterns) {
|
|
182
|
+
collectMatches(
|
|
183
|
+
text,
|
|
184
|
+
pattern,
|
|
185
|
+
emitName,
|
|
186
|
+
{ type, category: 'personal', rule: 'vocabulary-cue', confidence: 0.85 },
|
|
187
|
+
2,
|
|
188
|
+
(value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
collectMatches(
|
|
192
|
+
text,
|
|
193
|
+
vocabulary.rolePattern,
|
|
194
|
+
emitName,
|
|
195
|
+
{
|
|
196
|
+
type: 'ORGANIZATION',
|
|
197
|
+
category: 'personal',
|
|
198
|
+
rule: 'vocabulary-role',
|
|
199
|
+
confidence: 0.8,
|
|
200
|
+
},
|
|
201
|
+
1,
|
|
202
|
+
(value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
|
|
203
|
+
);
|
|
204
|
+
collectMatches(
|
|
205
|
+
text,
|
|
206
|
+
vocabulary.reverseRolePattern,
|
|
207
|
+
emitName,
|
|
208
|
+
{
|
|
209
|
+
type: 'ORGANIZATION',
|
|
210
|
+
category: 'personal',
|
|
211
|
+
rule: 'vocabulary-role',
|
|
212
|
+
confidence: 0.8,
|
|
213
|
+
},
|
|
214
|
+
1,
|
|
215
|
+
(value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
|
|
216
|
+
);
|
|
217
|
+
}
|
package/src/worker-options.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
// Node normally inherits flags while excluding process/V8-only options.
|
|
2
|
+
export function hasWorkerCallbacks(options) {
|
|
3
|
+
return Boolean(
|
|
4
|
+
options.debug ||
|
|
5
|
+
options.verifyPublic ||
|
|
6
|
+
options.detectors?.length ||
|
|
7
|
+
[
|
|
8
|
+
options.transformation,
|
|
9
|
+
...Object.values(options.transformations ?? {}),
|
|
10
|
+
].some((value) => typeof value === 'function')
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
2
14
|
// An explicit execArgv bypasses that filtering, so only override it when
|
|
3
15
|
// parent stdin parsing would reject the file worker's module URL.
|
|
4
16
|
export function workerExecArgv() {
|