residoo 0.3.0 → 0.3.2
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 +35 -8
- package/package.json +35 -9
- package/src/decode.js +416 -0
- package/src/patterns.js +19 -1
- package/src/report.js +19 -1
- package/src/rotation.js +15 -1
- package/src/scan.js +205 -17
- package/src/sources/agent-configs.js +197 -13
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.
|
|
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
|
-
│
|
|
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
|
-
|
|
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.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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,19 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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)",
|
|
7
|
-
"repository": {
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dandovdub/residoo.git"
|
|
10
|
+
},
|
|
8
11
|
"homepage": "https://github.com/dandovdub/residoo#readme",
|
|
9
|
-
"bugs": {
|
|
10
|
-
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dandovdub/residoo/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"residoo": "bin/residoo.js"
|
|
17
|
+
},
|
|
11
18
|
"main": "src/cli.js",
|
|
12
|
-
"engines": {
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node tests/smoke.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
15
32
|
"keywords": [
|
|
16
|
-
"security",
|
|
17
|
-
"
|
|
33
|
+
"security",
|
|
34
|
+
"secrets",
|
|
35
|
+
"secret-scanning",
|
|
36
|
+
"ai-agent",
|
|
37
|
+
"claude-code",
|
|
38
|
+
"cursor",
|
|
39
|
+
"copilot",
|
|
40
|
+
"mcp",
|
|
41
|
+
"privacy",
|
|
42
|
+
"cli",
|
|
43
|
+
"encryption"
|
|
18
44
|
]
|
|
19
45
|
}
|
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
|
+
// - There is deliberately NO per-line candidate cap. Real transcript lines
|
|
53
|
+
// routinely hold hundreds of decode-sized alnum runs (uuids, hashes,
|
|
54
|
+
// request ids), so any cap either silently starves a genuine blob
|
|
55
|
+
// sitting past it or flags nearly every real file as partially checked.
|
|
56
|
+
// None is needed for cost: each character belongs to at most one
|
|
57
|
+
// candidate and decoding is a few linear passes, so total work per line
|
|
58
|
+
// is O(line length) with small constants, and line length is already
|
|
59
|
+
// bounded by the sources' own file-read caps.
|
|
60
|
+
|
|
61
|
+
// A run is made of characters that can appear in base64 or base64url:
|
|
62
|
+
// A-Z a-z 0-9 + / = _ - (see isB64Code). Wrap separators between chunks of
|
|
63
|
+
// one logical blob are line breaks (CR, LF) only — the whitespace that base64
|
|
64
|
+
// line-wrapping actually emits. TAB and space are NOT separators: base64
|
|
65
|
+
// wrapping never uses either, and merging across them splices adjacent cells
|
|
66
|
+
// of tabbed or spaced output into one dead candidate. When wrapped base64 is
|
|
67
|
+
// embedded in a JSON string the line breaks arrive as the escape sequences
|
|
68
|
+
// \n \r \t (two chars, backslash+letter); normalizeEscapes() turns those
|
|
69
|
+
// into the real characters first, so the escape's letter can never leak into
|
|
70
|
+
// a run (a real TAB then dirties the gap like any non-wrap character).
|
|
71
|
+
// Candidate runs are located by a hand-rolled single-pass character scan,
|
|
72
|
+
// NOT a regex. The obvious regex forms both fail on real data: a repeated
|
|
73
|
+
// group (chunk (sep chunk)*) recurses per repetition, and even a plain
|
|
74
|
+
// character-class quantifier pushes a backtrack frame per matched character
|
|
75
|
+
// in V8 — either one overflows the call stack on the multi-megabyte single
|
|
76
|
+
// lines that real transcripts contain (observed on a 7MB tool_result line).
|
|
77
|
+
// A char-code loop is O(n) with O(1) stack, whatever the line looks like.
|
|
78
|
+
const B64_MIN_CHUNK = 4;
|
|
79
|
+
|
|
80
|
+
const B64_MIN_CHARS = 24; // fewer chars cannot hide a real credential
|
|
81
|
+
const B64_MAX_ENCODED = 90000; // ~64KB decoded ceiling; skip bigger runs
|
|
82
|
+
const PRINTABLE_MIN = 0.85; // decoded bytes must be mostly text to rescan
|
|
83
|
+
|
|
84
|
+
/** JSON whitespace escapes -> the real line breaks they stand for. */
|
|
85
|
+
function normalizeEscapes(line) {
|
|
86
|
+
return line.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, "\t");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Chunk runs merged into logical candidates: consecutive chunks whose gap
|
|
91
|
+
* consists solely of wrap line breaks are one wrapped blob (its separators
|
|
92
|
+
* dropped, exactly as any base64 decoder ignores whitespace); any other gap
|
|
93
|
+
* (a space, TAB, prose, punctuation) ends the candidate. Iterative on
|
|
94
|
+
* purpose — see the character-scan note above isB64Code.
|
|
95
|
+
*
|
|
96
|
+
* Each candidate is returned as its ARRAY of chunks, not pre-joined: the
|
|
97
|
+
* decode step needs the chunk boundaries to retry with an edge chunk dropped
|
|
98
|
+
* (see findDecodedMatches). Candidates shorter than B64_MIN_CHARS joined are
|
|
99
|
+
* discarded here for free: they can never decode (decodeToText rejects them
|
|
100
|
+
* by length), and most base64-charset runs on a line are exactly such short
|
|
101
|
+
* prose words and ids.
|
|
102
|
+
*/
|
|
103
|
+
function isB64Code(c) {
|
|
104
|
+
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
|
|
105
|
+
c === 43 || c === 47 || c === 61 || c === 95 || c === 45; // + / = _ -
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* "=" can only be terminal padding in valid base64, so an "=" group with more
|
|
110
|
+
* base64 characters after it inside one run separates two independent values
|
|
111
|
+
* glued together — the common shapes being an env assignment (`NAME=<blob>`,
|
|
112
|
+
* where the variable name and "=" would otherwise poison the blob) and a
|
|
113
|
+
* padding-terminated blob directly followed by more encoded content. Split
|
|
114
|
+
* there, keeping the padding attached to the value it terminates.
|
|
115
|
+
*/
|
|
116
|
+
function splitAtPadding(chunk) {
|
|
117
|
+
const parts = [];
|
|
118
|
+
let start = 0;
|
|
119
|
+
for (let k = 0; k < chunk.length - 1; k++) {
|
|
120
|
+
if (chunk.charCodeAt(k) === 61 && chunk.charCodeAt(k + 1) !== 61) { // "=" then non-"="
|
|
121
|
+
parts.push(chunk.slice(start, k + 1));
|
|
122
|
+
start = k + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
parts.push(chunk.slice(start));
|
|
126
|
+
return parts;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function b64Candidates(norm) {
|
|
130
|
+
const candidates = []; // array of chunk arrays
|
|
131
|
+
let current = null; // chunk array of the candidate in progress
|
|
132
|
+
let runStart = -1; // start of the b64 run in progress, -1 when not in one
|
|
133
|
+
let gapClean = true; // gap since the last chunk held only \r \n
|
|
134
|
+
const push = (cand) => {
|
|
135
|
+
let len = 0;
|
|
136
|
+
for (const c of cand) len += c.length;
|
|
137
|
+
if (len >= B64_MIN_CHARS) candidates.push(cand);
|
|
138
|
+
};
|
|
139
|
+
const n = norm.length;
|
|
140
|
+
for (let i = 0; i <= n; i++) {
|
|
141
|
+
const c = i < n ? norm.charCodeAt(i) : -1; // one virtual terminator flushes the last run
|
|
142
|
+
if (c !== -1 && isB64Code(c)) {
|
|
143
|
+
if (runStart < 0) runStart = i;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (runStart >= 0) {
|
|
147
|
+
const raw = norm.slice(runStart, i);
|
|
148
|
+
runStart = -1;
|
|
149
|
+
if (raw.length >= B64_MIN_CHUNK) {
|
|
150
|
+
const parts = splitAtPadding(raw);
|
|
151
|
+
for (let p = 0; p < parts.length; p++) {
|
|
152
|
+
if (p === 0 && current !== null && gapClean) current.push(parts[p]);
|
|
153
|
+
else {
|
|
154
|
+
if (current !== null) push(current);
|
|
155
|
+
current = [parts[p]];
|
|
156
|
+
}
|
|
157
|
+
// A part ending in padding is a complete value: nothing after it —
|
|
158
|
+
// not even across a clean wrap gap — can belong to the same blob.
|
|
159
|
+
if (parts[p].charCodeAt(parts[p].length - 1) === 61) {
|
|
160
|
+
push(current);
|
|
161
|
+
current = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
gapClean = true;
|
|
165
|
+
} else {
|
|
166
|
+
// A sub-minimum run is gap content, not a chunk: it breaks the wrap.
|
|
167
|
+
gapClean = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Line breaks (CR, LF) keep a wrap gap clean; anything else — TAB and
|
|
171
|
+
// space included, base64 wrapping uses neither — dirties it.
|
|
172
|
+
if (c !== -1 && c !== 10 && c !== 13) gapClean = false;
|
|
173
|
+
}
|
|
174
|
+
if (current !== null) push(current);
|
|
175
|
+
return candidates;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Decode a cleaned base64 run to text, or null if it is not decodable, is too
|
|
180
|
+
* large, or decodes to mostly non-printable bytes (i.e. is not text worth
|
|
181
|
+
* rescanning). Returns { text, encoding } where encoding is "base64url" when
|
|
182
|
+
* the run used the URL-safe alphabet, else "base64".
|
|
183
|
+
*/
|
|
184
|
+
function decodeToText(cleaned) {
|
|
185
|
+
if (cleaned.length < B64_MIN_CHARS || cleaned.length > B64_MAX_ENCODED) return null;
|
|
186
|
+
// A wrapped blob decodes as one unit; a run that is not wholly base64 after
|
|
187
|
+
// cleaning is not a base64 blob.
|
|
188
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(cleaned)) return null;
|
|
189
|
+
const urlSafe = /[-_]/.test(cleaned);
|
|
190
|
+
const std = urlSafe ? cleaned.replace(/-/g, "+").replace(/_/g, "/") : cleaned;
|
|
191
|
+
let buf;
|
|
192
|
+
try {
|
|
193
|
+
buf = Buffer.from(std, "base64");
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!buf.length || buf.length > 65536) return null;
|
|
198
|
+
// Printable gate first (a cheap byte loop): most non-base64 alnum runs
|
|
199
|
+
// (uuids, hashes, request ids) decode to non-text and are rejected here
|
|
200
|
+
// before the costlier round-trip re-encode runs.
|
|
201
|
+
let printable = 0;
|
|
202
|
+
for (const b of buf) {
|
|
203
|
+
if (b === 9 || b === 10 || b === 13 || (b >= 32 && b <= 126)) printable++;
|
|
204
|
+
}
|
|
205
|
+
if (printable / buf.length < PRINTABLE_MIN) return null;
|
|
206
|
+
// Round-trip guard: Buffer.from is lenient and will "decode" strings that
|
|
207
|
+
// are not really base64 by dropping stray bytes. If re-encoding does not
|
|
208
|
+
// reproduce the input (modulo padding), this was not a base64 blob and its
|
|
209
|
+
// "decoded" bytes are noise we should not rescan.
|
|
210
|
+
const reenc = buf.toString("base64").replace(/=+$/, "");
|
|
211
|
+
if (reenc !== std.replace(/=+$/, "")) return null;
|
|
212
|
+
return { text: buf.toString("utf8"), encoding: urlSafe ? "base64url" : "base64" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Find credentials that appear only base64-encoded on one line. `rules` MUST
|
|
217
|
+
* be the high-confidence subset (see LIMITS above). Returns
|
|
218
|
+
* [{ ruleId, label, confidence, value, encoding }] with `value` the DECODED
|
|
219
|
+
* secret (caller redacts), deduped by rule+value so a blob echoed twice on
|
|
220
|
+
* one line (content plus tool-result mirror) is one entry.
|
|
221
|
+
*/
|
|
222
|
+
function findDecodedMatches(line, rules) {
|
|
223
|
+
const out = [];
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
const norm = normalizeEscapes(line);
|
|
226
|
+
for (const chunks of b64Candidates(norm)) {
|
|
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 out;
|
|
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
|
-
|
|
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
|
|
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,101 @@ 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
|
|
62
|
-
if (
|
|
181
|
+
const suppressedReason = suppressionReason(m[0], before);
|
|
182
|
+
if (suppressedReason && !includeSuppressed) {
|
|
63
183
|
suppressedCount++;
|
|
64
184
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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".
|
|
200
|
+
const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
|
|
201
|
+
for (const d of findDecodedMatches(line, highRules)) {
|
|
202
|
+
const suppressedReason = suppressionReason(d.value, null);
|
|
203
|
+
if (suppressedReason && !includeSuppressed) {
|
|
204
|
+
suppressedCount++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
record({ id: d.ruleId, label: d.label }, d.value, relFile, file, lineNo,
|
|
208
|
+
mtimeMs, suppressedReason ? "low" : "high", suppressedReason, { encoding: d.encoding });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Feature 2: split-line boundary join. A finding here means one credential
|
|
213
|
+
// was split across this line and the next and is contiguous on neither. It
|
|
214
|
+
// is recorded against BOTH contributing lines (each holds a fragment of the
|
|
215
|
+
// exposed secret) and carries a `spanLines` marker. `contentA`/`contentB`
|
|
216
|
+
// are the two lines' content projections, computed once per line by the
|
|
217
|
+
// caller and reused across both of a line's pairs.
|
|
218
|
+
const boundaryPair = (contentA, contentB, file, relFile, lineNoA, mtimeMs) => {
|
|
219
|
+
for (const b of findBoundaryMatches(contentA, contentB, rules)) {
|
|
220
|
+
const suppressedReason = suppressionReason(b.value, null);
|
|
221
|
+
if (suppressedReason && !includeSuppressed) {
|
|
222
|
+
// One straddling match is one suppressed match, even though an
|
|
223
|
+
// unsuppressed one records against both contributing lines.
|
|
224
|
+
suppressedCount++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const span = [lineNoA, lineNoA + 1];
|
|
228
|
+
const conf = suppressedReason ? "low" : b.confidence;
|
|
229
|
+
record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA, mtimeMs, conf, suppressedReason, { spanLines: span });
|
|
230
|
+
record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA + 1, mtimeMs, conf, suppressedReason, { spanLines: span });
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
84
234
|
for (const source of sources) {
|
|
85
235
|
let sourceScannedAnything = false;
|
|
86
236
|
|
|
@@ -134,8 +284,43 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
134
284
|
bytesScanned += bytesRead || sizeBytes || 0;
|
|
135
285
|
|
|
136
286
|
const relFile = { name: safeName(file), source: source.id() };
|
|
287
|
+
// Content projection of the PREVIOUS line, kept so each line is
|
|
288
|
+
// projected once and reused for both pairs it belongs to.
|
|
289
|
+
let prevContent = null;
|
|
290
|
+
// Per-file degradation flag, surfaced at most once so a pathological
|
|
291
|
+
// file produces one visible entry, not thousands.
|
|
292
|
+
let lineMatchFailed = false;
|
|
137
293
|
for (let i = 0; i < lines.length; i++) {
|
|
138
|
-
|
|
294
|
+
const line = lines[i];
|
|
295
|
+
if (line) {
|
|
296
|
+
// A rule regex itself can throw on adversarial input: V8's
|
|
297
|
+
// backtrack stack overflows (RangeError) when an open-ended
|
|
298
|
+
// quantifier meets a prefix followed by a multi-megabyte
|
|
299
|
+
// same-charset run — real transcripts contain such lines. One
|
|
300
|
+
// unmatched line must degrade to a visible per-file flag, never
|
|
301
|
+
// abort the scan and discard every finding already collected
|
|
302
|
+
// (same contract as the readLines catch above).
|
|
303
|
+
try {
|
|
304
|
+
matchLine(line, file, relFile, i + 1, mtimeMs);
|
|
305
|
+
decodeLine(line, file, relFile, i + 1, mtimeMs);
|
|
306
|
+
const content = contentProjection(line);
|
|
307
|
+
// Boundary join with the previous line (2-way splits only; see
|
|
308
|
+
// decode.js). Both lines must be non-empty so a blank separator
|
|
309
|
+
// never forms a spurious pair.
|
|
310
|
+
if (prevContent !== null) {
|
|
311
|
+
boundaryPair(prevContent, content, file, relFile, i, mtimeMs);
|
|
312
|
+
}
|
|
313
|
+
prevContent = content;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (!lineMatchFailed) {
|
|
316
|
+
lineMatchFailed = true;
|
|
317
|
+
unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
|
|
318
|
+
}
|
|
319
|
+
prevContent = null;
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
prevContent = null;
|
|
323
|
+
}
|
|
139
324
|
}
|
|
140
325
|
}
|
|
141
326
|
|
|
@@ -160,4 +345,7 @@ function emptyResult() {
|
|
|
160
345
|
};
|
|
161
346
|
}
|
|
162
347
|
|
|
163
|
-
|
|
348
|
+
// VENDOR_EXAMPLE_VALUES is exported for the smoke tests, which assert every
|
|
349
|
+
// literal in it is still matched IN FULL by some detection rule — a literal
|
|
350
|
+
// no rule can produce as a whole match is dead weight that suppresses nothing.
|
|
351
|
+
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* project
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
100
|
-
* would double-report every finding.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|