residoo 0.3.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/README.md CHANGED
@@ -91,8 +91,22 @@ won't be built into the tool that writes it.
91
91
  - Scans your local AI-agent session transcripts for high-confidence secret
92
92
  patterns: cloud provider keys, private key blocks, OAuth/API tokens,
93
93
  database connection strings, and more (see `src/patterns.js`).
94
+ - Sees through two transcript-specific disguises. A credential present only
95
+ base64-encoded on a line (an env dump piped through `base64`, wrap
96
+ newlines included) is decoded and rescanned with the high-confidence
97
+ vendor-prefixed rules; the report marks it `base64-wrapped` and redacts
98
+ the decoded value. A credential split across two adjacent streaming
99
+ records, contiguous on neither line, is rejoined at the content boundary
100
+ and rescanned; the report marks it `split across lines` with the line
101
+ pair. Both are general mechanisms with stated limits (one decode level,
102
+ no base64 blocks spanning physical lines, two-way splits only; see
103
+ `src/decode.js`).
104
+ - Covers Stripe keys in both modes: live (`sk_live`/`rk_live`) and test
105
+ (`sk_test`/`rk_test`), because a leaked test key still holds real
106
+ permissions in its sandbox and reveals account structure.
94
107
  - Redacts everything in its own output. You get a shape and a first/last-4
95
- preview, never the real value, including in `--json` mode.
108
+ preview, never the real value, including in `--json` mode. A decoded or
109
+ rejoined secret is redacted exactly like a plain one.
96
110
  - Tells you how many **distinct** secrets it found versus how many times one
97
111
  got echoed back across tool calls, so the headline number reflects real
98
112
  exposure, not repetition.
@@ -123,7 +137,11 @@ packages, because Claude Code's approved-command cache quietly accumulates
123
137
  tokens and no packaging tool ignores `.claude/` by default. So as of v0.2.0,
124
138
  `residoo scan` includes an **agent config source** covering the home-level
125
139
  config files of Claude Code, Claude Desktop, Cursor, Gemini CLI, Codex, and
126
- Kiro. Every path is verified against a real install or published sources (one
140
+ Kiro. As of v0.3.1 it also reaches project-level Claude Code configs
141
+ (`.mcp.json`, `.claude/settings.json`, `.claude/settings.local.json`) by
142
+ resolving the project roots the agent itself recorded at home level
143
+ (`~/.claude.json` and transcript `cwd` fields) rather than by walking or
144
+ guessing directories; only those vendor-fixed per-project filenames are read. Every path is verified against a real install or published sources (one
127
145
  disclosed exception, a stealer-target path backed by a single published
128
146
  list, argued openly in the source header), with the full verification trail
129
147
  written into `src/sources/agent-configs.js`.
@@ -172,7 +190,7 @@ counted as clean.
172
190
  │ ├──────────────┬───────────────┤ │
173
191
  │ ▼ │ ▼ │
174
192
  │ stream + match │ integrity checks │
175
- 35 verified rules │ hooks · droppers · │
193
+ 36 verified rules │ hooks · droppers · │
176
194
  │ │ │ zero-width unicode │
177
195
  │ ▼ ▼ │ │
178
196
  │ redacted report (first/last 4 chars only) ◀────────────┤
@@ -231,7 +249,7 @@ it" leaves all of that untouched. So every finding in a residoo report comes
231
249
  with the way out:
232
250
 
233
251
  - **A rotation hint per finding**, from a per-rule guidance map covering all
234
- 35 detection rules (plus the opt-in noisy ones). Where a rotation URL is
252
+ 36 detection rules (plus the opt-in noisy ones). Where a rotation URL is
235
253
  shown, that exact URL was fetched and confirmed to document rotating or
236
254
  revoking that credential type; where a vendor's docs are login-walled or
237
255
  unfetchable, the report gives the console path in words instead of a link
@@ -454,10 +472,19 @@ submitting. See the note above on why that matters here specifically.
454
472
 
455
473
  Shape-based detection can't tell a real secret from a realistic-looking
456
474
  example in a fetched web page or a piece of documentation your agent read
457
- aloud back to you. The `--include-suppressed`/placeholder-context heuristic
458
- catches the common UI-hint case, not every case. Treat every finding as a
459
- lead to check, not a certainty. The same is true of every tool in this
460
- category, including the well-established ones.
475
+ aloud back to you. Three suppression layers narrow the gap: known
476
+ vendor-documented example values (AWS's `AKIAIOSFODNN7EXAMPLE` and its
477
+ siblings, GitHub's docs tokens, jwt.io's demo token) are suppressed by
478
+ exact match; a placeholder body built from one repeated character (no
479
+ vendor issues zero-entropy key material) is suppressed by value; and
480
+ placeholder-looking context around a match catches the common UI-hint
481
+ case. The two value-based layers apply identically to base64-decoded and
482
+ boundary-joined findings, since a decoded example is the same non-secret
483
+ as a plain one. None of the three catches every case, and all are
484
+ re-includable with `--include-suppressed`. Treat every finding as a lead
485
+ to check, not a
486
+ certainty. The same is true of every tool in this category, including the
487
+ well-established ones.
461
488
 
462
489
  ## License
463
490
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
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 };
package/src/patterns.js CHANGED
@@ -26,8 +26,26 @@ const PATTERNS = [
26
26
  re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
27
27
  { id: "slack_token", label: "Slack token", confidence: "high",
28
28
  re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
29
- { id: "stripe_key", label: "Stripe API key", confidence: "high",
29
+ { id: "stripe_key", label: "Stripe API key (live mode)", confidence: "high",
30
30
  re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
31
+ // The sandbox-mode twin of the rule above, same body charset and the same
32
+ // 20-char floor. Format verified against two production detectors plus the
33
+ // vendor (2026-09-02): gitleaks' stripe-access-token rule matches
34
+ // (sk|rk)_(test|live|prod)_[a-zA-Z0-9]{10,99}; trufflehog's Stripe
35
+ // detector is [rs]k_live_[a-zA-Z0-9]{20,247} with an explicit
36
+ // "doesn't include test keys" comment (a scope choice, not a format
37
+ // claim); and Stripe's own docs (docs.stripe.com/keys) name sk_test_ and
38
+ // rk_test_ as the sandbox secret/restricted prefixes. A separate rule
39
+ // rather than a widened live regex so a report can say WHICH mode leaked
40
+ // and rotation guidance can differ. A test key in a transcript is a real
41
+ // finding, not noise: the prefix is vendor-unique, the key grants full
42
+ // API access to the sandbox account (Stripe's docs: a secret key has
43
+ // unrestricted permissions on all Stripe APIs in its mode, and sandbox
44
+ // mode exposes ALL of the account's keys to whoever can call it), and a
45
+ // transcript that pastes sk_test today is the same workflow that will
46
+ // paste sk_live at go-live.
47
+ { id: "stripe_test_key", label: "Stripe API key (test mode)", confidence: "high",
48
+ re: /\b(sk|rk)_test_[A-Za-z0-9]{20,}\b/g },
31
49
  // The negative lookahead keeps this rule mutually exclusive with anthropic_key
32
50
  // and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
33
51
  // BOTH this pattern and the more specific one, and get reported twice under
package/src/report.js CHANGED
@@ -216,7 +216,16 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
216
216
  const distinctNote = distinct && distinct !== items.length
217
217
  ? paint(c.dim, ` (${distinct} distinct value${distinct === 1 ? "" : "s"}, re-exposed ${items.length - distinct}× across tool output)`)
218
218
  : "";
219
- push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}`);
219
+ // Flag when a rule's matches came from a decode/reconstruct pass rather
220
+ // than plain text: those would be invisible to a line-oriented scanner,
221
+ // so the reader should know the value was hidden.
222
+ const encoded = items.filter((f) => f.encoding).length;
223
+ const split = items.filter((f) => f.spanLines).length;
224
+ const marks = [];
225
+ if (encoded) marks.push(`${encoded} base64-wrapped`);
226
+ if (split) marks.push(`${split} split across lines`);
227
+ const markNote = marks.length ? paint(c.yellow, ` [${marks.join(", ")}]`) : "";
228
+ push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}${markNote}`);
220
229
  }
221
230
 
222
231
  push();
@@ -268,7 +277,16 @@ function renderJson(result, integrity = null, rotation = null) {
268
277
  findings: result.findings.map((f) => ({
269
278
  rule: f.ruleId, label: f.label, confidence: f.confidence,
270
279
  source: f.source, file: f.relFile, line: f.line, preview: f.preview,
280
+ // Markers for the two decode/reconstruct passes (absent on ordinary
281
+ // findings). `encoding` names how the value was wrapped ("base64" /
282
+ // "base64url"); `spanLines` names the adjacent line pair a split value
283
+ // was reconstructed across.
284
+ ...(f.encoding ? { encoding: f.encoding } : {}),
285
+ ...(f.spanLines ? { spanLines: f.spanLines } : {}),
271
286
  fingerprint: fingerprintFinding(f),
287
+ // Only present on an --include-suppressed run: says WHY this finding
288
+ // is low-confidence, so a JSON consumer doesn't have to guess.
289
+ ...(f.suppressedReason ? { suppressedReason: f.suppressedReason } : {}),
272
290
  })),
273
291
  // orderAdvisory mirrors the human report's ChainDrop ordering warning:
274
292
  // remediation order is safety-critical when planted persistence and
package/src/rotation.js CHANGED
@@ -76,7 +76,7 @@ const ROTATION_ORDER_ADVISORY =
76
76
  // ── rotation guidance map ───────────────────────────────────────────────────
77
77
 
78
78
  /**
79
- * One entry per rule id in src/patterns.js (all 35 of PATTERNS, plus the two
79
+ * One entry per rule id in src/patterns.js (all 36 of PATTERNS, plus the two
80
80
  * NOISY_PATTERNS ids so an --include-noisy run still renders guidance).
81
81
  * Shape: { label, rotateUrl?, consolePath?, steps: [1..3 strings],
82
82
  * revokeNote, generic? }. `generic: true` marks entries that cannot name a
@@ -174,6 +174,20 @@ const ROTATION_GUIDANCE = {
174
174
  ],
175
175
  revokeNote: "Rotating with expiration Now kills the old key immediately; a scheduled rotation keeps both valid for up to 7 days for zero-downtime migration.",
176
176
  },
177
+ // Same URL as stripe_key, and the same fetch check covers it (2026-09-02):
178
+ // docs.stripe.com/keys documents the Rotate key flow for both modes, the
179
+ // API keys page's sandbox/live toggle, and that sandbox mode exposes all
180
+ // of the account's keys to anyone who can open it.
181
+ stripe_test_key: {
182
+ label: "Stripe API key (test mode)",
183
+ rotateUrl: "https://docs.stripe.com/keys",
184
+ steps: [
185
+ "Dashboard > Developers > API keys, toggled to sandbox (test) mode",
186
+ "Overflow menu on the key > Rotate key; choose expiration Now for a compromised key",
187
+ "Check how the key leaked: test and live keys usually travel the same channel, so verify no live key was exposed alongside it",
188
+ ],
189
+ revokeNote: "Test mode is not harmless: the key grants full API access to the sandbox account, and its leak marks a workflow that will handle live keys the same way.",
190
+ },
177
191
  // help.openai.com articles 5112595 and 8304786 exist (surfaced by search)
178
192
  // but the help center serves HTTP 403 to this project's fetcher, so no URL
179
193
  // is shipped: unverifiable end to end fails the bar above.
package/src/scan.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require("path");
4
4
  const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
+ const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
5
6
 
6
7
  /**
7
8
  * Text immediately before a match that strongly suggests "this is an example
@@ -16,6 +17,84 @@ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
16
17
  const SUPPRESS_CONTEXT_RE = /(placeholder|example|sample|dummy|<REDACTED>|xxxxxxxx|your[_-]?(api[_-]?)?key|EXAMPLE)/i;
17
18
  const CONTEXT_WINDOW = 40;
18
19
 
20
+ /**
21
+ * Exact literals that vendors publish in their own documentation as example
22
+ * credentials. These pass every shape check by construction (they ARE the
23
+ * documented shape), and the context heuristic above can't be relied on to
24
+ * catch them: it only looks at the 40 characters BEFORE a match, so "the
25
+ * docs show AKIAIOSFODNN7EXAMPLE as the placeholder" sails straight through.
26
+ * The value itself is the signal here. Same policy as the context heuristic:
27
+ * suppressed by default, counted, re-includable with --include-suppressed.
28
+ * gitleaks and other production scanners filter the AWS pair the same way.
29
+ *
30
+ * Every literal below was verified against the vendor's own published docs
31
+ * (2026-09), not copied from another scanner's allowlist:
32
+ * - AWS's two documented example access key ids, used across the IAM and
33
+ * STS docs (e.g. the GetAccessKeyInfo API reference).
34
+ * - GitHub's documented example tokens from docs.github.com: the REST API
35
+ * getting-started guide's PAT, and the OAuth-apps guide's access +
36
+ * refresh token pair (the same body appears under ghp_ and gho_).
37
+ * - jwt.io's default demo token (header {"alg":"HS256","typ":"JWT"},
38
+ * payload sub 1234567890 / John Doe), the canonical example JWT quoted
39
+ * in tutorials everywhere.
40
+ */
41
+ /**
42
+ * A trailing run of 12+ identical characters inside a matched value. No
43
+ * vendor issues credentials with a repeated-character body — key material is
44
+ * random, and 12 identical characters in a row in a real random body is a
45
+ * ~62^-11 event — but placeholder keys built as prefix + XXXX.../0000... are
46
+ * everywhere in docs and templates, and they match the shape rules by
47
+ * construction. This is a property of the VALUE, so unlike the context
48
+ * heuristic it also works where no surrounding text exists: a placeholder
49
+ * that arrives base64-encoded or split across lines is still zero-entropy
50
+ * after decoding/joining. gitleaks ships equivalent repeated-character
51
+ * allowlists. Anchored to the END of the value on purpose: an INTERIOR run
52
+ * can occur inside a real token (base64 of a zero-byte run is a run of
53
+ * "A"s, so a genuine JWT payload can contain one), but real key material
54
+ * never ends in one, and prefix+XXXX placeholders always do. Same policy
55
+ * as every suppression: counted, re-includable with --include-suppressed,
56
+ * never silently dropped.
57
+ *
58
+ * Implemented as a fixed 12-character look at the END of the value, not as
59
+ * the equivalent anchored-backreference regex /(.)\1{11,}$/ — that regex is
60
+ * O(n^2) on a matched value containing a long INTERIOR identical-character
61
+ * run (the greedy backreference re-tests the anchor at every start
62
+ * position), and such values are reachable: base64 of zero-heavy bytes is a
63
+ * long run of "A"s inside a prefix-matched value. Checking only the last 12
64
+ * code units is exactly equivalent to "ends in 12 or more identical
65
+ * characters" and O(1) whatever the value looks like.
66
+ */
67
+ function zeroEntropyTail(value) {
68
+ if (value.length < 12) return false;
69
+ const last = value.charCodeAt(value.length - 1);
70
+ for (let i = value.length - 12; i < value.length - 1; i++) {
71
+ if (value.charCodeAt(i) !== last) return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ const VENDOR_EXAMPLE_VALUES = new Set([
77
+ "AKIAIOSFODNN7EXAMPLE",
78
+ "AKIAI44QH8DHBEXAMPLE",
79
+ "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
80
+ "gho_16C7e42F292c6912E7710c838347Ae178B4a",
81
+ "ghr_1B4a2e77838347a7E420ce178F2E7c6912E169246c34E1ccbF66C46812d16D5B1A9Dc86A1498",
82
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
83
+ // Stripe's two published sample test keys, verified against Stripe's own
84
+ // material (2026-09): the API reference authentication page
85
+ // (docs.stripe.com/api/authentication) embeds the first in its curl
86
+ // example under "A sample test API key is included in all the examples
87
+ // here"; the second is Stripe's long-running docs sample key, present
88
+ // verbatim in Stripe's own repositories (stripe/stripe-java and
89
+ // stripe/stripe-dotnet test suites) and echoed by virtually every Stripe
90
+ // tutorial a transcript might read. Both match stripe_test_key by
91
+ // construction, so without this entry each is reported at high confidence.
92
+ // Written split (prefix + body) so the faithful example literals do not
93
+ // trip GitHub push protection; the Set still holds the whole values.
94
+ "sk_test_" + "BQokikJOvBiI2HlWgH4olfQ2",
95
+ "sk_test_" + "4eC39HqLyjWDarjtT1zdp7dc",
96
+ ]);
97
+
19
98
  /** Matches every finding's own `relFile` convention — never the full path. See SECURITY.md. */
20
99
  function safeName(file) { return path.basename(file); }
21
100
 
@@ -39,6 +118,11 @@ function safeName(file) { return path.basename(file); }
39
118
  */
40
119
  async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null } = {}) {
41
120
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
121
+ // The decode pass (see decode.js) only applies high-confidence, vendor-
122
+ // prefixed rules to decoded bytes: random binary that decodes to printable
123
+ // text can shape-match a generic rule, but not a vendor prefix. NOISY rules
124
+ // are low confidence and never qualify.
125
+ const highRules = rules.filter((r) => r.confidence === "high");
42
126
  const findings = [];
43
127
  let suppressedCount = 0;
44
128
  let filesScanned = 0;
@@ -52,35 +136,105 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
52
136
  // never leaves this function.
53
137
  const distinctByRule = new Map();
54
138
 
139
+ // One place raw matched text turns into a recorded finding: counts the
140
+ // distinct value and pushes the redacted record. `extra` carries the
141
+ // encoding / split markers for the decode and boundary passes; the raw pass
142
+ // passes none.
143
+ const record = (rule, value, relFile, file, lineNo, mtimeMs, confidence, suppressedReason, extra) => {
144
+ if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
145
+ distinctByRule.get(rule.id).add(value);
146
+ findings.push({
147
+ ruleId: rule.id,
148
+ label: rule.label,
149
+ confidence,
150
+ suppressedReason: suppressedReason || null,
151
+ source: relFile.source,
152
+ file, relFile: relFile.name,
153
+ line: lineNo,
154
+ preview: redact(value),
155
+ fileMTimeMs: mtimeMs,
156
+ ...(extra || {}),
157
+ });
158
+ };
159
+
160
+ // One suppression policy for all three passes (raw, decoded, boundary).
161
+ // The value-based checks run first: they are exact properties of the match
162
+ // itself, so they apply identically to a value found raw, decoded out of
163
+ // base64, or reconstructed across a line boundary — a decoded vendor
164
+ // example is the same non-secret as a plain one. The context heuristic is
165
+ // last and only where surrounding text exists (`before` is null for the
166
+ // decode and boundary passes, whose transforms have no stable "40 chars
167
+ // before" in the original line).
168
+ const suppressionReason = (value, before) => {
169
+ if (VENDOR_EXAMPLE_VALUES.has(value)) return "vendor-documented example value";
170
+ if (zeroEntropyTail(value)) return "zero-entropy body";
171
+ if (before !== null && SUPPRESS_CONTEXT_RE.test(before)) return "placeholder-like context";
172
+ return null;
173
+ };
174
+
55
175
  const matchLine = (line, file, relFile, lineNo, mtimeMs) => {
56
176
  for (const rule of rules) {
57
177
  rule.re.lastIndex = 0; // rules are reused across files; reset global regex state
58
178
  let m;
59
179
  while ((m = rule.re.exec(line)) !== null) {
60
180
  const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
61
- const looksLikePlaceholder = SUPPRESS_CONTEXT_RE.test(before);
62
- if (looksLikePlaceholder && !includeSuppressed) {
181
+ const suppressedReason = suppressionReason(m[0], before);
182
+ if (suppressedReason && !includeSuppressed) {
63
183
  suppressedCount++;
64
184
  } else {
65
- if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
66
- distinctByRule.get(rule.id).add(m[0]);
67
- findings.push({
68
- ruleId: rule.id,
69
- label: rule.label,
70
- confidence: looksLikePlaceholder ? "low" : rule.confidence,
71
- suppressedReason: looksLikePlaceholder ? "placeholder-like context" : null,
72
- source: relFile.source,
73
- file, relFile: relFile.name,
74
- line: lineNo,
75
- preview: redact(m[0]),
76
- fileMTimeMs: mtimeMs,
77
- });
185
+ record(rule, m[0], relFile, file, lineNo,
186
+ mtimeMs,
187
+ suppressedReason ? "low" : rule.confidence,
188
+ suppressedReason);
78
189
  }
79
190
  if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
80
191
  }
81
192
  }
82
193
  };
83
194
 
195
+ // Feature 1: base64 decode-then-rescan. A finding here means a credential
196
+ // was present only encoded on this line. It redacts from the DECODED value
197
+ // (the encoded run is treated as secret material and never appears in the
198
+ // preview), and carries an `encoding` marker the report renders as
199
+ // "base64-wrapped". Returns true when the per-line candidate cap left
200
+ // encoded runs on this line unchecked, so the caller can flag the file as
201
+ // only partially checked instead of staying silent about the gap.
202
+ const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
203
+ const { matches, truncated } = findDecodedMatches(line, highRules);
204
+ for (const d of matches) {
205
+ const suppressedReason = suppressionReason(d.value, null);
206
+ if (suppressedReason && !includeSuppressed) {
207
+ suppressedCount++;
208
+ continue;
209
+ }
210
+ record({ id: d.ruleId, label: d.label }, d.value, relFile, file, lineNo,
211
+ mtimeMs, suppressedReason ? "low" : "high", suppressedReason, { encoding: d.encoding });
212
+ }
213
+ return truncated;
214
+ };
215
+
216
+ // Feature 2: split-line boundary join. A finding here means one credential
217
+ // was split across this line and the next and is contiguous on neither. It
218
+ // is recorded against BOTH contributing lines (each holds a fragment of the
219
+ // exposed secret) and carries a `spanLines` marker. `contentA`/`contentB`
220
+ // are the two lines' content projections, computed once per line by the
221
+ // caller and reused across both of a line's pairs.
222
+ const boundaryPair = (contentA, contentB, file, relFile, lineNoA, mtimeMs) => {
223
+ for (const b of findBoundaryMatches(contentA, contentB, rules)) {
224
+ const suppressedReason = suppressionReason(b.value, null);
225
+ if (suppressedReason && !includeSuppressed) {
226
+ // One straddling match is one suppressed match, even though an
227
+ // unsuppressed one records against both contributing lines.
228
+ suppressedCount++;
229
+ continue;
230
+ }
231
+ const span = [lineNoA, lineNoA + 1];
232
+ const conf = suppressedReason ? "low" : b.confidence;
233
+ record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA, mtimeMs, conf, suppressedReason, { spanLines: span });
234
+ record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA + 1, mtimeMs, conf, suppressedReason, { spanLines: span });
235
+ }
236
+ };
237
+
84
238
  for (const source of sources) {
85
239
  let sourceScannedAnything = false;
86
240
 
@@ -134,8 +288,47 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
134
288
  bytesScanned += bytesRead || sizeBytes || 0;
135
289
 
136
290
  const relFile = { name: safeName(file), source: source.id() };
291
+ // Content projection of the PREVIOUS line, kept so each line is
292
+ // projected once and reused for both pairs it belongs to.
293
+ let prevContent = null;
294
+ // Per-file degradation flags, each surfaced at most once so a
295
+ // pathological file produces one visible entry, not thousands.
296
+ let lineMatchFailed = false;
297
+ let decodeTruncated = false;
137
298
  for (let i = 0; i < lines.length; i++) {
138
- if (lines[i]) matchLine(lines[i], file, relFile, i + 1, mtimeMs);
299
+ const line = lines[i];
300
+ if (line) {
301
+ // A rule regex itself can throw on adversarial input: V8's
302
+ // backtrack stack overflows (RangeError) when an open-ended
303
+ // quantifier meets a prefix followed by a multi-megabyte
304
+ // same-charset run — real transcripts contain such lines. One
305
+ // unmatched line must degrade to a visible per-file flag, never
306
+ // abort the scan and discard every finding already collected
307
+ // (same contract as the readLines catch above).
308
+ try {
309
+ matchLine(line, file, relFile, i + 1, mtimeMs);
310
+ decodeTruncated = decodeLine(line, file, relFile, i + 1, mtimeMs) || decodeTruncated;
311
+ const content = contentProjection(line);
312
+ // Boundary join with the previous line (2-way splits only; see
313
+ // decode.js). Both lines must be non-empty so a blank separator
314
+ // never forms a spurious pair.
315
+ if (prevContent !== null) {
316
+ boundaryPair(prevContent, content, file, relFile, i, mtimeMs);
317
+ }
318
+ prevContent = content;
319
+ } catch (err) {
320
+ if (!lineMatchFailed) {
321
+ lineMatchFailed = true;
322
+ unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
323
+ }
324
+ prevContent = null;
325
+ }
326
+ } else {
327
+ prevContent = null;
328
+ }
329
+ }
330
+ if (decodeTruncated) {
331
+ unreadableFiles.push({ file: safeName(file), reason: "some lines held more encoded runs than the per-line bound; checked partially" });
139
332
  }
140
333
  }
141
334
 
@@ -160,4 +353,7 @@ function emptyResult() {
160
353
  };
161
354
  }
162
355
 
163
- module.exports = { scan, emptyResult };
356
+ // VENDOR_EXAMPLE_VALUES is exported for the smoke tests, which assert every
357
+ // literal in it is still matched IN FULL by some detection rule — a literal
358
+ // no rule can produce as a whole match is dead weight that suppresses nothing.
359
+ module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES };
@@ -19,15 +19,28 @@ const { createInterface } = require("readline/promises");
19
19
  * Bitwarden-CLI-hijack write-up, StepSecurity's Nx Console analysis, the
20
20
  * keyv/Shai-Hulud reports) name several of the paths below verbatim.
21
21
  *
22
- * SCOPE — home-level only, and that limitation is real, not rhetorical:
23
- * project-level configs (`.mcp.json`, `.claude/settings.json`,
24
- * `.cursor/rules/`, `.vscode/tasks.json`, per-repo CLAUDE.md/AGENTS.md
25
- * the files Miasma actually planted in cloned repos) live inside arbitrary
26
- * repositories this tool has no way to enumerate from a home directory.
27
- * A clean report from this source therefore says nothing about any
28
- * project's own config files. v1 ships the home-level set because those
29
- * paths are fixed and verifiable; the project-level gap is stated here
30
- * rather than papered over.
22
+ * SCOPE — fixed home-level paths, plus project-level Claude Code configs
23
+ * reachable through the agent's own breadcrumbs. Project configs live
24
+ * inside arbitrary repositories that no home-level scanner can enumerate
25
+ * by walking a directory tree but the agent itself records every project
26
+ * root it has been used in, at home level: `~/.claude.json` keeps a
27
+ * top-level `projects` map keyed by absolute project path (vendor-
28
+ * documented per-project state), and every transcript under
29
+ * `~/.claude/projects/<slug>/` carries the project's absolute path in its
30
+ * records' `cwd` field (vendor JSONL schema). Following those recorded
31
+ * roots to the vendor-FIXED per-project config filenames (`.mcp.json`,
32
+ * `.claude/settings.json`, `.claude/settings.local.json` — the exact
33
+ * files GitGuardian measured 24k secrets in and Lakera found shipped in
34
+ * npm packages) is a general mechanism, not path guessing: nothing is
35
+ * discovered by scanning repositories, only by resolving what the agent
36
+ * already wrote down. Limits, stated: a machine whose agent state was
37
+ * wiped yields no roots (discovery degrades to absence, the same
38
+ * information a human reading the state would have); other agents'
39
+ * project-level configs (`.cursor/rules/`, `.vscode/tasks.json`, per-repo
40
+ * CLAUDE.md/AGENTS.md — the files Miasma actually planted) stay
41
+ * uncovered until their home-level state formats are verified to the same
42
+ * bar; and a clean report still says nothing about repositories the agent
43
+ * was never pointed at.
31
44
  *
32
45
  * PER-PATH VERIFICATION (per CONTRIBUTING.md's no-guessed-paths rule —
33
46
  * "real install" below means the populated machine this source was built
@@ -96,8 +109,12 @@ const { createInterface } = require("readline/promises");
96
109
  * Bitwarden-CLI target list.
97
110
  *
98
111
  * DELIBERATELY NOT READ, and why:
99
- * - `~/.claude/projects/**` — claude-code.js's territory. Overlapping it
100
- * would double-report every finding. (Named side effect: a
112
+ * - `~/.claude/projects/**` AS SCAN CONTENT — claude-code.js's territory;
113
+ * overlapping it would double-report every finding. The project-root
114
+ * discovery below does open transcripts, but only to read the `cwd`
115
+ * field out of the first records (a bounded probe, nothing from the
116
+ * content is ever reported), which indexes projects without scanning
117
+ * a single transcript line here. (Named side effect: a
101
118
  * `projects/<slug>/memory/MEMORY.md` is covered by NEITHER source
102
119
  * today — a real gap that belongs to the transcript source's scope
103
120
  * discussion, recorded here so it isn't mistaken for covered.)
@@ -257,9 +274,176 @@ function* statIfPresent(p) {
257
274
  yield { file: p, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
258
275
  }
259
276
 
260
- /** Yield { file, mtimeMs, sizeBytes, broken } for every candidate present. */
277
+ // ── project-level config discovery ──────────────────────────────────────────
278
+ //
279
+ // The per-project config filenames are vendor-fixed (Claude Code's own docs:
280
+ // project-scope MCP servers in `.mcp.json`, shared settings in
281
+ // `.claude/settings.json`, local settings in `.claude/settings.local.json`);
282
+ // what varies per machine is only WHERE the project roots are, and the agent
283
+ // records exactly that in home-level state. Two independent record sources,
284
+ // both vendor artifacts, both read best-effort (a failure to discover a root
285
+ // is absence, never an error — see the SCOPE limits in the header):
286
+ //
287
+ // 1. `~/.claude.json`'s top-level `projects` object, keyed by absolute
288
+ // project path. Already a scan candidate above; parsed here a second
289
+ // time only for its keys.
290
+ // 2. The `cwd` field in transcript records under
291
+ // `~/.claude/projects/<slug>/`. The slug itself also encodes the path,
292
+ // but lossily (path separators and dashes collapse into the same
293
+ // character), so the record field is the reliable form. Only the first
294
+ // few records of the first few files per slug directory are probed,
295
+ // bounded by bytes and line count: every session in a slug directory
296
+ // shares one project root by construction.
297
+ //
298
+ // A recorded root that no longer exists usually means the project was
299
+ // deleted — except when the WHOLE home tree has been relocated (a mounted
300
+ // backup, a copied disk image, HOME pinned at a snapshot for auditing), in
301
+ // which case every recorded absolute path is stale by the same prefix. That
302
+ // case is detected generally: a missing root whose prefix is shaped like a
303
+ // home directory (macOS /Users/<u>, Linux /home/<u>, Windows
304
+ // <drive>:\Users\<u>) is retried at the same home-relative path under the
305
+ // CURRENT home, and used only if that directory actually exists. Non-default
306
+ // home locations (e.g. /srv/data/<u>) defeat the re-rooting and are a stated
307
+ // limit, not a silent one.
308
+ //
309
+ // Recorded roots are data read from transcripts and state files, which a
310
+ // hostile transcript can influence — so nothing is ever globbed or walked
311
+ // beneath them: only the three fixed basenames are lstat'd, reads stay
312
+ // read-only through the same readLines every candidate gets, and anything
313
+ // matched is redacted by the report layer like every other finding.
314
+
315
+ const CLAUDE_STATE_JSON = path.join(HOME, ".claude.json");
316
+ const CLAUDE_PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
317
+
318
+ const PROJECT_CONFIG_RELPATHS = [
319
+ ".mcp.json",
320
+ path.join(".claude", "settings.json"),
321
+ path.join(".claude", "settings.local.json"),
322
+ ];
323
+
324
+ // Probe bounds: transcript first-records are KB-scale; 256KB and 20 lines is
325
+ // headroom, not a tuned fit. One resolving file per slug directory suffices.
326
+ const CWD_PROBE_BYTES = 256 * 1024;
327
+ const CWD_PROBE_LINES = 20;
328
+ const CWD_PROBE_FILES_PER_DIR = 3;
329
+
330
+ function rootsFromClaudeState() {
331
+ let stat;
332
+ try { stat = fs.statSync(CLAUDE_STATE_JSON); } catch { return []; }
333
+ if (!stat.isFile() || stat.size > MAX_BYTES) return [];
334
+ try {
335
+ const doc = JSON.parse(fs.readFileSync(CLAUDE_STATE_JSON, "utf-8"));
336
+ if (doc && typeof doc === "object" && doc.projects &&
337
+ typeof doc.projects === "object" && !Array.isArray(doc.projects)) {
338
+ return Object.keys(doc.projects).filter((k) => typeof k === "string" && k.length > 0);
339
+ }
340
+ } catch {
341
+ // Unparseable state: discovery loses this index, but the file itself is
342
+ // still scanned line-by-line as a fixed candidate above, so no content
343
+ // goes unexamined because of a parse failure here.
344
+ }
345
+ return [];
346
+ }
347
+
348
+ /** First string `cwd` in the leading records of one transcript, or null. */
349
+ function firstCwdIn(file) {
350
+ let fd = null;
351
+ try {
352
+ fd = fs.openSync(file, "r");
353
+ const buf = Buffer.alloc(CWD_PROBE_BYTES);
354
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
355
+ // A line truncated at the probe boundary simply fails JSON.parse and is
356
+ // skipped — the bound costs recall on that one record, never a crash.
357
+ const lines = buf.toString("utf-8", 0, n).split("\n", CWD_PROBE_LINES);
358
+ for (const line of lines) {
359
+ try {
360
+ const rec = JSON.parse(line);
361
+ if (rec && typeof rec === "object" && typeof rec.cwd === "string" && rec.cwd) return rec.cwd;
362
+ } catch { /* meta/summary records and non-JSON lines: keep looking */ }
363
+ }
364
+ } catch {
365
+ // Unreadable transcript: claude-code.js owns surfacing that; a root
366
+ // index probe must not duplicate its error reporting.
367
+ } finally {
368
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function rootsFromTranscriptDirs() {
374
+ let dirs;
375
+ try { dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }); }
376
+ catch { return []; }
377
+ const roots = [];
378
+ for (const d of dirs) {
379
+ if (!d.isDirectory()) continue;
380
+ const dir = path.join(CLAUDE_PROJECTS_DIR, d.name);
381
+ let entries;
382
+ // An unlistable slug directory is not silently swallowed overall:
383
+ // claude-code.js's own files() walk reports that same directory as
384
+ // broken; this index probe just loses one root.
385
+ try { entries = fs.readdirSync(dir); } catch { continue; }
386
+ const sessions = entries.filter((n) => n.endsWith(".jsonl")).sort();
387
+ for (const name of sessions.slice(0, CWD_PROBE_FILES_PER_DIR)) {
388
+ const cwd = firstCwdIn(path.join(dir, name));
389
+ if (cwd) { roots.push(cwd); break; }
390
+ }
391
+ }
392
+ return roots;
393
+ }
394
+
395
+ // Default home-directory shapes for the three platforms Claude Code ships
396
+ // on. Anchored and existence-checked, never a rewrite of arbitrary paths.
397
+ const HOME_SHAPED_PREFIX = /^(?:\/(?:Users|home)\/[^/]+|[A-Za-z]:[\\/]Users[\\/][^\\/]+)(?=[\\/]|$)/;
398
+
399
+ /** Resolve one recorded project root to an existing directory, or null. */
400
+ function resolveRecordedRoot(recorded) {
401
+ try { if (fs.statSync(recorded).isDirectory()) return recorded; } catch { /* fall through to re-rooting */ }
402
+ const m = HOME_SHAPED_PREFIX.exec(recorded);
403
+ if (!m) return null;
404
+ const rest = recorded.slice(m[0].length).split(/[\\/]+/).filter(Boolean);
405
+ const rehomed = rest.length === 0 ? HOME : path.join(HOME, ...rest);
406
+ try { if (fs.statSync(rehomed).isDirectory()) return rehomed; } catch { /* relocated copy absent too */ }
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * Yield candidate entries for every discovered project root's fixed config
412
+ * filenames, deduplicated against paths already yielded (a recorded root
413
+ * that resolves to the home directory itself would otherwise re-yield
414
+ * `~/.claude/settings.json`). Sorted for deterministic output across runs —
415
+ * readdir order is not.
416
+ */
417
+ function* projectConfigCandidates(seenResolved) {
418
+ const recorded = new Set([...rootsFromClaudeState(), ...rootsFromTranscriptDirs()]);
419
+ const resolved = new Set();
420
+ for (const r of recorded) {
421
+ const root = resolveRecordedRoot(r);
422
+ if (root) resolved.add(path.resolve(root));
423
+ }
424
+ for (const root of [...resolved].sort()) {
425
+ for (const rel of PROJECT_CONFIG_RELPATHS) {
426
+ const p = path.join(root, rel);
427
+ const key = path.resolve(p);
428
+ if (seenResolved.has(key)) continue;
429
+ seenResolved.add(key);
430
+ yield* statIfPresent(p);
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Yield { file, mtimeMs, sizeBytes, broken } for every candidate present:
437
+ * the fixed home-level paths first, then per-project configs at the roots
438
+ * the agent's own state records (see the discovery block above).
439
+ */
261
440
  function* files() {
262
- for (const p of CANDIDATES) yield* statIfPresent(p);
441
+ const seen = new Set();
442
+ for (const p of CANDIDATES) {
443
+ seen.add(path.resolve(p));
444
+ yield* statIfPresent(p);
445
+ }
446
+ yield* projectConfigCandidates(seen);
263
447
  }
264
448
 
265
449
  /**