residoo 0.4.4 → 0.4.6
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 +70 -23
- package/package.json +1 -1
- package/src/cli.js +40 -19
- package/src/patterns.js +32 -0
- package/src/report.js +26 -4
- package/src/rotation.js +113 -21
- package/src/scan.js +155 -20
- package/src/verify.js +378 -18
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 27 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)
|
|
@@ -123,18 +147,32 @@ won't be built into the tool that writes it.
|
|
|
123
147
|
in the Rotation section, instead of just "last seen." Only `exp` is ever
|
|
124
148
|
read; every other claim in the payload is decoded transiently and
|
|
125
149
|
discarded. See `src/jwtExpiry.js`.
|
|
126
|
-
- **`--verify`** (opt-in, makes a real network call):
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
- **`--verify`** (opt-in, makes a real network call): asks a credential's own
|
|
151
|
+
vendor whether it still authenticates, using the exact value found in your
|
|
152
|
+
transcript. 27 vendors today: **AWS** (an access key id found paired with
|
|
153
|
+
its secret, checked via `sts:get-caller-identity`, the same free,
|
|
154
|
+
read-only, permission-less call the AWS CLI and tools like aws-vault use
|
|
155
|
+
for exactly this; shells out to your own `aws` CLI rather than
|
|
156
|
+
reimplementing AWS request signing, since residoo ships zero runtime
|
|
157
|
+
dependencies and a subtly wrong signing implementation would silently
|
|
158
|
+
report real keys as invalid, worse than not checking), and **26 more via a
|
|
159
|
+
direct API call each, no CLI needed**: Slack, OpenAI, Anthropic, GitHub,
|
|
160
|
+
Hugging Face, Replicate, DigitalOcean, Pinecone, SendGrid, Groq, xAI,
|
|
161
|
+
OpenRouter, Stripe, npm, Notion, GitLab, Supabase (management tokens
|
|
162
|
+
only, project-scoped anon/service_role keys need a project URL residoo
|
|
163
|
+
doesn't have), ElevenLabs, CircleCI, Airtable, Cloudflare, Heroku,
|
|
164
|
+
Netlify, Linear, Telegram, and Discord webhooks. Every one of the 27 was
|
|
165
|
+
researched against that vendor's own current docs or a named open-source
|
|
166
|
+
scanner's real, running verifier code before being wired up, the same bar
|
|
167
|
+
as the first five; a real, sourced reason (no free endpoint, needs
|
|
168
|
+
external context the credential doesn't carry, or a paired credential
|
|
169
|
+
residoo doesn't yet detect together) is why some detected credential
|
|
170
|
+
types aren't wired to `--verify` at all, not an oversight. A
|
|
171
|
+
verified-active credential is escalated to "rotate immediately"; a
|
|
172
|
+
verified-invalid one is reported as already dead, no action needed, and
|
|
173
|
+
sorted out of the way. Off by default; every environment variable the
|
|
174
|
+
`aws` CLI reads is built from scratch, never inherited, so it can never
|
|
175
|
+
fall back to your own real AWS profile. See `src/verify.js`.
|
|
138
176
|
- With `--include-noisy`, filters the broad generic-secret rules by how
|
|
139
177
|
machine-random the matched value actually looks (a lightweight, offline
|
|
140
178
|
approximation of BPE-tokenization rarity checks): ordinary English, a
|
|
@@ -146,10 +184,10 @@ won't be built into the tool that writes it.
|
|
|
146
184
|
preview, never the real value, including in `--json` mode. A decoded or
|
|
147
185
|
rejoined secret is redacted exactly like a plain one.
|
|
148
186
|
- On an interactive terminal, prints who it is and where it lives before
|
|
149
|
-
scanning starts (`residoo v0.4.
|
|
187
|
+
scanning starts (`residoo v0.4.6 · find secrets your AI coding agent left
|
|
150
188
|
on disk` plus the repo URL), then a live spinner naming the current file
|
|
151
189
|
as it scans. Every report also opens with the exact version and timestamp
|
|
152
|
-
it was run with (`residoo v0.4.
|
|
190
|
+
it was run with (`residoo v0.4.6 · scanned 2026-01-01 12:00`; `--json`
|
|
153
191
|
carries the same as `residooVersion`/`scannedAt`), so a report pasted or
|
|
154
192
|
screenshotted later never leaves you guessing which build produced it.
|
|
155
193
|
When there are findings, the report closes with a "Next steps" pointer to
|
|
@@ -335,7 +373,14 @@ with the way out:
|
|
|
335
373
|
versus how many are already resolved. A machine with a lot of history can
|
|
336
374
|
report hundreds of raw findings that are really a handful of distinct
|
|
337
375
|
values echoed repeatedly; the summary is built around what's actually left
|
|
338
|
-
to triage, not the raw count.
|
|
376
|
+
to triage, not the raw count. A value `--verify` confirmed dead, or a JWT
|
|
377
|
+
whose own signed `exp` claim is already past, is subtracted from "needs
|
|
378
|
+
review" the same way an acked or dismissed one is, since residoo already
|
|
379
|
+
knows it needs no action, not just that nobody has said so yet. This is a
|
|
380
|
+
strictly per-VALUE fact: it is never rolled up into a whole rule's
|
|
381
|
+
confidence tag in the breakdown below, since `--verify` only ever checks
|
|
382
|
+
the specific values it can (a paired AWS credential, a bearer token), and
|
|
383
|
+
a rule's other, unchecked findings say nothing either way.
|
|
339
384
|
- **The rotation list is grouped by credential type**, so the rotation URL
|
|
340
385
|
prints once per type instead of once per finding. Each distinct value's own
|
|
341
386
|
line shows its redacted preview, which file it's in, and when it was last
|
|
@@ -373,7 +418,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
373
418
|
```yaml
|
|
374
419
|
steps:
|
|
375
420
|
- uses: actions/checkout@v4
|
|
376
|
-
- uses: dandovdub/residoo@v0.4.
|
|
421
|
+
- uses: dandovdub/residoo@v0.4.6
|
|
377
422
|
```
|
|
378
423
|
|
|
379
424
|
As a pre-commit hook:
|
|
@@ -381,7 +426,7 @@ As a pre-commit hook:
|
|
|
381
426
|
```yaml
|
|
382
427
|
repos:
|
|
383
428
|
- repo: https://github.com/dandovdub/residoo
|
|
384
|
-
rev: v0.4.
|
|
429
|
+
rev: v0.4.6
|
|
385
430
|
hooks:
|
|
386
431
|
- id: residoo
|
|
387
432
|
```
|
|
@@ -398,12 +443,14 @@ documented in [docs/ci.md](docs/ci.md).
|
|
|
398
443
|
- **No network calls in the default path, and none at all unless you
|
|
399
444
|
explicitly pass `--upload-cloudroam` or `--verify`.** A secret scanner that
|
|
400
445
|
phones home is not a tool you should trust with your secrets. Verify this
|
|
401
|
-
yourself:
|
|
402
|
-
|
|
403
|
-
`--
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
446
|
+
yourself: every network-capable call in the codebase lives behind one of
|
|
447
|
+
those two flags. `src/sealvault.js` holds the one `fetch` call reachable
|
|
448
|
+
from `--upload-cloudroam`, and sends only encrypted bytes. `src/verify.js`
|
|
449
|
+
holds everything reachable from `--verify`: a `fetch` call per vendor
|
|
450
|
+
(Slack, OpenAI, Anthropic, GitHub), each sending nothing but the exact
|
|
451
|
+
credential a scan found to that credential's own vendor, plus a subprocess
|
|
452
|
+
call to your own `aws` CLI for AWS credentials, never a `fetch`. Neither
|
|
453
|
+
file's code runs unless you pass the matching flag.
|
|
407
454
|
- **Nothing destructive, ever.** Scanning is read-only. Sealing creates *new*
|
|
408
455
|
files and modifies or deletes nothing, not even the plaintext it just
|
|
409
456
|
encrypted a copy of. That last step is deliberately left to a human.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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
|
@@ -56,9 +56,10 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
|
|
|
56
56
|
|
|
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
|
-
--verify, which asks
|
|
60
|
-
authenticates
|
|
61
|
-
only. It never modifies or deletes anything that already
|
|
59
|
+
--verify, which asks a credential's own vendor whether it still
|
|
60
|
+
authenticates (27 vendors today, see below). Sealing (--seal) writes NEW
|
|
61
|
+
encrypted files only. It never modifies or deletes anything that already
|
|
62
|
+
exists.
|
|
62
63
|
|
|
63
64
|
Usage:
|
|
64
65
|
residoo scan [options]
|
|
@@ -98,17 +99,26 @@ Scan options:
|
|
|
98
99
|
--no-integrity skip the integrity checks (planted hooks, dropper
|
|
99
100
|
files, auto-run tasks, hidden Unicode)
|
|
100
101
|
--no-color disable ANSI colour
|
|
101
|
-
--verify
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
the aws CLI on PATH
|
|
108
|
-
rather than reimplementing AWS request
|
|
109
|
-
|
|
102
|
+
--verify ask the credential's own vendor whether it still
|
|
103
|
+
authenticates, using the exact value found in
|
|
104
|
+
your transcript. THIS MAKES A REAL NETWORK CALL.
|
|
105
|
+
Off by default. 27 vendors today: AWS (an access
|
|
106
|
+
key id found paired with its secret, see Rotation
|
|
107
|
+
below, checked via sts:get-caller-identity;
|
|
108
|
+
needs the aws CLI on PATH, residoo shells out to
|
|
109
|
+
it rather than reimplementing AWS request
|
|
110
|
+
signing) and 26 more via a direct, dependency-
|
|
111
|
+
free API call each, no CLI needed: Slack, OpenAI,
|
|
112
|
+
Anthropic, GitHub, Hugging Face, Replicate,
|
|
113
|
+
DigitalOcean, Pinecone, SendGrid, Groq, xAI,
|
|
114
|
+
OpenRouter, Stripe, npm, Notion, GitLab, Supabase
|
|
115
|
+
(management tokens only), ElevenLabs, CircleCI,
|
|
116
|
+
Airtable, Cloudflare, Heroku, Netlify, Linear,
|
|
117
|
+
Telegram, Discord webhooks. A verified-invalid
|
|
110
118
|
credential is reported as already dead, not as
|
|
111
|
-
something to rotate
|
|
119
|
+
something to rotate; a JWT's own signed exp claim
|
|
120
|
+
is checked locally with no network call at all,
|
|
121
|
+
on by default, not part of --verify.
|
|
112
122
|
|
|
113
123
|
Rotation:
|
|
114
124
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
@@ -449,11 +459,12 @@ async function main(argv) {
|
|
|
449
459
|
const failOnFind = args.includes("--fail-on-find");
|
|
450
460
|
const allowAcked = args.includes("--allow-acked");
|
|
451
461
|
// The one flag that makes residoo do something other than read local
|
|
452
|
-
// files: --verify
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
462
|
+
// files: --verify asks the credential's own vendor whether it still
|
|
463
|
+
// authenticates, for every vendor residoo knows how to check today (AWS
|
|
464
|
+
// access key + paired secret via the aws CLI, Slack tokens via a direct
|
|
465
|
+
// API call; see verify.js). Off by default; every other flag here only
|
|
466
|
+
// changes what is READ or how it is DISPLAYED.
|
|
467
|
+
const verify = args.includes("--verify");
|
|
457
468
|
|
|
458
469
|
// --project [dir]: the dir is optional (CI passes ".", a bare --project
|
|
459
470
|
// means the current directory). null means machine mode.
|
|
@@ -563,7 +574,17 @@ async function main(argv) {
|
|
|
563
574
|
}
|
|
564
575
|
|
|
565
576
|
const progress = makeProgressReporter(noColor);
|
|
566
|
-
const result = await scan({
|
|
577
|
+
const result = await scan({
|
|
578
|
+
sources, includeNoisy, includeSuppressed, verify,
|
|
579
|
+
onProgress: progress.onProgress,
|
|
580
|
+
// Clears the spinner's last frame before --verify's own stderr lines
|
|
581
|
+
// print; without this the last spinner line sits uncleared on screen
|
|
582
|
+
// and the first --verify line gets appended directly onto its end with
|
|
583
|
+
// no separator (a real rendering bug caught live). Safe to call twice:
|
|
584
|
+
// stop() is idempotent, and the normal post-scan progress.stop() below
|
|
585
|
+
// still runs regardless of whether this fired.
|
|
586
|
+
onBeforeVerify: progress.stop,
|
|
587
|
+
});
|
|
567
588
|
progress.stop();
|
|
568
589
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
569
590
|
const rotation = renderRotation(result.findings, acks, dismissed);
|
package/src/patterns.js
CHANGED
|
@@ -105,12 +105,44 @@ 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
|
+
// Current CircleCI PAT format only (CCIPAT_<22 alnum>_<40 hex>, confirmed
|
|
121
|
+
// via circleci.com/docs/api/v2). The legacy format is a bare 40-char hex
|
|
122
|
+
// string with no prefix at all — nowhere near specific enough to be a
|
|
123
|
+
// vendor signal, so deliberately left out, same reasoning as Vault's
|
|
124
|
+
// legacy "s." format above.
|
|
125
|
+
{ id: "circleci_token", label: "CircleCI personal API token", confidence: "high",
|
|
126
|
+
re: /\bCCIPAT_[A-Za-z0-9]{22}_[a-f0-9]{40}\b/g },
|
|
127
|
+
// Confirmed via airtable.com/developers/web/api: pat + 14 alnum + "." + 64 hex.
|
|
128
|
+
{ id: "airtable_token", label: "Airtable personal access token", confidence: "high",
|
|
129
|
+
re: /\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b/g },
|
|
130
|
+
// Current Cloudflare API Token format only (cfat_/cfut_, confirmed via
|
|
131
|
+
// developers.cloudflare.com). The legacy format is a bare 40-char string
|
|
132
|
+
// with no prefix, left out for the same reason as CircleCI's legacy form.
|
|
133
|
+
{ id: "cloudflare_api_token", label: "Cloudflare API token", confidence: "high",
|
|
134
|
+
re: /\bcf[au]t_[a-zA-Z0-9]{40}[a-f0-9]{8}\b/g },
|
|
135
|
+
// Current Heroku API key format only (HRKU-AA + 58 chars, confirmed via
|
|
136
|
+
// Heroku's own help docs). The legacy format is a bare UUID, left out:
|
|
137
|
+
// "any UUID-shaped string" is exactly the noisy, unspecific shape this
|
|
138
|
+
// file's header says to avoid.
|
|
139
|
+
{ id: "heroku_api_key", label: "Heroku API key", confidence: "high",
|
|
140
|
+
re: /\bHRKU-AA[0-9a-zA-Z_-]{58}\b/g },
|
|
141
|
+
// Current Netlify PAT format only (nfp_ + 36, confirmed via trufflehog's
|
|
142
|
+
// live netlify/v2 detector). The legacy format is a bare 43-45 char
|
|
143
|
+
// opaque string with no prefix, left out for the same reason as above.
|
|
144
|
+
{ id: "netlify_token", label: "Netlify personal access token", confidence: "high",
|
|
145
|
+
re: /\bnfp_[a-zA-Z0-9_]{36}\b/g },
|
|
114
146
|
{ id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
|
|
115
147
|
// Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
|
|
116
148
|
// 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
|
|
|
@@ -143,9 +150,15 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
143
150
|
// raw values, while these entries dedupe fingerprints (which include the
|
|
144
151
|
// basename, so one value in two differently-named files is two rotations to
|
|
145
152
|
// track). Two counts under one word would read as a contradiction.
|
|
153
|
+
// "pending" keeps its literal ledger meaning here (not yet acked or
|
|
154
|
+
// dismissed): every entry below tagged pending really is, status-wise.
|
|
155
|
+
// confirmedDead is folded into this note, not into the count itself, so
|
|
156
|
+
// this line explains rather than contradicts "Recommended actions"
|
|
157
|
+
// above, which DOES subtract it from what still needs a look.
|
|
146
158
|
const resolvedNote = [
|
|
147
159
|
counts.acked > 0 ? `${counts.acked} acknowledged` : null,
|
|
148
160
|
counts.dismissed > 0 ? `${counts.dismissed} dismissed` : null,
|
|
161
|
+
counts.confirmedDead > 0 ? `${counts.confirmedDead} confirmed inactive` : null,
|
|
149
162
|
].filter(Boolean).join(", ");
|
|
150
163
|
push(paint(c.bold, "Rotation:") +
|
|
151
164
|
` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
|
|
@@ -411,17 +424,26 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
411
424
|
// everything else is either already handled or a re-exposure of a value
|
|
412
425
|
// already accounted for.
|
|
413
426
|
if (rotation && rotation.counts.distinct > 0) {
|
|
414
|
-
const { pending, distinct, acked, dismissed } = rotation.counts;
|
|
427
|
+
const { pending, distinct, acked, dismissed, confirmedDead } = rotation.counts;
|
|
428
|
+
// confirmedDead is a PER-VALUE fact (a real --verify rejection, or a
|
|
429
|
+
// JWT's own signed exp claim already past), never an aggregate guess
|
|
430
|
+
// about a whole rule (see the Rotation section note: a rule's other,
|
|
431
|
+
// unverified findings say nothing either way). Subtracted here, not
|
|
432
|
+
// folded into `pending` itself, so --fail-on-find/--allow-acked and
|
|
433
|
+
// every other consumer of pending's original meaning are unaffected;
|
|
434
|
+
// this only changes what this one summary line tells a human to do.
|
|
435
|
+
const needsReview = pending - confirmedDead;
|
|
415
436
|
push();
|
|
416
437
|
push(paint(c.bold, "Recommended actions:"));
|
|
417
|
-
if (
|
|
418
|
-
push(` ${paint(c.yellow, "→")} ${
|
|
438
|
+
if (needsReview > 0) {
|
|
439
|
+
push(` ${paint(c.yellow, "→")} ${needsReview} of ${distinct} distinct value${distinct === 1 ? "" : "s"} ${needsReview === 1 ? "needs" : "need"} review: rotate the real ones (residoo ack), dismiss the rest (residoo dismiss)`);
|
|
419
440
|
} else {
|
|
420
441
|
push(` ${paint(c.green, "✓")} Nothing new to review; every distinct value here has already been triaged`);
|
|
421
442
|
}
|
|
422
443
|
const resolvedParts = [
|
|
423
444
|
acked > 0 ? `${acked} acknowledged` : null,
|
|
424
445
|
dismissed > 0 ? `${dismissed} dismissed` : null,
|
|
446
|
+
confirmedDead > 0 ? `${confirmedDead} confirmed inactive (verified rejected, or expired)` : null,
|
|
425
447
|
].filter(Boolean);
|
|
426
448
|
if (resolvedParts.length > 0) {
|
|
427
449
|
push(paint(c.dim, ` ${resolvedParts.join(", ")} already, no action needed (see Rotation below for which)`));
|
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/
|
|
@@ -491,6 +504,70 @@ const ROTATION_GUIDANCE = {
|
|
|
491
504
|
],
|
|
492
505
|
revokeNote: "Revoking immediately removes the token's access to every vault the service account could reach.",
|
|
493
506
|
},
|
|
507
|
+
// Fetched https://circleci.com/docs/managing-api-tokens/ (2026-09-03):
|
|
508
|
+
// Personal API Tokens tab under User Settings, revoke deletes it
|
|
509
|
+
// immediately.
|
|
510
|
+
circleci_token: {
|
|
511
|
+
label: "CircleCI personal API token",
|
|
512
|
+
consolePath: "circleci.com > User Settings > Personal API Tokens",
|
|
513
|
+
steps: [
|
|
514
|
+
"Open Personal API Tokens under your CircleCI user settings",
|
|
515
|
+
"Revoke the leaked token",
|
|
516
|
+
"Create a replacement and update whatever used the old one",
|
|
517
|
+
],
|
|
518
|
+
revokeNote: "Revocation is immediate; the token stops authenticating on the next request.",
|
|
519
|
+
},
|
|
520
|
+
// Fetched https://airtable.com/developers/web/guides/personal-access-tokens
|
|
521
|
+
// (2026-09-03): tokens are managed and deleted from the Personal access
|
|
522
|
+
// tokens page in the Airtable developer hub (airtable.com/create/tokens).
|
|
523
|
+
airtable_token: {
|
|
524
|
+
label: "Airtable personal access token",
|
|
525
|
+
consolePath: "airtable.com/create/tokens",
|
|
526
|
+
steps: [
|
|
527
|
+
"Open the Personal access tokens page in the developer hub",
|
|
528
|
+
"Delete the leaked token",
|
|
529
|
+
"Create a replacement scoped only to what your integration needs",
|
|
530
|
+
],
|
|
531
|
+
revokeNote: "Deletion is immediate and applies to every base the token could reach.",
|
|
532
|
+
},
|
|
533
|
+
// Fetched https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
|
|
534
|
+
// (2026-09-03): tokens are managed and revoked from My Profile > API
|
|
535
|
+
// Tokens.
|
|
536
|
+
cloudflare_api_token: {
|
|
537
|
+
label: "Cloudflare API token",
|
|
538
|
+
consolePath: "dash.cloudflare.com > My Profile > API Tokens",
|
|
539
|
+
steps: [
|
|
540
|
+
"Open My Profile > API Tokens in the Cloudflare dashboard",
|
|
541
|
+
"Roll (regenerate) or delete the leaked token",
|
|
542
|
+
"Update whatever used the old token with the replacement",
|
|
543
|
+
],
|
|
544
|
+
revokeNote: "Deleting is immediate; a scoped token only affects the zones/permissions it was granted.",
|
|
545
|
+
},
|
|
546
|
+
// Fetched https://help.heroku.com/PBGP6IDE (2026-09-03): API keys are
|
|
547
|
+
// regenerated from Account Settings, which invalidates the previous key.
|
|
548
|
+
heroku_api_key: {
|
|
549
|
+
label: "Heroku API key",
|
|
550
|
+
consolePath: "dashboard.heroku.com/account > API Key",
|
|
551
|
+
steps: [
|
|
552
|
+
"Open Account Settings in the Heroku dashboard",
|
|
553
|
+
"Regenerate the API key (this immediately invalidates the old one)",
|
|
554
|
+
"Update the CLI/CI configs and tools that used the old key",
|
|
555
|
+
],
|
|
556
|
+
revokeNote: "Regeneration is the only way to invalidate a Heroku API key; there is no separate revoke action.",
|
|
557
|
+
},
|
|
558
|
+
// Fetched https://docs.netlify.com/api/get-started/#authentication
|
|
559
|
+
// (2026-09-03): personal access tokens are managed from User settings >
|
|
560
|
+
// Applications > Personal access tokens.
|
|
561
|
+
netlify_token: {
|
|
562
|
+
label: "Netlify personal access token",
|
|
563
|
+
consolePath: "app.netlify.com/user/applications#personal-access-tokens",
|
|
564
|
+
steps: [
|
|
565
|
+
"Open User settings > Applications > Personal access tokens",
|
|
566
|
+
"Delete the leaked token",
|
|
567
|
+
"Create a replacement and update whatever used the old one",
|
|
568
|
+
],
|
|
569
|
+
revokeNote: "Deletion is immediate; the token stops authenticating on the next request.",
|
|
570
|
+
},
|
|
494
571
|
|
|
495
572
|
// ── Comms / SaaS ──────────────────────────────────────────────────────
|
|
496
573
|
// The user-facing support article (support.discord.com article 228383668)
|
|
@@ -903,12 +980,13 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
903
980
|
// payload. null for every non-JWT finding, and for a JWT that
|
|
904
981
|
// failed to decode or carries no exp claim.
|
|
905
982
|
jwtExpiresAtMs: null,
|
|
906
|
-
// --verify only (see verify.js): whether
|
|
907
|
-
//
|
|
908
|
-
// this value is one
|
|
909
|
-
// network calls otherwise.
|
|
910
|
-
|
|
911
|
-
|
|
983
|
+
// --verify only (see verify.js): whether the credential's own
|
|
984
|
+
// vendor (AWS, Slack) still accepts it. null unless the scan was
|
|
985
|
+
// run with --verify AND this value is one residoo knows how to
|
|
986
|
+
// check; residoo makes no network calls otherwise. Same two fields
|
|
987
|
+
// regardless of vendor: the ruleId already says which one answered.
|
|
988
|
+
verified: null,
|
|
989
|
+
verifiedDetail: null,
|
|
912
990
|
};
|
|
913
991
|
byFp.set(st.fingerprint, e);
|
|
914
992
|
}
|
|
@@ -922,7 +1000,7 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
922
1000
|
// credential was rotated or revoked, only that residoo hasn't seen it
|
|
923
1001
|
// paste anywhere more recently than this. residoo makes no network
|
|
924
1002
|
// calls in the default path, so this alone never checks a provider for
|
|
925
|
-
// whether a key is still live (see
|
|
1003
|
+
// whether a key is still live (see verified above for the opt-in
|
|
926
1004
|
// exception, and jwtExpiresAtMs for the zero-network JWT case).
|
|
927
1005
|
if (typeof f.fileMTimeMs === "number" && (e.lastSeenMs === null || f.fileMTimeMs > e.lastSeenMs)) {
|
|
928
1006
|
e.lastSeenMs = f.fileMTimeMs;
|
|
@@ -940,26 +1018,31 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
940
1018
|
if (e.jwtExpiresAtMs === null && typeof f.jwtExpiresAtMs === "number") {
|
|
941
1019
|
e.jwtExpiresAtMs = f.jwtExpiresAtMs;
|
|
942
1020
|
}
|
|
943
|
-
if (e.
|
|
944
|
-
e.
|
|
945
|
-
e.
|
|
1021
|
+
if (e.verified === null && typeof f.verified === "string") {
|
|
1022
|
+
e.verified = f.verified;
|
|
1023
|
+
e.verifiedDetail = typeof f.verifiedDetail === "string" ? f.verifiedDetail : null;
|
|
946
1024
|
}
|
|
947
1025
|
}
|
|
948
1026
|
|
|
949
1027
|
// Within a status tier, order by how demonstrated-urgent an entry is, not
|
|
950
1028
|
// just its rule id: a real pair (see pairing.js) is a DEMONSTRATED usable
|
|
951
|
-
// credential, and --verify confirming
|
|
952
|
-
// evidence still; either way this entry must never be the one
|
|
953
|
-
// cap (see renderRotationSection) pushes into "N more."
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
1029
|
+
// credential, and --verify confirming the vendor still accepts it is
|
|
1030
|
+
// stronger evidence still; either way this entry must never be the one
|
|
1031
|
+
// the display cap (see renderRotationSection) pushes into "N more."
|
|
1032
|
+
//
|
|
1033
|
+
// isConfirmedDead is the other direction, PROOF rather than a guess:
|
|
1034
|
+
// --verify got a real "no" from the vendor, or a JWT's own signed exp
|
|
1035
|
+
// claim is already in the past (decoded locally, no network call, always
|
|
1036
|
+
// attempted; see jwtExpiry.js). Deliberately NOT "unverified" or "no
|
|
1037
|
+
// pairing found": those mean residoo doesn't know, a weaker claim than
|
|
1038
|
+
// residoo knows this one specific value needs no action. Sorts LOWER
|
|
1039
|
+
// than an ordinary finding within its tier, and (see confirmedDead below)
|
|
1040
|
+
// is subtracted from what the report tells a human still needs a look.
|
|
1041
|
+
const isConfirmedDead = (e) => e.verified === "invalid" || (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now());
|
|
958
1042
|
const priorityScore = (e) => {
|
|
959
|
-
if (e.
|
|
1043
|
+
if (e.verified === "active") return -2;
|
|
960
1044
|
if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null) return -1;
|
|
961
|
-
if (e
|
|
962
|
-
if (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now()) return 1;
|
|
1045
|
+
if (isConfirmedDead(e)) return 1;
|
|
963
1046
|
return 0;
|
|
964
1047
|
};
|
|
965
1048
|
const entries = [...byFp.values()].sort((a, b) => {
|
|
@@ -970,7 +1053,16 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
970
1053
|
return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
971
1054
|
});
|
|
972
1055
|
|
|
973
|
-
|
|
1056
|
+
// A per-VALUE fact, never rolled up into a per-RULE label: only the
|
|
1057
|
+
// specific values residoo actually checked (or that carry their own
|
|
1058
|
+
// signed exp claim) ever count here, so this can never overstate what was
|
|
1059
|
+
// proven about the rest of a rule's unverified findings. Counted only
|
|
1060
|
+
// among PENDING entries: one already acked or dismissed is excluded from
|
|
1061
|
+
// "needs review" for its own reason already, and double-subtracting would
|
|
1062
|
+
// make the arithmetic in the report not add up.
|
|
1063
|
+
const confirmedDead = entries.filter((e) => e.status === "pending" && isConfirmedDead(e)).length;
|
|
1064
|
+
|
|
1065
|
+
return { counts: { ...counts, confirmedDead }, entries };
|
|
974
1066
|
}
|
|
975
1067
|
|
|
976
1068
|
module.exports = {
|
package/src/scan.js
CHANGED
|
@@ -6,13 +6,89 @@ const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("
|
|
|
6
6
|
const { findPairedSecret } = require("./pairing");
|
|
7
7
|
const { looksRandom } = require("./rarity");
|
|
8
8
|
const { decodeJwtExpiryMs } = require("./jwtExpiry");
|
|
9
|
-
const {
|
|
9
|
+
const {
|
|
10
|
+
isAwsCliAvailable, verifyAwsCredential,
|
|
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
|
+
} = require("./verify");
|
|
10
18
|
|
|
11
|
-
// Never verify more than this many distinct
|
|
12
|
-
// pathological transcript with dozens of distinct
|
|
13
|
-
// not turn --verify into a long burst of outbound
|
|
14
|
-
// 0-2; this is a backstop, not the expected path.
|
|
15
|
-
const
|
|
19
|
+
// Never verify more than this many distinct credentials of ONE vendor in a
|
|
20
|
+
// single scan: a pathological transcript with dozens of distinct
|
|
21
|
+
// credentials should not turn --verify into a long burst of outbound calls.
|
|
22
|
+
// Real scans see 0-2 per vendor; this is a backstop, not the expected path.
|
|
23
|
+
const MAX_VERIFICATIONS_PER_VENDOR = 10;
|
|
24
|
+
|
|
25
|
+
// Every vendor whose credential is a single, unpaired bearer token: no
|
|
26
|
+
// AWS-style "two halves make one credential" pairing step, so these all
|
|
27
|
+
// share one collection/verification path below (see pendingSimpleVerifications).
|
|
28
|
+
// Deliberately NOT here despite being detected: google_api_key (a key can
|
|
29
|
+
// belong to any Google product; testing it against one product's endpoint
|
|
30
|
+
// would misreport a valid key for a DIFFERENT product as invalid) and
|
|
31
|
+
// perplexity_key (no free, side-effect-free endpoint exists at all). See
|
|
32
|
+
// verify.js's own header comment for the fuller reasoning, including the
|
|
33
|
+
// vendors deferred rather than rushed (PlanetScale, Fly.io).
|
|
34
|
+
const SIMPLE_VERIFY_FNS = {
|
|
35
|
+
slack_token: verifySlackToken,
|
|
36
|
+
openai_key: verifyOpenAiKey,
|
|
37
|
+
anthropic_key: verifyAnthropicKey,
|
|
38
|
+
github_pat: verifyGithubToken,
|
|
39
|
+
huggingface_token: verifyHuggingFaceToken,
|
|
40
|
+
replicate_token: verifyReplicateToken,
|
|
41
|
+
digitalocean_token: verifyDigitalOceanToken,
|
|
42
|
+
pinecone_key: verifyPineconeKey,
|
|
43
|
+
sendgrid_key: verifySendgridKey,
|
|
44
|
+
groq_key: verifyGroqKey,
|
|
45
|
+
xai_key: verifyXaiKey,
|
|
46
|
+
openrouter_key: verifyOpenRouterKey,
|
|
47
|
+
stripe_key: verifyStripeKey,
|
|
48
|
+
stripe_test_key: verifyStripeKey,
|
|
49
|
+
npm_token: verifyNpmToken,
|
|
50
|
+
notion_token: verifyNotionToken,
|
|
51
|
+
gitlab_pat: verifyGitlabToken,
|
|
52
|
+
supabase_token: verifySupabaseToken,
|
|
53
|
+
elevenlabs_key: verifyElevenLabsKey,
|
|
54
|
+
circleci_token: verifyCircleciToken,
|
|
55
|
+
airtable_token: verifyAirtableToken,
|
|
56
|
+
cloudflare_api_token: verifyCloudflareToken,
|
|
57
|
+
heroku_api_key: verifyHerokuKey,
|
|
58
|
+
netlify_token: verifyNetlifyToken,
|
|
59
|
+
linear_key: verifyLinearKey,
|
|
60
|
+
telegram_bot_token: verifyTelegramToken,
|
|
61
|
+
discord_webhook: verifyDiscordWebhook,
|
|
62
|
+
};
|
|
63
|
+
const SIMPLE_VERIFY_VENDOR_LABEL = {
|
|
64
|
+
slack_token: "Slack's auth.test",
|
|
65
|
+
openai_key: "OpenAI's models endpoint",
|
|
66
|
+
anthropic_key: "Anthropic's models endpoint",
|
|
67
|
+
github_pat: "GitHub's user endpoint",
|
|
68
|
+
huggingface_token: "Hugging Face's whoami endpoint",
|
|
69
|
+
replicate_token: "Replicate's account endpoint",
|
|
70
|
+
digitalocean_token: "DigitalOcean's account endpoint",
|
|
71
|
+
pinecone_key: "Pinecone's indexes endpoint",
|
|
72
|
+
sendgrid_key: "SendGrid's scopes endpoint",
|
|
73
|
+
groq_key: "Groq's models endpoint",
|
|
74
|
+
xai_key: "xAI's api-key endpoint",
|
|
75
|
+
openrouter_key: "OpenRouter's key endpoint",
|
|
76
|
+
stripe_key: "Stripe's balance endpoint",
|
|
77
|
+
stripe_test_key: "Stripe's balance endpoint",
|
|
78
|
+
npm_token: "npm's whoami endpoint",
|
|
79
|
+
notion_token: "Notion's users endpoint",
|
|
80
|
+
gitlab_pat: "GitLab's user endpoint",
|
|
81
|
+
supabase_token: "Supabase's projects endpoint",
|
|
82
|
+
elevenlabs_key: "ElevenLabs' user endpoint",
|
|
83
|
+
circleci_token: "CircleCI's me endpoint",
|
|
84
|
+
airtable_token: "Airtable's whoami endpoint",
|
|
85
|
+
cloudflare_api_token: "Cloudflare's token-verify endpoint",
|
|
86
|
+
heroku_api_key: "Heroku's account endpoint",
|
|
87
|
+
netlify_token: "Netlify's sites endpoint",
|
|
88
|
+
linear_key: "Linear's GraphQL API",
|
|
89
|
+
telegram_bot_token: "Telegram's getMe endpoint",
|
|
90
|
+
discord_webhook: "Discord's webhook-info endpoint",
|
|
91
|
+
};
|
|
16
92
|
|
|
17
93
|
// Rule ids that findPairedSecret's window search applies to (see pairing.js):
|
|
18
94
|
// AWS access key ids and STS session tokens both pair with the same shape
|
|
@@ -135,7 +211,7 @@ function safeName(file) { return path.basename(file); }
|
|
|
135
211
|
* absolute path can itself carry a username or a project name the rest of
|
|
136
212
|
* this report is careful never to print.
|
|
137
213
|
*/
|
|
138
|
-
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null,
|
|
214
|
+
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null } = {}) {
|
|
139
215
|
const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
|
|
140
216
|
// The decode pass (see decode.js) only applies high-confidence, vendor-
|
|
141
217
|
// prefixed rules to decoded bytes: random binary that decodes to printable
|
|
@@ -165,6 +241,12 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
165
241
|
// call; nothing in it is ever written to a finding until verification has
|
|
166
242
|
// REPLACED the raw values with a status string.
|
|
167
243
|
const pendingAwsVerifications = new Map();
|
|
244
|
+
// --verify only (see verify.js): ruleId -> (token value -> { refs }), for
|
|
245
|
+
// every SIMPLE_VERIFY_FNS vendor. Unlike AWS, none of these need pairing
|
|
246
|
+
// (the token itself is the complete credential), so this is simpler: one
|
|
247
|
+
// entry per distinct value per rule, `refs` accumulating every finding
|
|
248
|
+
// object that value produced.
|
|
249
|
+
const pendingSimpleVerifications = new Map();
|
|
168
250
|
|
|
169
251
|
// One place raw matched text turns into a recorded finding: counts the
|
|
170
252
|
// distinct value and pushes the redacted record. `extra` carries the
|
|
@@ -268,7 +350,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
268
350
|
const jwtExtra = (!suppressedReason && rule.id === "jwt")
|
|
269
351
|
? { jwtExpiresAtMs: decodeJwtExpiryMs(m[0]) }
|
|
270
352
|
: null;
|
|
271
|
-
const
|
|
353
|
+
const primaryFinding = record(rule, m[0], relFile, file, lineNo,
|
|
272
354
|
mtimeMs,
|
|
273
355
|
resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
|
|
274
356
|
suppressedReason,
|
|
@@ -282,12 +364,30 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
282
364
|
// re-echoed across several lines gets several finding objects, and
|
|
283
365
|
// the eventual result is applied to every one of them, not only
|
|
284
366
|
// the first.
|
|
285
|
-
if (
|
|
286
|
-
if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size <
|
|
367
|
+
if (verify && secretFinding && rawPairedSecret) {
|
|
368
|
+
if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
287
369
|
pendingAwsVerifications.set(m[0], { secretValue: rawPairedSecret, refs: [] });
|
|
288
370
|
}
|
|
289
371
|
const entry = pendingAwsVerifications.get(m[0]);
|
|
290
|
-
if (entry) entry.refs.push({ akiaFinding, secretFinding });
|
|
372
|
+
if (entry) entry.refs.push({ akiaFinding: primaryFinding, secretFinding });
|
|
373
|
+
}
|
|
374
|
+
// --verify, single-token vendors (Slack, OpenAI, Anthropic,
|
|
375
|
+
// GitHub): none of these need pairing (the value IS the complete
|
|
376
|
+
// credential), so queue every unsuppressed match directly, same
|
|
377
|
+
// dedup-by-value / accumulate-all-refs shape as the AWS map above,
|
|
378
|
+
// just one level deeper (keyed by rule id too, since several
|
|
379
|
+
// vendors share this path).
|
|
380
|
+
if (verify && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
|
|
381
|
+
let byValue = pendingSimpleVerifications.get(rule.id);
|
|
382
|
+
if (!byValue) {
|
|
383
|
+
byValue = new Map();
|
|
384
|
+
pendingSimpleVerifications.set(rule.id, byValue);
|
|
385
|
+
}
|
|
386
|
+
if (!byValue.has(m[0]) && byValue.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
387
|
+
byValue.set(m[0], { refs: [] });
|
|
388
|
+
}
|
|
389
|
+
const entry = byValue.get(m[0]);
|
|
390
|
+
if (entry) entry.refs.push(primaryFinding);
|
|
291
391
|
}
|
|
292
392
|
}
|
|
293
393
|
if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
|
|
@@ -446,16 +546,36 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
446
546
|
|
|
447
547
|
// --verify: runs once, here, after every file has been scanned, never
|
|
448
548
|
// interleaved with the matching pass above. A real network call per
|
|
449
|
-
// distinct
|
|
549
|
+
// distinct credential, one at a time (not concurrent), so this is the one
|
|
450
550
|
// place a scan's wall-clock time depends on something other than disk
|
|
451
551
|
// I/O; that tradeoff only exists when a caller explicitly asked for it.
|
|
452
|
-
|
|
453
|
-
|
|
552
|
+
//
|
|
553
|
+
// onBeforeVerify exists so a caller with its own stderr chatter (the
|
|
554
|
+
// progress spinner) can clear it first: this pass writes its own stderr
|
|
555
|
+
// lines below, and the spinner's own stop() doesn't run until scan()
|
|
556
|
+
// fully returns, which is AFTER those lines have already printed. Without
|
|
557
|
+
// this, the last spinner frame sits uncleared on screen and the first
|
|
558
|
+
// --verify line gets appended directly onto the end of it with no
|
|
559
|
+
// separator, a real rendering bug caught live. Only called when there is
|
|
560
|
+
// actually something to verify, so a plain --verify with nothing to check
|
|
561
|
+
// never clears a spinner line for no reason.
|
|
562
|
+
const anyPending = pendingAwsVerifications.size > 0 ||
|
|
563
|
+
[...pendingSimpleVerifications.values()].some((byValue) => byValue.size > 0);
|
|
564
|
+
if (verify && anyPending && typeof onBeforeVerify === "function") onBeforeVerify();
|
|
565
|
+
|
|
566
|
+
// Same field names (verified/verifiedDetail) regardless of which vendor
|
|
567
|
+
// produced the result: rotation.js and report.js render them identically,
|
|
568
|
+
// and the finding's own ruleId already says which vendor answered.
|
|
569
|
+
const applyVerifyResult = (refs, result) => {
|
|
570
|
+
for (const ref of refs) {
|
|
571
|
+
ref.verified = result.status;
|
|
572
|
+
ref.verifiedDetail = result.detail;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
if (verify && pendingAwsVerifications.size > 0) {
|
|
576
|
+
const applyPair = (refs, result) => {
|
|
454
577
|
for (const ref of refs) {
|
|
455
|
-
ref.akiaFinding.
|
|
456
|
-
ref.akiaFinding.awsVerifiedDetail = result.detail;
|
|
457
|
-
ref.secretFinding.awsVerified = result.status;
|
|
458
|
-
ref.secretFinding.awsVerifiedDetail = result.detail;
|
|
578
|
+
applyVerifyResult([ref.akiaFinding, ref.secretFinding], result);
|
|
459
579
|
}
|
|
460
580
|
};
|
|
461
581
|
if (!isAwsCliAvailable()) {
|
|
@@ -465,7 +585,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
465
585
|
"Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) to use --verify.\n"
|
|
466
586
|
);
|
|
467
587
|
const result = { status: "error", detail: "aws CLI not found on PATH" };
|
|
468
|
-
for (const { refs } of pendingAwsVerifications.values())
|
|
588
|
+
for (const { refs } of pendingAwsVerifications.values()) applyPair(refs, result);
|
|
469
589
|
} else {
|
|
470
590
|
process.stderr.write(
|
|
471
591
|
`residoo --verify: calling AWS sts:get-caller-identity for ${pendingAwsVerifications.size} ` +
|
|
@@ -474,7 +594,22 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
474
594
|
);
|
|
475
595
|
for (const [accessKeyValue, { secretValue, refs }] of pendingAwsVerifications) {
|
|
476
596
|
const result = verifyAwsCredential(accessKeyValue, secretValue);
|
|
477
|
-
|
|
597
|
+
applyPair(refs, result);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (verify) {
|
|
602
|
+
for (const [ruleId, byValue] of pendingSimpleVerifications) {
|
|
603
|
+
if (byValue.size === 0) continue;
|
|
604
|
+
const verifyFn = SIMPLE_VERIFY_FNS[ruleId];
|
|
605
|
+
process.stderr.write(
|
|
606
|
+
`residoo --verify: calling ${SIMPLE_VERIFY_VENDOR_LABEL[ruleId]} for ${byValue.size} ` +
|
|
607
|
+
"token(s) found in this scan. This is a real network request, using the exact " +
|
|
608
|
+
"token found in your transcript, one at a time.\n"
|
|
609
|
+
);
|
|
610
|
+
for (const [value, { refs }] of byValue) {
|
|
611
|
+
const result = await verifyFn(value);
|
|
612
|
+
applyVerifyResult(refs, result);
|
|
478
613
|
}
|
|
479
614
|
}
|
|
480
615
|
}
|
package/src/verify.js
CHANGED
|
@@ -6,31 +6,46 @@
|
|
|
6
6
|
* Everything else in residoo is detection only: a shape matched a pattern,
|
|
7
7
|
* nothing more, zero network calls, by design (see README's "What it does
|
|
8
8
|
* not do"). This module is the one deliberate exception, and only when a
|
|
9
|
-
* user explicitly passes --verify: it
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* permission-less call AWS's own docs and tools like the AWS CLI and
|
|
13
|
-
* aws-vault use for exactly this "is this credential still alive" check.
|
|
9
|
+
* user explicitly passes --verify: it asks the credential's own vendor
|
|
10
|
+
* whether it still authenticates, via whatever free, read-only check that
|
|
11
|
+
* vendor documents for exactly this "is this still alive" question.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Two different implementation strategies live in this one file, chosen
|
|
14
|
+
* per vendor by how risky it would be to get wrong:
|
|
15
|
+
*
|
|
16
|
+
* AWS (verifyAwsCredential) shells out to the user's own `aws` CLI rather
|
|
17
|
+
* than hand-rolling AWS SigV4 request signing. Two reasons, not one: first,
|
|
17
18
|
* residoo ships zero runtime dependencies, and a correct SigV4
|
|
18
19
|
* implementation is real, easy-to-get-subtly-wrong cryptographic code this
|
|
19
20
|
* project cannot verify against a live AWS account in CI; a signing bug
|
|
20
21
|
* here would silently report every real key as "invalid," which is actively
|
|
21
22
|
* worse than not verifying at all. Second, the AWS CLI is exactly the
|
|
22
|
-
* client AWS itself maintains and tests against its own service
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Every environment variable the aws CLI reads is built from scratch here,
|
|
27
|
-
* never inherited from process.env: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
|
|
28
|
-
* are set to the exact values found in the scan, and AWS_CONFIG_FILE/
|
|
23
|
+
* client AWS itself maintains and tests against its own service. Every
|
|
24
|
+
* environment variable the aws CLI reads is built from scratch, never
|
|
25
|
+
* inherited from process.env: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are
|
|
26
|
+
* set to the exact values found in the scan, and AWS_CONFIG_FILE/
|
|
29
27
|
* AWS_SHARED_CREDENTIALS_FILE point at /dev/null so the CLI cannot fall
|
|
30
28
|
* back to the user's own real default profile if the found credential is
|
|
31
|
-
* malformed in some way that would otherwise trigger a fallback.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
29
|
+
* malformed in some way that would otherwise trigger a fallback.
|
|
30
|
+
*
|
|
31
|
+
* Slack (verifySlackToken) calls the API directly with the built-in fetch
|
|
32
|
+
* instead: unlike AWS, Slack's auth check (auth.test) is a single bearer-
|
|
33
|
+
* token HTTP call with no request signing at all, so there is no signing
|
|
34
|
+
* bug to be worried about, and no CLI most residoo users would already
|
|
35
|
+
* have installed the way they'd have the aws CLI. Direct fetch is both
|
|
36
|
+
* simpler and more portable here; shelling out to a hypothetical "slack
|
|
37
|
+
* CLI" would add a dependency for no safety benefit. This is the pattern
|
|
38
|
+
* for any future vendor: shell out to that vendor's own official CLI only
|
|
39
|
+
* when the auth scheme itself is complex enough to be worth not
|
|
40
|
+
* reimplementing (AWS's SigV4); call directly for a plain bearer token.
|
|
41
|
+
*
|
|
42
|
+
* OpenAI, Anthropic, and GitHub (verifyOpenAiKey, verifyAnthropicKey,
|
|
43
|
+
* verifyGithubToken) share one implementation (verifyByStatusCode): each is
|
|
44
|
+
* a plain GET to a free, side-effect-free, already-authenticated endpoint
|
|
45
|
+
* (that vendor's own "list what I can see" call), where the HTTP status
|
|
46
|
+
* code alone says whether the credential authenticated. Slack needed its
|
|
47
|
+
* own function because auth.test always returns HTTP 200 and signals
|
|
48
|
+
* failure inside the JSON body instead of the status code.
|
|
34
49
|
*/
|
|
35
50
|
|
|
36
51
|
const { spawnSync } = require("child_process");
|
|
@@ -129,4 +144,349 @@ function verifyAwsCredential(accessKeyId, secretAccessKey, { spawnFn = spawnSync
|
|
|
129
144
|
return { status: "error", detail: `could not verify: ${sanitizeDetail(stderr).slice(0, 120) || `aws exited ${r.status}`}` };
|
|
130
145
|
}
|
|
131
146
|
|
|
132
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Test-only escape hatch, same purpose as RESIDOO_TEST_AWS_CLI above but for
|
|
149
|
+
* an HTTP call instead of a subprocess: when RESIDOO_TEST_SLACK_API_URL is
|
|
150
|
+
* set, verifySlackToken calls that URL instead of Slack's real API, so a
|
|
151
|
+
* test can point at a small local HTTP server and exercise the real fetch +
|
|
152
|
+
* header + JSON-parsing plumbing without ever reaching slack.com.
|
|
153
|
+
*/
|
|
154
|
+
function slackAuthTestUrl() {
|
|
155
|
+
return process.env.RESIDOO_TEST_SLACK_API_URL || "https://slack.com/api/auth.test";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Slack's own documented error codes for auth.test that mean the token
|
|
159
|
+
// itself is dead (revoked, expired, or never valid), not merely rate
|
|
160
|
+
// limited or a transient server problem.
|
|
161
|
+
const SLACK_DEAD_TOKEN_ERRORS = new Set([
|
|
162
|
+
"invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive",
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ask Slack whether this exact token still authenticates, via auth.test
|
|
167
|
+
* (api.slack.com/methods/auth.test): a bearer-token-only call Slack's own
|
|
168
|
+
* docs recommend for checking token validity, needing no scope of its own.
|
|
169
|
+
* Same three-way { status, detail } contract as verifyAwsCredential.
|
|
170
|
+
*/
|
|
171
|
+
async function verifySlackToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetchFn(slackAuthTestUrl(), {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
177
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
178
|
+
});
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return { status: "error", detail: `could not reach Slack (${sanitizeDetail(e && e.message)})` };
|
|
181
|
+
}
|
|
182
|
+
let body;
|
|
183
|
+
try {
|
|
184
|
+
body = await res.json();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { status: "error", detail: `Slack returned a non-JSON response (HTTP ${res.status})` };
|
|
187
|
+
}
|
|
188
|
+
if (body && body.ok === true) {
|
|
189
|
+
return { status: "active", detail: "Slack accepted this token (auth.test)" };
|
|
190
|
+
}
|
|
191
|
+
const err = body && typeof body.error === "string" ? body.error : null;
|
|
192
|
+
if (err && SLACK_DEAD_TOKEN_ERRORS.has(err)) {
|
|
193
|
+
return { status: "invalid", detail: `Slack rejected this token (${sanitizeDetail(err)})` };
|
|
194
|
+
}
|
|
195
|
+
return { status: "error", detail: `could not verify: ${sanitizeDetail(err) || `HTTP ${res.status}`}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shared implementation for every vendor below Slack: a plain GET to a
|
|
200
|
+
* free, side-effect-free, already-authenticated endpoint (each vendor's own
|
|
201
|
+
* "list what I can see" call), where the HTTP status code alone says
|
|
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.
|
|
216
|
+
*/
|
|
217
|
+
async function verifyByStatusCode(vendorName, url, buildHeaders, {
|
|
218
|
+
fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
219
|
+
invalidStatuses = [401, 403], activeExtra = [],
|
|
220
|
+
} = {}) {
|
|
221
|
+
let res;
|
|
222
|
+
try {
|
|
223
|
+
res = await fetchFn(url, {
|
|
224
|
+
method: "GET",
|
|
225
|
+
headers: buildHeaders(),
|
|
226
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
227
|
+
});
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return { status: "error", detail: `could not reach ${vendorName} (${sanitizeDetail(e && e.message)})` };
|
|
230
|
+
}
|
|
231
|
+
if (res.status === 200 || activeExtra.includes(res.status)) {
|
|
232
|
+
return { status: "active", detail: `${vendorName} accepted this key` };
|
|
233
|
+
}
|
|
234
|
+
if (invalidStatuses.includes(res.status)) {
|
|
235
|
+
return { status: "invalid", detail: `${vendorName} rejected this key (HTTP ${res.status})` };
|
|
236
|
+
}
|
|
237
|
+
return { status: "error", detail: `could not verify: HTTP ${res.status} from ${vendorName}` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Test-only escape hatches, same purpose and pattern as
|
|
241
|
+
// RESIDOO_TEST_SLACK_API_URL above: when set, the matching verify function
|
|
242
|
+
// calls that URL instead of the vendor's real one.
|
|
243
|
+
function openAiModelsUrl() {
|
|
244
|
+
return process.env.RESIDOO_TEST_OPENAI_API_URL || "https://api.openai.com/v1/models";
|
|
245
|
+
}
|
|
246
|
+
function anthropicModelsUrl() {
|
|
247
|
+
return process.env.RESIDOO_TEST_ANTHROPIC_API_URL || "https://api.anthropic.com/v1/models";
|
|
248
|
+
}
|
|
249
|
+
function githubUserUrl() {
|
|
250
|
+
return process.env.RESIDOO_TEST_GITHUB_API_URL || "https://api.github.com/user";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** OpenAI: GET /v1/models, a free, read-only call that needs only a valid key, no usage cost. */
|
|
254
|
+
function verifyOpenAiKey(key, opts) {
|
|
255
|
+
return verifyByStatusCode("OpenAI", openAiModelsUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Anthropic: GET /v1/models. Two headers, not one, and NOT an Authorization
|
|
260
|
+
* Bearer header: Anthropic's API takes the key as x-api-key, and every
|
|
261
|
+
* request needs an anthropic-version header regardless of endpoint.
|
|
262
|
+
*/
|
|
263
|
+
function verifyAnthropicKey(key, opts) {
|
|
264
|
+
return verifyByStatusCode("Anthropic", anthropicModelsUrl(), () => ({
|
|
265
|
+
"x-api-key": key,
|
|
266
|
+
"anthropic-version": "2023-06-01",
|
|
267
|
+
}), opts);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** GitHub: GET /user with the token, a free, read-only call that needs no scopes. */
|
|
271
|
+
function verifyGithubToken(token, opts) {
|
|
272
|
+
return verifyByStatusCode("GitHub", githubUserUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
273
|
+
}
|
|
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
|
+
// - PlanetScale, Fly.io: real, confirmed endpoints, deliberately deferred
|
|
293
|
+
// rather than rushed. PlanetScale's auth header needs BOTH the token
|
|
294
|
+
// id and the secret together, a paired-credential shape residoo has no
|
|
295
|
+
// detection rule for (only the secret's pscale_tkn_ prefix is
|
|
296
|
+
// detected). Fly.io needs one of two different Authorization header
|
|
297
|
+
// schemes depending on which of two token prefixes was found (a plain
|
|
298
|
+
// Bearer header for one, the literal string "FlyV1 <token>" for the
|
|
299
|
+
// other) layered on top of a GraphQL body check — real, but more
|
|
300
|
+
// engineering than the rest of this batch, better done as its own
|
|
301
|
+
// careful pass than folded in here.
|
|
302
|
+
|
|
303
|
+
function huggingfaceUrl() { return process.env.RESIDOO_TEST_HUGGINGFACE_API_URL || "https://huggingface.co/api/whoami-v2"; }
|
|
304
|
+
function sendgridUrl() { return process.env.RESIDOO_TEST_SENDGRID_API_URL || "https://api.sendgrid.com/v3/scopes"; }
|
|
305
|
+
function replicateUrl() { return process.env.RESIDOO_TEST_REPLICATE_API_URL || "https://api.replicate.com/v1/account"; }
|
|
306
|
+
function digitaloceanUrl() { return process.env.RESIDOO_TEST_DIGITALOCEAN_API_URL || "https://api.digitalocean.com/v2/account"; }
|
|
307
|
+
function pineconeUrl() { return process.env.RESIDOO_TEST_PINECONE_API_URL || "https://api.pinecone.io/indexes"; }
|
|
308
|
+
function groqUrl() { return process.env.RESIDOO_TEST_GROQ_API_URL || "https://api.groq.com/openai/v1/models"; }
|
|
309
|
+
function xaiUrl() { return process.env.RESIDOO_TEST_XAI_API_URL || "https://api.x.ai/v1/api-key"; }
|
|
310
|
+
function openrouterUrl() { return process.env.RESIDOO_TEST_OPENROUTER_API_URL || "https://openrouter.ai/api/v1/key"; }
|
|
311
|
+
function stripeUrl() { return process.env.RESIDOO_TEST_STRIPE_API_URL || "https://api.stripe.com/v1/balance"; }
|
|
312
|
+
function npmUrl() { return process.env.RESIDOO_TEST_NPM_API_URL || "https://registry.npmjs.org/-/whoami"; }
|
|
313
|
+
function notionUrl() { return process.env.RESIDOO_TEST_NOTION_API_URL || "https://api.notion.com/v1/users"; }
|
|
314
|
+
function gitlabUrl() { return process.env.RESIDOO_TEST_GITLAB_API_URL || "https://gitlab.com/api/v4/user"; }
|
|
315
|
+
function supabaseUrl() { return process.env.RESIDOO_TEST_SUPABASE_API_URL || "https://api.supabase.com/v1/projects"; }
|
|
316
|
+
function elevenlabsUrl() { return process.env.RESIDOO_TEST_ELEVENLABS_API_URL || "https://api.elevenlabs.io/v1/user"; }
|
|
317
|
+
function circleciUrl() { return process.env.RESIDOO_TEST_CIRCLECI_API_URL || "https://circleci.com/api/v2/me"; }
|
|
318
|
+
function airtableUrl() { return process.env.RESIDOO_TEST_AIRTABLE_API_URL || "https://api.airtable.com/v0/meta/whoami"; }
|
|
319
|
+
function cloudflareUrl() { return process.env.RESIDOO_TEST_CLOUDFLARE_API_URL || "https://api.cloudflare.com/client/v4/user/tokens/verify"; }
|
|
320
|
+
function herokuUrl() { return process.env.RESIDOO_TEST_HEROKU_API_URL || "https://api.heroku.com/account"; }
|
|
321
|
+
function netlifyUrl() { return process.env.RESIDOO_TEST_NETLIFY_API_URL || "https://api.netlify.com/api/v1/sites"; }
|
|
322
|
+
function linearUrl() { return process.env.RESIDOO_TEST_LINEAR_API_URL || "https://api.linear.app/graphql"; }
|
|
323
|
+
function telegramUrl(token) {
|
|
324
|
+
const base = process.env.RESIDOO_TEST_TELEGRAM_API_URL || "https://api.telegram.org";
|
|
325
|
+
return `${base}/bot${token}/getMe`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function verifyHuggingFaceToken(token, opts) {
|
|
329
|
+
return verifyByStatusCode("Hugging Face", huggingfaceUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
330
|
+
}
|
|
331
|
+
function verifyReplicateToken(token, opts) {
|
|
332
|
+
return verifyByStatusCode("Replicate", replicateUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
333
|
+
}
|
|
334
|
+
function verifyDigitalOceanToken(token, opts) {
|
|
335
|
+
return verifyByStatusCode("DigitalOcean", digitaloceanUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
336
|
+
}
|
|
337
|
+
/** 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. */
|
|
338
|
+
function verifySendgridKey(key, opts) {
|
|
339
|
+
return verifyByStatusCode("SendGrid", sendgridUrl(), () => ({ Authorization: `Bearer ${key}` }), { ...opts, activeExtra: [403] });
|
|
340
|
+
}
|
|
341
|
+
/** 403 from Pinecone means the key is real but lacks control-plane permissions, not that it's dead. */
|
|
342
|
+
function verifyPineconeKey(key, opts) {
|
|
343
|
+
return verifyByStatusCode("Pinecone", pineconeUrl(), () => ({ "Api-Key": key }), { ...opts, activeExtra: [403] });
|
|
344
|
+
}
|
|
345
|
+
function verifyGroqKey(key, opts) {
|
|
346
|
+
return verifyByStatusCode("Groq", groqUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
|
|
347
|
+
}
|
|
348
|
+
function verifyXaiKey(key, opts) {
|
|
349
|
+
return verifyByStatusCode("xAI", xaiUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
|
|
350
|
+
}
|
|
351
|
+
function verifyOpenRouterKey(key, opts) {
|
|
352
|
+
return verifyByStatusCode("OpenRouter", openrouterUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
|
|
353
|
+
}
|
|
354
|
+
/** Stripe: HTTP Basic auth, the key as username and an empty password — NOT a Bearer header. */
|
|
355
|
+
function verifyStripeKey(key, opts) {
|
|
356
|
+
return verifyByStatusCode("Stripe", stripeUrl(), () => ({
|
|
357
|
+
Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
|
|
358
|
+
}), opts);
|
|
359
|
+
}
|
|
360
|
+
function verifyNpmToken(token, opts) {
|
|
361
|
+
return verifyByStatusCode("npm", npmUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
362
|
+
}
|
|
363
|
+
/** Notion requires an explicit API version header on every request, regardless of endpoint. */
|
|
364
|
+
function verifyNotionToken(token, opts) {
|
|
365
|
+
return verifyByStatusCode("Notion", notionUrl(), () => ({
|
|
366
|
+
Authorization: `Bearer ${token}`,
|
|
367
|
+
"Notion-Version": "2022-06-28",
|
|
368
|
+
}), opts);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* GitLab's own docs recommend PRIVATE-TOKEN over a Bearer header for
|
|
372
|
+
* personal access tokens. 403 there means valid token, wrong scope for
|
|
373
|
+
* this specific call (trufflehog's own detector treats it the same way,
|
|
374
|
+
* except when the response body says the account itself is blocked — a
|
|
375
|
+
* rare enough edge case, and one where reporting "active" instead of
|
|
376
|
+
* "invalid" is the safe direction to be wrong in, that this doesn't
|
|
377
|
+
* special-case it further).
|
|
378
|
+
*/
|
|
379
|
+
function verifyGitlabToken(token, opts) {
|
|
380
|
+
return verifyByStatusCode("GitLab", gitlabUrl(), () => ({ "PRIVATE-TOKEN": token }), { ...opts, activeExtra: [403] });
|
|
381
|
+
}
|
|
382
|
+
/** 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. */
|
|
383
|
+
function verifySupabaseToken(token, opts) {
|
|
384
|
+
return verifyByStatusCode("Supabase", supabaseUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
385
|
+
}
|
|
386
|
+
function verifyElevenLabsKey(key, opts) {
|
|
387
|
+
return verifyByStatusCode("ElevenLabs", elevenlabsUrl(), () => ({ "xi-api-key": key }), opts);
|
|
388
|
+
}
|
|
389
|
+
function verifyCircleciToken(token, opts) {
|
|
390
|
+
return verifyByStatusCode("CircleCI", circleciUrl(), () => ({ "Circle-Token": token }), opts);
|
|
391
|
+
}
|
|
392
|
+
function verifyAirtableToken(token, opts) {
|
|
393
|
+
return verifyByStatusCode("Airtable", airtableUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Cloudflare's own /user/tokens/verify endpoint exists for exactly this
|
|
397
|
+
* check (its whole purpose, per Cloudflare's docs, is confirming a
|
|
398
|
+
* token's validity), so treating any HTTP 200 from THIS SPECIFIC endpoint
|
|
399
|
+
* as active is a documented guarantee, not an approximation the way it
|
|
400
|
+
* would be for a generic "list resources" endpoint.
|
|
401
|
+
*/
|
|
402
|
+
function verifyCloudflareToken(token, opts) {
|
|
403
|
+
return verifyByStatusCode("Cloudflare", cloudflareUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
404
|
+
}
|
|
405
|
+
function verifyHerokuKey(key, opts) {
|
|
406
|
+
return verifyByStatusCode("Heroku", herokuUrl(), () => ({
|
|
407
|
+
Authorization: `Bearer ${key}`,
|
|
408
|
+
Accept: "application/vnd.heroku+json; version=3",
|
|
409
|
+
}), opts);
|
|
410
|
+
}
|
|
411
|
+
function verifyNetlifyToken(token, opts) {
|
|
412
|
+
return verifyByStatusCode("Netlify", netlifyUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Linear: a GraphQL API, one POST endpoint for everything, not a plain GET.
|
|
417
|
+
* A GraphQL server can answer HTTP 200 even for some authorization-level
|
|
418
|
+
* failures (the error lives in the response body's `errors` field, not the
|
|
419
|
+
* status code), so this checks for a populated `data.viewer` instead of
|
|
420
|
+
* trusting status code alone — the same reasoning that gave Slack its own
|
|
421
|
+
* function above. Linear's own docs confirm no "Bearer" prefix on personal
|
|
422
|
+
* API keys (Bearer is reserved for OAuth tokens).
|
|
423
|
+
*/
|
|
424
|
+
async function verifyLinearKey(key, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
425
|
+
let res;
|
|
426
|
+
try {
|
|
427
|
+
res = await fetchFn(linearUrl(), {
|
|
428
|
+
method: "POST",
|
|
429
|
+
headers: { Authorization: key, "Content-Type": "application/json" },
|
|
430
|
+
body: JSON.stringify({ query: "{ viewer { id } }" }),
|
|
431
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
432
|
+
});
|
|
433
|
+
} catch (e) {
|
|
434
|
+
return { status: "error", detail: `could not reach Linear (${sanitizeDetail(e && e.message)})` };
|
|
435
|
+
}
|
|
436
|
+
if (res.status === 401) return { status: "invalid", detail: "Linear rejected this key (HTTP 401)" };
|
|
437
|
+
let body;
|
|
438
|
+
try {
|
|
439
|
+
body = await res.json();
|
|
440
|
+
} catch {
|
|
441
|
+
return { status: "error", detail: `Linear returned a non-JSON response (HTTP ${res.status})` };
|
|
442
|
+
}
|
|
443
|
+
if (body && body.data && body.data.viewer && body.data.viewer.id) {
|
|
444
|
+
return { status: "active", detail: "Linear accepted this key" };
|
|
445
|
+
}
|
|
446
|
+
return { status: "error", detail: `could not verify: ${sanitizeDetail(JSON.stringify(body && body.errors)).slice(0, 120) || `HTTP ${res.status}`}` };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Telegram: the token is embedded directly in the URL path, not a header,
|
|
451
|
+
* and (like Slack) the response is always HTTP 200 with an `ok` boolean in
|
|
452
|
+
* the body signaling success or failure — never a 401.
|
|
453
|
+
*/
|
|
454
|
+
async function verifyTelegramToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
455
|
+
let res;
|
|
456
|
+
try {
|
|
457
|
+
res = await fetchFn(telegramUrl(token), { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
|
|
458
|
+
} catch (e) {
|
|
459
|
+
return { status: "error", detail: `could not reach Telegram (${sanitizeDetail(e && e.message)})` };
|
|
460
|
+
}
|
|
461
|
+
let body;
|
|
462
|
+
try {
|
|
463
|
+
body = await res.json();
|
|
464
|
+
} catch {
|
|
465
|
+
return { status: "error", detail: `Telegram returned a non-JSON response (HTTP ${res.status})` };
|
|
466
|
+
}
|
|
467
|
+
if (body && body.ok === true) return { status: "active", detail: "Telegram accepted this bot token (getMe)" };
|
|
468
|
+
if (body && body.ok === false && typeof body.error_code === "number") {
|
|
469
|
+
return { status: "invalid", detail: `Telegram rejected this token (${sanitizeDetail(body.description) || body.error_code})` };
|
|
470
|
+
}
|
|
471
|
+
return { status: "error", detail: `could not verify: HTTP ${res.status} from Telegram` };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Discord webhooks: the credential IS a full URL, not a token to attach to
|
|
476
|
+
* a fixed endpoint elsewhere. A plain GET on that URL is Discord's own
|
|
477
|
+
* documented read-only "fetch webhook info" call, distinct from POSTing to
|
|
478
|
+
* it (which would send a real, visible message — never done here).
|
|
479
|
+
*/
|
|
480
|
+
function verifyDiscordWebhook(webhookUrl, opts) {
|
|
481
|
+
return verifyByStatusCode("Discord", webhookUrl, () => ({}), { ...opts, invalidStatuses: [404] });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = {
|
|
485
|
+
isAwsCliAvailable, verifyAwsCredential, verifySlackToken,
|
|
486
|
+
verifyOpenAiKey, verifyAnthropicKey, verifyGithubToken,
|
|
487
|
+
verifyHuggingFaceToken, verifyReplicateToken, verifyDigitalOceanToken, verifyPineconeKey,
|
|
488
|
+
verifySendgridKey, verifyGroqKey, verifyXaiKey, verifyOpenRouterKey, verifyStripeKey, verifyNpmToken,
|
|
489
|
+
verifyNotionToken, verifyGitlabToken, verifySupabaseToken, verifyElevenLabsKey,
|
|
490
|
+
verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
|
|
491
|
+
verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
|
|
492
|
+
};
|