residoo 0.7.1 → 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
@@ -10,6 +10,7 @@
10
10
 
11
11
  [![npm version](https://img.shields.io/npm/v/residoo)](https://www.npmjs.com/package/residoo)
12
12
  [![CI](https://github.com/dandovdub/residoo/actions/workflows/ci.yml/badge.svg)](https://github.com/dandovdub/residoo/actions/workflows/ci.yml)
13
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dandovdub/residoo/badge)](https://scorecard.dev/viewer/?uri=github.com/dandovdub/residoo)
13
14
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
14
15
  [![node >=18](https://img.shields.io/badge/node-%3E%3D18-339933)](package.json)
15
16
  [![runtime dependencies: 0](https://img.shields.io/badge/runtime_dependencies-0-brightgreen)](package.json)
@@ -60,13 +61,33 @@ watching 43 sources, 118 files · polling every 5s
60
61
  > trufflehog/betterleaks' verification postures, in
61
62
  > [docs/comparison.md](docs/comparison.md).
62
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
+
63
84
  ## Benchmark: measured, not claimed
64
85
 
65
86
  A reproducible benchmark against 8 real competing tools, on a synthetic-but-
66
87
  pattern-true corpus (72 Claude Code sessions, 45 planted credentials, zero
67
88
  real secrets), with live egress monitoring so "no network calls" is
68
89
  observed, not just documented. Re-run against every meaningful release,
69
- most recently v0.4.14:
90
+ most recently v0.8.0:
70
91
 
71
92
  | | residoo | best of the rest |
72
93
  |---|---|---|
@@ -310,7 +331,7 @@ As a GitHub Action (this repo doubles as a composite action):
310
331
  ```yaml
311
332
  steps:
312
333
  - uses: actions/checkout@v4
313
- - uses: dandovdub/residoo@v0.7.1
334
+ - uses: dandovdub/residoo@v0.8.0
314
335
  ```
315
336
 
316
337
  As a pre-commit hook:
@@ -318,7 +339,7 @@ As a pre-commit hook:
318
339
  ```yaml
319
340
  repos:
320
341
  - repo: https://github.com/dandovdub/residoo
321
- rev: v0.7.1
342
+ rev: v0.8.0
322
343
  hooks:
323
344
  - id: residoo
324
345
  ```
@@ -451,6 +472,10 @@ you running the CLI in a terminal:
451
472
  claude mcp add residoo -- residoo mcp
452
473
  ```
453
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
+
454
479
  or add it directly to `.mcp.json`:
455
480
 
456
481
  ```json
@@ -473,11 +498,27 @@ directly, not built on `@modelcontextprotocol/sdk`: zero runtime
473
498
  dependencies stays true here too. A sixth, opt-in tool exists for
474
499
  injected-credential execution, covered below.
475
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
+
476
513
  ## Cred: run commands with injected credentials
477
514
 
478
- `residoo cred` stores a live, reusable credential in the OS keychain and
479
- runs one allow-listed command with it injected as environment variables:
480
- Claude never sees the raw value, before, during, or after:
515
+ The usual way an AI coding agent ends up able to use a real credential is
516
+ you pasting it into the chat, which puts it in that conversation's
517
+ transcript forever, indistinguishable from any other leak `residoo scan`
518
+ finds. `residoo cred` is the alternative: store the credential once in
519
+ your OS keychain, then let Claude run one allow-listed command with it
520
+ injected as environment variables. Claude never sees the raw value,
521
+ before, during, or after, and it's never written into a script either.
481
522
 
482
523
  ```bash
483
524
  residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
@@ -540,6 +581,41 @@ Storage is macOS (`security`) or Linux (`secret-tool`) only, matching
540
581
  with a clear message rather than half-built. There is no `residoo cred
541
582
  list` in v1: you need to already know the name you set.
542
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
+
543
619
  ## Sources supported today
544
620
 
545
621
  43 sources: 42 transcript stores plus the agent-config source above, in two
package/SECURITY.md CHANGED
@@ -83,7 +83,16 @@ the kind of thing worth impersonating.
83
83
  built it; check the provenance badge on the npm page. One honest
84
84
  exception, stated rather than hidden: the very first release (v0.1.0)
85
85
  was a manual upload to claim the name, so provenance starts at the first
86
- CI-published version after it.
86
+ CI-published version after it. Concretely, this is npm's own [Trusted
87
+ Publishing](https://docs.npmjs.com/trusted-publishers): no stored token
88
+ anywhere, GitHub mints a short-lived, workflow-scoped OIDC credential per
89
+ release, and the resulting provenance statement is signed and published
90
+ to the public [Sigstore transparency log](https://search.sigstore.dev/),
91
+ independently checkable by anyone, not just trusted on residoo's word.
92
+ In [SLSA](https://slsa.dev/) terms that's Build Level 2 (a hosted,
93
+ authenticated build platform generates non-forgeable provenance); Level
94
+ 3 (fully hermetic, isolated builds) isn't implemented yet, stated
95
+ plainly rather than implied.
87
96
  - The only PyPI package is **`residoo`**: a thin official launcher whose
88
97
  entire job is running the npm CLI via `npx`. Its source lives in this
89
98
  repository under `pypi/`. It exists partly so nobody else can hold the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.7.1",
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/decode.js CHANGED
@@ -306,6 +306,25 @@ function findDecodedMatches(line, rules) {
306
306
  const BOUNDARY_WINDOW = 300; // chars taken from each side of the seam
307
307
  const BOUNDARY_MIN_CONTENT = 24; // shorter "longest string" is treated as non-content
308
308
 
309
+ // Minimum characters EACH side of the seam must contribute to a straddling
310
+ // match for it to be trusted as a genuine split, not coincidence. Found via
311
+ // this project's own benchmark stress-testing (2026-09-03), not hypothetical:
312
+ // a variable-length rule (bearer_header, {16,1000}) can sit ONE character
313
+ // short of its own minimum at the end of a line (a near-miss, not a complete
314
+ // match — the existing greedy-extension guard above only recognizes COMPLETE
315
+ // tail-alone matches, so it never sees this case), and if the very next
316
+ // line's content happens to start with even one or two more characters the
317
+ // pattern's class allows, the straddle pass stitches two entirely unrelated,
318
+ // benign lines into a fabricated value that exists in neither. Existing
319
+ // legitimate-split tests (tests/smoke.js) cut real secrets 9-11 characters
320
+ // from each end, comfortably clear of this floor; a genuine chunked-
321
+ // streaming boundary landing with less than this on one side, while
322
+ // possible, is far rarer than the coincidental-concatenation failure mode
323
+ // this exists to close, and the fragment still gets caught by the raw,
324
+ // single-line pass once enough of it lands on either side to satisfy the
325
+ // rule outright.
326
+ const BOUNDARY_MIN_CONTRIBUTION = 4;
327
+
309
328
  /**
310
329
  * Escape-aware list of JSON string-literal CONTENTS on a line. Field names
311
330
  * are included (this walker does not distinguish keys from values); the
@@ -395,8 +414,15 @@ function findBoundaryMatches(contentA, contentB, rules) {
395
414
  const start = m.index;
396
415
  const end = m.index + m[0].length;
397
416
  // Straddle-only: the match must cross the seam, else it lay wholly in
398
- // one line and the single-line pass already reported it.
399
- if (start < seam && end > seam) straddles.push({ start, end, value: m[0] });
417
+ // one line and the single-line pass already reported it. Each side
418
+ // must also contribute a real fragment (BOUNDARY_MIN_CONTRIBUTION,
419
+ // see its own doc comment) -- otherwise this is a near-miss single-
420
+ // line match that unrelated adjacent content happened to push over a
421
+ // length-quantifier's minimum, not a genuine split.
422
+ if (start < seam && end > seam &&
423
+ (seam - start) >= BOUNDARY_MIN_CONTRIBUTION && (end - seam) >= BOUNDARY_MIN_CONTRIBUTION) {
424
+ straddles.push({ start, end, value: m[0] });
425
+ }
400
426
  if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
401
427
  }
402
428
  let flush = null;
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 };