@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/src/engines.js CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  import { failure, internalFailure } from './detection.js';
17
17
  import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
+ import { hasCustomTransforms } from './transforms.js';
20
+ import { builtInEnabled } from './policy.js';
19
21
  import {
20
22
  initializePlaceholders,
21
23
  placeholderKnown,
@@ -70,7 +72,8 @@ async function secretlintDetect(text) {
70
72
  }));
71
73
  }
72
74
 
73
- async function detect(engine, text, limit) {
75
+ const secretlintEngine = { detect: secretlintDetect };
76
+ async function detect(engine, text, limit, options) {
74
77
  try {
75
78
  const found = await engine.detect(text);
76
79
  if (!Array.isArray(found)) {
@@ -79,7 +82,12 @@ async function detect(engine, text, limit) {
79
82
  if (found.length > limit) {
80
83
  throw failure('ERR_LIMIT');
81
84
  }
82
- return found.map((finding) => sanitizeFinding(finding, text.length));
85
+ return found
86
+ .map((finding) => sanitizeFinding(finding, text.length))
87
+ .filter(
88
+ (finding) =>
89
+ engine !== secretlintEngine || builtInEnabled(finding, options)
90
+ );
83
91
  } catch {
84
92
  throw failure('ERR_ENGINE');
85
93
  }
@@ -88,7 +96,7 @@ async function detect(engine, text, limit) {
88
96
  async function engineFindings(engine, projection, options, emit) {
89
97
  const limit = options.maxFindings ?? 100000;
90
98
  if (projection.changed) {
91
- for (const f of await detect(engine, projection.text, limit)) {
99
+ for (const f of await detect(engine, projection.text, limit, options)) {
92
100
  emit({
93
101
  ...f,
94
102
  start: projection.starts[f.start],
@@ -106,7 +114,7 @@ async function engineFindings(engine, projection, options, emit) {
106
114
  const view = projectText(run.text).text;
107
115
  const decoded = markFake(
108
116
  view,
109
- await detect(engine, view, limit),
117
+ await detect(engine, view, limit, options),
110
118
  options
111
119
  ).filter((f) => actionable(f, options));
112
120
  if (!decoded.length) {
@@ -124,6 +132,28 @@ async function engineFindings(engine, projection, options, emit) {
124
132
  }
125
133
  }
126
134
 
135
+ async function collectEngineFindings(text, engines, options, emit) {
136
+ const projection = engines.length ? projectText(text) : undefined;
137
+ for (const engine of engines) {
138
+ if (engine !== secretlintEngine && !options.detectors?.includes(engine)) {
139
+ continue;
140
+ }
141
+ for (const finding of await detect(
142
+ engine,
143
+ text,
144
+ options.maxFindings ?? 100000,
145
+ options
146
+ )) {
147
+ emit(finding);
148
+ }
149
+ await engineFindings(engine, projection, options, (finding) => {
150
+ if (builtInEnabled(finding, options)) {
151
+ emit(finding);
152
+ }
153
+ });
154
+ }
155
+ }
156
+
127
157
  async function analyzeStructured(
128
158
  text,
129
159
  initial,
@@ -142,6 +172,7 @@ async function analyzeStructured(
142
172
  true,
143
173
  {
144
174
  ...options,
175
+ detectors: [],
145
176
  properNames: undefined,
146
177
  documentEntities: options.properNames
147
178
  ? false
@@ -199,9 +230,7 @@ export function createSanitizer(options = {}) {
199
230
  }
200
231
  }
201
232
  const engines =
202
- options.secretlint === false || options.knownOnly
203
- ? []
204
- : [{ detect: secretlintDetect }];
233
+ options.secretlint === false || options.knownOnly ? [] : [secretlintEngine];
205
234
  if (!options.knownOnly) {
206
235
  engines.push(...(options.detectors ?? []));
207
236
  }
@@ -230,20 +259,15 @@ export function createSanitizer(options = {}) {
230
259
  }
231
260
  // Native inspection performs its own normalization. Additional views are
232
261
  // needed here only when a required external engine is enabled.
233
- const projection = engines.length ? projectText(text) : undefined;
234
- for (const engine of engines) {
235
- for (const finding of await detect(
236
- engine,
237
- text,
238
- options.maxFindings ?? 100000
239
- )) {
240
- emit(finding);
241
- }
242
- await engineFindings(engine, projection, options, emit);
243
- }
262
+ await collectEngineFindings(text, engines, options, emit);
244
263
  const detected =
245
264
  nativeFindings === undefined
246
- ? inspect(text, { ...options, structured: undefined, findings })
265
+ ? inspect(text, {
266
+ ...options,
267
+ structured: undefined,
268
+ detectors: [],
269
+ findings,
270
+ })
247
271
  : reviewFindings(text, [...nativeFindings, ...findings], options);
248
272
  return options.verifyPublic
249
273
  ? verifyFindings(text, detected, options.verifyPublic)
@@ -297,16 +321,17 @@ export function createSanitizer(options = {}) {
297
321
  findings,
298
322
  fullRedaction ? fullOptions : options,
299
323
  (decoded, recursiveOptions) =>
300
- depth < 2
301
- ? sanitizeText(
302
- decoded,
303
- depth + 1,
304
- fullRedaction,
305
- true,
306
- recursiveOptions
307
- )
308
- : Promise.resolve({ text: '[REDACTED]' })
324
+ sanitizeNested(
325
+ sanitizeText,
326
+ decoded,
327
+ depth,
328
+ fullRedaction,
329
+ recursiveOptions
330
+ )
309
331
  );
332
+ if (await customResidual(result.text, options, plain, analyze)) {
333
+ throw failure('ERR_RESIDUAL');
334
+ }
310
335
  const sanitized = fullRedaction
311
336
  ? result.text
312
337
  : (
@@ -315,9 +340,13 @@ export function createSanitizer(options = {}) {
315
340
  findings,
316
341
  fullOptions,
317
342
  (decoded, recursiveOptions) =>
318
- depth < 2
319
- ? sanitizeText(decoded, depth + 1, true, true, recursiveOptions)
320
- : Promise.resolve({ text: '[REDACTED]' })
343
+ sanitizeNested(
344
+ sanitizeText,
345
+ decoded,
346
+ depth,
347
+ true,
348
+ recursiveOptions
349
+ )
321
350
  )
322
351
  ).text;
323
352
  // Verification at the publication boundary runs every enabled engine.
@@ -330,15 +359,21 @@ export function createSanitizer(options = {}) {
330
359
  sanitizeText(result.text, depth, fullRedaction, plain, next, nextRound)
331
360
  );
332
361
  }
333
- if (options.structured && !plain) {
334
- verifyStructured(result.text, options);
335
- }
362
+ verifyStructuredResult(result.text, options, plain);
336
363
  if (await placeholderResidual(result.text, options, plain, analyze)) {
337
364
  throw failure('ERR_RESIDUAL');
338
365
  }
339
366
  return { ...result, findings: auditFaked(findings, options, result) };
340
367
  }
341
368
  }
369
+ async function customResidual(text, options, plain, analyze) {
370
+ return (
371
+ hasCustomTransforms(options) &&
372
+ (await analyze(text, [], plain, options)).some((f) =>
373
+ actionable(f, options)
374
+ )
375
+ );
376
+ }
342
377
 
343
378
  function guarded(callback, cleanup = () => {}) {
344
379
  return async (...args) => {
@@ -409,3 +444,15 @@ async function verifyFindings(text, findings, verifier) {
409
444
  }
410
445
  return retained;
411
446
  }
447
+
448
+ function sanitizeNested(sanitizeText, decoded, depth, fullRedaction, options) {
449
+ return depth < 2
450
+ ? sanitizeText(decoded, depth + 1, fullRedaction, true, options)
451
+ : Promise.resolve({ text: '[REDACTED]' });
452
+ }
453
+
454
+ function verifyStructuredResult(text, options, plain) {
455
+ if (options.structured && !plain) {
456
+ verifyStructured(text, options);
457
+ }
458
+ }
package/src/index.d.ts CHANGED
@@ -21,7 +21,20 @@ export interface Finding {
21
21
  export interface PersonalValue {
22
22
  type: string;
23
23
  value: string;
24
+ properCase?: boolean;
24
25
  }
26
+ export type VocabularyList =
27
+ readonly string[] | { values: readonly string[]; replace: true };
28
+ export type Vocabulary = Partial<
29
+ Record<
30
+ | 'roles'
31
+ | 'organizationCues'
32
+ | 'educationCues'
33
+ | 'personalLabels'
34
+ | 'personalFields',
35
+ VocabularyList
36
+ >
37
+ >;
25
38
  export interface PublicEntity {
26
39
  type: 'PERSON' | 'ORGANIZATION' | 'EDUCATION' | 'LOCATION' | 'EMAIL';
27
40
  value: string;
@@ -38,18 +51,28 @@ export interface Detector {
38
51
  export interface SanitizerOptions {
39
52
  /** Preserve all personal/account data; credential findings still redact. */
40
53
  credentialsOnly?: boolean;
41
- /** Replace only knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
54
+ /** Use explicit findings, knownPersonal/knownSecrets and reused map values; skip automatic detectors. */
42
55
  knownOnly?: boolean;
43
56
  profile?: 'publication' | 'documents';
44
57
  /** Opt in to bounded employer, school, city/region heuristics. */
45
58
  documentEntities?: boolean;
59
+ /** Opt-in aggressive stored-log attributes, ages, dates and numeric shapes. */
60
+ strict?: boolean;
61
+ vocabulary?: Vocabulary;
62
+ /** Stable ids from builtInRules. Unknown ids fail with ERR_CONFIG. */
63
+ rules?: Record<string, boolean>;
64
+ types?: Record<string, boolean>;
65
+ /** Required to disable any built-in credential rule or type. */
66
+ allowCredentialDisable?: boolean;
67
+ /** Treat a built-in property as ordinary scanned text. */
68
+ fields?: Record<string, 'scan'>;
46
69
  /** Aggressive structural proper names in prose values; opt-in, off by default. */
47
70
  properNames?: 'capitalized';
48
71
  /** Unicode Script aliases that activate prose detection; default ['Cyrillic']. */
49
72
  scripts?: string[];
50
73
  confirmedPersonal?: Array<{ value: string; type: string }>;
51
74
  knownSecrets?: string[];
52
- knownPersonal?: PersonalValue[];
75
+ knownPersonal?: readonly PersonalValue[];
53
76
  publicEntities?: PublicEntity[];
54
77
  /** Built-in exact public entities/resolvers and role mail on known domains. */
55
78
  publicKnowledge?: boolean;
@@ -67,8 +90,12 @@ export interface SanitizerOptions {
67
90
  structured?: 'json' | 'jsonl';
68
91
  /** Property names whose metadata heuristics may be suppressed. */
69
92
  structuralFields?: string[];
70
- /** Property names normalized across case/underscores/hyphens; entire JSON/JSONL values redact. */
93
+ /** Normalized property names or dotted paths with *; entire JSON/JSONL values redact. */
71
94
  sensitiveFields?: string[];
95
+ /** Match sensitiveFields/allowedFields with exact case and punctuation. */
96
+ exactFields?: boolean;
97
+ /** Only these string leaves remain eligible to keep; listed strings are still scanned. */
98
+ allowedFields?: string[];
72
99
  /** Personal replacement threshold, default 0.5. Inspect retains lower scores. */
73
100
  minConfidence?: number;
74
101
  /** Alias for minConfidence; specifying both requires equal values. */
@@ -84,7 +111,7 @@ export interface SanitizerOptions {
84
111
  maxInputLength?: number;
85
112
  maxFindings?: number;
86
113
  debug?: (event: DebugEvent) => void;
87
- /** Used by createSanitizer; synchronous functions use native rules only. */
114
+ /** Required callbacks; synchronous functions reject Promise-returning detectors. */
88
115
  detectors?: Detector[];
89
116
  /** Required by default in createSanitizer. */
90
117
  secretlint?: boolean;
@@ -111,6 +138,22 @@ export interface Sanitizer {
111
138
  inspect(text: string): Promise<Finding[]>;
112
139
  sanitize(text: string): Promise<SanitizedText>;
113
140
  }
141
+ export interface SyncSanitizer {
142
+ inspect(text: string): Finding[];
143
+ sanitize(text: string): SanitizedText;
144
+ }
145
+ export declare function createSyncSanitizer(
146
+ options?: SanitizerOptions
147
+ ): SyncSanitizer;
148
+ /** Copies and freezes caller values, merging lists and caching matching/projection. */
149
+ export declare function compileKnown(
150
+ ...lists: Array<readonly PersonalValue[]>
151
+ ): readonly Readonly<PersonalValue>[];
152
+ export declare const builtInRules: readonly string[];
153
+ export declare const defaultVocabulary: Readonly<
154
+ Record<keyof Vocabulary, readonly string[]>
155
+ >;
156
+ export declare const documentVocabulary: readonly (readonly string[])[];
114
157
  export interface JsonlSanitizer extends Sanitizer {
115
158
  sanitizeJsonl(text: string): Promise<SanitizedText>;
116
159
  }
@@ -229,7 +272,12 @@ export declare function auditGitHistory(
229
272
  maxTotalBytes?: number;
230
273
  }
231
274
  ): Promise<HistoryAudit>;
275
+ /** Callback receives private matched data. Sync APIs require an immediate string. */
276
+ export type TransformationCallback = (
277
+ finding: Readonly<Finding & { value: string }>
278
+ ) => string | Promise<string>;
232
279
  export type Transformation =
280
+ | TransformationCallback
233
281
  | { mode: 'redact' | 'hive-mask' }
234
282
  | {
235
283
  mode: 'placeholder';
package/src/index.js CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  export { inspect, sanitize, redact } from './sanitizer.js';
4
4
  export { createSanitizer } from './engines.js';
5
+ export { compileKnown } from './compiled-known.js';
6
+ export { builtInRules } from './rule-catalog.js';
7
+ export { defaultVocabulary } from './vocabulary.js';
8
+ export { documentVocabulary } from './document-vocabulary.js';
9
+ export { createSyncSanitizer } from './sync-sanitizer.js';
5
10
  export { REDACTED, entropy, luhn } from './detection.js';
6
11
  export { codePointRange } from './projection.js';
7
12
  export {
@@ -3,7 +3,7 @@ import { Worker } from 'node:worker_threads';
3
3
  import { URL } from 'node:url';
4
4
  import { failure } from './detection.js';
5
5
  import { encodingFailure } from './utf8.js';
6
- import { workerExecArgv } from './worker-options.js';
6
+ import { workerExecArgv, hasWorkerCallbacks } from './worker-options.js';
7
7
  import { prepareStreamEngine } from './engines.js';
8
8
  import { inspect } from './sanitizer.js';
9
9
  import { hasPlaceholders } from './placeholders.js';
@@ -129,10 +129,7 @@ function workerCount(options, config) {
129
129
  return 1;
130
130
  }
131
131
  const callbacks =
132
- options.sanitizer ||
133
- config.sanitizerOptions.debug ||
134
- config.sanitizerOptions.verifyPublic ||
135
- config.sanitizerOptions.detectors?.length;
132
+ options.sanitizer || hasWorkerCallbacks(config.sanitizerOptions);
136
133
  const count =
137
134
  options.workers ?? (callbacks || typeof Deno !== 'undefined' ? 1 : 2);
138
135
  if (
package/src/ner.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Finding } from './index.d.ts';
2
+ export declare function createNerDetector(options?: {
3
+ languages?: Array<'ru' | 'en'>;
4
+ debug?: (event: { event: 'offline-ner'; count: number }) => void;
5
+ }): Promise<{ id: string; detect(text: string): Finding[] }>;
package/src/ner.js ADDED
@@ -0,0 +1,96 @@
1
+ // Optional entry: no core module imports NLP.js or this recognizer.
2
+ import { firstNames } from './names.js';
3
+ import { documentVocabulary } from './document-vocabulary.js';
4
+
5
+ const boundary = '[\\p{L}\\p{M}\\p{N}_]';
6
+ const proper = "\\p{Lu}[\\p{L}\\p{M}’'-]{1,47}";
7
+ const name = `${proper}(?:[ \\t]+${proper}){0,3}`;
8
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ const blocked =
10
+ /^(?:Kubernetes|PostgreSQL|Python|JavaScript|TypeScript|Node|Docker|Redis|Linux|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|REDACTED)$/iu;
11
+ const configError = () =>
12
+ Object.assign(new Error('Invalid offline recognizer configuration.'), {
13
+ code: 'ERR_CONFIG',
14
+ });
15
+
16
+ function rulesFor(language) {
17
+ const names = firstNames[language]
18
+ .split(' ')
19
+ .filter((s) => !/^(?:May|Will|Mark|Rose)$/u.test(s));
20
+ const places = documentVocabulary
21
+ .filter((row) => row[0] === language)
22
+ .flatMap((row) => row[3].split('|'));
23
+ const rules = [
24
+ ['PERSON', `(?:${names.map(escape).join('|')})(?:[ \\t]+${proper})?`],
25
+ ['LOCATION', `(?:${places.map(escape).join('|') || '(?!)'})`],
26
+ ];
27
+ if (language === 'ru') {
28
+ rules.push(
29
+ ['ORGANIZATION', `(?<=(?:[Рр]аботал[аи]?|[Сс]лужил[аи]?) в )${name}`],
30
+ [
31
+ 'ORGANIZATION',
32
+ `${name}(?=:[ \\t]*(?:менеджер|директор|разработчик|инженер|аналитик|маркетолог)(?!\\p{L}))`,
33
+ ],
34
+ ['EDUCATION', '(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ|МФТИ|ИТМО)'],
35
+ ['EDUCATION', `${name}[ \\t]+(?:университет|институт|академия)`],
36
+ ['LOCATION', `(?<=(?:[Жж]иву|[Пп]роживаю) в )${name}`]
37
+ );
38
+ } else {
39
+ rules.push(
40
+ ['ORGANIZATION', `(?<=(?:[Aa]t|[Jj]oined|[Ee]mployer:) )${name}`],
41
+ ['EDUCATION', `(?<=(?:[Gg]raduated from|[Ss]tudied at) )${name}`],
42
+ [
43
+ 'EDUCATION',
44
+ '(?:MIT|MSU|UCLA|UCL|NYU|Harvard|Stanford|Oxford|Cambridge)',
45
+ ],
46
+ ['EDUCATION', `${name}[ \\t]+(?:University|College|Institute)`],
47
+ ['LOCATION', `(?<=(?:[Ll]ive in|[Ll]ocated in|[Bb]ased in) )${name}`]
48
+ );
49
+ }
50
+ return rules.map(([type, source]) => ({
51
+ name: type,
52
+ type: 'regex',
53
+ rules: [new RegExp(`(?<!${boundary})(?:${source})(?!${boundary})`, 'gu')],
54
+ }));
55
+ }
56
+
57
+ /** Prepare an offline recognizer with synchronous detection. */
58
+ export async function createNerDetector(options = {}) {
59
+ const languages = options.languages ?? ['ru', 'en'];
60
+ if (
61
+ !Array.isArray(languages) ||
62
+ !languages.length ||
63
+ languages.length > 2 ||
64
+ languages.some((s) => !['ru', 'en'].includes(s)) ||
65
+ Object.keys(options).some((key) => !['languages', 'debug'].includes(key)) ||
66
+ (options.debug !== undefined && typeof options.debug !== 'function')
67
+ ) {
68
+ throw configError();
69
+ }
70
+ const { ExtractorRegex } = await import('@nlpjs/ner');
71
+ const extractor = new ExtractorRegex();
72
+ const nerRules = [...new Set(languages)].flatMap(rulesFor);
73
+ return Object.freeze({
74
+ id: 'offline-ner',
75
+ detect(text) {
76
+ if (typeof text !== 'string' || text.length > 1024 * 1024) {
77
+ throw Object.assign(new Error('Offline recognizer input limit.'), {
78
+ code: 'ERR_LIMIT',
79
+ });
80
+ }
81
+ const findings = extractor
82
+ .extract({ text, nerRules })
83
+ .edges.filter((edge) => !blocked.test(edge.sourceText))
84
+ .map((edge) => ({
85
+ start: edge.start,
86
+ end: edge.end + 1,
87
+ type: edge.entity,
88
+ category: 'personal',
89
+ rule: 'ner/offline',
90
+ confidence: 0.8,
91
+ }));
92
+ options.debug?.({ event: 'offline-ner', count: findings.length });
93
+ return findings;
94
+ },
95
+ });
96
+ }
@@ -1,5 +1,6 @@
1
1
  import { escapePattern, failure } from './detection.js';
2
2
  import { russianNameRoles } from './russian-names.js';
3
+ import { compiledPatterns } from './compiled-known.js';
3
4
 
4
5
  const boundary = '[\\p{L}\\p{M}\\p{N}_]';
5
6
  export function organizationCore(value) {
@@ -143,6 +144,22 @@ export function personalPatterns(value, type) {
143
144
  return [...patterns];
144
145
  }
145
146
  export function personalRegex(value, type) {
147
+ if (type === 'URL') {
148
+ let core = value.replace(/^https?:\/\//iu, '');
149
+ const suffix = core.search(/[?#]/u);
150
+ if (suffix >= 0) {
151
+ core = core.slice(0, suffix);
152
+ }
153
+ let end = core.length;
154
+ while (end > 0 && core[end - 1] === '/') {
155
+ end--;
156
+ }
157
+ core = core.slice(0, end);
158
+ return new RegExp(
159
+ `(?<![\\p{L}\\p{N}_./-])(?:https?:\\/\\/)?${escapePattern(core)}(?:\\/)?(?:[?#][^\\s"<>\\\\]*)?(?![\\p{L}\\p{N}_-])`,
160
+ 'giu'
161
+ );
162
+ }
146
163
  return new RegExp(
147
164
  `(?<!${boundary})(?:${personalPatterns(value, type).join('|')})(?!${boundary})`,
148
165
  'giu'
@@ -153,17 +170,21 @@ export function samePersonal(value, original, type) {
153
170
  new RegExp(`^(?:${pattern})$`, 'iu').test(value)
154
171
  );
155
172
  }
173
+ function confirmedFamily(entry, rule, options) {
174
+ return rule === 'confirmed-personal' &&
175
+ options.documentEntities &&
176
+ !options.knownOnly &&
177
+ entry.type === 'ORGANIZATION'
178
+ ? organizationFamily(organizationCore(entry.value))
179
+ : undefined;
180
+ }
156
181
  export function detectKnownPersonal(text, entries, rule, emit, options) {
157
182
  let count = 0;
158
- for (const entry of entries ?? []) {
159
- const family =
160
- rule === 'confirmed-personal' &&
161
- options.documentEntities &&
162
- !options.knownOnly &&
163
- entry.type === 'ORGANIZATION' &&
164
- organizationFamily(organizationCore(entry.value));
183
+ for (const [index, entry] of (entries ?? []).entries()) {
184
+ const family = confirmedFamily(entry, rule, options);
165
185
  for (const pattern of [
166
- personalRegex(entry.value, entry.type),
186
+ compiledPatterns(entries)?.[index] ??
187
+ personalRegex(entry.value, entry.type),
167
188
  ...(family ? [family.pattern] : []),
168
189
  ]) {
169
190
  for (const match of text.matchAll(pattern)) {
package/src/policy.js ADDED
@@ -0,0 +1,107 @@
1
+ import { failure } from './detection.js';
2
+ import { builtInRules, credentialRules } from './rule-catalog.js';
3
+ import { normalizeFieldName } from './document-fields.js';
4
+
5
+ const ruleIds = new Set(builtInRules);
6
+ const credentialType = (type) =>
7
+ /SECRET|TOKEN|PASSWORD|AUTH|CREDENTIAL|PRIVATE_KEY|ACCESS_KEY|^PIN$|CVV|CVC|CONNECTION.*STRING/.test(
8
+ type
9
+ );
10
+ const record = (value) =>
11
+ value && typeof value === 'object' && !Array.isArray(value);
12
+ // These recognizers consume the replaceable cue lists. Independent evidence
13
+ // (legal forms, dates, full names, identifiers) remains available.
14
+ const vocabularyRules = {
15
+ roles: ['document-reversed-role', 'document-role-employer'],
16
+ organizationCues: [
17
+ 'organization-context',
18
+ 'employer-label',
19
+ 'employer-prose',
20
+ 'organization-cue',
21
+ 'organization-group-cue',
22
+ 'document-employer-prefix',
23
+ 'document-employer-dash',
24
+ ],
25
+ educationCues: [
26
+ 'school-acronym-context',
27
+ 'education-label',
28
+ 'education-training',
29
+ 'document-school-context',
30
+ 'numbered-school',
31
+ 'education-type',
32
+ 'education-russian',
33
+ ],
34
+ personalLabels: ['document-role'],
35
+ };
36
+ export function validatePolicyControls(options) {
37
+ for (const key of ['allowCredentialDisable', 'strict', 'exactFields']) {
38
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
39
+ throw failure('ERR_CONFIG');
40
+ }
41
+ }
42
+ for (const key of ['rules', 'types', 'fields']) {
43
+ if (options[key] === undefined) {
44
+ continue;
45
+ }
46
+ if (!record(options[key])) {
47
+ throw failure('ERR_CONFIG');
48
+ }
49
+ for (const [id, value] of Object.entries(options[key])) {
50
+ validateControl(key, id, value, options);
51
+ }
52
+ }
53
+ }
54
+ function validateControl(key, id, value, options) {
55
+ const validId =
56
+ key === 'rules'
57
+ ? ruleIds.has(id)
58
+ : key === 'types'
59
+ ? /^[A-Z][A-Z0-9_]{0,63}$/.test(id)
60
+ : id.length > 0;
61
+ if (
62
+ !validId ||
63
+ (key === 'fields' ? value !== 'scan' : typeof value !== 'boolean')
64
+ ) {
65
+ throw failure('ERR_CONFIG');
66
+ }
67
+ const credential =
68
+ key === 'rules' ? credentialRules.has(id) : credentialType(id);
69
+ if (value === false && !options.allowCredentialDisable && credential) {
70
+ throw failure('ERR_CONFIG');
71
+ }
72
+ }
73
+ export function builtInEnabled(finding, options) {
74
+ if (finding.category === 'credential' && !options.allowCredentialDisable) {
75
+ return true;
76
+ }
77
+ for (const [list, rules] of Object.entries(vocabularyRules)) {
78
+ if (
79
+ options.vocabulary?.[list]?.replace === true &&
80
+ rules.includes(finding.rule)
81
+ ) {
82
+ return false;
83
+ }
84
+ }
85
+ return (
86
+ options.rules?.[finding.rule] !== false &&
87
+ options.types?.[finding.type] !== false
88
+ );
89
+ }
90
+ export function scanField(field, options) {
91
+ return Object.keys(options.fields ?? {}).some(
92
+ (name) => normalizeFieldName(name) === normalizeFieldName(field)
93
+ );
94
+ }
95
+ export function fieldMatches(patterns, field, path, exact = false) {
96
+ const normalize = exact ? (s) => s : normalizeFieldName;
97
+ return (patterns ?? []).some((pattern) => {
98
+ const parts = pattern.split('.');
99
+ const values = parts.length === 1 ? [field] : path;
100
+ return (
101
+ parts.length === values.length &&
102
+ parts.every(
103
+ (part, i) => part === '*' || normalize(part) === normalize(values[i])
104
+ )
105
+ );
106
+ });
107
+ }
@@ -1,5 +1,11 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
2
  import { createSanitizer, prepareStreamEngine } from './engines.js';
3
+ import { compileKnown } from './compiled-known.js';
4
+
5
+ // Structured cloning discards the caller's weak compilation metadata.
6
+ if (workerData.knownPersonal) {
7
+ workerData.knownPersonal = compileKnown(workerData.knownPersonal);
8
+ }
3
9
 
4
10
  parentPort.on(
5
11
  'message',