residoo 0.10.0 → 0.12.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
@@ -51,8 +51,10 @@ into the conversation at all — which also means a long session compacting
51
51
  away the exact value you pasted days ago can't force you to paste it
52
52
  again, since there's nothing to lose. `residoo guard` blocks an obviously
53
53
  sensitive file read before it happens (100% recall, 0% false positives on
54
- its own [scored 81-case corpus](bench/guard/RESULTS.md)). All four are
55
- covered in [docs/features.md](docs/features.md).
54
+ its own [scored 81-case corpus](bench/guard/RESULTS.md)) and, via a second
55
+ hook, a secret typed directly into the prompt itself — confirmed against
56
+ Claude Code's own docs to block before the model ever processes it. All
57
+ four are covered in [docs/features.md](docs/features.md).
56
58
 
57
59
  > [!NOTE]
58
60
  > gitleaks and trufflehog scan **commits**. residoo scans the **conversation
@@ -97,7 +99,7 @@ while losing rows, then fixed in public against the classes it was losing
97
99
 
98
100
  ## What it does
99
101
 
100
- - Scans your local AI-agent session transcripts for 79 high-confidence
102
+ - Scans your local AI-agent session transcripts for 84 high-confidence
101
103
  secret patterns: cloud provider keys, private key blocks, OAuth/API
102
104
  tokens, database connection strings, and more. See
103
105
  [`src/patterns.js`](src/patterns.js).
@@ -138,7 +140,8 @@ while losing rows, then fixed in public against the classes it was losing
138
140
  for CI and pre-commit. See [docs/ci.md](docs/ci.md).
139
141
  - `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard`:
140
142
  continuous scanning, conversational queries, credential injection
141
- without pasting, and pre-read blocking. See [docs/features.md](docs/features.md).
143
+ without pasting, and blocking a sensitive file read or a sensitive
144
+ prompt before either happens. See [docs/features.md](docs/features.md).
142
145
 
143
146
  ## What it does not do
144
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.10.0",
3
+ "version": "0.12.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
@@ -218,24 +218,47 @@ Cred:
218
218
  tool, present only when RESIDOO_CRED_ALLOWED_COMMANDS is configured.
219
219
 
220
220
  Guard:
221
- residoo guard a Claude Code PreToolUse hook that blocks an
222
- obviously-sensitive file read (.env, id_rsa,
223
- .aws/credentials, and similar) before it can be
224
- written to the session transcript at all --
225
- prevention, not just detection. Reads one hook
226
- payload from stdin, writes a deny decision to
227
- stdout only when it matches; never blocks on
228
- anything it doesn't recognize. This is narrower
229
- than it sounds: Claude Code's hooks API can see a
230
- proposed Bash command or Read path before it
231
- runs, but never the command's OUTPUT, so this
232
- cannot catch a secret typed into a prompt or one
233
- arriving through an unrelated command's output --
234
- scan/watch/mcp remain the real safety net. Add to
221
+ residoo guard one binary, two Claude Code hooks, dispatched on
222
+ the payload's own hook_event_name:
223
+
224
+ PreToolUse blocks an obviously-sensitive file read
225
+ (.env, id_rsa, .aws/credentials, and similar)
226
+ before it can be written to the session
227
+ transcript at all. Narrower than it sounds:
228
+ Claude Code's hooks API can see a proposed Bash
229
+ command or Read path before it runs, but never
230
+ the command's OUTPUT, so this alone cannot catch
231
+ a secret typed into a prompt or one arriving
232
+ through an unrelated command's output.
233
+
234
+ UserPromptSubmit closes exactly that gap: it
235
+ checks the user's own typed prompt against
236
+ residoo's 79 high-confidence rules (never
237
+ --verify, never --ocr -- this hook has no matcher
238
+ and fires on every single prompt, so it must stay
239
+ fast) and can block it before Claude processes it
240
+ at all -- confirmed directly against Claude
241
+ Code's own docs, not assumed. A block ERASES the
242
+ user's whole typed message, so this path is
243
+ extra-conservative: a documented vendor-example
244
+ key or an obvious placeholder is suppressed, same
245
+ as residoo scan itself. Best-effort, not a
246
+ guarantee: per Claude Code's own docs, a slow
247
+ hook here fails OPEN (the prompt goes through
248
+ unscanned) rather than stalling the session,
249
+ consistent with this hook never blocking on
250
+ anything it doesn't recognize.
251
+
252
+ scan/watch/mcp remain the real safety net either
253
+ way -- this is prevention on top of detection,
254
+ not a replacement for it. Add both to
235
255
  .claude/settings.json:
236
- {"hooks":{"PreToolUse":[{"matcher":"Bash|Read",
237
- "hooks":[{"type":"command",
238
- "command":"residoo guard"}]}]}}
256
+ {"hooks":{
257
+ "PreToolUse":[{"matcher":"Bash|Read",
258
+ "hooks":[{"type":"command",
259
+ "command":"residoo guard"}]}],
260
+ "UserPromptSubmit":[{"hooks":[{"type":"command",
261
+ "command":"residoo guard"}]}]}}
239
262
 
240
263
  Rotation:
241
264
  residoo explain <rule-id> full rotation runbook for one detection rule
package/src/guard.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
 
3
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.
4
+ * `residoo guard`: two Claude Code hooks in one binary, dispatched on the
5
+ * payload's own `hook_event_name` field.
6
+ *
7
+ * PreToolUse blocks an obviously-sensitive file read before it happens,
8
+ * instead of finding the leak in the transcript afterward.
7
9
  *
8
10
  * Scope, stated plainly because it is much narrower than "prevent secrets
9
11
  * from leaking": Claude Code's hooks API gives a PreToolUse hook the
@@ -17,17 +19,53 @@
17
19
  * and similar) -- it cannot catch a secret typed directly into a prompt, a
18
20
  * secret arriving in the output of an otherwise-unremarkable command
19
21
  * (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
+ * UserPromptSubmit closes exactly the "typed directly into a prompt" gap
24
+ * named above: Claude Code's own docs (code.claude.com/docs/en/hooks,
25
+ * fetched and read directly, not summarized secondhand) confirm this hook
26
+ * "runs before every prompt and blocks model processing until it
27
+ * completes" -- a real prevention point, not an after-the-fact alert.
28
+ * Verified precisely, not assumed: the payload's `prompt` field carries the
29
+ * exact submitted text (the field is `prompt`, NOT `user_prompt` -- a
30
+ * specific claim to the contrary did not survive this session's own
31
+ * adversarial verification pass), and a JSON response with
32
+ * `"decision": "block"` "prevents the prompt from being processed and
33
+ * erases it from context." Checked against residoo's existing 79
34
+ * high-confidence PATTERNS.js rules ONLY -- never NOISY_PATTERNS, never
35
+ * --verify (a network call), never --ocr (tesseract, and irrelevant to a
36
+ * text prompt anyway) -- because this hook has NO matcher support (it
37
+ * fires on every single prompt in every session, confirmed in the same
38
+ * docs) and must stay fast: Claude Code's own default timeout for this
39
+ * event's command hooks is 30s, down from 600s elsewhere, specifically
40
+ * because "a stuck hook stalls the session." A false positive here also
41
+ * costs more than a PreToolUse block: it erases the user's entire typed
42
+ * message, not one denied tool call, which is why this path additionally
43
+ * runs the same vendor-example/placeholder suppression `residoo scan`
44
+ * itself uses (see scan.js's VENDOR_EXAMPLE_VALUES/zeroEntropyTail) before
45
+ * ever blocking -- a documented AWS example key or an obvious placeholder
46
+ * must never eat a real prompt.
47
+ *
48
+ * Same fail-open timeout reality as scan.js's whole 30-vendor --verify
49
+ * surface, disclosed rather than hidden: per Claude Code's own docs, a
50
+ * command/http/mcp_tool hook (this one) that times out on UserPromptSubmit
51
+ * has its output discarded and "the prompt still reaches Claude" unblocked
52
+ * -- a genuinely slow scan cannot stall the session, but that also means
53
+ * this is best-effort, additive coverage, not an absolute guarantee, the
54
+ * same honesty already applied to --ocr.
22
55
  *
23
56
  * Fails safe in the direction of NOT blocking on any uncertainty: a
24
57
  * malformed hook payload, an unrecognized tool name, or a parse error all
25
58
  * fall through to "allow" (no stdout, exit 0) rather than denying a call
26
59
  * 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.
60
+ * is silently hang or crash the agent's turn over a tool call -- or a
61
+ * prompt -- that was always going to be fine. `residoo scan`/`watch`/`mcp`
62
+ * remain the actual safety net; this is a narrower, best-effort tripwire on
63
+ * top, not a replacement for them.
29
64
  */
30
65
 
66
+ const { PATTERNS, redact } = require("./patterns");
67
+ const { VENDOR_EXAMPLE_VALUES, zeroEntropyTail } = require("./scan");
68
+
31
69
  // A matched path fragment must be preceded by a path separator or the start
32
70
  // of the string, and followed by either the end of the string (the common
33
71
  // case for Read's file_path) or a shell metacharacter/whitespace (the case
@@ -127,12 +165,49 @@ function evaluateToolInput(toolName, toolInput) {
127
165
  };
128
166
  }
129
167
 
168
+ // High-confidence only, computed once at module load (PATTERNS is a static
169
+ // array): never NOISY_PATTERNS, matching the same elevated bar decodeLine/
170
+ // ocrLine already use in scan.js for content one step removed from a plain
171
+ // file line -- a user's own live-typed prompt deserves at least that same
172
+ // bar, arguably a higher one given what a wrong block costs here (see this
173
+ // file's own docstring).
174
+ const PROMPT_GUARD_RULES = PATTERNS.filter((r) => r.confidence === "high");
175
+
176
+ /**
177
+ * Pure decision function: given a UserPromptSubmit hook payload's raw
178
+ * `prompt` string, decide whether to block. No I/O, fully unit-testable.
179
+ * Suppresses the same way `residoo scan` does (a documented vendor-example
180
+ * key, or an obviously-placeholder zero-entropy tail) before ever blocking
181
+ * -- a false positive here erases the user's entire typed message, not one
182
+ * denied tool call, so this path is deliberately more conservative than
183
+ * evaluateToolInput above, not just a copy of the same bar.
184
+ */
185
+ function evaluatePromptText(promptText) {
186
+ if (typeof promptText !== "string" || !promptText) return { block: false, reason: null };
187
+ for (const rule of PROMPT_GUARD_RULES) {
188
+ rule.re.lastIndex = 0;
189
+ const m = rule.re.exec(promptText);
190
+ if (!m) continue;
191
+ const value = m[0];
192
+ if (VENDOR_EXAMPLE_VALUES.has(value) || zeroEntropyTail(value)) continue;
193
+ return {
194
+ block: true,
195
+ reason: `residoo guard: this prompt looks like it contains ${rule.label} (${redact(value)}). ` +
196
+ `Blocked before it could be sent. If this is a false positive, rephrase or remove it, or disable this hook in .claude/settings.json.`,
197
+ };
198
+ }
199
+ return { block: false, reason: null };
200
+ }
201
+
130
202
  /**
131
- * Reads one PreToolUse hook payload from `input` (default stdin), decides,
132
- * and writes the hook's own JSON response protocol to `output` (default
133
- * stdout) -- exit code is the caller's job (bin/residoo.js), this returns
134
- * the intended process exit code instead of calling process.exit itself,
135
- * matching every other run* function in cli.js.
203
+ * Reads one PreToolUse OR UserPromptSubmit hook payload from `input`
204
+ * (default stdin) -- distinguished by the payload's own `hook_event_name`
205
+ * common field, a single binary handling both the way Claude Code's own
206
+ * hook registration allows -- decides, and writes the hook's own JSON
207
+ * response protocol to `output` (default stdout) -- exit code is the
208
+ * caller's job (bin/residoo.js), this returns the intended process exit
209
+ * code instead of calling process.exit itself, matching every other run*
210
+ * function in cli.js.
136
211
  */
137
212
  async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
138
213
  const chunks = [];
@@ -146,6 +221,20 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
146
221
  return 0; // malformed payload: fail open, never block on something we can't parse
147
222
  }
148
223
 
224
+ // hook_event_name is a documented COMMON field present on every hook
225
+ // event's payload (code.claude.com/docs/en/hooks), not specific to one
226
+ // event -- checked first so a UserPromptSubmit payload (which has no
227
+ // tool_name/tool_input at all) never falls through to evaluateToolInput
228
+ // and is instead routed to its own decision function and its own
229
+ // response shape (decision:"block", not hookSpecificOutput.
230
+ // permissionDecision -- the two events do not share a response schema).
231
+ if (payload.hook_event_name === "UserPromptSubmit") {
232
+ const decision = evaluatePromptText(payload.prompt);
233
+ if (!decision.block) return 0;
234
+ output.write(JSON.stringify({ decision: "block", reason: decision.reason }) + "\n");
235
+ return 0;
236
+ }
237
+
149
238
  const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
150
239
  if (!decision.block) return 0;
151
240
 
@@ -159,4 +248,4 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
159
248
  return 0;
160
249
  }
161
250
 
162
- module.exports = { evaluateToolInput, matchSensitivePath, runGuard, SENSITIVE_PATH_PATTERNS };
251
+ module.exports = { evaluateToolInput, matchSensitivePath, evaluatePromptText, runGuard, SENSITIVE_PATH_PATTERNS };
package/src/patterns.js CHANGED
@@ -539,6 +539,49 @@ const PATTERNS = [
539
539
  // same bare/opaque shape already excluded elsewhere in this file.
540
540
  { id: "akamai_edgegrid_token", label: "Akamai EdgeGrid token", confidence: "high",
541
541
  re: /\bakab-[a-z0-9]{16,32}-[a-z0-9]{6,32}\b/g },
542
+ // Doppler: seven distinct token kinds, all sharing the same dp.<tag>.
543
+ // structure. Confirmed via Doppler's own docs
544
+ // (docs.doppler.com/reference/auth-token-formats), which state plainly
545
+ // that "each token type uses a distinct prefix to enable identification
546
+ // during secret scanning operations" -- a format designed to be
547
+ // regex-detected, not inferred. Service tokens (dp.st.) alone carry an
548
+ // optional environment segment between the tag and the body; every other
549
+ // kind goes straight from the tag to the 40-44-char body.
550
+ { id: "doppler_token", label: "Doppler token", confidence: "high",
551
+ re: /\bdp\.(?:ct|pt|sa|said|scim|audit)\.[A-Za-z0-9]{40,44}\b|\bdp\.st\.(?:[a-z0-9_-]{2,35}\.)?[A-Za-z0-9]{40,44}\b/g },
552
+ // Postman API key. Postman's own docs describe how to generate one but
553
+ // not its literal format; sourced instead from gitleaks' own
554
+ // production-tested rule (config/gitleaks.toml, id "postman-api-token"),
555
+ // same tier as azure_ad_client_secret's sourcing earlier in this file.
556
+ { id: "postman_token", label: "Postman API key", confidence: "high",
557
+ re: /\bPMAK-[a-f0-9]{24}-[a-f0-9]{34}\b/gi },
558
+ // Figma personal access token. Figma's own docs don't publish the
559
+ // format either; sourced from trufflehog's own shipped detectors, which
560
+ // track two real, distinct generations: figd_ (the long-established
561
+ // form, trufflehog's v2 detector) and figp_ (a newer form, trufflehog's
562
+ // v3 detector, no keyword-proximity needed unlike v1's bare UUID shape,
563
+ // which is NOT included here for the same reason Bitbucket's keyword-
564
+ // dependent Client ID/Secret rules were declined -- no distinctive
565
+ // standalone prefix).
566
+ { id: "figma_token", label: "Figma personal access token", confidence: "high",
567
+ re: /\bfig[dp]_[A-Za-z0-9_=-]{40,54}\b/g },
568
+ // Bitbucket App Password (distinct from Bitbucket's Client ID/Secret,
569
+ // declined elsewhere in this file's history for being keyword-dependent
570
+ // with no standalone prefix). Confirmed via an Atlassian staff reply on
571
+ // Atlassian's own community forum naming ATBB as the current App
572
+ // Password prefix -- the same source already used for atlassian_api_token.
573
+ { id: "bitbucket_app_password", label: "Bitbucket App Password", confidence: "high",
574
+ re: /\bATBB[a-zA-Z0-9]{32}\b/g },
575
+ // SonarQube/SonarCloud token. gitleaks' own rule needs "sonar" keyword
576
+ // proximity because its body class also has to catch a bare unprefixed
577
+ // 40-char fallback -- unsafe as a standalone rule, so only the three
578
+ // confirmed literal prefixes are used here, which need no such context.
579
+ // squ_/sqp_/sqa_ confirmed real and current via SonarSource's own docs
580
+ // (docs.sonarsource.com), whose own worked example (sqp_ followed by
581
+ // 1aa323...8a1d13) is added to VENDOR_EXAMPLE_VALUES in scan.js as a
582
+ // documented example, not a findable secret.
583
+ { id: "sonarqube_token", label: "SonarQube/SonarCloud token", confidence: "high",
584
+ re: /\b(?:squ|sqp|sqa)_[a-z0-9=_-]{40}\b/g },
542
585
  ];
543
586
 
544
587
  /**
package/src/rotation.js CHANGED
@@ -1093,6 +1093,58 @@ const ROTATION_GUIDANCE = {
1093
1093
  ],
1094
1094
  revokeNote: "Deactivation is immediate; anything still using the old credential starts failing authentication at once.",
1095
1095
  },
1096
+ // docs.doppler.com/reference/auth-token-formats confirms the prefix
1097
+ // table; the tokens page itself is login-walled.
1098
+ doppler_token: {
1099
+ label: "Doppler token",
1100
+ consolePath: "dashboard.doppler.com > the relevant project/workplace > Access > Tokens (or Service Tokens / Service Accounts, matching which prefix leaked)",
1101
+ steps: [
1102
+ "Identify which token kind leaked from its prefix (dp.pt. personal, dp.st. service, dp.sa. service account, dp.scim. SCIM, dp.audit. audit log)",
1103
+ "Revoke it from that kind's own settings page",
1104
+ "Create a replacement and update whatever used the old one",
1105
+ ],
1106
+ revokeNote: "A dp.sa. (Service Account) or dp.st. (Service Token) leak can reach every secret in its assigned project/config -- treat it as broader than a personal token leak.",
1107
+ },
1108
+ postman_token: {
1109
+ label: "Postman API key",
1110
+ consolePath: "Postman > Settings (gear icon) > API keys",
1111
+ steps: [
1112
+ "Open API keys under your account settings",
1113
+ "Delete the leaked key",
1114
+ "Generate a replacement and update whatever used the old one",
1115
+ ],
1116
+ revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
1117
+ },
1118
+ figma_token: {
1119
+ label: "Figma personal access token",
1120
+ consolePath: "Figma > account Settings > Personal access tokens",
1121
+ steps: [
1122
+ "Open Personal access tokens under account Settings",
1123
+ "Revoke the leaked token",
1124
+ "Create a replacement and update whatever used the old one",
1125
+ ],
1126
+ revokeNote: "Revocation is immediate; anything still using the old token starts failing authentication at once.",
1127
+ },
1128
+ bitbucket_app_password: {
1129
+ label: "Bitbucket App Password",
1130
+ consolePath: "id.atlassian.com > Security > App passwords",
1131
+ steps: [
1132
+ "Open App passwords under account Security settings",
1133
+ "Delete the leaked app password",
1134
+ "Create a replacement with the narrowest scopes it needs",
1135
+ ],
1136
+ revokeNote: "Atlassian is steering users toward API tokens/Access tokens instead of App Passwords -- consider migrating rather than just replacing like-for-like.",
1137
+ },
1138
+ sonarqube_token: {
1139
+ label: "SonarQube/SonarCloud token",
1140
+ consolePath: "SonarQube/SonarCloud > My Account > Security",
1141
+ steps: [
1142
+ "Open the Security tab under My Account",
1143
+ "Revoke the leaked token",
1144
+ "Generate a replacement and update whatever used the old one",
1145
+ ],
1146
+ revokeNote: "Revocation is immediate; anything still using the old token starts failing authentication at once.",
1147
+ },
1096
1148
 
1097
1149
  // ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
1098
1150
  generic_password_assignment: {
package/src/scan.js CHANGED
@@ -217,6 +217,11 @@ function zeroEntropyTail(value) {
217
217
  const VENDOR_EXAMPLE_VALUES = new Set([
218
218
  "AKIAIOSFODNN7EXAMPLE",
219
219
  "AKIAI44QH8DHBEXAMPLE",
220
+ // SonarSource's own worked example, repeated across its docs.sonarsource.com
221
+ // documentation (an API-key usage snippet, not a "this is our example
222
+ // secret" callout page, but used identically and repeatedly the same way
223
+ // AWS's own documented example key is).
224
+ "sqp_" + "1aa323ae0689cd4a1abd062a2ad0a224ae8a1d13",
220
225
  "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
221
226
  "gho_16C7e42F292c6912E7710c838347Ae178B4a",
222
227
  "ghr_1B4a2e77838347a7E420ce178F2E7c6912E169246c34E1ccbF66C46812d16D5B1A9Dc86A1498",
@@ -943,4 +948,4 @@ function emptyResult() {
943
948
  // so residoo_verify_finding's v1 only supports the single-token vendors below.
944
949
  const VERIFIABLE_RULE_IDS = new Set(Object.keys(SIMPLE_VERIFY_FNS));
945
950
 
946
- module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES, VERIFIABLE_RULE_IDS };
951
+ module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES, VERIFIABLE_RULE_IDS, zeroEntropyTail };