@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/sanitizer.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { generatedValues } from './fake.js';
2
+ import { isCompiledKnown, projectedKnown } from './compiled-known.js';
3
+ import { detectSync, validateDetectors } from './sync-detectors.js';
4
+ import { validatePolicyControls, builtInEnabled } from './policy.js';
5
+ import { compiledVocabulary } from './vocabulary.js';
2
6
  import { validateProperNames } from './proper-names.js';
3
7
  import { prepareFakeText } from './fake-entities.js';
4
8
  import { alignEscapes } from './escape-spans.js';
@@ -16,7 +20,12 @@ import { projectText } from './projection.js';
16
20
  import { decodedRuns, encodeRun, encodedValue } from './encoded.js';
17
21
  import { URL } from 'node:url';
18
22
  import { isPublic } from './public.js';
19
- import { validateTransforms, renderReplacement } from './transforms.js';
23
+ import {
24
+ validateTransforms,
25
+ renderReplacement,
26
+ hasCustomTransforms,
27
+ renderCallbackAsync,
28
+ } from './transforms.js';
20
29
  import { fakeIdentityValue, isIdentityType } from './identity.js';
21
30
  import { rejectRestoreMap } from './restore-map.js';
22
31
  import {
@@ -38,6 +47,7 @@ import {
38
47
  mapStructuredFindings,
39
48
  preserveIdentifierKeys,
40
49
  structuredValueRanges,
50
+ structuredStringRanges,
41
51
  verifyStructured,
42
52
  } from './structured.js';
43
53
 
@@ -81,6 +91,14 @@ const optionNames = new Set([
81
91
  'documentEntities',
82
92
  'properNames',
83
93
  'scripts',
94
+ 'rules',
95
+ 'types',
96
+ 'fields',
97
+ 'allowCredentialDisable',
98
+ 'vocabulary',
99
+ 'strict',
100
+ 'exactFields',
101
+ 'allowedFields',
84
102
  ]);
85
103
 
86
104
  export function applyProfile(options = {}) {
@@ -108,6 +126,7 @@ function validateCollections(options) {
108
126
  'fakeValues',
109
127
  'structuralFields',
110
128
  'sensitiveFields',
129
+ 'allowedFields',
111
130
  ]) {
112
131
  if (options[key] !== undefined && !Array.isArray(options[key])) {
113
132
  throw failure('ERR_CONFIG');
@@ -118,6 +137,7 @@ function validateCollections(options) {
118
137
  'fakeValues',
119
138
  'structuralFields',
120
139
  'sensitiveFields',
140
+ 'allowedFields',
121
141
  ]) {
122
142
  if (
123
143
  (options[key] ?? []).some((value) => typeof value !== 'string' || !value)
@@ -126,7 +146,9 @@ function validateCollections(options) {
126
146
  }
127
147
  }
128
148
  for (const entry of [
129
- ...(options.knownPersonal ?? []),
149
+ ...(isCompiledKnown(options.knownPersonal)
150
+ ? []
151
+ : (options.knownPersonal ?? [])),
130
152
  ...(options.confirmedPersonal ?? []),
131
153
  ]) {
132
154
  if (
@@ -203,6 +225,9 @@ export function validateOptions(options = {}) {
203
225
  throw failure('ERR_CONFIG');
204
226
  }
205
227
  validateCollections(options);
228
+ validateDetectors(options.detectors);
229
+ validatePolicyControls(options);
230
+ compiledVocabulary(options.vocabulary);
206
231
  validateTransforms(options);
207
232
  validateLimits(options);
208
233
  for (const entry of options.publicEntities ?? []) {
@@ -351,7 +376,12 @@ function encodedDetect(text, options, emit) {
351
376
  }
352
377
  const found = [];
353
378
  const view = projectText(run.text).text;
354
- nativeDetect(view, options, (f) => found.push(f));
379
+ nativeDetect(view, options, (f) =>
380
+ found.push(sanitizeFinding(f, view.length))
381
+ );
382
+ detectSync(view, options, (f) =>
383
+ found.push(sanitizeFinding(f, view.length))
384
+ );
355
385
  const sensitive = reviewFindings(view, found, options).filter((f) =>
356
386
  actionable(f, options)
357
387
  );
@@ -414,6 +444,7 @@ function inspectText(text, options = {}) {
414
444
  ...options,
415
445
  structured: undefined,
416
446
  findings: [],
447
+ detectors: [],
417
448
  properNames: undefined,
418
449
  documentEntities: options.properNames
419
450
  ? false
@@ -449,10 +480,12 @@ function inspectText(text, options = {}) {
449
480
  knownSecrets: (options.knownSecrets ?? []).map(
450
481
  (value) => projectText(value).text
451
482
  ),
452
- knownPersonal: (options.knownPersonal ?? []).map((entry) => ({
453
- ...entry,
454
- value: projectText(entry.value).text,
455
- })),
483
+ knownPersonal:
484
+ projectedKnown(options.knownPersonal) ??
485
+ (options.knownPersonal ?? []).map((entry) => ({
486
+ ...entry,
487
+ value: projectText(entry.value).text,
488
+ })),
456
489
  confirmedPersonal: (options.confirmedPersonal ?? []).map((entry) => ({
457
490
  ...entry,
458
491
  value: projectText(entry.value).text,
@@ -470,9 +503,15 @@ function inspectText(text, options = {}) {
470
503
  );
471
504
  if (projection.changed) {
472
505
  nativeDetect(text, options, emit);
506
+ detectSync(text, options, emit);
473
507
  }
474
508
  nativeDetect(projection.text, nativeOptions, mapped);
475
- encodedDetect(projection.text, nativeOptions, mapped);
509
+ detectSync(projection.text, nativeOptions, mapped);
510
+ encodedDetect(projection.text, nativeOptions, (finding) => {
511
+ if (builtInEnabled(finding, options)) {
512
+ mapped(finding);
513
+ }
514
+ });
476
515
  for (const f of options.findings ?? []) {
477
516
  emit(f);
478
517
  }
@@ -745,22 +784,28 @@ export function redactResult(
745
784
  const parts = [];
746
785
  const faked = [];
747
786
  const occurrences = [];
787
+ const jsonRanges = callbackStringRanges(text, options);
748
788
  let irreversibleRedactions = 0;
749
789
  let outputLength = 0;
750
790
  let cursor = 0;
751
791
  for (const span of spans) {
752
792
  const replacement =
753
793
  replacements.get(`${span.start}:${span.end}`) ??
754
- renderReplacement(text, span, options, (decoded) =>
755
- sanitize(
756
- decoded,
757
- nestedPlaceholderOptions({
758
- ...confirmationOptions(text, findings, options),
759
- findings: [],
760
- preserveEncoding: true,
761
- structured: undefined,
762
- })
763
- )
794
+ renderReplacement(
795
+ text,
796
+ span,
797
+ options,
798
+ (decoded) =>
799
+ sanitize(
800
+ decoded,
801
+ nestedPlaceholderOptions({
802
+ ...confirmationOptions(text, findings, options),
803
+ findings: [],
804
+ preserveEncoding: true,
805
+ structured: undefined,
806
+ })
807
+ ),
808
+ jsonRanges
764
809
  );
765
810
  parts.push(text.slice(cursor, span.start), replacement);
766
811
  outputLength += span.start - cursor;
@@ -812,6 +857,20 @@ function retainedReplacement(transform, mode, span, replacement) {
812
857
  );
813
858
  }
814
859
 
860
+ function callbackStringRanges(text, options) {
861
+ return options.structured && hasCustomTransforms(options)
862
+ ? structuredStringRanges(text)
863
+ : undefined;
864
+ }
865
+ function isCallbackSpan(callback, span, run, options) {
866
+ return (
867
+ typeof callback === 'function' &&
868
+ span.type !== 'JSON_VALUE' &&
869
+ (span.type !== 'ENCODED_SENSITIVE' ||
870
+ (!options.preserveEncoding && run?.encoding !== 'json-content'))
871
+ );
872
+ }
873
+
815
874
  export async function redactResultAsync(
816
875
  text,
817
876
  findings,
@@ -821,8 +880,22 @@ export async function redactResultAsync(
821
880
  options = initializePlaceholders(options);
822
881
  preparePlaceholderText(text, findings, options);
823
882
  const replacements = new Map();
883
+ const jsonRanges = callbackStringRanges(text, options);
824
884
  {
825
885
  for (const span of validatedSpans(text, findings, options)) {
886
+ const callback =
887
+ options.transformations?.[span.type] ?? options.transformation;
888
+ const run =
889
+ span.type === 'ENCODED_SENSITIVE'
890
+ ? encodedValue(text.slice(span.start, span.end))
891
+ : undefined;
892
+ if (isCallbackSpan(callback, span, run, options)) {
893
+ replacements.set(
894
+ `${span.start}:${span.end}`,
895
+ await renderCallbackAsync(text, span, callback, jsonRanges)
896
+ );
897
+ continue;
898
+ }
826
899
  if (span.type !== 'ENCODED_SENSITIVE') {
827
900
  if (hasPlaceholders(options)) {
828
901
  replacements.set(
@@ -832,7 +905,6 @@ export async function redactResultAsync(
832
905
  }
833
906
  continue;
834
907
  }
835
- const run = encodedValue(text.slice(span.start, span.end));
836
908
  if (!run) {
837
909
  if (options.preserveEncoding) {
838
910
  throw failure('ERR_ENCODING');
@@ -872,6 +944,14 @@ function sanitizeText(text, options = {}, round = 0) {
872
944
  options = placeholderKnown(initializePlaceholders(options));
873
945
  const findings = inspect(text, options);
874
946
  const result = redactResult(text, findings, options);
947
+ if (
948
+ hasCustomTransforms(options) &&
949
+ inspect(result.text, { ...options, findings: [] }).some((f) =>
950
+ actionable(f, options)
951
+ )
952
+ ) {
953
+ throw failure('ERR_RESIDUAL');
954
+ }
875
955
  const sanitized = redactResult(text, findings, {
876
956
  ...options,
877
957
  transformation: undefined,
@@ -1,6 +1,11 @@
1
1
  import { parentPort, workerData } from 'node:worker_threads';
2
2
  import { sanitizeFileToFile } from './stream.js';
3
+ import { compileKnown } from './compiled-known.js';
3
4
  try {
5
+ const options = workerData.options.sanitizerOptions ?? workerData.options;
6
+ if (options.knownPersonal) {
7
+ options.knownPersonal = compileKnown(options.knownPersonal);
8
+ }
4
9
  parentPort.postMessage({
5
10
  value: await sanitizeFileToFile(
6
11
  workerData.source,
package/src/stream.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from './engines.js';
21
21
  import { failure, internalFailure } from './detection.js';
22
22
  import { sanitizeJsonlStream } from './jsonl-stream.js';
23
- import { workerExecArgv } from './worker-options.js';
23
+ import { workerExecArgv, hasWorkerCallbacks } from './worker-options.js';
24
24
  import { streamIdentity } from './stream-identity.js';
25
25
  import { workerResult } from './file-worker-result.js';
26
26
  import {
@@ -439,11 +439,7 @@ export async function sanitizeFileToFile(
439
439
  }
440
440
  }
441
441
  export async function sanitizeFileBounded(source, target, options = {}) {
442
- if (
443
- options.worker === false ||
444
- options.sanitizer ||
445
- hasPlaceholders(options.sanitizerOptions ?? options)
446
- ) {
442
+ if (useCoordinator(options)) {
447
443
  return sanitizeFileToFile(source, target, options);
448
444
  }
449
445
  const workerHeapMb = options.workerHeapMb ?? 256,
@@ -484,3 +480,12 @@ export async function sanitizeFileBounded(source, target, options = {}) {
484
480
  await rm(staging, { recursive: true, force: true });
485
481
  }
486
482
  }
483
+ function useCoordinator(options) {
484
+ const policy = options.sanitizerOptions ?? options;
485
+ return (
486
+ options.worker === false ||
487
+ options.sanitizer ||
488
+ hasWorkerCallbacks(policy) ||
489
+ hasPlaceholders(policy)
490
+ );
491
+ }
package/src/strict.js ADDED
@@ -0,0 +1,66 @@
1
+ import { collectMatches } from './detection.js';
2
+ import { months } from './personal-context.js';
3
+
4
+ const boundary = '[\\p{L}\\p{N}_]';
5
+ const attributes =
6
+ /(?<![\p{L}\p{N}_])(?:женщин[аыуеой]{1,2}|мужчин[аыуеой]{1,2}|замужем|женат(?:а|ы)?|(?:один|одна|двое|трое|четверо|пятеро|\d{1,2})[ \t]+(?:детей|реб[её]нок|реб[её]нка)|русск(?:ая|ий|ого|ой|ие)|татар(?:ин|ка|ы)|православн(?:ый|ая|ого|ой)|мусульман(?:ин|ка)|инвалидност[ьи](?:[ \t]+\d[ \t]+группы)?|беременн(?:а|ая|ость)|судимость(?:[ \t]+погашена)?|член[ \t]+партии|woman|women|man|men|male|female|married|divorced|pregnant|pregnancy|disabled|disability|christian|muslim|jewish|orthodox|russian|hispanic|asian|criminal[ \t]+record|party[ \t]+member|(?:one|two|three|\d{1,2})[ \t]+children)(?![\p{L}\p{N}_])/giu;
7
+ const additionalAttributes =
8
+ /(?<![\p{L}\p{N}_])(?:диабет(?:ом|а|у|е)?|ВИЧ(?:-положительн(?:ый|ая|ого|ой))?|болен|больна|больны|католик(?:ом|а|и)?|католичк(?:а|ой|и)|буддист(?:ка|кой|ом|а|ы)?|атеист(?:ка|кой|ом|а|ы)?|украин(?:ец|ка|цы|цем|кой)|белорус(?:ка|кой|ом|а|ы)?|несудим(?:а|ы)?|судим(?:а|ы)?|член[ \t]+политической[ \t]+партии|холост(?:а|ы)?|развед[её]н(?:а|ы)?|в[ \t]+разводе|diabetes|diabetic|HIV(?:[ \t-]+positive)?|cancer|buddhist|catholic|atheist|ukrainian|belarusian|previous[ \t]+conviction|member[ \t]+of[ \t]+(?:a[ \t]+)?(?:political[ \t]+)?party|single|unmarried|widowed)(?![\p{L}\p{N}_])/giu;
9
+
10
+ export function detectStrict(text, emit, options) {
11
+ if (!options.strict) {
12
+ return;
13
+ }
14
+ for (const pattern of [attributes, additionalAttributes]) {
15
+ collectMatches(text, pattern, emit, {
16
+ type: 'PERSONAL_ATTRIBUTE',
17
+ rule: 'strict-attribute',
18
+ category: 'personal',
19
+ confidence: 0.9,
20
+ });
21
+ }
22
+ const date = new RegExp(
23
+ `(?<!${boundary})(?:\\d{4}-\\d{2}-\\d{2}|\\d{1,2}[./]\\d{1,2}[./]\\d{4}|\\d{1,2}[ \\t]+(?:${months})[ \\t]+\\d{4})(?!${boundary}|[Tt]\\d)`,
24
+ 'giu'
25
+ );
26
+ collectMatches(text, date, emit, {
27
+ type: 'DATE',
28
+ rule: 'strict-date',
29
+ category: 'personal',
30
+ confidence: 0.9,
31
+ });
32
+ collectMatches(
33
+ text,
34
+ /(?<![\p{L}\p{N}_])\d{1,3}[ \t]+(?:год|года|лет|years?[ \t]+old)(?![\p{L}\p{N}_])/giu,
35
+ emit,
36
+ { type: 'AGE', rule: 'strict-age', category: 'personal', confidence: 0.9 },
37
+ 0,
38
+ (_value, match) =>
39
+ !/(?:опыт(?:[ \t]+работы)?|стаж|experience|duration|за|через|спустя)[ \t]*$/iu.test(
40
+ text.slice(Math.max(0, match.index - 64), match.index)
41
+ )
42
+ );
43
+ for (const [type, pattern] of [
44
+ [
45
+ 'PASSPORT_NUMBER',
46
+ /(?<![\p{L}\p{N}_])(?:\d{2}[ \t]\d{2}[ \t]\d{6}|\d{10})(?![\p{L}\p{N}_])/gu,
47
+ ],
48
+ [
49
+ 'PHONE',
50
+ /(?<![\p{L}\p{N}_])(?:[78][ \t-]?)?9\d{2}[ \t-]?\d{3}[ \t-]?\d{2}[ \t-]?\d{2}(?![\p{L}\p{N}_])/gu,
51
+ ],
52
+ ['POSTAL_CODE', /(?<![\p{L}\p{N}_])\d{6}(?![\p{L}\p{N}_])/gu],
53
+ ]) {
54
+ collectMatches(
55
+ text,
56
+ pattern,
57
+ emit,
58
+ { type, rule: 'strict-number', category: 'personal', confidence: 0.9 },
59
+ 0,
60
+ (value, match) =>
61
+ !/^[ \t]*(?:ns|ms|µs|us|seconds?|bytes?|kb|mb|gb)\b/iu.test(
62
+ text.slice(match.index + value.length)
63
+ )
64
+ );
65
+ }
66
+ }
package/src/structured.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { failure } from './detection.js';
2
- import { documentFieldType, normalizeFieldName } from './document-fields.js';
2
+ import { documentFieldType } from './document-fields.js';
3
3
  import { hasPlaceholders } from './placeholders.js';
4
+ import { scanField, fieldMatches, builtInEnabled } from './policy.js';
5
+ import { vocabularyField } from './vocabulary.js';
4
6
 
5
7
  // Session metadata is opt-in at the CLI; library callers choose their own
6
8
  // fields. These names do not grant exemptions to provider/known credentials.
@@ -35,28 +37,45 @@ function* lexemes(text) {
35
37
  }
36
38
  }
37
39
 
40
+ const fieldPath = (frame) => [
41
+ ...frame.path,
42
+ frame.array ? String(frame.index) : frame.field,
43
+ ];
44
+ function stringToken(match, frame, offset) {
45
+ const key = match.key,
46
+ value = JSON.parse(match.raw);
47
+ if (key) {
48
+ frame.field = value;
49
+ }
50
+ return {
51
+ raw: match.raw,
52
+ start: offset + match.index,
53
+ field: key ? '' : frame.field,
54
+ property: key ? value : undefined,
55
+ path: fieldPath(frame),
56
+ key,
57
+ };
58
+ }
38
59
  function tokens(text, offset = 0) {
39
60
  const strings = [];
40
- const stack = [{ field: '' }];
61
+ const stack = [{ field: '', path: [], index: 0, array: false }];
41
62
  for (const match of lexemes(text)) {
42
63
  const raw = match.raw,
43
64
  frame = stack.at(-1);
44
65
  if (raw[0] === '"') {
45
- const key = match.key;
46
- const value = JSON.parse(raw);
47
- if (key) {
48
- frame.field = value;
49
- }
50
- strings.push({
51
- raw,
52
- start: offset + match.index,
53
- field: key ? '' : frame.field,
54
- key,
55
- });
66
+ strings.push(stringToken(match, frame, offset));
56
67
  } else if (raw === '{' || raw === '[') {
57
- stack.push({ field: frame.field });
68
+ const path = stack.length === 1 ? [] : fieldPath(frame);
69
+ stack.push({
70
+ field: raw === '[' ? frame.field : '',
71
+ path,
72
+ array: raw === '[',
73
+ index: 0,
74
+ });
58
75
  } else if (raw === '}' || raw === ']') {
59
76
  stack.pop();
77
+ } else if (raw === ',' && frame.array) {
78
+ frame.index++;
60
79
  }
61
80
  }
62
81
  return strings;
@@ -116,29 +135,47 @@ export function structuredBatches(text, options) {
116
135
  }
117
136
 
118
137
  // Schema declarations take priority over metadata/public/fake heuristics.
138
+ function configuredFieldType(field, options) {
139
+ if (!options.documentEntities || scanField(field, options)) {
140
+ return undefined;
141
+ }
142
+ const personal = vocabularyField(field, options),
143
+ builtin = documentFieldType(field);
144
+ if (personal === true) {
145
+ return 'PERSON';
146
+ }
147
+ return personal === false && builtin === 'PERSON' ? undefined : builtin;
148
+ }
119
149
  export function structuredFieldFindings(batch, options) {
120
150
  return batch.entries.flatMap((entry) => {
121
- const type = options.documentEntities
122
- ? documentFieldType(entry.field)
123
- : undefined;
151
+ const type = configuredFieldType(entry.field, options);
152
+ const denied =
153
+ options.allowedFields !== undefined &&
154
+ !entry.key &&
155
+ !fieldMatches(
156
+ options.allowedFields,
157
+ entry.field,
158
+ entry.path,
159
+ options.exactFields
160
+ );
124
161
  if (
125
162
  entry.key ||
126
- !type ||
127
- entry.raw.length <= 2 ||
163
+ (!type && !denied) ||
164
+ (!denied && entry.raw.length <= 2) ||
128
165
  JSON.parse(entry.raw) === '[REDACTED]'
129
166
  ) {
130
167
  return [];
131
168
  }
132
169
  return [
133
170
  {
134
- start: entry.start + 1,
135
- end: entry.start + entry.raw.length - 1,
136
- type,
171
+ start: entry.start + (denied ? 0 : 1),
172
+ end: entry.start + entry.raw.length - (denied ? 0 : 1),
173
+ type: denied ? 'JSON_VALUE' : type,
137
174
  category: 'personal',
138
175
  rule: 'known-personal',
139
176
  confidence: 1,
140
177
  },
141
- ];
178
+ ].filter((finding) => denied || builtInEnabled(finding, options));
142
179
  });
143
180
  }
144
181
 
@@ -152,8 +189,19 @@ export function structuredValueRanges(text, options) {
152
189
  }))
153
190
  );
154
191
  }
192
+ export function structuredStringRanges(text) {
193
+ return [...lexemes(text)]
194
+ .filter((entry) => entry.raw[0] === '"')
195
+ .map((entry) => ({
196
+ start: entry.index + 1,
197
+ end: entry.index + entry.raw.length - 1,
198
+ }));
199
+ }
155
200
 
156
201
  function weakStructuralFinding(finding, entry, batchText) {
202
+ if (finding.rule.startsWith('strict-')) {
203
+ return true;
204
+ }
157
205
  if (
158
206
  ['known-secret', 'known-personal', 'confirmed-personal'].includes(
159
207
  finding.rule
@@ -227,6 +275,13 @@ export function preserveIdentifierKeys(text, findings, options) {
227
275
  });
228
276
  }
229
277
  function preserveStructuredEntry(entry, finding, text, options) {
278
+ if (
279
+ !entry.key &&
280
+ scanField(entry.field, options) &&
281
+ (finding.rule === 'label' || finding.rule.startsWith('catalog/'))
282
+ ) {
283
+ return finding.category === 'personal';
284
+ }
230
285
  if (options.properNames && ordinaryPersonalKey(entry, finding, options)) {
231
286
  return true;
232
287
  }
@@ -407,32 +462,41 @@ export function declaredFieldFindings(text, options) {
407
462
  if (!options.sensitiveFields?.length) {
408
463
  return [];
409
464
  }
410
- const fields = new Set(options.sensitiveFields.map(normalizeFieldName));
411
465
  const findings = [];
412
- for (const token of lexemes(text)) {
413
- if (
414
- token.index < (findings.at(-1)?.end ?? 0) ||
415
- !token.key ||
416
- !fields.has(normalizeFieldName(JSON.parse(token.raw)))
417
- ) {
418
- continue;
419
- }
420
- let start = token.index + token.raw.length;
421
- while (/[\s:]/.test(text[start] ?? '') && start < text.length) {
422
- start++;
423
- }
424
- const end = jsonValueEnd(text, start);
425
- if (text.slice(start, end) === '"[REDACTED]"') {
426
- continue;
466
+ const records = options.structured === 'jsonl' ? text.split('\n') : [text];
467
+ let offset = 0;
468
+ for (const record of records) {
469
+ for (const token of tokens(record, offset)) {
470
+ if (
471
+ token.start < (findings.at(-1)?.end ?? 0) ||
472
+ !token.key ||
473
+ !fieldMatches(
474
+ options.sensitiveFields,
475
+ token.property,
476
+ [...token.path.slice(0, -1), token.property],
477
+ options.exactFields
478
+ )
479
+ ) {
480
+ continue;
481
+ }
482
+ let start = token.start + token.raw.length;
483
+ while (/[\s:]/.test(text[start] ?? '') && start < text.length) {
484
+ start++;
485
+ }
486
+ const end = jsonValueEnd(text, start);
487
+ if (text.slice(start, end) === '"[REDACTED]"') {
488
+ continue;
489
+ }
490
+ findings.push({
491
+ start,
492
+ end,
493
+ type: 'JSON_VALUE',
494
+ category: 'personal',
495
+ rule: 'known-personal',
496
+ confidence: 1,
497
+ });
427
498
  }
428
- findings.push({
429
- start,
430
- end,
431
- type: 'JSON_VALUE',
432
- category: 'personal',
433
- rule: 'known-personal',
434
- confidence: 1,
435
- });
499
+ offset += record.length + 1;
436
500
  }
437
501
  return findings;
438
502
  }
@@ -0,0 +1,38 @@
1
+ import { failure } from './detection.js';
2
+
3
+ export function validateDetectors(detectors = []) {
4
+ if (
5
+ !Array.isArray(detectors) ||
6
+ detectors.some((d) => !d || typeof d.detect !== 'function')
7
+ ) {
8
+ throw failure('ERR_CONFIG');
9
+ }
10
+ }
11
+
12
+ export function detectSync(text, options, emit) {
13
+ if (options.knownOnly) {
14
+ return;
15
+ }
16
+ for (const detector of options.detectors ?? []) {
17
+ let findings;
18
+ try {
19
+ findings = detector.detect(text);
20
+ } catch {
21
+ throw failure('ERR_ENGINE');
22
+ }
23
+ if (findings && typeof findings.then === 'function') {
24
+ // Observe rejections without allowing async work to publish output.
25
+ Promise.resolve(findings).catch(() => {});
26
+ throw failure('ERR_CONFIG');
27
+ }
28
+ if (!Array.isArray(findings)) {
29
+ throw failure('ERR_ENGINE');
30
+ }
31
+ if (findings.length > (options.maxFindings ?? 100000)) {
32
+ throw failure('ERR_LIMIT');
33
+ }
34
+ for (const finding of findings) {
35
+ emit(finding);
36
+ }
37
+ }
38
+ }
@@ -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
+ }