residoo 0.3.1 → 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 +35 -9
- package/src/decode.js +21 -21
- package/src/patterns.js +19 -19
- package/src/scan.js +30 -24
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,19 +1,45 @@
|
|
|
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)",
|
|
7
|
-
"repository": {
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dandovdub/residoo.git"
|
|
10
|
+
},
|
|
8
11
|
"homepage": "https://github.com/dandovdub/residoo#readme",
|
|
9
|
-
"bugs": {
|
|
10
|
-
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/dandovdub/residoo/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"residoo": "bin/residoo.js"
|
|
17
|
+
},
|
|
11
18
|
"main": "src/cli.js",
|
|
12
|
-
"engines": {
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node tests/smoke.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
15
32
|
"keywords": [
|
|
16
|
-
"security",
|
|
17
|
-
"
|
|
33
|
+
"security",
|
|
34
|
+
"secrets",
|
|
35
|
+
"secret-scanning",
|
|
36
|
+
"ai-agent",
|
|
37
|
+
"claude-code",
|
|
38
|
+
"cursor",
|
|
39
|
+
"copilot",
|
|
40
|
+
"mcp",
|
|
41
|
+
"privacy",
|
|
42
|
+
"cli",
|
|
43
|
+
"encryption"
|
|
18
44
|
]
|
|
19
45
|
}
|
package/src/decode.js
CHANGED
|
@@ -49,9 +49,14 @@
|
|
|
49
49
|
// is recovered by retrying the decode with ONE edge chunk dropped — but
|
|
50
50
|
// only one, and only at an edge: junk on both edges, or prose merged into
|
|
51
51
|
// the middle of a blob, still loses the whole candidate.
|
|
52
|
-
// -
|
|
53
|
-
//
|
|
54
|
-
//
|
|
52
|
+
// - There is deliberately NO per-line candidate cap. Real transcript lines
|
|
53
|
+
// routinely hold hundreds of decode-sized alnum runs (uuids, hashes,
|
|
54
|
+
// request ids), so any cap either silently starves a genuine blob
|
|
55
|
+
// sitting past it or flags nearly every real file as partially checked.
|
|
56
|
+
// None is needed for cost: each character belongs to at most one
|
|
57
|
+
// candidate and decoding is a few linear passes, so total work per line
|
|
58
|
+
// is O(line length) with small constants, and line length is already
|
|
59
|
+
// bounded by the sources' own file-read caps.
|
|
55
60
|
|
|
56
61
|
// A run is made of characters that can appear in base64 or base64url:
|
|
57
62
|
// A-Z a-z 0-9 + / = _ - (see isB64Code). Wrap separators between chunks of
|
|
@@ -74,7 +79,6 @@ const B64_MIN_CHUNK = 4;
|
|
|
74
79
|
|
|
75
80
|
const B64_MIN_CHARS = 24; // fewer chars cannot hide a real credential
|
|
76
81
|
const B64_MAX_ENCODED = 90000; // ~64KB decoded ceiling; skip bigger runs
|
|
77
|
-
const B64_MAX_CANDIDATES = 256; // per line, so a pathological line stays bounded
|
|
78
82
|
const PRINTABLE_MIN = 0.85; // decoded bytes must be mostly text to rescan
|
|
79
83
|
|
|
80
84
|
/** JSON whitespace escapes -> the real line breaks they stand for. */
|
|
@@ -91,9 +95,10 @@ function normalizeEscapes(line) {
|
|
|
91
95
|
*
|
|
92
96
|
* Each candidate is returned as its ARRAY of chunks, not pre-joined: the
|
|
93
97
|
* decode step needs the chunk boundaries to retry with an edge chunk dropped
|
|
94
|
-
* (see findDecodedMatches).
|
|
95
|
-
*
|
|
96
|
-
*
|
|
98
|
+
* (see findDecodedMatches). Candidates shorter than B64_MIN_CHARS joined are
|
|
99
|
+
* discarded here for free: they can never decode (decodeToText rejects them
|
|
100
|
+
* by length), and most base64-charset runs on a line are exactly such short
|
|
101
|
+
* prose words and ids.
|
|
97
102
|
*/
|
|
98
103
|
function isB64Code(c) {
|
|
99
104
|
return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
|
|
@@ -123,14 +128,13 @@ function splitAtPadding(chunk) {
|
|
|
123
128
|
|
|
124
129
|
function b64Candidates(norm) {
|
|
125
130
|
const candidates = []; // array of chunk arrays
|
|
126
|
-
let truncated = false;
|
|
127
131
|
let current = null; // chunk array of the candidate in progress
|
|
128
132
|
let runStart = -1; // start of the b64 run in progress, -1 when not in one
|
|
129
133
|
let gapClean = true; // gap since the last chunk held only \r \n
|
|
130
134
|
const push = (cand) => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
let len = 0;
|
|
136
|
+
for (const c of cand) len += c.length;
|
|
137
|
+
if (len >= B64_MIN_CHARS) candidates.push(cand);
|
|
134
138
|
};
|
|
135
139
|
const n = norm.length;
|
|
136
140
|
for (let i = 0; i <= n; i++) {
|
|
@@ -147,13 +151,13 @@ function b64Candidates(norm) {
|
|
|
147
151
|
for (let p = 0; p < parts.length; p++) {
|
|
148
152
|
if (p === 0 && current !== null && gapClean) current.push(parts[p]);
|
|
149
153
|
else {
|
|
150
|
-
if (current !== null
|
|
154
|
+
if (current !== null) push(current);
|
|
151
155
|
current = [parts[p]];
|
|
152
156
|
}
|
|
153
157
|
// A part ending in padding is a complete value: nothing after it —
|
|
154
158
|
// not even across a clean wrap gap — can belong to the same blob.
|
|
155
159
|
if (parts[p].charCodeAt(parts[p].length - 1) === 61) {
|
|
156
|
-
|
|
160
|
+
push(current);
|
|
157
161
|
current = null;
|
|
158
162
|
}
|
|
159
163
|
}
|
|
@@ -168,7 +172,7 @@ function b64Candidates(norm) {
|
|
|
168
172
|
if (c !== -1 && c !== 10 && c !== 13) gapClean = false;
|
|
169
173
|
}
|
|
170
174
|
if (current !== null) push(current);
|
|
171
|
-
return
|
|
175
|
+
return candidates;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
/**
|
|
@@ -211,19 +215,15 @@ function decodeToText(cleaned) {
|
|
|
211
215
|
/**
|
|
212
216
|
* Find credentials that appear only base64-encoded on one line. `rules` MUST
|
|
213
217
|
* be the high-confidence subset (see LIMITS above). Returns
|
|
214
|
-
* { matches, truncated } where matches is
|
|
215
218
|
* [{ ruleId, label, confidence, value, encoding }] with `value` the DECODED
|
|
216
219
|
* secret (caller redacts), deduped by rule+value so a blob echoed twice on
|
|
217
|
-
* one line (content plus tool-result mirror) is one entry
|
|
218
|
-
* is true when the per-line candidate cap left runs unchecked (the caller
|
|
219
|
-
* surfaces that as partial coverage, never silently).
|
|
220
|
+
* one line (content plus tool-result mirror) is one entry.
|
|
220
221
|
*/
|
|
221
222
|
function findDecodedMatches(line, rules) {
|
|
222
223
|
const out = [];
|
|
223
224
|
const seen = new Set();
|
|
224
225
|
const norm = normalizeEscapes(line);
|
|
225
|
-
const
|
|
226
|
-
for (const chunks of candidates) {
|
|
226
|
+
for (const chunks of b64Candidates(norm)) {
|
|
227
227
|
// A wrap-merged candidate can carry one glued-on neighbor token: a word
|
|
228
228
|
// or filename sitting directly above or below the blob across the wrap
|
|
229
229
|
// newline merges into the candidate and breaks the decode (alignment
|
|
@@ -254,7 +254,7 @@ function findDecodedMatches(line, rules) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
-
return
|
|
257
|
+
return out;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
// ── Feature 2: split-line boundary join ─────────────────────────────────────
|
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
|
@@ -196,12 +196,9 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
196
196
|
// was present only encoded on this line. It redacts from the DECODED value
|
|
197
197
|
// (the encoded run is treated as secret material and never appears in the
|
|
198
198
|
// preview), and carries an `encoding` marker the report renders as
|
|
199
|
-
// "base64-wrapped".
|
|
200
|
-
// encoded runs on this line unchecked, so the caller can flag the file as
|
|
201
|
-
// only partially checked instead of staying silent about the gap.
|
|
199
|
+
// "base64-wrapped".
|
|
202
200
|
const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
|
|
203
|
-
const
|
|
204
|
-
for (const d of matches) {
|
|
201
|
+
for (const d of findDecodedMatches(line, highRules)) {
|
|
205
202
|
const suppressedReason = suppressionReason(d.value, null);
|
|
206
203
|
if (suppressedReason && !includeSuppressed) {
|
|
207
204
|
suppressedCount++;
|
|
@@ -210,7 +207,6 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
210
207
|
record({ id: d.ruleId, label: d.label }, d.value, relFile, file, lineNo,
|
|
211
208
|
mtimeMs, suppressedReason ? "low" : "high", suppressedReason, { encoding: d.encoding });
|
|
212
209
|
}
|
|
213
|
-
return truncated;
|
|
214
210
|
};
|
|
215
211
|
|
|
216
212
|
// Feature 2: split-line boundary join. A finding here means one credential
|
|
@@ -291,23 +287,39 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
291
287
|
// Content projection of the PREVIOUS line, kept so each line is
|
|
292
288
|
// projected once and reused for both pairs it belongs to.
|
|
293
289
|
let prevContent = null;
|
|
294
|
-
// Per-file degradation
|
|
295
|
-
//
|
|
290
|
+
// Per-file degradation flag, surfaced at most once so a pathological
|
|
291
|
+
// file produces one visible entry, not thousands.
|
|
296
292
|
let lineMatchFailed = false;
|
|
297
|
-
|
|
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
|
+
};
|
|
298
309
|
for (let i = 0; i < lines.length; i++) {
|
|
299
310
|
const line = lines[i];
|
|
300
311
|
if (line) {
|
|
301
|
-
// A rule regex itself can throw on adversarial input: V8's
|
|
302
|
-
// backtrack stack overflows (RangeError) when an open-ended
|
|
303
|
-
// quantifier meets a prefix followed by a multi-megabyte
|
|
304
|
-
// same-charset run — real transcripts contain such lines. One
|
|
305
|
-
// unmatched line must degrade to a visible per-file flag, never
|
|
306
|
-
// abort the scan and discard every finding already collected
|
|
307
|
-
// (same contract as the readLines catch above).
|
|
308
312
|
try {
|
|
309
313
|
matchLine(line, file, relFile, i + 1, mtimeMs);
|
|
310
|
-
|
|
314
|
+
} catch (err) {
|
|
315
|
+
flagFailed();
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
decodeLine(line, file, relFile, i + 1, mtimeMs);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
flagFailed();
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
311
323
|
const content = contentProjection(line);
|
|
312
324
|
// Boundary join with the previous line (2-way splits only; see
|
|
313
325
|
// decode.js). Both lines must be non-empty so a blank separator
|
|
@@ -317,19 +329,13 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
317
329
|
}
|
|
318
330
|
prevContent = content;
|
|
319
331
|
} catch (err) {
|
|
320
|
-
|
|
321
|
-
lineMatchFailed = true;
|
|
322
|
-
unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
|
|
323
|
-
}
|
|
332
|
+
flagFailed();
|
|
324
333
|
prevContent = null;
|
|
325
334
|
}
|
|
326
335
|
} else {
|
|
327
336
|
prevContent = null;
|
|
328
337
|
}
|
|
329
338
|
}
|
|
330
|
-
if (decodeTruncated) {
|
|
331
|
-
unreadableFiles.push({ file: safeName(file), reason: "some lines held more encoded runs than the per-line bound; checked partially" });
|
|
332
|
-
}
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
if (sourceScannedAnything) sourcesScanned.push(source.id());
|