residoo 0.3.8 → 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 +30 -16
- package/package.json +1 -1
- package/src/cli.js +70 -22
- package/src/report.js +78 -16
- package/src/rotation.js +124 -65
package/README.md
CHANGED
|
@@ -121,13 +121,18 @@ won't be built into the tool that writes it.
|
|
|
121
121
|
- Redacts everything in its own output. You get a shape and a first/last-4
|
|
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
|
-
-
|
|
125
|
-
(`residoo v0.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
- On an interactive terminal, prints who it is and where it lives before
|
|
125
|
+
scanning starts (`residoo v0.4.0 · find secrets your AI coding agent left
|
|
126
|
+
on disk` plus the repo URL), then a live spinner naming the current file
|
|
127
|
+
as it scans. Every report also opens with the exact version and timestamp
|
|
128
|
+
it was run with (`residoo v0.4.0 · scanned 2026-01-01 12:00`; `--json`
|
|
129
|
+
carries the same as `residooVersion`/`scannedAt`), so a report pasted or
|
|
130
|
+
screenshotted later never leaves you guessing which build produced it.
|
|
131
|
+
When there are findings, the report closes with a "Next steps" pointer to
|
|
132
|
+
`--json` and `--seal`. All of the interactive chatter (the intro, the
|
|
133
|
+
spinner) goes to stderr only and is a complete no-op when stdout/stderr
|
|
134
|
+
are piped, redirected, or run in CI, so none of it can ever interleave
|
|
135
|
+
with `--json`/`--sarif` output.
|
|
131
136
|
- `--sarif` emits SARIF 2.1.0 for GitHub code scanning's Security tab and
|
|
132
137
|
inline pull-request annotations, the same format gitleaks/trufflehog/
|
|
133
138
|
agentsweep already speak, so residoo's own Action and pre-commit hook plug
|
|
@@ -292,12 +297,21 @@ with the way out:
|
|
|
292
297
|
type: where to revoke, the steps, and what revocation actually does at
|
|
293
298
|
that vendor. `residoo explain --list` shows the whole catalogue.
|
|
294
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.
|
|
295
303
|
Every finding carries a stable fingerprint (derived only from
|
|
296
304
|
already-redacted material, so the ledger can never leak), shown in the
|
|
297
|
-
report and in `--json`.
|
|
298
|
-
next scan instead of re-alarming forever.
|
|
299
|
-
`~/.residoo/rotations.json`: residoo's own file, written
|
|
300
|
-
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.
|
|
301
315
|
- **Order matters, and the report says so when it does.** The ChainDrop
|
|
302
316
|
campaign (Aug 2026) shipped a token monitor that fires an attacker payload
|
|
303
317
|
the moment the stolen GitHub token is revoked. When one scan finds both
|
|
@@ -305,9 +319,9 @@ with the way out:
|
|
|
305
319
|
the planted persistence first and rotate second, because "rotate
|
|
306
320
|
everything now" advice can itself trigger the damage.
|
|
307
321
|
|
|
308
|
-
Acks change what the report says, never what CI does:
|
|
309
|
-
fails on every finding,
|
|
310
|
-
`--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).
|
|
311
325
|
|
|
312
326
|
## CI and pre-commit
|
|
313
327
|
|
|
@@ -324,7 +338,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
324
338
|
```yaml
|
|
325
339
|
steps:
|
|
326
340
|
- uses: actions/checkout@v4
|
|
327
|
-
- uses: dandovdub/residoo@v0.
|
|
341
|
+
- uses: dandovdub/residoo@v0.4.0
|
|
328
342
|
```
|
|
329
343
|
|
|
330
344
|
As a pre-commit hook:
|
|
@@ -332,7 +346,7 @@ As a pre-commit hook:
|
|
|
332
346
|
```yaml
|
|
333
347
|
repos:
|
|
334
348
|
- repo: https://github.com/dandovdub/residoo
|
|
335
|
-
rev: v0.
|
|
349
|
+
rev: v0.4.0
|
|
336
350
|
hooks:
|
|
337
351
|
- id: residoo
|
|
338
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
|
@@ -5,10 +5,10 @@ const fs = require("fs");
|
|
|
5
5
|
const crypto = require("crypto");
|
|
6
6
|
const { availableSources, ALL_SOURCES } = require("./sources");
|
|
7
7
|
const { scan, emptyResult } = require("./scan");
|
|
8
|
-
const { render, renderIntegrity, renderJson, renderSarif, makeProgressReporter } = require("./report");
|
|
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;
|
|
@@ -404,6 +449,7 @@ async function main(argv) {
|
|
|
404
449
|
// mutated env var would leak past this one invocation and silently kill
|
|
405
450
|
// color for a later call that never asked for that.
|
|
406
451
|
const noColor = args.includes("--no-color");
|
|
452
|
+
printIntro(noColor);
|
|
407
453
|
|
|
408
454
|
// Integrity runs by default: a scan that reports "no secrets leaked" while
|
|
409
455
|
// a planted SessionStart hook sits ready to re-leak them next session is
|
|
@@ -466,9 +512,11 @@ async function main(argv) {
|
|
|
466
512
|
sources = availableSources();
|
|
467
513
|
}
|
|
468
514
|
|
|
469
|
-
// loadAcks
|
|
470
|
-
// 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.
|
|
471
518
|
const acks = loadAcks();
|
|
519
|
+
const dismissed = loadDismissed();
|
|
472
520
|
|
|
473
521
|
if (sources.length === 0) {
|
|
474
522
|
const empty = emptyResult();
|
|
@@ -481,7 +529,7 @@ async function main(argv) {
|
|
|
481
529
|
// A --json caller (CI, a script piping into jq) must always get valid JSON
|
|
482
530
|
// on stdout, even on the "nothing to scan" path — a plain-text message on
|
|
483
531
|
// stderr with exit 0 silently breaks that contract.
|
|
484
|
-
process.stdout.write(renderJson(empty, integrity, renderRotation([], acks)) + "\n");
|
|
532
|
+
process.stdout.write(renderJson(empty, integrity, renderRotation([], acks, dismissed)) + "\n");
|
|
485
533
|
} else {
|
|
486
534
|
process.stderr.write(
|
|
487
535
|
"No known transcript sources found on this machine.\n" +
|
|
@@ -494,11 +542,11 @@ async function main(argv) {
|
|
|
494
542
|
return failOnFind && integrityWarnCount(integrity) > 0 ? 1 : 0;
|
|
495
543
|
}
|
|
496
544
|
|
|
497
|
-
const progress = makeProgressReporter();
|
|
545
|
+
const progress = makeProgressReporter(noColor);
|
|
498
546
|
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
|
|
499
547
|
progress.stop();
|
|
500
548
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
501
|
-
const rotation = renderRotation(result.findings, acks);
|
|
549
|
+
const rotation = renderRotation(result.findings, acks, dismissed);
|
|
502
550
|
process.stdout.write((wantsSarif
|
|
503
551
|
? renderSarif(result)
|
|
504
552
|
: wantsJson
|
package/src/report.js
CHANGED
|
@@ -32,35 +32,58 @@ function ageDays(mtimeMs) {
|
|
|
32
32
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* Printed once, before scanning starts: what this is, what version, where
|
|
36
|
+
* it lives. Answers exactly the question a first-time (or every-time)
|
|
37
|
+
* reader has before results ever appear, without waiting for the report.
|
|
38
|
+
* Same TTY gate and same stream as the progress spinner below and for the
|
|
39
|
+
* same reason: stderr only, so a --json/--sarif consumer's stdout is
|
|
40
|
+
* untouched, and a complete no-op under redirection, piping, or CI.
|
|
41
|
+
*/
|
|
42
|
+
function printIntro(noColor) {
|
|
43
|
+
if (!process.stderr.isTTY) return;
|
|
44
|
+
const paint = makePaint(noColor);
|
|
45
|
+
const { version } = require("../package.json");
|
|
46
|
+
process.stderr.write(
|
|
47
|
+
paint(c.bold + c.cyan, `residoo v${version}`) +
|
|
48
|
+
paint(c.dim, " · find secrets your AI coding agent left on disk\n") +
|
|
49
|
+
paint(c.dim, "https://github.com/dandovdub/residoo\n\n")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A progress indicator for the scan phase, wired to scan()'s own
|
|
36
55
|
* onProgress callback. Writes to STDERR only, never stdout: --json/--sarif
|
|
37
56
|
* consumers pipe stdout into a parser, and a spinner corrupting that would
|
|
38
57
|
* be a much worse bug than not having one. Gated on stderr actually being a
|
|
39
58
|
* TTY, so it is a complete no-op under redirection, piping, or CI, exactly
|
|
40
59
|
* the contexts where carriage-return spam in a captured log would be
|
|
41
|
-
* useless or actively annoying, not merely invisible.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
60
|
+
* useless or actively annoying, not merely invisible. Shows the actual
|
|
61
|
+
* current file (basename only, through the same safeBasename() every other
|
|
62
|
+
* displayed path in this report goes through — control bytes stripped,
|
|
63
|
+
* invisible code points made visible), not just a running count: real
|
|
64
|
+
* signal, not just motion, and genuinely useful if a scan stalls on one
|
|
65
|
+
* huge file. `stop()` clears the line so whatever prints next starts
|
|
45
66
|
* clean.
|
|
46
67
|
*/
|
|
47
|
-
function makeProgressReporter() {
|
|
68
|
+
function makeProgressReporter(noColor) {
|
|
48
69
|
if (!process.stderr.isTTY) return { onProgress: null, stop() {} };
|
|
70
|
+
const paint = makePaint(noColor);
|
|
49
71
|
let count = 0;
|
|
50
72
|
let lastWriteMs = 0;
|
|
51
73
|
let lastLineLen = 0;
|
|
52
74
|
let frame = 0;
|
|
53
|
-
const write = (s) => {
|
|
75
|
+
const write = (s, visibleLen) => {
|
|
54
76
|
process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r" + s);
|
|
55
|
-
lastLineLen =
|
|
77
|
+
lastLineLen = visibleLen;
|
|
56
78
|
};
|
|
57
|
-
const onProgress = ({ source }) => {
|
|
79
|
+
const onProgress = ({ source, file }) => {
|
|
58
80
|
count++;
|
|
59
81
|
const now = Date.now();
|
|
60
82
|
if (now - lastWriteMs < 80) return; // throttled: avoid flicker on a fast scan
|
|
61
83
|
lastWriteMs = now;
|
|
62
84
|
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
63
|
-
|
|
85
|
+
const label = `scanning ${source}… ${count} file${count === 1 ? "" : "s"} ${safeBasename(file)}`;
|
|
86
|
+
write(paint(c.bold + c.cyan, SPINNER_FRAMES[frame]) + " " + paint(c.dim, label), 2 + label.length);
|
|
64
87
|
};
|
|
65
88
|
const stop = () => { if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r"); };
|
|
66
89
|
return { onProgress, stop };
|
|
@@ -120,9 +143,13 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
120
143
|
// raw values, while these entries dedupe fingerprints (which include the
|
|
121
144
|
// basename, so one value in two differently-named files is two rotations to
|
|
122
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(", ");
|
|
123
150
|
push(paint(c.bold, "Rotation:") +
|
|
124
151
|
` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
|
|
125
|
-
(
|
|
152
|
+
(resolvedNote ? ` (${resolvedNote})` : ""));
|
|
126
153
|
if (showAdvisory) {
|
|
127
154
|
const wrapped = wrapText(ROTATION_ORDER_ADVISORY, 72, " ");
|
|
128
155
|
push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
|
|
@@ -132,11 +159,17 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
132
159
|
// dump. Everything elided here is in --json in full.
|
|
133
160
|
const MAX_SHOWN = 12;
|
|
134
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
|
+
};
|
|
135
167
|
for (const e of shown) {
|
|
136
|
-
|
|
137
|
-
push(` ${tag} ${e.fingerprint} ${e.label}`);
|
|
168
|
+
push(` ${STATUS_TAG[e.status]} ${e.fingerprint} ${e.label}`);
|
|
138
169
|
if (e.status === "acked") {
|
|
139
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)`));
|
|
140
173
|
} else {
|
|
141
174
|
const g = e.guidance;
|
|
142
175
|
const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
|
|
@@ -146,7 +179,7 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
146
179
|
if (entries.length > shown.length) {
|
|
147
180
|
push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
|
|
148
181
|
}
|
|
149
|
-
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>`));
|
|
150
183
|
return lines.join("\n");
|
|
151
184
|
}
|
|
152
185
|
|
|
@@ -264,6 +297,31 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
264
297
|
push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
|
|
265
298
|
push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
|
|
266
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
|
+
}
|
|
267
325
|
push();
|
|
268
326
|
|
|
269
327
|
const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
|
|
@@ -304,9 +362,13 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
304
362
|
push(renderIntegrity(integrity, { noColor }));
|
|
305
363
|
}
|
|
306
364
|
|
|
365
|
+
push();
|
|
366
|
+
push(paint(c.bold, "Next steps:"));
|
|
367
|
+
push(` residoo scan --json ${paint(c.dim, "machine-readable output, full detail")}`);
|
|
368
|
+
push(` residoo scan --seal ${paint(c.dim, "encrypt the affected files into a local vault (originals untouched)")}`);
|
|
307
369
|
push();
|
|
308
370
|
push(paint(c.dim, "Values are redacted in this report (first/last 4 characters only). Nothing scanned"));
|
|
309
|
-
push(paint(c.dim, "here left your machine; residoo makes no network calls.
|
|
371
|
+
push(paint(c.dim, "here left your machine; residoo makes no network calls."));
|
|
310
372
|
|
|
311
373
|
return lines.join("\n");
|
|
312
374
|
}
|
|
@@ -450,4 +512,4 @@ function renderSarif(result) {
|
|
|
450
512
|
}, null, 2);
|
|
451
513
|
}
|
|
452
514
|
|
|
453
|
-
module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, makeProgressReporter };
|
|
515
|
+
module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, makeProgressReporter, printIntro };
|
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
|
};
|