@link-foundation/sensitive-data-sanitizer 0.17.1 → 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,9 @@
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';
6
+ import { validateProperNames } from './proper-names.js';
2
7
  import { prepareFakeText } from './fake-entities.js';
3
8
  import { alignEscapes } from './escape-spans.js';
4
9
  import { actionable } from './confidence.js';
@@ -15,7 +20,12 @@ import { projectText } from './projection.js';
15
20
  import { decodedRuns, encodeRun, encodedValue } from './encoded.js';
16
21
  import { URL } from 'node:url';
17
22
  import { isPublic } from './public.js';
18
- import { validateTransforms, renderReplacement } from './transforms.js';
23
+ import {
24
+ validateTransforms,
25
+ renderReplacement,
26
+ hasCustomTransforms,
27
+ renderCallbackAsync,
28
+ } from './transforms.js';
19
29
  import { fakeIdentityValue, isIdentityType } from './identity.js';
20
30
  import { rejectRestoreMap } from './restore-map.js';
21
31
  import {
@@ -37,6 +47,7 @@ import {
37
47
  mapStructuredFindings,
38
48
  preserveIdentifierKeys,
39
49
  structuredValueRanges,
50
+ structuredStringRanges,
40
51
  verifyStructured,
41
52
  } from './structured.js';
42
53
 
@@ -78,6 +89,16 @@ const optionNames = new Set([
78
89
  'threshold',
79
90
  'profile',
80
91
  'documentEntities',
92
+ 'properNames',
93
+ 'scripts',
94
+ 'rules',
95
+ 'types',
96
+ 'fields',
97
+ 'allowCredentialDisable',
98
+ 'vocabulary',
99
+ 'strict',
100
+ 'exactFields',
101
+ 'allowedFields',
81
102
  ]);
82
103
 
83
104
  export function applyProfile(options = {}) {
@@ -105,6 +126,7 @@ function validateCollections(options) {
105
126
  'fakeValues',
106
127
  'structuralFields',
107
128
  'sensitiveFields',
129
+ 'allowedFields',
108
130
  ]) {
109
131
  if (options[key] !== undefined && !Array.isArray(options[key])) {
110
132
  throw failure('ERR_CONFIG');
@@ -115,6 +137,7 @@ function validateCollections(options) {
115
137
  'fakeValues',
116
138
  'structuralFields',
117
139
  'sensitiveFields',
140
+ 'allowedFields',
118
141
  ]) {
119
142
  if (
120
143
  (options[key] ?? []).some((value) => typeof value !== 'string' || !value)
@@ -123,7 +146,9 @@ function validateCollections(options) {
123
146
  }
124
147
  }
125
148
  for (const entry of [
126
- ...(options.knownPersonal ?? []),
149
+ ...(isCompiledKnown(options.knownPersonal)
150
+ ? []
151
+ : (options.knownPersonal ?? [])),
127
152
  ...(options.confirmedPersonal ?? []),
128
153
  ]) {
129
154
  if (
@@ -191,6 +216,7 @@ export function validateOptions(options = {}) {
191
216
  }
192
217
  }
193
218
  validateConfidence(options);
219
+ validateProperNames(options);
194
220
  validatePolicy(options);
195
221
  if (
196
222
  options.verifyPublic !== undefined &&
@@ -199,6 +225,9 @@ export function validateOptions(options = {}) {
199
225
  throw failure('ERR_CONFIG');
200
226
  }
201
227
  validateCollections(options);
228
+ validateDetectors(options.detectors);
229
+ validatePolicyControls(options);
230
+ compiledVocabulary(options.vocabulary);
202
231
  validateTransforms(options);
203
232
  validateLimits(options);
204
233
  for (const entry of options.publicEntities ?? []) {
@@ -347,7 +376,12 @@ function encodedDetect(text, options, emit) {
347
376
  }
348
377
  const found = [];
349
378
  const view = projectText(run.text).text;
350
- 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
+ );
351
385
  const sensitive = reviewFindings(view, found, options).filter((f) =>
352
386
  actionable(f, options)
353
387
  );
@@ -410,11 +444,21 @@ function inspectText(text, options = {}) {
410
444
  ...options,
411
445
  structured: undefined,
412
446
  findings: [],
447
+ detectors: [],
448
+ properNames: undefined,
449
+ documentEntities: options.properNames
450
+ ? false
451
+ : options.documentEntities,
413
452
  }),
414
453
  options
415
454
  ),
416
- ...inspectStructuredValues(batch, options, (value) =>
417
- inspect(value, { ...options, structured: undefined, findings: [] })
455
+ ...inspectStructuredValues(batch, options, (value, prose) =>
456
+ inspect(value, {
457
+ ...options,
458
+ structured: undefined,
459
+ findings: [],
460
+ properNames: prose ? options.properNames : undefined,
461
+ })
418
462
  ),
419
463
  ]);
420
464
  return inspectFindingsOnly(
@@ -436,10 +480,12 @@ function inspectText(text, options = {}) {
436
480
  knownSecrets: (options.knownSecrets ?? []).map(
437
481
  (value) => projectText(value).text
438
482
  ),
439
- knownPersonal: (options.knownPersonal ?? []).map((entry) => ({
440
- ...entry,
441
- value: projectText(entry.value).text,
442
- })),
483
+ knownPersonal:
484
+ projectedKnown(options.knownPersonal) ??
485
+ (options.knownPersonal ?? []).map((entry) => ({
486
+ ...entry,
487
+ value: projectText(entry.value).text,
488
+ })),
443
489
  confirmedPersonal: (options.confirmedPersonal ?? []).map((entry) => ({
444
490
  ...entry,
445
491
  value: projectText(entry.value).text,
@@ -457,9 +503,15 @@ function inspectText(text, options = {}) {
457
503
  );
458
504
  if (projection.changed) {
459
505
  nativeDetect(text, options, emit);
506
+ detectSync(text, options, emit);
460
507
  }
461
508
  nativeDetect(projection.text, nativeOptions, mapped);
462
- 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
+ });
463
515
  for (const f of options.findings ?? []) {
464
516
  emit(f);
465
517
  }
@@ -732,22 +784,28 @@ export function redactResult(
732
784
  const parts = [];
733
785
  const faked = [];
734
786
  const occurrences = [];
787
+ const jsonRanges = callbackStringRanges(text, options);
735
788
  let irreversibleRedactions = 0;
736
789
  let outputLength = 0;
737
790
  let cursor = 0;
738
791
  for (const span of spans) {
739
792
  const replacement =
740
793
  replacements.get(`${span.start}:${span.end}`) ??
741
- renderReplacement(text, span, options, (decoded) =>
742
- sanitize(
743
- decoded,
744
- nestedPlaceholderOptions({
745
- ...confirmationOptions(text, findings, options),
746
- findings: [],
747
- preserveEncoding: true,
748
- structured: undefined,
749
- })
750
- )
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
751
809
  );
752
810
  parts.push(text.slice(cursor, span.start), replacement);
753
811
  outputLength += span.start - cursor;
@@ -799,6 +857,20 @@ function retainedReplacement(transform, mode, span, replacement) {
799
857
  );
800
858
  }
801
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
+
802
874
  export async function redactResultAsync(
803
875
  text,
804
876
  findings,
@@ -808,8 +880,22 @@ export async function redactResultAsync(
808
880
  options = initializePlaceholders(options);
809
881
  preparePlaceholderText(text, findings, options);
810
882
  const replacements = new Map();
883
+ const jsonRanges = callbackStringRanges(text, options);
811
884
  {
812
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
+ }
813
899
  if (span.type !== 'ENCODED_SENSITIVE') {
814
900
  if (hasPlaceholders(options)) {
815
901
  replacements.set(
@@ -819,7 +905,6 @@ export async function redactResultAsync(
819
905
  }
820
906
  continue;
821
907
  }
822
- const run = encodedValue(text.slice(span.start, span.end));
823
908
  if (!run) {
824
909
  if (options.preserveEncoding) {
825
910
  throw failure('ERR_ENCODING');
@@ -859,6 +944,14 @@ function sanitizeText(text, options = {}, round = 0) {
859
944
  options = placeholderKnown(initializePlaceholders(options));
860
945
  const findings = inspect(text, options);
861
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
+ }
862
955
  const sanitized = redactResult(text, findings, {
863
956
  ...options,
864
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
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
@@ -182,10 +230,10 @@ function weakStructuralFinding(finding, entry, batchText) {
182
230
  }
183
231
  return false;
184
232
  }
185
- function ordinaryPersonalKey(entry, finding) {
233
+ function ordinaryPersonalKey(entry, finding, options = {}) {
186
234
  return (
187
235
  entry.key &&
188
- /^[A-Za-z_$][\w$]*$/.test(JSON.parse(entry.raw)) &&
236
+ (options.properNames || /^[A-Za-z_$][\w$]*$/.test(JSON.parse(entry.raw))) &&
189
237
  finding.category === 'personal' &&
190
238
  finding.rule !== 'known-personal'
191
239
  );
@@ -193,12 +241,17 @@ function ordinaryPersonalKey(entry, finding) {
193
241
  // Propagation runs again at source offsets. Apply the same key policy after it
194
242
  // so a name confirmed in a value cannot rename an ordinary identifier key.
195
243
  export function preserveIdentifierKeys(text, findings, options) {
196
- if (!options.structured || hasPlaceholders(options)) {
244
+ if (
245
+ !options.structured ||
246
+ (hasPlaceholders(options) && !options.properNames)
247
+ ) {
197
248
  return findings;
198
249
  }
199
250
  const keys = structuredBatches(text, options).flatMap((batch) =>
200
251
  batch.entries
201
- .filter((entry) => ordinaryPersonalKey(entry, { category: 'personal' }))
252
+ .filter((entry) =>
253
+ ordinaryPersonalKey(entry, { category: 'personal' }, options)
254
+ )
202
255
  .map((entry) => ({
203
256
  start: entry.start,
204
257
  end: entry.start + entry.raw.length,
@@ -222,13 +275,23 @@ export function preserveIdentifierKeys(text, findings, options) {
222
275
  });
223
276
  }
224
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
+ }
285
+ if (options.properNames && ordinaryPersonalKey(entry, finding, options)) {
286
+ return true;
287
+ }
225
288
  if (hasPlaceholders(options)) {
226
289
  return false;
227
290
  }
228
291
  return (
229
292
  (entry.key &&
230
293
  ['confirmed-personal', 'document-propagation'].includes(finding.rule)) ||
231
- ordinaryPersonalKey(entry, finding) ||
294
+ ordinaryPersonalKey(entry, finding, options) ||
232
295
  (!entry.key &&
233
296
  options.structuralFields?.includes(entry.field) &&
234
297
  weakStructuralFinding(finding, entry, text))
@@ -346,28 +409,28 @@ export function* structuredValueViews(
346
409
  end: entry.start + raw.end,
347
410
  });
348
411
  };
349
- yield { text: value, map };
350
- // Match the encoded detector's finite depth; arbitrary nesting is a limit.
412
+ // A serialized container is a transport envelope, not one prose value.
413
+ // Activate the optional scanner only in its independently decoded leaves.
414
+ let batches = [];
351
415
  if (depth < 2 && /^[ \t\r\n]*[[{]/.test(value)) {
352
- let batches;
353
416
  try {
354
417
  batches = structuredBatches(value, { ...options, structured: 'json' });
355
418
  } catch (error) {
356
419
  if (error.code !== 'ERR_JSON') {
357
420
  throw error;
358
421
  }
359
- batches = [];
360
- }
361
- for (const nested of batches) {
362
- yield* structuredValueViews(nested, options, depth + 1, map);
363
422
  }
364
423
  }
424
+ yield { text: value, map, prose: !entry.key && batches.length === 0 };
425
+ for (const nested of batches) {
426
+ yield* structuredValueViews(nested, options, depth + 1, map);
427
+ }
365
428
  }
366
429
  }
367
430
  export function inspectStructuredValues(batch, options, inspectValue) {
368
431
  const result = [];
369
432
  for (const view of structuredValueViews(batch, options)) {
370
- const findings = inspectValue(view.text);
433
+ const findings = inspectValue(view.text, view.prose);
371
434
  options.debug?.({ event: 'structured-value', count: findings.length });
372
435
  for (const finding of findings) {
373
436
  const mapped = view.map(finding);
@@ -399,32 +462,41 @@ export function declaredFieldFindings(text, options) {
399
462
  if (!options.sensitiveFields?.length) {
400
463
  return [];
401
464
  }
402
- const fields = new Set(options.sensitiveFields);
403
465
  const findings = [];
404
- for (const token of lexemes(text)) {
405
- if (
406
- token.index < (findings.at(-1)?.end ?? 0) ||
407
- !token.key ||
408
- !fields.has(JSON.parse(token.raw))
409
- ) {
410
- continue;
411
- }
412
- let start = token.index + token.raw.length;
413
- while (/[\s:]/.test(text[start] ?? '') && start < text.length) {
414
- start++;
415
- }
416
- const end = jsonValueEnd(text, start);
417
- if (text.slice(start, end) === '"[REDACTED]"') {
418
- 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
+ });
419
498
  }
420
- findings.push({
421
- start,
422
- end,
423
- type: 'JSON_VALUE',
424
- category: 'personal',
425
- rule: 'known-personal',
426
- confidence: 1,
427
- });
499
+ offset += record.length + 1;
428
500
  }
429
501
  return findings;
430
502
  }