@link-assistant/hive-mind 2.12.2 → 2.12.4

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,1009 @@
1
+ /**
2
+ * Encoding-aware credential detection (issue #2156).
3
+ *
4
+ * Every sanitizer layer that existed before this module — the maintained regex
5
+ * core, the custom named patterns, Secretlint, and the known-local-token
6
+ * registry — matches credentials in their *plaintext* representation only.
7
+ *
8
+ * That is not sufficient. The leak documented in
9
+ * `docs/case-studies/issue-2156/analysis.md` happened because a GitHub CLI
10
+ * OAuth token was exchanged at `https://ghcr.io/token`, and GHCR echoes the
11
+ * supplied credential back inside a JSON body **base64-encoded**:
12
+ *
13
+ * {"token":"Z2h...<base64 of the caller's gho_ token>...=="}
14
+ *
15
+ * No plaintext `gho_` substring exists anywhere in that payload, so every
16
+ * detector passed it through. GitHub's own secret scanning *does* decode
17
+ * base64, detected the credential in the published gist, and revoked it.
18
+ *
19
+ * This module closes that class of gap with two independent strategies:
20
+ *
21
+ * A. `findEncodedKnownTokenRuns` — exact matching. For a credential whose
22
+ * value we already hold locally, derive every encoded representation of
23
+ * it (base64/base64url at all three byte alignments, hex, percent, JS/
24
+ * JSON unicode escapes, HTML entities) and locate those. Zero false
25
+ * positives by construction: we are searching for a transform of a
26
+ * string we know verbatim.
27
+ *
28
+ * B. `findEncodedSecretRuns` — generic decode-and-rescan. Locate encoded
29
+ * runs of any kind, decode them (recursively, to a bounded depth), and
30
+ * hand the decoded bytes back to the caller's plaintext detector. This
31
+ * catches encoded credentials we do *not* hold locally, which is the
32
+ * case for any third-party service response.
33
+ *
34
+ * The module is deliberately dependency-free and synchronous so that
35
+ * `credential-sanitization-core.lib.mjs` — which is itself used by the
36
+ * lowest-level stream sanitizer — can call it without an import cycle or an
37
+ * async hop.
38
+ *
39
+ * @module encoded-credential-detection
40
+ */
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Tunables
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Shortest encoded run worth decoding. A 16-character credential is 24
48
+ * base64 characters, so anything below this cannot carry a credential of a
49
+ * length we would mask in the first place (`maskToken` emits `[REDACTED]` at
50
+ * or below 12 characters).
51
+ */
52
+ const MIN_ENCODED_RUN_LENGTH = 24;
53
+
54
+ /**
55
+ * Upper bound on a single decoded payload. Published logs embed multi-megabyte
56
+ * base64 blobs (screenshots, session snapshots); decoding those in full costs
57
+ * far more than the detection is worth, and a credential is never megabytes
58
+ * long. Runs above this are still *scanned* — we decode a bounded prefix and
59
+ * suffix rather than skipping the run entirely (see `decodeBase64Bounded`).
60
+ */
61
+ const MAX_DECODE_BYTES = 256 * 1024;
62
+
63
+ /** How many times to peel nested encodings (base64 of base64 of ...). */
64
+ const MAX_DECODE_DEPTH = 3;
65
+
66
+ /**
67
+ * Minimum share of decoded bytes that must be printable ASCII before we run
68
+ * plaintext credential rules over them. Binary payloads (PNG, gzip, protobuf)
69
+ * decode to noise; running context-sensitive rules such as `password=` over
70
+ * that noise produces false positives without protecting anything. Structured
71
+ * credentials are always printable text.
72
+ */
73
+ const MIN_PRINTABLE_RATIO = 0.9;
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Run patterns
77
+ // ---------------------------------------------------------------------------
78
+
79
+ // Standard base64 and URL-safe base64 share one scan; `decodeBase64Bounded`
80
+ // normalizes the alphabet before decoding.
81
+ //
82
+ // These are stored as source strings rather than RegExp instances on purpose.
83
+ // Scanning recurses — sanitizing a decoded payload rescans it for further
84
+ // encoded runs — and a shared `/g` RegExp carries mutable `lastIndex` state,
85
+ // so a nested scan would rewind the enclosing one and loop forever. Every scan
86
+ // compiles its own instance via `matchRuns`.
87
+ const BASE64_RUN = `[A-Za-z0-9+/_-]{${MIN_ENCODED_RUN_LENGTH},}={0,2}`;
88
+ const HEX_RUN = String.raw`\b[0-9a-fA-F]{32,}\b`;
89
+
90
+ // Base64 is very often *wrapped*: the `base64` CLI breaks its output at 76
91
+ // characters by default, MIME bodies wrap at 76, PEM at 64, and pretty-printed
92
+ // JSON viewers wrap at whatever the terminal is. A wrapped blob defeats
93
+ // {@link BASE64_RUN} completely — each line is matched as a separate run, and
94
+ // an individual line is a byte-misaligned slice that decodes to noise, so the
95
+ // printable-ratio gate discards it and the credential inside is never seen.
96
+ //
97
+ // Matching the wrapped form as one run and folding the whitespace out before
98
+ // decoding restores the blob. Lines are required to be substantial so that two
99
+ // consecutive short words of prose cannot be mistaken for a wrapped blob.
100
+ // A wrapped run is found by locating its *interior* — the lines that consist of
101
+ // nothing but base64 — and then expanding outwards over the partial first line
102
+ // (`body=AAAA…`) and the short remainder line that ends the blob.
103
+ //
104
+ // The alternative, one regular expression describing the whole shape, is
105
+ // ruinously slow. It can begin matching at any base64 character, which in a
106
+ // 17 MB log is most of the file: 2235 ms per scan, against 23 ms for the
107
+ // line-anchored form below, for identical results.
108
+ const MIN_WRAPPED_LINE_LENGTH = 16;
109
+ const WRAPPED_FULL_LINE = String.raw`(\r?\n)([ \t]*)([A-Za-z0-9+/_-]{${MIN_WRAPPED_LINE_LENGTH},}={0,2})[ \t]*(?=\r?\n|$)`;
110
+ const WRAPPED_TAIL_LINE = new RegExp(String.raw`^[ \t]*\r?\n[ \t]*[A-Za-z0-9+/_-]{1,${MIN_WRAPPED_LINE_LENGTH - 1}}={0,2}(?=[ \t]*(?:\r?\n|$))`);
111
+ const BASE64_LINE_CHARACTER = /[A-Za-z0-9+/_-]/;
112
+
113
+ // Percent-encoding leaves unreserved characters alone, and every character a
114
+ // GitHub token is made of is unreserved. `encodeURIComponent(JSON.stringify(…))`
115
+ // therefore yields `%7B%22access_token%22%3A%22gho_…%22%7D`: the punctuation is
116
+ // escaped, the credential is not, and the escapes are never consecutive. A run
117
+ // must be allowed to interleave unreserved characters or the credential is
118
+ // never reached. Anchoring the run at a `%` keeps scanning linear — the engine
119
+ // only ever starts matching at an escape — and {@link hasEnoughEscapes} then
120
+ // discards runs that are really just prose with one stray escape in them.
121
+ const PERCENT_ESCAPE = String.raw`%[0-9a-fA-F]{2}`;
122
+ const PERCENT_UNRESERVED = String.raw`[A-Za-z0-9._~!*'()-]`;
123
+ const PERCENT_RUN = `${PERCENT_ESCAPE}(?:${PERCENT_ESCAPE}|${PERCENT_UNRESERVED})*`;
124
+
125
+ const ESCAPE_RUN = String.raw`(?:\\u00[0-9a-fA-F]{2}|\\x[0-9a-fA-F]{2}){8,}`;
126
+ const ENTITY_RUN = String.raw`(?:&#x?[0-9a-fA-F]{1,5};){8,}`;
127
+
128
+ /** Escapes a percent-encoded run needs before it is worth decoding. */
129
+ const MIN_PERCENT_ESCAPES = 4;
130
+
131
+ /**
132
+ * Reject percent-runs that are ordinary text carrying an incidental escape
133
+ * (`%20` in a URL path, a `%2F` in a query string).
134
+ *
135
+ * @param {string} run
136
+ * @returns {boolean}
137
+ */
138
+ const hasEnoughEscapes = run => (run.match(/%[0-9a-fA-F]{2}/g) || []).length >= MIN_PERCENT_ESCAPES;
139
+
140
+ /**
141
+ * Collect every match of `source` in `text` up front, before any callback can
142
+ * run. Materializing the list first keeps recursive scanning safe.
143
+ *
144
+ * @param {string} text
145
+ * @param {string} source regular-expression source
146
+ * @returns {Array<{run: string, index: number}>}
147
+ */
148
+ const runsOf = (decoder, text) => (decoder.findRuns ? decoder.findRuns(text) : matchRuns(text, decoder.pattern));
149
+
150
+ const matchRuns = (text, source) => {
151
+ const pattern = new RegExp(source, 'g');
152
+ const runs = [];
153
+ let match;
154
+ while ((match = pattern.exec(text)) !== null) {
155
+ runs.push({ run: match[0], index: match.index });
156
+ if (match[0].length === 0) pattern.lastIndex++;
157
+ }
158
+ return runs;
159
+ };
160
+
161
+ /**
162
+ * Whether the short line following a group of base64 lines is the payload's
163
+ * remainder or an unrelated record.
164
+ *
165
+ * Both readings are syntactically valid — `done`, `OK` and `tail` are as much
166
+ * base64 as any remainder — so the question is settled by decoding. Appending a
167
+ * genuine remainder extends printable text with printable text; appending an
168
+ * unrelated word appends bytes that decode to noise. Absorbing the wrong line
169
+ * deletes it from the log, so anything that lowers printability is rejected.
170
+ *
171
+ * @param {string} group the base64-only lines, as found
172
+ * @param {string} tail the candidate remainder line, including its separator
173
+ * @returns {boolean}
174
+ */
175
+ const tailContinuesPayload = (group, tail) => {
176
+ const folded = foldWhitespace(group);
177
+ const extended = folded + foldWhitespace(tail);
178
+ // `=` padding terminates a payload, so nothing can follow it. This has to be
179
+ // checked explicitly: decoders ignore whatever comes after the padding, so
180
+ // the printability comparison below sees two identical decodes and absorbs a
181
+ // line that was never part of the blob.
182
+ if (folded.endsWith('=')) return false;
183
+
184
+ // Every wrapper we have seen emits canonical, padded base64, whose total
185
+ // length is a multiple of 4. A remainder that does not complete the payload
186
+ // to that boundary is a different record — `OK` decodes to a printable byte
187
+ // and would otherwise pass the check below.
188
+ if (extended.length % 4 !== 0) return false;
189
+ return printableRatio(decodeBase64Bounded(extended)) >= printableRatio(decodeBase64Bounded(folded));
190
+ };
191
+
192
+ /**
193
+ * Locate wrapped base64 blobs: two or more lines that together form one
194
+ * encoded payload.
195
+ *
196
+ * Matching starts from complete base64-only lines, of which a log has very few,
197
+ * and each contiguous group of them is then expanded to cover the partial line
198
+ * it may have started on and the short remainder line it may end on.
199
+ *
200
+ * @param {string} text
201
+ * @returns {Array<{run: string, index: number}>}
202
+ */
203
+ export const findWrappedBase64Runs = text => {
204
+ const content = String(text ?? '');
205
+ if (content.length < MIN_WRAPPED_FOLDED_LENGTH || content.indexOf('\n') === -1) return [];
206
+
207
+ const pattern = new RegExp(WRAPPED_FULL_LINE, 'g');
208
+ const groups = [];
209
+ let group = null;
210
+ let previousEnd = -1;
211
+ let match;
212
+
213
+ while ((match = pattern.exec(content)) !== null) {
214
+ const contentStart = match.index + match[1].length + match[2].length;
215
+ const contentEnd = contentStart + match[3].length;
216
+ // A following line is part of the same blob only when this match begins
217
+ // exactly where the previous one stopped, i.e. no other line came between.
218
+ if (group && match.index === previousEnd) group.end = contentEnd;
219
+ else {
220
+ if (group) groups.push(group);
221
+ group = { breakStart: match.index, start: contentStart, end: contentEnd };
222
+ }
223
+ previousEnd = pattern.lastIndex;
224
+ }
225
+ if (group) groups.push(group);
226
+
227
+ const runs = [];
228
+ for (const { breakStart, start, end } of groups) {
229
+ // Backwards over the partial first line. `=` is not in the class, so a
230
+ // `body=` prefix stops the walk exactly where the payload begins.
231
+ let from = breakStart;
232
+ while (from > 0 && (content[from - 1] === ' ' || content[from - 1] === '\t')) from--;
233
+ while (from > 0 && BASE64_LINE_CHARACTER.test(content[from - 1])) from--;
234
+ if (from === breakStart) from = start;
235
+
236
+ // Forwards over a short remainder line, which is too short to have been
237
+ // matched as a full line of its own — but only when it really belongs to
238
+ // the payload, since the next line of a log is very often a short word.
239
+ let to = end;
240
+ const tail = WRAPPED_TAIL_LINE.exec(content.slice(to));
241
+ if (tail && tailContinuesPayload(content.slice(start, end), tail[0])) to += tail[0].length;
242
+
243
+ const run = content.slice(from, to);
244
+ if (run.indexOf('\n') !== -1) runs.push({ run, index: from });
245
+
246
+ // Absorbing that first line is a guess. A preceding line made entirely of
247
+ // base64 alphabet characters — a bare word, a path, the tail of an earlier
248
+ // blob — is indistinguishable from a real `body=<first segment>` prefix,
249
+ // and absorbing one that does not belong shifts the fold out of alignment
250
+ // so the payload decodes to noise and nothing is detected at all. Offer the
251
+ // line-aligned group as a second candidate: overlapping candidates collapse
252
+ // to whichever one actually decodes, so the correct reading wins either way.
253
+ if (from < start) {
254
+ const aligned = content.slice(start, to);
255
+ if (aligned.indexOf('\n') !== -1) runs.push({ run: aligned, index: start });
256
+ }
257
+ }
258
+ return runs;
259
+ };
260
+
261
+ /**
262
+ * Upper bound on how much of a stream may be retained while waiting to see
263
+ * whether a run of base64-only lines ends. Output that is nothing but base64
264
+ * (a redirected image, a `cat` of an encoded artefact) would otherwise be
265
+ * buffered without limit and never reach the terminal.
266
+ */
267
+ const MAX_WRAPPED_HOLD_CHARS = 256 * 1024;
268
+
269
+ /**
270
+ * Offset from which a record-oriented sanitizer must retain `text[0, end)`
271
+ * because its final lines may be the beginning of a wrapped base64 blob whose
272
+ * remaining lines have not arrived yet.
273
+ *
274
+ * @param {string} text
275
+ * @param {number} end offset just past the last complete record
276
+ * @returns {number|null} offset to release up to, or null to release everything
277
+ */
278
+ export const wrappedBase64HoldStart = (text, end) => {
279
+ const content = String(text ?? '');
280
+ if (end <= 0 || end > content.length) return null;
281
+
282
+ // Step back over exactly one record separator. Doing this before each
283
+ // backwards search is what lets the walk move from line to line: without it
284
+ // the search starts *on* the separator it just consumed, finds itself, and
285
+ // reports an empty line. Only one separator is skipped, so a blank line still
286
+ // terminates the walk rather than silently joining two blobs.
287
+ const beforeSeparator = index => {
288
+ let at = index;
289
+ if (at > 0 && content[at - 1] === '\n') at -= 1;
290
+ if (at > 0 && content[at - 1] === '\r') at -= 1;
291
+ return at;
292
+ };
293
+
294
+ let cursor = beforeSeparator(end);
295
+ let lineStart = null;
296
+ // Walk complete lines backwards for as long as each is base64 and nothing
297
+ // else. Trailing `=` padding means the blob ended, so nothing is held.
298
+ while (cursor > 0) {
299
+ const separator = Math.max(content.lastIndexOf('\n', cursor - 1), content.lastIndexOf('\r', cursor - 1));
300
+ const start = separator + 1;
301
+ const line = content.slice(start, cursor).trim();
302
+ if (line.length < MIN_WRAPPED_LINE_LENGTH || !/^[A-Za-z0-9+/_-]+$/.test(line)) break;
303
+ lineStart = start;
304
+ if (end - lineStart > MAX_WRAPPED_HOLD_CHARS) return null;
305
+ cursor = beforeSeparator(start);
306
+ }
307
+ if (lineStart === null) {
308
+ // No group yet — but the last complete line may be the one a blob *starts*
309
+ // on, with its continuation still in flight. Expanding backwards over that
310
+ // line (below) only works while it is still pending; once it has been
311
+ // released there is nothing left to join the group to, and a credential
312
+ // straddling the two is invisible to both halves. So the opening line is
313
+ // held on its own, for exactly one record.
314
+ const openerStart = wrappedBase64OpenerStart(content, end, beforeSeparator);
315
+ return openerStart === null || end - openerStart > MAX_WRAPPED_HOLD_CHARS ? null : openerStart;
316
+ }
317
+
318
+ // The line before the group may be the partial one the blob started on — the
319
+ // shape `body=<first segment>` takes — so it is held too. That costs one
320
+ // extra record of latency and is released as soon as the blob ends.
321
+ const beforeGroup = beforeSeparator(lineStart);
322
+ const previousSeparator = Math.max(content.lastIndexOf('\n', beforeGroup - 1), content.lastIndexOf('\r', beforeGroup - 1));
323
+ return Math.max(previousSeparator + 1, 0);
324
+ };
325
+
326
+ /**
327
+ * Start of the last complete line in `text[0, end)` when that line looks like
328
+ * the opening line of a wrapped base64 blob: `body=<first segment>`.
329
+ *
330
+ * Length alone cannot decide this. Ordinary output is full of long runs drawn
331
+ * from the base64 alphabet — URL path segments, commit SHAs, content digests —
332
+ * and holding every line that ends in one would add a record of latency to
333
+ * routine terminal output. What separates them is that an encoded *payload*
334
+ * decodes to text: the discriminator is the printable ratio of the decoded run,
335
+ * which measures 1.00 for a JSON body and below 0.5 for a URL, a hex digest or
336
+ * a commit SHA.
337
+ *
338
+ * @param {string} content
339
+ * @param {number} end offset just past the last complete record
340
+ * @param {(index: number) => number} beforeSeparator steps back over one record separator
341
+ * @returns {number|null} start offset of the line to hold, or null to release
342
+ */
343
+ const wrappedBase64OpenerStart = (content, end, beforeSeparator) => {
344
+ const lineEnd = beforeSeparator(end);
345
+ if (lineEnd <= 0) return null;
346
+ const lineStart = Math.max(content.lastIndexOf('\n', lineEnd - 1), content.lastIndexOf('\r', lineEnd - 1)) + 1;
347
+ const line = content.slice(lineStart, lineEnd).trimEnd();
348
+
349
+ // A run that carries padding is a blob that already ended, so nothing
350
+ // follows it and there is nothing to wait for.
351
+ const run = /[A-Za-z0-9+/_-]+$/.exec(line)?.[0];
352
+ if (!run || run.length < MIN_WRAPPED_LINE_LENGTH) return null;
353
+
354
+ // Decoded directly rather than through `decodeBase64Bounded`, whose floor is
355
+ // the 24 characters a *whole* credential needs. This run is a fragment of a
356
+ // blob, and the fold width sets its length: `MIN_WRAPPED_LINE_LENGTH` is the
357
+ // relevant bound, and the 20-column fold that first exposed this gap sits
358
+ // between the two.
359
+ const normalized = run.replace(/-/g, '+').replace(/_/g, '/');
360
+ if (!/[A-Za-z0-9]/.test(normalized)) return null;
361
+
362
+ // The blob may begin at any of the three byte alignments within the run, so
363
+ // a decode that yields text at any of them is enough to hold.
364
+ for (let alignment = 0; alignment < 4; alignment++) {
365
+ const shifted = normalized.slice(alignment);
366
+ const usable = shifted.length % 4 === 1 ? shifted.slice(0, -1) : shifted;
367
+ if (usable.length < MIN_WRAPPED_LINE_LENGTH) break;
368
+ if (printableRatio(decodeBytes(Buffer.from(usable, 'base64'))) >= MIN_PRINTABLE_RATIO) return lineStart;
369
+ }
370
+ return null;
371
+ };
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Helpers
375
+ // ---------------------------------------------------------------------------
376
+
377
+ const isPrintableByte = code => code === 9 || code === 10 || code === 13 || (code >= 32 && code <= 126);
378
+
379
+ /**
380
+ * Bytes → text for the byte-oriented encodings (base64, hex).
381
+ *
382
+ * UTF-8 rather than latin1, so that a decoded payload can be sanitized and
383
+ * re-encoded byte-identically. Masked values contain `…`, which latin1 cannot
384
+ * represent; encoding it back under latin1 silently produced different bytes,
385
+ * the round-trip check then failed, and every hit degraded to wholesale
386
+ * redaction. Binary blobs decode to replacement characters either way and are
387
+ * rejected by {@link printableRatio} before any rule sees them.
388
+ *
389
+ * @param {Buffer} bytes
390
+ * @returns {string}
391
+ */
392
+ const decodeBytes = bytes => bytes.toString('utf8');
393
+
394
+ /**
395
+ * Text → bytes, the inverse of {@link decodeBytes}.
396
+ *
397
+ * @param {string} text
398
+ * @returns {Buffer}
399
+ */
400
+ const encodeBytes = text => Buffer.from(text, 'utf8');
401
+
402
+ /**
403
+ * Share of printable ASCII in a decoded string. Used to reject binary blobs
404
+ * before applying text-oriented credential rules.
405
+ *
406
+ * @param {string} text
407
+ * @returns {number} ratio in [0, 1]; an empty string scores 0
408
+ */
409
+ export const printableRatio = text => {
410
+ if (!text || text.length === 0) return 0;
411
+ let printable = 0;
412
+ for (let index = 0; index < text.length; index++) {
413
+ if (isPrintableByte(text.charCodeAt(index))) printable++;
414
+ }
415
+ return printable / text.length;
416
+ };
417
+
418
+ /**
419
+ * Decode a base64 / base64url run, bounding the work for very large blobs.
420
+ *
421
+ * A credential embedded in a huge payload is still worth finding, so instead
422
+ * of skipping oversized runs we decode a prefix and a suffix. The prefix and
423
+ * suffix are cut on 4-character boundaries so both decode correctly.
424
+ *
425
+ * @param {string} run
426
+ * @returns {string|null} decoded text, or null when the run is not decodable
427
+ * as base64
428
+ */
429
+ const decodeBase64Bounded = run => {
430
+ const normalized = run.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
431
+ // A run made only of `-`/`_` (a hyphenated identifier, a snake_case name)
432
+ // normalizes to `+`/`/` and carries no information. Require a real mix.
433
+ if (!/[A-Za-z0-9]/.test(normalized)) return null;
434
+ // Lengths of 2 and 3 (mod 4) are valid — they encode 1 and 2 trailing bytes.
435
+ // Only a remainder of 1 is impossible, and dropping more than that would
436
+ // lose the final bytes and make the re-encode round trip fail for every
437
+ // padded run, which is the overwhelmingly common case.
438
+ const usable = normalized.length % 4 === 1 ? normalized.slice(0, normalized.length - 1) : normalized;
439
+ if (usable.length < MIN_ENCODED_RUN_LENGTH) return null;
440
+
441
+ const maxChars = Math.floor((MAX_DECODE_BYTES * 4) / 3 / 4) * 4;
442
+ try {
443
+ if (usable.length <= maxChars) {
444
+ return decodeBytes(Buffer.from(usable, 'base64'));
445
+ }
446
+ const half = Math.floor(maxChars / 2 / 4) * 4;
447
+ const head = decodeBytes(Buffer.from(usable.slice(0, half), 'base64'));
448
+ const tailStart = usable.length - half - ((usable.length - half) % 4);
449
+ const tail = decodeBytes(Buffer.from(usable.slice(tailStart), 'base64'));
450
+ // The join is not a real byte boundary, so separate the two halves with a
451
+ // newline. That prevents a rule from matching across the seam and
452
+ // reporting a credential that does not exist.
453
+ return `${head}\n${tail}`;
454
+ } catch {
455
+ return null;
456
+ }
457
+ };
458
+
459
+ /**
460
+ * Remove the line structure from a wrapped base64 run, leaving the blob.
461
+ *
462
+ * @param {string} run
463
+ * @returns {string}
464
+ */
465
+ const foldWhitespace = run => run.replace(/[\s]+/g, '');
466
+
467
+ /**
468
+ * Line layout of a wrapped run, so a rebuilt blob can be re-wrapped the same
469
+ * way. The first line is frequently a partial one — the run starts wherever
470
+ * `body=` ended — so the wrap width is taken from the longest line rather than
471
+ * the first.
472
+ *
473
+ * @param {string} run
474
+ * @returns {{width: number, separator: string, indent: string}}
475
+ */
476
+ const wrappedLayout = run => {
477
+ const separator = run.includes('\r\n') ? '\r\n' : '\n';
478
+ const lines = run.split(/\r?\n/);
479
+ const indentMatch = /^[ \t]*/.exec(lines[1] ?? '');
480
+ return {
481
+ width: Math.max(...lines.map(line => line.trim().length), MIN_WRAPPED_LINE_LENGTH),
482
+ separator,
483
+ indent: indentMatch ? indentMatch[0] : '',
484
+ };
485
+ };
486
+
487
+ /**
488
+ * Minimum folded length for a wrapped run to be worth decoding. A blob only
489
+ * gets wrapped because it exceeded the wrap width, so anything this short is
490
+ * two ordinary words that happened to land on consecutive lines.
491
+ */
492
+ const MIN_WRAPPED_FOLDED_LENGTH = MIN_ENCODED_RUN_LENGTH * 2;
493
+
494
+ /**
495
+ * Reject wrapped candidates that cannot be base64 at all. A length of 1 (mod 4)
496
+ * is the only remainder base64 can never produce.
497
+ *
498
+ * @param {string} run
499
+ * @returns {boolean}
500
+ */
501
+ const isWrappedBase64Candidate = run => {
502
+ const folded = foldWhitespace(run);
503
+ return folded.length >= MIN_WRAPPED_FOLDED_LENGTH && folded.length % 4 !== 1;
504
+ };
505
+
506
+ const decodeWrappedBase64 = run => decodeBase64Bounded(foldWhitespace(run));
507
+
508
+ const decodeHex = run => {
509
+ if (run.length % 2 !== 0) return null;
510
+ try {
511
+ return decodeBytes(Buffer.from(run.slice(0, Math.min(run.length, MAX_DECODE_BYTES * 2)), 'hex'));
512
+ } catch {
513
+ return null;
514
+ }
515
+ };
516
+
517
+ const decodePercent = run => {
518
+ try {
519
+ return decodeURIComponent(run);
520
+ } catch {
521
+ // A malformed sequence still yields useful bytes when decoded manually.
522
+ return run.replace(/%([0-9a-fA-F]{2})/g, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
523
+ }
524
+ };
525
+
526
+ const decodeEscapes = run => run.replace(/\\u00([0-9a-fA-F]{2})|\\x([0-9a-fA-F]{2})/g, (_match, unicodeHex, hexHex) => String.fromCharCode(parseInt(unicodeHex || hexHex, 16)));
527
+
528
+ const decodeEntities = run =>
529
+ run.replace(/&#(x?)([0-9a-fA-F]{1,5});/g, (match, hexMarker, digits) => {
530
+ const code = parseInt(digits, hexMarker ? 16 : 10);
531
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
532
+ });
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Encoders (used to rebuild a run after its decoded content was sanitized)
536
+ // ---------------------------------------------------------------------------
537
+
538
+ /**
539
+ * Re-encode `text` the way `run` was encoded, so a sanitized payload can be
540
+ * substituted back in place without disturbing the surrounding format. The
541
+ * original run is inspected to preserve the URL-safe alphabet and padding.
542
+ *
543
+ * @param {string} text decoded, already-sanitized content
544
+ * @param {string} run the original encoded run
545
+ * @returns {string}
546
+ */
547
+ const encodeBase64Like = (text, run) => {
548
+ const urlSafe = /[-_]/.test(run) && !/[+/]/.test(run);
549
+ let encoded = encodeBytes(text).toString('base64');
550
+ if (urlSafe) encoded = encoded.replace(/\+/g, '-').replace(/\//g, '_');
551
+ if (!/=$/.test(run)) encoded = encoded.replace(/=+$/, '');
552
+ return encoded;
553
+ };
554
+
555
+ /**
556
+ * Re-encode sanitized content and re-apply the original line layout, so a
557
+ * wrapped blob stays wrapped — and, more importantly, so that decoding the
558
+ * rebuilt run reproduces the sanitized bytes and the round-trip check passes.
559
+ *
560
+ * @param {string} text decoded, already-sanitized content
561
+ * @param {string} run the original wrapped run
562
+ * @returns {string}
563
+ */
564
+ const encodeWrappedBase64Like = (text, run) => {
565
+ const encoded = encodeBase64Like(text, foldWhitespace(run));
566
+ const { width, separator, indent } = wrappedLayout(run);
567
+ const lines = [];
568
+ for (let offset = 0; offset < encoded.length; offset += width) {
569
+ lines.push(encoded.slice(offset, offset + width));
570
+ }
571
+ return lines.join(`${separator}${indent}`);
572
+ };
573
+
574
+ const encodeHex = (text, run) => {
575
+ const encoded = encodeBytes(text).toString('hex');
576
+ return /[A-F]/.test(run) && !/[a-f]/.test(run) ? encoded.toUpperCase() : encoded;
577
+ };
578
+
579
+ const encodePercent = text => [...encodeBytes(text)].map(byte => `%${byte.toString(16).padStart(2, '0').toUpperCase()}`).join('');
580
+
581
+ // Byte escapes carry latin1 semantics: `\u00XX` is one character per escape.
582
+ // A masked value contains `…`, which has no single-byte form, so the round-trip
583
+ // check rejects the rebuilt run and the caller redacts it whole instead.
584
+ const encodeEscapes = (text, run) => [...Buffer.from(text, 'latin1')].map(byte => (/\\u/.test(run) ? `\\u00${byte.toString(16).padStart(2, '0')}` : `\\x${byte.toString(16).padStart(2, '0')}`)).join('');
585
+
586
+ const encodeEntities = (text, run) => [...text].map(char => (/&#x/.test(run) ? `&#x${char.codePointAt(0).toString(16)};` : `&#${char.codePointAt(0)};`)).join('');
587
+
588
+ // `accept` is an optional per-encoding gate applied to a matched run before it
589
+ // is decoded, for conditions a regular expression cannot express. `findRuns`
590
+ // replaces `pattern` for shapes no single expression can locate efficiently.
591
+ const DECODERS = Object.freeze([
592
+ // Wrapped base64 is tried before the single-line form so that its (wider)
593
+ // range wins the overlap merge in `sanitizeEncodedCredentials`.
594
+ { encoding: 'base64-wrapped', findRuns: findWrappedBase64Runs, decode: decodeWrappedBase64, encode: encodeWrappedBase64Like, accept: isWrappedBase64Candidate },
595
+ { encoding: 'base64', pattern: BASE64_RUN, decode: decodeBase64Bounded, encode: encodeBase64Like },
596
+ { encoding: 'hex', pattern: HEX_RUN, decode: decodeHex, encode: encodeHex },
597
+ { encoding: 'percent', pattern: PERCENT_RUN, decode: decodePercent, encode: encodePercent, accept: hasEnoughEscapes },
598
+ { encoding: 'escape', pattern: ESCAPE_RUN, decode: decodeEscapes, encode: encodeEscapes },
599
+ { encoding: 'entity', pattern: ENTITY_RUN, decode: decodeEntities, encode: encodeEntities },
600
+ ]);
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Strategy A — encoded representations of a credential we already hold
604
+ // ---------------------------------------------------------------------------
605
+
606
+ /**
607
+ * Base64 fragments of `value` that survive being embedded at an arbitrary
608
+ * byte offset inside a larger payload.
609
+ *
610
+ * base64 maps each aligned group of 3 input bytes onto 4 output characters.
611
+ * When `value` starts at byte offset `k` of the payload, only the groups that
612
+ * lie entirely inside `value` are determined by `value` alone; the groups that
613
+ * straddle its edges also depend on the neighbouring bytes and therefore
614
+ * cannot be predicted. Encoding `value` at each of the three possible
615
+ * alignments and keeping just the fully-determined interior gives three
616
+ * fragments, at least one of which appears verbatim in any base64 payload that
617
+ * contains `value`.
618
+ *
619
+ * @param {string} value plaintext credential
620
+ * @returns {Array<string>} distinct alignment-stable fragments
621
+ */
622
+ export const base64AlignmentFragments = value => {
623
+ const bytes = Buffer.from(String(value ?? ''), 'utf8');
624
+ if (bytes.length < 12) return [];
625
+
626
+ const fragments = new Set();
627
+ for (let alignment = 0; alignment < 3; alignment++) {
628
+ const padded = Buffer.concat([Buffer.alloc(alignment, 0x41), bytes, Buffer.alloc(2, 0x41)]);
629
+ const encoded = padded.toString('base64');
630
+ const firstGroup = Math.ceil(alignment / 3);
631
+ const lastGroup = Math.floor((alignment + bytes.length) / 3) - 1;
632
+ if (lastGroup < firstGroup) continue;
633
+ const fragment = encoded.slice(firstGroup * 4, (lastGroup + 1) * 4);
634
+ // Below 16 characters a fragment stops being specific enough to assert a
635
+ // credential match on its own.
636
+ if (fragment.length >= 16) fragments.add(fragment);
637
+ }
638
+ return [...fragments];
639
+ };
640
+
641
+ /**
642
+ * Every encoded representation of `value` that we know how to look for.
643
+ *
644
+ * @param {string} value plaintext credential
645
+ * @returns {Array<{encoding: string, needle: string}>}
646
+ */
647
+ export const encodedRepresentations = value => {
648
+ const text = String(value ?? '');
649
+ if (text.length < 12) return [];
650
+ const buffer = Buffer.from(text, 'utf8');
651
+ const representations = [];
652
+
653
+ for (const fragment of base64AlignmentFragments(text)) {
654
+ representations.push({ encoding: 'base64', needle: fragment });
655
+ // URL-safe base64 uses the same layout with a different alphabet, so the
656
+ // alignment fragments translate directly.
657
+ const urlSafe = fragment.replace(/\+/g, '-').replace(/\//g, '_');
658
+ if (urlSafe !== fragment) representations.push({ encoding: 'base64url', needle: urlSafe });
659
+ }
660
+
661
+ const hex = buffer.toString('hex');
662
+ representations.push({ encoding: 'hex', needle: hex });
663
+ representations.push({ encoding: 'hex-upper', needle: hex.toUpperCase() });
664
+
665
+ representations.push({ encoding: 'percent', needle: encodeURIComponent(text) });
666
+ representations.push({
667
+ encoding: 'percent-full',
668
+ needle: [...buffer].map(byte => `%${byte.toString(16).padStart(2, '0').toUpperCase()}`).join(''),
669
+ });
670
+
671
+ representations.push({
672
+ encoding: 'unicode-escape',
673
+ needle: [...text].map(char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`).join(''),
674
+ });
675
+ representations.push({
676
+ encoding: 'hex-escape',
677
+ needle: [...buffer].map(byte => `\\x${byte.toString(16).padStart(2, '0')}`).join(''),
678
+ });
679
+ representations.push({
680
+ encoding: 'html-entity',
681
+ needle: [...text].map(char => `&#${char.codePointAt(0)};`).join(''),
682
+ });
683
+
684
+ // Drop anything that degenerates to the plaintext itself (already handled by
685
+ // the verbatim layer) or that is too short to assert on.
686
+ return representations.filter(({ needle }) => needle.length >= 16 && needle !== text);
687
+ };
688
+
689
+ const BASE64_CHARACTER = /[A-Za-z0-9+/=_-]/;
690
+
691
+ /**
692
+ * Widen `[start, end)` to cover the complete encoded run it sits inside, so
693
+ * masking removes the whole credential rather than the predictable middle of
694
+ * it.
695
+ *
696
+ * @param {string} text
697
+ * @param {number} start
698
+ * @param {number} end
699
+ * @param {RegExp} characterClass
700
+ * @returns {{start: number, end: number}}
701
+ */
702
+ const expandRun = (text, start, end, characterClass) => {
703
+ let from = start;
704
+ let to = end;
705
+ while (from > 0 && characterClass.test(text[from - 1])) from--;
706
+ while (to < text.length && characterClass.test(text[to])) to++;
707
+ return { start: from, end: to };
708
+ };
709
+
710
+ const CHARACTER_CLASS_FOR_ENCODING = {
711
+ base64: BASE64_CHARACTER,
712
+ base64url: BASE64_CHARACTER,
713
+ hex: /[0-9a-fA-F]/,
714
+ 'hex-upper': /[0-9a-fA-F]/,
715
+ percent: /[%0-9a-fA-F]/,
716
+ 'percent-full': /[%0-9a-fA-F]/,
717
+ 'unicode-escape': /[\\u0-9a-fA-F]/,
718
+ 'hex-escape': /[\\x0-9a-fA-F]/,
719
+ 'html-entity': /[&#;0-9a-fA-Fx]/,
720
+ };
721
+
722
+ /**
723
+ * Locate encoded occurrences of known credential values in `text`.
724
+ *
725
+ * @param {string} text
726
+ * @param {Array<string|{value: string}>} knownTokens
727
+ * @returns {Array<{start: number, end: number, encoding: string, value: string}>}
728
+ * ranges sorted by descending start so callers can splice without
729
+ * recomputing offsets
730
+ */
731
+ export const findEncodedKnownTokenRuns = (text, knownTokens = []) => {
732
+ const content = String(text ?? '');
733
+ if (content.length === 0) return [];
734
+
735
+ const found = [];
736
+ const wrappedNeedles = [];
737
+ for (const entry of knownTokens) {
738
+ const value = typeof entry === 'string' ? entry : entry?.value;
739
+ if (typeof value !== 'string' || value.length < 12) continue;
740
+
741
+ for (const { encoding, needle } of encodedRepresentations(value)) {
742
+ // A wrapped blob has line breaks inside it, so no needle occurs verbatim.
743
+ // Collect the base64 forms for the folded scan below; the other encodings
744
+ // are not line-wrapped by any tool we have seen.
745
+ if (encoding === 'base64' || encoding === 'base64url') wrappedNeedles.push({ needle, value });
746
+
747
+ let index = content.indexOf(needle);
748
+ while (index !== -1) {
749
+ const characterClass = CHARACTER_CLASS_FOR_ENCODING[encoding] || BASE64_CHARACTER;
750
+ const range = expandRun(content, index, index + needle.length, characterClass);
751
+ found.push({ ...range, encoding, value });
752
+ index = content.indexOf(needle, index + needle.length);
753
+ }
754
+ }
755
+ }
756
+
757
+ if (wrappedNeedles.length > 0) {
758
+ for (const { run, index } of findWrappedBase64Runs(content)) {
759
+ if (!isWrappedBase64Candidate(run)) continue;
760
+ const folded = foldWhitespace(run);
761
+ for (const { needle, value } of wrappedNeedles) {
762
+ if (!folded.includes(needle)) continue;
763
+ found.push({ start: index, end: index + run.length, encoding: 'base64-wrapped', value });
764
+ break;
765
+ }
766
+ }
767
+ }
768
+
769
+ return dedupeRanges(found);
770
+ };
771
+
772
+ /**
773
+ * Collapse overlapping ranges, keeping the widest, and sort descending by
774
+ * start so that splicing from the end never invalidates a later offset.
775
+ *
776
+ * @param {Array<{start: number, end: number}>} ranges
777
+ * @returns {Array<{start: number, end: number}>}
778
+ */
779
+ const dedupeRanges = ranges => {
780
+ const sorted = [...ranges].sort((a, b) => a.start - b.start || b.end - a.end);
781
+ const merged = [];
782
+ for (const range of sorted) {
783
+ const previous = merged[merged.length - 1];
784
+ if (previous && range.start < previous.end) {
785
+ if (range.end > previous.end) previous.end = range.end;
786
+ continue;
787
+ }
788
+ merged.push({ ...range });
789
+ }
790
+ return merged.reverse();
791
+ };
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // Strategy B — generic decode-and-rescan
795
+ // ---------------------------------------------------------------------------
796
+
797
+ /**
798
+ * Locate encoded runs whose decoded content a plaintext detector considers a
799
+ * credential.
800
+ *
801
+ * The detector is injected rather than imported so this module stays free of
802
+ * project dependencies and so callers can decide how aggressive the plaintext
803
+ * rules should be. `detect` receives decoded text and returns a truthy rule
804
+ * identifier (or `true`) when that text contains a credential.
805
+ *
806
+ * @param {string} text
807
+ * @param {(decoded: string) => (string|boolean)} detect
808
+ * @param {Object} [options]
809
+ * @param {number} [options.maxDepth] nested-encoding peel limit
810
+ * @returns {Array<{start: number, end: number, encoding: string, depth: number, ruleId: string}>}
811
+ * ranges sorted by descending start
812
+ */
813
+ export const findEncodedSecretRuns = (text, detect, options = {}) => {
814
+ const content = String(text ?? '');
815
+ if (content.length === 0 || typeof detect !== 'function') return [];
816
+ const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : MAX_DECODE_DEPTH;
817
+
818
+ const found = [];
819
+
820
+ for (const decoder of DECODERS) {
821
+ const { encoding, decode, accept } = decoder;
822
+ for (const { run, index } of runsOf(decoder, content)) {
823
+ if (run.length < MIN_ENCODED_RUN_LENGTH) continue;
824
+ if (accept && !accept(run)) continue;
825
+
826
+ const hit = scanDecoded(run, decode, detect, maxDepth);
827
+ if (hit) {
828
+ found.push({ start: index, end: index + run.length, encoding, depth: hit.depth, ruleId: hit.ruleId });
829
+ }
830
+ }
831
+ }
832
+
833
+ return dedupeRanges(found);
834
+ };
835
+
836
+ /**
837
+ * Peel nested encodings off `run`, testing each decoded layer.
838
+ *
839
+ * @param {string} run
840
+ * @param {(run: string) => (string|null)} decode first-layer decoder
841
+ * @param {(decoded: string) => (string|boolean)} detect
842
+ * @param {number} maxDepth
843
+ * @returns {{depth: number, ruleId: string}|null}
844
+ */
845
+ const scanDecoded = (run, decode, detect, maxDepth) => {
846
+ let current = decode(run);
847
+ for (let depth = 1; depth <= maxDepth; depth++) {
848
+ if (typeof current !== 'string' || current.length === 0) return null;
849
+ // Text-oriented rules over binary noise report credentials that are not
850
+ // there. A real encoded credential always decodes to printable text.
851
+ if (printableRatio(current) >= MIN_PRINTABLE_RATIO) {
852
+ const verdict = detect(current);
853
+ if (verdict) return { depth, ruleId: typeof verdict === 'string' ? verdict : 'encoded-credential' };
854
+ }
855
+ if (depth === maxDepth) return null;
856
+ // Peel one more layer: the decoded text may itself be an encoded blob.
857
+ const inner = current.trim();
858
+ if (inner.length < MIN_ENCODED_RUN_LENGTH || !/^[A-Za-z0-9+/=_-]+$/.test(inner)) return null;
859
+ current = decodeBase64Bounded(inner);
860
+ }
861
+ return null;
862
+ };
863
+
864
+ /**
865
+ * Every run in `text` that decodes to printable text, with its decoded payload.
866
+ *
867
+ * This is the single walk over the decoder table that both the synchronous
868
+ * masking path and the asynchronous scanner layer consume. Sharing it is the
869
+ * point: an encoding one layer knows how to decode but the other does not would
870
+ * be a hole exactly the width of the difference, and that hole would not show
871
+ * up in any test that exercises only one of them.
872
+ *
873
+ * @param {string} input
874
+ * @returns {Array<{start: number, end: number, encoding: string, run: string, decoded: string}>}
875
+ * in decoder-table order, so wrapped base64 precedes its single-line form
876
+ */
877
+ export const findDecodableRuns = input => {
878
+ const text = String(input ?? '');
879
+ if (text.length === 0) return [];
880
+
881
+ const found = [];
882
+ for (const decoder of DECODERS) {
883
+ const { encoding, decode, accept } = decoder;
884
+ for (const { run, index } of runsOf(decoder, text)) {
885
+ if (run.length < MIN_ENCODED_RUN_LENGTH) continue;
886
+ if (accept && !accept(run)) continue;
887
+
888
+ const decoded = decode(run);
889
+ // Text rules over binary noise report credentials that are not there.
890
+ // A genuinely encoded credential always decodes to printable text.
891
+ if (typeof decoded !== 'string' || decoded.length === 0) continue;
892
+ if (printableRatio(decoded) < MIN_PRINTABLE_RATIO) continue;
893
+
894
+ found.push({ start: index, end: index + run.length, encoding, run, decoded });
895
+ }
896
+ }
897
+ return found;
898
+ };
899
+
900
+ /** Look up a decoder by the `encoding` tag {@link findDecodableRuns} reports. */
901
+ const decoderFor = encoding => DECODERS.find(decoder => decoder.encoding === encoding);
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // Masking
905
+ // ---------------------------------------------------------------------------
906
+
907
+ /**
908
+ * Mask credentials that are present in `input` only in encoded form.
909
+ *
910
+ * A run is considered to carry a credential when recursively sanitizing its
911
+ * *decoded* content changes that content. Formulating detection as "the
912
+ * plaintext sanitizer has something to say about this" means the encoded layer
913
+ * automatically inherits every present and future plaintext rule, instead of
914
+ * maintaining a second, drifting copy of the rule set.
915
+ *
916
+ * Where possible the sanitized payload is re-encoded in the original encoding
917
+ * and substituted back, so a base64 blob that merely *contains* a credential
918
+ * keeps all of its other fields intact and stays parseable. The re-encoded
919
+ * value is verified by decoding it again before it is used; if the round trip
920
+ * does not reproduce the sanitized bytes exactly, the whole run is replaced
921
+ * with `[REDACTED]` instead. Failing towards total redaction keeps a partially
922
+ * understood payload from being published.
923
+ *
924
+ * @param {string} input
925
+ * @param {Object} options
926
+ * @param {(text: string) => string} options.sanitizePlaintext recursive
927
+ * sanitizer applied to decoded content
928
+ * @param {Array<string|{value: string}>} [options.knownTokens] credential
929
+ * values we hold locally; their encoded forms are matched exactly, without
930
+ * relying on the decode step
931
+ * @param {string} [options.redactedMarker]
932
+ * @returns {string}
933
+ */
934
+ export const sanitizeEncodedCredentials = (input, options = {}) => {
935
+ const text = String(input ?? '');
936
+ const { sanitizePlaintext, knownTokens = [], redactedMarker = '[REDACTED]' } = options;
937
+ if (text.length === 0 || typeof sanitizePlaintext !== 'function') return text;
938
+
939
+ const replacements = [];
940
+
941
+ for (const { start, end, encoding, run, decoded } of findDecodableRuns(text)) {
942
+ const sanitized = sanitizePlaintext(decoded);
943
+ if (sanitized === decoded) continue;
944
+
945
+ const { decode, encode } = decoderFor(encoding);
946
+ let replacement = redactedMarker;
947
+ const reEncoded = encode(sanitized, run);
948
+ // Only substitute a rebuilt run when it provably decodes back to exactly
949
+ // what we intended to publish.
950
+ if (decode(reEncoded) === sanitized) replacement = reEncoded;
951
+
952
+ replacements.push({ start, end, replacement });
953
+ }
954
+
955
+ // Exact encoded forms of credentials we already hold are matched without
956
+ // decoding, which also covers runs too large to decode in full.
957
+ for (const { start, end } of findEncodedKnownTokenRuns(text, knownTokens)) {
958
+ replacements.push({ start, end, replacement: redactedMarker });
959
+ }
960
+
961
+ if (replacements.length === 0) return text;
962
+
963
+ // Splice from the end so earlier offsets stay valid. Overlaps collapse to
964
+ // the widest range, and total redaction wins over a rebuilt run.
965
+ const ordered = [...replacements].sort((a, b) => a.start - b.start || b.end - a.end);
966
+ const merged = [];
967
+ for (const item of ordered) {
968
+ const previous = merged[merged.length - 1];
969
+ if (previous && item.start < previous.end) {
970
+ previous.end = Math.max(previous.end, item.end);
971
+ if (item.replacement === redactedMarker || previous.replacement === redactedMarker) {
972
+ previous.replacement = redactedMarker;
973
+ }
974
+ continue;
975
+ }
976
+ merged.push({ ...item });
977
+ }
978
+
979
+ let output = text;
980
+ for (const { start, end, replacement } of merged.reverse()) {
981
+ output = output.slice(0, start) + replacement + output.slice(end);
982
+ }
983
+ return output;
984
+ };
985
+
986
+ /**
987
+ * Non-sensitive report of encoded credential material still present in `input`.
988
+ * Callers never receive the matching value.
989
+ *
990
+ * @param {string} input
991
+ * @param {Object} options same shape as {@link sanitizeEncodedCredentials}
992
+ * @returns {Array<{ruleId: string}>}
993
+ */
994
+ export const findEncodedCredentialResiduals = (input, options = {}) => {
995
+ const text = String(input ?? '');
996
+ return sanitizeEncodedCredentials(text, options) === text ? [] : [{ ruleId: 'encoded-credential' }];
997
+ };
998
+
999
+ export default {
1000
+ base64AlignmentFragments,
1001
+ findWrappedBase64Runs,
1002
+ wrappedBase64HoldStart,
1003
+ encodedRepresentations,
1004
+ findEncodedKnownTokenRuns,
1005
+ findEncodedSecretRuns,
1006
+ findEncodedCredentialResiduals,
1007
+ sanitizeEncodedCredentials,
1008
+ printableRatio,
1009
+ };