residoo 0.3.9 → 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 +31 -11
- package/package.json +1 -1
- package/src/cli.js +67 -20
- package/src/report.js +109 -16
- package/src/rotation.js +133 -65
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.
|
|
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.
|
|
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
|
|
@@ -297,12 +297,32 @@ with the way out:
|
|
|
297
297
|
type: where to revoke, the steps, and what revocation actually does at
|
|
298
298
|
that vendor. `residoo explain --list` shows the whole catalogue.
|
|
299
299
|
- **`residoo ack <fingerprint>`** records that you rotated one finding.
|
|
300
|
+
**`residoo dismiss <fingerprint>`** records that you determined it was
|
|
301
|
+
never a real secret (a test fixture, a vendor example not already
|
|
302
|
+
recognized), a separate resolution from ack, since nothing was rotated.
|
|
300
303
|
Every finding carries a stable fingerprint (derived only from
|
|
301
304
|
already-redacted material, so the ledger can never leak), shown in the
|
|
302
|
-
report and in `--json`.
|
|
303
|
-
next scan instead of re-alarming forever.
|
|
304
|
-
`~/.residoo/rotations.json`: residoo's own file, written
|
|
305
|
-
notes redacted through the same pipeline as previews.
|
|
305
|
+
report and in `--json`. Acked and dismissed findings are both reported as
|
|
306
|
+
resolved on the next scan instead of re-alarming forever. Both live in the
|
|
307
|
+
same ledger at `~/.residoo/rotations.json`: residoo's own file, written
|
|
308
|
+
atomically, notes redacted through the same pipeline as previews.
|
|
309
|
+
- **A "Recommended actions" summary leads the report**, before the detailed
|
|
310
|
+
findings breakdown: how many *distinct* values still need a decision,
|
|
311
|
+
versus how many are already resolved. A machine with a lot of history can
|
|
312
|
+
report hundreds of raw findings that are really a handful of distinct
|
|
313
|
+
values echoed repeatedly; the summary is built around what's actually left
|
|
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.
|
|
306
326
|
- **Order matters, and the report says so when it does.** The ChainDrop
|
|
307
327
|
campaign (Aug 2026) shipped a token monitor that fires an attacker payload
|
|
308
328
|
the moment the stolen GitHub token is revoked. When one scan finds both
|
|
@@ -310,9 +330,9 @@ with the way out:
|
|
|
310
330
|
the planted persistence first and rotate second, because "rotate
|
|
311
331
|
everything now" advice can itself trigger the damage.
|
|
312
332
|
|
|
313
|
-
Acks change what the report says, never what CI does:
|
|
314
|
-
fails on every finding,
|
|
315
|
-
`--allow-acked` (integrity warnings always fail either way).
|
|
333
|
+
Acks and dismissals change what the report says, never what CI does:
|
|
334
|
+
`--fail-on-find` fails on every finding, resolved or not, unless you
|
|
335
|
+
explicitly pass `--allow-acked` (integrity warnings always fail either way).
|
|
316
336
|
|
|
317
337
|
## CI and pre-commit
|
|
318
338
|
|
|
@@ -329,7 +349,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
329
349
|
```yaml
|
|
330
350
|
steps:
|
|
331
351
|
- uses: actions/checkout@v4
|
|
332
|
-
- uses: dandovdub/residoo@v0.
|
|
352
|
+
- uses: dandovdub/residoo@v0.4.1
|
|
333
353
|
```
|
|
334
354
|
|
|
335
355
|
As a pre-commit hook:
|
|
@@ -337,7 +357,7 @@ As a pre-commit hook:
|
|
|
337
357
|
```yaml
|
|
338
358
|
repos:
|
|
339
359
|
- repo: https://github.com/dandovdub/residoo
|
|
340
|
-
rev: v0.
|
|
360
|
+
rev: v0.4.1
|
|
341
361
|
hooks:
|
|
342
362
|
- id: residoo
|
|
343
363
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "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/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ const { scan, emptyResult } = require("./scan");
|
|
|
8
8
|
const { render, renderIntegrity, renderJson, renderSarif, makeProgressReporter, printIntro } = require("./report");
|
|
9
9
|
const { checkIntegrity } = require("./integrity");
|
|
10
10
|
const {
|
|
11
|
-
ROTATION_GUIDANCE, guidanceFor, loadAcks, ackFinding, renderRotation,
|
|
11
|
+
ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
|
|
12
12
|
} = require("./rotation");
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -48,9 +48,11 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
|
|
|
48
48
|
path, verified against their docs, because a leaked key that is found but
|
|
49
49
|
never rotated is still leaked (64% of leaked secrets stay valid for years).
|
|
50
50
|
"residoo explain <rule-id>" prints the full runbook for one credential
|
|
51
|
-
type; "residoo ack <fingerprint>" records that you rotated one,
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
type; "residoo ack <fingerprint>" records that you rotated one, and
|
|
52
|
+
"residoo dismiss <fingerprint>" records that you determined it was never a
|
|
53
|
+
real secret (a test fixture, a vendor example not already recognized).
|
|
54
|
+
Both are recorded in ~/.residoo/rotations.json, the only file residoo
|
|
55
|
+
ever writes outside an explicit --seal.
|
|
54
56
|
|
|
55
57
|
Scanning makes NO network calls and changes nothing on disk. Findings are
|
|
56
58
|
redacted in every output format. Sealing (--seal) writes NEW encrypted
|
|
@@ -60,6 +62,7 @@ Usage:
|
|
|
60
62
|
residoo scan [options]
|
|
61
63
|
residoo explain <rule-id> (or: residoo explain --list)
|
|
62
64
|
residoo ack <fingerprint> [--note <text>]
|
|
65
|
+
residoo dismiss <fingerprint> [--note <text>]
|
|
63
66
|
residoo unseal <vault-dir> [--restore <n> --out <path>]
|
|
64
67
|
|
|
65
68
|
Scan options:
|
|
@@ -85,23 +88,29 @@ Scan options:
|
|
|
85
88
|
findings and integrity WARNINGS count; integrity
|
|
86
89
|
info-level review items do not
|
|
87
90
|
--allow-acked with --fail-on-find: findings whose fingerprint was
|
|
88
|
-
acknowledged via "residoo ack"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
acknowledged via "residoo ack" OR dismissed via
|
|
92
|
+
"residoo dismiss" no longer fail the run; pending
|
|
93
|
+
findings and integrity warnings still do. Without
|
|
94
|
+
this flag, --fail-on-find fails on every finding,
|
|
95
|
+
acked, dismissed, or not.
|
|
92
96
|
--no-integrity skip the integrity checks (planted hooks, dropper
|
|
93
97
|
files, auto-run tasks, hidden Unicode)
|
|
94
98
|
--no-color disable ANSI colour
|
|
95
99
|
|
|
96
100
|
Rotation:
|
|
97
|
-
residoo explain <rule-id>
|
|
98
|
-
|
|
99
|
-
residoo explain --list
|
|
100
|
-
residoo ack <fingerprint>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
102
|
+
(where to revoke, steps, what revocation does)
|
|
103
|
+
residoo explain --list every rule id with its credential label
|
|
104
|
+
residoo ack <fingerprint> mark one finding's rotation done; fingerprints
|
|
105
|
+
appear next to findings in the report and in
|
|
106
|
+
--json. Optional --note <text> is stored with
|
|
107
|
+
the acknowledgement (redacted if it matches a
|
|
108
|
+
secret pattern).
|
|
109
|
+
residoo dismiss <fingerprint> mark one finding as reviewed and NOT a real
|
|
110
|
+
secret (a test fixture, a vendor example not
|
|
111
|
+
already recognized, etc.), distinct from ack:
|
|
112
|
+
nothing was rotated, there was nothing to
|
|
113
|
+
rotate. Same --note handling as ack.
|
|
105
114
|
|
|
106
115
|
Seal options (used with scan):
|
|
107
116
|
--seal after scanning, encrypt every transcript that carried a
|
|
@@ -367,6 +376,41 @@ function runAck(args) {
|
|
|
367
376
|
return 0;
|
|
368
377
|
}
|
|
369
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Record that one finding was reviewed and determined NOT to be a real
|
|
381
|
+
* secret (a test fixture, a value used to verify residoo's own detection,
|
|
382
|
+
* a vendor example not already on the built-in suppression list, etc.) —
|
|
383
|
+
* distinct from `ack`, which means "I rotated a real credential." Without
|
|
384
|
+
* this, the only way to stop a confirmed-fake finding from reappearing
|
|
385
|
+
* every scan was to `ack` it, which is semantically wrong (nothing was
|
|
386
|
+
* rotated) and reads misleadingly in the rotation ledger.
|
|
387
|
+
*/
|
|
388
|
+
function runDismiss(args) {
|
|
389
|
+
const fp = args[1] && !args[1].startsWith("--") ? args[1] : null;
|
|
390
|
+
if (!fp) {
|
|
391
|
+
process.stderr.write("usage: residoo dismiss <fingerprint> [--note <text>]\n" +
|
|
392
|
+
"Fingerprints (rf1-...) are shown next to findings in the scan report and in --json.\n");
|
|
393
|
+
return 2;
|
|
394
|
+
}
|
|
395
|
+
let res;
|
|
396
|
+
try {
|
|
397
|
+
res = dismissFinding(fp, argValue(args, "--note"));
|
|
398
|
+
} catch (err) {
|
|
399
|
+
process.stderr.write(`residoo: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
400
|
+
return 2;
|
|
401
|
+
}
|
|
402
|
+
process.stdout.write(
|
|
403
|
+
`Dismissed ${res.fingerprint} at ${res.at}` +
|
|
404
|
+
(res.note ? ` with note: ${res.note}` : "") + "\n" +
|
|
405
|
+
"The next scan reports this finding as dismissed instead of pending, and it is\n" +
|
|
406
|
+
"excluded from --fail-on-find the same way an acked finding is with --allow-acked.\n" +
|
|
407
|
+
"(dismiss is stateless, same as ack: it cannot check this fingerprint against a\n" +
|
|
408
|
+
"scan, so a mistyped one is recorded too; the intended finding would then still\n" +
|
|
409
|
+
"show as pending on the next scan.)\n"
|
|
410
|
+
);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
370
414
|
async function main(argv) {
|
|
371
415
|
const args = argv.slice(2);
|
|
372
416
|
if (args.includes("-h") || args.includes("--help") || args.length === 0) {
|
|
@@ -378,6 +422,7 @@ async function main(argv) {
|
|
|
378
422
|
if (cmd === "unseal") return runUnseal(args);
|
|
379
423
|
if (cmd === "explain") return runExplain(args);
|
|
380
424
|
if (cmd === "ack") return runAck(args);
|
|
425
|
+
if (cmd === "dismiss") return runDismiss(args);
|
|
381
426
|
if (cmd !== "scan") {
|
|
382
427
|
process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
|
|
383
428
|
return 2;
|
|
@@ -467,9 +512,11 @@ async function main(argv) {
|
|
|
467
512
|
sources = availableSources();
|
|
468
513
|
}
|
|
469
514
|
|
|
470
|
-
// loadAcks
|
|
471
|
-
// so a broken
|
|
515
|
+
// loadAcks/loadDismissed degrade to {} (loudly, on stderr) if the state
|
|
516
|
+
// file is corrupt, so a broken rotation ledger can never block or distort
|
|
517
|
+
// a scan.
|
|
472
518
|
const acks = loadAcks();
|
|
519
|
+
const dismissed = loadDismissed();
|
|
473
520
|
|
|
474
521
|
if (sources.length === 0) {
|
|
475
522
|
const empty = emptyResult();
|
|
@@ -482,7 +529,7 @@ async function main(argv) {
|
|
|
482
529
|
// A --json caller (CI, a script piping into jq) must always get valid JSON
|
|
483
530
|
// on stdout, even on the "nothing to scan" path — a plain-text message on
|
|
484
531
|
// stderr with exit 0 silently breaks that contract.
|
|
485
|
-
process.stdout.write(renderJson(empty, integrity, renderRotation([], acks)) + "\n");
|
|
532
|
+
process.stdout.write(renderJson(empty, integrity, renderRotation([], acks, dismissed)) + "\n");
|
|
486
533
|
} else {
|
|
487
534
|
process.stderr.write(
|
|
488
535
|
"No known transcript sources found on this machine.\n" +
|
|
@@ -499,7 +546,7 @@ async function main(argv) {
|
|
|
499
546
|
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
|
|
500
547
|
progress.stop();
|
|
501
548
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
502
|
-
const rotation = renderRotation(result.findings, acks);
|
|
549
|
+
const rotation = renderRotation(result.findings, acks, dismissed);
|
|
503
550
|
process.stdout.write((wantsSarif
|
|
504
551
|
? renderSarif(result)
|
|
505
552
|
: wantsJson
|
package/src/report.js
CHANGED
|
@@ -143,33 +143,101 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
143
143
|
// raw values, while these entries dedupe fingerprints (which include the
|
|
144
144
|
// basename, so one value in two differently-named files is two rotations to
|
|
145
145
|
// track). Two counts under one word would read as a contradiction.
|
|
146
|
+
const resolvedNote = [
|
|
147
|
+
counts.acked > 0 ? `${counts.acked} acknowledged` : null,
|
|
148
|
+
counts.dismissed > 0 ? `${counts.dismissed} dismissed` : null,
|
|
149
|
+
].filter(Boolean).join(", ");
|
|
146
150
|
push(paint(c.bold, "Rotation:") +
|
|
147
151
|
` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
|
|
148
|
-
(
|
|
152
|
+
(resolvedNote ? ` (${resolvedNote})` : ""));
|
|
149
153
|
if (showAdvisory) {
|
|
150
154
|
const wrapped = wrapText(ROTATION_ORDER_ADVISORY, 72, " ");
|
|
151
155
|
push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
|
|
152
156
|
for (const l of wrapped.slice(1)) push(` ${paint(c.red, l)}`);
|
|
153
157
|
}
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
+
|
|
184
|
+
const STATUS_TAG = {
|
|
185
|
+
pending: paint(c.yellow, "pending "),
|
|
186
|
+
acked: paint(c.green, "acked "),
|
|
187
|
+
dismissed: paint(c.dim, "dismissed"),
|
|
188
|
+
};
|
|
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.
|
|
156
193
|
const MAX_SHOWN = 12;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}
|
|
167
233
|
}
|
|
168
234
|
}
|
|
169
|
-
if (
|
|
170
|
-
push(
|
|
235
|
+
if (elided > 0) {
|
|
236
|
+
push();
|
|
237
|
+
push(paint(c.dim, ` … and ${elided} more; see --json for the full list`));
|
|
171
238
|
}
|
|
172
|
-
push(
|
|
239
|
+
push();
|
|
240
|
+
push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · rotated: residoo ack <fp> · not a secret: residoo dismiss <fp>`));
|
|
173
241
|
return lines.join("\n");
|
|
174
242
|
}
|
|
175
243
|
|
|
@@ -287,6 +355,31 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
287
355
|
push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
|
|
288
356
|
push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
|
|
289
357
|
if (unreadableNote) push(unreadableNote);
|
|
358
|
+
|
|
359
|
+
// The practical, act-on-this-now summary, first, before the full
|
|
360
|
+
// rule-by-rule and file-by-file detail below: a raw count of findings
|
|
361
|
+
// (which can run into the hundreds on a machine with a lot of history) is
|
|
362
|
+
// not by itself a to-do list. What actually needs a person's attention is
|
|
363
|
+
// the count of DISTINCT values not yet triaged (rotated or dismissed) —
|
|
364
|
+
// everything else is either already handled or a re-exposure of a value
|
|
365
|
+
// already accounted for.
|
|
366
|
+
if (rotation && rotation.counts.distinct > 0) {
|
|
367
|
+
const { pending, distinct, acked, dismissed } = rotation.counts;
|
|
368
|
+
push();
|
|
369
|
+
push(paint(c.bold, "Recommended actions:"));
|
|
370
|
+
if (pending > 0) {
|
|
371
|
+
push(` ${paint(c.yellow, "→")} ${pending} of ${distinct} distinct value${distinct === 1 ? "" : "s"} ${pending === 1 ? "needs" : "need"} review: rotate the real ones (residoo ack), dismiss the rest (residoo dismiss)`);
|
|
372
|
+
} else {
|
|
373
|
+
push(` ${paint(c.green, "✓")} Nothing new to review; every distinct value here has already been triaged`);
|
|
374
|
+
}
|
|
375
|
+
const resolvedParts = [
|
|
376
|
+
acked > 0 ? `${acked} acknowledged` : null,
|
|
377
|
+
dismissed > 0 ? `${dismissed} dismissed` : null,
|
|
378
|
+
].filter(Boolean);
|
|
379
|
+
if (resolvedParts.length > 0) {
|
|
380
|
+
push(paint(c.dim, ` ${resolvedParts.join(", ")} already, no action needed (see Rotation below for which)`));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
290
383
|
push();
|
|
291
384
|
|
|
292
385
|
const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
|
package/src/rotation.js
CHANGED
|
@@ -668,47 +668,86 @@ function statePath() {
|
|
|
668
668
|
}
|
|
669
669
|
|
|
670
670
|
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
671
|
+
* Parse one map (acks, or dismissed) out of the already-JSON-parsed state
|
|
672
|
+
* file body. Shared by loadFullState() for both keys: same validation, same
|
|
673
|
+
* per-entry degrade-not-discard behavior, so a hand-edited or foreign
|
|
674
|
+
* ledger loses only its malformed entries, never the whole file.
|
|
675
|
+
*/
|
|
676
|
+
function parseFpMap(raw) {
|
|
677
|
+
const out = {};
|
|
678
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
679
|
+
for (const [fp, v] of Object.entries(raw)) {
|
|
680
|
+
if (!FINGERPRINT_RE.test(fp)) continue;
|
|
681
|
+
if (!v || typeof v !== "object") continue;
|
|
682
|
+
// Control bytes stripped on READ as well as on write: this file sits on
|
|
683
|
+
// disk between the two, and a hand-edited or foreign ledger must not be
|
|
684
|
+
// able to put a terminal escape into the report via a note.
|
|
685
|
+
out[fp] = {
|
|
686
|
+
at: typeof v.at === "string" ? stripControlChars(v.at) : null,
|
|
687
|
+
note: typeof v.note === "string" ? stripControlChars(v.note) : null,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Load the full rotation state: { acks, dismissed }, both shaped
|
|
695
|
+
* { "<fingerprint>": { at, note } }. Missing file is the normal first-run
|
|
696
|
+
* case and returns both empty silently. A corrupt or unreadable file
|
|
697
|
+
* returns both empty too, but LOUDLY: one note on stderr, because "your
|
|
698
|
+
* acks are gone" must never be silent, and because the next write will
|
|
675
699
|
* start a fresh store over the corrupt one.
|
|
676
700
|
*/
|
|
677
|
-
function
|
|
701
|
+
function loadFullState({ file = statePath() } = {}) {
|
|
702
|
+
const empty = { acks: {}, dismissed: {} };
|
|
678
703
|
let text;
|
|
679
704
|
try {
|
|
680
705
|
text = fs.readFileSync(file, "utf-8");
|
|
681
706
|
} catch (err) {
|
|
682
|
-
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return
|
|
683
|
-
process.stderr.write(`residoo: rotation state ${path.basename(file)} could not be read; continuing with no acknowledgements\n`);
|
|
684
|
-
return
|
|
707
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return empty;
|
|
708
|
+
process.stderr.write(`residoo: rotation state ${path.basename(file)} could not be read; continuing with no acknowledgements or dismissals\n`);
|
|
709
|
+
return empty;
|
|
685
710
|
}
|
|
686
711
|
let parsed;
|
|
687
712
|
try {
|
|
688
713
|
parsed = JSON.parse(text);
|
|
689
714
|
} catch {
|
|
690
|
-
process.stderr.write(`residoo: rotation state ${path.basename(file)} is corrupt; continuing with no acknowledgements (
|
|
691
|
-
return
|
|
715
|
+
process.stderr.write(`residoo: rotation state ${path.basename(file)} is corrupt; continuing with no acknowledgements or dismissals (the next write will start a fresh store)\n`);
|
|
716
|
+
return empty;
|
|
692
717
|
}
|
|
693
|
-
if (!parsed || typeof parsed !== "object" || parsed.v !== 1
|
|
694
|
-
process.stderr.write(`residoo: rotation state ${path.basename(file)} has an unrecognized shape; continuing with no acknowledgements\n`);
|
|
695
|
-
return
|
|
718
|
+
if (!parsed || typeof parsed !== "object" || parsed.v !== 1) {
|
|
719
|
+
process.stderr.write(`residoo: rotation state ${path.basename(file)} has an unrecognized shape; continuing with no acknowledgements or dismissals\n`);
|
|
720
|
+
return empty;
|
|
696
721
|
}
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
722
|
+
// dismissed is a later addition to this same file (v stays 1: additive,
|
|
723
|
+
// tolerant of a file written by an older residoo that never had this key).
|
|
724
|
+
return { acks: parseFpMap(parsed.acks), dismissed: parseFpMap(parsed.dismissed) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Backward-compatible: the acks half of loadFullState(), same call shape as before dismiss existed. */
|
|
728
|
+
function loadAcks({ file = statePath() } = {}) {
|
|
729
|
+
return loadFullState({ file }).acks;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The dismissed half of loadFullState(). */
|
|
733
|
+
function loadDismissed({ file = statePath() } = {}) {
|
|
734
|
+
return loadFullState({ file }).dismissed;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Atomic write of the full state: temp file in the same directory, then rename; 0o600/0o700, same as before. */
|
|
738
|
+
function writeFullState(file, { acks, dismissed }) {
|
|
739
|
+
const dir = path.dirname(file);
|
|
740
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
741
|
+
const tmp = path.join(dir, `.rotations.json.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
|
|
742
|
+
const body = JSON.stringify({ v: 1, acks, dismissed }, null, 2) + "\n";
|
|
743
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
744
|
+
try {
|
|
745
|
+
fs.renameSync(tmp, file);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
// The rename failing must not strand a temp file next to the state.
|
|
748
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
749
|
+
throw err;
|
|
710
750
|
}
|
|
711
|
-
return acks;
|
|
712
751
|
}
|
|
713
752
|
|
|
714
753
|
/**
|
|
@@ -732,42 +771,44 @@ function sanitizeNote(note) {
|
|
|
732
771
|
}
|
|
733
772
|
|
|
734
773
|
/**
|
|
735
|
-
* Record that the user acknowledged (rotated
|
|
774
|
+
* Record that the user acknowledged (rotated) one finding, or dismissed it
|
|
775
|
+
* (decided it was never a real secret, a test fixture, a vendor example not
|
|
776
|
+
* already on the suppression list, etc.) — two DIFFERENT resolutions of the
|
|
777
|
+
* same "this is no longer pending" question, kept as separate maps in one
|
|
778
|
+
* file rather than a single status field: acked and dismissed have
|
|
779
|
+
* different guidance implications (an acked finding's guidance stays
|
|
780
|
+
* relevant if you ever need to re-check the rotation; a dismissed one
|
|
781
|
+
* never needed guidance in the first place) and different --fail-on-find
|
|
782
|
+
* semantics may want to diverge later. Shared helper for both:
|
|
783
|
+
* ackFinding(fp, note) / dismissFinding(fp, note).
|
|
784
|
+
*
|
|
736
785
|
* Atomic: temp file in the same directory, then rename; 0o600 on the file,
|
|
737
786
|
* 0o700 on the directory, since even a redacted rotation ledger is nobody
|
|
738
787
|
* else's business.
|
|
739
788
|
*
|
|
740
|
-
* Atomic is not serialized: two concurrent `residoo ack` runs
|
|
741
|
-
* load-modify-write, and the last rename wins, silently dropping the
|
|
742
|
-
* run's
|
|
743
|
-
* one at a time, the ledger is per-user state, and the failure
|
|
744
|
-
* fail-safe (a dropped
|
|
745
|
-
* reverse). A lockfile would add a stale-lock recovery path for
|
|
746
|
-
* a person cannot realistically produce.
|
|
789
|
+
* Atomic is not serialized: two concurrent `residoo ack`/`dismiss` runs
|
|
790
|
+
* each load-modify-write, and the last rename wins, silently dropping the
|
|
791
|
+
* other run's change. Accepted as a single-writer design: these are typed
|
|
792
|
+
* by a human one at a time, the ledger is per-user state, and the failure
|
|
793
|
+
* direction is fail-safe (a dropped entry reverts that finding to pending,
|
|
794
|
+
* never the reverse). A lockfile would add a stale-lock recovery path for
|
|
795
|
+
* a race that a person cannot realistically produce.
|
|
747
796
|
*/
|
|
748
|
-
function
|
|
797
|
+
function resolveFinding(kind, fp, note, { file = statePath() } = {}) {
|
|
749
798
|
if (typeof fp !== "string" || !FINGERPRINT_RE.test(fp)) {
|
|
750
|
-
throw new TypeError(
|
|
799
|
+
throw new TypeError(`${kind}Finding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)`);
|
|
751
800
|
}
|
|
752
|
-
const
|
|
801
|
+
const state = loadFullState({ file });
|
|
753
802
|
const entry = { at: new Date().toISOString(), note: sanitizeNote(note) };
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
758
|
-
const tmp = path.join(dir, `.rotations.json.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
|
|
759
|
-
const body = JSON.stringify({ v: 1, acks }, null, 2) + "\n";
|
|
760
|
-
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
761
|
-
try {
|
|
762
|
-
fs.renameSync(tmp, file);
|
|
763
|
-
} catch (err) {
|
|
764
|
-
// The rename failing must not strand a temp file next to the state.
|
|
765
|
-
try { fs.unlinkSync(tmp); } catch {}
|
|
766
|
-
throw err;
|
|
767
|
-
}
|
|
803
|
+
const key = kind === "ack" ? "acks" : "dismissed";
|
|
804
|
+
state[key][fp] = entry;
|
|
805
|
+
writeFullState(file, state);
|
|
768
806
|
return { fingerprint: fp, ...entry, file };
|
|
769
807
|
}
|
|
770
808
|
|
|
809
|
+
function ackFinding(fp, note, opts) { return resolveFinding("ack", fp, note, opts); }
|
|
810
|
+
function dismissFinding(fp, note, opts) { return resolveFinding("dismiss", fp, note, opts); }
|
|
811
|
+
|
|
771
812
|
// ── summaries for the report layer ──────────────────────────────────────────
|
|
772
813
|
|
|
773
814
|
/**
|
|
@@ -776,42 +817,58 @@ function ackFinding(fp, note, { file = statePath() } = {}) {
|
|
|
776
817
|
* across a transcript are one rotation to do, not five (the same
|
|
777
818
|
* distinct-vs-re-exposed reasoning scan.js applies to counting).
|
|
778
819
|
*/
|
|
779
|
-
function pendingSummary(findings, acks) {
|
|
820
|
+
function pendingSummary(findings, acks, dismissed = {}) {
|
|
780
821
|
const list = Array.isArray(findings) ? findings : [];
|
|
781
822
|
const ackMap = acks && typeof acks === "object" ? acks : {};
|
|
823
|
+
const dismissMap = dismissed && typeof dismissed === "object" ? dismissed : {};
|
|
782
824
|
const statuses = [];
|
|
783
|
-
const
|
|
825
|
+
const distinctStatus = new Map(); // fp -> "acked" | "dismissed" | "pending"
|
|
784
826
|
for (const f of list) {
|
|
785
827
|
const fp = fingerprintFinding(f);
|
|
786
828
|
const ack = ackMap[fp] || null;
|
|
829
|
+
const dismiss = dismissMap[fp] || null;
|
|
830
|
+
// Precedence when a fingerprint somehow has both (not reachable through
|
|
831
|
+
// the CLI today, but the state file is hand-editable): acked wins. "I
|
|
832
|
+
// rotated it" is the more thorough resolution of the two, and reverting
|
|
833
|
+
// to "acked" from a stray dismissed entry is the fail-safe direction —
|
|
834
|
+
// it keeps guidance attached rather than silently dropping a real
|
|
835
|
+
// rotation's record.
|
|
836
|
+
const status = ack ? "acked" : dismiss ? "dismissed" : "pending";
|
|
837
|
+
const resolved = ack || dismiss;
|
|
787
838
|
statuses.push({
|
|
788
839
|
fingerprint: fp,
|
|
789
|
-
status
|
|
790
|
-
ackedAt:
|
|
791
|
-
ackNote:
|
|
840
|
+
status,
|
|
841
|
+
ackedAt: resolved ? resolved.at : null,
|
|
842
|
+
ackNote: resolved ? resolved.note : null,
|
|
792
843
|
});
|
|
793
|
-
if (!
|
|
844
|
+
if (!distinctStatus.has(fp)) distinctStatus.set(fp, status);
|
|
845
|
+
}
|
|
846
|
+
let acked = 0, dismissedCount = 0;
|
|
847
|
+
for (const status of distinctStatus.values()) {
|
|
848
|
+
if (status === "acked") acked++;
|
|
849
|
+
else if (status === "dismissed") dismissedCount++;
|
|
794
850
|
}
|
|
795
|
-
let acked = 0;
|
|
796
|
-
for (const isAcked of distinct.values()) if (isAcked) acked++;
|
|
797
851
|
return {
|
|
798
852
|
counts: {
|
|
799
853
|
findings: list.length,
|
|
800
|
-
distinct:
|
|
801
|
-
pending:
|
|
854
|
+
distinct: distinctStatus.size,
|
|
855
|
+
pending: distinctStatus.size - acked - dismissedCount,
|
|
802
856
|
acked,
|
|
857
|
+
dismissed: dismissedCount,
|
|
803
858
|
},
|
|
804
859
|
statuses,
|
|
805
860
|
};
|
|
806
861
|
}
|
|
807
862
|
|
|
863
|
+
const STATUS_ORDER = { pending: 0, acked: 1, dismissed: 2 };
|
|
864
|
+
|
|
808
865
|
/**
|
|
809
866
|
* Pure data for the report layer: one entry per distinct fingerprint, with
|
|
810
867
|
* rotation guidance attached and pending entries first. Prints nothing.
|
|
811
868
|
*/
|
|
812
|
-
function renderRotation(findings, acks) {
|
|
869
|
+
function renderRotation(findings, acks, dismissed = {}) {
|
|
813
870
|
const list = Array.isArray(findings) ? findings : [];
|
|
814
|
-
const { counts, statuses } = pendingSummary(list, acks);
|
|
871
|
+
const { counts, statuses } = pendingSummary(list, acks, dismissed);
|
|
815
872
|
|
|
816
873
|
const byFp = new Map();
|
|
817
874
|
for (let i = 0; i < list.length; i++) {
|
|
@@ -831,6 +888,7 @@ function renderRotation(findings, acks) {
|
|
|
831
888
|
status: st.status,
|
|
832
889
|
ackedAt: st.ackedAt,
|
|
833
890
|
ackNote: st.ackNote,
|
|
891
|
+
lastSeenMs: null,
|
|
834
892
|
};
|
|
835
893
|
byFp.set(st.fingerprint, e);
|
|
836
894
|
}
|
|
@@ -839,10 +897,18 @@ function renderRotation(findings, acks) {
|
|
|
839
897
|
if (rel && !e.files.includes(rel)) e.files.push(rel);
|
|
840
898
|
const src = f.source != null ? String(f.source) : null;
|
|
841
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
|
+
}
|
|
842
908
|
}
|
|
843
909
|
|
|
844
910
|
const entries = [...byFp.values()].sort((a, b) => {
|
|
845
|
-
if (a.status !== b.status) return a.status
|
|
911
|
+
if (a.status !== b.status) return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
846
912
|
if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
|
|
847
913
|
return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
848
914
|
});
|
|
@@ -857,7 +923,9 @@ module.exports = {
|
|
|
857
923
|
fingerprintFinding,
|
|
858
924
|
statePath,
|
|
859
925
|
loadAcks,
|
|
926
|
+
loadDismissed,
|
|
860
927
|
ackFinding,
|
|
928
|
+
dismissFinding,
|
|
861
929
|
pendingSummary,
|
|
862
930
|
renderRotation,
|
|
863
931
|
};
|