residoo 0.3.1 → 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/package.json +35 -9
- package/src/decode.js +21 -21
- package/src/scan.js +5 -13
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
CHANGED
|
@@ -49,9 +49,14 @@
|
|
|
49
49
|
// is recovered by retrying the decode with ONE edge chunk dropped — but
|
|
50
50
|
// only one, and only at an edge: junk on both edges, or prose merged into
|
|
51
51
|
// the middle of a blob, still loses the whole candidate.
|
|
52
|
-
// -
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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.
|
|
55
60
|
|
|
56
61
|
// A run is made of characters that can appear in base64 or base64url:
|
|
57
62
|
// A-Z a-z 0-9 + / = _ - (see isB64Code). Wrap separators between chunks of
|
|
@@ -74,7 +79,6 @@ const B64_MIN_CHUNK = 4;
|
|
|
74
79
|
|
|
75
80
|
const B64_MIN_CHARS = 24; // fewer chars cannot hide a real credential
|
|
76
81
|
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
82
|
const PRINTABLE_MIN = 0.85; // decoded bytes must be mostly text to rescan
|
|
79
83
|
|
|
80
84
|
/** JSON whitespace escapes -> the real line breaks they stand for. */
|
|
@@ -91,9 +95,10 @@ function normalizeEscapes(line) {
|
|
|
91
95
|
*
|
|
92
96
|
* Each candidate is returned as its ARRAY of chunks, not pre-joined: the
|
|
93
97
|
* decode step needs the chunk boundaries to retry with an edge chunk dropped
|
|
94
|
-
* (see findDecodedMatches).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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.
|
|
97
102
|
*/
|
|
98
103
|
function isB64Code(c) {
|
|
99
104
|
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
|
|
@@ -123,14 +128,13 @@ function splitAtPadding(chunk) {
|
|
|
123
128
|
|
|
124
129
|
function b64Candidates(norm) {
|
|
125
130
|
const candidates = []; // array of chunk arrays
|
|
126
|
-
let truncated = false;
|
|
127
131
|
let current = null; // chunk array of the candidate in progress
|
|
128
132
|
let runStart = -1; // start of the b64 run in progress, -1 when not in one
|
|
129
133
|
let gapClean = true; // gap since the last chunk held only \r \n
|
|
130
134
|
const push = (cand) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
let len = 0;
|
|
136
|
+
for (const c of cand) len += c.length;
|
|
137
|
+
if (len >= B64_MIN_CHARS) candidates.push(cand);
|
|
134
138
|
};
|
|
135
139
|
const n = norm.length;
|
|
136
140
|
for (let i = 0; i <= n; i++) {
|
|
@@ -147,13 +151,13 @@ function b64Candidates(norm) {
|
|
|
147
151
|
for (let p = 0; p < parts.length; p++) {
|
|
148
152
|
if (p === 0 && current !== null && gapClean) current.push(parts[p]);
|
|
149
153
|
else {
|
|
150
|
-
if (current !== null
|
|
154
|
+
if (current !== null) push(current);
|
|
151
155
|
current = [parts[p]];
|
|
152
156
|
}
|
|
153
157
|
// A part ending in padding is a complete value: nothing after it —
|
|
154
158
|
// not even across a clean wrap gap — can belong to the same blob.
|
|
155
159
|
if (parts[p].charCodeAt(parts[p].length - 1) === 61) {
|
|
156
|
-
|
|
160
|
+
push(current);
|
|
157
161
|
current = null;
|
|
158
162
|
}
|
|
159
163
|
}
|
|
@@ -168,7 +172,7 @@ function b64Candidates(norm) {
|
|
|
168
172
|
if (c !== -1 && c !== 10 && c !== 13) gapClean = false;
|
|
169
173
|
}
|
|
170
174
|
if (current !== null) push(current);
|
|
171
|
-
return
|
|
175
|
+
return candidates;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/**
|
|
@@ -211,19 +215,15 @@ function decodeToText(cleaned) {
|
|
|
211
215
|
/**
|
|
212
216
|
* Find credentials that appear only base64-encoded on one line. `rules` MUST
|
|
213
217
|
* be the high-confidence subset (see LIMITS above). Returns
|
|
214
|
-
* { matches, truncated } where matches is
|
|
215
218
|
* [{ ruleId, label, confidence, value, encoding }] with `value` the DECODED
|
|
216
219
|
* secret (caller redacts), deduped by rule+value so a blob echoed twice on
|
|
217
|
-
* one line (content plus tool-result mirror) is one entry
|
|
218
|
-
* is true when the per-line candidate cap left runs unchecked (the caller
|
|
219
|
-
* surfaces that as partial coverage, never silently).
|
|
220
|
+
* one line (content plus tool-result mirror) is one entry.
|
|
220
221
|
*/
|
|
221
222
|
function findDecodedMatches(line, rules) {
|
|
222
223
|
const out = [];
|
|
223
224
|
const seen = new Set();
|
|
224
225
|
const norm = normalizeEscapes(line);
|
|
225
|
-
const
|
|
226
|
-
for (const chunks of candidates) {
|
|
226
|
+
for (const chunks of b64Candidates(norm)) {
|
|
227
227
|
// A wrap-merged candidate can carry one glued-on neighbor token: a word
|
|
228
228
|
// or filename sitting directly above or below the blob across the wrap
|
|
229
229
|
// newline merges into the candidate and breaks the decode (alignment
|
|
@@ -254,7 +254,7 @@ function findDecodedMatches(line, rules) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
-
return
|
|
257
|
+
return out;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
// ── Feature 2: split-line boundary join ─────────────────────────────────────
|
package/src/scan.js
CHANGED
|
@@ -196,12 +196,9 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
196
196
|
// was present only encoded on this line. It redacts from the DECODED value
|
|
197
197
|
// (the encoded run is treated as secret material and never appears in the
|
|
198
198
|
// preview), and carries an `encoding` marker the report renders as
|
|
199
|
-
// "base64-wrapped".
|
|
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.
|
|
199
|
+
// "base64-wrapped".
|
|
202
200
|
const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
|
|
203
|
-
const
|
|
204
|
-
for (const d of matches) {
|
|
201
|
+
for (const d of findDecodedMatches(line, highRules)) {
|
|
205
202
|
const suppressedReason = suppressionReason(d.value, null);
|
|
206
203
|
if (suppressedReason && !includeSuppressed) {
|
|
207
204
|
suppressedCount++;
|
|
@@ -210,7 +207,6 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
210
207
|
record({ id: d.ruleId, label: d.label }, d.value, relFile, file, lineNo,
|
|
211
208
|
mtimeMs, suppressedReason ? "low" : "high", suppressedReason, { encoding: d.encoding });
|
|
212
209
|
}
|
|
213
|
-
return truncated;
|
|
214
210
|
};
|
|
215
211
|
|
|
216
212
|
// Feature 2: split-line boundary join. A finding here means one credential
|
|
@@ -291,10 +287,9 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
291
287
|
// Content projection of the PREVIOUS line, kept so each line is
|
|
292
288
|
// projected once and reused for both pairs it belongs to.
|
|
293
289
|
let prevContent = null;
|
|
294
|
-
// Per-file degradation
|
|
295
|
-
//
|
|
290
|
+
// Per-file degradation flag, surfaced at most once so a pathological
|
|
291
|
+
// file produces one visible entry, not thousands.
|
|
296
292
|
let lineMatchFailed = false;
|
|
297
|
-
let decodeTruncated = false;
|
|
298
293
|
for (let i = 0; i < lines.length; i++) {
|
|
299
294
|
const line = lines[i];
|
|
300
295
|
if (line) {
|
|
@@ -307,7 +302,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
307
302
|
// (same contract as the readLines catch above).
|
|
308
303
|
try {
|
|
309
304
|
matchLine(line, file, relFile, i + 1, mtimeMs);
|
|
310
|
-
|
|
305
|
+
decodeLine(line, file, relFile, i + 1, mtimeMs);
|
|
311
306
|
const content = contentProjection(line);
|
|
312
307
|
// Boundary join with the previous line (2-way splits only; see
|
|
313
308
|
// decode.js). Both lines must be non-empty so a blank separator
|
|
@@ -327,9 +322,6 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
327
322
|
prevContent = null;
|
|
328
323
|
}
|
|
329
324
|
}
|
|
330
|
-
if (decodeTruncated) {
|
|
331
|
-
unreadableFiles.push({ file: safeName(file), reason: "some lines held more encoded runs than the per-line bound; checked partially" });
|
|
332
|
-
}
|
|
333
325
|
}
|
|
334
326
|
|
|
335
327
|
if (sourceScannedAnything) sourcesScanned.push(source.id());
|