residoo 0.3.9 → 0.4.0
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 +20 -11
- package/package.json +1 -1
- package/src/cli.js +67 -20
- package/src/report.js +39 -4
- package/src/rotation.js +124 -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.0 · 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.0 · 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,21 @@ 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.
|
|
306
315
|
- **Order matters, and the report says so when it does.** The ChainDrop
|
|
307
316
|
campaign (Aug 2026) shipped a token monitor that fires an attacker payload
|
|
308
317
|
the moment the stolen GitHub token is revoked. When one scan finds both
|
|
@@ -310,9 +319,9 @@ with the way out:
|
|
|
310
319
|
the planted persistence first and rotate second, because "rotate
|
|
311
320
|
everything now" advice can itself trigger the damage.
|
|
312
321
|
|
|
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).
|
|
322
|
+
Acks and dismissals change what the report says, never what CI does:
|
|
323
|
+
`--fail-on-find` fails on every finding, resolved or not, unless you
|
|
324
|
+
explicitly pass `--allow-acked` (integrity warnings always fail either way).
|
|
316
325
|
|
|
317
326
|
## CI and pre-commit
|
|
318
327
|
|
|
@@ -329,7 +338,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
329
338
|
```yaml
|
|
330
339
|
steps:
|
|
331
340
|
- uses: actions/checkout@v4
|
|
332
|
-
- uses: dandovdub/residoo@v0.
|
|
341
|
+
- uses: dandovdub/residoo@v0.4.0
|
|
333
342
|
```
|
|
334
343
|
|
|
335
344
|
As a pre-commit hook:
|
|
@@ -337,7 +346,7 @@ As a pre-commit hook:
|
|
|
337
346
|
```yaml
|
|
338
347
|
repos:
|
|
339
348
|
- repo: https://github.com/dandovdub/residoo
|
|
340
|
-
rev: v0.
|
|
349
|
+
rev: v0.4.0
|
|
341
350
|
hooks:
|
|
342
351
|
- id: residoo
|
|
343
352
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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,9 +143,13 @@ 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])}`);
|
|
@@ -155,11 +159,17 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
155
159
|
// dump. Everything elided here is in --json in full.
|
|
156
160
|
const MAX_SHOWN = 12;
|
|
157
161
|
const shown = entries.slice(0, MAX_SHOWN);
|
|
162
|
+
const STATUS_TAG = {
|
|
163
|
+
pending: paint(c.yellow, "pending "),
|
|
164
|
+
acked: paint(c.green, "acked "),
|
|
165
|
+
dismissed: paint(c.dim, "dismissed"),
|
|
166
|
+
};
|
|
158
167
|
for (const e of shown) {
|
|
159
|
-
|
|
160
|
-
push(` ${tag} ${e.fingerprint} ${e.label}`);
|
|
168
|
+
push(` ${STATUS_TAG[e.status]} ${e.fingerprint} ${e.label}`);
|
|
161
169
|
if (e.status === "acked") {
|
|
162
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)`));
|
|
163
173
|
} else {
|
|
164
174
|
const g = e.guidance;
|
|
165
175
|
const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
|
|
@@ -169,7 +179,7 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
169
179
|
if (entries.length > shown.length) {
|
|
170
180
|
push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
|
|
171
181
|
}
|
|
172
|
-
push(paint(c.dim, ` Full runbook: residoo explain <rule-id> ·
|
|
182
|
+
push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · rotated: residoo ack <fp> · not a secret: residoo dismiss <fp>`));
|
|
173
183
|
return lines.join("\n");
|
|
174
184
|
}
|
|
175
185
|
|
|
@@ -287,6 +297,31 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
287
297
|
push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
|
|
288
298
|
push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
|
|
289
299
|
if (unreadableNote) push(unreadableNote);
|
|
300
|
+
|
|
301
|
+
// The practical, act-on-this-now summary, first, before the full
|
|
302
|
+
// rule-by-rule and file-by-file detail below: a raw count of findings
|
|
303
|
+
// (which can run into the hundreds on a machine with a lot of history) is
|
|
304
|
+
// not by itself a to-do list. What actually needs a person's attention is
|
|
305
|
+
// the count of DISTINCT values not yet triaged (rotated or dismissed) —
|
|
306
|
+
// everything else is either already handled or a re-exposure of a value
|
|
307
|
+
// already accounted for.
|
|
308
|
+
if (rotation && rotation.counts.distinct > 0) {
|
|
309
|
+
const { pending, distinct, acked, dismissed } = rotation.counts;
|
|
310
|
+
push();
|
|
311
|
+
push(paint(c.bold, "Recommended actions:"));
|
|
312
|
+
if (pending > 0) {
|
|
313
|
+
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)`);
|
|
314
|
+
} else {
|
|
315
|
+
push(` ${paint(c.green, "✓")} Nothing new to review; every distinct value here has already been triaged`);
|
|
316
|
+
}
|
|
317
|
+
const resolvedParts = [
|
|
318
|
+
acked > 0 ? `${acked} acknowledged` : null,
|
|
319
|
+
dismissed > 0 ? `${dismissed} dismissed` : null,
|
|
320
|
+
].filter(Boolean);
|
|
321
|
+
if (resolvedParts.length > 0) {
|
|
322
|
+
push(paint(c.dim, ` ${resolvedParts.join(", ")} already, no action needed (see Rotation below for which)`));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
290
325
|
push();
|
|
291
326
|
|
|
292
327
|
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++) {
|
|
@@ -842,7 +899,7 @@ function renderRotation(findings, acks) {
|
|
|
842
899
|
}
|
|
843
900
|
|
|
844
901
|
const entries = [...byFp.values()].sort((a, b) => {
|
|
845
|
-
if (a.status !== b.status) return a.status
|
|
902
|
+
if (a.status !== b.status) return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
846
903
|
if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
|
|
847
904
|
return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
848
905
|
});
|
|
@@ -857,7 +914,9 @@ module.exports = {
|
|
|
857
914
|
fingerprintFinding,
|
|
858
915
|
statePath,
|
|
859
916
|
loadAcks,
|
|
917
|
+
loadDismissed,
|
|
860
918
|
ackFinding,
|
|
919
|
+
dismissFinding,
|
|
861
920
|
pendingSummary,
|
|
862
921
|
renderRotation,
|
|
863
922
|
};
|