residoo 0.4.6 → 0.4.7

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
@@ -98,7 +98,7 @@ precise about rather than lumping together:
98
98
  with an X, and states it is "fully offline, with zero network calls
99
99
  during scanning or redacting" apart from an optional PyPI version-check
100
100
  ping. residoo's `--verify` follows betterleaks' posture, not
101
- trufflehog's: off by default, an explicit flag, and 27 vendors today
101
+ trufflehog's: off by default, an explicit flag, and 32 vendors today
102
102
  (still short of trufflehog's 700+) rather than every vendor its own
103
103
  detection can name, deduped the same way betterleaks dedupes, and gated
104
104
  the same way patterns.js's own detection rules are: only added where a
@@ -149,25 +149,36 @@ won't be built into the tool that writes it.
149
149
  discarded. See `src/jwtExpiry.js`.
150
150
  - **`--verify`** (opt-in, makes a real network call): asks a credential's own
151
151
  vendor whether it still authenticates, using the exact value found in your
152
- transcript. 27 vendors today: **AWS** (an access key id found paired with
153
- its secret, checked via `sts:get-caller-identity`, the same free,
154
- read-only, permission-less call the AWS CLI and tools like aws-vault use
155
- for exactly this; shells out to your own `aws` CLI rather than
156
- reimplementing AWS request signing, since residoo ships zero runtime
157
- dependencies and a subtly wrong signing implementation would silently
158
- report real keys as invalid, worse than not checking), and **26 more via a
159
- direct API call each, no CLI needed**: Slack, OpenAI, Anthropic, GitHub,
152
+ transcript. 32 vendors today. Two need a paired id+secret (see the
153
+ Rotation section below for what that means): **AWS** (checked via
154
+ `sts:get-caller-identity`, the same free, read-only, permission-less call
155
+ the AWS CLI and tools like aws-vault use for exactly this; shells out to
156
+ your own `aws` CLI rather than reimplementing AWS request signing, since
157
+ residoo ships zero runtime dependencies and a subtly wrong signing
158
+ implementation would silently report real keys as invalid, worse than not
159
+ checking) and **PlanetScale** (a direct API call, like every other
160
+ non-AWS vendor here, with the id/secret pairing found the same way AWS's
161
+ is, see `src/pairing.js`). The other **30 are a single credential each,
162
+ one direct API call, no CLI needed**: Slack, OpenAI, Anthropic, GitHub,
160
163
  Hugging Face, Replicate, DigitalOcean, Pinecone, SendGrid, Groq, xAI,
161
164
  OpenRouter, Stripe, npm, Notion, GitLab, Supabase (management tokens
162
165
  only, project-scoped anon/service_role keys need a project URL residoo
163
166
  doesn't have), ElevenLabs, CircleCI, Airtable, Cloudflare, Heroku,
164
- Netlify, Linear, Telegram, and Discord webhooks. Every one of the 27 was
165
- researched against that vendor's own current docs or a named open-source
166
- scanner's real, running verifier code before being wired up, the same bar
167
- as the first five; a real, sourced reason (no free endpoint, needs
168
- external context the credential doesn't carry, or a paired credential
169
- residoo doesn't yet detect together) is why some detected credential
170
- types aren't wired to `--verify` at all, not an oversight. A
167
+ Netlify, Linear, Telegram, Discord webhooks, Vercel, Cerebras, Render,
168
+ and Fly.io's `fo1_` tokens (Fly.io's other token family, `fm1a_`/`fm1r_`/
169
+ `fm2_` "macaroons," is detected nowhere in residoo: caught on this
170
+ project's own real-machine testing, that shape's short prefix plus a
171
+ wide, unstructured base64 body produced over a dozen apparent matches
172
+ inside one real, unrelated job-queue log file that simply contained a lot
173
+ of embedded base64 data, a measured false-positive rate, not a
174
+ hypothetical one). Every one of the 32 was researched against that
175
+ vendor's own current docs or a
176
+ named open-source scanner's real, running verifier code before being
177
+ wired up, the same bar as the first five; a real, sourced reason (no free
178
+ endpoint, needs external context the credential doesn't carry, or a
179
+ credential format not confirmed specifically enough to detect safely in
180
+ the first place) is why some detected credential types aren't wired to
181
+ `--verify` at all, not an oversight. A
171
182
  verified-active credential is escalated to "rotate immediately"; a
172
183
  verified-invalid one is reported as already dead, no action needed, and
173
184
  sorted out of the way. Off by default; every environment variable the
@@ -184,10 +195,10 @@ won't be built into the tool that writes it.
184
195
  preview, never the real value, including in `--json` mode. A decoded or
185
196
  rejoined secret is redacted exactly like a plain one.
186
197
  - On an interactive terminal, prints who it is and where it lives before
187
- scanning starts (`residoo v0.4.6 · find secrets your AI coding agent left
198
+ scanning starts (`residoo v0.4.7 · find secrets your AI coding agent left
188
199
  on disk` plus the repo URL), then a live spinner naming the current file
189
200
  as it scans. Every report also opens with the exact version and timestamp
190
- it was run with (`residoo v0.4.6 · scanned 2026-01-01 12:00`; `--json`
201
+ it was run with (`residoo v0.4.7 · scanned 2026-01-01 12:00`; `--json`
191
202
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
192
203
  screenshotted later never leaves you guessing which build produced it.
193
204
  When there are findings, the report closes with a "Next steps" pointer to
@@ -418,7 +429,7 @@ As a GitHub Action (this repository doubles as a composite action):
418
429
  ```yaml
419
430
  steps:
420
431
  - uses: actions/checkout@v4
421
- - uses: dandovdub/residoo@v0.4.6
432
+ - uses: dandovdub/residoo@v0.4.7
422
433
  ```
423
434
 
424
435
  As a pre-commit hook:
@@ -426,7 +437,7 @@ As a pre-commit hook:
426
437
  ```yaml
427
438
  repos:
428
439
  - repo: https://github.com/dandovdub/residoo
429
- rev: v0.4.6
440
+ rev: v0.4.7
430
441
  hooks:
431
442
  - id: residoo
432
443
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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
@@ -57,7 +57,7 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
57
57
  Scanning makes NO network calls by default and changes nothing on disk.
58
58
  Findings are redacted in every output format. The one opt-in exception is
59
59
  --verify, which asks a credential's own vendor whether it still
60
- authenticates (27 vendors today, see below). Sealing (--seal) writes NEW
60
+ authenticates (32 vendors today, see below). Sealing (--seal) writes NEW
61
61
  encrypted files only. It never modifies or deletes anything that already
62
62
  exists.
63
63
 
@@ -102,23 +102,27 @@ Scan options:
102
102
  --verify ask the credential's own vendor whether it still
103
103
  authenticates, using the exact value found in
104
104
  your transcript. THIS MAKES A REAL NETWORK CALL.
105
- Off by default. 27 vendors today: AWS (an access
106
- key id found paired with its secret, see Rotation
107
- below, checked via sts:get-caller-identity;
108
- needs the aws CLI on PATH, residoo shells out to
109
- it rather than reimplementing AWS request
110
- signing) and 26 more via a direct, dependency-
111
- free API call each, no CLI needed: Slack, OpenAI,
112
- Anthropic, GitHub, Hugging Face, Replicate,
113
- DigitalOcean, Pinecone, SendGrid, Groq, xAI,
114
- OpenRouter, Stripe, npm, Notion, GitLab, Supabase
115
- (management tokens only), ElevenLabs, CircleCI,
116
- Airtable, Cloudflare, Heroku, Netlify, Linear,
117
- Telegram, Discord webhooks. A verified-invalid
118
- credential is reported as already dead, not as
119
- something to rotate; a JWT's own signed exp claim
120
- is checked locally with no network call at all,
121
- on by default, not part of --verify.
105
+ Off by default. 32 vendors today. Two need a
106
+ paired id+secret (see Rotation below): AWS,
107
+ checked via sts:get-caller-identity (needs the
108
+ aws CLI on PATH, residoo shells out to it rather
109
+ than reimplementing AWS request signing), and
110
+ PlanetScale, checked via a direct API call like
111
+ every other non-AWS vendor here. The other 30 are
112
+ each a single credential, one direct,
113
+ dependency-free API call, no CLI needed: Slack,
114
+ OpenAI, Anthropic, GitHub, Hugging Face,
115
+ Replicate, DigitalOcean, Pinecone, SendGrid,
116
+ Groq, xAI, OpenRouter, Stripe, npm, Notion,
117
+ GitLab, Supabase (management tokens only),
118
+ ElevenLabs, CircleCI, Airtable, Cloudflare,
119
+ Heroku, Netlify, Linear, Telegram, Discord
120
+ webhooks, Vercel, Cerebras, Render, and Fly.io.
121
+ A verified-invalid credential is reported as
122
+ already dead, not as something to rotate; a
123
+ JWT's own signed exp claim is checked locally
124
+ with no network call at all, on by default, not
125
+ part of --verify.
122
126
 
123
127
  Rotation:
124
128
  residoo explain <rule-id> full rotation runbook for one detection rule
package/src/pairing.js CHANGED
@@ -23,6 +23,11 @@
23
23
  * other mechanism here: no rule is added to the default set, and a bare
24
24
  * 40-char base64 string anywhere else on a line, with no access key nearby,
25
25
  * is still silently ignored exactly as before this feature existed.
26
+ *
27
+ * findNearbyCandidate below is the same mechanism generalized: PlanetScale
28
+ * (see scan.js) needs an identical pairing step, just with the anchor and
29
+ * candidate roles swapped (the SECRET is the prefixed, independently
30
+ * detected value; the unprefixed id is what gets found nearby).
26
31
  */
27
32
 
28
33
  const WINDOW = 400; // chars searched on each side of the access-key match
@@ -51,33 +56,48 @@ function looksZeroEntropy(value) {
51
56
  }
52
57
 
53
58
  /**
54
- * Find an AWS secret access key candidate paired with an already-matched
55
- * access key id or session token on this line. `akiaValue` and `akiaIndex`
56
- * locate the paired match so the search can be windowed around it and so
57
- * the access key's own text is never re-matched as its own pair.
59
+ * Find a candidate value paired with an already-matched anchor value on
60
+ * this line, within `window` characters on either side. `anchorValue` and
61
+ * `anchorIndex` locate the anchor so the search can exclude the anchor's
62
+ * own text from matching itself.
58
63
  *
59
64
  * Returns the candidate string, or null when there is none, or when more
60
65
  * than one distinct candidate sits in the window. Ambiguous pairing is
61
66
  * reported as nothing at all: for a finding whose whole point is "this is
62
67
  * high confidence because of what it's next to," guessing wrong is worse
63
68
  * than staying silent.
69
+ *
70
+ * Generic over which value is the anchor and which is the candidate: AWS
71
+ * anchors on the prefixed access key id and searches for the unprefixed
72
+ * secret; PlanetScale (see scan.js) anchors on the prefixed secret and
73
+ * searches for the unprefixed id — same mechanism, opposite roles, so one
74
+ * function serves both rather than two near-identical copies.
64
75
  */
65
- function findPairedSecret(line, akiaValue, akiaIndex) {
66
- const start = Math.max(0, akiaIndex - WINDOW);
67
- const end = Math.min(line.length, akiaIndex + akiaValue.length + WINDOW);
76
+ function findNearbyCandidate(line, anchorValue, anchorIndex, candidateRe, window) {
77
+ const start = Math.max(0, anchorIndex - window);
78
+ const end = Math.min(line.length, anchorIndex + anchorValue.length + window);
68
79
  const around = line.slice(start, end);
69
- CANDIDATE_RE.lastIndex = 0;
80
+ candidateRe.lastIndex = 0;
70
81
  let m;
71
82
  let found = null;
72
- while ((m = CANDIDATE_RE.exec(around)) !== null) {
83
+ while ((m = candidateRe.exec(around)) !== null) {
73
84
  const value = m[0];
74
- if (value !== akiaValue && !looksZeroEntropy(value)) {
85
+ if (value !== anchorValue && !looksZeroEntropy(value)) {
75
86
  if (found !== null && found !== value) return null;
76
87
  found = value;
77
88
  }
78
- if (m.index === CANDIDATE_RE.lastIndex) CANDIDATE_RE.lastIndex++;
89
+ if (m.index === candidateRe.lastIndex) candidateRe.lastIndex++;
79
90
  }
80
91
  return found;
81
92
  }
82
93
 
83
- module.exports = { findPairedSecret };
94
+ /**
95
+ * Find an AWS secret access key candidate paired with an already-matched
96
+ * access key id or session token on this line. See findNearbyCandidate for
97
+ * the shared mechanism this wraps with AWS's own candidate shape and window.
98
+ */
99
+ function findPairedSecret(line, akiaValue, akiaIndex) {
100
+ return findNearbyCandidate(line, akiaValue, akiaIndex, CANDIDATE_RE, WINDOW);
101
+ }
102
+
103
+ module.exports = { findPairedSecret, findNearbyCandidate };
package/src/patterns.js CHANGED
@@ -117,6 +117,16 @@ const PATTERNS = [
117
117
  re: /\b(?:dop|doo|dor)_v1_[a-f0-9]{64}\b/g },
118
118
  { id: "supabase_token", label: "Supabase personal access token", confidence: "high",
119
119
  re: /\bsbp_[a-z0-9]{40}\b/g },
120
+ // Confirmed via planetscale.com/docs/api/reference/service-tokens: the
121
+ // secret half of a service token pair. The id half (12 lowercase
122
+ // alphanumeric characters, no prefix) is not a rule on its own for the
123
+ // same reason AWS's secret access key isn't: on its own it is
124
+ // indistinguishable from any other short id. Instead it is found the
125
+ // same way AWS's secret is (see pairing.js's findNearbyCandidate), just
126
+ // with the anchor and candidate roles swapped — this prefixed secret is
127
+ // the confirmed anchor, and the id is the nearby unprefixed candidate.
128
+ { id: "planetscale_secret", label: "PlanetScale service token", confidence: "high",
129
+ re: /\bpscale_tkn_[A-Za-z0-9_]{43}\b/g },
120
130
  // Current CircleCI PAT format only (CCIPAT_<22 alnum>_<40 hex>, confirmed
121
131
  // via circleci.com/docs/api/v2). The legacy format is a bare 40-char hex
122
132
  // string with no prefix at all — nowhere near specific enough to be a
@@ -143,6 +153,39 @@ const PATTERNS = [
143
153
  // opaque string with no prefix, left out for the same reason as above.
144
154
  { id: "netlify_token", label: "Netlify personal access token", confidence: "high",
145
155
  re: /\bnfp_[a-zA-Z0-9_]{36}\b/g },
156
+ // Confirmed via vercel.com/docs/accounts/access-tokens (updated 2026-08):
157
+ // "Personal access tokens begin with the prefix vcp_", 24-char alnum
158
+ // body shown in the docs' own example. A recent format rollout — an
159
+ // earlier research pass on this vendor found no confirmed prefix at all,
160
+ // since the vendor had not yet published this shape.
161
+ { id: "vercel_token", label: "Vercel personal access token", confidence: "high",
162
+ re: /\bvcp_[A-Za-z0-9]{24}\b/g },
163
+ // Fly.io issues a second token family too (fm1a_/fm1r_/fm2_
164
+ // "macaroons"), confirmed straight from Fly's own macaroon library
165
+ // source (github.com/superfly/macaroon, format.go). Deliberately NOT a
166
+ // rule here: caught live on this project's own real-machine testing, the
167
+ // macaroon shape (a short 4-5 char prefix plus a WIDE 100-700 char plain
168
+ // base64 body, no further structure) produced 16 distinct apparent
169
+ // matches inside a single real, unrelated job-queue log file that simply
170
+ // contained a lot of embedded base64 data — a real false-positive rate,
171
+ // not a hypothetical one, and exactly the noisy-shape case this file's
172
+ // own header says to leave out. fo1_ below did not show this problem
173
+ // (its body is a FIXED 43-char requirement, far less permissive), so
174
+ // that half of Fly.io's tokens is still covered.
175
+ { id: "flyio_bearer_token", label: "Fly.io API token", confidence: "high",
176
+ re: /\bfo1_[\w-]{43}\b/g },
177
+ // Prefix confirmed via Cerebras' own docs (inference-docs.cerebras.ai:
178
+ // "API Key (starts with csk-)"), but Cerebras has not published an exact
179
+ // body length — same situation as notion_token's ntn_ format above, so
180
+ // the bound here is a floor and a generous ceiling, not a verified exact
181
+ // count.
182
+ { id: "cerebras_key", label: "Cerebras API key", confidence: "high",
183
+ re: /\bcsk-[A-Za-z0-9]{20,200}\b/g },
184
+ // Prefix confirmed via Render's own docs (render.com, appears 6 times in
185
+ // the full-text docs dump), body length not published — same
186
+ // floor/ceiling treatment as Cerebras above.
187
+ { id: "render_key", label: "Render API key", confidence: "high",
188
+ re: /\brnd_[A-Za-z0-9]{20,200}\b/g },
146
189
  { id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
147
190
  // Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
148
191
  // format is a bare "s." + 18-40 chars — "s." is nowhere near specific
package/src/report.js CHANGED
@@ -267,17 +267,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
267
267
  // downgrade it to "already dead": both come from a real answer from
268
268
  // AWS, not a guess, so they get their own wording rather than folding
269
269
  // into the generic pairing line.
270
- if (e.pairedSecretPreview || e.pairedAccessKeyPreview) {
270
+ if (e.pairedSecretPreview || e.pairedAccessKeyPreview || e.pairedOtherPreview) {
271
271
  const otherHalf = e.pairedSecretPreview
272
272
  ? `paired with secret ${e.pairedSecretPreview}`
273
- : `paired with access key ${e.pairedAccessKeyPreview}`;
274
- if (e.awsVerified === "active") {
275
- push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · VERIFIED ACTIVE: AWS accepted these credentials moments ago, rotate immediately`));
276
- } else if (e.awsVerified === "invalid") {
277
- push(paint(c.green, ` ${otherHalf} · already inactive: AWS rejected these credentials, no rotation needed`));
278
- } else if (e.awsVerified === "error") {
273
+ : e.pairedAccessKeyPreview
274
+ ? `paired with access key ${e.pairedAccessKeyPreview}`
275
+ : `paired with ${e.pairedOtherLabel || "value"} ${e.pairedOtherPreview}`;
276
+ if (e.verified === "active") {
277
+ push(paint(c.red + c.bold, ` ${otherHalf} · VERIFIED ACTIVE: the vendor accepted these credentials moments ago, rotate immediately`));
278
+ } else if (e.verified === "invalid") {
279
+ push(paint(c.green, ` ✓ ${otherHalf} · already inactive: the vendor rejected these credentials, no rotation needed`));
280
+ } else if (e.verified === "error") {
279
281
  push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`) +
280
- paint(c.dim, ` (could not verify: ${e.awsVerifiedDetail || "unknown error"})`));
282
+ paint(c.dim, ` (could not verify: ${e.verifiedDetail || "unknown error"})`));
281
283
  } else {
282
284
  push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`));
283
285
  }
package/src/rotation.js CHANGED
@@ -478,6 +478,29 @@ const ROTATION_GUIDANCE = {
478
478
  ],
479
479
  revokeNote: "This is the account-level token (sbp_); a project's anon and service_role keys rotate separately in that project's API settings.",
480
480
  },
481
+ // Fetched https://planetscale.com/docs/api/reference/service-tokens
482
+ // (2026-09-03): tokens are managed and revoked from the organization's
483
+ // Service tokens page in the PlanetScale dashboard.
484
+ planetscale_secret: {
485
+ label: "PlanetScale service token",
486
+ consolePath: "app.planetscale.com > organization Settings > Service tokens",
487
+ steps: [
488
+ "Open Service tokens under your organization's settings",
489
+ "Delete the leaked token",
490
+ "Create a replacement and update whatever used the old one",
491
+ ],
492
+ revokeNote: "Deletion is immediate; the token stops authenticating on the next request.",
493
+ },
494
+ planetscale_id: {
495
+ label: "PlanetScale service token id (paired with a leaked secret)",
496
+ consolePath: "app.planetscale.com > organization Settings > Service tokens",
497
+ steps: [
498
+ "This is the id half of the service token also found on this line",
499
+ "Delete the leaked token from Service tokens; the id dies with it",
500
+ "Create a replacement and update whatever used the old one",
501
+ ],
502
+ revokeNote: "The id cannot be revoked on its own: deleting the paired token is what invalidates it.",
503
+ },
481
504
  // Fetched https://developer.hashicorp.com/vault/docs/commands/token/revoke
482
505
  // (2026-09-02): "token revoke revokes authentication tokens and their
483
506
  // children", -accessor and -mode flags.
@@ -568,6 +591,55 @@ const ROTATION_GUIDANCE = {
568
591
  ],
569
592
  revokeNote: "Deletion is immediate; the token stops authenticating on the next request.",
570
593
  },
594
+ // Fetched https://vercel.com/docs/accounts/access-tokens (2026-09-03):
595
+ // tokens are managed and deleted from Account Settings > Tokens.
596
+ vercel_token: {
597
+ label: "Vercel personal access token",
598
+ consolePath: "vercel.com/account/tokens",
599
+ steps: [
600
+ "Open Account Settings > Tokens",
601
+ "Delete the leaked token",
602
+ "Create a replacement and update whatever used the old one",
603
+ ],
604
+ revokeNote: "Deletion is immediate; the token stops authenticating on the next request.",
605
+ },
606
+ // Fetched https://fly.io/docs/security/tokens/ (2026-09-03): tokens are
607
+ // managed via flyctl or the dashboard's Tokens page; revoking is
608
+ // immediate.
609
+ flyio_bearer_token: {
610
+ label: "Fly.io API token",
611
+ consolePath: "fly.io/dashboard > your organization > Tokens (or: flyctl tokens revoke)",
612
+ steps: [
613
+ "Open Tokens under your organization in the Fly.io dashboard",
614
+ "Revoke the leaked token",
615
+ "Create a replacement (flyctl tokens create, or the dashboard)",
616
+ ],
617
+ revokeNote: "Revocation is immediate; the token stops authenticating on the next request.",
618
+ },
619
+ // Fetched https://inference-docs.cerebras.ai (2026-09-03): keys are
620
+ // managed and deleted from the Cerebras Cloud platform's API Keys page.
621
+ cerebras_key: {
622
+ label: "Cerebras API key",
623
+ consolePath: "cloud.cerebras.ai > API Keys",
624
+ steps: [
625
+ "Open API Keys in the Cerebras Cloud platform",
626
+ "Delete the leaked key",
627
+ "Create a replacement and update the applications using it",
628
+ ],
629
+ revokeNote: "Deletion is immediate; the key stops authenticating on the next request.",
630
+ },
631
+ // Fetched https://render.com's own docs (llms-full.txt dump, 2026-09-03):
632
+ // API keys are managed and revoked from Account Settings > API Keys.
633
+ render_key: {
634
+ label: "Render API key",
635
+ consolePath: "dashboard.render.com/u/settings#api-keys",
636
+ steps: [
637
+ "Open Account Settings > API Keys in the Render dashboard",
638
+ "Revoke the leaked key",
639
+ "Create a replacement and update whatever used the old one",
640
+ ],
641
+ revokeNote: "Revocation is immediate; the key stops authenticating on the next request.",
642
+ },
571
643
 
572
644
  // ── Comms / SaaS ──────────────────────────────────────────────────────
573
645
  // The user-facing support article (support.discord.com article 228383668)
@@ -974,6 +1046,15 @@ function renderRotation(findings, acks, dismissed = {}) {
974
1046
  // usable credential pair, not just that a secret exists somewhere.
975
1047
  pairedSecretPreview: null,
976
1048
  pairedAccessKeyPreview: null,
1049
+ // Same idea, generic: for any OTHER paired-credential vendor (see
1050
+ // pairing.js's findNearbyCandidate, generalized from the AWS
1051
+ // mechanism above), pairedOtherPreview is the other half's redacted
1052
+ // preview and pairedOtherLabel is a short noun for what that other
1053
+ // half IS ("id", "secret", ...) — kept separate from the AWS-named
1054
+ // fields above since "paired with access key" is wrong wording for,
1055
+ // say, PlanetScale's id/secret pair.
1056
+ pairedOtherPreview: null,
1057
+ pairedOtherLabel: null,
977
1058
  // A JWT's own `exp` claim, decoded locally (see jwtExpiry.js): the
978
1059
  // one credential type residoo can say "still valid" or "expired"
979
1060
  // about with zero network calls, since expiry is inside the signed
@@ -1015,6 +1096,10 @@ function renderRotation(findings, acks, dismissed = {}) {
1015
1096
  if (e.pairedAccessKeyPreview === null && typeof f.pairedAccessKeyPreview === "string") {
1016
1097
  e.pairedAccessKeyPreview = f.pairedAccessKeyPreview;
1017
1098
  }
1099
+ if (e.pairedOtherPreview === null && typeof f.pairedOtherPreview === "string") {
1100
+ e.pairedOtherPreview = f.pairedOtherPreview;
1101
+ e.pairedOtherLabel = typeof f.pairedOtherLabel === "string" ? f.pairedOtherLabel : null;
1102
+ }
1018
1103
  if (e.jwtExpiresAtMs === null && typeof f.jwtExpiresAtMs === "number") {
1019
1104
  e.jwtExpiresAtMs = f.jwtExpiresAtMs;
1020
1105
  }
@@ -1041,7 +1126,7 @@ function renderRotation(findings, acks, dismissed = {}) {
1041
1126
  const isConfirmedDead = (e) => e.verified === "invalid" || (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now());
1042
1127
  const priorityScore = (e) => {
1043
1128
  if (e.verified === "active") return -2;
1044
- if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null) return -1;
1129
+ if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null || e.pairedOtherPreview !== null) return -1;
1045
1130
  if (isConfirmedDead(e)) return 1;
1046
1131
  return 0;
1047
1132
  };
package/src/scan.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const path = require("path");
4
4
  const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
5
  const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
6
- const { findPairedSecret } = require("./pairing");
6
+ const { findPairedSecret, findNearbyCandidate } = require("./pairing");
7
7
  const { looksRandom } = require("./rarity");
8
8
  const { decodeJwtExpiryMs } = require("./jwtExpiry");
9
9
  const {
@@ -14,8 +14,24 @@ const {
14
14
  verifyNotionToken, verifyGitlabToken, verifySupabaseToken, verifyElevenLabsKey,
15
15
  verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
16
16
  verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
17
+ verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
18
+ verifyFlyioBearerToken,
17
19
  } = require("./verify");
18
20
 
21
+ // PlanetScale's id half: 12 lowercase alphanumeric characters, no prefix —
22
+ // confirmed via planetscale.com/docs/api/reference/service-tokens. Searched
23
+ // for near an already-confirmed planetscale_secret match the same way AWS's
24
+ // secret is searched for near an access key id (see pairing.js).
25
+ const PLANETSCALE_ID_RE = /\b[a-z0-9]{12}\b/g;
26
+ // A tighter window than AWS's: PlanetScale's own docs show the id and
27
+ // secret adjacent, joined by a colon ("<id>:<token>"), not spread across a
28
+ // config file the way an AWS access key and secret often are. A 12-char
29
+ // lowercase-alnum candidate is also a much more common shape to collide
30
+ // with by accident (a hash fragment, a short id) than AWS's 40-char one, so
31
+ // a smaller window reduces how often an unrelated nearby string creates a
32
+ // false ambiguous match.
33
+ const PLANETSCALE_PAIR_WINDOW = 100;
34
+
19
35
  // Never verify more than this many distinct credentials of ONE vendor in a
20
36
  // single scan: a pathological transcript with dozens of distinct
21
37
  // credentials should not turn --verify into a long burst of outbound calls.
@@ -28,9 +44,11 @@ const MAX_VERIFICATIONS_PER_VENDOR = 10;
28
44
  // Deliberately NOT here despite being detected: google_api_key (a key can
29
45
  // belong to any Google product; testing it against one product's endpoint
30
46
  // would misreport a valid key for a DIFFERENT product as invalid) and
31
- // perplexity_key (no free, side-effect-free endpoint exists at all). See
32
- // verify.js's own header comment for the fuller reasoning, including the
33
- // vendors deferred rather than rushed (PlanetScale, Fly.io).
47
+ // perplexity_key (no free, side-effect-free endpoint exists at all).
48
+ // PlanetScale is ALSO not here despite being verified: it needs pairing
49
+ // (see pendingPlanetScaleVerifications below), the same reason AWS isn't
50
+ // here either. See verify.js's own header comment for the fuller
51
+ // reasoning behind every vendor left out.
34
52
  const SIMPLE_VERIFY_FNS = {
35
53
  slack_token: verifySlackToken,
36
54
  openai_key: verifyOpenAiKey,
@@ -59,6 +77,10 @@ const SIMPLE_VERIFY_FNS = {
59
77
  linear_key: verifyLinearKey,
60
78
  telegram_bot_token: verifyTelegramToken,
61
79
  discord_webhook: verifyDiscordWebhook,
80
+ vercel_token: verifyVercelToken,
81
+ cerebras_key: verifyCerebrasKey,
82
+ render_key: verifyRenderKey,
83
+ flyio_bearer_token: verifyFlyioBearerToken,
62
84
  };
63
85
  const SIMPLE_VERIFY_VENDOR_LABEL = {
64
86
  slack_token: "Slack's auth.test",
@@ -88,6 +110,10 @@ const SIMPLE_VERIFY_VENDOR_LABEL = {
88
110
  linear_key: "Linear's GraphQL API",
89
111
  telegram_bot_token: "Telegram's getMe endpoint",
90
112
  discord_webhook: "Discord's webhook-info endpoint",
113
+ vercel_token: "Vercel's user endpoint",
114
+ cerebras_key: "Cerebras's models endpoint",
115
+ render_key: "Render's owners endpoint",
116
+ flyio_bearer_token: "Fly.io's GraphQL API",
91
117
  };
92
118
 
93
119
  // Rule ids that findPairedSecret's window search applies to (see pairing.js):
@@ -241,11 +267,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
241
267
  // call; nothing in it is ever written to a finding until verification has
242
268
  // REPLACED the raw values with a status string.
243
269
  const pendingAwsVerifications = new Map();
270
+ // Same shape as pendingAwsVerifications, for PlanetScale's paired
271
+ // credential (see the planetscale_secret match branch below): keyed by
272
+ // the secret value (the confirmed, prefixed anchor) -> { idValue, refs }.
273
+ const pendingPlanetScaleVerifications = new Map();
244
274
  // --verify only (see verify.js): ruleId -> (token value -> { refs }), for
245
- // every SIMPLE_VERIFY_FNS vendor. Unlike AWS, none of these need pairing
246
- // (the token itself is the complete credential), so this is simpler: one
247
- // entry per distinct value per rule, `refs` accumulating every finding
248
- // object that value produced.
275
+ // every SIMPLE_VERIFY_FNS vendor. Unlike AWS/PlanetScale, none of these
276
+ // need pairing (the token itself is the complete credential), so this is
277
+ // simpler: one entry per distinct value per rule, `refs` accumulating
278
+ // every finding object that value produced.
249
279
  const pendingSimpleVerifications = new Map();
250
280
 
251
281
  // One place raw matched text turns into a recorded finding: counts the
@@ -343,6 +373,32 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
343
373
  }
344
374
  }
345
375
  }
376
+ // PlanetScale: the opposite pairing direction from AWS (see
377
+ // pairing.js's findNearbyCandidate) — the SECRET is the
378
+ // confirmed, prefixed anchor here, and the unprefixed id is the
379
+ // nearby candidate. Uses the generic pairedOtherPreview/
380
+ // pairedOtherLabel fields rather than AWS's pairedSecretPreview/
381
+ // pairedAccessKeyPreview, since neither of those names fits ("the
382
+ // secret is paired with an id", not a second secret or an access
383
+ // key) — a future paired vendor reuses these same generic fields
384
+ // rather than growing a new AWS-shaped pair each time.
385
+ let planetScaleIdFinding = null;
386
+ let rawPlanetScaleId = null;
387
+ if (!suppressedReason && rule.id === "planetscale_secret") {
388
+ const pairedId = findNearbyCandidate(line, m[0], m.index, PLANETSCALE_ID_RE, PLANETSCALE_PAIR_WINDOW);
389
+ if (pairedId) {
390
+ const idSuppressedReason = suppressionReason(pairedId, null);
391
+ if (idSuppressedReason && !includeSuppressed) {
392
+ suppressedCount++;
393
+ } else {
394
+ rawPlanetScaleId = pairedId;
395
+ planetScaleIdFinding = record({ id: "planetscale_id", label: "PlanetScale service token id (paired with secret)" },
396
+ pairedId, relFile, file, lineNo, mtimeMs,
397
+ idSuppressedReason ? "low" : "high", idSuppressedReason,
398
+ { paired: true, pairedOtherPreview: redact(m[0]), pairedOtherLabel: "secret" });
399
+ }
400
+ }
401
+ }
346
402
  // Local, offline JWT expiry (see jwtExpiry.js): only ever reads
347
403
  // the `exp` claim out of the decoded payload, nothing else, and
348
404
  // only for the unsuppressed default `jwt` rule, since a
@@ -354,7 +410,11 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
354
410
  mtimeMs,
355
411
  resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
356
412
  suppressedReason,
357
- { ...(pairedSecretPreview ? { pairedSecretPreview } : {}), ...(jwtExtra || {}) });
413
+ {
414
+ ...(pairedSecretPreview ? { pairedSecretPreview } : {}),
415
+ ...(planetScaleIdFinding ? { pairedOtherPreview: redact(rawPlanetScaleId), pairedOtherLabel: "id" } : {}),
416
+ ...(jwtExtra || {}),
417
+ });
358
418
 
359
419
  // --verify only, and only for a DEMONSTRATED pair (both halves
360
420
  // present, neither suppressed): queue it for the verification pass
@@ -371,6 +431,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
371
431
  const entry = pendingAwsVerifications.get(m[0]);
372
432
  if (entry) entry.refs.push({ akiaFinding: primaryFinding, secretFinding });
373
433
  }
434
+ // --verify, PlanetScale: same dedup-by-anchor-value shape as AWS
435
+ // above, keyed by the secret (the confirmed anchor) this time.
436
+ if (verify && planetScaleIdFinding && rawPlanetScaleId) {
437
+ if (!pendingPlanetScaleVerifications.has(m[0]) && pendingPlanetScaleVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
438
+ pendingPlanetScaleVerifications.set(m[0], { idValue: rawPlanetScaleId, refs: [] });
439
+ }
440
+ const psEntry = pendingPlanetScaleVerifications.get(m[0]);
441
+ if (psEntry) psEntry.refs.push({ secretFinding: primaryFinding, idFinding: planetScaleIdFinding });
442
+ }
374
443
  // --verify, single-token vendors (Slack, OpenAI, Anthropic,
375
444
  // GitHub): none of these need pairing (the value IS the complete
376
445
  // credential), so queue every unsuppressed match directly, same
@@ -559,7 +628,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
559
628
  // separator, a real rendering bug caught live. Only called when there is
560
629
  // actually something to verify, so a plain --verify with nothing to check
561
630
  // never clears a spinner line for no reason.
562
- const anyPending = pendingAwsVerifications.size > 0 ||
631
+ const anyPending = pendingAwsVerifications.size > 0 || pendingPlanetScaleVerifications.size > 0 ||
563
632
  [...pendingSimpleVerifications.values()].some((byValue) => byValue.size > 0);
564
633
  if (verify && anyPending && typeof onBeforeVerify === "function") onBeforeVerify();
565
634
 
@@ -598,6 +667,17 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
598
667
  }
599
668
  }
600
669
  }
670
+ if (verify && pendingPlanetScaleVerifications.size > 0) {
671
+ process.stderr.write(
672
+ `residoo --verify: calling PlanetScale's organizations endpoint for ${pendingPlanetScaleVerifications.size} ` +
673
+ "credential(s) found in this scan. This is a real network request to PlanetScale, using the exact " +
674
+ "credential found in your transcript, one at a time.\n"
675
+ );
676
+ for (const [secretValue, { idValue, refs }] of pendingPlanetScaleVerifications) {
677
+ const result = await verifyPlanetScaleToken(idValue, secretValue);
678
+ for (const ref of refs) applyVerifyResult([ref.secretFinding, ref.idFinding], result);
679
+ }
680
+ }
601
681
  if (verify) {
602
682
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
603
683
  if (byValue.size === 0) continue;
package/src/verify.js CHANGED
@@ -289,16 +289,22 @@ function verifyGithubToken(token, opts) {
289
289
  // - Cohere, Mistral, Together AI, Fireworks, DeepSeek: not detected in
290
290
  // the first place (see patterns.js's own comment on this), so wiring a
291
291
  // verifier would be dead code — verification needs detection first.
292
- // - PlanetScale, Fly.io: real, confirmed endpoints, deliberately deferred
293
- // rather than rushed. PlanetScale's auth header needs BOTH the token
294
- // id and the secret together, a paired-credential shape residoo has no
295
- // detection rule for (only the secret's pscale_tkn_ prefix is
296
- // detected). Fly.io needs one of two different Authorization header
297
- // schemes depending on which of two token prefixes was found (a plain
298
- // Bearer header for one, the literal string "FlyV1 <token>" for the
299
- // other) layered on top of a GraphQL body check — real, but more
300
- // engineering than the rest of this batch, better done as its own
301
- // careful pass than folded in here.
292
+ // - Linode / Akamai Cloud: researched twice, ruled out both times, the
293
+ // second time more conclusively than the first: its own OpenAPI schema
294
+ // documents the token as a bare opaque string with no prefix
295
+ // whatsoever, not merely an undocumented one, so this is not something
296
+ // a future docs update could fix.
297
+ // Two vendors that were deferred in an earlier pass are no longer on this
298
+ // list, once their real formats or engineering were worked out: PlanetScale
299
+ // (see verifyPlanetScaleToken below and pairing.js's findNearbyCandidate,
300
+ // generalized from AWS's own pairing mechanism to cover its id/secret
301
+ // pair) and Fly.io's fo1_ token family (see verifyFlyioBearerToken below).
302
+ // Fly.io's OTHER token family, fm1a_/fm1r_/fm2_ "macaroons," stays
303
+ // undetected: caught live on this project's own real-machine testing, that
304
+ // shape's short prefix plus a wide, unstructured base64 body produced
305
+ // dozens of apparent matches inside an unrelated real file that just
306
+ // happened to contain a lot of embedded base64 data. See patterns.js's own
307
+ // comment on flyio_bearer_token for the measured false-positive rate.
302
308
 
303
309
  function huggingfaceUrl() { return process.env.RESIDOO_TEST_HUGGINGFACE_API_URL || "https://huggingface.co/api/whoami-v2"; }
304
310
  function sendgridUrl() { return process.env.RESIDOO_TEST_SENDGRID_API_URL || "https://api.sendgrid.com/v3/scopes"; }
@@ -412,6 +418,67 @@ function verifyNetlifyToken(token, opts) {
412
418
  return verifyByStatusCode("Netlify", netlifyUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
413
419
  }
414
420
 
421
+ function planetscaleUrl() { return process.env.RESIDOO_TEST_PLANETSCALE_API_URL || "https://api.planetscale.com/v1/organizations"; }
422
+ /**
423
+ * PlanetScale: a paired credential (like AWS), not a single token — the
424
+ * Authorization header is the literal "<id>:<token>", no Bearer/Basic
425
+ * prefix, verbatim from PlanetScale's own docs' curl example.
426
+ */
427
+ function verifyPlanetScaleToken(id, secret, opts) {
428
+ return verifyByStatusCode("PlanetScale", planetscaleUrl(), () => ({ Authorization: `${id}:${secret}` }), opts);
429
+ }
430
+
431
+ function vercelUrl() { return process.env.RESIDOO_TEST_VERCEL_API_URL || "https://api.vercel.com/v2/user"; }
432
+ function verifyVercelToken(token, opts) {
433
+ return verifyByStatusCode("Vercel", vercelUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
434
+ }
435
+
436
+ function cerebrasUrl() { return process.env.RESIDOO_TEST_CEREBRAS_API_URL || "https://api.cerebras.ai/v1/models"; }
437
+ function verifyCerebrasKey(key, opts) {
438
+ return verifyByStatusCode("Cerebras", cerebrasUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
439
+ }
440
+
441
+ function renderUrl() { return process.env.RESIDOO_TEST_RENDER_API_URL || "https://api.render.com/v1/owners"; }
442
+ function verifyRenderKey(key, opts) {
443
+ return verifyByStatusCode("Render", renderUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
444
+ }
445
+
446
+ function flyioUrl() { return process.env.RESIDOO_TEST_FLYIO_API_URL || "https://api.fly.io/graphql"; }
447
+ /**
448
+ * Fly.io: one GraphQL endpoint, Bearer header (the scheme flyctl-issued
449
+ * fo1_ tokens use; Fly's other token family, fm1a_/fm1r_/fm2_ "macaroons",
450
+ * uses a different literal "FlyV1 <token>" scheme instead, but that family
451
+ * is not detected — see patterns.js's own comment on why). Like Linear, a
452
+ * GraphQL 200 can still carry an auth failure in the body, so this checks
453
+ * for a populated data.viewer instead of trusting the status code alone,
454
+ * except for 401, which Fly's own API does use for an outright missing or
455
+ * malformed token.
456
+ */
457
+ async function verifyFlyioBearerToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
458
+ let res;
459
+ try {
460
+ res = await fetchFn(flyioUrl(), {
461
+ method: "POST",
462
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
463
+ body: JSON.stringify({ query: "{ viewer { email } }" }),
464
+ signal: AbortSignal.timeout(timeoutMs),
465
+ });
466
+ } catch (e) {
467
+ return { status: "error", detail: `could not reach Fly.io (${sanitizeDetail(e && e.message)})` };
468
+ }
469
+ if (res.status === 401) return { status: "invalid", detail: "Fly.io rejected this token (HTTP 401)" };
470
+ let body;
471
+ try {
472
+ body = await res.json();
473
+ } catch {
474
+ return { status: "error", detail: `Fly.io returned a non-JSON response (HTTP ${res.status})` };
475
+ }
476
+ if (body && body.data && body.data.viewer && body.data.viewer.email) {
477
+ return { status: "active", detail: "Fly.io accepted this token" };
478
+ }
479
+ return { status: "error", detail: `could not verify: ${sanitizeDetail(JSON.stringify(body && body.errors)).slice(0, 120) || `HTTP ${res.status}`}` };
480
+ }
481
+
415
482
  /**
416
483
  * Linear: a GraphQL API, one POST endpoint for everything, not a plain GET.
417
484
  * A GraphQL server can answer HTTP 200 even for some authorization-level
@@ -489,4 +556,6 @@ module.exports = {
489
556
  verifyNotionToken, verifyGitlabToken, verifySupabaseToken, verifyElevenLabsKey,
490
557
  verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
491
558
  verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
559
+ verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
560
+ verifyFlyioBearerToken,
492
561
  };