@link-foundation/sensitive-data-sanitizer 0.20.0 → 0.21.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +14 -1
  3. package/bin/sensitive-data-sanitizer.js +1 -0
  4. package/docs/API.md +163 -15
  5. package/docs/COVERAGE.md +16 -6
  6. package/docs/RULES.md +1 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/context.js +176 -25
  10. package/src/credential-spans.js +180 -0
  11. package/src/credentials-entry.d.ts +23 -0
  12. package/src/credentials-entry.js +62 -0
  13. package/src/credentials.js +8 -1
  14. package/src/detection.js +68 -37
  15. package/src/encoded.js +137 -51
  16. package/src/engines.js +33 -10
  17. package/src/exempt.js +72 -0
  18. package/src/fake-memory.js +32 -0
  19. package/src/fake.js +4 -28
  20. package/src/features.js +29 -0
  21. package/src/html-tags.js +8 -0
  22. package/src/identity-types.js +63 -0
  23. package/src/identity.js +3 -61
  24. package/src/index.d.ts +41 -2
  25. package/src/index.js +6 -0
  26. package/src/known.js +33 -15
  27. package/src/personal-features.js +39 -0
  28. package/src/personal.js +29 -5
  29. package/src/placeholder-types.js +154 -4
  30. package/src/placeholders.js +127 -45
  31. package/src/projection.js +1 -8
  32. package/src/proper-names.js +48 -5
  33. package/src/prose-words.js +120 -0
  34. package/src/record-worker.js +1 -0
  35. package/src/rule-catalog.js +2 -0
  36. package/src/rules.js +8 -0
  37. package/src/sanitizer.js +85 -20
  38. package/src/stream-worker.js +1 -0
  39. package/src/strict-attributes.js +48 -4
  40. package/src/strict.js +1 -1
  41. package/src/structured.js +2 -0
  42. package/src/transforms.js +38 -8
  43. package/src/vocabulary.js +27 -1
  44. package/src/worker-options.js +1 -0
package/src/detection.js CHANGED
@@ -5,26 +5,21 @@ import {
5
5
  uatpCard,
6
6
  } from './confidence.js';
7
7
  import { isIP } from 'node:net';
8
+ import { Buffer } from 'node:buffer';
8
9
  import { serviceRules } from './rules.js';
9
- import { detectContext, valueRange, authorizationRange } from './context.js';
10
- import { detectPersonal } from './personal.js';
10
+ import { detectContext, valueRange, authorizationRanges } from './context.js';
11
11
  import { detectGitleaks } from './gitleaks.js';
12
- import { detectCatalog } from './entities.js';
13
- import { detectIdentity } from './identity.js';
14
- import { detectNational } from './national.js';
15
- import { detectPersonalContext } from './personal-context.js';
16
- import { detectPersonalEvidence } from './personal-evidence.js';
17
12
  import {
18
13
  detectCredentialFormats,
19
14
  credentialPlaceholder,
15
+ maskedValue,
20
16
  } from './credentials.js';
21
17
  import { localAddress } from './public.js';
22
18
  import { detectKnownPersonal } from './personal-variants.js';
23
- import { detectDocumentEntities } from './document-entities.js';
24
- import { detectProseAndDocuments } from './proper-names.js';
25
19
  import { builtInEnabled } from './policy.js';
26
- import { detectStrict } from './strict.js';
20
+ import { feature } from './features.js';
27
21
  import { tokenChecksum } from './token-checksum.js';
22
+ import { refineCredentialSpans, cookieValues } from './credential-spans.js';
28
23
 
29
24
  export const REDACTED = '[REDACTED]';
30
25
  export const failure = (code = 'ERR_SANITIZATION', location = {}) =>
@@ -56,6 +51,30 @@ export function personalPattern(value) {
56
51
  .map(escapePattern)
57
52
  .join('[ \\t_-]+');
58
53
  }
54
+ // Hex and base64 spellings of a known secret, at any length. The generic
55
+ // decoders need 16 or more encoded characters, so they cannot see short ones.
56
+ const encodedForms = new Map();
57
+ function knownEncodedForms(value) {
58
+ let pattern = encodedForms.get(value);
59
+ if (!pattern) {
60
+ const bytes = Buffer.from(value, 'utf8');
61
+ const hex = bytes.toString('hex');
62
+ const base64 = bytes.toString('base64').replace(/=+$/, '');
63
+ const url = bytes.toString('base64url');
64
+ const alternatives = [...new Set([hex, hex.toUpperCase()])].join('|');
65
+ const base64Forms = [...new Set([base64, url])].map(escapePattern);
66
+ pattern = new RegExp(
67
+ `(?<![\\da-fA-F])(?:${alternatives})(?![\\da-fA-F])|(?<![\\w+/-])(?:${base64Forms.join('|')})(?:={0,2}(?![\\w+/=-]))`,
68
+ 'g'
69
+ );
70
+ if (encodedForms.size > 1024) {
71
+ encodedForms.clear();
72
+ }
73
+ encodedForms.set(value, pattern);
74
+ }
75
+ return pattern;
76
+ }
77
+
59
78
  export function escapePattern(text) {
60
79
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
80
  }
@@ -130,34 +149,42 @@ export function luhn(value) {
130
149
  function detectCookies(text, emit) {
131
150
  for (const match of text.matchAll(/(?:Set-)?Cookie[ \t]*:[ \t]*/gi)) {
132
151
  const range = valueRange(text, match.index + match[0].length, 'line');
133
- if (
134
- range &&
135
- range.end > range.start &&
136
- text.slice(range.start, range.end) !== REDACTED
137
- ) {
138
- emit({
139
- ...range,
140
- type: 'SECRET',
141
- category: 'credential',
142
- rule: 'cookie',
143
- });
152
+ for (const value of range
153
+ ? cookieValues(text, range.start, range.end)
154
+ : []) {
155
+ if (!maskedValue(text.slice(value.start, value.end))) {
156
+ emit({
157
+ ...value,
158
+ type: 'SECRET',
159
+ category: 'credential',
160
+ rule: 'cookie',
161
+ });
162
+ }
144
163
  }
145
164
  }
146
165
  }
147
166
 
148
167
  export function nativeDetect(text, options, emit) {
149
- const output = emit;
150
- emit = (finding) => {
168
+ const found = [];
169
+ detectNative(text, options, (finding) => found.push(finding));
170
+ for (const finding of refineCredentialSpans(text, found)) {
151
171
  if (builtInEnabled(finding, options)) {
152
- output(finding);
172
+ emit(finding);
153
173
  }
154
- };
174
+ }
175
+ }
176
+
177
+ function detectNative(text, options, emit) {
155
178
  const credential = { type: 'SECRET', category: 'credential' };
156
179
  for (const value of options.knownSecrets ?? []) {
157
180
  collectMatches(text, new RegExp(escapePattern(value), 'g'), emit, {
158
181
  ...credential,
159
182
  rule: 'known-secret',
160
183
  });
184
+ collectMatches(text, knownEncodedForms(value), emit, {
185
+ ...credential,
186
+ rule: 'known-secret',
187
+ });
161
188
  }
162
189
  detectKnownPersonal(
163
190
  text,
@@ -194,14 +221,7 @@ export function nativeDetect(text, options, emit) {
194
221
  }
195
222
  detectContext(text, emit, options.credentialsOnly);
196
223
  if (!options.credentialsOnly) {
197
- detectPersonal(text, emit);
198
- detectProseAndDocuments(text, emit, options, detectDocumentEntities);
199
- detectCatalog(text, emit);
200
- detectIdentity(text, emit);
201
- detectNational(text, emit);
202
- detectPersonalContext(text, emit);
203
- detectPersonalEvidence(text, emit);
204
- detectStrict(text, emit, options);
224
+ feature('detectPersonalData')(text, emit, options);
205
225
  }
206
226
  detectCredentialFormats(text, emit);
207
227
  // detect-secrets-style quoted entropy detection; bare commit hashes and
@@ -262,9 +282,11 @@ function detectAuthorization(text, emit, credential) {
262
282
  for (const match of text.matchAll(
263
283
  /(?:Proxy-)?Authorization[ \t]*:[ \t]*/gi
264
284
  )) {
265
- const range = authorizationRange(text, match.index + match[0].length);
266
- if (range && !credentialPlaceholder(text.slice(range.start, range.end))) {
267
- emit({ ...range, ...credential, rule: 'authorization' });
285
+ const start = match.index + match[0].length;
286
+ for (const range of authorizationRanges(text, start)) {
287
+ if (!credentialPlaceholder(text.slice(range.start, range.end))) {
288
+ emit({ ...range, ...credential, rule: 'authorization' });
289
+ }
268
290
  }
269
291
  }
270
292
  }
@@ -291,7 +313,16 @@ function detectPersonalFormats(text, options, emit) {
291
313
  collectMatches(
292
314
  text,
293
315
  /(?<![\p{L}\p{N}_.!#$%&'*+/?^`{|}~-])[\p{L}\p{N}_.!#$%&'*+/?^`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{N}.-]{0,252})\.[\p{L}]{2,63}(?![\p{L}\p{N}_-])/gu,
294
- (finding) => emit(emailAfterMention(text, finding)),
316
+ (finding) => {
317
+ // `https://user:password@host` is URL user info, not an address.
318
+ if (
319
+ !/[a-z][a-z0-9+.-]*:\/\/[^\s/@]*:$/i.test(
320
+ text.slice(Math.max(0, finding.start - 256), finding.start)
321
+ )
322
+ ) {
323
+ emit(emailAfterMention(text, finding));
324
+ }
325
+ },
295
326
  { type: 'EMAIL', category: 'personal', rule: 'email' }
296
327
  );
297
328
  collectMatches(
package/src/encoded.js CHANGED
@@ -1,68 +1,101 @@
1
1
  import { TextDecoder } from 'node:util';
2
2
  import { Buffer } from 'node:buffer';
3
- import { failure } from './detection.js';
4
3
  import { jsonStringRuns } from './json-string-runs.js';
5
4
 
5
+ // Decoded candidates are re-scanned by every detector, so their number is
6
+ // bounded per window of the input; a denser window falls back to masking the
7
+ // remaining candidates as `encoded-limit` runs, without blocking the text.
8
+ const WINDOW = 65536;
9
+ const WINDOW_CANDIDATES = 4096;
10
+
11
+ const WRAPPED =
12
+ /(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g;
13
+
6
14
  export function* decodedRuns(text, depth = 0) {
7
15
  if (depth >= 2) {
8
16
  return;
9
17
  }
18
+ // Wrapped runs come first: a line inside a decoded wrapped run is covered
19
+ // by the run's own finding and is not decoded again.
10
20
  const patterns = [
21
+ WRAPPED,
11
22
  /(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16}[A-Za-z0-9_+/-]*={0,2}(?![A-Za-z0-9_+/-])/g,
12
23
  /(?:[^\s"'<>?=&:/]{0,128}%[\da-fA-F]{2})+(?:[^\s"'<>?=&:/]{0,128})/g,
13
- /(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g,
14
24
  /(?:&#(?:x[\da-fA-F]{1,6}|\d{1,7});|&(?:amp|lt|gt|quot|apos|nbsp);){2,2048}/g,
15
25
  /(?:\\x[\da-fA-F]{2}){2,2048}/g,
16
26
  undefined, // JSON candidates use a linear scanner below.
17
27
  ];
18
- const seen = new Set();
19
- let count = 0;
28
+ const scan = { depth, seen: new Set(), counts: new Map(), covered: [] };
20
29
  for (const [index, pattern] of patterns.entries()) {
21
- const marker = ['', '%', '\n', '&', '\\x', '"'][index];
30
+ const marker = ['\n', '', '%', '&', '\\x', '"'][index];
22
31
  if (marker && !text.includes(marker)) {
23
32
  continue;
24
33
  }
34
+ const inWrappedRun = coverage(scan.covered);
25
35
  for (const match of candidateMatches(text, pattern)) {
26
- const raw = match[0];
27
- if (raw.length > 8192) {
28
- if (isLimitedTextRun(raw)) {
29
- yield {
30
- start: match.index,
31
- end: match.index + raw.length,
32
- limited: true,
33
- depth,
34
- };
35
- }
36
- continue;
37
- }
38
- const candidates = decodeCandidates(raw);
39
- for (const candidate of candidates) {
40
- const { text: decoded, encoding } = candidate;
41
- const key = `${match.index}:${raw.length}:${decoded}`;
42
- if (seen.has(key) || !decodedText(raw, decoded)) {
43
- continue;
44
- }
45
- seen.add(key);
46
- if (++count > 4096) {
47
- throw failure('ERR_LIMIT');
48
- }
49
- const inset = encoding === 'json-content' ? 1 : 0;
50
- const run = {
51
- start: match.index + inset,
52
- end: match.index + raw.length - inset,
53
- text: decoded,
54
- encoding,
55
- depth,
56
- };
57
- yield run;
58
- for (const inner of decodedRuns(decoded, depth + 1)) {
59
- yield { ...run, text: inner.text, depth: inner.depth };
60
- }
36
+ if (index !== 1 || !inWrappedRun(match.index)) {
37
+ yield* matchRuns(match, index === 0, scan);
61
38
  }
62
39
  }
63
40
  }
64
41
  }
65
42
 
43
+ // Tests ascending offsets against the sorted, disjoint wrapped runs.
44
+ function coverage(covered) {
45
+ let cursor = 0;
46
+ return (start) => {
47
+ while (cursor < covered.length && covered[cursor].end <= start) {
48
+ cursor++;
49
+ }
50
+ return cursor < covered.length && covered[cursor].start <= start;
51
+ };
52
+ }
53
+
54
+ function* matchRuns(match, wrapped, scan) {
55
+ const raw = match[0];
56
+ const start = match.index;
57
+ const end = start + raw.length;
58
+ const limited = { start, end, limited: true, depth: scan.depth };
59
+ if (raw.length > 8192) {
60
+ if (isLimitedTextRun(raw, wrapped)) {
61
+ scan.covered.push(limited);
62
+ yield limited;
63
+ }
64
+ return;
65
+ }
66
+ const window = Math.floor(start / WINDOW);
67
+ for (const candidate of decodeCandidates(raw)) {
68
+ const { text: decoded, encoding } = candidate;
69
+ const key = `${start}:${raw.length}:${decoded}`;
70
+ if (scan.seen.has(key) || !decodedText(raw, decoded)) {
71
+ continue;
72
+ }
73
+ scan.seen.add(key);
74
+ const count = (scan.counts.get(window) ?? 0) + 1;
75
+ scan.counts.set(window, count);
76
+ if (count > WINDOW_CANDIDATES) {
77
+ yield limited;
78
+ return;
79
+ }
80
+ if (wrapped && scan.covered.at(-1)?.start !== start) {
81
+ scan.covered.push({ start, end });
82
+ }
83
+ const inset = encoding === 'json-content' ? 1 : 0;
84
+ const run = {
85
+ start: start + inset,
86
+ end: end - inset,
87
+ text: decoded,
88
+ encoding,
89
+ depth: scan.depth,
90
+ ...(candidate.layout && { layout: candidate.layout }),
91
+ };
92
+ yield run;
93
+ for (const inner of decodedRuns(decoded, scan.depth + 1)) {
94
+ yield { ...run, text: inner.text, depth: inner.depth };
95
+ }
96
+ }
97
+ }
98
+
66
99
  function candidateMatches(text, pattern) {
67
100
  return pattern ? text.matchAll(pattern) : jsonStringRuns(text);
68
101
  }
@@ -76,20 +109,59 @@ function decodedText(raw, decoded) {
76
109
  );
77
110
  }
78
111
 
79
- function isLimitedTextRun(raw) {
112
+ function isLimitedTextRun(raw, wrapped) {
80
113
  // Bare opaque identifiers may share the base64 alphabet. Require a
81
- // textual decode, or an explicit percent/wrapped encoding boundary.
82
- const probe = decodeCandidates(raw.slice(0, 4096), true);
114
+ // textual decode, an explicit percent encoding, or the uniform layout of
115
+ // wrapped base64 (`base64 -w`, PEM bodies). Lists of identifiers or commit
116
+ // hashes are ordinary lines and are scanned one by one.
117
+ if (raw.includes('%')) {
118
+ return true;
119
+ }
120
+ const head = wrapped
121
+ ? raw.slice(0, 8192).replace(/\s/g, '').slice(0, 4096)
122
+ : raw.slice(0, 4096);
123
+ const probe = decodeCandidates(head, true);
83
124
  return (
84
- raw.includes('%') ||
85
- raw.includes('\n') ||
86
125
  probe.some(
87
126
  // eslint-disable-next-line no-control-regex -- Exclude binary controls from the bounded UTF-8 text probe.
88
127
  (c) => c.text && !/[\x00-\x08\x0e-\x1f\x7f]/.test(c.text)
128
+ ) ||
129
+ (wrapped && isWrappedBinary(raw))
130
+ );
131
+ }
132
+
133
+ // Wrapped binary base64 uses the standard alphabet, lines of one width that
134
+ // is a multiple of four and is not plain hexadecimal.
135
+ function isWrappedBinary(raw) {
136
+ // Trim the padding without a regular expression: `/=+$/` backtracks
137
+ // quadratically over long `=` runs that are not at the end.
138
+ let end = raw.length;
139
+ while (end > 0 && raw.charCodeAt(end - 1) === 61) {
140
+ end -= 1;
141
+ }
142
+ const lines = raw.slice(0, end).split(/\r?\n/);
143
+ const width = lines[0].length;
144
+ return (
145
+ width % 4 === 0 &&
146
+ !/[-_]/.test(raw) &&
147
+ !/^[\da-fA-F\s]+$/.test(raw) &&
148
+ lines.every((line, i) =>
149
+ i === lines.length - 1 ? line.length <= width : line.length === width
89
150
  )
90
151
  );
91
152
  }
92
153
 
154
+ // Line width and separator of a wrapped run, re-applied when re-encoding.
155
+ function wrapLayout(raw) {
156
+ if (!raw.includes('\n')) {
157
+ return undefined;
158
+ }
159
+ return {
160
+ width: raw.split(/\r?\n/, 1)[0].length,
161
+ newline: raw.includes('\r\n') ? '\r\n' : '\n',
162
+ };
163
+ }
164
+
93
165
  function decodeCandidates(raw, partial) {
94
166
  const candidates = [];
95
167
  if (raw.startsWith('"')) {
@@ -153,8 +225,7 @@ function decodeCandidates(raw, partial) {
153
225
  text: new TextDecoder('utf-8', { fatal: true }).decode(bytes, {
154
226
  stream: partial,
155
227
  }),
156
- wrapped: raw.includes('\n'),
157
- padded: raw.endsWith('='),
228
+ layout: wrapLayout(raw),
158
229
  });
159
230
  } catch {
160
231
  /* Binary content. */
@@ -167,6 +238,7 @@ function decodeCandidates(raw, partial) {
167
238
  text: new TextDecoder('utf-8', { fatal: true }).decode(
168
239
  Buffer.from(compact, 'hex')
169
240
  ),
241
+ layout: wrapLayout(raw),
170
242
  });
171
243
  } catch {
172
244
  /* Binary content. */
@@ -198,16 +270,30 @@ export function encodeRun(run, text) {
198
270
  : `&#${c.codePointAt(0)};`
199
271
  ).join('');
200
272
  } else {
201
- encoded = bytes.toString(run.encoding);
273
+ encoded = wrap(bytes.toString(run.encoding), run.layout);
202
274
  }
203
275
  const decoded =
204
276
  run.encoding === 'json-content'
205
277
  ? { text: JSON.parse(`"${encoded}"`) }
206
278
  : decodeCandidates(encoded).find((c) => c.encoding === run.encoding);
207
- if (!decoded || decoded.text !== text) {
208
- throw failure('ERR_ENCODING');
279
+ // A short payload may re-encode below the length a decoder recognizes
280
+ // (hex needs 32 digits); callers then mask the encoded text as it stands.
281
+ return decoded?.text === text ? encoded : undefined;
282
+ }
283
+
284
+ function wrap(encoded, layout) {
285
+ if (!layout?.width || encoded.length <= layout.width) {
286
+ return encoded;
287
+ }
288
+ const lines = [];
289
+ for (let i = 0; i < encoded.length; i += layout.width) {
290
+ lines.push(encoded.slice(i, i + layout.width));
291
+ }
292
+ // Padding never starts a line of its own.
293
+ if (/^=+$/.test(lines.at(-1)) && lines.length > 1) {
294
+ lines[lines.length - 2] += lines.pop();
209
295
  }
210
- return encoded;
296
+ return lines.join(layout.newline);
211
297
  }
212
298
 
213
299
  export function encodedValue(raw) {
package/src/engines.js CHANGED
@@ -18,6 +18,7 @@ import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
19
  import { hasCustomTransforms } from './transforms.js';
20
20
  import { builtInEnabled } from './policy.js';
21
+ import { exemptFindingsAsync } from './exempt.js';
21
22
  import {
22
23
  initializePlaceholders,
23
24
  placeholderKnown,
@@ -220,10 +221,7 @@ async function analyzeStructured(
220
221
  );
221
222
  }
222
223
 
223
- export function createSanitizer(options = {}) {
224
- options = applyProfile(options);
225
- validateOptions(options);
226
- options = initializePlaceholders(options);
224
+ function configuredEngines(options) {
227
225
  for (const detector of options.detectors ?? []) {
228
226
  if (!detector || typeof detector.detect !== 'function') {
229
227
  throw failure('ERR_CONFIG');
@@ -234,6 +232,14 @@ export function createSanitizer(options = {}) {
234
232
  if (!options.knownOnly) {
235
233
  engines.push(...(options.detectors ?? []));
236
234
  }
235
+ return engines;
236
+ }
237
+
238
+ export function createSanitizer(options = {}) {
239
+ options = applyProfile(options);
240
+ validateOptions(options);
241
+ options = initializePlaceholders(options);
242
+ const engines = configuredEngines(options);
237
243
  let prepared;
238
244
  async function analyze(
239
245
  text,
@@ -266,6 +272,7 @@ export function createSanitizer(options = {}) {
266
272
  ...options,
267
273
  structured: undefined,
268
274
  detectors: [],
275
+ exempt: undefined,
269
276
  findings,
270
277
  })
271
278
  : reviewFindings(text, [...nativeFindings, ...findings], options);
@@ -274,15 +281,31 @@ export function createSanitizer(options = {}) {
274
281
  : detected;
275
282
  }
276
283
 
284
+ // The caller's `exempt` hook screens the findings of the text it sees,
285
+ // including the verification passes over the published output.
286
+ async function screened(
287
+ text,
288
+ initial,
289
+ plain,
290
+ scopedOptions = options,
291
+ nativeFindings
292
+ ) {
293
+ return exemptFindingsAsync(
294
+ text,
295
+ await analyze(text, initial, plain, scopedOptions, nativeFindings),
296
+ scopedOptions.exempt
297
+ );
298
+ }
299
+
277
300
  const sanitizer = {
278
- inspect: guarded(analyze),
301
+ inspect: guarded(screened),
279
302
  sanitize: guarded(sanitizeText, () => discardPlaceholderState(options)),
280
303
  sanitizeJsonl: (text) =>
281
304
  createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
282
305
  };
283
306
  configurations.set(sanitizer, options);
284
307
  streamPreparers.set(sanitizer, async (text, nativeFindings) => {
285
- const findings = await analyze(
308
+ const findings = await screened(
286
309
  text,
287
310
  nativeFindings === undefined ? (options.findings ?? []) : [],
288
311
  false,
@@ -305,7 +328,7 @@ export function createSanitizer(options = {}) {
305
328
  const findings =
306
329
  depth === 0 && prepared?.text === text
307
330
  ? prepared.findings
308
- : await analyze(text, options.findings ?? [], plain, options);
331
+ : await screened(text, options.findings ?? [], plain, options);
309
332
  if (depth === 0) {
310
333
  prepared = undefined;
311
334
  }
@@ -329,7 +352,7 @@ export function createSanitizer(options = {}) {
329
352
  recursiveOptions
330
353
  )
331
354
  );
332
- if (await customResidual(result.text, options, plain, analyze)) {
355
+ if (await customResidual(result.text, options, plain, screened)) {
333
356
  throw failure('ERR_RESIDUAL');
334
357
  }
335
358
  const sanitized = fullRedaction
@@ -351,7 +374,7 @@ export function createSanitizer(options = {}) {
351
374
  ).text;
352
375
  // Verification at the publication boundary runs every enabled engine.
353
376
  if (
354
- (await analyze(sanitized, [], plain, options)).some((f) =>
377
+ (await screened(sanitized, [], plain, options)).some((f) =>
355
378
  actionable(f, options)
356
379
  )
357
380
  ) {
@@ -360,7 +383,7 @@ export function createSanitizer(options = {}) {
360
383
  );
361
384
  }
362
385
  verifyStructuredResult(result.text, options, plain);
363
- if (await placeholderResidual(result.text, options, plain, analyze)) {
386
+ if (await placeholderResidual(result.text, options, plain, screened)) {
364
387
  throw failure('ERR_RESIDUAL');
365
388
  }
366
389
  return { ...result, findings: auditFaked(findings, options, result) };
package/src/exempt.js ADDED
@@ -0,0 +1,72 @@
1
+ import { failure } from './detection.js';
2
+
3
+ // `exempt(finding, text) => boolean` keeps findings the caller's policy
4
+ // knows are not sensitive (a configuration flag, a counter, a value another
5
+ // tool already masked). It runs after detection and before transformation.
6
+ // The hook decides per span: verification of the published text consults it
7
+ // again for each finding in the output, so the same value in another place
8
+ // is still caught unless the hook keeps that span too.
9
+ // Values the caller declared (`knownSecrets`, `knownPersonal`,
10
+ // `confirmedPersonal`) and structured `JSON_VALUE` denials are never offered.
11
+ const declared = new Set([
12
+ 'known-secret',
13
+ 'known-personal',
14
+ 'confirmed-personal',
15
+ ]);
16
+ const offered = (finding) =>
17
+ !declared.has(finding.rule) && finding.type !== 'JSON_VALUE';
18
+
19
+ function view(text, finding) {
20
+ return Object.freeze({
21
+ ...finding,
22
+ value: text.slice(finding.start, finding.end),
23
+ });
24
+ }
25
+
26
+ export function validateExempt(exempt) {
27
+ if (exempt !== undefined && typeof exempt !== 'function') {
28
+ throw failure('ERR_CONFIG');
29
+ }
30
+ }
31
+
32
+ function decision(kept) {
33
+ if (typeof kept !== 'boolean') {
34
+ // `undefined` from a forgotten return must not silently keep a secret.
35
+ throw failure('ERR_CONFIG');
36
+ }
37
+ return kept;
38
+ }
39
+
40
+ export function exemptFindings(text, findings, exempt) {
41
+ if (!exempt) {
42
+ return findings;
43
+ }
44
+ return findings.filter((finding) => {
45
+ if (!offered(finding)) {
46
+ return true;
47
+ }
48
+ const kept = exempt(view(text, finding), text);
49
+ if (typeof kept?.then === 'function') {
50
+ // An async hook needs the async engine (`createSanitizer`).
51
+ kept.then(undefined, () => {});
52
+ throw failure('ERR_CONFIG');
53
+ }
54
+ return !decision(kept);
55
+ });
56
+ }
57
+
58
+ export async function exemptFindingsAsync(text, findings, exempt) {
59
+ if (!exempt) {
60
+ return findings;
61
+ }
62
+ const retained = [];
63
+ for (const finding of findings) {
64
+ if (
65
+ !offered(finding) ||
66
+ !decision(await exempt(view(text, finding), text))
67
+ ) {
68
+ retained.push(finding);
69
+ }
70
+ }
71
+ return retained;
72
+ }
@@ -0,0 +1,32 @@
1
+ import { failure } from './detection.js';
2
+
3
+ // Values that `mode: 'fake'` generated, per transformation object, so that a
4
+ // later pass recognizes them as fictional. Kept apart from fake.js so that the
5
+ // credentials entry can read the (empty) memory without loading faker.
6
+ const generated = new WeakMap();
7
+ export function generatedValues(options) {
8
+ const transforms = [
9
+ options.transformation,
10
+ ...Object.values(options.transformations ?? {}),
11
+ ];
12
+ return transforms.flatMap((t) =>
13
+ t?.mode === 'fake'
14
+ ? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
15
+ value,
16
+ credential,
17
+ }))
18
+ : []
19
+ );
20
+ }
21
+ export function remember(value, transform, credential) {
22
+ let values = generated.get(transform);
23
+ if (!values) {
24
+ values = new Map();
25
+ generated.set(transform, values);
26
+ }
27
+ if (values.size >= 100000 && !values.has(value)) {
28
+ throw failure('ERR_LIMIT');
29
+ }
30
+ values.set(value, credential);
31
+ return value;
32
+ }
package/src/fake.js CHANGED
@@ -4,35 +4,11 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js/max';
4
4
  import { mrzCheck } from './identity.js';
5
5
  import { fakeName } from './fake-names.js';
6
6
  import * as checks from './national-checks.js';
7
- import { REDACTED, failure } from './detection.js';
7
+ import { REDACTED } from './detection.js';
8
+ import { remember } from './fake-memory.js';
9
+
10
+ export { generatedValues } from './fake-memory.js';
8
11
 
9
- const generated = new WeakMap();
10
- export function generatedValues(options) {
11
- const transforms = [
12
- options.transformation,
13
- ...Object.values(options.transformations ?? {}),
14
- ];
15
- return transforms.flatMap((t) =>
16
- t?.mode === 'fake'
17
- ? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
18
- value,
19
- credential,
20
- }))
21
- : []
22
- );
23
- }
24
- function remember(value, transform, credential) {
25
- let values = generated.get(transform);
26
- if (!values) {
27
- values = new Map();
28
- generated.set(transform, values);
29
- }
30
- if (values.size >= 100000 && !values.has(value)) {
31
- throw failure('ERR_LIMIT');
32
- }
33
- values.set(value, credential);
34
- return value;
35
- }
36
12
  function digest(value, key, purpose = 'value') {
37
13
  return createHmac('sha256', key).update(`${purpose}\0${value}`).digest();
38
14
  }