residoo 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -9
- package/package.json +1 -1
- package/src/cli.js +171 -5
- package/src/decode.js +416 -0
- package/src/integrity.js +55 -35
- package/src/patterns.js +19 -1
- package/src/report.js +136 -5
- package/src/rotation.js +848 -0
- package/src/scan.js +213 -17
- package/src/sources/agent-configs.js +197 -13
- package/src/sources/project-artifacts.js +355 -0
package/README.md
CHANGED
|
@@ -91,8 +91,22 @@ won't be built into the tool that writes it.
|
|
|
91
91
|
- Scans your local AI-agent session transcripts for high-confidence secret
|
|
92
92
|
patterns: cloud provider keys, private key blocks, OAuth/API tokens,
|
|
93
93
|
database connection strings, and more (see `src/patterns.js`).
|
|
94
|
+
- Sees through two transcript-specific disguises. A credential present only
|
|
95
|
+
base64-encoded on a line (an env dump piped through `base64`, wrap
|
|
96
|
+
newlines included) is decoded and rescanned with the high-confidence
|
|
97
|
+
vendor-prefixed rules; the report marks it `base64-wrapped` and redacts
|
|
98
|
+
the decoded value. A credential split across two adjacent streaming
|
|
99
|
+
records, contiguous on neither line, is rejoined at the content boundary
|
|
100
|
+
and rescanned; the report marks it `split across lines` with the line
|
|
101
|
+
pair. Both are general mechanisms with stated limits (one decode level,
|
|
102
|
+
no base64 blocks spanning physical lines, two-way splits only; see
|
|
103
|
+
`src/decode.js`).
|
|
104
|
+
- Covers Stripe keys in both modes: live (`sk_live`/`rk_live`) and test
|
|
105
|
+
(`sk_test`/`rk_test`), because a leaked test key still holds real
|
|
106
|
+
permissions in its sandbox and reveals account structure.
|
|
94
107
|
- Redacts everything in its own output. You get a shape and a first/last-4
|
|
95
|
-
preview, never the real value, including in `--json` mode.
|
|
108
|
+
preview, never the real value, including in `--json` mode. A decoded or
|
|
109
|
+
rejoined secret is redacted exactly like a plain one.
|
|
96
110
|
- Tells you how many **distinct** secrets it found versus how many times one
|
|
97
111
|
got echoed back across tool calls, so the headline number reflects real
|
|
98
112
|
exposure, not repetition.
|
|
@@ -103,6 +117,14 @@ won't be built into the tool that writes it.
|
|
|
103
117
|
files), and checks the places the 2026 supply-chain campaigns planted
|
|
104
118
|
persistence: hooks, dropper scripts, auto-run tasks, invisible Unicode.
|
|
105
119
|
See the next section.
|
|
120
|
+
- Attaches a **rotation runbook** to every finding: the vendor's real
|
|
121
|
+
revocation path, verified against their own docs, plus a local
|
|
122
|
+
acknowledgement ledger so "found it" can become "closed it". See
|
|
123
|
+
[Rotation](#rotation-from-found-to-closed).
|
|
124
|
+
- Scans a **repository checkout** instead of the machine with
|
|
125
|
+
`--project <dir>`: committed transcripts, agent configs, and root `.env`
|
|
126
|
+
files, built for CI and pre-commit. See
|
|
127
|
+
[CI and pre-commit](#ci-and-pre-commit).
|
|
106
128
|
|
|
107
129
|
## Beyond transcripts: configs and planted persistence
|
|
108
130
|
|
|
@@ -115,7 +137,11 @@ packages, because Claude Code's approved-command cache quietly accumulates
|
|
|
115
137
|
tokens and no packaging tool ignores `.claude/` by default. So as of v0.2.0,
|
|
116
138
|
`residoo scan` includes an **agent config source** covering the home-level
|
|
117
139
|
config files of Claude Code, Claude Desktop, Cursor, Gemini CLI, Codex, and
|
|
118
|
-
Kiro.
|
|
140
|
+
Kiro. As of v0.3.1 it also reaches project-level Claude Code configs
|
|
141
|
+
(`.mcp.json`, `.claude/settings.json`, `.claude/settings.local.json`) by
|
|
142
|
+
resolving the project roots the agent itself recorded at home level
|
|
143
|
+
(`~/.claude.json` and transcript `cwd` fields) rather than by walking or
|
|
144
|
+
guessing directories; only those vendor-fixed per-project filenames are read. Every path is verified against a real install or published sources (one
|
|
119
145
|
disclosed exception, a stealer-target path backed by a single published
|
|
120
146
|
list, argued openly in the source header), with the full verification trail
|
|
121
147
|
written into `src/sources/agent-configs.js`.
|
|
@@ -159,15 +185,19 @@ counted as clean.
|
|
|
159
185
|
│ │
|
|
160
186
|
│ 42 transcript sources agent config files │
|
|
161
187
|
│ ~/.claude, Cursor, Codex… settings · MCP · memory │
|
|
188
|
+
│ (--project <dir>: a repo checkout instead of the machine) │
|
|
162
189
|
│ │ │ │
|
|
163
190
|
│ ├──────────────┬───────────────┤ │
|
|
164
191
|
│ ▼ │ ▼ │
|
|
165
192
|
│ stream + match │ integrity checks │
|
|
166
|
-
│
|
|
193
|
+
│ 36 verified rules │ hooks · droppers · │
|
|
167
194
|
│ │ │ zero-width unicode │
|
|
168
195
|
│ ▼ ▼ │ │
|
|
169
196
|
│ redacted report (first/last 4 chars only) ◀────────────┤
|
|
170
197
|
│ │ │
|
|
198
|
+
│ ├─▶ rotation hints per finding · explain / ack │
|
|
199
|
+
│ │ ledger: ~/.residoo/rotations.json │
|
|
200
|
+
│ │ │
|
|
171
201
|
│ ▼ --seal (only if you ask) │
|
|
172
202
|
│ AES-256-GCM vault · scrypt key · encrypted manifest │
|
|
173
203
|
│ │ │ │
|
|
@@ -179,7 +209,9 @@ counted as clean.
|
|
|
179
209
|
|
|
180
210
|
The `--seal` and `--upload-cloudroam` legs never run unless you pass their
|
|
181
211
|
flag. Everything above the vault happens on every scan; nothing in the
|
|
182
|
-
diagram ever modifies or deletes an existing file.
|
|
212
|
+
diagram ever modifies or deletes an existing file. The one exception, stated
|
|
213
|
+
in the open: `residoo ack` writes residoo's own rotation ledger at
|
|
214
|
+
`~/.residoo/rotations.json` (atomic, redacted, never a user file).
|
|
183
215
|
|
|
184
216
|
## Sealing what it finds
|
|
185
217
|
|
|
@@ -207,6 +239,76 @@ touches the network, it never runs unless you pass the flag, and only
|
|
|
207
239
|
ciphertext is transmitted.** The vault is sealed before upload code ever
|
|
208
240
|
executes.
|
|
209
241
|
|
|
242
|
+
## Rotation: from found to closed
|
|
243
|
+
|
|
244
|
+
Detection without rotation is theater, and the field's own numbers say so:
|
|
245
|
+
64% of secrets leaked publicly in 2022 were still valid years later, 88% of
|
|
246
|
+
re-verified leaked AWS keys still authenticated, and the median time to
|
|
247
|
+
remediate a GitHub-leaked secret is 94 days. A scanner that stops at "found
|
|
248
|
+
it" leaves all of that untouched. So every finding in a residoo report comes
|
|
249
|
+
with the way out:
|
|
250
|
+
|
|
251
|
+
- **A rotation hint per finding**, from a per-rule guidance map covering all
|
|
252
|
+
36 detection rules (plus the opt-in noisy ones). Where a rotation URL is
|
|
253
|
+
shown, that exact URL was fetched and confirmed to document rotating or
|
|
254
|
+
revoking that credential type; where a vendor's docs are login-walled or
|
|
255
|
+
unfetchable, the report gives the console path in words instead of a link
|
|
256
|
+
it could not verify. Generic shapes (a JWT, a bearer header) get honest
|
|
257
|
+
generic guidance that says how to identify the issuer, never a pretend
|
|
258
|
+
vendor.
|
|
259
|
+
- **`residoo explain <rule-id>`** prints the full runbook for one credential
|
|
260
|
+
type: where to revoke, the steps, and what revocation actually does at
|
|
261
|
+
that vendor. `residoo explain --list` shows the whole catalogue.
|
|
262
|
+
- **`residoo ack <fingerprint>`** records that you rotated one finding.
|
|
263
|
+
Every finding carries a stable fingerprint (derived only from
|
|
264
|
+
already-redacted material, so the ledger can never leak), shown in the
|
|
265
|
+
report and in `--json`. Acknowledged findings are reported as such on the
|
|
266
|
+
next scan instead of re-alarming forever. The ledger lives at
|
|
267
|
+
`~/.residoo/rotations.json`: residoo's own file, written atomically, ack
|
|
268
|
+
notes redacted through the same pipeline as previews.
|
|
269
|
+
- **Order matters, and the report says so when it does.** The ChainDrop
|
|
270
|
+
campaign (Aug 2026) shipped a token monitor that fires an attacker payload
|
|
271
|
+
the moment the stolen GitHub token is revoked. When one scan finds both
|
|
272
|
+
integrity warnings and leaked credentials, the report tells you to remove
|
|
273
|
+
the planted persistence first and rotate second, because "rotate
|
|
274
|
+
everything now" advice can itself trigger the damage.
|
|
275
|
+
|
|
276
|
+
Acks change what the report says, never what CI does: `--fail-on-find`
|
|
277
|
+
fails on every finding, acknowledged or not, unless you explicitly pass
|
|
278
|
+
`--allow-acked` (integrity warnings always fail either way).
|
|
279
|
+
|
|
280
|
+
## CI and pre-commit
|
|
281
|
+
|
|
282
|
+
`residoo scan --project <dir>` scans a repository checkout instead of the
|
|
283
|
+
machine it runs on: committed agent transcripts (Claude Code `.jsonl`
|
|
284
|
+
trees, Codex `rollout-*.jsonl`, SpecStory histories), agent config and
|
|
285
|
+
rules files at any depth, and root-level `.env` files, plus the integrity
|
|
286
|
+
checks anchored at that directory. It deliberately does not touch the
|
|
287
|
+
machine's home-level sources, so a clean CI run means the checkout is
|
|
288
|
+
clean and claims nothing about anyone's laptop.
|
|
289
|
+
|
|
290
|
+
As a GitHub Action (this repository doubles as a composite action):
|
|
291
|
+
|
|
292
|
+
```yaml
|
|
293
|
+
steps:
|
|
294
|
+
- uses: actions/checkout@v4
|
|
295
|
+
- uses: dandovdub/residoo@v0.3.0
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
As a pre-commit hook:
|
|
299
|
+
|
|
300
|
+
```yaml
|
|
301
|
+
repos:
|
|
302
|
+
- repo: https://github.com/dandovdub/residoo
|
|
303
|
+
rev: v0.3.0
|
|
304
|
+
hooks:
|
|
305
|
+
- id: residoo
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Or with no integration at all: `npx --yes residoo scan --project . --fail-on-find`.
|
|
309
|
+
Exit codes, inputs, and exactly what project mode does and does not see are
|
|
310
|
+
documented in [docs/ci.md](docs/ci.md).
|
|
311
|
+
|
|
210
312
|
## What it does not do
|
|
211
313
|
|
|
212
314
|
- **No network calls in the default path, and none at all unless you
|
|
@@ -232,6 +334,20 @@ npm install -g residoo
|
|
|
232
334
|
residoo scan
|
|
233
335
|
```
|
|
234
336
|
|
|
337
|
+
A Homebrew formula ships in this repo at `packaging/homebrew/`. It installs
|
|
338
|
+
the exact tarball published to npm (same bits, sha256 verified), so Homebrew
|
|
339
|
+
is a second door to the same release, not a second build. Once the tap
|
|
340
|
+
repository (`dandovdub/homebrew-residoo`) is published, installation is:
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
brew tap dandovdub/residoo
|
|
344
|
+
brew install residoo
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Until the tap is up, npm above is the way in. The formula always points at
|
|
348
|
+
the latest *published* npm release (its sha256 is computed from the real
|
|
349
|
+
tarball), so it can lag a fresh tag by one publish cycle.
|
|
350
|
+
|
|
235
351
|
Requires Node.js 18+. The SQLite-backed sources listed below additionally
|
|
236
352
|
need 22.5+; residoo still runs and scans every line-delimited/JSON source,
|
|
237
353
|
including Claude Code, fine without it. Zero runtime dependencies, and you
|
|
@@ -243,10 +359,14 @@ can check `package.json` rather than take that on faith.
|
|
|
243
359
|
residoo scan [options]
|
|
244
360
|
|
|
245
361
|
--json machine-readable output (full detail, still redacted)
|
|
362
|
+
--project [dir] scan a repository checkout instead of this machine
|
|
363
|
+
(committed transcripts, agent configs, root .env)
|
|
246
364
|
--include-noisy also run broad, false-positive-prone rules
|
|
247
365
|
--include-suppressed also show matches that looked like placeholder/example text
|
|
248
366
|
--fail-on-find exit code 1 if anything is found (for CI): secret
|
|
249
367
|
findings and integrity warnings count, review items don't
|
|
368
|
+
--allow-acked with --fail-on-find: acknowledged findings no longer
|
|
369
|
+
fail the run (pending ones and warnings still do)
|
|
250
370
|
--no-integrity skip the integrity checks
|
|
251
371
|
--no-color disable ANSI colour
|
|
252
372
|
|
|
@@ -255,6 +375,10 @@ residoo scan [options]
|
|
|
255
375
|
--upload-cloudroam also upload the sealed vault (needs CLOUDROAM_API_KEY,
|
|
256
376
|
--connector <id>, --bucket <name>; ciphertext only)
|
|
257
377
|
|
|
378
|
+
residoo explain <rule-id> full rotation runbook for one rule
|
|
379
|
+
residoo explain --list every rule id and label
|
|
380
|
+
residoo ack <fingerprint> [--note <text>] mark one finding rotated
|
|
381
|
+
|
|
258
382
|
residoo unseal <vault-dir> list a vault's contents
|
|
259
383
|
residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
|
|
260
384
|
```
|
|
@@ -266,7 +390,10 @@ so pick one you keep.
|
|
|
266
390
|
## Sources supported today
|
|
267
391
|
|
|
268
392
|
43 sources as of this writing (42 transcript stores plus the agent-config
|
|
269
|
-
source described above), in two honestly-distinct tiers.
|
|
393
|
+
source described above), in two honestly-distinct tiers. Project mode
|
|
394
|
+
(`--project`) adds one more, opt-in source (`src/sources/project-artifacts.js`)
|
|
395
|
+
that scans a repository checkout rather than the machine and never
|
|
396
|
+
participates in a default scan. See
|
|
270
397
|
`src/sources/index.js` for the full list and grouping, and each source file's
|
|
271
398
|
own header for exactly what was and wasn't checked.
|
|
272
399
|
|
|
@@ -345,10 +472,19 @@ submitting. See the note above on why that matters here specifically.
|
|
|
345
472
|
|
|
346
473
|
Shape-based detection can't tell a real secret from a realistic-looking
|
|
347
474
|
example in a fetched web page or a piece of documentation your agent read
|
|
348
|
-
aloud back to you.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
475
|
+
aloud back to you. Three suppression layers narrow the gap: known
|
|
476
|
+
vendor-documented example values (AWS's `AKIAIOSFODNN7EXAMPLE` and its
|
|
477
|
+
siblings, GitHub's docs tokens, jwt.io's demo token) are suppressed by
|
|
478
|
+
exact match; a placeholder body built from one repeated character (no
|
|
479
|
+
vendor issues zero-entropy key material) is suppressed by value; and
|
|
480
|
+
placeholder-looking context around a match catches the common UI-hint
|
|
481
|
+
case. The two value-based layers apply identically to base64-decoded and
|
|
482
|
+
boundary-joined findings, since a decoded example is the same non-secret
|
|
483
|
+
as a plain one. None of the three catches every case, and all are
|
|
484
|
+
re-includable with `--include-suppressed`. Treat every finding as a lead
|
|
485
|
+
to check, not a
|
|
486
|
+
certainty. The same is true of every tool in this category, including the
|
|
487
|
+
well-established ones.
|
|
352
488
|
|
|
353
489
|
## License
|
|
354
490
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CloudRoam (https://cloudroam.io)",
|
package/src/cli.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|