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 +82 -6
- package/SECURITY.md +10 -1
- package/package.json +1 -1
- package/src/cli.js +26 -0
- package/src/decode.js +28 -2
- package/src/guard.js +128 -0
- package/src/mcpTools.js +101 -8
- package/src/scan.js +27 -6
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/residoo)
|
|
12
12
|
[](https://github.com/dandovdub/residoo/actions/workflows/ci.yml)
|
|
13
|
+
[](https://scorecard.dev/viewer/?uri=github.com/dandovdub/residoo)
|
|
13
14
|
[](LICENSE)
|
|
14
15
|
[](package.json)
|
|
15
16
|
[](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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 };
|