residoo 0.2.0 → 0.3.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 CHANGED
@@ -103,6 +103,14 @@ won't be built into the tool that writes it.
103
103
  files), and checks the places the 2026 supply-chain campaigns planted
104
104
  persistence: hooks, dropper scripts, auto-run tasks, invisible Unicode.
105
105
  See the next section.
106
+ - Attaches a **rotation runbook** to every finding: the vendor's real
107
+ revocation path, verified against their own docs, plus a local
108
+ acknowledgement ledger so "found it" can become "closed it". See
109
+ [Rotation](#rotation-from-found-to-closed).
110
+ - Scans a **repository checkout** instead of the machine with
111
+ `--project <dir>`: committed transcripts, agent configs, and root `.env`
112
+ files, built for CI and pre-commit. See
113
+ [CI and pre-commit](#ci-and-pre-commit).
106
114
 
107
115
  ## Beyond transcripts: configs and planted persistence
108
116
 
@@ -159,6 +167,7 @@ counted as clean.
159
167
  │ │
160
168
  │ 42 transcript sources agent config files │
161
169
  │ ~/.claude, Cursor, Codex… settings · MCP · memory │
170
+ │ (--project <dir>: a repo checkout instead of the machine) │
162
171
  │ │ │ │
163
172
  │ ├──────────────┬───────────────┤ │
164
173
  │ ▼ │ ▼ │
@@ -168,6 +177,9 @@ counted as clean.
168
177
  │ ▼ ▼ │ │
169
178
  │ redacted report (first/last 4 chars only) ◀────────────┤
170
179
  │ │ │
180
+ │ ├─▶ rotation hints per finding · explain / ack │
181
+ │ │ ledger: ~/.residoo/rotations.json │
182
+ │ │ │
171
183
  │ ▼ --seal (only if you ask) │
172
184
  │ AES-256-GCM vault · scrypt key · encrypted manifest │
173
185
  │ │ │ │
@@ -179,7 +191,9 @@ counted as clean.
179
191
 
180
192
  The `--seal` and `--upload-cloudroam` legs never run unless you pass their
181
193
  flag. Everything above the vault happens on every scan; nothing in the
182
- diagram ever modifies or deletes an existing file.
194
+ diagram ever modifies or deletes an existing file. The one exception, stated
195
+ in the open: `residoo ack` writes residoo's own rotation ledger at
196
+ `~/.residoo/rotations.json` (atomic, redacted, never a user file).
183
197
 
184
198
  ## Sealing what it finds
185
199
 
@@ -207,6 +221,76 @@ touches the network, it never runs unless you pass the flag, and only
207
221
  ciphertext is transmitted.** The vault is sealed before upload code ever
208
222
  executes.
209
223
 
224
+ ## Rotation: from found to closed
225
+
226
+ Detection without rotation is theater, and the field's own numbers say so:
227
+ 64% of secrets leaked publicly in 2022 were still valid years later, 88% of
228
+ re-verified leaked AWS keys still authenticated, and the median time to
229
+ remediate a GitHub-leaked secret is 94 days. A scanner that stops at "found
230
+ it" leaves all of that untouched. So every finding in a residoo report comes
231
+ with the way out:
232
+
233
+ - **A rotation hint per finding**, from a per-rule guidance map covering all
234
+ 35 detection rules (plus the opt-in noisy ones). Where a rotation URL is
235
+ shown, that exact URL was fetched and confirmed to document rotating or
236
+ revoking that credential type; where a vendor's docs are login-walled or
237
+ unfetchable, the report gives the console path in words instead of a link
238
+ it could not verify. Generic shapes (a JWT, a bearer header) get honest
239
+ generic guidance that says how to identify the issuer, never a pretend
240
+ vendor.
241
+ - **`residoo explain <rule-id>`** prints the full runbook for one credential
242
+ type: where to revoke, the steps, and what revocation actually does at
243
+ that vendor. `residoo explain --list` shows the whole catalogue.
244
+ - **`residoo ack <fingerprint>`** records that you rotated one finding.
245
+ Every finding carries a stable fingerprint (derived only from
246
+ already-redacted material, so the ledger can never leak), shown in the
247
+ report and in `--json`. Acknowledged findings are reported as such on the
248
+ next scan instead of re-alarming forever. The ledger lives at
249
+ `~/.residoo/rotations.json`: residoo's own file, written atomically, ack
250
+ notes redacted through the same pipeline as previews.
251
+ - **Order matters, and the report says so when it does.** The ChainDrop
252
+ campaign (Aug 2026) shipped a token monitor that fires an attacker payload
253
+ the moment the stolen GitHub token is revoked. When one scan finds both
254
+ integrity warnings and leaked credentials, the report tells you to remove
255
+ the planted persistence first and rotate second, because "rotate
256
+ everything now" advice can itself trigger the damage.
257
+
258
+ Acks change what the report says, never what CI does: `--fail-on-find`
259
+ fails on every finding, acknowledged or not, unless you explicitly pass
260
+ `--allow-acked` (integrity warnings always fail either way).
261
+
262
+ ## CI and pre-commit
263
+
264
+ `residoo scan --project <dir>` scans a repository checkout instead of the
265
+ machine it runs on: committed agent transcripts (Claude Code `.jsonl`
266
+ trees, Codex `rollout-*.jsonl`, SpecStory histories), agent config and
267
+ rules files at any depth, and root-level `.env` files, plus the integrity
268
+ checks anchored at that directory. It deliberately does not touch the
269
+ machine's home-level sources, so a clean CI run means the checkout is
270
+ clean and claims nothing about anyone's laptop.
271
+
272
+ As a GitHub Action (this repository doubles as a composite action):
273
+
274
+ ```yaml
275
+ steps:
276
+ - uses: actions/checkout@v4
277
+ - uses: dandovdub/residoo@v0.3.0
278
+ ```
279
+
280
+ As a pre-commit hook:
281
+
282
+ ```yaml
283
+ repos:
284
+ - repo: https://github.com/dandovdub/residoo
285
+ rev: v0.3.0
286
+ hooks:
287
+ - id: residoo
288
+ ```
289
+
290
+ Or with no integration at all: `npx --yes residoo scan --project . --fail-on-find`.
291
+ Exit codes, inputs, and exactly what project mode does and does not see are
292
+ documented in [docs/ci.md](docs/ci.md).
293
+
210
294
  ## What it does not do
211
295
 
212
296
  - **No network calls in the default path, and none at all unless you
@@ -232,6 +316,20 @@ npm install -g residoo
232
316
  residoo scan
233
317
  ```
234
318
 
319
+ A Homebrew formula ships in this repo at `packaging/homebrew/`. It installs
320
+ the exact tarball published to npm (same bits, sha256 verified), so Homebrew
321
+ is a second door to the same release, not a second build. Once the tap
322
+ repository (`dandovdub/homebrew-residoo`) is published, installation is:
323
+
324
+ ```bash
325
+ brew tap dandovdub/residoo
326
+ brew install residoo
327
+ ```
328
+
329
+ Until the tap is up, npm above is the way in. The formula always points at
330
+ the latest *published* npm release (its sha256 is computed from the real
331
+ tarball), so it can lag a fresh tag by one publish cycle.
332
+
235
333
  Requires Node.js 18+. The SQLite-backed sources listed below additionally
236
334
  need 22.5+; residoo still runs and scans every line-delimited/JSON source,
237
335
  including Claude Code, fine without it. Zero runtime dependencies, and you
@@ -243,10 +341,14 @@ can check `package.json` rather than take that on faith.
243
341
  residoo scan [options]
244
342
 
245
343
  --json machine-readable output (full detail, still redacted)
344
+ --project [dir] scan a repository checkout instead of this machine
345
+ (committed transcripts, agent configs, root .env)
246
346
  --include-noisy also run broad, false-positive-prone rules
247
347
  --include-suppressed also show matches that looked like placeholder/example text
248
348
  --fail-on-find exit code 1 if anything is found (for CI): secret
249
349
  findings and integrity warnings count, review items don't
350
+ --allow-acked with --fail-on-find: acknowledged findings no longer
351
+ fail the run (pending ones and warnings still do)
250
352
  --no-integrity skip the integrity checks
251
353
  --no-color disable ANSI colour
252
354
 
@@ -255,6 +357,10 @@ residoo scan [options]
255
357
  --upload-cloudroam also upload the sealed vault (needs CLOUDROAM_API_KEY,
256
358
  --connector <id>, --bucket <name>; ciphertext only)
257
359
 
360
+ residoo explain <rule-id> full rotation runbook for one rule
361
+ residoo explain --list every rule id and label
362
+ residoo ack <fingerprint> [--note <text>] mark one finding rotated
363
+
258
364
  residoo unseal <vault-dir> list a vault's contents
259
365
  residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
260
366
  ```
@@ -266,7 +372,10 @@ so pick one you keep.
266
372
  ## Sources supported today
267
373
 
268
374
  43 sources as of this writing (42 transcript stores plus the agent-config
269
- source described above), in two honestly-distinct tiers. See
375
+ source described above), in two honestly-distinct tiers. Project mode
376
+ (`--project`) adds one more, opt-in source (`src/sources/project-artifacts.js`)
377
+ that scans a repository checkout rather than the machine and never
378
+ participates in a default scan. See
270
379
  `src/sources/index.js` for the full list and grouping, and each source file's
271
380
  own header for exactly what was and wasn't checked.
272
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.2.0",
3
+ "version": "0.3.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,6 +5,9 @@ const { availableSources, ALL_SOURCES } = require("./sources");
5
5
  const { scan, emptyResult } = require("./scan");
6
6
  const { render, renderIntegrity, renderJson } = require("./report");
7
7
  const { checkIntegrity } = require("./integrity");
8
+ const {
9
+ ROTATION_GUIDANCE, guidanceFor, loadAcks, ackFinding, renderRotation,
10
+ } = require("./rotation");
8
11
 
9
12
  /**
10
13
  * A source is unavailable for the ordinary reason (not installed — nothing
@@ -39,25 +42,61 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
39
42
  checked locations is listed for your review; only published campaign IOCs
40
43
  and campaign-shaped behaviors escalate to warnings.
41
44
 
45
+ Every finding comes with a rotation hint: the vendor's real revocation
46
+ path, verified against their docs, because a leaked key that is found but
47
+ never rotated is still leaked (64% of leaked secrets stay valid for years).
48
+ "residoo explain <rule-id>" prints the full runbook for one credential
49
+ type; "residoo ack <fingerprint>" records that you rotated one, in
50
+ ~/.residoo/rotations.json, the only file residoo ever writes outside an
51
+ explicit --seal.
52
+
42
53
  Scanning makes NO network calls and changes nothing on disk. Findings are
43
54
  redacted in every output format. Sealing (--seal) writes NEW encrypted
44
55
  files only. It never modifies or deletes anything that already exists.
45
56
 
46
57
  Usage:
47
58
  residoo scan [options]
59
+ residoo explain <rule-id> (or: residoo explain --list)
60
+ residoo ack <fingerprint> [--note <text>]
48
61
  residoo unseal <vault-dir> [--restore <n> --out <path>]
49
62
 
50
63
  Scan options:
51
64
  --json machine-readable output (full detail, still redacted)
65
+ --project [dir] scan a repository checkout instead of this machine
66
+ (default dir: current directory). Covers committed
67
+ agent transcripts, agent config/rules files, and
68
+ root-level .env files inside the checkout, plus
69
+ integrity checks anchored at that directory. The
70
+ home-level transcript and config sources are NOT
71
+ scanned in this mode, deliberately: in CI they
72
+ would scan the runner's home and say nothing about
73
+ the repo, and a clean project scan must never be
74
+ mistaken for a clean machine. Run residoo scan
75
+ without --project for the machine itself.
52
76
  --include-noisy also run broad, false-positive-prone rules
53
77
  --include-suppressed also show matches that looked like placeholder/example text
54
78
  --fail-on-find exit code 1 if anything is found (for CI): secret
55
79
  findings and integrity WARNINGS count; integrity
56
80
  info-level review items do not
81
+ --allow-acked with --fail-on-find: findings whose fingerprint was
82
+ acknowledged via "residoo ack" no longer fail the
83
+ run; pending findings and integrity warnings still
84
+ do. Without this flag, --fail-on-find fails on
85
+ every finding, acknowledged or not.
57
86
  --no-integrity skip the integrity checks (planted hooks, dropper
58
87
  files, auto-run tasks, hidden Unicode)
59
88
  --no-color disable ANSI colour
60
89
 
90
+ Rotation:
91
+ residoo explain <rule-id> full rotation runbook for one detection rule
92
+ (where to revoke, steps, what revocation does)
93
+ residoo explain --list every rule id with its credential label
94
+ residoo ack <fingerprint> mark one finding's rotation done; fingerprints
95
+ appear next to findings in the report and in
96
+ --json. Optional --note <text> is stored with
97
+ the acknowledgement (redacted if it matches a
98
+ secret pattern).
99
+
61
100
  Seal options (used with scan):
62
101
  --seal after scanning, encrypt every transcript that carried a
63
102
  finding into a local vault directory (AES-256-GCM,
@@ -186,6 +225,78 @@ async function runUnseal(args) {
186
225
  return 1;
187
226
  }
188
227
 
228
+ /**
229
+ * Full rotation runbook for one rule id, or the whole catalogue via --list.
230
+ * Prints for a HUMAN about to revoke a credential, so it stays plain text
231
+ * and never assumes the finding is still on screen.
232
+ */
233
+ function runExplain(args) {
234
+ const ids = Object.keys(ROTATION_GUIDANCE);
235
+ const ruleId = args[1] && !args[1].startsWith("--") ? args[1] : null;
236
+
237
+ if (args.includes("--list") || ruleId === null) {
238
+ process.stdout.write("Rotation runbooks available (residoo explain <rule-id>):\n");
239
+ const width = Math.max(...ids.map((i) => i.length)) + 2;
240
+ for (const id of ids) {
241
+ process.stdout.write(` ${id.padEnd(width)}${ROTATION_GUIDANCE[id].label}\n`);
242
+ }
243
+ return 0;
244
+ }
245
+
246
+ const known = Object.prototype.hasOwnProperty.call(ROTATION_GUIDANCE, ruleId);
247
+ const g = guidanceFor(ruleId);
248
+ const out = [];
249
+ out.push(`${g.label} (rule: ${ruleId})`);
250
+ if (g.rotateUrl) out.push(` rotate/revoke docs: ${g.rotateUrl}`);
251
+ if (g.consolePath) out.push(` where: ${g.consolePath}`);
252
+ out.push(" steps:");
253
+ g.steps.forEach((s, i) => out.push(` ${i + 1}. ${s}`));
254
+ out.push(` note: ${g.revokeNote}`);
255
+ if (g.generic) {
256
+ out.push(" (generic guidance: this rule matches a shape, not a single vendor,");
257
+ out.push(" so the issuing service has to be identified from the finding's context)");
258
+ }
259
+ process.stdout.write(out.join("\n") + "\n");
260
+ if (!known) {
261
+ process.stderr.write(`residoo: "${ruleId}" is not a known rule id; the guidance above is the generic fallback. residoo explain --list shows every known id.\n`);
262
+ return 2;
263
+ }
264
+ return 0;
265
+ }
266
+
267
+ /**
268
+ * Record that one finding's credential was rotated. Takes the fingerprint
269
+ * exactly as the report and --json print it; acks change what the report
270
+ * SAYS (pending vs acknowledged) but never what --fail-on-find DOES unless
271
+ * --allow-acked is also passed to the scan, which keeps a CI gate honest by
272
+ * default.
273
+ */
274
+ function runAck(args) {
275
+ const fp = args[1] && !args[1].startsWith("--") ? args[1] : null;
276
+ if (!fp) {
277
+ process.stderr.write("usage: residoo ack <fingerprint> [--note <text>]\n" +
278
+ "Fingerprints (rf1-...) are shown next to findings in the scan report and in --json.\n");
279
+ return 2;
280
+ }
281
+ let res;
282
+ try {
283
+ res = ackFinding(fp, argValue(args, "--note"));
284
+ } catch (err) {
285
+ process.stderr.write(`residoo: ${err instanceof Error ? err.message : String(err)}\n`);
286
+ return 2;
287
+ }
288
+ process.stdout.write(
289
+ `Acknowledged ${res.fingerprint} at ${res.at}` +
290
+ (res.note ? ` with note: ${res.note}` : "") + "\n" +
291
+ "The next scan reports this finding as acknowledged. It still counts toward\n" +
292
+ "--fail-on-find unless the scan is run with --allow-acked.\n" +
293
+ "(ack is stateless: it cannot check this fingerprint against a scan, so a\n" +
294
+ "mistyped one is recorded too; the intended finding would then still show\n" +
295
+ "as pending on the next scan.)\n"
296
+ );
297
+ return 0;
298
+ }
299
+
189
300
  async function main(argv) {
190
301
  const args = argv.slice(2);
191
302
  if (args.includes("-h") || args.includes("--help") || args.length === 0) {
@@ -195,6 +306,8 @@ async function main(argv) {
195
306
 
196
307
  const cmd = args[0];
197
308
  if (cmd === "unseal") return runUnseal(args);
309
+ if (cmd === "explain") return runExplain(args);
310
+ if (cmd === "ack") return runAck(args);
198
311
  if (cmd !== "scan") {
199
312
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
200
313
  return 2;
@@ -204,6 +317,16 @@ async function main(argv) {
204
317
  const includeNoisy = args.includes("--include-noisy");
205
318
  const includeSuppressed = args.includes("--include-suppressed");
206
319
  const failOnFind = args.includes("--fail-on-find");
320
+ const allowAcked = args.includes("--allow-acked");
321
+
322
+ // --project [dir]: the dir is optional (CI passes ".", a bare --project
323
+ // means the current directory). null means machine mode.
324
+ let projectRoot = null;
325
+ const projectIdx = args.indexOf("--project");
326
+ if (projectIdx >= 0) {
327
+ const next = args[projectIdx + 1];
328
+ projectRoot = path.resolve(next && !next.startsWith("--") ? next : ".");
329
+ }
207
330
  // Passed through explicitly to render() rather than mutating
208
331
  // process.env.NO_COLOR — main() is an exported function a host process can
209
332
  // legitimately call more than once (a wrapper CLI, a test runner), and a
@@ -226,8 +349,26 @@ async function main(argv) {
226
349
  // throw degrades to a warn-severity finding: the run stays alive, the
227
350
  // failure stays loud (it still gates --fail-on-find), and it is never a
228
351
  // silent all-clear.
352
+ // In project mode both anchors point at the project root: the transcript
353
+ // sources are already project-only, and letting the integrity pass read the
354
+ // invoking machine's home would fail a CI/pre-commit run on a developer's
355
+ // own home-level hooks — a verdict about the wrong thing. The root's own
356
+ // .claude/, .gemini/, .cursor/, .vscode/, CLAUDE.md and .cursorrules are
357
+ // exactly the committed plant sites the campaigns used.
229
358
  const runIntegrity = () => {
230
- try { return checkIntegrity(); }
359
+ try {
360
+ // projectMode additionally makes checkIntegrity ignore the machine's
361
+ // GEMINI_CLI_HOME override and suppress the home-anchored hook
362
+ // demotion — both are statements about this machine, and a committed
363
+ // repo config is not this user's standing config (see integrity.js).
364
+ const integ = checkIntegrity(projectRoot ? { home: projectRoot, cwd: projectRoot, projectMode: true } : {});
365
+ // checkIntegrity's stock scope note says "current working directory";
366
+ // in project mode that would misdescribe what was checked.
367
+ if (projectRoot) {
368
+ integ.scopeNote = "Integrity checks cover the --project directory only (paths shown relative to it); this machine's home-level agent configs were not examined on this run.";
369
+ }
370
+ return integ;
371
+ }
231
372
  catch (e) {
232
373
  const why = String((e && e.message) || e).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
233
374
  return {
@@ -241,7 +382,23 @@ async function main(argv) {
241
382
  }
242
383
  };
243
384
 
244
- const sources = availableSources();
385
+ let sources;
386
+ if (projectRoot) {
387
+ const projectArtifacts = require("./sources/project-artifacts");
388
+ const src = projectArtifacts.withRoot(projectRoot);
389
+ if (!src.available()) {
390
+ process.stderr.write(`--project: "${projectRoot}" is not a readable directory.\n`);
391
+ return 2;
392
+ }
393
+ sources = [src];
394
+ } else {
395
+ sources = availableSources();
396
+ }
397
+
398
+ // loadAcks degrades to {} (loudly, on stderr) if the state file is corrupt,
399
+ // so a broken ack ledger can never block or distort a scan.
400
+ const acks = loadAcks();
401
+
245
402
  if (sources.length === 0) {
246
403
  const empty = emptyResult();
247
404
  const integrity = wantsIntegrity ? runIntegrity() : null;
@@ -249,7 +406,7 @@ async function main(argv) {
249
406
  // A --json caller (CI, a script piping into jq) must always get valid JSON
250
407
  // on stdout, even on the "nothing to scan" path — a plain-text message on
251
408
  // stderr with exit 0 silently breaks that contract.
252
- process.stdout.write(renderJson(empty, integrity) + "\n");
409
+ process.stdout.write(renderJson(empty, integrity, renderRotation([], acks)) + "\n");
253
410
  } else {
254
411
  process.stderr.write(
255
412
  "No known transcript sources found on this machine.\n" +
@@ -264,14 +421,23 @@ async function main(argv) {
264
421
 
265
422
  const result = await scan({ sources, includeNoisy, includeSuppressed });
266
423
  const integrity = wantsIntegrity ? runIntegrity() : null;
267
- process.stdout.write((wantsJson ? renderJson(result, integrity) : render(result, { noColor, integrity })) + "\n");
424
+ const rotation = renderRotation(result.findings, acks);
425
+ process.stdout.write((wantsJson
426
+ ? renderJson(result, integrity, rotation)
427
+ : render(result, { noColor, integrity, rotation })) + "\n");
268
428
 
269
429
  if (args.includes("--seal")) {
270
430
  const sealExit = await runSeal(result, args);
271
431
  if (sealExit !== 0) return sealExit;
272
432
  }
273
433
 
274
- return failOnFind && (result.findings.length > 0 || integrityWarnCount(integrity) > 0) ? 1 : 0;
434
+ // --allow-acked narrows the SECRET gate only: an acknowledged rotation says
435
+ // nothing about a planted hook, so integrity warnings always fail. Without
436
+ // the flag, acks change what the report says, never what CI does — a gate
437
+ // that silently honored local ack state would let one developer's ledger
438
+ // green-light everyone's pipeline.
439
+ const secretGate = allowAcked ? rotation.counts.pending > 0 : result.findings.length > 0;
440
+ return failOnFind && (secretGate || integrityWarnCount(integrity) > 0) ? 1 : 0;
275
441
  }
276
442
 
277
443
  module.exports = { main };