residoo 0.4.5 → 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
@@ -82,6 +82,30 @@ precise about rather than lumping together:
82
82
  different set of choices, and its README is honest about its own tradeoffs
83
83
  too. Worth a look if broader source coverage matters more to you than a
84
84
  minimal dependency footprint.
85
+ - **On verifying a found value is still live**, not just shaped like one:
86
+ the field splits into two real postures, and residoo picked a side.
87
+ [trufflehog](https://github.com/trufflesecurity/trufflehog) verifies
88
+ **by default**, an opt-out (`--no-verification`) rather than an opt-in,
89
+ across 700+ vendor-specific checks; that means a plain, unconfigured
90
+ trufflehog run makes network calls. gitleaks never added verification at
91
+ all and is now feature-complete (security patches only); its declared
92
+ successor, [betterleaks](https://github.com/betterleaks/betterleaks),
93
+ added it the other way: **off by default**, one global `--validation`
94
+ flag, each rule's own validate expression deciding whether and how it
95
+ calls out, with repeated occurrences of the same value deduped to one
96
+ request. agentsweep has none either, and is explicit about it: its own
97
+ README scores trufflehog's verification with a checkmark and its own
98
+ with an X, and states it is "fully offline, with zero network calls
99
+ during scanning or redacting" apart from an optional PyPI version-check
100
+ ping. residoo's `--verify` follows betterleaks' posture, not
101
+ trufflehog's: off by default, an explicit flag, and 32 vendors today
102
+ (still short of trufflehog's 700+) rather than every vendor its own
103
+ detection can name, deduped the same way betterleaks dedupes, and gated
104
+ the same way patterns.js's own detection rules are: only added where a
105
+ real, cited endpoint exists, not assumed by analogy to a similar vendor.
106
+ See [verify.js](src/verify.js) and
107
+ [What it does not do](#what-it-does-not-do) for exactly what it touches
108
+ and when.
85
109
 
86
110
  This isn't a gap Anthropic is planning to close upstream, either: a
87
111
  [request to scrub secrets from `~/.claude/projects` natively](https://github.com/anthropics/claude-code/issues/50014)
@@ -125,17 +149,38 @@ won't be built into the tool that writes it.
125
149
  discarded. See `src/jwtExpiry.js`.
126
150
  - **`--verify`** (opt-in, makes a real network call): asks a credential's own
127
151
  vendor whether it still authenticates, using the exact value found in your
128
- transcript. Five vendors today: **AWS** (an access key id found paired with
129
- its secret, checked via `sts:get-caller-identity`, the same free,
130
- read-only, permission-less call the AWS CLI and tools like aws-vault use
131
- for exactly this; shells out to your own `aws` CLI rather than
132
- reimplementing AWS request signing, since residoo ships zero runtime
133
- dependencies and a subtly wrong signing implementation would silently
134
- report real keys as invalid, worse than not checking), and **Slack,
135
- OpenAI, Anthropic, GitHub** (a direct API call to each vendor's own free
136
- "list what I can see" endpoint, no CLI needed, no request signing to get
137
- wrong). A verified-active credential is escalated to "rotate immediately";
138
- a verified-invalid one is reported as already dead, no action needed, and
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,
163
+ Hugging Face, Replicate, DigitalOcean, Pinecone, SendGrid, Groq, xAI,
164
+ OpenRouter, Stripe, npm, Notion, GitLab, Supabase (management tokens
165
+ only, project-scoped anon/service_role keys need a project URL residoo
166
+ doesn't have), ElevenLabs, CircleCI, Airtable, Cloudflare, Heroku,
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
182
+ verified-active credential is escalated to "rotate immediately"; a
183
+ verified-invalid one is reported as already dead, no action needed, and
139
184
  sorted out of the way. Off by default; every environment variable the
140
185
  `aws` CLI reads is built from scratch, never inherited, so it can never
141
186
  fall back to your own real AWS profile. See `src/verify.js`.
@@ -150,10 +195,10 @@ won't be built into the tool that writes it.
150
195
  preview, never the real value, including in `--json` mode. A decoded or
151
196
  rejoined secret is redacted exactly like a plain one.
152
197
  - On an interactive terminal, prints who it is and where it lives before
153
- scanning starts (`residoo v0.4.5 · find secrets your AI coding agent left
198
+ scanning starts (`residoo v0.4.7 · find secrets your AI coding agent left
154
199
  on disk` plus the repo URL), then a live spinner naming the current file
155
200
  as it scans. Every report also opens with the exact version and timestamp
156
- it was run with (`residoo v0.4.5 · scanned 2026-01-01 12:00`; `--json`
201
+ it was run with (`residoo v0.4.7 · scanned 2026-01-01 12:00`; `--json`
157
202
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
158
203
  screenshotted later never leaves you guessing which build produced it.
159
204
  When there are findings, the report closes with a "Next steps" pointer to
@@ -384,7 +429,7 @@ As a GitHub Action (this repository doubles as a composite action):
384
429
  ```yaml
385
430
  steps:
386
431
  - uses: actions/checkout@v4
387
- - uses: dandovdub/residoo@v0.4.5
432
+ - uses: dandovdub/residoo@v0.4.7
388
433
  ```
389
434
 
390
435
  As a pre-commit hook:
@@ -392,7 +437,7 @@ As a pre-commit hook:
392
437
  ```yaml
393
438
  repos:
394
439
  - repo: https://github.com/dandovdub/residoo
395
- rev: v0.4.5
440
+ rev: v0.4.7
396
441
  hooks:
397
442
  - id: residoo
398
443
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.5",
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,9 +57,9 @@ 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 (AWS, Slack, OpenAI, Anthropic, GitHub today); see below.
61
- Sealing (--seal) writes NEW encrypted files only. It never modifies or
62
- deletes anything that already exists.
60
+ authenticates (32 vendors today, see below). Sealing (--seal) writes NEW
61
+ encrypted files only. It never modifies or deletes anything that already
62
+ exists.
63
63
 
64
64
  Usage:
65
65
  residoo scan [options]
@@ -102,21 +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. Five vendors today:
106
- AWS: every access key id found paired with its
107
- secret (see Rotation below) is checked via
108
- sts:get-caller-identity. Needs the aws CLI on
109
- PATH; residoo shells out to it rather than
110
- reimplementing AWS request signing.
111
- Slack: every token via auth.test.
112
- OpenAI, Anthropic, GitHub: every key/token via
113
- that vendor's own models/user listing endpoint.
114
- All four non-AWS vendors are a direct, dependency-
115
- free API call, no CLI needed. A verified-invalid
116
- credential is reported as already dead, not as
117
- something to rotate; a JWT's own signed exp claim
118
- is checked locally with no network call at all,
119
- 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.
120
126
 
121
127
  Rotation:
122
128
  residoo explain <rule-id> full rotation runbook for one detection rule
@@ -572,7 +578,17 @@ async function main(argv) {
572
578
  }
573
579
 
574
580
  const progress = makeProgressReporter(noColor);
575
- const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress, verify });
581
+ const result = await scan({
582
+ sources, includeNoisy, includeSuppressed, verify,
583
+ onProgress: progress.onProgress,
584
+ // Clears the spinner's last frame before --verify's own stderr lines
585
+ // print; without this the last spinner line sits uncleared on screen
586
+ // and the first --verify line gets appended directly onto its end with
587
+ // no separator (a real rendering bug caught live). Safe to call twice:
588
+ // stop() is idempotent, and the normal post-scan progress.stop() below
589
+ // still runs regardless of whether this fired.
590
+ onBeforeVerify: progress.stop,
591
+ });
576
592
  progress.stop();
577
593
  const integrity = wantsIntegrity ? runIntegrity() : null;
578
594
  const rotation = renderRotation(result.findings, acks, dismissed);
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
@@ -105,12 +105,87 @@ const PATTERNS = [
105
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
+ // Confirmed via ElevenLabs' own docs (elevenlabs.io/docs/api-reference/authentication):
109
+ // sk_ + 48 hex. Distinct from the sk-/sk_live_/sk_test_ families above —
110
+ // underscore not hyphen, and no "_live_"/"_test_" substring, so it cannot
111
+ // collide with any of them.
112
+ { id: "elevenlabs_key", label: "ElevenLabs API key", confidence: "high",
113
+ re: /\bsk_[a-f0-9]{48}\b/g },
108
114
 
109
115
  // ── Cloud / infra ──────────────────────────────────────────────────────
110
116
  { id: "digitalocean_token", label: "DigitalOcean access token", confidence: "high",
111
117
  re: /\b(?:dop|doo|dor)_v1_[a-f0-9]{64}\b/g },
112
118
  { id: "supabase_token", label: "Supabase personal access token", confidence: "high",
113
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 },
130
+ // Current CircleCI PAT format only (CCIPAT_<22 alnum>_<40 hex>, confirmed
131
+ // via circleci.com/docs/api/v2). The legacy format is a bare 40-char hex
132
+ // string with no prefix at all — nowhere near specific enough to be a
133
+ // vendor signal, so deliberately left out, same reasoning as Vault's
134
+ // legacy "s." format above.
135
+ { id: "circleci_token", label: "CircleCI personal API token", confidence: "high",
136
+ re: /\bCCIPAT_[A-Za-z0-9]{22}_[a-f0-9]{40}\b/g },
137
+ // Confirmed via airtable.com/developers/web/api: pat + 14 alnum + "." + 64 hex.
138
+ { id: "airtable_token", label: "Airtable personal access token", confidence: "high",
139
+ re: /\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b/g },
140
+ // Current Cloudflare API Token format only (cfat_/cfut_, confirmed via
141
+ // developers.cloudflare.com). The legacy format is a bare 40-char string
142
+ // with no prefix, left out for the same reason as CircleCI's legacy form.
143
+ { id: "cloudflare_api_token", label: "Cloudflare API token", confidence: "high",
144
+ re: /\bcf[au]t_[a-zA-Z0-9]{40}[a-f0-9]{8}\b/g },
145
+ // Current Heroku API key format only (HRKU-AA + 58 chars, confirmed via
146
+ // Heroku's own help docs). The legacy format is a bare UUID, left out:
147
+ // "any UUID-shaped string" is exactly the noisy, unspecific shape this
148
+ // file's header says to avoid.
149
+ { id: "heroku_api_key", label: "Heroku API key", confidence: "high",
150
+ re: /\bHRKU-AA[0-9a-zA-Z_-]{58}\b/g },
151
+ // Current Netlify PAT format only (nfp_ + 36, confirmed via trufflehog's
152
+ // live netlify/v2 detector). The legacy format is a bare 43-45 char
153
+ // opaque string with no prefix, left out for the same reason as above.
154
+ { id: "netlify_token", label: "Netlify personal access token", confidence: "high",
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 },
114
189
  { id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
115
190
  // Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
116
191
  // format is a bare "s." + 18-40 chars — "s." is nowhere near specific
package/src/report.js CHANGED
@@ -85,7 +85,14 @@ function makeProgressReporter(noColor) {
85
85
  const label = `scanning ${source}… ${count} file${count === 1 ? "" : "s"} ${safeBasename(file)}`;
86
86
  write(paint(c.bold + c.cyan, SPINNER_FRAMES[frame]) + " " + paint(c.dim, label), 2 + label.length);
87
87
  };
88
- const stop = () => { if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r"); };
88
+ // Idempotent: a caller may legitimately stop() more than once (scan.js's
89
+ // onBeforeVerify calls it before --verify's own stderr lines, and the
90
+ // normal post-scan call still follows); lastLineLen resets to 0 so a
91
+ // second call is a true no-op, not a second blank-line clear.
92
+ const stop = () => {
93
+ if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r");
94
+ lastLineLen = 0;
95
+ };
89
96
  return { onProgress, stop };
90
97
  }
91
98
 
@@ -260,17 +267,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
260
267
  // downgrade it to "already dead": both come from a real answer from
261
268
  // AWS, not a guess, so they get their own wording rather than folding
262
269
  // into the generic pairing line.
263
- if (e.pairedSecretPreview || e.pairedAccessKeyPreview) {
270
+ if (e.pairedSecretPreview || e.pairedAccessKeyPreview || e.pairedOtherPreview) {
264
271
  const otherHalf = e.pairedSecretPreview
265
272
  ? `paired with secret ${e.pairedSecretPreview}`
266
- : `paired with access key ${e.pairedAccessKeyPreview}`;
267
- if (e.awsVerified === "active") {
268
- push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · VERIFIED ACTIVE: AWS accepted these credentials moments ago, rotate immediately`));
269
- } else if (e.awsVerified === "invalid") {
270
- push(paint(c.green, ` ${otherHalf} · already inactive: AWS rejected these credentials, no rotation needed`));
271
- } 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") {
272
281
  push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`) +
273
- paint(c.dim, ` (could not verify: ${e.awsVerifiedDetail || "unknown error"})`));
282
+ paint(c.dim, ` (could not verify: ${e.verifiedDetail || "unknown error"})`));
274
283
  } else {
275
284
  push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`));
276
285
  }
package/src/rotation.js CHANGED
@@ -435,6 +435,19 @@ const ROTATION_GUIDANCE = {
435
435
  ],
436
436
  revokeNote: "Disabling stops all API requests with that token immediately.",
437
437
  },
438
+ // Fetched https://elevenlabs.io/docs/api-reference/authentication
439
+ // (2026-09-03): keys are managed and deleted from the Profile + API keys
440
+ // page in the ElevenLabs dashboard.
441
+ elevenlabs_key: {
442
+ label: "ElevenLabs API key",
443
+ consolePath: "elevenlabs.io > Profile + API keys",
444
+ steps: [
445
+ "Open Profile + API keys in the ElevenLabs dashboard",
446
+ "Delete the leaked key",
447
+ "Create a replacement and update the applications using it",
448
+ ],
449
+ revokeNote: "A key grants full account access (voices, generations, billing); deletion is immediate.",
450
+ },
438
451
 
439
452
  // ── Cloud / infra ─────────────────────────────────────────────────────
440
453
  // docs.digitalocean.com/reference/api/create-personal-access-token/
@@ -465,6 +478,29 @@ const ROTATION_GUIDANCE = {
465
478
  ],
466
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.",
467
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
+ },
468
504
  // Fetched https://developer.hashicorp.com/vault/docs/commands/token/revoke
469
505
  // (2026-09-02): "token revoke revokes authentication tokens and their
470
506
  // children", -accessor and -mode flags.
@@ -491,6 +527,119 @@ const ROTATION_GUIDANCE = {
491
527
  ],
492
528
  revokeNote: "Revoking immediately removes the token's access to every vault the service account could reach.",
493
529
  },
530
+ // Fetched https://circleci.com/docs/managing-api-tokens/ (2026-09-03):
531
+ // Personal API Tokens tab under User Settings, revoke deletes it
532
+ // immediately.
533
+ circleci_token: {
534
+ label: "CircleCI personal API token",
535
+ consolePath: "circleci.com > User Settings > Personal API Tokens",
536
+ steps: [
537
+ "Open Personal API Tokens under your CircleCI user settings",
538
+ "Revoke the leaked token",
539
+ "Create a replacement and update whatever used the old one",
540
+ ],
541
+ revokeNote: "Revocation is immediate; the token stops authenticating on the next request.",
542
+ },
543
+ // Fetched https://airtable.com/developers/web/guides/personal-access-tokens
544
+ // (2026-09-03): tokens are managed and deleted from the Personal access
545
+ // tokens page in the Airtable developer hub (airtable.com/create/tokens).
546
+ airtable_token: {
547
+ label: "Airtable personal access token",
548
+ consolePath: "airtable.com/create/tokens",
549
+ steps: [
550
+ "Open the Personal access tokens page in the developer hub",
551
+ "Delete the leaked token",
552
+ "Create a replacement scoped only to what your integration needs",
553
+ ],
554
+ revokeNote: "Deletion is immediate and applies to every base the token could reach.",
555
+ },
556
+ // Fetched https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
557
+ // (2026-09-03): tokens are managed and revoked from My Profile > API
558
+ // Tokens.
559
+ cloudflare_api_token: {
560
+ label: "Cloudflare API token",
561
+ consolePath: "dash.cloudflare.com > My Profile > API Tokens",
562
+ steps: [
563
+ "Open My Profile > API Tokens in the Cloudflare dashboard",
564
+ "Roll (regenerate) or delete the leaked token",
565
+ "Update whatever used the old token with the replacement",
566
+ ],
567
+ revokeNote: "Deleting is immediate; a scoped token only affects the zones/permissions it was granted.",
568
+ },
569
+ // Fetched https://help.heroku.com/PBGP6IDE (2026-09-03): API keys are
570
+ // regenerated from Account Settings, which invalidates the previous key.
571
+ heroku_api_key: {
572
+ label: "Heroku API key",
573
+ consolePath: "dashboard.heroku.com/account > API Key",
574
+ steps: [
575
+ "Open Account Settings in the Heroku dashboard",
576
+ "Regenerate the API key (this immediately invalidates the old one)",
577
+ "Update the CLI/CI configs and tools that used the old key",
578
+ ],
579
+ revokeNote: "Regeneration is the only way to invalidate a Heroku API key; there is no separate revoke action.",
580
+ },
581
+ // Fetched https://docs.netlify.com/api/get-started/#authentication
582
+ // (2026-09-03): personal access tokens are managed from User settings >
583
+ // Applications > Personal access tokens.
584
+ netlify_token: {
585
+ label: "Netlify personal access token",
586
+ consolePath: "app.netlify.com/user/applications#personal-access-tokens",
587
+ steps: [
588
+ "Open User settings > Applications > Personal access tokens",
589
+ "Delete the leaked token",
590
+ "Create a replacement and update whatever used the old one",
591
+ ],
592
+ revokeNote: "Deletion is immediate; the token stops authenticating on the next request.",
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
+ },
494
643
 
495
644
  // ── Comms / SaaS ──────────────────────────────────────────────────────
496
645
  // The user-facing support article (support.discord.com article 228383668)
@@ -897,6 +1046,15 @@ function renderRotation(findings, acks, dismissed = {}) {
897
1046
  // usable credential pair, not just that a secret exists somewhere.
898
1047
  pairedSecretPreview: null,
899
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,
900
1058
  // A JWT's own `exp` claim, decoded locally (see jwtExpiry.js): the
901
1059
  // one credential type residoo can say "still valid" or "expired"
902
1060
  // about with zero network calls, since expiry is inside the signed
@@ -938,6 +1096,10 @@ function renderRotation(findings, acks, dismissed = {}) {
938
1096
  if (e.pairedAccessKeyPreview === null && typeof f.pairedAccessKeyPreview === "string") {
939
1097
  e.pairedAccessKeyPreview = f.pairedAccessKeyPreview;
940
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
+ }
941
1103
  if (e.jwtExpiresAtMs === null && typeof f.jwtExpiresAtMs === "number") {
942
1104
  e.jwtExpiresAtMs = f.jwtExpiresAtMs;
943
1105
  }
@@ -964,7 +1126,7 @@ function renderRotation(findings, acks, dismissed = {}) {
964
1126
  const isConfirmedDead = (e) => e.verified === "invalid" || (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now());
965
1127
  const priorityScore = (e) => {
966
1128
  if (e.verified === "active") return -2;
967
- if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null) return -1;
1129
+ if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null || e.pairedOtherPreview !== null) return -1;
968
1130
  if (isConfirmedDead(e)) return 1;
969
1131
  return 0;
970
1132
  };
package/src/scan.js CHANGED
@@ -3,14 +3,35 @@
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 {
10
10
  isAwsCliAvailable, verifyAwsCredential,
11
11
  verifySlackToken, verifyOpenAiKey, verifyAnthropicKey, verifyGithubToken,
12
+ verifyHuggingFaceToken, verifyReplicateToken, verifyDigitalOceanToken, verifyPineconeKey,
13
+ verifySendgridKey, verifyGroqKey, verifyXaiKey, verifyOpenRouterKey, verifyStripeKey, verifyNpmToken,
14
+ verifyNotionToken, verifyGitlabToken, verifySupabaseToken, verifyElevenLabsKey,
15
+ verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
16
+ verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
17
+ verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
18
+ verifyFlyioBearerToken,
12
19
  } = require("./verify");
13
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
+
14
35
  // Never verify more than this many distinct credentials of ONE vendor in a
15
36
  // single scan: a pathological transcript with dozens of distinct
16
37
  // credentials should not turn --verify into a long burst of outbound calls.
@@ -20,17 +41,79 @@ const MAX_VERIFICATIONS_PER_VENDOR = 10;
20
41
  // Every vendor whose credential is a single, unpaired bearer token: no
21
42
  // AWS-style "two halves make one credential" pairing step, so these all
22
43
  // share one collection/verification path below (see pendingSimpleVerifications).
44
+ // Deliberately NOT here despite being detected: google_api_key (a key can
45
+ // belong to any Google product; testing it against one product's endpoint
46
+ // would misreport a valid key for a DIFFERENT product as invalid) and
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.
23
52
  const SIMPLE_VERIFY_FNS = {
24
53
  slack_token: verifySlackToken,
25
54
  openai_key: verifyOpenAiKey,
26
55
  anthropic_key: verifyAnthropicKey,
27
56
  github_pat: verifyGithubToken,
57
+ huggingface_token: verifyHuggingFaceToken,
58
+ replicate_token: verifyReplicateToken,
59
+ digitalocean_token: verifyDigitalOceanToken,
60
+ pinecone_key: verifyPineconeKey,
61
+ sendgrid_key: verifySendgridKey,
62
+ groq_key: verifyGroqKey,
63
+ xai_key: verifyXaiKey,
64
+ openrouter_key: verifyOpenRouterKey,
65
+ stripe_key: verifyStripeKey,
66
+ stripe_test_key: verifyStripeKey,
67
+ npm_token: verifyNpmToken,
68
+ notion_token: verifyNotionToken,
69
+ gitlab_pat: verifyGitlabToken,
70
+ supabase_token: verifySupabaseToken,
71
+ elevenlabs_key: verifyElevenLabsKey,
72
+ circleci_token: verifyCircleciToken,
73
+ airtable_token: verifyAirtableToken,
74
+ cloudflare_api_token: verifyCloudflareToken,
75
+ heroku_api_key: verifyHerokuKey,
76
+ netlify_token: verifyNetlifyToken,
77
+ linear_key: verifyLinearKey,
78
+ telegram_bot_token: verifyTelegramToken,
79
+ discord_webhook: verifyDiscordWebhook,
80
+ vercel_token: verifyVercelToken,
81
+ cerebras_key: verifyCerebrasKey,
82
+ render_key: verifyRenderKey,
83
+ flyio_bearer_token: verifyFlyioBearerToken,
28
84
  };
29
85
  const SIMPLE_VERIFY_VENDOR_LABEL = {
30
86
  slack_token: "Slack's auth.test",
31
87
  openai_key: "OpenAI's models endpoint",
32
88
  anthropic_key: "Anthropic's models endpoint",
33
89
  github_pat: "GitHub's user endpoint",
90
+ huggingface_token: "Hugging Face's whoami endpoint",
91
+ replicate_token: "Replicate's account endpoint",
92
+ digitalocean_token: "DigitalOcean's account endpoint",
93
+ pinecone_key: "Pinecone's indexes endpoint",
94
+ sendgrid_key: "SendGrid's scopes endpoint",
95
+ groq_key: "Groq's models endpoint",
96
+ xai_key: "xAI's api-key endpoint",
97
+ openrouter_key: "OpenRouter's key endpoint",
98
+ stripe_key: "Stripe's balance endpoint",
99
+ stripe_test_key: "Stripe's balance endpoint",
100
+ npm_token: "npm's whoami endpoint",
101
+ notion_token: "Notion's users endpoint",
102
+ gitlab_pat: "GitLab's user endpoint",
103
+ supabase_token: "Supabase's projects endpoint",
104
+ elevenlabs_key: "ElevenLabs' user endpoint",
105
+ circleci_token: "CircleCI's me endpoint",
106
+ airtable_token: "Airtable's whoami endpoint",
107
+ cloudflare_api_token: "Cloudflare's token-verify endpoint",
108
+ heroku_api_key: "Heroku's account endpoint",
109
+ netlify_token: "Netlify's sites endpoint",
110
+ linear_key: "Linear's GraphQL API",
111
+ telegram_bot_token: "Telegram's getMe endpoint",
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",
34
117
  };
35
118
 
36
119
  // Rule ids that findPairedSecret's window search applies to (see pairing.js):
@@ -154,7 +237,7 @@ function safeName(file) { return path.basename(file); }
154
237
  * absolute path can itself carry a username or a project name the rest of
155
238
  * this report is careful never to print.
156
239
  */
157
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false } = {}) {
240
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null } = {}) {
158
241
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
159
242
  // The decode pass (see decode.js) only applies high-confidence, vendor-
160
243
  // prefixed rules to decoded bytes: random binary that decodes to printable
@@ -184,11 +267,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
184
267
  // call; nothing in it is ever written to a finding until verification has
185
268
  // REPLACED the raw values with a status string.
186
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();
187
274
  // --verify only (see verify.js): ruleId -> (token value -> { refs }), for
188
- // every SIMPLE_VERIFY_FNS vendor. Unlike AWS, none of these need pairing
189
- // (the token itself is the complete credential), so this is simpler: one
190
- // entry per distinct value per rule, `refs` accumulating every finding
191
- // 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.
192
279
  const pendingSimpleVerifications = new Map();
193
280
 
194
281
  // One place raw matched text turns into a recorded finding: counts the
@@ -286,6 +373,32 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
286
373
  }
287
374
  }
288
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
+ }
289
402
  // Local, offline JWT expiry (see jwtExpiry.js): only ever reads
290
403
  // the `exp` claim out of the decoded payload, nothing else, and
291
404
  // only for the unsuppressed default `jwt` rule, since a
@@ -297,7 +410,11 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
297
410
  mtimeMs,
298
411
  resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
299
412
  suppressedReason,
300
- { ...(pairedSecretPreview ? { pairedSecretPreview } : {}), ...(jwtExtra || {}) });
413
+ {
414
+ ...(pairedSecretPreview ? { pairedSecretPreview } : {}),
415
+ ...(planetScaleIdFinding ? { pairedOtherPreview: redact(rawPlanetScaleId), pairedOtherLabel: "id" } : {}),
416
+ ...(jwtExtra || {}),
417
+ });
301
418
 
302
419
  // --verify only, and only for a DEMONSTRATED pair (both halves
303
420
  // present, neither suppressed): queue it for the verification pass
@@ -314,6 +431,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
314
431
  const entry = pendingAwsVerifications.get(m[0]);
315
432
  if (entry) entry.refs.push({ akiaFinding: primaryFinding, secretFinding });
316
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
+ }
317
443
  // --verify, single-token vendors (Slack, OpenAI, Anthropic,
318
444
  // GitHub): none of these need pairing (the value IS the complete
319
445
  // credential), so queue every unsuppressed match directly, same
@@ -492,6 +618,20 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
492
618
  // distinct credential, one at a time (not concurrent), so this is the one
493
619
  // place a scan's wall-clock time depends on something other than disk
494
620
  // I/O; that tradeoff only exists when a caller explicitly asked for it.
621
+ //
622
+ // onBeforeVerify exists so a caller with its own stderr chatter (the
623
+ // progress spinner) can clear it first: this pass writes its own stderr
624
+ // lines below, and the spinner's own stop() doesn't run until scan()
625
+ // fully returns, which is AFTER those lines have already printed. Without
626
+ // this, the last spinner frame sits uncleared on screen and the first
627
+ // --verify line gets appended directly onto the end of it with no
628
+ // separator, a real rendering bug caught live. Only called when there is
629
+ // actually something to verify, so a plain --verify with nothing to check
630
+ // never clears a spinner line for no reason.
631
+ const anyPending = pendingAwsVerifications.size > 0 || pendingPlanetScaleVerifications.size > 0 ||
632
+ [...pendingSimpleVerifications.values()].some((byValue) => byValue.size > 0);
633
+ if (verify && anyPending && typeof onBeforeVerify === "function") onBeforeVerify();
634
+
495
635
  // Same field names (verified/verifiedDetail) regardless of which vendor
496
636
  // produced the result: rotation.js and report.js render them identically,
497
637
  // and the finding's own ruleId already says which vendor answered.
@@ -527,6 +667,17 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
527
667
  }
528
668
  }
529
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
+ }
530
681
  if (verify) {
531
682
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
532
683
  if (byValue.size === 0) continue;
package/src/verify.js CHANGED
@@ -199,13 +199,25 @@ async function verifySlackToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TI
199
199
  * Shared implementation for every vendor below Slack: a plain GET to a
200
200
  * free, side-effect-free, already-authenticated endpoint (each vendor's own
201
201
  * "list what I can see" call), where the HTTP status code alone says
202
- * whether the credential authenticated. 200 is active; 401/403 is a real
203
- * rejection; anything else (429 rate limited, 5xx, a network failure) is
204
- * inconclusive, never guessed as either active or invalid. Slack needed its
205
- * own function above because its auth.test always returns HTTP 200 and
206
- * signals failure inside the JSON body instead.
202
+ * whether the credential authenticated. 200 is active by default; 401/403
203
+ * is a real rejection by default; anything else (429 rate limited, 5xx, a
204
+ * network failure) is inconclusive, never guessed as either active or
205
+ * invalid. Slack needed its own function above because its auth.test
206
+ * always returns HTTP 200 and signals failure inside the JSON body
207
+ * instead.
208
+ *
209
+ * invalidStatuses/activeExtra override the defaults for the handful of
210
+ * vendors whose docs document a DIFFERENT meaning for a given code: Discord
211
+ * signals a dead webhook with 404, not 401/403; Pinecone, SendGrid, and
212
+ * GitLab each document 403 as "the credential is real but this specific
213
+ * call is out of scope," not "dead" — treating that as invalid would be
214
+ * exactly the false-negative-in-the-dangerous-direction failure this
215
+ * module exists to avoid, so those three pass 403 in activeExtra instead.
207
216
  */
208
- async function verifyByStatusCode(vendorName, url, buildHeaders, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
217
+ async function verifyByStatusCode(vendorName, url, buildHeaders, {
218
+ fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
219
+ invalidStatuses = [401, 403], activeExtra = [],
220
+ } = {}) {
209
221
  let res;
210
222
  try {
211
223
  res = await fetchFn(url, {
@@ -216,10 +228,10 @@ async function verifyByStatusCode(vendorName, url, buildHeaders, { fetchFn = fet
216
228
  } catch (e) {
217
229
  return { status: "error", detail: `could not reach ${vendorName} (${sanitizeDetail(e && e.message)})` };
218
230
  }
219
- if (res.status === 200) {
231
+ if (res.status === 200 || activeExtra.includes(res.status)) {
220
232
  return { status: "active", detail: `${vendorName} accepted this key` };
221
233
  }
222
- if (res.status === 401 || res.status === 403) {
234
+ if (invalidStatuses.includes(res.status)) {
223
235
  return { status: "invalid", detail: `${vendorName} rejected this key (HTTP ${res.status})` };
224
236
  }
225
237
  return { status: "error", detail: `could not verify: HTTP ${res.status} from ${vendorName}` };
@@ -260,7 +272,290 @@ function verifyGithubToken(token, opts) {
260
272
  return verifyByStatusCode("GitHub", githubUserUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
261
273
  }
262
274
 
275
+ // ── The rest of this file: 18 more vendors added after researching ~65
276
+ // candidates against real vendor docs and open-source scanner source (see
277
+ // the project's verification coverage research). Each one below already
278
+ // has a residoo detection rule (src/patterns.js) with a specific enough
279
+ // prefix that wiring it to a vendor is safe; several confirmed-viable
280
+ // vendors from that research are deliberately NOT here, for reasons worth
281
+ // stating precisely rather than silently omitting:
282
+ // - google_api_key, perplexity_key: DETECTED, but not wired. A Google API
283
+ // key can belong to any Google product (Maps, Firebase, Gemini, ...),
284
+ // and residoo's detection can't tell which; testing it against
285
+ // Gemini's endpoint specifically would report a perfectly valid Maps
286
+ // key as "invalid" — the exact false-negative-in-the-dangerous-
287
+ // direction failure this file exists to avoid. Perplexity has no free,
288
+ // side-effect-free endpoint at all (only a paid /chat/completions).
289
+ // - Cohere, Mistral, Together AI, Fireworks, DeepSeek: not detected in
290
+ // the first place (see patterns.js's own comment on this), so wiring a
291
+ // verifier would be dead code — verification needs detection first.
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.
308
+
309
+ function huggingfaceUrl() { return process.env.RESIDOO_TEST_HUGGINGFACE_API_URL || "https://huggingface.co/api/whoami-v2"; }
310
+ function sendgridUrl() { return process.env.RESIDOO_TEST_SENDGRID_API_URL || "https://api.sendgrid.com/v3/scopes"; }
311
+ function replicateUrl() { return process.env.RESIDOO_TEST_REPLICATE_API_URL || "https://api.replicate.com/v1/account"; }
312
+ function digitaloceanUrl() { return process.env.RESIDOO_TEST_DIGITALOCEAN_API_URL || "https://api.digitalocean.com/v2/account"; }
313
+ function pineconeUrl() { return process.env.RESIDOO_TEST_PINECONE_API_URL || "https://api.pinecone.io/indexes"; }
314
+ function groqUrl() { return process.env.RESIDOO_TEST_GROQ_API_URL || "https://api.groq.com/openai/v1/models"; }
315
+ function xaiUrl() { return process.env.RESIDOO_TEST_XAI_API_URL || "https://api.x.ai/v1/api-key"; }
316
+ function openrouterUrl() { return process.env.RESIDOO_TEST_OPENROUTER_API_URL || "https://openrouter.ai/api/v1/key"; }
317
+ function stripeUrl() { return process.env.RESIDOO_TEST_STRIPE_API_URL || "https://api.stripe.com/v1/balance"; }
318
+ function npmUrl() { return process.env.RESIDOO_TEST_NPM_API_URL || "https://registry.npmjs.org/-/whoami"; }
319
+ function notionUrl() { return process.env.RESIDOO_TEST_NOTION_API_URL || "https://api.notion.com/v1/users"; }
320
+ function gitlabUrl() { return process.env.RESIDOO_TEST_GITLAB_API_URL || "https://gitlab.com/api/v4/user"; }
321
+ function supabaseUrl() { return process.env.RESIDOO_TEST_SUPABASE_API_URL || "https://api.supabase.com/v1/projects"; }
322
+ function elevenlabsUrl() { return process.env.RESIDOO_TEST_ELEVENLABS_API_URL || "https://api.elevenlabs.io/v1/user"; }
323
+ function circleciUrl() { return process.env.RESIDOO_TEST_CIRCLECI_API_URL || "https://circleci.com/api/v2/me"; }
324
+ function airtableUrl() { return process.env.RESIDOO_TEST_AIRTABLE_API_URL || "https://api.airtable.com/v0/meta/whoami"; }
325
+ function cloudflareUrl() { return process.env.RESIDOO_TEST_CLOUDFLARE_API_URL || "https://api.cloudflare.com/client/v4/user/tokens/verify"; }
326
+ function herokuUrl() { return process.env.RESIDOO_TEST_HEROKU_API_URL || "https://api.heroku.com/account"; }
327
+ function netlifyUrl() { return process.env.RESIDOO_TEST_NETLIFY_API_URL || "https://api.netlify.com/api/v1/sites"; }
328
+ function linearUrl() { return process.env.RESIDOO_TEST_LINEAR_API_URL || "https://api.linear.app/graphql"; }
329
+ function telegramUrl(token) {
330
+ const base = process.env.RESIDOO_TEST_TELEGRAM_API_URL || "https://api.telegram.org";
331
+ return `${base}/bot${token}/getMe`;
332
+ }
333
+
334
+ function verifyHuggingFaceToken(token, opts) {
335
+ return verifyByStatusCode("Hugging Face", huggingfaceUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
336
+ }
337
+ function verifyReplicateToken(token, opts) {
338
+ return verifyByStatusCode("Replicate", replicateUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
339
+ }
340
+ function verifyDigitalOceanToken(token, opts) {
341
+ return verifyByStatusCode("DigitalOcean", digitaloceanUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
342
+ }
343
+ /** 403 from SendGrid's own /v3/scopes means the key is real but lacks the scope for this call, not that it's dead — trufflehog's own detector treats it identically. */
344
+ function verifySendgridKey(key, opts) {
345
+ return verifyByStatusCode("SendGrid", sendgridUrl(), () => ({ Authorization: `Bearer ${key}` }), { ...opts, activeExtra: [403] });
346
+ }
347
+ /** 403 from Pinecone means the key is real but lacks control-plane permissions, not that it's dead. */
348
+ function verifyPineconeKey(key, opts) {
349
+ return verifyByStatusCode("Pinecone", pineconeUrl(), () => ({ "Api-Key": key }), { ...opts, activeExtra: [403] });
350
+ }
351
+ function verifyGroqKey(key, opts) {
352
+ return verifyByStatusCode("Groq", groqUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
353
+ }
354
+ function verifyXaiKey(key, opts) {
355
+ return verifyByStatusCode("xAI", xaiUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
356
+ }
357
+ function verifyOpenRouterKey(key, opts) {
358
+ return verifyByStatusCode("OpenRouter", openrouterUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
359
+ }
360
+ /** Stripe: HTTP Basic auth, the key as username and an empty password — NOT a Bearer header. */
361
+ function verifyStripeKey(key, opts) {
362
+ return verifyByStatusCode("Stripe", stripeUrl(), () => ({
363
+ Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
364
+ }), opts);
365
+ }
366
+ function verifyNpmToken(token, opts) {
367
+ return verifyByStatusCode("npm", npmUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
368
+ }
369
+ /** Notion requires an explicit API version header on every request, regardless of endpoint. */
370
+ function verifyNotionToken(token, opts) {
371
+ return verifyByStatusCode("Notion", notionUrl(), () => ({
372
+ Authorization: `Bearer ${token}`,
373
+ "Notion-Version": "2022-06-28",
374
+ }), opts);
375
+ }
376
+ /**
377
+ * GitLab's own docs recommend PRIVATE-TOKEN over a Bearer header for
378
+ * personal access tokens. 403 there means valid token, wrong scope for
379
+ * this specific call (trufflehog's own detector treats it the same way,
380
+ * except when the response body says the account itself is blocked — a
381
+ * rare enough edge case, and one where reporting "active" instead of
382
+ * "invalid" is the safe direction to be wrong in, that this doesn't
383
+ * special-case it further).
384
+ */
385
+ function verifyGitlabToken(token, opts) {
386
+ return verifyByStatusCode("GitLab", gitlabUrl(), () => ({ "PRIVATE-TOKEN": token }), { ...opts, activeExtra: [403] });
387
+ }
388
+ /** The Supabase Management API personal access token (sbp_ prefix) only — project-scoped anon/service_role keys need a project URL residoo doesn't have and are not verifiable this way. */
389
+ function verifySupabaseToken(token, opts) {
390
+ return verifyByStatusCode("Supabase", supabaseUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
391
+ }
392
+ function verifyElevenLabsKey(key, opts) {
393
+ return verifyByStatusCode("ElevenLabs", elevenlabsUrl(), () => ({ "xi-api-key": key }), opts);
394
+ }
395
+ function verifyCircleciToken(token, opts) {
396
+ return verifyByStatusCode("CircleCI", circleciUrl(), () => ({ "Circle-Token": token }), opts);
397
+ }
398
+ function verifyAirtableToken(token, opts) {
399
+ return verifyByStatusCode("Airtable", airtableUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
400
+ }
401
+ /**
402
+ * Cloudflare's own /user/tokens/verify endpoint exists for exactly this
403
+ * check (its whole purpose, per Cloudflare's docs, is confirming a
404
+ * token's validity), so treating any HTTP 200 from THIS SPECIFIC endpoint
405
+ * as active is a documented guarantee, not an approximation the way it
406
+ * would be for a generic "list resources" endpoint.
407
+ */
408
+ function verifyCloudflareToken(token, opts) {
409
+ return verifyByStatusCode("Cloudflare", cloudflareUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
410
+ }
411
+ function verifyHerokuKey(key, opts) {
412
+ return verifyByStatusCode("Heroku", herokuUrl(), () => ({
413
+ Authorization: `Bearer ${key}`,
414
+ Accept: "application/vnd.heroku+json; version=3",
415
+ }), opts);
416
+ }
417
+ function verifyNetlifyToken(token, opts) {
418
+ return verifyByStatusCode("Netlify", netlifyUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
419
+ }
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
+
482
+ /**
483
+ * Linear: a GraphQL API, one POST endpoint for everything, not a plain GET.
484
+ * A GraphQL server can answer HTTP 200 even for some authorization-level
485
+ * failures (the error lives in the response body's `errors` field, not the
486
+ * status code), so this checks for a populated `data.viewer` instead of
487
+ * trusting status code alone — the same reasoning that gave Slack its own
488
+ * function above. Linear's own docs confirm no "Bearer" prefix on personal
489
+ * API keys (Bearer is reserved for OAuth tokens).
490
+ */
491
+ async function verifyLinearKey(key, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
492
+ let res;
493
+ try {
494
+ res = await fetchFn(linearUrl(), {
495
+ method: "POST",
496
+ headers: { Authorization: key, "Content-Type": "application/json" },
497
+ body: JSON.stringify({ query: "{ viewer { id } }" }),
498
+ signal: AbortSignal.timeout(timeoutMs),
499
+ });
500
+ } catch (e) {
501
+ return { status: "error", detail: `could not reach Linear (${sanitizeDetail(e && e.message)})` };
502
+ }
503
+ if (res.status === 401) return { status: "invalid", detail: "Linear rejected this key (HTTP 401)" };
504
+ let body;
505
+ try {
506
+ body = await res.json();
507
+ } catch {
508
+ return { status: "error", detail: `Linear returned a non-JSON response (HTTP ${res.status})` };
509
+ }
510
+ if (body && body.data && body.data.viewer && body.data.viewer.id) {
511
+ return { status: "active", detail: "Linear accepted this key" };
512
+ }
513
+ return { status: "error", detail: `could not verify: ${sanitizeDetail(JSON.stringify(body && body.errors)).slice(0, 120) || `HTTP ${res.status}`}` };
514
+ }
515
+
516
+ /**
517
+ * Telegram: the token is embedded directly in the URL path, not a header,
518
+ * and (like Slack) the response is always HTTP 200 with an `ok` boolean in
519
+ * the body signaling success or failure — never a 401.
520
+ */
521
+ async function verifyTelegramToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
522
+ let res;
523
+ try {
524
+ res = await fetchFn(telegramUrl(token), { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
525
+ } catch (e) {
526
+ return { status: "error", detail: `could not reach Telegram (${sanitizeDetail(e && e.message)})` };
527
+ }
528
+ let body;
529
+ try {
530
+ body = await res.json();
531
+ } catch {
532
+ return { status: "error", detail: `Telegram returned a non-JSON response (HTTP ${res.status})` };
533
+ }
534
+ if (body && body.ok === true) return { status: "active", detail: "Telegram accepted this bot token (getMe)" };
535
+ if (body && body.ok === false && typeof body.error_code === "number") {
536
+ return { status: "invalid", detail: `Telegram rejected this token (${sanitizeDetail(body.description) || body.error_code})` };
537
+ }
538
+ return { status: "error", detail: `could not verify: HTTP ${res.status} from Telegram` };
539
+ }
540
+
541
+ /**
542
+ * Discord webhooks: the credential IS a full URL, not a token to attach to
543
+ * a fixed endpoint elsewhere. A plain GET on that URL is Discord's own
544
+ * documented read-only "fetch webhook info" call, distinct from POSTing to
545
+ * it (which would send a real, visible message — never done here).
546
+ */
547
+ function verifyDiscordWebhook(webhookUrl, opts) {
548
+ return verifyByStatusCode("Discord", webhookUrl, () => ({}), { ...opts, invalidStatuses: [404] });
549
+ }
550
+
263
551
  module.exports = {
264
552
  isAwsCliAvailable, verifyAwsCredential, verifySlackToken,
265
553
  verifyOpenAiKey, verifyAnthropicKey, verifyGithubToken,
554
+ verifyHuggingFaceToken, verifyReplicateToken, verifyDigitalOceanToken, verifyPineconeKey,
555
+ verifySendgridKey, verifyGroqKey, verifyXaiKey, verifyOpenRouterKey, verifyStripeKey, verifyNpmToken,
556
+ verifyNotionToken, verifyGitlabToken, verifySupabaseToken, verifyElevenLabsKey,
557
+ verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
558
+ verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
559
+ verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
560
+ verifyFlyioBearerToken,
266
561
  };