residoo 0.11.0 → 0.13.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 +1 -1
- package/package.json +1 -1
- package/src/guard.js +42 -7
- package/src/patterns.js +43 -0
- package/src/rotation.js +52 -0
- package/src/scan.js +5 -0
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ while losing rows, then fixed in public against the classes it was losing
|
|
|
99
99
|
|
|
100
100
|
## What it does
|
|
101
101
|
|
|
102
|
-
- Scans your local AI-agent session transcripts for
|
|
102
|
+
- Scans your local AI-agent session transcripts for 84 high-confidence
|
|
103
103
|
secret patterns: cloud provider keys, private key blocks, OAuth/API
|
|
104
104
|
tokens, database connection strings, and more. See
|
|
105
105
|
[`src/patterns.js`](src/patterns.js).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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/guard.js
CHANGED
|
@@ -153,13 +153,14 @@ const GUARDED_TOOL_NAMES = new Set(["Bash", "Read"]);
|
|
|
153
153
|
*/
|
|
154
154
|
function evaluateToolInput(toolName, toolInput) {
|
|
155
155
|
if (!GUARDED_TOOL_NAMES.has(toolName) || !toolInput || typeof toolInput !== "object") {
|
|
156
|
-
return { block: false, reason: null };
|
|
156
|
+
return { block: false, label: null, reason: null };
|
|
157
157
|
}
|
|
158
158
|
const candidate = toolName === "Bash" ? toolInput.command : toolInput.file_path;
|
|
159
159
|
const label = matchSensitivePath(candidate);
|
|
160
|
-
if (!label) return { block: false, reason: null };
|
|
160
|
+
if (!label) return { block: false, label: null, reason: null };
|
|
161
161
|
return {
|
|
162
162
|
block: true,
|
|
163
|
+
label,
|
|
163
164
|
reason: `residoo guard: this looks like a read of ${label}. Blocked before it could be written to the session transcript. ` +
|
|
164
165
|
`If this is intentional and safe, ask the human to read it themselves, or disable this hook in .claude/settings.json.`,
|
|
165
166
|
};
|
|
@@ -183,20 +184,44 @@ const PROMPT_GUARD_RULES = PATTERNS.filter((r) => r.confidence === "high");
|
|
|
183
184
|
* evaluateToolInput above, not just a copy of the same bar.
|
|
184
185
|
*/
|
|
185
186
|
function evaluatePromptText(promptText) {
|
|
186
|
-
if (typeof promptText !== "string" || !promptText) return { block: false, reason: null };
|
|
187
|
+
if (typeof promptText !== "string" || !promptText) return { block: false, label: null, preview: null, reason: null };
|
|
187
188
|
for (const rule of PROMPT_GUARD_RULES) {
|
|
188
189
|
rule.re.lastIndex = 0;
|
|
189
190
|
const m = rule.re.exec(promptText);
|
|
190
191
|
if (!m) continue;
|
|
191
192
|
const value = m[0];
|
|
192
193
|
if (VENDOR_EXAMPLE_VALUES.has(value) || zeroEntropyTail(value)) continue;
|
|
194
|
+
const preview = redact(value);
|
|
193
195
|
return {
|
|
194
196
|
block: true,
|
|
195
|
-
|
|
197
|
+
label: rule.label,
|
|
198
|
+
preview,
|
|
199
|
+
reason: `residoo guard: this prompt looks like it contains ${rule.label} (${preview}). ` +
|
|
196
200
|
`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
201
|
};
|
|
198
202
|
}
|
|
199
|
-
return { block: false, reason: null };
|
|
203
|
+
return { block: false, label: null, preview: null, reason: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Writes one structured audit line to stderr for a block decision --
|
|
208
|
+
* CONTRIBUTING.md's own hard rule (rule 3) names `~/.residoo/rotations.json`
|
|
209
|
+
* as "the only file residoo ever writes outside an explicit --seal...
|
|
210
|
+
* nothing else may claim this carve-out," so this is NOT a new file, the
|
|
211
|
+
* same choice `cred`'s own audit trail already made for the same reason
|
|
212
|
+
* (see src/credRun.js). Durability is the operator's choice: redirect the
|
|
213
|
+
* hook's own stderr at launch if you want it kept, same as `cred`.
|
|
214
|
+
* Never the raw matched value -- `preview` is already redact()'d by the
|
|
215
|
+
* caller (rule 4: no raw value in any log line, ever), and PreToolUse
|
|
216
|
+
* decisions carry no value at all, only a path-pattern label.
|
|
217
|
+
*/
|
|
218
|
+
function logAuditLine(errOutput, { event, label, preview, sessionId, cwd }) {
|
|
219
|
+
try {
|
|
220
|
+
errOutput.write(JSON.stringify({
|
|
221
|
+
ts: new Date().toISOString(), tool: "residoo guard", event, decision: "block",
|
|
222
|
+
label, ...(preview ? { preview } : {}), sessionId: sessionId || null, cwd: cwd || null,
|
|
223
|
+
}) + "\n");
|
|
224
|
+
} catch { /* stderr write failing is never a reason to fail the hook decision itself */ }
|
|
200
225
|
}
|
|
201
226
|
|
|
202
227
|
/**
|
|
@@ -207,9 +232,11 @@ function evaluatePromptText(promptText) {
|
|
|
207
232
|
* response protocol to `output` (default stdout) -- exit code is the
|
|
208
233
|
* caller's job (bin/residoo.js), this returns the intended process exit
|
|
209
234
|
* code instead of calling process.exit itself, matching every other run*
|
|
210
|
-
* function in cli.js.
|
|
235
|
+
* function in cli.js. Every BLOCK decision also gets one structured line
|
|
236
|
+
* on `errOutput` (default stderr) -- see logAuditLine's own docstring for
|
|
237
|
+
* why stderr, never a file.
|
|
211
238
|
*/
|
|
212
|
-
async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
|
|
239
|
+
async function runGuard({ input = process.stdin, output = process.stdout, errOutput = process.stderr } = {}) {
|
|
213
240
|
const chunks = [];
|
|
214
241
|
for await (const chunk of input) chunks.push(chunk);
|
|
215
242
|
const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString("utf-8");
|
|
@@ -231,6 +258,10 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
|
|
|
231
258
|
if (payload.hook_event_name === "UserPromptSubmit") {
|
|
232
259
|
const decision = evaluatePromptText(payload.prompt);
|
|
233
260
|
if (!decision.block) return 0;
|
|
261
|
+
logAuditLine(errOutput, {
|
|
262
|
+
event: "UserPromptSubmit", label: decision.label, preview: decision.preview,
|
|
263
|
+
sessionId: payload.session_id, cwd: payload.cwd,
|
|
264
|
+
});
|
|
234
265
|
output.write(JSON.stringify({ decision: "block", reason: decision.reason }) + "\n");
|
|
235
266
|
return 0;
|
|
236
267
|
}
|
|
@@ -238,6 +269,10 @@ async function runGuard({ input = process.stdin, output = process.stdout } = {})
|
|
|
238
269
|
const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
|
|
239
270
|
if (!decision.block) return 0;
|
|
240
271
|
|
|
272
|
+
logAuditLine(errOutput, {
|
|
273
|
+
event: "PreToolUse", label: decision.label,
|
|
274
|
+
sessionId: payload.session_id, cwd: payload.cwd,
|
|
275
|
+
});
|
|
241
276
|
output.write(JSON.stringify({
|
|
242
277
|
hookSpecificOutput: {
|
|
243
278
|
hookEventName: "PreToolUse",
|
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",
|