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 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.3.9 · 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.3.9 · 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
@@ -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`. Acknowledged findings are reported as such on the
303
- next scan instead of re-alarming forever. The ledger lives at
304
- `~/.residoo/rotations.json`: residoo's own file, written atomically, ack
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: `--fail-on-find`
314
- fails on every finding, acknowledged or not, unless you explicitly pass
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.3.9
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.3.9
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.9",
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, in
52
- ~/.residoo/rotations.json, the only file residoo ever writes outside an
53
- explicit --seal.
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" no longer fail the
89
- run; pending findings and integrity warnings still
90
- do. Without this flag, --fail-on-find fails on
91
- every finding, acknowledged or not.
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> full rotation runbook for one detection rule
98
- (where to revoke, steps, what revocation does)
99
- residoo explain --list every rule id with its credential label
100
- residoo ack <fingerprint> mark one finding's rotation done; fingerprints
101
- appear next to findings in the report and in
102
- --json. Optional --note <text> is stored with
103
- the acknowledgement (redacted if it matches a
104
- secret pattern).
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 degrades to {} (loudly, on stderr) if the state file is corrupt,
471
- // so a broken ack ledger can never block or distort a scan.
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
- (counts.acked > 0 ? ` (${counts.acked} acknowledged)` : ""));
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
- // Same anti-flood policy as the by-file table: a report is a summary, not a
155
- // dump. Everything elided here is in --json in full.
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
- const shown = entries.slice(0, MAX_SHOWN);
158
- for (const e of shown) {
159
- const tag = e.status === "pending" ? paint(c.yellow, "pending") : paint(c.green, "acked ");
160
- push(` ${tag} ${e.fingerprint} ${e.label}`);
161
- if (e.status === "acked") {
162
- push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""}`));
163
- } else {
164
- const g = e.guidance;
165
- const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
166
- push(paint(c.dim, ` ${where}`));
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 (entries.length > shown.length) {
170
- 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`));
171
238
  }
172
- push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · mark one rotated: residoo ack <fingerprint>`));
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
- * Load the ack map: { "<fingerprint>": { at, note } }. Missing file is the
672
- * normal first-run case and returns {} silently. A corrupt or unreadable
673
- * file returns {} too, but LOUDLY: one note on stderr, because "your acks
674
- * are gone" must never be silent, and because the next ackFinding() will
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 loadAcks({ file = statePath() } = {}) {
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 (a new acknowledgement will start a fresh store)\n`);
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 || !parsed.acks || typeof parsed.acks !== "object" || Array.isArray(parsed.acks)) {
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
- // Only well-formed entries under well-formed keys survive: state written
698
- // by a future version (or hand-edited) degrades per-entry, not per-file.
699
- const acks = {};
700
- for (const [fp, v] of Object.entries(parsed.acks)) {
701
- if (!FINGERPRINT_RE.test(fp)) continue;
702
- if (!v || typeof v !== "object") continue;
703
- // Control bytes stripped on READ as well as on write: this file sits on
704
- // disk between the two, and a hand-edited or foreign ledger must not be
705
- // able to put a terminal escape into the report via an ack note.
706
- acks[fp] = {
707
- at: typeof v.at === "string" ? stripControlChars(v.at) : null,
708
- note: typeof v.note === "string" ? stripControlChars(v.note) : null,
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 / accepted) one finding.
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 each
741
- * load-modify-write, and the last rename wins, silently dropping the other
742
- * run's ack. Accepted as a single-writer design: acks are typed by a human
743
- * one at a time, the ledger is per-user state, and the failure direction is
744
- * fail-safe (a dropped ack reverts that finding to pending, never the
745
- * reverse). A lockfile would add a stale-lock recovery path for a race that
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 ackFinding(fp, note, { file = statePath() } = {}) {
797
+ function resolveFinding(kind, fp, note, { file = statePath() } = {}) {
749
798
  if (typeof fp !== "string" || !FINGERPRINT_RE.test(fp)) {
750
- throw new TypeError("ackFinding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)");
799
+ throw new TypeError(`${kind}Finding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)`);
751
800
  }
752
- const acks = loadAcks({ file });
801
+ const state = loadFullState({ file });
753
802
  const entry = { at: new Date().toISOString(), note: sanitizeNote(note) };
754
- acks[fp] = entry;
755
-
756
- const dir = path.dirname(file);
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 distinct = new Map();
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: ack ? "acked" : "pending",
790
- ackedAt: ack ? ack.at : null,
791
- ackNote: ack ? ack.note : null,
840
+ status,
841
+ ackedAt: resolved ? resolved.at : null,
842
+ ackNote: resolved ? resolved.note : null,
792
843
  });
793
- if (!distinct.has(fp)) distinct.set(fp, !!ack);
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: distinct.size,
801
- pending: distinct.size - acked,
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 === "pending" ? -1 : 1;
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
  };