@link-foundation/sensitive-data-sanitizer 0.18.0 → 0.19.1

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.
@@ -0,0 +1,16 @@
1
+ import {
2
+ inspect,
3
+ sanitize,
4
+ validateOptions,
5
+ applyProfile,
6
+ } from './sanitizer.js';
7
+
8
+ /** A reusable synchronous engine with the same verified publication boundary. */
9
+ export function createSyncSanitizer(options = {}) {
10
+ options = applyProfile(options);
11
+ validateOptions(options);
12
+ return Object.freeze({
13
+ inspect: (text) => inspect(text, options),
14
+ sanitize: (text) => sanitize(text, options),
15
+ });
16
+ }
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(text, finding, options, encodedSanitize) {
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 renderValue(text, finding, transform, options) {
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
- if (jsonQuoted) {
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
- return REDACTED;
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,226 @@
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: [
44
+ 'firstname',
45
+ 'lastname',
46
+ 'middlename',
47
+ 'patronymic',
48
+ 'candidatename',
49
+ 'displayname',
50
+ 'fullname',
51
+ 'originalfilename',
52
+ ],
53
+ }).map(([key, values]) => [key, Object.freeze(values)])
54
+ )
55
+ );
56
+ const cache = new Map();
57
+ const boundary = '[\\p{L}\\p{N}_]';
58
+ const name =
59
+ '[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}(?:[ \\t]+[\\p{Lu}][\\p{L}\\p{M}.-]{1,47}){0,3}';
60
+ const casedName = new RegExp(`^${name}`, 'u');
61
+ function normalizedLists(vocabulary) {
62
+ if (vocabulary === undefined) {
63
+ return undefined;
64
+ }
65
+ if (
66
+ !vocabulary ||
67
+ typeof vocabulary !== 'object' ||
68
+ Array.isArray(vocabulary) ||
69
+ Object.keys(vocabulary).some(
70
+ (key) => !Object.hasOwn(defaultVocabulary, key)
71
+ )
72
+ ) {
73
+ throw failure('ERR_CONFIG');
74
+ }
75
+ const lists = {};
76
+ for (const [key, defaults] of Object.entries(defaultVocabulary)) {
77
+ const input = vocabulary[key];
78
+ if (input === undefined) {
79
+ lists[key] = defaults;
80
+ continue;
81
+ }
82
+ lists[key] = normalizedList(input, defaults);
83
+ }
84
+ return lists;
85
+ }
86
+ function normalizedList(input, defaults) {
87
+ const values = Array.isArray(input) ? input : input?.values;
88
+ const invalid =
89
+ !Array.isArray(input) &&
90
+ (input?.replace !== true ||
91
+ Object.keys(input).some((k) => !['values', 'replace'].includes(k)));
92
+ if (
93
+ !Array.isArray(values) ||
94
+ values.length > 256 ||
95
+ invalid ||
96
+ values.some(
97
+ (s) =>
98
+ typeof s !== 'string' ||
99
+ !s.trim() ||
100
+ s.length > 128 ||
101
+ /[\r\n\0]/.test(s)
102
+ )
103
+ ) {
104
+ throw failure('ERR_CONFIG');
105
+ }
106
+ return [
107
+ ...new Set(
108
+ [...(input.replace ? [] : defaults), ...values].map((s) =>
109
+ s.normalize('NFC').trim().toLowerCase()
110
+ )
111
+ ),
112
+ ];
113
+ }
114
+ export function compiledVocabulary(vocabulary) {
115
+ if (vocabulary === undefined) {
116
+ return undefined;
117
+ }
118
+ const lists = normalizedLists(vocabulary);
119
+ const key = JSON.stringify(lists);
120
+ if (cache.has(key)) {
121
+ return cache.get(key);
122
+ }
123
+ const alternatives = (values) =>
124
+ values.length
125
+ ? values
126
+ .map((s) =>
127
+ s
128
+ .split(/\s+/u)
129
+ .map((w) => wordPattern(w, 'ORGANIZATION'))
130
+ .join('[ \\t]+')
131
+ )
132
+ .sort((a, b) => b.length - a.length)
133
+ .join('|')
134
+ : '(?!)';
135
+ const result = { lists, patterns: [] };
136
+ for (const [list, type] of [
137
+ ['organizationCues', 'ORGANIZATION'],
138
+ ['educationCues', 'EDUCATION'],
139
+ ['personalLabels', 'PERSON'],
140
+ ]) {
141
+ const cue = alternatives(lists[list]);
142
+ // Fold cues here and validate the captured name's casing at emission.
143
+ const pattern = new RegExp(
144
+ `(?<!${boundary})(${cue})[ \\t]*(?::|[-–—])?[ \\t]+(${name})(?!${boundary})`,
145
+ 'giu'
146
+ );
147
+ result.patterns.push({ type, pattern });
148
+ }
149
+ result.roles = new RegExp(
150
+ `^(?:${alternatives(lists.roles)})(?!\\p{L})`,
151
+ 'iu'
152
+ );
153
+ result.rolePattern = new RegExp(
154
+ `(?<!${boundary})(?:${alternatives(lists.roles)})[ \\t]+(?:в[ \\t]+)?(${name})(?!${boundary})`,
155
+ 'giu'
156
+ );
157
+ result.reverseRolePattern = new RegExp(
158
+ `(?<!${boundary})(${name})[ \\t]*[:,–—-][ \\t]*(?:${alternatives(lists.roles)})(?!${boundary})`,
159
+ 'giu'
160
+ );
161
+ result.fields = new Set(lists.personalFields.map(normalizeFieldName));
162
+ if (cache.size >= 64) {
163
+ cache.clear();
164
+ }
165
+ cache.set(key, result);
166
+ return result;
167
+ }
168
+ export function vocabularyField(field, options) {
169
+ const vocabulary = compiledVocabulary(options.vocabulary);
170
+ if (!vocabulary) {
171
+ return undefined;
172
+ }
173
+ return vocabulary.fields.has(normalizeFieldName(field))
174
+ ? true
175
+ : options.vocabulary.personalFields?.replace
176
+ ? false
177
+ : undefined;
178
+ }
179
+ export function detectVocabulary(text, emit, options) {
180
+ const vocabulary = compiledVocabulary(options.vocabulary);
181
+ if (!vocabulary) {
182
+ return;
183
+ }
184
+ const emitName = (finding) => {
185
+ const value = text.slice(finding.start, finding.end).match(casedName)?.[0];
186
+ if (value && value !== 'REDACTED') {
187
+ emit({ ...finding, end: finding.start + value.length });
188
+ }
189
+ };
190
+ for (const { type, pattern } of vocabulary.patterns) {
191
+ collectMatches(
192
+ text,
193
+ pattern,
194
+ emitName,
195
+ { type, category: 'personal', rule: 'vocabulary-cue', confidence: 0.85 },
196
+ 2,
197
+ (value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
198
+ );
199
+ }
200
+ collectMatches(
201
+ text,
202
+ vocabulary.rolePattern,
203
+ emitName,
204
+ {
205
+ type: 'ORGANIZATION',
206
+ category: 'personal',
207
+ rule: 'vocabulary-role',
208
+ confidence: 0.8,
209
+ },
210
+ 1,
211
+ (value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
212
+ );
213
+ collectMatches(
214
+ text,
215
+ vocabulary.reverseRolePattern,
216
+ emitName,
217
+ {
218
+ type: 'ORGANIZATION',
219
+ category: 'personal',
220
+ rule: 'vocabulary-role',
221
+ confidence: 0.8,
222
+ },
223
+ 1,
224
+ (value) => /^\p{Lu}/u.test(value) && value !== 'REDACTED'
225
+ );
226
+ }
@@ -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() {