residoo 0.2.0 → 0.3.1

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/src/decode.js ADDED
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Two general engine mechanisms that recover credentials a line-oriented
5
+ * regex pass alone cannot see. Both are content transforms feeding the SAME
6
+ * detection PATTERNS; neither adds a new rule. They are deliberately schema
7
+ * agnostic (they never look at transcript field names), because residoo scans
8
+ * many tools' formats and must not key on any one of them.
9
+ *
10
+ * 1. base64 decode-then-rescan (findDecodedMatches)
11
+ * 2. split-line boundary join (findBoundaryMatches)
12
+ *
13
+ * Both return the DECODED / RECONSTRUCTED plaintext secret to the caller,
14
+ * which is responsible for redaction: like the raw matcher in scan.js, the
15
+ * secret value lives only in-process and never reaches a report unredacted.
16
+ * A decoded or reconstructed secret is still a secret.
17
+ */
18
+
19
+ // ── Feature 1: base64 decode-then-rescan ────────────────────────────────────
20
+ //
21
+ // Agents routinely print credentials only in encoded form (a `base64 config`
22
+ // dump, an env file pasted as one blob), so the raw pattern never sees the
23
+ // key. We locate base64 runs in a line, decode them, and re-run the
24
+ // high-confidence patterns over the decoded text.
25
+ //
26
+ // WRAP TOLERANCE: `base64` output is line-wrapped (RFC 2045 MIME wraps at 76
27
+ // columns; the base64 CLI does too). When that wrapped output is embedded in
28
+ // a JSON string (a JSONL transcript's tool_result), each wrap newline is
29
+ // serialized as the two characters backslash+n. So a single logical base64
30
+ // blob shows up on one physical line as several runs separated by real
31
+ // whitespace or by the escape sequences \n \r \t. We treat those separators
32
+ // as part of one candidate and strip them before decoding, exactly as any
33
+ // base64 decoder ignores whitespace. This is the transcript-relevant wrap
34
+ // case and stays within one physical line.
35
+ //
36
+ // LIMITS (general, honestly stated):
37
+ // - Only high-confidence, vendor-prefixed patterns are applied to decoded
38
+ // text. Random binary that happens to decode to printable bytes can shape
39
+ // match a generic/entropy rule; a vendor prefix (AKIA, ghp_, ...) cannot
40
+ // be forged by accident, so restricting to those keeps decode-path false
41
+ // positives near zero.
42
+ // - base64 wrapped across REAL physical newlines (a PEM/MIME block spanning
43
+ // several lines of a plain-text file) is out of this per-line pass's
44
+ // scope. So is hex encoding. Both are future work, left out here rather
45
+ // than half-done.
46
+ // - One decode level only: we do not recursively decode base64-in-base64.
47
+ // - A neighbor token glued onto a wrapped blob across the wrap newline (a
48
+ // command word or filename directly above or below the encoded output)
49
+ // is recovered by retrying the decode with ONE edge chunk dropped — but
50
+ // only one, and only at an edge: junk on both edges, or prose merged into
51
+ // the middle of a blob, still loses the whole candidate.
52
+ // - At most B64_MAX_CANDIDATES candidates are decoded per line; a line with
53
+ // more encoded runs than that is reported by the caller as only partially
54
+ // checked rather than silently truncated.
55
+
56
+ // A run is made of characters that can appear in base64 or base64url:
57
+ // A-Z a-z 0-9 + / = _ - (see isB64Code). Wrap separators between chunks of
58
+ // one logical blob are line breaks (CR, LF) only — the whitespace that base64
59
+ // line-wrapping actually emits. TAB and space are NOT separators: base64
60
+ // wrapping never uses either, and merging across them splices adjacent cells
61
+ // of tabbed or spaced output into one dead candidate. When wrapped base64 is
62
+ // embedded in a JSON string the line breaks arrive as the escape sequences
63
+ // \n \r \t (two chars, backslash+letter); normalizeEscapes() turns those
64
+ // into the real characters first, so the escape's letter can never leak into
65
+ // a run (a real TAB then dirties the gap like any non-wrap character).
66
+ // Candidate runs are located by a hand-rolled single-pass character scan,
67
+ // NOT a regex. The obvious regex forms both fail on real data: a repeated
68
+ // group (chunk (sep chunk)*) recurses per repetition, and even a plain
69
+ // character-class quantifier pushes a backtrack frame per matched character
70
+ // in V8 — either one overflows the call stack on the multi-megabyte single
71
+ // lines that real transcripts contain (observed on a 7MB tool_result line).
72
+ // A char-code loop is O(n) with O(1) stack, whatever the line looks like.
73
+ const B64_MIN_CHUNK = 4;
74
+
75
+ const B64_MIN_CHARS = 24; // fewer chars cannot hide a real credential
76
+ const B64_MAX_ENCODED = 90000; // ~64KB decoded ceiling; skip bigger runs
77
+ const B64_MAX_CANDIDATES = 256; // per line, so a pathological line stays bounded
78
+ const PRINTABLE_MIN = 0.85; // decoded bytes must be mostly text to rescan
79
+
80
+ /** JSON whitespace escapes -> the real line breaks they stand for. */
81
+ function normalizeEscapes(line) {
82
+ return line.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
83
+ }
84
+
85
+ /**
86
+ * Chunk runs merged into logical candidates: consecutive chunks whose gap
87
+ * consists solely of wrap line breaks are one wrapped blob (its separators
88
+ * dropped, exactly as any base64 decoder ignores whitespace); any other gap
89
+ * (a space, TAB, prose, punctuation) ends the candidate. Iterative on
90
+ * purpose — see the character-scan note above isB64Code.
91
+ *
92
+ * Each candidate is returned as its ARRAY of chunks, not pre-joined: the
93
+ * decode step needs the chunk boundaries to retry with an edge chunk dropped
94
+ * (see findDecodedMatches). `truncated` is true when the per-line candidate
95
+ * cap cut the list short, so the caller can surface the partial coverage
96
+ * instead of silently dropping the rest.
97
+ */
98
+ function isB64Code(c) {
99
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
100
+ c === 43 || c === 47 || c === 61 || c === 95 || c === 45; // + / = _ -
101
+ }
102
+
103
+ /**
104
+ * "=" can only be terminal padding in valid base64, so an "=" group with more
105
+ * base64 characters after it inside one run separates two independent values
106
+ * glued together — the common shapes being an env assignment (`NAME=<blob>`,
107
+ * where the variable name and "=" would otherwise poison the blob) and a
108
+ * padding-terminated blob directly followed by more encoded content. Split
109
+ * there, keeping the padding attached to the value it terminates.
110
+ */
111
+ function splitAtPadding(chunk) {
112
+ const parts = [];
113
+ let start = 0;
114
+ for (let k = 0; k < chunk.length - 1; k++) {
115
+ if (chunk.charCodeAt(k) === 61 && chunk.charCodeAt(k + 1) !== 61) { // "=" then non-"="
116
+ parts.push(chunk.slice(start, k + 1));
117
+ start = k + 1;
118
+ }
119
+ }
120
+ parts.push(chunk.slice(start));
121
+ return parts;
122
+ }
123
+
124
+ function b64Candidates(norm) {
125
+ const candidates = []; // array of chunk arrays
126
+ let truncated = false;
127
+ let current = null; // chunk array of the candidate in progress
128
+ let runStart = -1; // start of the b64 run in progress, -1 when not in one
129
+ let gapClean = true; // gap since the last chunk held only \r \n
130
+ const push = (cand) => {
131
+ if (candidates.length >= B64_MAX_CANDIDATES) { truncated = true; return false; }
132
+ candidates.push(cand);
133
+ return true;
134
+ };
135
+ const n = norm.length;
136
+ for (let i = 0; i <= n; i++) {
137
+ const c = i < n ? norm.charCodeAt(i) : -1; // one virtual terminator flushes the last run
138
+ if (c !== -1 && isB64Code(c)) {
139
+ if (runStart < 0) runStart = i;
140
+ continue;
141
+ }
142
+ if (runStart >= 0) {
143
+ const raw = norm.slice(runStart, i);
144
+ runStart = -1;
145
+ if (raw.length >= B64_MIN_CHUNK) {
146
+ const parts = splitAtPadding(raw);
147
+ for (let p = 0; p < parts.length; p++) {
148
+ if (p === 0 && current !== null && gapClean) current.push(parts[p]);
149
+ else {
150
+ if (current !== null && !push(current)) return { candidates, truncated };
151
+ current = [parts[p]];
152
+ }
153
+ // A part ending in padding is a complete value: nothing after it —
154
+ // not even across a clean wrap gap — can belong to the same blob.
155
+ if (parts[p].charCodeAt(parts[p].length - 1) === 61) {
156
+ if (!push(current)) return { candidates, truncated };
157
+ current = null;
158
+ }
159
+ }
160
+ gapClean = true;
161
+ } else {
162
+ // A sub-minimum run is gap content, not a chunk: it breaks the wrap.
163
+ gapClean = false;
164
+ }
165
+ }
166
+ // Line breaks (CR, LF) keep a wrap gap clean; anything else — TAB and
167
+ // space included, base64 wrapping uses neither — dirties it.
168
+ if (c !== -1 && c !== 10 && c !== 13) gapClean = false;
169
+ }
170
+ if (current !== null) push(current);
171
+ return { candidates, truncated };
172
+ }
173
+
174
+ /**
175
+ * Decode a cleaned base64 run to text, or null if it is not decodable, is too
176
+ * large, or decodes to mostly non-printable bytes (i.e. is not text worth
177
+ * rescanning). Returns { text, encoding } where encoding is "base64url" when
178
+ * the run used the URL-safe alphabet, else "base64".
179
+ */
180
+ function decodeToText(cleaned) {
181
+ if (cleaned.length < B64_MIN_CHARS || cleaned.length > B64_MAX_ENCODED) return null;
182
+ // A wrapped blob decodes as one unit; a run that is not wholly base64 after
183
+ // cleaning is not a base64 blob.
184
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(cleaned)) return null;
185
+ const urlSafe = /[-_]/.test(cleaned);
186
+ const std = urlSafe ? cleaned.replace(/-/g, "+").replace(/_/g, "/") : cleaned;
187
+ let buf;
188
+ try {
189
+ buf = Buffer.from(std, "base64");
190
+ } catch (e) {
191
+ return null;
192
+ }
193
+ if (!buf.length || buf.length > 65536) return null;
194
+ // Printable gate first (a cheap byte loop): most non-base64 alnum runs
195
+ // (uuids, hashes, request ids) decode to non-text and are rejected here
196
+ // before the costlier round-trip re-encode runs.
197
+ let printable = 0;
198
+ for (const b of buf) {
199
+ if (b === 9 || b === 10 || b === 13 || (b >= 32 && b <= 126)) printable++;
200
+ }
201
+ if (printable / buf.length < PRINTABLE_MIN) return null;
202
+ // Round-trip guard: Buffer.from is lenient and will "decode" strings that
203
+ // are not really base64 by dropping stray bytes. If re-encoding does not
204
+ // reproduce the input (modulo padding), this was not a base64 blob and its
205
+ // "decoded" bytes are noise we should not rescan.
206
+ const reenc = buf.toString("base64").replace(/=+$/, "");
207
+ if (reenc !== std.replace(/=+$/, "")) return null;
208
+ return { text: buf.toString("utf8"), encoding: urlSafe ? "base64url" : "base64" };
209
+ }
210
+
211
+ /**
212
+ * Find credentials that appear only base64-encoded on one line. `rules` MUST
213
+ * be the high-confidence subset (see LIMITS above). Returns
214
+ * { matches, truncated } where matches is
215
+ * [{ ruleId, label, confidence, value, encoding }] with `value` the DECODED
216
+ * secret (caller redacts), deduped by rule+value so a blob echoed twice on
217
+ * one line (content plus tool-result mirror) is one entry, and `truncated`
218
+ * is true when the per-line candidate cap left runs unchecked (the caller
219
+ * surfaces that as partial coverage, never silently).
220
+ */
221
+ function findDecodedMatches(line, rules) {
222
+ const out = [];
223
+ const seen = new Set();
224
+ const norm = normalizeEscapes(line);
225
+ const { candidates, truncated } = b64Candidates(norm);
226
+ for (const chunks of candidates) {
227
+ // A wrap-merged candidate can carry one glued-on neighbor token: a word
228
+ // or filename sitting directly above or below the blob across the wrap
229
+ // newline merges into the candidate and breaks the decode (alignment
230
+ // shift or round-trip failure). When the full join fails, retry once
231
+ // with the first chunk dropped and once with the last chunk dropped —
232
+ // the two positions a foreign neighbor can occupy. LIMIT: one edge
233
+ // chunk only; junk on both edges or prose merged mid-blob still loses
234
+ // the candidate. The round-trip guard in decodeToText applies to every
235
+ // retry, so a retry can not "decode" junk into findings.
236
+ const attempts = [chunks.join("")];
237
+ if (chunks.length > 1) {
238
+ attempts.push(chunks.slice(1).join(""));
239
+ attempts.push(chunks.slice(0, -1).join(""));
240
+ }
241
+ let decoded = null;
242
+ for (const a of attempts) { decoded = decodeToText(a); if (decoded) break; }
243
+ if (!decoded) continue;
244
+ for (const rule of rules) {
245
+ rule.re.lastIndex = 0;
246
+ let m;
247
+ while ((m = rule.re.exec(decoded.text)) !== null) {
248
+ const key = rule.id + "\u0000" + m[0];
249
+ if (!seen.has(key)) {
250
+ seen.add(key);
251
+ out.push({ ruleId: rule.id, label: rule.label, confidence: rule.confidence, value: m[0], encoding: decoded.encoding });
252
+ }
253
+ if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
254
+ }
255
+ }
256
+ }
257
+ return { matches: out, truncated };
258
+ }
259
+
260
+ // ── Feature 2: split-line boundary join ─────────────────────────────────────
261
+ //
262
+ // Streaming agents write a single assistant/user turn as several adjacent
263
+ // records, so a value can be split across two lines and never appear
264
+ // contiguously on either. We reconstruct the boundary and rescan it.
265
+ //
266
+ // The two fragments are the TAIL of the content on line A and the HEAD of the
267
+ // content on line B. In a JSONL transcript the conversational content is one
268
+ // string VALUE buried inside a per-record JSON envelope (ids, usage, cwd,
269
+ // timestamps), often hundreds of characters from the physical line edge. The
270
+ // "JSONL structural seam" separating the two fragments is therefore that
271
+ // whole envelope, not a couple of punctuation chars. We strip it in a general
272
+ // way: project each line to its free-text payload before taking the window.
273
+ //
274
+ // CONTENT PROJECTION: on a JSON line the free-text payload is the longest
275
+ // string VALUE. Conversational text (a sentence, a pasted blob) is long;
276
+ // structural metadata (uuids, model names, enums, paths, timestamps) is
277
+ // short. This is a property of records that wrap a message, true across agent
278
+ // transcript formats, and it never references a field name. Non-JSON lines
279
+ // (plain-text chat logs, shell history) are used as-is.
280
+ //
281
+ // LIMITS (general, honestly stated):
282
+ // - Two-way splits only. A value broken across three or more records, or
283
+ // with unrelated records interleaved between the halves, is out of scope.
284
+ // - The projection assumes the split value lives in the line's LONGEST
285
+ // string. A record whose longest string is not the conversational content
286
+ // (e.g. a huge embedded blob or a very long path) defeats it.
287
+ // - Only BOUNDARY_WINDOW chars from each side of the seam are joined, so a
288
+ // fragment longer than that window (possible only for unbounded-length
289
+ // token shapes) is not reconstructed; the raw pass still fires on any
290
+ // prefix-bearing fragment, so the window never causes a silent all-clear.
291
+ // - A reconstructed match must straddle the seam; a match lying wholly
292
+ // within either original line is already reported by the single-line pass
293
+ // and is dropped here, so nothing is double-counted.
294
+ // - GREEDY-EXTENSION GUARD: a rule with an open-ended quantifier that
295
+ // matches a value ending flush at line A's content end would "straddle"
296
+ // by swallowing whatever alphanumeric characters happen to start line B's
297
+ // content — fabricating a longer, never-existing value out of a complete
298
+ // match plus its neighbor. So a straddling match is dropped when the same
299
+ // rule already produces a complete match ending exactly at the seam (or
300
+ // starting exactly at it) that the straddling match merely extends. The
301
+ // cost, accepted knowingly: a token split so that its first fragment is
302
+ // BY ITSELF a complete valid match of the same rule is reported as that
303
+ // fragment (by the raw pass) rather than reconstructed — for fixed-length
304
+ // token shapes, the common case, no such ambiguity exists.
305
+
306
+ const BOUNDARY_WINDOW = 300; // chars taken from each side of the seam
307
+ const BOUNDARY_MIN_CONTENT = 24; // shorter "longest string" is treated as non-content
308
+
309
+ /**
310
+ * Escape-aware list of JSON string-literal CONTENTS on a line. Field names
311
+ * are included (this walker does not distinguish keys from values); the
312
+ * longest-value projection below makes short structural strings lose, which
313
+ * is what keeps keys out of the projection in practice.
314
+ */
315
+ function jsonStringValues(line) {
316
+ const out = [];
317
+ const n = line.length;
318
+ let i = 0;
319
+ while (i < n) {
320
+ if (line[i] === '"') {
321
+ let j = i + 1;
322
+ let buf = "";
323
+ while (j < n) {
324
+ const ch = line[j];
325
+ if (ch === "\\") { buf += ch + (line[j + 1] || ""); j += 2; continue; }
326
+ if (ch === '"') break;
327
+ buf += ch;
328
+ j++;
329
+ }
330
+ out.push(buf);
331
+ i = j + 1;
332
+ } else i++;
333
+ }
334
+ return out;
335
+ }
336
+
337
+ /** Project a line to its free-text payload (see CONTENT PROJECTION above). */
338
+ function contentProjection(line) {
339
+ const t = line.trim();
340
+ if (t[0] === "{" || t[0] === "[") {
341
+ let longest = "";
342
+ for (const s of jsonStringValues(line)) if (s.length > longest.length) longest = s;
343
+ if (longest.length >= BOUNDARY_MIN_CONTENT) return longest;
344
+ }
345
+ return line;
346
+ }
347
+
348
+ /**
349
+ * Find credentials split across the boundary of two adjacent lines. Takes the
350
+ * CONTENT PROJECTIONS of the two lines (from contentProjection), which the
351
+ * caller computes once per line and reuses across both of that line's pairs.
352
+ * Returns [{ ruleId, label, confidence, value }] for matches that straddle
353
+ * the seam; `value` is the reconstructed secret (caller redacts). Deduped by
354
+ * rule+value.
355
+ */
356
+ function findBoundaryMatches(contentA, contentB, rules) {
357
+ const tail = contentA.slice(-BOUNDARY_WINDOW);
358
+ const head = contentB.slice(0, BOUNDARY_WINDOW);
359
+ if (!tail || !head) return [];
360
+ const joined = tail + head;
361
+ const seam = tail.length;
362
+ const out = [];
363
+ const seen = new Set();
364
+
365
+ // GREEDY-EXTENSION GUARD (see LIMITS above): the start positions of this
366
+ // rule's complete matches in the tail ALONE that end flush at the seam,
367
+ // and the end positions (in joined coordinates) of its complete matches
368
+ // in the head ALONE that start flush at the seam. A straddling match that
369
+ // shares a start with the former or an end with the latter is a complete
370
+ // single-line match greedily extended across the seam, not a
371
+ // reconstruction, and reporting it would fabricate a value that exists
372
+ // nowhere. Computed lazily: most pairs produce no straddling match.
373
+ const seamFlush = (rule) => {
374
+ const startsAtSeamEnd = new Set();
375
+ const endsFromSeamStart = new Set();
376
+ let t;
377
+ rule.re.lastIndex = 0;
378
+ while ((t = rule.re.exec(tail)) !== null) {
379
+ if (t.index + t[0].length === seam) startsAtSeamEnd.add(t.index);
380
+ if (t.index === rule.re.lastIndex) rule.re.lastIndex++;
381
+ }
382
+ rule.re.lastIndex = 0;
383
+ while ((t = rule.re.exec(head)) !== null) {
384
+ if (t.index === 0) endsFromSeamStart.add(seam + t[0].length);
385
+ if (t.index === rule.re.lastIndex) rule.re.lastIndex++;
386
+ }
387
+ return { startsAtSeamEnd, endsFromSeamStart };
388
+ };
389
+
390
+ for (const rule of rules) {
391
+ const straddles = [];
392
+ rule.re.lastIndex = 0;
393
+ let m;
394
+ while ((m = rule.re.exec(joined)) !== null) {
395
+ const start = m.index;
396
+ const end = m.index + m[0].length;
397
+ // Straddle-only: the match must cross the seam, else it lay wholly in
398
+ // one line and the single-line pass already reported it.
399
+ if (start < seam && end > seam) straddles.push({ start, end, value: m[0] });
400
+ if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
401
+ }
402
+ let flush = null;
403
+ for (const p of straddles) {
404
+ if (flush === null) flush = seamFlush(rule);
405
+ if (flush.startsAtSeamEnd.has(p.start) || flush.endsFromSeamStart.has(p.end)) continue;
406
+ const key = rule.id + "\u0000" + p.value;
407
+ if (!seen.has(key)) {
408
+ seen.add(key);
409
+ out.push({ ruleId: rule.id, label: rule.label, confidence: rule.confidence, value: p.value });
410
+ }
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+
416
+ module.exports = { findDecodedMatches, findBoundaryMatches, contentProjection };