residoo 0.3.2 → 0.3.3
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 +2 -2
- package/SECURITY.md +13 -2
- package/package.json +1 -1
- package/src/patterns.js +19 -19
- package/src/scan.js +25 -11
package/README.md
CHANGED
|
@@ -292,7 +292,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
292
292
|
```yaml
|
|
293
293
|
steps:
|
|
294
294
|
- uses: actions/checkout@v4
|
|
295
|
-
- uses: dandovdub/residoo@v0.3.
|
|
295
|
+
- uses: dandovdub/residoo@v0.3.3
|
|
296
296
|
```
|
|
297
297
|
|
|
298
298
|
As a pre-commit hook:
|
|
@@ -300,7 +300,7 @@ As a pre-commit hook:
|
|
|
300
300
|
```yaml
|
|
301
301
|
repos:
|
|
302
302
|
- repo: https://github.com/dandovdub/residoo
|
|
303
|
-
rev: v0.3.
|
|
303
|
+
rev: v0.3.3
|
|
304
304
|
hooks:
|
|
305
305
|
- id: residoo
|
|
306
306
|
```
|
package/SECURITY.md
CHANGED
|
@@ -45,8 +45,19 @@ just asserted. See the git history for the actual commands run:
|
|
|
45
45
|
- **Not vulnerable to regex denial-of-service.** Every pattern checked
|
|
46
46
|
against the nested-quantifier shape behind real, dated CVEs in adjacent
|
|
47
47
|
tooling (e.g. CVE-2026-0621, a ReDoS in Anthropic's own MCP SDK from
|
|
48
|
-
catastrophic backtracking on an exploded template pattern).
|
|
49
|
-
|
|
48
|
+
catastrophic backtracking on an exploded template pattern). A second,
|
|
49
|
+
distinct failure mode was found and fixed during a pre-launch audit: an
|
|
50
|
+
open-ended quantifier (`{n,}`) matching a multi-megabyte same-charset run
|
|
51
|
+
can overflow V8's regex engine on stack depth alone, independent of
|
|
52
|
+
catastrophic backtracking. The raw-match, base64-decode, and split-line
|
|
53
|
+
passes shared one try/catch at the time, so a crash partway through the
|
|
54
|
+
rule list could silently skip every rule after it for that line. Every
|
|
55
|
+
rule's quantifier is now explicitly bounded to its format's real maximum
|
|
56
|
+
length (a credential shape has a knowable ceiling), each of the three
|
|
57
|
+
passes has its own try/catch as a second, independent layer, and a
|
|
58
|
+
regression test asserts a real secret placed immediately after a
|
|
59
|
+
multi-megabyte adversarial run is still found. Stress-tested directly
|
|
60
|
+
against multi-megabyte adversarial inputs, including that exact shape.
|
|
50
61
|
- **No supply-chain surface.** Zero runtime dependencies, zero
|
|
51
62
|
pre/post-install lifecycle scripts. Check `package.json` yourself;
|
|
52
63
|
there's nothing to hide behind a `postinstall` hook.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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/patterns.js
CHANGED
|
@@ -21,13 +21,13 @@ const PATTERNS = [
|
|
|
21
21
|
{ id: "private_key_block", label: "Private key block", confidence: "high",
|
|
22
22
|
re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
23
23
|
{ id: "github_pat", label: "GitHub personal access token", confidence: "high",
|
|
24
|
-
re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
|
|
24
|
+
re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
|
|
25
25
|
{ id: "gitlab_pat", label: "GitLab personal access token", confidence: "high",
|
|
26
|
-
re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
26
|
+
re: /\bglpat-[A-Za-z0-9_-]{20,100}\b/g },
|
|
27
27
|
{ id: "slack_token", label: "Slack token", confidence: "high",
|
|
28
|
-
re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
28
|
+
re: /\bxox[baprs]-[0-9A-Za-z-]{10,500}\b/g },
|
|
29
29
|
{ id: "stripe_key", label: "Stripe API key (live mode)", confidence: "high",
|
|
30
|
-
re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
|
|
30
|
+
re: /\b(sk|rk)_live_[A-Za-z0-9]{20,250}\b/g },
|
|
31
31
|
// The sandbox-mode twin of the rule above, same body charset and the same
|
|
32
32
|
// 20-char floor. Format verified against two production detectors plus the
|
|
33
33
|
// vendor (2026-09-02): gitleaks' stripe-access-token rule matches
|
|
@@ -45,34 +45,34 @@ const PATTERNS = [
|
|
|
45
45
|
// transcript that pastes sk_test today is the same workflow that will
|
|
46
46
|
// paste sk_live at go-live.
|
|
47
47
|
{ id: "stripe_test_key", label: "Stripe API key (test mode)", confidence: "high",
|
|
48
|
-
re: /\b(sk|rk)_test_[A-Za-z0-9]{20,}\b/g },
|
|
48
|
+
re: /\b(sk|rk)_test_[A-Za-z0-9]{20,250}\b/g },
|
|
49
49
|
// The negative lookahead keeps this rule mutually exclusive with anthropic_key
|
|
50
50
|
// and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
|
|
51
51
|
// BOTH this pattern and the more specific one, and get reported twice under
|
|
52
52
|
// two different (one wrong) provider labels. Verified: all three regexes
|
|
53
53
|
// independently matched their overlapping synthetic keys before this fix.
|
|
54
54
|
{ id: "openai_key", label: "OpenAI API key", confidence: "high",
|
|
55
|
-
re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
55
|
+
re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,300}\b/g },
|
|
56
56
|
{ id: "anthropic_key", label: "Anthropic API key", confidence: "high",
|
|
57
|
-
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
57
|
+
re: /\bsk-ant-[A-Za-z0-9_-]{20,300}\b/g },
|
|
58
58
|
{ id: "google_api_key", label: "Google / Firebase API key", confidence: "high",
|
|
59
59
|
re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
60
60
|
{ id: "npm_token", label: "npm access token", confidence: "high",
|
|
61
61
|
re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
62
62
|
{ id: "sendgrid_key", label: "SendGrid API key", confidence: "high",
|
|
63
|
-
re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
|
|
63
|
+
re: /\bSG\.[A-Za-z0-9_-]{16,100}\.[A-Za-z0-9_-]{16,100}\b/g },
|
|
64
64
|
{ id: "twilio_key", label: "Twilio API key", confidence: "high",
|
|
65
65
|
re: /\bSK[a-f0-9]{32}\b/g },
|
|
66
66
|
{ id: "jwt", label: "JWT-shaped token", confidence: "medium",
|
|
67
|
-
re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
67
|
+
re: /\beyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,20000}\.[A-Za-z0-9_-]{10,2000}\b/g },
|
|
68
68
|
{ id: "connection_string_with_password", label: "Database connection string with embedded password", confidence: "high",
|
|
69
|
-
re: /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s:@\/]
|
|
69
|
+
re: /\b(postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s:@\/]{1,255}:[^\s@\/]{3,255}@[^\s\/]{1,255}/g },
|
|
70
70
|
{ id: "bearer_header", label: "Authorization: Bearer header with a real-looking token", confidence: "medium",
|
|
71
|
-
re: /\bauthorization["']?\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._-]{16,}/gi },
|
|
71
|
+
re: /\bauthorization["']?\s*[:=]\s*["']?bearer\s+[A-Za-z0-9._-]{16,1000}/gi },
|
|
72
72
|
{ id: "refresh_token_field", label: "refresh_token field", confidence: "medium",
|
|
73
|
-
re: /"refresh_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
73
|
+
re: /"refresh_token"\s*:\s*"[^"\s]{20,4000}"/gi },
|
|
74
74
|
{ id: "access_token_field", label: "access_token field", confidence: "medium",
|
|
75
|
-
re: /"access_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
75
|
+
re: /"access_token"\s*:\s*"[^"\s]{20,4000}"/gi },
|
|
76
76
|
|
|
77
77
|
// ── AI / LLM providers (added: competitive gap-close, see project history) ─
|
|
78
78
|
// Every regex body below was checked against a production, field-tested
|
|
@@ -102,7 +102,7 @@ const PATTERNS = [
|
|
|
102
102
|
// "pplx-" + a >=40-char body — consistent across independent sources even
|
|
103
103
|
// without one canonical spec page.
|
|
104
104
|
{ id: "perplexity_key", label: "Perplexity API key", confidence: "high",
|
|
105
|
-
re: /\bpplx-[A-Za-z0-9]{40,}\b/g },
|
|
105
|
+
re: /\bpplx-[A-Za-z0-9]{40,200}\b/g },
|
|
106
106
|
{ id: "replicate_token", label: "Replicate API token", confidence: "high",
|
|
107
107
|
re: /\br8_[0-9A-Za-z_-]{37}\b/g },
|
|
108
108
|
|
|
@@ -121,7 +121,7 @@ const PATTERNS = [
|
|
|
121
121
|
// Confirmed against 1Password's own developer docs (developer.1password.com
|
|
122
122
|
// -> 1password.dev/service-accounts/security): the token is "ops_" plus a
|
|
123
123
|
// base64-encoded JWT, so it always continues "eyJ" (base64 of `{"`).
|
|
124
|
-
re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,}\b/g },
|
|
124
|
+
re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,2000}\b/g },
|
|
125
125
|
|
|
126
126
|
// ── Comms / SaaS ───────────────────────────────────────────────────────
|
|
127
127
|
{ id: "discord_webhook", label: "Discord webhook URL", confidence: "high",
|
|
@@ -138,13 +138,13 @@ const PATTERNS = [
|
|
|
138
138
|
// but Notion has not published an exact body length for it, so its bound
|
|
139
139
|
// below is a floor, not a verified exact count.
|
|
140
140
|
{ id: "notion_token", label: "Notion integration token", confidence: "high",
|
|
141
|
-
re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,})\b/g },
|
|
141
|
+
re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,200})\b/g },
|
|
142
142
|
{ id: "linear_key", label: "Linear API key", confidence: "high",
|
|
143
143
|
re: /\blin_api_[0-9A-Za-z]{40}\b/g },
|
|
144
144
|
{ id: "sentry_token", label: "Sentry auth token", confidence: "high",
|
|
145
145
|
// Covers both current Sentry token shapes: org-scoped (sntrys_, base64
|
|
146
146
|
// JWT-like body) and user-scoped (sntryu_, hex body).
|
|
147
|
-
re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,}|sntryu_[a-f0-9]{64})\b/g },
|
|
147
|
+
re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,4000}|sntryu_[a-f0-9]{64})\b/g },
|
|
148
148
|
];
|
|
149
149
|
|
|
150
150
|
/**
|
|
@@ -154,9 +154,9 @@ const PATTERNS = [
|
|
|
154
154
|
*/
|
|
155
155
|
const NOISY_PATTERNS = [
|
|
156
156
|
{ id: "generic_password_assignment", label: "password / pwd assignment", confidence: "low",
|
|
157
|
-
re: /\b(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{6,}["']?/gi },
|
|
157
|
+
re: /\b(password|passwd|pwd)\s*[:=]\s*["']?[^\s"']{6,500}["']?/gi },
|
|
158
158
|
{ id: "generic_secret_assignment", label: "generic secret / apikey assignment", confidence: "low",
|
|
159
|
-
re: /\b(api[_-]?key|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-\/+=]{12,}["']?/gi },
|
|
159
|
+
re: /\b(api[_-]?key|secret)\s*[:=]\s*["']?[A-Za-z0-9_\-\/+=]{12,500}["']?/gi },
|
|
160
160
|
];
|
|
161
161
|
|
|
162
162
|
/**
|
package/src/scan.js
CHANGED
|
@@ -290,19 +290,36 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
290
290
|
// Per-file degradation flag, surfaced at most once so a pathological
|
|
291
291
|
// file produces one visible entry, not thousands.
|
|
292
292
|
let lineMatchFailed = false;
|
|
293
|
+
// Each pass gets its own try/catch: every rule quantifier is bounded
|
|
294
|
+
// (see patterns.js) so none of these should throw on adversarial input
|
|
295
|
+
// any more, but this is the second, independent layer against that
|
|
296
|
+
// failure mode — a throw in one pass must never suppress the other
|
|
297
|
+
// two for the same line. Without this, a bug reintroduced in any one
|
|
298
|
+
// pass silently blinds the other two for that line rather than
|
|
299
|
+
// degrading loudly on its own. One unmatched line must degrade to a
|
|
300
|
+
// visible per-file flag, never abort the scan and discard every
|
|
301
|
+
// finding already collected (same contract as the readLines catch
|
|
302
|
+
// above).
|
|
303
|
+
const flagFailed = () => {
|
|
304
|
+
if (!lineMatchFailed) {
|
|
305
|
+
lineMatchFailed = true;
|
|
306
|
+
unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
|
|
307
|
+
}
|
|
308
|
+
};
|
|
293
309
|
for (let i = 0; i < lines.length; i++) {
|
|
294
310
|
const line = lines[i];
|
|
295
311
|
if (line) {
|
|
296
|
-
// A rule regex itself can throw on adversarial input: V8's
|
|
297
|
-
// backtrack stack overflows (RangeError) when an open-ended
|
|
298
|
-
// quantifier meets a prefix followed by a multi-megabyte
|
|
299
|
-
// same-charset run — real transcripts contain such lines. One
|
|
300
|
-
// unmatched line must degrade to a visible per-file flag, never
|
|
301
|
-
// abort the scan and discard every finding already collected
|
|
302
|
-
// (same contract as the readLines catch above).
|
|
303
312
|
try {
|
|
304
313
|
matchLine(line, file, relFile, i + 1, mtimeMs);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
flagFailed();
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
305
318
|
decodeLine(line, file, relFile, i + 1, mtimeMs);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
flagFailed();
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
306
323
|
const content = contentProjection(line);
|
|
307
324
|
// Boundary join with the previous line (2-way splits only; see
|
|
308
325
|
// decode.js). Both lines must be non-empty so a blank separator
|
|
@@ -312,10 +329,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
312
329
|
}
|
|
313
330
|
prevContent = content;
|
|
314
331
|
} catch (err) {
|
|
315
|
-
|
|
316
|
-
lineMatchFailed = true;
|
|
317
|
-
unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
|
|
318
|
-
}
|
|
332
|
+
flagFailed();
|
|
319
333
|
prevContent = null;
|
|
320
334
|
}
|
|
321
335
|
} else {
|