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 +31 -20
- package/package.json +1 -1
- package/src/cli.js +22 -18
- package/src/pairing.js +32 -12
- package/src/patterns.js +43 -0
- package/src/report.js +10 -8
- package/src/rotation.js +86 -1
- package/src/scan.js +90 -10
- package/src/verify.js +79 -10
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
|
|
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.
|
|
153
|
-
|
|
154
|
-
read-only, permission-less call
|
|
155
|
-
for exactly this; shells out to
|
|
156
|
-
reimplementing AWS request signing, since
|
|
157
|
-
dependencies and a subtly wrong signing
|
|
158
|
-
report real keys as invalid, worse than not
|
|
159
|
-
|
|
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,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
55
|
-
*
|
|
56
|
-
* locate the
|
|
57
|
-
*
|
|
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
|
|
66
|
-
const start = Math.max(0,
|
|
67
|
-
const end = Math.min(line.length,
|
|
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
|
-
|
|
80
|
+
candidateRe.lastIndex = 0;
|
|
70
81
|
let m;
|
|
71
82
|
let found = null;
|
|
72
|
-
while ((m =
|
|
83
|
+
while ((m = candidateRe.exec(around)) !== null) {
|
|
73
84
|
const value = m[0];
|
|
74
|
-
if (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 ===
|
|
89
|
+
if (m.index === candidateRe.lastIndex) candidateRe.lastIndex++;
|
|
79
90
|
}
|
|
80
91
|
return found;
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
|
|
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
|
-
:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
push(paint(c.
|
|
278
|
-
} else if (e.
|
|
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.
|
|
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).
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
246
|
-
// (the token itself is the complete credential), so this is
|
|
247
|
-
// entry per distinct value per rule, `refs` accumulating
|
|
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
|
-
{
|
|
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
|
-
// -
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
};
|