residoo 0.4.5 → 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 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)
@@ -125,17 +149,27 @@ won't be built into the tool that writes it.
125
149
  discarded. See `src/jwtExpiry.js`.
126
150
  - **`--verify`** (opt-in, makes a real network call): asks a credential's own
127
151
  vendor whether it still authenticates, using the exact value found in your
128
- transcript. Five vendors today: **AWS** (an access key id found paired with
152
+ transcript. 27 vendors today: **AWS** (an access key id found paired with
129
153
  its secret, checked via `sts:get-caller-identity`, the same free,
130
154
  read-only, permission-less call the AWS CLI and tools like aws-vault use
131
155
  for exactly this; shells out to your own `aws` CLI rather than
132
156
  reimplementing AWS request signing, since residoo ships zero runtime
133
157
  dependencies and a subtly wrong signing implementation would silently
134
- report real keys as invalid, worse than not checking), and **Slack,
135
- OpenAI, Anthropic, GitHub** (a direct API call to each vendor's own free
136
- "list what I can see" endpoint, no CLI needed, no request signing to get
137
- wrong). A verified-active credential is escalated to "rotate immediately";
138
- a verified-invalid one is reported as already dead, no action needed, and
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
139
173
  sorted out of the way. Off by default; every environment variable the
140
174
  `aws` CLI reads is built from scratch, never inherited, so it can never
141
175
  fall back to your own real AWS profile. See `src/verify.js`.
@@ -150,10 +184,10 @@ won't be built into the tool that writes it.
150
184
  preview, never the real value, including in `--json` mode. A decoded or
151
185
  rejoined secret is redacted exactly like a plain one.
152
186
  - On an interactive terminal, prints who it is and where it lives before
153
- scanning starts (`residoo v0.4.5 · find secrets your AI coding agent left
187
+ scanning starts (`residoo v0.4.6 · find secrets your AI coding agent left
154
188
  on disk` plus the repo URL), then a live spinner naming the current file
155
189
  as it scans. Every report also opens with the exact version and timestamp
156
- it was run with (`residoo v0.4.5 · scanned 2026-01-01 12:00`; `--json`
190
+ it was run with (`residoo v0.4.6 · scanned 2026-01-01 12:00`; `--json`
157
191
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
158
192
  screenshotted later never leaves you guessing which build produced it.
159
193
  When there are findings, the report closes with a "Next steps" pointer to
@@ -384,7 +418,7 @@ As a GitHub Action (this repository doubles as a composite action):
384
418
  ```yaml
385
419
  steps:
386
420
  - uses: actions/checkout@v4
387
- - uses: dandovdub/residoo@v0.4.5
421
+ - uses: dandovdub/residoo@v0.4.6
388
422
  ```
389
423
 
390
424
  As a pre-commit hook:
@@ -392,7 +426,7 @@ As a pre-commit hook:
392
426
  ```yaml
393
427
  repos:
394
428
  - repo: https://github.com/dandovdub/residoo
395
- rev: v0.4.5
429
+ rev: v0.4.6
396
430
  hooks:
397
431
  - id: residoo
398
432
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.5",
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
@@ -57,9 +57,9 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
57
57
  Scanning makes NO network calls by default and changes nothing on disk.
58
58
  Findings are redacted in every output format. The one opt-in exception is
59
59
  --verify, which asks a credential's own vendor whether it still
60
- authenticates (AWS, Slack, OpenAI, Anthropic, GitHub today); see below.
61
- Sealing (--seal) writes NEW encrypted files only. It never modifies or
62
- deletes anything that already exists.
60
+ authenticates (27 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,17 +102,19 @@ Scan options:
102
102
  --verify ask the credential's own vendor whether it still
103
103
  authenticates, using the exact value found in
104
104
  your transcript. THIS MAKES A REAL NETWORK CALL.
105
- Off by default. Five vendors today:
106
- AWS: every access key id found paired with its
107
- secret (see Rotation below) is checked via
108
- sts:get-caller-identity. Needs the aws CLI on
109
- PATH; residoo shells out to it rather than
110
- reimplementing AWS request signing.
111
- Slack: every token via auth.test.
112
- OpenAI, Anthropic, GitHub: every key/token via
113
- that vendor's own models/user listing endpoint.
114
- All four non-AWS vendors are a direct, dependency-
115
- free API call, no CLI needed. A verified-invalid
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
116
118
  credential is reported as already dead, not as
117
119
  something to rotate; a JWT's own signed exp claim
118
120
  is checked locally with no network call at all,
@@ -572,7 +574,17 @@ async function main(argv) {
572
574
  }
573
575
 
574
576
  const progress = makeProgressReporter(noColor);
575
- const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress, verify });
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
+ });
576
588
  progress.stop();
577
589
  const integrity = wantsIntegrity ? runIntegrity() : null;
578
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
- const stop = () => { if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r"); };
88
+ // Idempotent: a caller may legitimately stop() more than once (scan.js's
89
+ // onBeforeVerify calls it before --verify's own stderr lines, and the
90
+ // normal post-scan call still follows); lastLineLen resets to 0 so a
91
+ // second call is a true no-op, not a second blank-line clear.
92
+ const stop = () => {
93
+ if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r");
94
+ lastLineLen = 0;
95
+ };
89
96
  return { onProgress, stop };
90
97
  }
91
98
 
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)
package/src/scan.js CHANGED
@@ -9,6 +9,11 @@ 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,
12
17
  } = require("./verify");
13
18
 
14
19
  // Never verify more than this many distinct credentials of ONE vendor in a
@@ -20,17 +25,69 @@ const MAX_VERIFICATIONS_PER_VENDOR = 10;
20
25
  // Every vendor whose credential is a single, unpaired bearer token: no
21
26
  // AWS-style "two halves make one credential" pairing step, so these all
22
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).
23
34
  const SIMPLE_VERIFY_FNS = {
24
35
  slack_token: verifySlackToken,
25
36
  openai_key: verifyOpenAiKey,
26
37
  anthropic_key: verifyAnthropicKey,
27
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,
28
62
  };
29
63
  const SIMPLE_VERIFY_VENDOR_LABEL = {
30
64
  slack_token: "Slack's auth.test",
31
65
  openai_key: "OpenAI's models endpoint",
32
66
  anthropic_key: "Anthropic's models endpoint",
33
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",
34
91
  };
35
92
 
36
93
  // Rule ids that findPairedSecret's window search applies to (see pairing.js):
@@ -154,7 +211,7 @@ function safeName(file) { return path.basename(file); }
154
211
  * absolute path can itself carry a username or a project name the rest of
155
212
  * this report is careful never to print.
156
213
  */
157
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false } = {}) {
214
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null } = {}) {
158
215
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
159
216
  // The decode pass (see decode.js) only applies high-confidence, vendor-
160
217
  // prefixed rules to decoded bytes: random binary that decodes to printable
@@ -492,6 +549,20 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
492
549
  // distinct credential, one at a time (not concurrent), so this is the one
493
550
  // place a scan's wall-clock time depends on something other than disk
494
551
  // I/O; that tradeoff only exists when a caller explicitly asked for it.
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
+
495
566
  // Same field names (verified/verifiedDetail) regardless of which vendor
496
567
  // produced the result: rotation.js and report.js render them identically,
497
568
  // and the finding's own ruleId already says which vendor answered.
package/src/verify.js CHANGED
@@ -199,13 +199,25 @@ async function verifySlackToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TI
199
199
  * Shared implementation for every vendor below Slack: a plain GET to a
200
200
  * free, side-effect-free, already-authenticated endpoint (each vendor's own
201
201
  * "list what I can see" call), where the HTTP status code alone says
202
- * whether the credential authenticated. 200 is active; 401/403 is a real
203
- * rejection; anything else (429 rate limited, 5xx, a network failure) is
204
- * inconclusive, never guessed as either active or invalid. Slack needed its
205
- * own function above because its auth.test always returns HTTP 200 and
206
- * signals failure inside the JSON body instead.
202
+ * whether the credential authenticated. 200 is active by default; 401/403
203
+ * is a real rejection by default; anything else (429 rate limited, 5xx, a
204
+ * network failure) is inconclusive, never guessed as either active or
205
+ * invalid. Slack needed its own function above because its auth.test
206
+ * always returns HTTP 200 and signals failure inside the JSON body
207
+ * instead.
208
+ *
209
+ * invalidStatuses/activeExtra override the defaults for the handful of
210
+ * vendors whose docs document a DIFFERENT meaning for a given code: Discord
211
+ * signals a dead webhook with 404, not 401/403; Pinecone, SendGrid, and
212
+ * GitLab each document 403 as "the credential is real but this specific
213
+ * call is out of scope," not "dead" — treating that as invalid would be
214
+ * exactly the false-negative-in-the-dangerous-direction failure this
215
+ * module exists to avoid, so those three pass 403 in activeExtra instead.
207
216
  */
208
- async function verifyByStatusCode(vendorName, url, buildHeaders, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
217
+ async function verifyByStatusCode(vendorName, url, buildHeaders, {
218
+ fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
219
+ invalidStatuses = [401, 403], activeExtra = [],
220
+ } = {}) {
209
221
  let res;
210
222
  try {
211
223
  res = await fetchFn(url, {
@@ -216,10 +228,10 @@ async function verifyByStatusCode(vendorName, url, buildHeaders, { fetchFn = fet
216
228
  } catch (e) {
217
229
  return { status: "error", detail: `could not reach ${vendorName} (${sanitizeDetail(e && e.message)})` };
218
230
  }
219
- if (res.status === 200) {
231
+ if (res.status === 200 || activeExtra.includes(res.status)) {
220
232
  return { status: "active", detail: `${vendorName} accepted this key` };
221
233
  }
222
- if (res.status === 401 || res.status === 403) {
234
+ if (invalidStatuses.includes(res.status)) {
223
235
  return { status: "invalid", detail: `${vendorName} rejected this key (HTTP ${res.status})` };
224
236
  }
225
237
  return { status: "error", detail: `could not verify: HTTP ${res.status} from ${vendorName}` };
@@ -260,7 +272,221 @@ 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
+ // - 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
+
263
484
  module.exports = {
264
485
  isAwsCliAvailable, verifyAwsCredential, verifySlackToken,
265
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,
266
492
  };