residoo 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,7 +87,7 @@ A reproducible benchmark against 8 real competing tools, on a synthetic-but-
87
87
  pattern-true corpus (72 Claude Code sessions, 45 planted credentials, zero
88
88
  real secrets), with live egress monitoring so "no network calls" is
89
89
  observed, not just documented. Re-run against every meaningful release,
90
- most recently v0.7.2:
90
+ most recently v0.8.0:
91
91
 
92
92
  | | residoo | best of the rest |
93
93
  |---|---|---|
@@ -331,7 +331,7 @@ As a GitHub Action (this repo doubles as a composite action):
331
331
  ```yaml
332
332
  steps:
333
333
  - uses: actions/checkout@v4
334
- - uses: dandovdub/residoo@v0.7.2
334
+ - uses: dandovdub/residoo@v0.8.0
335
335
  ```
336
336
 
337
337
  As a pre-commit hook:
@@ -339,7 +339,7 @@ As a pre-commit hook:
339
339
  ```yaml
340
340
  repos:
341
341
  - repo: https://github.com/dandovdub/residoo
342
- rev: v0.7.2
342
+ rev: v0.8.0
343
343
  hooks:
344
344
  - id: residoo
345
345
  ```
@@ -472,6 +472,10 @@ you running the CLI in a terminal:
472
472
  claude mcp add residoo -- residoo mcp
473
473
  ```
474
474
 
475
+ or run `scripts/install-mcp.sh` (also in this repo), which installs residoo
476
+ itself first if it isn't already, then registers it and verifies the
477
+ connection.
478
+
475
479
  or add it directly to `.mcp.json`:
476
480
 
477
481
  ```json
@@ -494,6 +498,18 @@ directly, not built on `@modelcontextprotocol/sdk`: zero runtime
494
498
  dependencies stays true here too. A sixth, opt-in tool exists for
495
499
  injected-credential execution, covered below.
496
500
 
501
+ A seventh tool, `residoo_verify_finding`, is genuinely different from the
502
+ other six: it asks a credential's own vendor, live, whether it's still
503
+ active (the same mechanism as `scan --verify`, scoped to exactly one
504
+ credential per call). This is the one MCP tool that makes a real network
505
+ call, so it does not exist at all unless you set
506
+ `RESIDOO_MCP_ALLOW_VERIFY=1` in the environment `residoo mcp` runs in — a
507
+ default install stays true to "zero network calls" without a caveat.
508
+ Once enabled, pass a `fingerprint` from a prior `residoo_scan`; you get
509
+ back `active`, `invalid`, or `unknown`, never the raw value. Paired
510
+ credentials (AWS access key + secret, PlanetScale, MongoDB Atlas) aren't
511
+ supported yet — use `residoo scan --verify` from a terminal for those.
512
+
497
513
  ## Cred: run commands with injected credentials
498
514
 
499
515
  The usual way an AI coding agent ends up able to use a real credential is
@@ -565,6 +581,41 @@ Storage is macOS (`security`) or Linux (`secret-tool`) only, matching
565
581
  with a clear message rather than half-built. There is no `residoo cred
566
582
  list` in v1: you need to already know the name you set.
567
583
 
584
+ ## Guard: block a sensitive read before it happens
585
+
586
+ Everything above finds a leak after it's already written to disk. `residoo
587
+ guard` is the one piece of residoo that tries to stop one from happening in
588
+ the first place — a Claude Code `PreToolUse` hook that blocks an obviously-
589
+ sensitive file read (`.env`, `id_rsa`, `.aws/credentials`, and similar)
590
+ before the command runs at all.
591
+
592
+ ```json
593
+ {
594
+ "hooks": {
595
+ "PreToolUse": [
596
+ { "matcher": "Bash|Read", "hooks": [{ "type": "command", "command": "residoo guard" }] }
597
+ ]
598
+ }
599
+ }
600
+ ```
601
+ in `.claude/settings.json`. It reads one hook payload from stdin and writes
602
+ a deny decision to stdout only when the proposed command or file path
603
+ matches; anything it doesn't recognize falls through untouched, with zero
604
+ output, exit 0.
605
+
606
+ This is narrower than it might sound, and the gap is worth stating
607
+ plainly rather than implying more than it does: Claude Code's hooks API
608
+ lets a `PreToolUse` hook see the proposed tool INPUT (a Bash command
609
+ string, a Read file path) before it runs, but there is no documented
610
+ mechanism for a hook to see or redact a tool's OUTPUT — by the time a
611
+ `PostToolUse` hook fires, that output is already committed to the
612
+ transcript. So this can only block on the shape of the request, never
613
+ clean up what a command already printed. It will not catch a secret typed
614
+ directly into a prompt, or one arriving in the output of an otherwise
615
+ unremarkable command (`curl`, a build log). `residoo scan` / `watch` /
616
+ `mcp` remain the actual safety net; this is a best-effort tripwire on top
617
+ of them, not a replacement.
618
+
568
619
  ## Sources supported today
569
620
 
570
621
  43 sources: 42 transcript stores plus the agent-config source above, in two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/cli.js CHANGED
@@ -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,128 @@
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).
43
+ const SEP = "(?:^|[\\s'\"`;|&(<>\\\\/])";
44
+ const pat = (body) => new RegExp(SEP + body + BOUNDARY, "i");
45
+
46
+ const SENSITIVE_PATH_PATTERNS = [
47
+ // dotenv files, including staged/numbered variants (.env.local, .env.1)
48
+ { re: pat("\\.env(?:\\.[\\w.-]+)?"), label: "a .env file" },
49
+ // SSH private keys: the conventional default names, and any *.pem/*.key
50
+ { re: pat("id_(?:rsa|dsa|ecdsa|ed25519)(?:\\.pub)?"), label: "an SSH private key" },
51
+ { re: new RegExp(SEP + "\\.ssh[\\\\/]", "i"), label: "the SSH directory" },
52
+ { re: pat("[\\w.-]+\\.pem"), label: "a .pem key file" },
53
+ { re: pat("[\\w.-]+\\.key"), label: "a .key file" },
54
+ // cloud / vendor credential files with a fixed, well-known name
55
+ { re: pat("\\.aws[\\\\/](?:credentials|config)"), label: "the AWS credentials file" },
56
+ { re: pat("\\.netrc"), label: "the .netrc file" },
57
+ { re: pat("\\.npmrc"), label: "the .npmrc file (may hold a publish token)" },
58
+ { re: pat("\\.git-credentials"), label: "the git-credentials file" },
59
+ { re: pat("\\.docker[\\\\/]config\\.json"), label: "the Docker config (may hold registry auth)" },
60
+ { re: pat("\\.kube[\\\\/]config"), label: "the kubeconfig file" },
61
+ { re: pat("application_default_credentials\\.json"), label: "gcloud application-default credentials" },
62
+ { re: pat("credentials\\.json"), label: "a credentials.json file" },
63
+ { re: pat("service[_-]?account[\\w.-]*\\.json"), label: "a GCP service-account key file" },
64
+ { re: pat("secrets?\\.(?:json|ya?ml)"), label: "a secrets file" },
65
+ ];
66
+
67
+ /** True if `text` (a file path, or a whole shell command string) contains a recognizable sensitive-path match. Returns the matched label or null. */
68
+ function matchSensitivePath(text) {
69
+ if (typeof text !== "string" || !text) return null;
70
+ for (const { re, label } of SENSITIVE_PATH_PATTERNS) {
71
+ if (re.test(text)) return label;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ const GUARDED_TOOL_NAMES = new Set(["Bash", "Read"]);
77
+
78
+ /**
79
+ * Pure decision function: given a PreToolUse hook payload's tool_name and
80
+ * tool_input, decide whether to block. No I/O, fully unit-testable.
81
+ */
82
+ function evaluateToolInput(toolName, toolInput) {
83
+ if (!GUARDED_TOOL_NAMES.has(toolName) || !toolInput || typeof toolInput !== "object") {
84
+ return { block: false, reason: null };
85
+ }
86
+ const candidate = toolName === "Bash" ? toolInput.command : toolInput.file_path;
87
+ const label = matchSensitivePath(candidate);
88
+ if (!label) return { block: false, reason: null };
89
+ return {
90
+ block: true,
91
+ reason: `residoo guard: this looks like a read of ${label}. Blocked before it could be written to the session transcript. ` +
92
+ `If this is intentional and safe, ask the human to read it themselves, or disable this hook in .claude/settings.json.`,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Reads one PreToolUse hook payload from `input` (default stdin), decides,
98
+ * and writes the hook's own JSON response protocol to `output` (default
99
+ * stdout) -- exit code is the caller's job (bin/residoo.js), this returns
100
+ * the intended process exit code instead of calling process.exit itself,
101
+ * matching every other run* function in cli.js.
102
+ */
103
+ async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
104
+ const chunks = [];
105
+ for await (const chunk of input) chunks.push(chunk);
106
+ const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString("utf-8");
107
+
108
+ let payload;
109
+ try {
110
+ payload = JSON.parse(raw);
111
+ } catch {
112
+ return 0; // malformed payload: fail open, never block on something we can't parse
113
+ }
114
+
115
+ const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
116
+ if (!decision.block) return 0;
117
+
118
+ output.write(JSON.stringify({
119
+ hookSpecificOutput: {
120
+ hookEventName: "PreToolUse",
121
+ permissionDecision: "deny",
122
+ permissionDecisionReason: decision.reason,
123
+ },
124
+ }) + "\n");
125
+ return 0;
126
+ }
127
+
128
+ 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 ANY tool here, on purpose: a
24
- * human typing `--verify` at a terminal is a deliberate, legible act; an
25
- * autonomous model choosing a network-triggering parameter mid-
26
- * conversation is a different trust boundary, and a generic tool-approval
27
- * prompt may not surface that a given call also makes a live vendor API
28
- * request with a real secret. Every `scan()`/`sweepOnce()` call below
29
- * hardcodes `verify: false`.
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
- if (verify && secretFinding && rawPairedSecret) {
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
- module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES };
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 };