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 +60 -15
- package/package.json +1 -1
- package/src/cli.js +35 -19
- package/src/pairing.js +32 -12
- package/src/patterns.js +75 -0
- package/src/report.js +18 -9
- package/src/rotation.js +163 -1
- package/src/scan.js +158 -7
- package/src/verify.js +303 -8
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.
|
|
129
|
-
|
|
130
|
-
read-only, permission-less call
|
|
131
|
-
for exactly this; shells out to
|
|
132
|
-
reimplementing AWS request signing, since
|
|
133
|
-
dependencies and a subtly wrong signing
|
|
134
|
-
report real keys as invalid, worse than not
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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({
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
push(paint(c.
|
|
271
|
-
} 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") {
|
|
272
281
|
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`) +
|
|
273
|
-
paint(c.dim, ` (could not verify: ${e.
|
|
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
|
|
189
|
-
// (the token itself is the complete credential), so this is
|
|
190
|
-
// entry per distinct value per rule, `refs` accumulating
|
|
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
|
-
{
|
|
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
|
|
203
|
-
* rejection; anything else (429 rate limited, 5xx, a
|
|
204
|
-
* inconclusive, never guessed as either active or
|
|
205
|
-
* own function above because its auth.test
|
|
206
|
-
* signals failure inside the JSON body
|
|
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, {
|
|
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 (
|
|
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
|
};
|