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.
Files changed (3) hide show
  1. package/package.json +35 -9
  2. package/src/decode.js +21 -21
  3. package/src/scan.js +5 -13
package/package.json CHANGED
@@ -1,19 +1,45 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.3.1",
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": { "type": "git", "url": "git+https://github.com/dandovdub/residoo.git" },
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": { "url": "https://github.com/dandovdub/residoo/issues" },
10
- "bin": { "residoo": "bin/residoo.js" },
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": { "node": ">=18" },
13
- "scripts": { "test": "node tests/smoke.js" },
14
- "files": ["bin", "src", "README.md", "SECURITY.md", "LICENSE"],
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", "secrets", "secret-scanning", "ai-agent", "claude-code",
17
- "cursor", "copilot", "mcp", "privacy", "cli", "encryption"
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
- // - 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.
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). `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.
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
- if (candidates.length >= B64_MAX_CANDIDATES) { truncated = true; return false; }
132
- candidates.push(cand);
133
- return true;
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 && !push(current)) return { candidates, truncated };
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
- if (!push(current)) return { candidates, truncated };
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 { candidates, truncated };
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, 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
+ * 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 { candidates, truncated } = b64Candidates(norm);
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 { matches: out, truncated };
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". 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.
199
+ // "base64-wrapped".
202
200
  const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
203
- const { matches, truncated } = findDecodedMatches(line, highRules);
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 flags, each surfaced at most once so a
295
- // pathological file produces one visible entry, not thousands.
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
- decodeTruncated = decodeLine(line, file, relFile, i + 1, mtimeMs) || decodeTruncated;
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());