@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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/credential-sanitization-core.lib.mjs +104 -9
- package/src/encoded-credential-detection.lib.mjs +1009 -0
- package/src/formal-ai-image.lib.mjs +222 -0
- package/src/formal-ai-isolation.lib.mjs +8 -1
- package/src/formal-ai-sidecar.lib.mjs +36 -13
- package/src/formal-ai-updater.lib.mjs +14 -2
- package/src/hive-mind-image.lib.mjs +56 -0
- package/src/isolation-runner.lib.mjs +71 -54
- package/src/locales/en.lino +3 -0
- package/src/locales/hi.lino +3 -0
- package/src/locales/ru.lino +3 -0
- package/src/locales/zh.lino +3 -0
- package/src/session-kill-recovery.lib.mjs +9 -1
- package/src/session-monitor.lib.mjs +35 -4
- package/src/session-store.lib.mjs +7 -2
- package/src/telegram-command-execution.lib.mjs +26 -7
- package/src/telegram-solve-queue.lib.mjs +30 -5
- package/src/token-sanitization.lib.mjs +182 -5
- package/src/work-session-formatting.lib.mjs +56 -3
|
@@ -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
|
+
};
|