@link-foundation/sensitive-data-sanitizer 0.21.1 → 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,17 @@
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
+
9
+ ## 0.21.2
10
+
11
+ ### Patch Changes
12
+
13
+ - b5bc2e1: Preserve prose after an existing credential mask on repeated plaintext and encoded sanitization passes, while continuing to detect later credentials and exact known secrets.
14
+
3
15
  ## 0.21.1
4
16
 
5
17
  ### 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.1",
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",
@@ -107,13 +107,25 @@ function narrowed(text, finding) {
107
107
  return parts ? parts.map((part) => ({ ...finding, ...part })) : [finding];
108
108
  }
109
109
 
110
+ // A complete existing mask bounds context too: vendor rules no longer match
111
+ // it on the next pass. Only strip sentence punctuation, never a glued suffix.
112
+ function maskedPrefix(text, start, end) {
113
+ const token = /^[^\s]+/u.exec(text.slice(start, end))?.[0] ?? '';
114
+ let length = token.length;
115
+ while (length > 0 && /[.,;:!?]/.test(token[length - 1])) {
116
+ length--;
117
+ }
118
+ const value = token.slice(0, length);
119
+ return maskedValue(value) ? start + value.length : end;
120
+ }
121
+
110
122
  // `token: ghp_… . Next sentence` — a context value that a vendor rule ends
111
123
  // earlier stops at that end when only punctuation or a space follows.
112
124
  function bounded(text, finding, findings) {
113
125
  if (!openEnded.has(finding.rule) || finding.category !== 'credential') {
114
126
  return finding;
115
127
  }
116
- let end = finding.end;
128
+ let end = maskedPrefix(text, finding.start, finding.end);
117
129
  for (const other of findings) {
118
130
  if (
119
131
  other.category === 'credential' &&
@@ -56,7 +56,10 @@ export function templatedCredential(text, finding) {
56
56
  }
57
57
  // A template glued to literal characters (`<x>abc`, `[A]x9`) is not one.
58
58
  const after = text[finding.start + value.length] ?? '';
59
- return !/[\w$%+/=.-]/.test(after) && templatedValue(value);
59
+ const sentenceEnd = /^[.,;:!?]+(?:\s|$)/.test(
60
+ text.slice(finding.start + value.length)
61
+ );
62
+ return (!/[\w$%+/=.-]/.test(after) || sentenceEnd) && templatedValue(value);
60
63
  }
61
64
  // Only variable references and the documented gh lookup are exempt. Arbitrary
62
65
  // shell commands and Actions expressions can contain literal credentials.
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') {