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 +15 -4
- package/package.json +1 -1
- package/src/report.js +74 -16
- package/src/rotation.js +9 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
180
|
-
push(
|
|
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) => {
|