@link-foundation/sensitive-data-sanitizer 0.21.2 → 0.21.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.3
4
+
5
+ ### Patch Changes
6
+
7
+ - ee1ec36: Preserve harmless wrapped base64 binary within a bounded complete inspection, while retaining embedded credential detection and fail-closed handling of uninspected oversized payloads. Add regression coverage for complete JSON re-encoding after surrounding prose through sync, async and streaming sanitization.
8
+
3
9
  ## 0.21.2
4
10
 
5
11
  ### Patch Changes
package/docs/API.md CHANGED
@@ -365,7 +365,12 @@ status identifiers. Explicit `knownPersonal` values override these heuristics.
365
365
 
366
366
  Oversized encoded runs receive a conservative complete-run `ENCODED_LIMIT`
367
367
  redaction rather than aborting the record. Decoding probes are capped at 4,096
368
- characters; full decoding stays capped at 8,192 characters. A multi-line run is
368
+ characters; ordinary text decoding stays capped at 8,192 characters. Uniform
369
+ wrapped binary base64 up to 256 KiB of encoded input is decoded completely
370
+ and its embedded text is inspected. Harmless binary bytes are preserved; a
371
+ sensitive binary payload is masked as a whole because a text re-encoding
372
+ cannot preserve its bytes. Larger uninspected wrapped binary runs still
373
+ receive `ENCODED_LIMIT`. A multi-line run is
369
374
  treated as one wrapped payload only when its probe decodes as text or its lines
370
375
  share one base64 width (a multiple of four, standard alphabet, not plain hex);
371
376
  lists of identifiers or commit hashes are scanned line by line. Up to 4,096
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-foundation/sensitive-data-sanitizer",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/encoded.js CHANGED
@@ -7,6 +7,9 @@ import { jsonStringRuns } from './json-string-runs.js';
7
7
  // remaining candidates as `encoded-limit` runs, without blocking the text.
8
8
  const WINDOW = 65536;
9
9
  const WINDOW_CANDIDATES = 4096;
10
+ // Inspect a complete bounded binary payload, including any embedded text.
11
+ // Ordinary text detection retains its 8 KiB cap.
12
+ const WRAPPED_BINARY_LIMIT = 256 * 1024;
10
13
 
11
14
  const WRAPPED =
12
15
  /(?<![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;
@@ -56,7 +59,8 @@ function* matchRuns(match, wrapped, scan) {
56
59
  const start = match.index;
57
60
  const end = start + raw.length;
58
61
  const limited = { start, end, limited: true, depth: scan.depth };
59
- if (raw.length > 8192) {
62
+ const candidates = runCandidates(raw, wrapped);
63
+ if (!candidates) {
60
64
  if (isLimitedTextRun(raw, wrapped)) {
61
65
  scan.covered.push(limited);
62
66
  yield limited;
@@ -64,7 +68,7 @@ function* matchRuns(match, wrapped, scan) {
64
68
  return;
65
69
  }
66
70
  const window = Math.floor(start / WINDOW);
67
- for (const candidate of decodeCandidates(raw)) {
71
+ for (const candidate of candidates) {
68
72
  const { text: decoded, encoding } = candidate;
69
73
  const key = `${start}:${raw.length}:${decoded}`;
70
74
  if (scan.seen.has(key) || !decodedText(raw, decoded)) {
@@ -91,11 +95,48 @@ function* matchRuns(match, wrapped, scan) {
91
95
  };
92
96
  yield run;
93
97
  for (const inner of decodedRuns(decoded, scan.depth + 1)) {
94
- yield { ...run, text: inner.text, depth: inner.depth };
98
+ yield inner.limited
99
+ ? { ...limited, depth: inner.depth }
100
+ : { ...run, text: inner.text, depth: inner.depth };
95
101
  }
96
102
  }
97
103
  }
98
104
 
105
+ function runCandidates(raw, wrapped) {
106
+ if (raw.length <= 8192) {
107
+ return decodeCandidates(raw);
108
+ }
109
+ const binary = wrapped && boundedWrappedBinary(raw);
110
+ return binary ? [binary] : undefined;
111
+ }
112
+
113
+ function boundedWrappedBinary(raw) {
114
+ if (raw.length > WRAPPED_BINARY_LIMIT || !isWrappedBinary(raw)) {
115
+ return false;
116
+ }
117
+ const bytes = base64Bytes(raw.replace(/\s/g, ''));
118
+ if (!bytes) {
119
+ return false;
120
+ }
121
+ try {
122
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
123
+ if (decodedText(raw, decoded)) {
124
+ return undefined;
125
+ }
126
+ } catch {
127
+ /* Binary content still needs an embedded-text detection view. */
128
+ }
129
+ return {
130
+ encoding: 'binary',
131
+ // Preserve text offsets within the view while separating binary controls
132
+ // and invalid UTF-8. This view is never used to reconstruct binary bytes.
133
+ text: bytes
134
+ .toString('utf8')
135
+ // eslint-disable-next-line no-control-regex -- Replace binary controls in the text detection view.
136
+ .replace(/[\x00-\x08\x0e-\x1f\x7f\ufffd]/g, ' '),
137
+ };
138
+ }
139
+
99
140
  function candidateMatches(text, pattern) {
100
141
  return pattern ? text.matchAll(pattern) : jsonStringRuns(text);
101
142
  }
@@ -211,11 +252,8 @@ function decodeCandidates(raw, partial) {
211
252
  } else {
212
253
  const compact = raw.replace(/\s/g, '');
213
254
  // Decode canonical base64 text only.
214
- const bytes = Buffer.from(compact, 'base64url');
215
- if (
216
- bytes.toString('base64url') ===
217
- compact.replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_')
218
- ) {
255
+ const bytes = base64Bytes(compact);
256
+ if (bytes) {
219
257
  try {
220
258
  candidates.push({
221
259
  encoding:
@@ -248,7 +286,18 @@ function decodeCandidates(raw, partial) {
248
286
  return candidates;
249
287
  }
250
288
 
289
+ function base64Bytes(compact) {
290
+ const bytes = Buffer.from(compact, 'base64url');
291
+ return bytes.toString('base64url') ===
292
+ compact.replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_')
293
+ ? bytes
294
+ : undefined;
295
+ }
296
+
251
297
  export function encodeRun(run, text) {
298
+ if (run.encoding === 'binary') {
299
+ return undefined;
300
+ }
252
301
  const bytes = Buffer.from(text, 'utf8');
253
302
  let encoded;
254
303
  if (run.encoding === 'json-content') {