residoo 0.4.0 → 0.4.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
@@ -122,10 +122,10 @@ won't be built into the tool that writes it.
122
122
  preview, never the real value, including in `--json` mode. A decoded or
123
123
  rejoined secret is redacted exactly like a plain one.
124
124
  - On an interactive terminal, prints who it is and where it lives before
125
- scanning starts (`residoo v0.4.0 · find secrets your AI coding agent left
125
+ scanning starts (`residoo v0.4.1 · find secrets your AI coding agent left
126
126
  on disk` plus the repo URL), then a live spinner naming the current file
127
127
  as it scans. Every report also opens with the exact version and timestamp
128
- it was run with (`residoo v0.4.0 · scanned 2026-01-01 12:00`; `--json`
128
+ it was run with (`residoo v0.4.1 · scanned 2026-01-01 12:00`; `--json`
129
129
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
130
130
  screenshotted later never leaves you guessing which build produced it.
131
131
  When there are findings, the report closes with a "Next steps" pointer to
@@ -312,6 +312,17 @@ with the way out:
312
312
  report hundreds of raw findings that are really a handful of distinct
313
313
  values echoed repeatedly; the summary is built around what's actually left
314
314
  to triage, not the raw count.
315
+ - **The rotation list is grouped by credential type**, so the rotation URL
316
+ prints once per type instead of once per finding. Each distinct value's own
317
+ line shows its redacted preview, which file it's in, and when it was last
318
+ seen in a transcript, not just a bare fingerprint. "Last seen" is exactly
319
+ that: the most recent transcript occurrence residoo found, not proof a
320
+ credential is still live or that an older value was rotated. Most
321
+ credential formats (AWS access keys, vendor API tokens) carry no shared
322
+ identifier linking a rotated key to its predecessor, and residoo makes no
323
+ network calls to ask the provider, so two distinct pending values of the
324
+ same type are always shown as two separate lines, never collapsed on a
325
+ guess.
315
326
  - **Order matters, and the report says so when it does.** The ChainDrop
316
327
  campaign (Aug 2026) shipped a token monitor that fires an attacker payload
317
328
  the moment the stolen GitHub token is revoked. When one scan finds both
@@ -338,7 +349,7 @@ As a GitHub Action (this repository doubles as a composite action):
338
349
  ```yaml
339
350
  steps:
340
351
  - uses: actions/checkout@v4
341
- - uses: dandovdub/residoo@v0.4.0
352
+ - uses: dandovdub/residoo@v0.4.1
342
353
  ```
343
354
 
344
355
  As a pre-commit hook:
@@ -346,7 +357,7 @@ As a pre-commit hook:
346
357
  ```yaml
347
358
  repos:
348
359
  - repo: https://github.com/dandovdub/residoo
349
- rev: v0.4.0
360
+ rev: v0.4.1
350
361
  hooks:
351
362
  - id: residoo
352
363
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.0",
3
+ "version": "0.4.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/report.js CHANGED
@@ -155,30 +155,88 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
155
155
  push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
156
156
  for (const l of wrapped.slice(1)) push(` ${paint(c.red, l)}`);
157
157
  }
158
- // Same anti-flood policy as the by-file table: a report is a summary, not a
159
- // dump. Everything elided here is in --json in full.
160
- const MAX_SHOWN = 12;
161
- const shown = entries.slice(0, MAX_SHOWN);
158
+ // Grouped by rule, not one row per finding: with several distinct values
159
+ // of the same credential type pending, the old flat list repeated the
160
+ // exact same rotation URL once per finding, all noise, no signal.
161
+ // Guidance now prints once per credential TYPE; what actually differs
162
+ // between two findings of the same type is which value and where, so
163
+ // that's what each row shows: the redacted preview (the one piece of
164
+ // information that lets a reader tell "this looks like my prod key" from
165
+ // "this looks like the placeholder ending in HERE" without cross-checking
166
+ // anything else) and how many files it's in, with the fingerprint kept
167
+ // but de-emphasized, still there for `ack`/`dismiss` but no longer the
168
+ // only thing on the line.
169
+ const groups = new Map(); // ruleId -> { label, entries: [] }
170
+ for (const e of entries) {
171
+ if (!groups.has(e.ruleId)) groups.set(e.ruleId, { label: e.label, entries: [] });
172
+ groups.get(e.ruleId).entries.push(e);
173
+ }
174
+ // Group order: any group with at least one pending entry first (matches
175
+ // the report's own "what needs attention" priority throughout), fully
176
+ // resolved groups after, alphabetical by label within each tier.
177
+ const groupList = [...groups.values()].sort((a, b) => {
178
+ const aPending = a.entries.some((e) => e.status === "pending");
179
+ const bPending = b.entries.some((e) => e.status === "pending");
180
+ if (aPending !== bPending) return aPending ? -1 : 1;
181
+ return a.label < b.label ? -1 : 1;
182
+ });
183
+
162
184
  const STATUS_TAG = {
163
185
  pending: paint(c.yellow, "pending "),
164
186
  acked: paint(c.green, "acked "),
165
187
  dismissed: paint(c.dim, "dismissed"),
166
188
  };
167
- for (const e of shown) {
168
- push(` ${STATUS_TAG[e.status]} ${e.fingerprint} ${e.label}`);
169
- if (e.status === "acked") {
170
- push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""}`));
171
- } else if (e.status === "dismissed") {
172
- push(paint(c.dim, ` dismissed ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} (not a real secret)`));
173
- } else {
174
- const g = e.guidance;
175
- const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
176
- push(paint(c.dim, ` ${where}`));
189
+ // Same anti-flood policy as the by-file table: a report is a summary, not
190
+ // a dump. Everything elided here is in --json in full. Counted in
191
+ // individual findings, not groups, so the cap means the same thing here
192
+ // as it always has.
193
+ const MAX_SHOWN = 12;
194
+ let shownCount = 0;
195
+ let elided = 0;
196
+ for (const g of groupList) {
197
+ if (shownCount >= MAX_SHOWN) { elided += g.entries.length; continue; }
198
+ push();
199
+ const g0 = g.entries[0];
200
+ const where = g0.guidance.rotateUrl ? `rotate: ${g0.guidance.rotateUrl}` : `where: ${g0.guidance.consolePath}`;
201
+ push(` ${paint(c.bold, g.label)}`);
202
+ push(paint(c.dim, ` ${where}`));
203
+ for (const e of g.entries) {
204
+ if (shownCount >= MAX_SHOWN) { elided++; continue; }
205
+ shownCount++;
206
+ // e.files always has exactly one entry: the fingerprint is derived
207
+ // from this same basename (see fingerprintFinding in rotation.js), so
208
+ // two findings only ever merge into one entry when they share it. Same
209
+ // discipline as the "By file:" table: a filename is attacker-
210
+ // controllable text (a hostile --project checkout picks its own
211
+ // names), so it goes through safeBasename before it ever reaches the
212
+ // terminal, control bytes stripped, invisible code points made visible.
213
+ const fileNote = safeBasename(e.files[0]);
214
+ // Last SEEN, not last used or last rotated: the most recent transcript
215
+ // occurrence residoo found, nothing more. There is no reliable way to
216
+ // tell from a local scan whether an older value of the same rule type
217
+ // was superseded by a newer one, both because most credential formats
218
+ // (AWS access keys, vendor API tokens) carry no shared identifier
219
+ // linking a rotated key to its predecessor, and because residoo makes
220
+ // no network calls to ask the provider. Two distinct pending values of
221
+ // the same type are shown as two separate lines on purpose, not
222
+ // collapsed on a guess.
223
+ const lastSeenNote = typeof e.lastSeenMs === "number" ? `last seen ~${ageDays(e.lastSeenMs)}d ago` : null;
224
+ push(` ${STATUS_TAG[e.status]} ${e.preview} ${paint(c.dim, fileNote)}` +
225
+ (lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : ""));
226
+ if (e.status === "acked") {
227
+ push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} · ${e.fingerprint}`));
228
+ } else if (e.status === "dismissed") {
229
+ push(paint(c.dim, ` dismissed ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} (not a real secret) · ${e.fingerprint}`));
230
+ } else {
231
+ push(paint(c.dim, ` ${e.fingerprint}`));
232
+ }
177
233
  }
178
234
  }
179
- if (entries.length > shown.length) {
180
- push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
235
+ if (elided > 0) {
236
+ push();
237
+ push(paint(c.dim, ` … and ${elided} more; see --json for the full list`));
181
238
  }
239
+ push();
182
240
  push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · rotated: residoo ack <fp> · not a secret: residoo dismiss <fp>`));
183
241
  return lines.join("\n");
184
242
  }
package/src/rotation.js CHANGED
@@ -888,6 +888,7 @@ function renderRotation(findings, acks, dismissed = {}) {
888
888
  status: st.status,
889
889
  ackedAt: st.ackedAt,
890
890
  ackNote: st.ackNote,
891
+ lastSeenMs: null,
891
892
  };
892
893
  byFp.set(st.fingerprint, e);
893
894
  }
@@ -896,6 +897,14 @@ function renderRotation(findings, acks, dismissed = {}) {
896
897
  if (rel && !e.files.includes(rel)) e.files.push(rel);
897
898
  const src = f.source != null ? String(f.source) : null;
898
899
  if (src && !e.sources.includes(src)) e.sources.push(src);
900
+ // Most recent occurrence across all files this value showed up in: the
901
+ // honest, locally-derivable signal for "how stale is this." NOT proof a
902
+ // credential was rotated or revoked, only that residoo hasn't seen it
903
+ // paste anywhere more recently than this. residoo makes no network
904
+ // calls, so it never checks a provider for whether a key is still live.
905
+ if (typeof f.fileMTimeMs === "number" && (e.lastSeenMs === null || f.fileMTimeMs > e.lastSeenMs)) {
906
+ e.lastSeenMs = f.fileMTimeMs;
907
+ }
899
908
  }
900
909
 
901
910
  const entries = [...byFp.values()].sort((a, b) => {