residoo 0.7.2 → 0.8.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 +54 -479
- package/package.json +1 -1
- package/src/cli.js +26 -0
- package/src/guard.js +162 -0
- package/src/mcpTools.js +101 -8
- package/src/scan.js +27 -6
package/README.md
CHANGED
|
@@ -43,16 +43,16 @@ scanned here left your machine; residoo makes no network calls.
|
|
|
43
43
|
|
|
44
44
|
That's one snapshot. `residoo watch` runs the same engine continuously and
|
|
45
45
|
alerts the moment a new secret lands, instead of waiting for you to
|
|
46
|
-
remember to scan again
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
remember to scan again. `residoo mcp` lets Claude Code query findings
|
|
47
|
+
conversationally. `residoo cred` removes the reason a credential gets
|
|
48
|
+
pasted into chat in the first place: store it once in your OS keychain,
|
|
49
|
+
run a command with it injected as an environment variable, never typed
|
|
50
|
+
into the conversation at all — which also means a long session compacting
|
|
51
|
+
away the exact value you pasted days ago can't force you to paste it
|
|
52
|
+
again, since there's nothing to lose. `residoo guard` blocks an obviously
|
|
53
|
+
sensitive file read before it happens (100% recall, 0% false positives on
|
|
54
|
+
its own [scored 81-case corpus](bench/guard/RESULTS.md)). All four are
|
|
55
|
+
covered in [docs/features.md](docs/features.md).
|
|
56
56
|
|
|
57
57
|
> [!NOTE]
|
|
58
58
|
> gitleaks and trufflehog scan **commits**. residoo scans the **conversation
|
|
@@ -61,45 +61,21 @@ watching 43 sources, 118 files · polling every 5s
|
|
|
61
61
|
> trufflehog/betterleaks' verification postures, in
|
|
62
62
|
> [docs/comparison.md](docs/comparison.md).
|
|
63
63
|
|
|
64
|
-
Scan and watch tell you what already leaked. The most common way a NEW
|
|
65
|
-
leak happens is pasting a key into the chat so Claude can use it, which
|
|
66
|
-
then sits in that conversation's transcript forever, the exact thing scan
|
|
67
|
-
exists to catch in the first place. `residoo cred` closes that loop: store
|
|
68
|
-
a credential once in your OS keychain, then let Claude run a command with
|
|
69
|
-
it injected as an environment variable, never pasted into the chat, never
|
|
70
|
-
written into a script (see
|
|
71
|
-
[Cred: run commands with injected credentials](#cred-run-commands-with-injected-credentials)):
|
|
72
|
-
|
|
73
|
-
```
|
|
74
|
-
$ residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
|
|
75
|
-
Value for AWS_ACCESS_KEY_ID (input hidden):
|
|
76
|
-
Value for AWS_SECRET_ACCESS_KEY (input hidden):
|
|
77
|
-
Stored credential "aws-prod" (2 env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).
|
|
78
|
-
|
|
79
|
-
$ residoo cred run aws-prod -- aws s3 ls
|
|
80
|
-
exit 0 (succeeded). stdout: 3 line(s), stderr: 0 line(s).
|
|
81
|
-
Command output is never shown by design, only exit status and line counts.
|
|
82
|
-
```
|
|
83
|
-
|
|
84
64
|
## Benchmark: measured, not claimed
|
|
85
65
|
|
|
86
|
-
|
|
87
|
-
pattern-true corpus
|
|
88
|
-
|
|
89
|
-
observed, not just documented. Re-run against every meaningful release,
|
|
90
|
-
most recently v0.7.2:
|
|
66
|
+
Scored #1 of 8 real competing tools on a reproducible, synthetic-but-
|
|
67
|
+
pattern-true corpus, with live egress monitoring so "no network calls" is
|
|
68
|
+
observed, not just documented:
|
|
91
69
|
|
|
92
70
|
| | residoo | best of the rest |
|
|
93
71
|
|---|---|---|
|
|
94
72
|
| Distinct credentials found (all claimed classes) | **45/45 (100%)** | agentsweep 33/42 (79%) |
|
|
95
73
|
| Precision (false positives) | **100%** (0 of 55 flags wrong) | gitleaks, whatileaked, trufflehog also 100% |
|
|
96
|
-
| Network egress during the scan | **none-observed** | 3 of 8 tools attempt real outbound calls in their *default* mode
|
|
74
|
+
| Network egress during the scan | **none-observed** | 3 of 8 tools attempt real outbound calls in their *default* mode |
|
|
97
75
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
benchmark
|
|
101
|
-
reproduction steps: [bench/](bench/). Self-run, pending independent
|
|
102
|
-
reproduction; everything needed to rerun it ships in this repo.
|
|
76
|
+
Published while losing rows, then fixed in public against the classes it
|
|
77
|
+
was losing — full methodology, every dated rerun, and how to reproduce it
|
|
78
|
+
yourself: [docs/benchmark.md](docs/benchmark.md).
|
|
103
79
|
|
|
104
80
|
## What it does
|
|
105
81
|
|
|
@@ -109,245 +85,34 @@ reproduction; everything needed to rerun it ships in this repo.
|
|
|
109
85
|
[`src/patterns.js`](src/patterns.js).
|
|
110
86
|
- Sees through two transcript-specific disguises: a credential dumped only
|
|
111
87
|
as base64 on a line, or split across two adjacent streaming records, is
|
|
112
|
-
decoded/rejoined and rescanned
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
PlanetScale and MongoDB Atlas Service Account credentials. See
|
|
120
|
-
[`src/pairing.js`](src/pairing.js).
|
|
121
|
-
- Decodes a JWT's own `exp` claim locally (no network call, since the
|
|
122
|
-
claim is inside the signed payload) and reports "valid until" or
|
|
123
|
-
"expired" instead of just "last seen." See
|
|
124
|
-
[`src/jwtExpiry.js`](src/jwtExpiry.js).
|
|
88
|
+
decoded/rejoined and rescanned. See [`src/decode.js`](src/decode.js).
|
|
89
|
+
- Pairs an AWS secret access key with a nearby confirmed access key id
|
|
90
|
+
(also PlanetScale and MongoDB Atlas Service Account credentials) and
|
|
91
|
+
reports both at high confidence; ambiguous pairings are reported as
|
|
92
|
+
nothing rather than a guess. See [`src/pairing.js`](src/pairing.js).
|
|
93
|
+
- Decodes a JWT's own `exp` claim locally and reports "valid until" or
|
|
94
|
+
"expired" instead of just "last seen."
|
|
125
95
|
- **`--verify`** (opt-in, makes a real network call): asks a credential's
|
|
126
|
-
own vendor whether it still authenticates
|
|
127
|
-
|
|
128
|
-
[Verifying credentials are still live](#verifying-credentials-are-still-live)
|
|
129
|
-
below.
|
|
130
|
-
- With `--include-noisy`, filters broad generic-secret rules by how
|
|
131
|
-
machine-random the matched value looks; never applied to the default
|
|
132
|
-
rules. See [`src/rarity.js`](src/rarity.js).
|
|
96
|
+
own vendor whether it still authenticates. 35 vendors today, off by
|
|
97
|
+
default. See [docs/architecture.md](docs/architecture.md#verifying-credentials-are-still-live).
|
|
133
98
|
- Redacts everything in its own output, including `--json`: you get a
|
|
134
99
|
shape and a first/last-4 preview, never the real value.
|
|
135
|
-
- `--sarif` emits SARIF 2.1.0 for GitHub code scanning
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
instead of a typed passphrase. See [Sealing](#sealing-what-it-finds).
|
|
100
|
+
- `--sarif` emits SARIF 2.1.0 for GitHub code scanning.
|
|
101
|
+
- `--seal --keychain` encrypts every transcript with a finding into a
|
|
102
|
+
local vault. See [docs/architecture.md](docs/architecture.md#sealing-what-it-finds).
|
|
139
103
|
- Tells you how many **distinct** secrets it found versus how many times
|
|
140
104
|
one got echoed back across tool calls, so the headline number reflects
|
|
141
105
|
real exposure, not repetition.
|
|
142
|
-
- Flags likely placeholder/example matches separately from real findings,
|
|
143
|
-
rather than hiding them or inflating the count.
|
|
144
106
|
- Also scans agent **config files** and checks for **planted persistence**
|
|
145
|
-
(hooks, droppers, invisible Unicode)
|
|
107
|
+
(hooks, droppers, invisible Unicode) — a different, better-documented
|
|
108
|
+
leak surface. See [docs/architecture.md](docs/architecture.md#beyond-transcripts-configs-and-planted-persistence).
|
|
146
109
|
- Attaches a **rotation runbook** to every finding, plus a local
|
|
147
|
-
acknowledgement ledger. See [
|
|
148
|
-
- `--project <dir>` scans a repository checkout instead of the machine,
|
|
149
|
-
CI and pre-commit. See [
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
- **`residoo mcp`**: query findings and manage rotation from inside Claude
|
|
154
|
-
Code itself, over a hand-rolled MCP server. See
|
|
155
|
-
[MCP: query findings from inside Claude Code](#mcp-query-findings-from-inside-claude-code)
|
|
156
|
-
below.
|
|
157
|
-
- **`residoo cred`**: store a live credential in the OS keychain and run
|
|
158
|
-
one allow-listed command with it injected as an environment variable,
|
|
159
|
-
never seen by the caller. See
|
|
160
|
-
[Cred: run commands with injected credentials](#cred-run-commands-with-injected-credentials)
|
|
161
|
-
below.
|
|
162
|
-
|
|
163
|
-
## Beyond transcripts: configs and planted persistence
|
|
164
|
-
|
|
165
|
-
Transcripts leak what your agent *saw*. Config files leak what it was
|
|
166
|
-
*configured with*, and that's the better-measured problem: GitGuardian
|
|
167
|
-
counted 24,008 secrets inside MCP config files on public GitHub (2,117
|
|
168
|
-
still valid), and Lakera found live credentials inside
|
|
169
|
-
`.claude/settings.local.json` shipped in ~30 published npm packages. So
|
|
170
|
-
`residoo scan` also covers the home-level config files of Claude Code,
|
|
171
|
-
Claude Desktop, Cursor, Gemini CLI, Codex, and Kiro, plus project-level
|
|
172
|
-
Claude Code configs (`.mcp.json`, `.claude/settings*.json`) resolved from
|
|
173
|
-
project roots the agent itself recorded, never by guessing directories.
|
|
174
|
-
|
|
175
|
-
Those same files are where 2026's supply-chain campaigns (Mini Shai-Hulud,
|
|
176
|
-
Miasma, the keyv/ChainDrop wave, TrapDoor) planted hooks, dropper scripts,
|
|
177
|
-
and zero-width-Unicode prompt injection. Every scan now also runs
|
|
178
|
-
**integrity checks** over those exact locations:
|
|
179
|
-
|
|
180
|
-
- Every auto-executing hook is listed; only a published campaign IOC or
|
|
181
|
-
campaign-shaped behavior (piping a download into a shell, base64-decode-
|
|
182
|
-
then-execute) escalates to a warning.
|
|
183
|
-
- Loose scripts in `.claude/` and known planted filenames are flagged by
|
|
184
|
-
name.
|
|
185
|
-
- `CLAUDE.md`, `.cursorrules`, and `.cursor/rules/*` are checked for
|
|
186
|
-
zero-width Unicode.
|
|
187
|
-
- `.vscode/tasks.json` is parsed for folder-open auto-run tasks.
|
|
188
|
-
|
|
189
|
-
Read-only like everything else. `--no-integrity` skips it entirely. A
|
|
190
|
-
config that can't be read is reported as unverified, never silently
|
|
191
|
-
counted clean.
|
|
192
|
-
|
|
193
|
-
## How it works
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
YOUR MACHINE · no network calls
|
|
197
|
-
┌───────────────────────────────────────────────────────────────┐
|
|
198
|
-
│ │
|
|
199
|
-
│ 42 transcript sources agent config files │
|
|
200
|
-
│ ~/.claude, Cursor, Codex… settings · MCP · memory │
|
|
201
|
-
│ (--project <dir>: a repo checkout instead of the machine) │
|
|
202
|
-
│ │ │ │
|
|
203
|
-
│ ├──────────────┬───────────────┤ │
|
|
204
|
-
│ ▼ │ ▼ │
|
|
205
|
-
│ stream + match │ integrity checks │
|
|
206
|
-
│ 50 verified rules │ hooks · droppers · │
|
|
207
|
-
│ │ │ zero-width unicode │
|
|
208
|
-
│ ▼ ▼ │ │
|
|
209
|
-
│ redacted report (first/last 4 chars only) ◀────────────┤
|
|
210
|
-
│ │ │
|
|
211
|
-
│ ├─▶ rotation hints per finding · explain / ack │
|
|
212
|
-
│ │ ledger: ~/.residoo/rotations.json │
|
|
213
|
-
│ │ │
|
|
214
|
-
│ ▼ --seal (only if you ask) │
|
|
215
|
-
│ AES-256-GCM vault · scrypt key · encrypted manifest │
|
|
216
|
-
│ │ │ │
|
|
217
|
-
│ ▼ unseal --restore ▼ --upload-cloudroam
|
|
218
|
-
│ SHA-256 verified copy ciphertext only ┄┄┄┄┄┄┄┄┄▶
|
|
219
|
-
│ │
|
|
220
|
-
└───────────────────────────────────────────────────────────────┘
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
The `--seal` and `--upload-cloudroam` legs never run unless you pass their
|
|
224
|
-
flag. Nothing in the diagram ever modifies or deletes an existing file. The
|
|
225
|
-
one exception, stated in the open: `residoo ack` writes residoo's own
|
|
226
|
-
rotation ledger at `~/.residoo/rotations.json` (atomic, redacted, never a
|
|
227
|
-
user file).
|
|
228
|
-
|
|
229
|
-
## Sealing what it finds
|
|
230
|
-
|
|
231
|
-
Finding a leaked key raises the obvious next question: *now what?*
|
|
232
|
-
|
|
233
|
-
```bash
|
|
234
|
-
residoo scan --seal
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Every transcript that carried a finding is encrypted into a local vault
|
|
238
|
-
directory: AES-256-GCM, key derived from your passphrase with scrypt,
|
|
239
|
-
streamed so an 800MB transcript never touches memory whole. The vault's own
|
|
240
|
-
manifest is encrypted too, so it doesn't advertise what's inside even by
|
|
241
|
-
name. **Originals are never touched.** Once you've verified a restore works
|
|
242
|
-
(`residoo unseal <vault> --restore 0001.sealed --out /tmp/check`, checked
|
|
243
|
-
byte-identical via a recorded SHA-256), deleting the plaintext is your
|
|
244
|
-
decision, made by you.
|
|
245
|
-
|
|
246
|
-
Optionally, `--upload-cloudroam` (with `CLOUDROAM_API_KEY`, `--connector`,
|
|
247
|
-
`--bucket`) copies the sealed vault to [CloudRoam](https://cloudroam.io) for
|
|
248
|
-
durable, cross-cloud storage.
|
|
249
|
-
|
|
250
|
-
> [!IMPORTANT]
|
|
251
|
-
> `--upload-cloudroam` is the *only* feature in residoo that touches the
|
|
252
|
-
> network to send your data anywhere. It never runs unless you pass the
|
|
253
|
-
> flag, and only ciphertext is transmitted: the vault is sealed before any
|
|
254
|
-
> upload code executes.
|
|
255
|
-
|
|
256
|
-
## Verifying credentials are still live
|
|
257
|
-
|
|
258
|
-
`--verify` asks a credential's own vendor whether it still authenticates,
|
|
259
|
-
using the exact value found in your transcript. Off by default, one real
|
|
260
|
-
network call per distinct credential.
|
|
261
|
-
|
|
262
|
-
Three vendors need a paired id+secret: **AWS** (via `sts:get-caller-identity`,
|
|
263
|
-
shelling out to your own `aws` CLI rather than reimplementing request
|
|
264
|
-
signing), **PlanetScale**, and **MongoDB Atlas** (Service Account
|
|
265
|
-
credentials only, the legacy Public/Private Key pair has no distinguishing
|
|
266
|
-
prefix and isn't detected at all). The other 32 are a single credential
|
|
267
|
-
each, one direct API call:
|
|
268
|
-
|
|
269
|
-
Slack · OpenAI · Anthropic · GitHub · Hugging Face · Replicate ·
|
|
270
|
-
DigitalOcean · Pinecone · SendGrid · Groq · xAI · OpenRouter · Stripe · npm ·
|
|
271
|
-
Notion · GitLab · Supabase · ElevenLabs · CircleCI · Airtable · Cloudflare ·
|
|
272
|
-
Heroku · Netlify · Linear · Telegram · Discord webhooks · Vercel · Cerebras ·
|
|
273
|
-
Render · Neon · PostHog · Fly.io
|
|
274
|
-
|
|
275
|
-
Every vendor clears the same two-stage bar before being wired up:
|
|
276
|
-
independent research against that vendor's own current docs, then a
|
|
277
|
-
separate, adversarial pass that tries to refute the first before it's
|
|
278
|
-
trusted. A real, sourced reason (no free endpoint, needs context the
|
|
279
|
-
credential doesn't carry, or a format not confirmed specifically enough to
|
|
280
|
-
detect safely) is why some detected credential types aren't wired to
|
|
281
|
-
`--verify` at all, not an oversight (Fly.io's `fm1a_`/`fm1r_`/`fm2_`
|
|
282
|
-
"macaroon" tokens are the clearest example: real-machine testing produced a
|
|
283
|
-
measured false-positive rate, so that family is detected nowhere in
|
|
284
|
-
residoo). A verified-active credential is escalated to "rotate
|
|
285
|
-
immediately"; a verified-invalid one is reported already dead, no action
|
|
286
|
-
needed. See [`src/verify.js`](src/verify.js).
|
|
287
|
-
|
|
288
|
-
## Rotation: from found to closed
|
|
289
|
-
|
|
290
|
-
Detection without rotation is theater: 64% of secrets leaked publicly in
|
|
291
|
-
2022 were still valid years later, 88% of re-verified leaked AWS keys still
|
|
292
|
-
authenticated, and the median time to remediate a GitHub-leaked secret is
|
|
293
|
-
94 days. Every finding in a residoo report comes with the way out:
|
|
294
|
-
|
|
295
|
-
- **A rotation hint per finding**, from a guidance map covering all 50
|
|
296
|
-
detection rules. Where shown, a rotation URL was fetched and confirmed to
|
|
297
|
-
document revoking that exact credential type.
|
|
298
|
-
- **`residoo explain <rule-id>`** prints the full runbook: where to revoke,
|
|
299
|
-
the steps, what revocation does. `residoo explain --list` shows the whole
|
|
300
|
-
catalogue.
|
|
301
|
-
- **`residoo ack <fingerprint>`** records that you rotated a finding.
|
|
302
|
-
**`residoo dismiss <fingerprint>`** records that it was never a real
|
|
303
|
-
secret. Both live in `~/.residoo/rotations.json`, residoo's own ledger,
|
|
304
|
-
written atomically, redacted through the same pipeline as previews.
|
|
305
|
-
- **"Recommended actions" leads the report**: how many *distinct* values
|
|
306
|
-
still need a decision, versus how many are already resolved (acked,
|
|
307
|
-
dismissed, or `--verify`-confirmed dead).
|
|
308
|
-
- **The rotation list groups by credential type**, so the URL prints once
|
|
309
|
-
per type. Each value's own line shows its redacted preview, file, and
|
|
310
|
-
when it was last seen.
|
|
311
|
-
- **Order matters, and the report says so.** The ChainDrop campaign (Aug
|
|
312
|
-
2026) shipped a token monitor that fires an attacker payload the moment a
|
|
313
|
-
stolen GitHub token is revoked. When a scan finds both integrity warnings
|
|
314
|
-
and leaked credentials, the report tells you to remove the planted
|
|
315
|
-
persistence first, rotate second.
|
|
316
|
-
|
|
317
|
-
Acks and dismissals change what the report *says*, never what CI *does*:
|
|
318
|
-
`--fail-on-find` fails on every finding, resolved or not, unless you pass
|
|
319
|
-
`--allow-acked` (integrity warnings always fail either way).
|
|
320
|
-
|
|
321
|
-
## CI and pre-commit
|
|
322
|
-
|
|
323
|
-
`residoo scan --project <dir>` scans a repository checkout instead of the
|
|
324
|
-
machine it runs on: committed transcripts, agent configs, root `.env`
|
|
325
|
-
files, plus integrity checks anchored at that directory. It never touches
|
|
326
|
-
the machine's home-level sources, so a clean CI run means the checkout is
|
|
327
|
-
clean and claims nothing about anyone's laptop.
|
|
328
|
-
|
|
329
|
-
As a GitHub Action (this repo doubles as a composite action):
|
|
330
|
-
|
|
331
|
-
```yaml
|
|
332
|
-
steps:
|
|
333
|
-
- uses: actions/checkout@v4
|
|
334
|
-
- uses: dandovdub/residoo@v0.7.2
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
As a pre-commit hook:
|
|
338
|
-
|
|
339
|
-
```yaml
|
|
340
|
-
repos:
|
|
341
|
-
- repo: https://github.com/dandovdub/residoo
|
|
342
|
-
rev: v0.7.2
|
|
343
|
-
hooks:
|
|
344
|
-
- id: residoo
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
Or with no integration at all:
|
|
348
|
-
`npm install -g residoo && residoo scan --project . --fail-on-find`.
|
|
349
|
-
Exit codes and exactly what project mode does and doesn't see are in
|
|
350
|
-
[docs/ci.md](docs/ci.md).
|
|
110
|
+
acknowledgement ledger. See [docs/architecture.md](docs/architecture.md#rotation-from-found-to-closed).
|
|
111
|
+
- `--project <dir>` scans a repository checkout instead of the machine,
|
|
112
|
+
for CI and pre-commit. See [docs/ci.md](docs/ci.md).
|
|
113
|
+
- `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard`:
|
|
114
|
+
continuous scanning, conversational queries, credential injection
|
|
115
|
+
without pasting, and pre-read blocking. See [docs/features.md](docs/features.md).
|
|
351
116
|
|
|
352
117
|
## What it does not do
|
|
353
118
|
|
|
@@ -364,6 +129,13 @@ Exit codes and exactly what project mode does and doesn't see are in
|
|
|
364
129
|
just encrypted a copy of.
|
|
365
130
|
- **No telemetry, no analytics, no update-check ping.**
|
|
366
131
|
|
|
132
|
+
Shape-based detection also can't tell a real secret from a realistic-
|
|
133
|
+
looking example in a fetched web page. Three suppression layers narrow the
|
|
134
|
+
gap, none catches every case, and all are re-includable with
|
|
135
|
+
`--include-suppressed`. Treat every finding as a lead to check, not a
|
|
136
|
+
certainty — true of every tool in this category, including the well-
|
|
137
|
+
established ones.
|
|
138
|
+
|
|
367
139
|
## Install
|
|
368
140
|
|
|
369
141
|
```bash
|
|
@@ -385,9 +157,10 @@ brew install residoo
|
|
|
385
157
|
The Homebrew formula installs the exact tarball published to npm (sha256
|
|
386
158
|
verified): same bits, not a second build.
|
|
387
159
|
|
|
388
|
-
Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed
|
|
389
|
-
residoo still runs fine without it).
|
|
390
|
-
`package.json` rather than take that on
|
|
160
|
+
Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed in
|
|
161
|
+
[docs/sources.md](docs/sources.md); residoo still runs fine without it).
|
|
162
|
+
Zero runtime dependencies: check `package.json` rather than take that on
|
|
163
|
+
faith.
|
|
391
164
|
|
|
392
165
|
## Usage
|
|
393
166
|
|
|
@@ -406,7 +179,7 @@ residoo scan [options]
|
|
|
406
179
|
--no-integrity skip the integrity checks
|
|
407
180
|
--no-color disable ANSI colour
|
|
408
181
|
--verify ask each credential's own vendor if it still authenticates
|
|
409
|
-
(real network call; see
|
|
182
|
+
(real network call; see docs/architecture.md)
|
|
410
183
|
|
|
411
184
|
--seal encrypt every transcript with findings into a local vault
|
|
412
185
|
--vault-dir <dir> vault location (default ./residoo-vault-<stamp>)
|
|
@@ -420,216 +193,18 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
|
|
|
420
193
|
residoo unseal <vault-dir> list a vault's contents
|
|
421
194
|
residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
|
|
422
195
|
|
|
423
|
-
residoo watch
|
|
424
|
-
|
|
425
|
-
--interval <seconds> how often to check for new content (default 5, minimum 1)
|
|
426
|
-
--json NDJSON events on stdout, one line per finding/re-exposure
|
|
427
|
-
--verify same opt-in vendor check as scan --verify, applied to
|
|
428
|
-
each newly found credential once
|
|
429
|
-
--include-noisy, --include-suppressed, --no-color same meaning as scan
|
|
196
|
+
residoo watch / mcp / cred / guard see docs/features.md
|
|
430
197
|
```
|
|
431
198
|
|
|
432
199
|
The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
|
|
433
200
|
prompt. There is no recovery if you lose it, so pick one you keep.
|
|
434
201
|
|
|
435
|
-
## Watch: continuous scanning
|
|
436
|
-
|
|
437
|
-
`residoo scan` is a snapshot. `residoo watch` is the same engine run
|
|
438
|
-
continuously: it polls every source `scan` already covers, and the moment a
|
|
439
|
-
new secret lands in a transcript, prints an alert with the redacted value,
|
|
440
|
-
the rule, the file, and the same rotation runbook a scan finding carries,
|
|
441
|
-
instead of waiting for you to remember to run `scan` again.
|
|
442
|
-
|
|
443
|
-
```
|
|
444
|
-
$ residoo watch
|
|
445
|
-
watching 43 sources, 118 files (61 tailed, 57 rescanned on change)
|
|
446
|
-
polling every 5s; fs.watch is not used, every alert comes from polling
|
|
447
|
-
|
|
448
|
-
2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
|
|
449
|
-
claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
It is watch-from-**now**: run `residoo scan` first for anything already on
|
|
453
|
-
disk, since a fresh `residoo watch` baselines whatever it finds on its first
|
|
454
|
-
sweep silently and only alerts on content written after it starts. A finding
|
|
455
|
-
already acknowledged or dismissed (`residoo ack` / `residoo dismiss`) stays
|
|
456
|
-
suppressed, checked against the same `~/.residoo/rotations.json` ledger, and
|
|
457
|
-
a ledger change made mid-watch takes effect within one poll interval, no
|
|
458
|
-
restart needed. A findings-free sweep prints nothing at all, including to
|
|
459
|
-
its own watched Claude Code session, by design: no other tool in this
|
|
460
|
-
project's own benchmark ([`bench/`](bench/)) has a continuous mode at all,
|
|
461
|
-
verified directly against each one's own `--help` output rather than
|
|
462
|
-
assumed; see [docs/comparison.md](docs/comparison.md) for how the one
|
|
463
|
-
adjacent thing, GitGuardian's `ggshield` AI hook, works differently.
|
|
464
|
-
|
|
465
|
-
## MCP: query findings from inside Claude Code
|
|
466
|
-
|
|
467
|
-
`residoo mcp` runs residoo as an MCP server over stdio, so Claude Code can
|
|
468
|
-
query findings and manage the rotation ledger conversationally instead of
|
|
469
|
-
you running the CLI in a terminal:
|
|
470
|
-
|
|
471
|
-
```bash
|
|
472
|
-
claude mcp add residoo -- residoo mcp
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
or add it directly to `.mcp.json`:
|
|
476
|
-
|
|
477
|
-
```json
|
|
478
|
-
{
|
|
479
|
-
"mcpServers": {
|
|
480
|
-
"residoo": { "type": "stdio", "command": "residoo", "args": ["mcp"] }
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
Five read-only tools, mirroring the CLI exactly: `residoo_scan` (a fresh
|
|
486
|
-
scan, merged with rotation status), `residoo_check` (only what's new
|
|
487
|
-
since the last check in this conversation, backed by the same engine as
|
|
488
|
-
`watch`), `residoo_explain` (a rule's rotation runbook), and
|
|
489
|
-
`residoo_ack` / `residoo_dismiss` (append to the local ledger). Every
|
|
490
|
-
value returned is redacted the same way the CLI's own output is; nothing
|
|
491
|
-
here makes a network call or touches the transcript files themselves.
|
|
492
|
-
Like the rest of residoo, this is hand-rolled against the MCP spec
|
|
493
|
-
directly, not built on `@modelcontextprotocol/sdk`: zero runtime
|
|
494
|
-
dependencies stays true here too. A sixth, opt-in tool exists for
|
|
495
|
-
injected-credential execution, covered below.
|
|
496
|
-
|
|
497
|
-
## Cred: run commands with injected credentials
|
|
498
|
-
|
|
499
|
-
The usual way an AI coding agent ends up able to use a real credential is
|
|
500
|
-
you pasting it into the chat, which puts it in that conversation's
|
|
501
|
-
transcript forever, indistinguishable from any other leak `residoo scan`
|
|
502
|
-
finds. `residoo cred` is the alternative: store the credential once in
|
|
503
|
-
your OS keychain, then let Claude run one allow-listed command with it
|
|
504
|
-
injected as environment variables. Claude never sees the raw value,
|
|
505
|
-
before, during, or after, and it's never written into a script either.
|
|
506
|
-
|
|
507
|
-
```bash
|
|
508
|
-
residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
|
|
509
|
-
# hidden-typed input, once per --env flag; interactive TTY only, no
|
|
510
|
-
# scripted entry, since a live credential is more sensitive than a vault
|
|
511
|
-
# passphrase and should never be typeable into a script or env var.
|
|
512
|
-
|
|
513
|
-
RESIDOO_CRED_ALLOWED_COMMANDS="aws=/usr/local/bin/aws" residoo mcp
|
|
514
|
-
# now residoo_run_with_cred exists as an MCP tool; it does not exist at
|
|
515
|
-
# all (won't appear in the tool list) until this is set.
|
|
516
|
-
|
|
517
|
-
residoo cred run aws-prod -- aws s3 ls
|
|
518
|
-
# same operation from a terminal, for testing without an MCP client.
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
**Why this is safer than it looks, stated precisely, not just asserted:**
|
|
522
|
-
|
|
523
|
-
- `RESIDOO_CRED_ALLOWED_COMMANDS` (`name=/absolute/path,...`) is an
|
|
524
|
-
environment variable the operator sets outside any conversation, read
|
|
525
|
-
fresh on every single invocation. Empty or unset means **nothing may
|
|
526
|
-
run, by design**: this is the actual, sole security boundary.
|
|
527
|
-
- The `command` a caller (human or model) supplies is used **only as a
|
|
528
|
-
lookup key** into that map, never as a path, never resolved via
|
|
529
|
-
`PATH`. This was not always true: a first draft matched by binary
|
|
530
|
-
*name* alone (checked fresh every call, but only verifying the name the
|
|
531
|
-
caller claimed, not the binary that actually ran), and an adversarial
|
|
532
|
-
review found two concrete ways around that: a caller-supplied path
|
|
533
|
-
smuggled straight past the check, and a same-named malicious binary
|
|
534
|
-
planted earlier on the server process's own inherited `PATH`. Both are
|
|
535
|
-
closed structurally now: `command` cannot cause any file other than the
|
|
536
|
-
operator-pinned absolute path to execute, full stop.
|
|
537
|
-
- Arguments are always a structured list, never a shell string, so no
|
|
538
|
-
shell metacharacter ever gets interpreted.
|
|
539
|
-
- The executed command's own stdout/stderr content is **never returned**,
|
|
540
|
-
only exit status and line counts, because that output is itself a
|
|
541
|
-
channel the injected secret could leak through in ways no redaction
|
|
542
|
-
pass can guarantee to catch (an echoed variable, a stack trace).
|
|
543
|
-
- A hung command is killed after 30 seconds, not configurable by the
|
|
544
|
-
caller (letting a model choose its own timeout has no legitimate use
|
|
545
|
-
and only helps an attacker keep a process alive longer).
|
|
546
|
-
- One line goes to `residoo mcp`'s own **stderr** per credential use
|
|
547
|
-
(timestamp, credential name, command, exit code, never the value or
|
|
548
|
-
the arguments). This is **not durable by default**: redirect the MCP
|
|
549
|
-
server's stderr at launch (`residoo mcp 2>> ~/.residoo-cred-audit.log`,
|
|
550
|
-
or your MCP client's equivalent) if you want a persistent trail.
|
|
551
|
-
|
|
552
|
-
**Only ever allow-list narrow, single-purpose CLIs**, never a tool that
|
|
553
|
-
can itself run arbitrary third-party code as part of normal operation
|
|
554
|
-
(`npm`, `npx`, `pip`, `make`, `cargo`, any build tool). Watch out even for
|
|
555
|
-
a seemingly narrow tool with its own plugin system: an allow-listed `gh`
|
|
556
|
-
still receives the injected credential as an inherited environment
|
|
557
|
-
variable in whatever `gh extension exec` or `gh alias` runs, which is
|
|
558
|
-
untrusted the moment it's a third-party extension. This is a residual
|
|
559
|
-
risk allow-listing alone doesn't remove, so narrow the tools you allow-list
|
|
560
|
-
accordingly, and prefer credentials scoped as tightly as the vendor
|
|
561
|
-
allows.
|
|
562
|
-
|
|
563
|
-
Storage is macOS (`security`) or Linux (`secret-tool`) only, matching
|
|
564
|
-
`--seal --keychain`'s own existing platform support; Windows is refused
|
|
565
|
-
with a clear message rather than half-built. There is no `residoo cred
|
|
566
|
-
list` in v1: you need to already know the name you set.
|
|
567
|
-
|
|
568
202
|
## Sources supported today
|
|
569
203
|
|
|
570
|
-
43 sources
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
**Real-install-verified**: run against an actual, populated installation
|
|
575
|
-
and confirmed to find real content: **Claude Code**
|
|
576
|
-
(`~/.claude/projects/**/*.jsonl`) and **agent config files** for its
|
|
577
|
-
Claude-family paths.
|
|
578
|
-
|
|
579
|
-
**Multi-source-corroborated-but-unverified**: backed by 2+ independent,
|
|
580
|
-
credible sources but not checked against a real install on any machine this
|
|
581
|
-
project was built on. Still built to fail loudly rather than silently
|
|
582
|
-
report "all clear":
|
|
583
|
-
|
|
584
|
-
Cursor, Codex CLI, OpenCode, Aider, Cline, Roo Code, Kilo Code, Windsurf,
|
|
585
|
-
PearAI, Trae, Void, Gemini CLI, Qwen Code, Continue, Open Interpreter,
|
|
586
|
-
Goose, GitHub Copilot Chat/CLI, `llm`, Codebuff, Mentat, Hermes, OpenClaw,
|
|
587
|
-
Warp, Crush, Grok Build, Kiro CLI/IDE, Zed, JetBrains Junie/AI Assistant,
|
|
588
|
-
Sourcegraph Cody, Amazon Q Developer, Qodo Gen, OpenHands, Factory Droid
|
|
589
|
-
CLI, Devin CLI, Pi, Google Antigravity, Kimi Code, and `fx`.
|
|
590
|
-
|
|
591
|
-
A few are SQLite-backed (Cursor, Crush, Cody, Devin CLI, Hermes, Kiro CLI,
|
|
592
|
-
`llm`, Trae, Void, Warp, Zed) and need Node.js 22.5+ for the built-in
|
|
593
|
-
`node:sqlite` module. On an older Node, each reports as detected-but-not-
|
|
594
|
-
scanned rather than silently dropping.
|
|
595
|
-
|
|
596
|
-
**Investigated and deliberately not included:** Plandex (client-server,
|
|
597
|
-
nothing local to scan), CodeGPT and Augment Code (account/cloud-based, no
|
|
598
|
-
local transcript file), Replit Agent (cloud-only). Tabby, Tabnine,
|
|
599
|
-
Zencoder, Tongyi Lingma, and Berd didn't clear the 2-independent-source bar
|
|
600
|
-
in the time available. A verified adapter for any of these is a welcome PR.
|
|
601
|
-
|
|
602
|
-
See [`src/sources/index.js`](src/sources/index.js) for the full list, and
|
|
603
|
-
each source file's own header for exactly what was and wasn't checked.
|
|
604
|
-
|
|
605
|
-
## Adding a source
|
|
606
|
-
|
|
607
|
-
A source is a small object with four methods: `id()`, `label()`,
|
|
608
|
-
`available()`, `files()`, `readLines(file)`.
|
|
609
|
-
[`src/sources/claude-code.js`](src/sources/claude-code.js) is the reference
|
|
610
|
-
implementation: copy it, point it at your tool's real local storage path,
|
|
611
|
-
open a PR. Two contracts worth getting right:
|
|
612
|
-
|
|
613
|
-
- **`files()`** yields `{ file, mtimeMs, sizeBytes, broken }`. Set
|
|
614
|
-
`broken: true` for an entry that looked scannable but wasn't (a dangling
|
|
615
|
-
symlink); don't just skip past it silently.
|
|
616
|
-
- **`readLines(file)`** is `async`, returning `{ lines, status, bytesRead }`
|
|
617
|
-
with `status` one of `"complete"`, `"partial"`, `"too-large"`, `"failed"`.
|
|
618
|
-
Whatever's in `lines` for a non-`"complete"` status still gets scanned.
|
|
619
|
-
|
|
620
|
-
Please verify the path actually exists and holds real content before
|
|
621
|
-
submitting.
|
|
622
|
-
|
|
623
|
-
## A known limitation, stated plainly
|
|
624
|
-
|
|
625
|
-
Shape-based detection can't tell a real secret from a realistic-looking
|
|
626
|
-
example in a fetched web page or documentation your agent read back to
|
|
627
|
-
you. Three suppression layers narrow the gap (known vendor-documented
|
|
628
|
-
example values, placeholder bodies built from one repeated character, and
|
|
629
|
-
placeholder-looking surrounding context), but none catches every case, and
|
|
630
|
-
all are re-includable with `--include-suppressed`. Treat every finding as a
|
|
631
|
-
lead to check, not a certainty. The same is true of every tool in this
|
|
632
|
-
category, including the well-established ones.
|
|
204
|
+
43 sources, real-install-verified for Claude Code and its config family,
|
|
205
|
+
multi-source-corroborated for the rest (Cursor, Codex CLI, Cline, Windsurf,
|
|
206
|
+
Gemini CLI, Copilot, and 30+ more). Full list, what "corroborated" means,
|
|
207
|
+
and how to add one: [docs/sources.md](docs/sources.md).
|
|
633
208
|
|
|
634
209
|
## License
|
|
635
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
const { startWatch, isTailable } = require("./watch");
|
|
14
14
|
const { startMcpServer } = require("./mcp");
|
|
15
15
|
const { buildTools } = require("./mcpTools");
|
|
16
|
+
const { runGuard: runGuardEngine } = require("./guard");
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* A source is unavailable for the ordinary reason (not installed — nothing
|
|
@@ -162,6 +163,10 @@ MCP:
|
|
|
162
163
|
with "claude mcp add residoo -- residoo mcp".
|
|
163
164
|
Zero runtime dependencies: the protocol is hand-
|
|
164
165
|
rolled, not the official SDK.
|
|
166
|
+
A 7th tool, residoo_verify_finding, asks a credential's own vendor, live,
|
|
167
|
+
whether it's still active -- the one MCP tool that makes a real network
|
|
168
|
+
call, so it does not exist unless RESIDOO_MCP_ALLOW_VERIFY=1 is set in the
|
|
169
|
+
server's own environment. See the README for the full scope and limits.
|
|
165
170
|
|
|
166
171
|
Cred:
|
|
167
172
|
residoo cred set <name> --env <ENV_VAR_NAME> [--env <ENV_VAR_NAME_2> ...]
|
|
@@ -191,6 +196,26 @@ Cred:
|
|
|
191
196
|
residoo mcp exposes the same operation as the residoo_run_with_cred
|
|
192
197
|
tool, present only when RESIDOO_CRED_ALLOWED_COMMANDS is configured.
|
|
193
198
|
|
|
199
|
+
Guard:
|
|
200
|
+
residoo guard a Claude Code PreToolUse hook that blocks an
|
|
201
|
+
obviously-sensitive file read (.env, id_rsa,
|
|
202
|
+
.aws/credentials, and similar) before it can be
|
|
203
|
+
written to the session transcript at all --
|
|
204
|
+
prevention, not just detection. Reads one hook
|
|
205
|
+
payload from stdin, writes a deny decision to
|
|
206
|
+
stdout only when it matches; never blocks on
|
|
207
|
+
anything it doesn't recognize. This is narrower
|
|
208
|
+
than it sounds: Claude Code's hooks API can see a
|
|
209
|
+
proposed Bash command or Read path before it
|
|
210
|
+
runs, but never the command's OUTPUT, so this
|
|
211
|
+
cannot catch a secret typed into a prompt or one
|
|
212
|
+
arriving through an unrelated command's output --
|
|
213
|
+
scan/watch/mcp remain the real safety net. Add to
|
|
214
|
+
.claude/settings.json:
|
|
215
|
+
{"hooks":{"PreToolUse":[{"matcher":"Bash|Read",
|
|
216
|
+
"hooks":[{"type":"command",
|
|
217
|
+
"command":"residoo guard"}]}]}}
|
|
218
|
+
|
|
194
219
|
Rotation:
|
|
195
220
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
196
221
|
(where to revoke, steps, what revocation does)
|
|
@@ -806,6 +831,7 @@ async function main(argv) {
|
|
|
806
831
|
if (cmd === "watch") return runWatch(args);
|
|
807
832
|
if (cmd === "mcp") return runMcp(args);
|
|
808
833
|
if (cmd === "cred") return runCred(args);
|
|
834
|
+
if (cmd === "guard") return runGuardEngine();
|
|
809
835
|
if (cmd !== "scan") {
|
|
810
836
|
process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
|
|
811
837
|
return 2;
|
package/src/guard.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `residoo guard`: a Claude Code PreToolUse hook that blocks an obviously-
|
|
5
|
+
* sensitive file read before it happens, instead of finding the leak in the
|
|
6
|
+
* transcript afterward.
|
|
7
|
+
*
|
|
8
|
+
* Scope, stated plainly because it is much narrower than "prevent secrets
|
|
9
|
+
* from leaking": Claude Code's hooks API gives a PreToolUse hook the
|
|
10
|
+
* PROPOSED tool input (a Bash command string, a Read file_path) before the
|
|
11
|
+
* tool runs, and lets it deny the call outright -- but it never sees the
|
|
12
|
+
* tool's OUTPUT, and by the time a PostToolUse hook fires, that output is
|
|
13
|
+
* already committed to the transcript and can no longer be redacted. There
|
|
14
|
+
* is no documented hook mechanism for "let the read happen, but strip the
|
|
15
|
+
* secret out of what the model sees." So this can only block INPUT that
|
|
16
|
+
* matches a known-sensitive file path pattern (.env, id_rsa, .aws/credentials,
|
|
17
|
+
* and similar) -- it cannot catch a secret typed directly into a prompt, a
|
|
18
|
+
* secret arriving in the output of an otherwise-unremarkable command
|
|
19
|
+
* (curl, a build log), or any file path this pattern list does not name.
|
|
20
|
+
* `residoo scan`/`watch`/`mcp` remain the actual safety net; this is a
|
|
21
|
+
* narrower, best-effort tripwire on top, not a replacement for them.
|
|
22
|
+
*
|
|
23
|
+
* Fails safe in the direction of NOT blocking on any uncertainty: a
|
|
24
|
+
* malformed hook payload, an unrecognized tool name, or a parse error all
|
|
25
|
+
* fall through to "allow" (no stdout, exit 0) rather than denying a call
|
|
26
|
+
* this module does not understand. The one thing this module must never do
|
|
27
|
+
* is silently hang or crash the agent's turn over a tool call that was
|
|
28
|
+
* always going to be fine.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// A matched path fragment must be preceded by a path separator or the start
|
|
32
|
+
// of the string, and followed by either the end of the string (the common
|
|
33
|
+
// case for Read's file_path) or a shell metacharacter/whitespace (the case
|
|
34
|
+
// for a Bash command string, where the path is one argument among several,
|
|
35
|
+
// e.g. "cat .env && echo done"). Applying this uniformly, rather than a
|
|
36
|
+
// bespoke `$`-anchor per pattern, is what makes every entry below work
|
|
37
|
+
// identically for both tool_input shapes.
|
|
38
|
+
const BOUNDARY = "(?:$|[\\s'\"`;|&)<>])";
|
|
39
|
+
// Left boundary: start of string, a path separator (mid-path, e.g.
|
|
40
|
+
// "/foo/.env"), OR whitespace/a shell metacharacter (the path is one
|
|
41
|
+
// argument in a longer command, e.g. "cat .env && echo done" -- ".env" is
|
|
42
|
+
// preceded by a space, not a separator). Deliberately NOT a hyphen: an
|
|
43
|
+
// earlier version added one (to catch "gcp-service-account-prod.json",
|
|
44
|
+
// see the dedicated pattern below instead) and it broke the public.pem/
|
|
45
|
+
// public.key exclusions below -- a negative lookahead only guards its own
|
|
46
|
+
// anchor position, and a hyphen boundary let the regex engine start
|
|
47
|
+
// matching again from a LATER position inside the same filename (e.g.
|
|
48
|
+
// right after "public-" in "public-key.pem"), silently walking around the
|
|
49
|
+
// exclusion. Kept narrow and per-pattern instead of widening this shared
|
|
50
|
+
// primitive for one case.
|
|
51
|
+
const SEP = "(?:^|[\\s'\"`;|&(<>\\\\/])";
|
|
52
|
+
const pat = (body) => new RegExp(SEP + body + BOUNDARY, "i");
|
|
53
|
+
|
|
54
|
+
// Suffixes that make a .env-shaped path a committed, secret-free template
|
|
55
|
+
// rather than the real thing: never planted with live credentials by
|
|
56
|
+
// convention, and reading one is completely routine (checking which vars
|
|
57
|
+
// a project needs). Found by testing this guard against realistic dev
|
|
58
|
+
// commands, not assumed: cat .env.example was blocked before this existed.
|
|
59
|
+
const ENV_SAFE_SUFFIX = "example|sample|template|dist|default|schema";
|
|
60
|
+
|
|
61
|
+
const SENSITIVE_PATH_PATTERNS = [
|
|
62
|
+
// dotenv files, including staged/numbered variants (.env.local, .env.1),
|
|
63
|
+
// but not a known-safe template suffix (see ENV_SAFE_SUFFIX above).
|
|
64
|
+
{ re: pat(`\\.env(?:\\.(?!(?:${ENV_SAFE_SUFFIX})(?:$|[\\s'"\`;|&)<>.]))[\\w.-]+)?`), label: "a .env file" },
|
|
65
|
+
// SSH private keys: the conventional default names. Deliberately NOT
|
|
66
|
+
// *.pub -- a public key is, by definition, meant to be shared (it's
|
|
67
|
+
// what you paste into GitHub's own SSH keys page); blocking its read
|
|
68
|
+
// protects nothing and was a real false positive found the same way.
|
|
69
|
+
{ re: pat("id_(?:rsa|dsa|ecdsa|ed25519)"), label: "an SSH private key" },
|
|
70
|
+
// The whole .ssh directory, EXCEPT its own *.pub files and known_hosts
|
|
71
|
+
// (host key fingerprints, not credentials -- reading it can't expose
|
|
72
|
+
// anything) -- same public-key/not-actually-sensitive principle as the
|
|
73
|
+
// id_/*.pem/*.key exclusions above, applied to a directory match.
|
|
74
|
+
{ re: new RegExp(SEP + "\\.ssh[\\\\/](?!(?:[\\w.-]+\\.pub|known_hosts(?:\\.old)?)(?:$|[\\s'\"`;|&)<>]))", "i"), label: "the SSH directory" },
|
|
75
|
+
// *.pem/*.key, except a filename that itself says "public": a real
|
|
76
|
+
// private key is never conventionally named that way, and "public.pem"/
|
|
77
|
+
// "public-key.pem" naming a non-sensitive cert is common enough that
|
|
78
|
+
// blocking it is pure noise, not protection.
|
|
79
|
+
{ re: pat("(?!public[-_.])[\\w.-]+\\.pem"), label: "a .pem key file" },
|
|
80
|
+
{ re: pat("(?!public[-_.])[\\w.-]+\\.key"), label: "a .key file" },
|
|
81
|
+
// cloud / vendor credential files with a fixed, well-known name
|
|
82
|
+
{ re: pat("\\.aws[\\\\/](?:credentials|config)"), label: "the AWS credentials file" },
|
|
83
|
+
{ re: pat("\\.netrc"), label: "the .netrc file" },
|
|
84
|
+
{ re: pat("\\.npmrc"), label: "the .npmrc file (may hold a publish token)" },
|
|
85
|
+
{ re: pat("\\.git-credentials"), label: "the git-credentials file" },
|
|
86
|
+
{ re: pat("\\.docker[\\\\/]config\\.json"), label: "the Docker config (may hold registry auth)" },
|
|
87
|
+
{ re: pat("\\.kube[\\\\/]config"), label: "the kubeconfig file" },
|
|
88
|
+
{ re: pat("application_default_credentials\\.json"), label: "gcloud application-default credentials" },
|
|
89
|
+
{ re: pat("credentials\\.json"), label: "a credentials.json file" },
|
|
90
|
+
// No SEP prefix here, on purpose: a real, common naming convention
|
|
91
|
+
// prefixes this with a project/company name and a hyphen (e.g.
|
|
92
|
+
// "gcp-service-account-prod.json"), which the standard SEP boundary
|
|
93
|
+
// (start/whitespace/separator, deliberately not a hyphen -- see SEP's
|
|
94
|
+
// own comment) would miss entirely. "service[_-]?account" is specific
|
|
95
|
+
// enough as a token that not requiring a left boundary here is a safe,
|
|
96
|
+
// narrow exception rather than a reason to widen SEP itself.
|
|
97
|
+
{ re: new RegExp("service[_-]?account[\\w.-]*\\.json" + BOUNDARY, "i"), label: "a GCP service-account key file" },
|
|
98
|
+
{ re: pat("secrets?\\.(?:json|ya?ml)"), label: "a secrets file" },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/** True if `text` (a file path, or a whole shell command string) contains a recognizable sensitive-path match. Returns the matched label or null. */
|
|
102
|
+
function matchSensitivePath(text) {
|
|
103
|
+
if (typeof text !== "string" || !text) return null;
|
|
104
|
+
for (const { re, label } of SENSITIVE_PATH_PATTERNS) {
|
|
105
|
+
if (re.test(text)) return label;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const GUARDED_TOOL_NAMES = new Set(["Bash", "Read"]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Pure decision function: given a PreToolUse hook payload's tool_name and
|
|
114
|
+
* tool_input, decide whether to block. No I/O, fully unit-testable.
|
|
115
|
+
*/
|
|
116
|
+
function evaluateToolInput(toolName, toolInput) {
|
|
117
|
+
if (!GUARDED_TOOL_NAMES.has(toolName) || !toolInput || typeof toolInput !== "object") {
|
|
118
|
+
return { block: false, reason: null };
|
|
119
|
+
}
|
|
120
|
+
const candidate = toolName === "Bash" ? toolInput.command : toolInput.file_path;
|
|
121
|
+
const label = matchSensitivePath(candidate);
|
|
122
|
+
if (!label) return { block: false, reason: null };
|
|
123
|
+
return {
|
|
124
|
+
block: true,
|
|
125
|
+
reason: `residoo guard: this looks like a read of ${label}. Blocked before it could be written to the session transcript. ` +
|
|
126
|
+
`If this is intentional and safe, ask the human to read it themselves, or disable this hook in .claude/settings.json.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads one PreToolUse hook payload from `input` (default stdin), decides,
|
|
132
|
+
* and writes the hook's own JSON response protocol to `output` (default
|
|
133
|
+
* stdout) -- exit code is the caller's job (bin/residoo.js), this returns
|
|
134
|
+
* the intended process exit code instead of calling process.exit itself,
|
|
135
|
+
* matching every other run* function in cli.js.
|
|
136
|
+
*/
|
|
137
|
+
async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
|
|
138
|
+
const chunks = [];
|
|
139
|
+
for await (const chunk of input) chunks.push(chunk);
|
|
140
|
+
const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString("utf-8");
|
|
141
|
+
|
|
142
|
+
let payload;
|
|
143
|
+
try {
|
|
144
|
+
payload = JSON.parse(raw);
|
|
145
|
+
} catch {
|
|
146
|
+
return 0; // malformed payload: fail open, never block on something we can't parse
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
|
|
150
|
+
if (!decision.block) return 0;
|
|
151
|
+
|
|
152
|
+
output.write(JSON.stringify({
|
|
153
|
+
hookSpecificOutput: {
|
|
154
|
+
hookEventName: "PreToolUse",
|
|
155
|
+
permissionDecision: "deny",
|
|
156
|
+
permissionDecisionReason: decision.reason,
|
|
157
|
+
},
|
|
158
|
+
}) + "\n");
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { evaluateToolInput, matchSensitivePath, runGuard, SENSITIVE_PATH_PATTERNS };
|
package/src/mcpTools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const path = require("path");
|
|
4
|
-
const { scan } = require("./scan");
|
|
4
|
+
const { scan, VERIFIABLE_RULE_IDS } = require("./scan");
|
|
5
5
|
const {
|
|
6
6
|
ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed,
|
|
7
7
|
ackFinding, dismissFinding, renderRotation,
|
|
@@ -20,13 +20,17 @@ const keychain = require("./keychain");
|
|
|
20
20
|
* human-facing presenters), and this file's whole job is to never let a
|
|
21
21
|
* byte reach stdout except through mcp.js's own `send()`.
|
|
22
22
|
*
|
|
23
|
-
* `verify` is not exposed as a parameter on
|
|
24
|
-
* human typing `--verify` at a terminal is a deliberate, legible
|
|
25
|
-
* autonomous model choosing a network-triggering parameter
|
|
26
|
-
* conversation is a different trust boundary, and a generic
|
|
27
|
-
* prompt may not surface that a given call also makes a live
|
|
28
|
-
* request with a real secret.
|
|
29
|
-
*
|
|
23
|
+
* `verify` is not exposed as a parameter on residoo_scan/residoo_check, on
|
|
24
|
+
* purpose: a human typing `--verify` at a terminal is a deliberate, legible
|
|
25
|
+
* act; an autonomous model choosing a network-triggering parameter
|
|
26
|
+
* mid-conversation is a different trust boundary, and a generic
|
|
27
|
+
* tool-approval prompt may not surface that a given call also makes a live
|
|
28
|
+
* vendor API request with a real secret. Both hardcode `verify: false`. Live
|
|
29
|
+
* verification instead gets its own narrowly-scoped tool, residoo_verify_finding
|
|
30
|
+
* (one credential per call, gated behind RESIDOO_MCP_ALLOW_VERIFY so it does
|
|
31
|
+
* not exist at all unless an operator deliberately opts in) -- see its own
|
|
32
|
+
* comment below for why that is a materially different, honestly-labeled
|
|
33
|
+
* trust boundary rather than the same one wearing a different name.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
const FINGERPRINT_PATTERN = /^rf1-[0-9a-f]{32}$/;
|
|
@@ -283,6 +287,62 @@ function buildTools({ sources }) {
|
|
|
283
287
|
});
|
|
284
288
|
}
|
|
285
289
|
|
|
290
|
+
async function handleVerifyFinding(args) {
|
|
291
|
+
const errs = rejectUnknownKeys(args, new Set(["fingerprint"]));
|
|
292
|
+
if (typeof args.fingerprint !== "string") {
|
|
293
|
+
errs.push("fingerprint is required and must be a string");
|
|
294
|
+
} else if (!FINGERPRINT_PATTERN.test(args.fingerprint)) {
|
|
295
|
+
errs.push("fingerprint must match ^rf1-[0-9a-f]{32}$ -- copy it verbatim from a prior residoo_scan/residoo_check result, never construct or guess one");
|
|
296
|
+
}
|
|
297
|
+
if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
|
|
298
|
+
|
|
299
|
+
const findEntry = async () => {
|
|
300
|
+
const result = await scan({ sources, includeNoisy: true, includeSuppressed: true, verify: false, noColor: true });
|
|
301
|
+
const rotation = renderRotation(result.findings, loadAcks(), loadDismissed());
|
|
302
|
+
return rotation.entries.find((e) => e.fingerprint === args.fingerprint) || null;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const before = await findEntry();
|
|
306
|
+
if (!before) {
|
|
307
|
+
return textResult({
|
|
308
|
+
fingerprint: args.fingerprint, found: false, verifiable: null, verified: null,
|
|
309
|
+
summary: "No finding with this fingerprint is currently on disk. It may have been resolved, the source file may have changed since it was last seen, or you may need to call residoo_scan first to see current fingerprints.",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!VERIFIABLE_RULE_IDS.has(before.ruleId)) {
|
|
313
|
+
return textResult({
|
|
314
|
+
fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: false, verified: null,
|
|
315
|
+
summary: `residoo cannot live-verify a ${before.ruleId} credential yet. Paired credentials (AWS, PlanetScale, MongoDB Atlas) and credential types with no vendor whoami-style endpoint (JWTs, private keys, bearer tokens of unknown origin, connection strings) are not supported by this tool. Run "residoo scan --project <dir> --verify" from a terminal for AWS/PlanetScale/MongoDB Atlas pairs.`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The actual network call: scoped so ONLY this one fingerprint's
|
|
320
|
+
// credential is ever queued for verification inside scan() (see
|
|
321
|
+
// verifyOnlyFingerprint in src/scan.js), regardless of how many other
|
|
322
|
+
// verifiable credentials exist on this machine. This is the whole reason
|
|
323
|
+
// this tool takes one fingerprint and not a list.
|
|
324
|
+
const result = await scan({ sources, includeNoisy: true, includeSuppressed: true, verify: true, verifyOnlyFingerprint: args.fingerprint, noColor: true });
|
|
325
|
+
const rotation = renderRotation(result.findings, loadAcks(), loadDismissed());
|
|
326
|
+
const after = rotation.entries.find((e) => e.fingerprint === args.fingerprint);
|
|
327
|
+
const checkedAt = new Date().toISOString();
|
|
328
|
+
if (!after || after.verified == null) {
|
|
329
|
+
return textResult({
|
|
330
|
+
fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: true, verified: "unknown",
|
|
331
|
+
checkedAt, summary: "The vendor check could not be completed (network error, timeout, or unexpected response). This does not mean the credential is inactive -- treat it as unverified, not as safe.",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const verified = after.verified === "active" ? "active" : after.verified === "invalid" ? "invalid" : "unknown";
|
|
335
|
+
const summary = verified === "active"
|
|
336
|
+
? `ACTIVE: this is a real, working credential. Rotate it. Checked ${checkedAt}.`
|
|
337
|
+
: verified === "invalid"
|
|
338
|
+
? `Inactive: the vendor rejected it. Checked ${checkedAt}.`
|
|
339
|
+
: `Could not verify${after.verifiedDetail ? `: ${after.verifiedDetail}` : ""}. Treat as unverified, not as safe. Checked ${checkedAt}.`;
|
|
340
|
+
return textResult({
|
|
341
|
+
fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: true, verified,
|
|
342
|
+
verifiedDetail: after.verifiedDetail || null, checkedAt, summary,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
286
346
|
const tools = new Map();
|
|
287
347
|
tools.set("residoo_scan", {
|
|
288
348
|
name: "residoo_scan",
|
|
@@ -391,6 +451,39 @@ function buildTools({ sources }) {
|
|
|
391
451
|
});
|
|
392
452
|
}
|
|
393
453
|
|
|
454
|
+
// Genuinely different from residoo_scan/residoo_check even though it also
|
|
455
|
+
// only reads local disk first: its SECOND step makes a real outbound
|
|
456
|
+
// network request to the credential's own vendor, using the actual secret
|
|
457
|
+
// value, to ask whether it still works. Nothing else in this file ever
|
|
458
|
+
// leaves the machine. Dynamically OMITTED from this Map (same pattern as
|
|
459
|
+
// residoo_run_with_cred above) unless RESIDOO_MCP_ALLOW_VERIFY is set to
|
|
460
|
+
// "1" or "true" -- an operator must deliberately opt in, outside the
|
|
461
|
+
// conversation, before this tool exists at all, so a default `residoo mcp`
|
|
462
|
+
// install stays true to "zero network calls" without qualification. This
|
|
463
|
+
// is a STRICTER gate than residoo_scan/residoo_check need, because giving
|
|
464
|
+
// this its own clearly-named, clearly-described tool (rather than a
|
|
465
|
+
// boolean flag buried on residoo_scan) only solves the discovery/approval-
|
|
466
|
+
// prompt-legibility problem the original MCP design flagged -- it does not
|
|
467
|
+
// by itself decide whether an autonomous model should ever be allowed to
|
|
468
|
+
// trigger a real vendor API call with a real secret. That is the
|
|
469
|
+
// operator's call, made once, outside any conversation.
|
|
470
|
+
const mcpAllowVerify = process.env.RESIDOO_MCP_ALLOW_VERIFY === "1" || process.env.RESIDOO_MCP_ALLOW_VERIFY === "true";
|
|
471
|
+
if (mcpAllowVerify) {
|
|
472
|
+
tools.set("residoo_verify_finding", {
|
|
473
|
+
name: "residoo_verify_finding",
|
|
474
|
+
description: "Ask ONE credential's own vendor, live, whether it is still active -- unlike every other residoo tool, this makes a real outbound network request (e.g. to Slack's auth.test, GitHub's user endpoint) using the actual secret value found on disk. The raw value itself is still never returned to you, only the vendor's answer: active (a real, working credential -- rotate it), invalid (the vendor already rejected it), or unknown (the check failed or timed out -- treat this the same as active, not as reassurance). fingerprint MUST be copied verbatim from a fingerprint field returned by a prior residoo_scan or residoo_check call in this conversation -- never construct or guess one. Only single-token credential types are supported (Slack, GitHub, OpenAI, Anthropic, Stripe, and similar) -- paired credentials (AWS access key + secret, PlanetScale, MongoDB Atlas) return verifiable:false; use `residoo scan --verify` from a terminal for those. This tool only exists because an operator deliberately enabled it outside this conversation (RESIDOO_MCP_ALLOW_VERIFY) -- never ask a human to paste a raw credential value to use it; it already reads the value residoo found on disk.",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
type: "object",
|
|
477
|
+
properties: {
|
|
478
|
+
fingerprint: { type: "string", pattern: "^rf1-[0-9a-f]{32}$", description: "Exact fingerprint string from a prior scan/check finding. Never invent one." },
|
|
479
|
+
},
|
|
480
|
+
required: ["fingerprint"],
|
|
481
|
+
additionalProperties: false,
|
|
482
|
+
},
|
|
483
|
+
handler: handleVerifyFinding,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
394
487
|
return tools;
|
|
395
488
|
}
|
|
396
489
|
|
package/src/scan.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
verifyFlyioBearerToken, verifyMongoDbAtlasCredential, verifyNeonKey, verifyPostHogKey,
|
|
19
19
|
} = require("./verify");
|
|
20
20
|
const { c, makePaint } = require("./color");
|
|
21
|
+
const { fingerprintFinding } = require("./rotation");
|
|
21
22
|
|
|
22
23
|
// PlanetScale's id half: 12 lowercase alphanumeric characters, no prefix —
|
|
23
24
|
// confirmed via planetscale.com/docs/api/reference/service-tokens. Searched
|
|
@@ -265,7 +266,7 @@ function localTimestamp(d) {
|
|
|
265
266
|
* absolute path can itself carry a username or a project name the rest of
|
|
266
267
|
* this report is careful never to print.
|
|
267
268
|
*/
|
|
268
|
-
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null, noColor = false } = {}) {
|
|
269
|
+
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false } = {}) {
|
|
269
270
|
const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
|
|
270
271
|
// The decode pass (see decode.js) only applies high-confidence, vendor-
|
|
271
272
|
// prefixed rules to decoded bytes: random binary that decodes to printable
|
|
@@ -481,7 +482,18 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
481
482
|
// re-echoed across several lines gets several finding objects, and
|
|
482
483
|
// the eventual result is applied to every one of them, not only
|
|
483
484
|
// the first.
|
|
484
|
-
|
|
485
|
+
//
|
|
486
|
+
// verifyOnlyFingerprint (residoo_verify_finding, src/mcpTools.js):
|
|
487
|
+
// when set, this scan still WALKS every file as normal, but only
|
|
488
|
+
// the one finding whose fingerprint matches is ever queued for a
|
|
489
|
+
// real network call -- every other eligible credential on the
|
|
490
|
+
// machine is silently skipped, matching that MCP tool's own
|
|
491
|
+
// documented "one credential per call" promise exactly. Computed
|
|
492
|
+
// from primaryFinding, not secretFinding/idFinding, because the
|
|
493
|
+
// fingerprint a caller holds always names the record they saw in
|
|
494
|
+
// a prior scan/check result, which is always the primary one.
|
|
495
|
+
const matchesTarget = !verifyOnlyFingerprint || fingerprintFinding(primaryFinding) === verifyOnlyFingerprint;
|
|
496
|
+
if (verify && matchesTarget && secretFinding && rawPairedSecret) {
|
|
485
497
|
if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
486
498
|
pendingAwsVerifications.set(m[0], { secretValue: rawPairedSecret, refs: [] });
|
|
487
499
|
}
|
|
@@ -490,7 +502,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
490
502
|
}
|
|
491
503
|
// --verify, PlanetScale: same dedup-by-anchor-value shape as AWS
|
|
492
504
|
// above, keyed by the secret (the confirmed anchor) this time.
|
|
493
|
-
if (verify && planetScaleIdFinding && rawPlanetScaleId) {
|
|
505
|
+
if (verify && matchesTarget && planetScaleIdFinding && rawPlanetScaleId) {
|
|
494
506
|
if (!pendingPlanetScaleVerifications.has(m[0]) && pendingPlanetScaleVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
495
507
|
pendingPlanetScaleVerifications.set(m[0], { idValue: rawPlanetScaleId, refs: [] });
|
|
496
508
|
}
|
|
@@ -499,7 +511,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
499
511
|
}
|
|
500
512
|
// --verify, MongoDB Atlas: same dedup-by-anchor-value shape as
|
|
501
513
|
// AWS/PlanetScale above, keyed by the secret this time.
|
|
502
|
-
if (verify && mongoDbIdFinding && rawMongoDbId) {
|
|
514
|
+
if (verify && matchesTarget && mongoDbIdFinding && rawMongoDbId) {
|
|
503
515
|
if (!pendingMongoDbAtlasVerifications.has(m[0]) && pendingMongoDbAtlasVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
504
516
|
pendingMongoDbAtlasVerifications.set(m[0], { idValue: rawMongoDbId, refs: [] });
|
|
505
517
|
}
|
|
@@ -512,7 +524,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
512
524
|
// dedup-by-value / accumulate-all-refs shape as the AWS map above,
|
|
513
525
|
// just one level deeper (keyed by rule id too, since several
|
|
514
526
|
// vendors share this path).
|
|
515
|
-
if (verify && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
|
|
527
|
+
if (verify && matchesTarget && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
|
|
516
528
|
let byValue = pendingSimpleVerifications.get(rule.id);
|
|
517
529
|
if (!byValue) {
|
|
518
530
|
byValue = new Map();
|
|
@@ -879,4 +891,13 @@ function emptyResult() {
|
|
|
879
891
|
// VENDOR_EXAMPLE_VALUES is exported for the smoke tests, which assert every
|
|
880
892
|
// literal in it is still matched IN FULL by some detection rule — a literal
|
|
881
893
|
// no rule can produce as a whole match is dead weight that suppresses nothing.
|
|
882
|
-
|
|
894
|
+
// Rule ids `scan({verify: true})` knows how to check live, for callers (the
|
|
895
|
+
// residoo_verify_finding MCP tool) that need to tell a caller upfront
|
|
896
|
+
// whether a given finding's ruleId is even eligible, without attempting a
|
|
897
|
+
// scan first. AWS/PlanetScale/MongoDB Atlas pairs are deliberately excluded
|
|
898
|
+
// here even though `scan()` itself does verify them: each needs BOTH halves
|
|
899
|
+
// of a pair in hand at once, which a single fingerprint alone can't express,
|
|
900
|
+
// so residoo_verify_finding's v1 only supports the single-token vendors below.
|
|
901
|
+
const VERIFIABLE_RULE_IDS = new Set(Object.keys(SIMPLE_VERIFY_FNS));
|
|
902
|
+
|
|
903
|
+
module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES, VERIFIABLE_RULE_IDS };
|