@link-foundation/sensitive-data-sanitizer 0.12.2 → 0.14.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.
@@ -0,0 +1,220 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Parser, Link } from 'links-notation';
3
+ import { failure, escapePattern } from './detection.js';
4
+ import { atomicWrite, readTextFile } from './files.js';
5
+
6
+ export const restoreMapKind = 'sensitive-data-restore-map';
7
+ export const documentDigest = (text) =>
8
+ createHash('sha256').update(text).digest('hex');
9
+ const defaultFormat = '{Type}-{n}';
10
+ export function validatePlaceholderFormat(format = defaultFormat) {
11
+ if (
12
+ typeof format !== 'string' ||
13
+ format.length > 128 ||
14
+ !/\{(?:Type|TYPE|type)\}/.test(format) ||
15
+ !format.includes('{n}') ||
16
+ /[\r\n\0]/.test(format) ||
17
+ (format.match(/\{n\}/g) ?? []).length !== 1 ||
18
+ (format.match(/\{(?:Type|TYPE|type)\}/g) ?? []).length !== 1 ||
19
+ /[{}]/.test(
20
+ format
21
+ .replace(/\{(?:Type|TYPE|type|n)\}/g, '')
22
+ .replaceAll('{{', '')
23
+ .replaceAll('}}', '')
24
+ )
25
+ ) {
26
+ throw failure('ERR_CONFIG');
27
+ }
28
+ return format;
29
+ }
30
+ export function placeholderToken(type, number, format = defaultFormat) {
31
+ const title = type
32
+ .toLowerCase()
33
+ .split('_')
34
+ .filter(Boolean)
35
+ .map((word) => word[0].toUpperCase() + word.slice(1))
36
+ .join('');
37
+ return format.replace(
38
+ /\{(Type|TYPE|type|n)\}/g,
39
+ (_, key) =>
40
+ ({
41
+ Type: title,
42
+ TYPE: type,
43
+ type: type.toLowerCase(),
44
+ n: String(number),
45
+ })[key]
46
+ );
47
+ }
48
+ export function createRestoreMap({ format = defaultFormat } = {}) {
49
+ return {
50
+ kind: restoreMapKind,
51
+ version: 1,
52
+ format: validatePlaceholderFormat(format),
53
+ entries: [],
54
+ documents: [],
55
+ };
56
+ }
57
+ export function validateRestoreMap(map) {
58
+ if (
59
+ !map ||
60
+ map.kind !== restoreMapKind ||
61
+ map.version !== 1 ||
62
+ !Array.isArray(map.entries) ||
63
+ !Array.isArray(map.documents) ||
64
+ map.entries.length > 100000 ||
65
+ map.documents.length > 100000
66
+ ) {
67
+ throw failure('ERR_MAP');
68
+ }
69
+ validatePlaceholderFormat(map.format);
70
+ validateEntries(map);
71
+ for (const doc of map.documents) {
72
+ validateDocument(doc);
73
+ }
74
+ return map;
75
+ }
76
+ function validateEntries(map) {
77
+ const tokens = new Set();
78
+ for (const entry of map.entries) {
79
+ if (
80
+ !entry ||
81
+ !/^[A-Z][A-Z0-9_]{0,63}$/.test(entry.type) ||
82
+ !Number.isSafeInteger(entry.number) ||
83
+ entry.number < 1 ||
84
+ entry.token !== placeholderToken(entry.type, entry.number, map.format) ||
85
+ tokens.has(entry.token) ||
86
+ !nonemptyStrings(entry.values) ||
87
+ !Array.isArray(entry.aliases) ||
88
+ entry.aliases.some((v) => typeof v !== 'string' || !v)
89
+ ) {
90
+ throw failure('ERR_MAP');
91
+ }
92
+ tokens.add(entry.token);
93
+ }
94
+ }
95
+ function nonemptyStrings(values) {
96
+ return (
97
+ Array.isArray(values) &&
98
+ values.length > 0 &&
99
+ values.every((v) => typeof v === 'string' && v.length > 0)
100
+ );
101
+ }
102
+ function validateDocument(doc) {
103
+ if (
104
+ !doc ||
105
+ !/^[a-f0-9]{64}$/.test(doc.digest) ||
106
+ !Array.isArray(doc.occurrences)
107
+ ) {
108
+ throw failure('ERR_MAP');
109
+ }
110
+ let end = 0;
111
+ for (const occurrence of doc.occurrences) {
112
+ if (
113
+ !occurrence ||
114
+ !Number.isSafeInteger(occurrence.start) ||
115
+ occurrence.start < end ||
116
+ !Number.isSafeInteger(occurrence.end) ||
117
+ occurrence.end <= occurrence.start ||
118
+ typeof occurrence.raw !== 'string' ||
119
+ typeof occurrence.token !== 'string' ||
120
+ !occurrence.token ||
121
+ occurrence.end - occurrence.start !== occurrence.token.length
122
+ ) {
123
+ throw failure('ERR_MAP');
124
+ }
125
+ end = occurrence.end;
126
+ }
127
+ }
128
+
129
+ export function serializeRestoreMap(map, format = 'json') {
130
+ validateRestoreMap(map);
131
+ if (format === 'json') {
132
+ return `${JSON.stringify(map, null, 2)}\n`;
133
+ }
134
+ if (format !== 'lino') {
135
+ throw failure('ERR_CONFIG');
136
+ }
137
+ // Let the upstream formatter handle quotes, slashes, newlines and Unicode.
138
+ return `${new Link(restoreMapKind, [new Link(JSON.stringify(map))]).toString()}\n`;
139
+ }
140
+ export function parseRestoreMap(text) {
141
+ try {
142
+ if (
143
+ typeof text !== 'string' ||
144
+ Buffer.byteLength(text) > 32 * 1024 * 1024
145
+ ) {
146
+ throw failure('ERR_MAP');
147
+ }
148
+ if (text.trimStart().startsWith('{')) {
149
+ return validateRestoreMap(JSON.parse(text));
150
+ }
151
+ const links = new Parser({
152
+ maxInputSize: 32 * 1024 * 1024,
153
+ maxDepth: 8,
154
+ }).parse(text);
155
+ if (
156
+ links.length !== 1 ||
157
+ links[0].id !== restoreMapKind ||
158
+ links[0].values.length !== 1 ||
159
+ links[0].values[0].values.length
160
+ ) {
161
+ throw failure('ERR_MAP');
162
+ }
163
+ return validateRestoreMap(JSON.parse(links[0].values[0].id));
164
+ } catch {
165
+ throw failure('ERR_MAP');
166
+ }
167
+ }
168
+ export const readRestoreMap = async (path) =>
169
+ parseRestoreMap(await readTextFile(path, 32 * 1024 * 1024));
170
+ export async function writeRestoreMap(
171
+ path,
172
+ map,
173
+ { format = path.endsWith('.lino') ? 'lino' : 'json', replace = false } = {}
174
+ ) {
175
+ await atomicWrite(path, serializeRestoreMap(map, format), { replace });
176
+ }
177
+ export function rejectRestoreMap(text) {
178
+ if (
179
+ /"kind"\s*:\s*"sensitive-data-restore-map"|\(["\x27`]?sensitive-data-restore-map["\x27`]?\s*:/.test(
180
+ text
181
+ )
182
+ ) {
183
+ throw failure('ERR_PRIVATE_MAP');
184
+ }
185
+ }
186
+ export function restore(text, map) {
187
+ if (typeof text !== 'string') {
188
+ throw failure('ERR_INPUT');
189
+ }
190
+ validateRestoreMap(map);
191
+ const document = map.documents.find(
192
+ (doc) => doc.digest === documentDigest(text)
193
+ );
194
+ if (document) {
195
+ let cursor = 0;
196
+ const parts = [];
197
+ for (const occurrence of document.occurrences) {
198
+ if (text.slice(occurrence.start, occurrence.end) !== occurrence.token) {
199
+ throw failure('ERR_MAP');
200
+ }
201
+ parts.push(text.slice(cursor, occurrence.start), occurrence.raw);
202
+ cursor = occurrence.end;
203
+ }
204
+ return parts.join('') + text.slice(cursor);
205
+ }
206
+ const entries = new Map(
207
+ map.entries.map((entry) => [entry.token, entry.values[0]])
208
+ );
209
+ if (!entries.size) {
210
+ return text;
211
+ }
212
+ const pattern = new RegExp(
213
+ `(?<![\\p{L}\\p{N}_])(?:${[...entries.keys()]
214
+ .sort((a, b) => b.length - a.length)
215
+ .map(escapePattern)
216
+ .join('|')})(?![\\p{L}\\p{N}_-])`,
217
+ 'gu'
218
+ );
219
+ return text.replace(pattern, (token) => entries.get(token));
220
+ }
package/src/sanitizer.js CHANGED
@@ -16,6 +16,17 @@ import { URL } from 'node:url';
16
16
  import { isPublic } from './public.js';
17
17
  import { validateTransforms, renderReplacement } from './transforms.js';
18
18
  import { fakeIdentityValue, isIdentityType } from './identity.js';
19
+ import { rejectRestoreMap } from './restore-map.js';
20
+ import {
21
+ initializePlaceholders,
22
+ placeholderKnown,
23
+ preparePlaceholderText,
24
+ finishPlaceholders,
25
+ hasPlaceholders,
26
+ placeholderValues,
27
+ placeholderNameFindings,
28
+ nestedPlaceholderOptions,
29
+ } from './placeholders.js';
19
30
  import {
20
31
  structuredBatches,
21
32
  mapStructuredFindings,
@@ -25,9 +36,16 @@ import {
25
36
  } from './structured.js';
26
37
 
27
38
  const fakedResults = new WeakMap();
28
- const publicTypes = new Set(['PERSON', 'ORGANIZATION', 'EMAIL']);
39
+ const publicTypes = new Set([
40
+ 'PERSON',
41
+ 'ORGANIZATION',
42
+ 'EDUCATION',
43
+ 'LOCATION',
44
+ 'EMAIL',
45
+ ]);
29
46
  const optionNames = new Set([
30
47
  'credentialsOnly',
48
+ 'knownOnly',
31
49
  'knownSecrets',
32
50
  'knownPersonal',
33
51
  'confirmedPersonal',
@@ -53,9 +71,13 @@ const optionNames = new Set([
53
71
  'minConfidence',
54
72
  'threshold',
55
73
  'profile',
74
+ 'documentEntities',
56
75
  ]);
57
76
 
58
77
  export function applyProfile(options = {}) {
78
+ if (options?.profile === 'documents') {
79
+ return { ...options, documentEntities: options.documentEntities ?? true };
80
+ }
59
81
  return options?.profile === 'publication'
60
82
  ? {
61
83
  ...options,
@@ -145,10 +167,12 @@ export function validateOptions(options = {}) {
145
167
  for (const key of [
146
168
  'paranoid',
147
169
  'credentialsOnly',
170
+ 'knownOnly',
148
171
  'decode',
149
172
  'secretlint',
150
173
  'publicKnowledge',
151
174
  'identityMask',
175
+ 'documentEntities',
152
176
  ]) {
153
177
  if (options[key] !== undefined && typeof options[key] !== 'boolean') {
154
178
  throw failure('ERR_CONFIG');
@@ -196,7 +220,7 @@ function validatePolicy(options) {
196
220
  for (const [key, values] of [
197
221
  ['fakeIdentity', [false, 'specimen-and-synthetic']],
198
222
  ['structured', ['json', 'jsonl']],
199
- ['profile', ['publication']],
223
+ ['profile', ['publication', 'documents']],
200
224
  ]) {
201
225
  if (options[key] !== undefined && !values.includes(options[key])) {
202
226
  throw failure('ERR_CONFIG');
@@ -212,6 +236,7 @@ export function validateInput(text, options) {
212
236
  if (typeof text !== 'string') {
213
237
  throw failure('ERR_INPUT');
214
238
  }
239
+ rejectRestoreMap(text);
215
240
  if (text.length > (options.maxInputLength ?? 32 * 1024 * 1024)) {
216
241
  throw failure('ERR_LIMIT');
217
242
  }
@@ -423,15 +448,16 @@ function inspectText(text, options = {}) {
423
448
 
424
449
  // Reapply policy to the native/required-engine union without rescanning text.
425
450
  export function reviewFindings(text, findings, options) {
451
+ findings = placeholderNameFindings(text, findings, options);
426
452
  if (findings.length > (options.maxFindings ?? 100000)) {
427
453
  throw failure('ERR_LIMIT');
428
454
  }
429
455
  const unique = new Map();
456
+ const exempt = findings.filter((f) =>
457
+ allowed(text, f, options.publicEntities ?? [])
458
+ );
430
459
  for (const f of findings) {
431
- if (
432
- !allowed(text, f, options.publicEntities ?? []) &&
433
- !isPublic(text, f, options)
434
- ) {
460
+ if (!coveredByPublic(f, exempt) && !isPublic(text, f, options)) {
435
461
  unique.set(`${f.start}:${f.end}:${f.category}:${f.type}:${f.rule}`, f);
436
462
  }
437
463
  }
@@ -449,6 +475,18 @@ export function reviewFindings(text, findings, options) {
449
475
  options.debug?.({ event: 'detected', count: result.length });
450
476
  return result;
451
477
  }
478
+ function coveredByPublic(finding, exempt) {
479
+ return (
480
+ finding.category === 'personal' &&
481
+ !['known-personal', 'confirmed-personal'].includes(finding.rule) &&
482
+ exempt.some(
483
+ (span) =>
484
+ span.type === finding.type &&
485
+ span.start <= finding.start &&
486
+ span.end >= finding.end
487
+ )
488
+ );
489
+ }
452
490
 
453
491
  export function inspectFindingsOnly(text, findings, options) {
454
492
  const all = [...findings, ...(options.findings ?? [])].map((f) =>
@@ -484,7 +522,10 @@ function selectedFindings(findings, credentialsOnly) {
484
522
 
485
523
  export function markFake(text, findings, options) {
486
524
  findings = selectedFindings(findings, options.credentialsOnly);
487
- const generated = generatedValues(options);
525
+ const generated = [
526
+ ...generatedValues(options),
527
+ ...placeholderValues(options),
528
+ ];
488
529
  if (
489
530
  !options.fakeIdentity &&
490
531
  !options.fakeValues?.length &&
@@ -651,37 +692,72 @@ export function redactResult(
651
692
  ) {
652
693
  options = applyProfile(options);
653
694
  const spans = validatedSpans(text, findings, options);
695
+ options = initializePlaceholders(options);
696
+ preparePlaceholderText(text, findings, options);
654
697
  const parts = [];
655
698
  const faked = [];
699
+ const occurrences = [];
700
+ let irreversibleRedactions = 0;
701
+ let outputLength = 0;
656
702
  let cursor = 0;
657
703
  for (const span of spans) {
658
704
  const replacement =
659
705
  replacements.get(`${span.start}:${span.end}`) ??
660
706
  renderReplacement(text, span, options, (decoded) =>
661
- sanitize(decoded, {
662
- ...confirmationOptions(text, findings, options),
663
- findings: [],
664
- preserveEncoding: true,
665
- structured: undefined,
666
- })
707
+ sanitize(
708
+ decoded,
709
+ nestedPlaceholderOptions({
710
+ ...confirmationOptions(text, findings, options),
711
+ findings: [],
712
+ preserveEncoding: true,
713
+ structured: undefined,
714
+ })
715
+ )
667
716
  );
668
717
  parts.push(text.slice(cursor, span.start), replacement);
718
+ outputLength += span.start - cursor;
669
719
  const transform =
670
720
  options.transformations?.[span.type] ?? options.transformation;
721
+ if (retainedReplacement(transform, 'placeholder', span, replacement)) {
722
+ occurrences.push({
723
+ start: outputLength,
724
+ end: outputLength + replacement.length,
725
+ token: replacement,
726
+ raw: text.slice(span.start, span.end),
727
+ });
728
+ }
671
729
  if (
672
- transform?.mode === 'fake' &&
673
- replacement !== REDACTED &&
674
- (span.category === 'personal' || transform.credentials)
730
+ hasPlaceholders(options) &&
731
+ !retainedReplacement(transform, 'placeholder', span, replacement)
675
732
  ) {
733
+ irreversibleRedactions++;
734
+ }
735
+ outputLength += replacement.length;
736
+ if (retainedReplacement(transform, 'fake', span, replacement)) {
676
737
  faked.push(span);
677
738
  }
678
739
  cursor = span.end;
679
740
  }
680
741
  parts.push(text.slice(cursor));
681
- const result = { text: parts.join(''), redactions: spans.length };
742
+ const result = finishPlaceholders(
743
+ {
744
+ text: parts.join(''),
745
+ redactions: spans.length,
746
+ ...(hasPlaceholders(options) ? { irreversibleRedactions } : {}),
747
+ },
748
+ occurrences,
749
+ options
750
+ );
682
751
  fakedResults.set(result, faked);
683
752
  return result;
684
753
  }
754
+ function retainedReplacement(transform, mode, span, replacement) {
755
+ return (
756
+ transform?.mode === mode &&
757
+ replacement !== REDACTED &&
758
+ (span.category === 'personal' || transform.credentials)
759
+ );
760
+ }
685
761
 
686
762
  export async function redactResultAsync(
687
763
  text,
@@ -689,10 +765,18 @@ export async function redactResultAsync(
689
765
  options,
690
766
  sanitizeDecoded
691
767
  ) {
768
+ options = initializePlaceholders(options);
769
+ preparePlaceholderText(text, findings, options);
692
770
  const replacements = new Map();
693
771
  {
694
772
  for (const span of validatedSpans(text, findings, options)) {
695
773
  if (span.type !== 'ENCODED_SENSITIVE') {
774
+ if (hasPlaceholders(options)) {
775
+ replacements.set(
776
+ `${span.start}:${span.end}`,
777
+ renderReplacement(text, span, options)
778
+ );
779
+ }
696
780
  continue;
697
781
  }
698
782
  const run = encodedValue(text.slice(span.start, span.end));
@@ -707,7 +791,7 @@ export async function redactResultAsync(
707
791
  }
708
792
  const result = await sanitizeDecoded(
709
793
  run.text,
710
- confirmationOptions(text, findings, options)
794
+ nestedPlaceholderOptions(confirmationOptions(text, findings, options))
711
795
  );
712
796
  replacements.set(
713
797
  `${span.start}:${span.end}`,
@@ -731,6 +815,8 @@ export function sanitize(text, options = {}) {
731
815
  }
732
816
  function sanitizeText(text, options = {}, round = 0) {
733
817
  options = applyProfile(options);
818
+ validateOptions(options);
819
+ options = placeholderKnown(initializePlaceholders(options));
734
820
  const findings = inspect(text, options);
735
821
  const result = redactResult(text, findings, options);
736
822
  const sanitized = redactResult(text, findings, {
@@ -752,6 +838,14 @@ function sanitizeText(text, options = {}, round = 0) {
752
838
  if (options.structured) {
753
839
  verifyStructured(result.text, options);
754
840
  }
841
+ if (
842
+ hasPlaceholders(options) &&
843
+ inspect(result.text, { ...options, findings: [] }).some((f) =>
844
+ actionable(f, options)
845
+ )
846
+ ) {
847
+ throw failure('ERR_RESIDUAL');
848
+ }
755
849
  return { ...result, findings: auditFaked(findings, options, result) };
756
850
  }
757
851
 
@@ -780,6 +874,13 @@ export function redactAgain(result, findings, options, round, rerun) {
780
874
  const merge = (next) => ({
781
875
  ...next,
782
876
  redactions: result.redactions + next.redactions,
877
+ ...(hasPlaceholders(options)
878
+ ? {
879
+ irreversibleRedactions:
880
+ (result.irreversibleRedactions ?? 0) +
881
+ (next.irreversibleRedactions ?? 0),
882
+ }
883
+ : {}),
783
884
  findings: auditFaked(findings, options, result),
784
885
  });
785
886
  const next = rerun({ ...options, findings: [] }, round + 1);
package/src/stream.js CHANGED
@@ -23,6 +23,11 @@ import { sanitizeJsonlStream } from './jsonl-stream.js';
23
23
  import { workerExecArgv } from './worker-options.js';
24
24
  import { streamIdentity } from './stream-identity.js';
25
25
  import { workerResult } from './file-worker-result.js';
26
+ import {
27
+ hasPlaceholders,
28
+ initializePlaceholders,
29
+ beginPlaceholderStream,
30
+ } from './placeholders.js';
26
31
 
27
32
  const streamKeys = new Set([
28
33
  'sanitizer',
@@ -63,7 +68,7 @@ function configuration(options, collecting = false, complete) {
63
68
  collecting,
64
69
  complete
65
70
  );
66
- const sanitizerOptions = identity.options;
71
+ const sanitizerOptions = initializePlaceholders(identity.options);
67
72
  const engine = createSanitizer(sanitizerOptions);
68
73
  return {
69
74
  maxRecordBytes,
@@ -215,6 +220,18 @@ export async function* sanitizeStream(source, options = {}) {
215
220
  }
216
221
  }
217
222
  async function* streamWithConfiguration(source, options, config) {
223
+ const finish = config.collecting
224
+ ? () => {}
225
+ : beginPlaceholderStream(config.sanitizerOptions);
226
+ let complete = false;
227
+ try {
228
+ yield* streamBatches(source, options, config);
229
+ complete = true;
230
+ } finally {
231
+ finish(complete);
232
+ }
233
+ }
234
+ async function* streamBatches(source, options, config) {
218
235
  if (config.jsonl) {
219
236
  yield* sanitizeJsonlStream(source, options, config);
220
237
  return;
@@ -422,7 +439,11 @@ export async function sanitizeFileToFile(
422
439
  }
423
440
  }
424
441
  export async function sanitizeFileBounded(source, target, options = {}) {
425
- if (options.worker === false || options.sanitizer) {
442
+ if (
443
+ options.worker === false ||
444
+ options.sanitizer ||
445
+ hasPlaceholders(options.sanitizerOptions ?? options)
446
+ ) {
426
447
  return sanitizeFileToFile(source, target, options);
427
448
  }
428
449
  const workerHeapMb = options.workerHeapMb ?? 256,
package/src/structured.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { failure } from './detection.js';
2
+ import { hasPlaceholders } from './placeholders.js';
2
3
 
3
4
  // Session metadata is opt-in at the CLI; library callers choose their own
4
5
  // fields. These names do not grant exemptions to provider/known credentials.
@@ -164,7 +165,7 @@ function ordinaryPersonalKey(entry, finding) {
164
165
  // Propagation runs again at source offsets. Apply the same key policy after it
165
166
  // so a name confirmed in a value cannot rename an ordinary identifier key.
166
167
  export function preserveIdentifierKeys(text, findings, options) {
167
- if (!options.structured) {
168
+ if (!options.structured || hasPlaceholders(options)) {
168
169
  return findings;
169
170
  }
170
171
  const keys = structuredBatches(text, options).flatMap((batch) =>
@@ -193,6 +194,9 @@ export function preserveIdentifierKeys(text, findings, options) {
193
194
  });
194
195
  }
195
196
  function preserveStructuredEntry(entry, finding, text, options) {
197
+ if (hasPlaceholders(options)) {
198
+ return false;
199
+ }
196
200
  return (
197
201
  (entry.key &&
198
202
  ['confirmed-personal', 'document-propagation'].includes(finding.rule)) ||
package/src/transforms.js CHANGED
@@ -3,6 +3,7 @@ import { createHmac } from 'node:crypto';
3
3
  import { failure, REDACTED } from './detection.js';
4
4
  import { encodedValue, encodeRun } from './encoded.js';
5
5
  import { isIdentityType } from './identity.js';
6
+ import { validatePlaceholder, placeholderReplacement } from './placeholders.js';
6
7
 
7
8
  const modes = new Set([
8
9
  'redact',
@@ -13,6 +14,7 @@ const modes = new Set([
13
14
  'format-preserving',
14
15
  'date-shift',
15
16
  'bucket',
17
+ 'placeholder',
16
18
  ]);
17
19
  export function validateTransforms(options) {
18
20
  if (
@@ -43,6 +45,9 @@ function validateTransform(transform) {
43
45
  if (!transform || !modes.has(transform.mode)) {
44
46
  throw failure('ERR_CONFIG');
45
47
  }
48
+ if (transform.mode === 'placeholder') {
49
+ validatePlaceholder(transform);
50
+ }
46
51
  const checks = {
47
52
  fake: () =>
48
53
  typeof transform.key === 'string' &&
@@ -177,6 +182,9 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
177
182
  throw failure('ERR_ENCODING');
178
183
  }
179
184
  }
185
+ return renderValue(text, finding, transform, options);
186
+ }
187
+ function renderValue(text, finding, transform, options) {
180
188
  let raw = text.slice(finding.start, finding.end);
181
189
  const jsonQuoted =
182
190
  text[finding.start - 1] === '"' && text[finding.end] === '"';
@@ -187,6 +195,9 @@ export function renderReplacement(text, finding, options, encodedSanitize) {
187
195
  return REDACTED;
188
196
  }
189
197
  }
190
- const result = transformed(raw, finding, transform);
198
+ const result =
199
+ transform?.mode === 'placeholder'
200
+ ? placeholderReplacement(raw, finding, transform, options)
201
+ : transformed(raw, finding, transform);
191
202
  return jsonQuoted ? JSON.stringify(result).slice(1, -1) : result;
192
203
  }