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 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.0
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.0
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). Also stress-
49
- tested directly against multi-megabyte adversarial inputs.
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.1",
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": { "type": "git", "url": "git+https://github.com/dandovdub/residoo.git" },
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": { "url": "https://github.com/dandovdub/residoo/issues" },
10
- "bin": { "residoo": "bin/residoo.js" },
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": { "node": ">=18" },
13
- "scripts": { "test": "node tests/smoke.js" },
14
- "files": ["bin", "src", "README.md", "SECURITY.md", "LICENSE"],
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", "secrets", "secret-scanning", "ai-agent", "claude-code",
17
- "cursor", "copilot", "mcp", "privacy", "cli", "encryption"
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
- // - At most B64_MAX_CANDIDATES candidates are decoded per line; a line with
53
- // more encoded runs than that is reported by the caller as only partially
54
- // checked rather than silently truncated.
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). `truncated` is true when the per-line candidate
95
- * cap cut the list short, so the caller can surface the partial coverage
96
- * instead of silently dropping the rest.
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
- if (candidates.length >= B64_MAX_CANDIDATES) { truncated = true; return false; }
132
- candidates.push(cand);
133
- return true;
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 && !push(current)) return { candidates, truncated };
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
- if (!push(current)) return { candidates, truncated };
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 { candidates, truncated };
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, and `truncated`
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 { candidates, truncated } = b64Candidates(norm);
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 { matches: out, truncated };
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:@\/]+:[^\s@\/]{3,}@[^\s\/]+/g },
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". Returns true when the per-line candidate cap left
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 { matches, truncated } = findDecodedMatches(line, highRules);
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 flags, each surfaced at most once so a
295
- // pathological file produces one visible entry, not thousands.
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
- let decodeTruncated = 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
+ };
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
- decodeTruncated = decodeLine(line, file, relFile, i + 1, mtimeMs) || decodeTruncated;
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
- if (!lineMatchFailed) {
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());