residoo 0.4.9 → 0.4.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/cli.js CHANGED
@@ -57,7 +57,7 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
57
57
  Scanning makes NO network calls by default and changes nothing on disk.
58
58
  Findings are redacted in every output format. The one opt-in exception is
59
59
  --verify, which asks a credential's own vendor whether it still
60
- authenticates (32 vendors today, see below). Sealing (--seal) writes NEW
60
+ authenticates (35 vendors today, see below). Sealing (--seal) writes NEW
61
61
  encrypted files only. It never modifies or deletes anything that already
62
62
  exists.
63
63
 
@@ -102,22 +102,24 @@ 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. 32 vendors today. Two need a
105
+ Off by default. 35 vendors today. Three need a
106
106
  paired id+secret (see Rotation below): AWS,
107
107
  checked via sts:get-caller-identity (needs the
108
108
  aws CLI on PATH, residoo shells out to it rather
109
- than reimplementing AWS request signing), and
110
- PlanetScale, checked via a direct API call like
111
- every other non-AWS vendor here. The other 30 are
112
- each a single credential, one direct,
113
- dependency-free API call, no CLI needed: Slack,
114
- OpenAI, Anthropic, GitHub, Hugging Face,
109
+ than reimplementing AWS request signing);
110
+ PlanetScale and MongoDB Atlas (Service Account
111
+ credentials only), each checked via a direct API
112
+ call like every other non-AWS vendor here. The
113
+ other 32 are each a single credential, one
114
+ direct, dependency-free API call, no CLI needed:
115
+ Slack, OpenAI, Anthropic, GitHub, Hugging Face,
115
116
  Replicate, DigitalOcean, Pinecone, SendGrid,
116
117
  Groq, xAI, OpenRouter, Stripe, npm, Notion,
117
118
  GitLab, Supabase (management tokens only),
118
119
  ElevenLabs, CircleCI, Airtable, Cloudflare,
119
120
  Heroku, Netlify, Linear, Telegram, Discord
120
- webhooks, Vercel, Cerebras, Render, and Fly.io.
121
+ webhooks, Vercel, Cerebras, Render, Fly.io,
122
+ Neon, and PostHog.
121
123
  A verified-invalid credential is reported as
122
124
  already dead, not as something to rotate; a
123
125
  JWT's own signed exp claim is checked locally
@@ -579,7 +581,7 @@ async function main(argv) {
579
581
 
580
582
  const progress = makeProgressReporter(noColor);
581
583
  const result = await scan({
582
- sources, includeNoisy, includeSuppressed, verify,
584
+ sources, includeNoisy, includeSuppressed, verify, noColor,
583
585
  onProgress: progress.onProgress,
584
586
  // Clears the spinner's last frame before --verify's own stderr lines
585
587
  // print; without this the last spinner line sits uncleared on screen
package/src/color.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Minimal raw ANSI — no chalk, no deps. A security tool asking you to trust
5
+ * a pile of third-party packages before it's even scanned anything is a bad
6
+ * first impression; residoo ships with zero runtime dependencies.
7
+ *
8
+ * Shared between report.js (stdout: the findings/rotation report) and
9
+ * scan.js (stderr: the --verify disclosure table), which is why `stream` is
10
+ * a parameter here rather than a hardcoded process.stdout: stdout and
11
+ * stderr can be redirected independently of each other (piping stdout to a
12
+ * file while stderr still reaches a real terminal, or the reverse), so each
13
+ * caller's own stream decides its own color support instead of one
14
+ * borrowing the other's answer.
15
+ */
16
+ const c = {
17
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
18
+ red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", cyan: "\x1b[36m",
19
+ };
20
+
21
+ // `forceNoColor` read fresh on every call, not captured once at require()
22
+ // time — a module-level const would freeze whatever the environment was
23
+ // before cli.js has even parsed argv. This is how cli.js's --no-color flag
24
+ // actually reaches these functions: as an explicit per-call argument, not
25
+ // by mutating process.env.NO_COLOR, so a mutated env var can never leak
26
+ // into a later call in the same process (a test runner, a wrapper CLI
27
+ // reusing this module) and silently disable color for a call that never
28
+ // asked for that.
29
+ function supportsColor(forceNoColor, stream = process.stdout) {
30
+ return !forceNoColor && stream.isTTY && process.env.NO_COLOR === undefined;
31
+ }
32
+ function makePaint(forceNoColor, stream = process.stdout) {
33
+ return (code, s) => (supportsColor(forceNoColor, stream) ? `${code}${s}${c.reset}` : s);
34
+ }
35
+
36
+ module.exports = { c, supportsColor, makePaint };
package/src/patterns.js CHANGED
@@ -186,6 +186,34 @@ const PATTERNS = [
186
186
  // floor/ceiling treatment as Cerebras above.
187
187
  { id: "render_key", label: "Render API key", confidence: "high",
188
188
  re: /\brnd_[A-Za-z0-9]{20,200}\b/g },
189
+ // Confirmed via Neon's own changelog (neon.com/docs/changelog/2025-01-31):
190
+ // keys created after that date are prefixed napi_ (personal), or
191
+ // neon_org_key_ / neon_project_key_ (org and project-scoped), specifically
192
+ // "to use secret scanning mechanisms that rely on identifiable markers" —
193
+ // about as direct an endorsement as a vendor gives. Length/charset are
194
+ // not published (only "randomly-generated 64-bit token," and the docs'
195
+ // own example is a transparently synthetic placeholder), so the bound
196
+ // here is a floor, same treatment as Cerebras/Render above. Keys created
197
+ // before 2025-01-31 have no prefix and are not covered — a real but
198
+ // bounded coverage gap, not a false negative in this rule's own logic.
199
+ { id: "neon_key", label: "Neon API key", confidence: "high",
200
+ re: /\b(?:napi_|neon_org_key_|neon_project_key_)[A-Za-z0-9]{20,}\b/g },
201
+ // MongoDB Atlas has two distinct credential systems; only one is a rule
202
+ // here. The legacy Programmatic API Key pair (Public Key / Private Key,
203
+ // HTTP Digest auth) has NO prefix at all -- an 8-char alnum string and a
204
+ // bare UUID, confirmed via MongoDB's own OpenAPI spec -- and is exactly
205
+ // the noisy, unspecific shape this file's header says to leave out. The
206
+ // newer Service Account pair does have a distinguishing prefix on BOTH
207
+ // halves (confirmed in the same OpenAPI spec): mdb_sa_sk_ for the client
208
+ // secret (this rule; length not published beyond the prefix, same
209
+ // floor-only treatment as Cerebras/Render) and mdb_sa_id_ for the client
210
+ // id (fully specified as exactly 24 hex characters by the spec's own
211
+ // schema pattern, matched only as a paired candidate near this secret --
212
+ // see pairing.js's findNearbyCandidate and PlanetScale's identical
213
+ // secret-is-the-anchor structure above -- never as a standalone rule,
214
+ // since verification needs both halves together).
215
+ { id: "mongodb_atlas_secret", label: "MongoDB Atlas Service Account secret", confidence: "high",
216
+ re: /\bmdb_sa_sk_[A-Za-z0-9]{16,}\b/g },
189
217
  { id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
190
218
  // Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
191
219
  // format is a bare "s." + 18-40 chars — "s." is nowhere near specific
@@ -220,6 +248,17 @@ const PATTERNS = [
220
248
  // Covers both current Sentry token shapes: org-scoped (sntrys_, base64
221
249
  // JWT-like body) and user-scoped (sntryu_, hex body).
222
250
  re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,4000}|sntryu_[a-f0-9]{64})\b/g },
251
+ // phx_ is PostHog's Personal API Key prefix, confirmed directly in
252
+ // PostHog's own docs (the masked example "phx_***1234" on the personal-
253
+ // api-keys page, and phx_ named explicitly in the API overview's GitHub
254
+ // secret-scanning section alongside sibling prefixes for its OTHER token
255
+ // types: phc_ is the PUBLIC project token and must never be targeted as a
256
+ // secret, phs_/pha_/phr_ are other PostHog token families not covered
257
+ // here). Exact length (~48 chars) is sourced from PostHog's own OSS
258
+ // source, not prose docs, so the bound below is a generous floor rather
259
+ // than a doc-confirmed exact count.
260
+ { id: "posthog_key", label: "PostHog personal API key", confidence: "high",
261
+ re: /\bphx_[A-Za-z0-9]{40,}\b/g },
223
262
  ];
224
263
 
225
264
  /**
package/src/report.js CHANGED
@@ -2,28 +2,10 @@
2
2
 
3
3
  const path = require("path");
4
4
  const { fingerprintFinding, ROTATION_ORDER_ADVISORY } = require("./rotation");
5
-
6
- // Minimal raw ANSI — no chalk, no deps. A security tool asking you to trust
7
- // a pile of third-party packages before it's even scanned anything is a bad
8
- // first impression; residoo ships with zero runtime dependencies.
9
- const c = {
10
- reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
11
- red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m", cyan: "\x1b[36m",
12
- };
13
- // Read fresh on every call, not once at require() time — a module-level
14
- // const would freeze whatever the environment was at require() time, before
15
- // cli.js has even parsed argv. `forceNoColor` is how cli.js's --no-color
16
- // flag actually reaches this function: as an explicit per-call argument, not
17
- // by mutating process.env.NO_COLOR. `main()` is an exported function, not
18
- // only a one-shot CLI entrypoint — a mutated env var would leak into any
19
- // later call in the same process (a test runner, a wrapper CLI reusing it)
20
- // and silently disable color for calls that never asked for that.
21
- function supportsColor(forceNoColor) {
22
- return !forceNoColor && process.stdout.isTTY && process.env.NO_COLOR === undefined;
23
- }
24
- function makePaint(forceNoColor) {
25
- return (code, s) => (supportsColor(forceNoColor) ? `${code}${s}${c.reset}` : s);
26
- }
5
+ // c/makePaint moved to color.js so scan.js's --verify disclosure table
6
+ // (written to stderr, not stdout) can use the same palette and
7
+ // NO_COLOR/--no-color contract without duplicating it.
8
+ const { c, makePaint } = require("./color");
27
9
 
28
10
  function ageDays(mtimeMs) {
29
11
  return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86400000));
@@ -453,9 +435,28 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
453
435
  }
454
436
  push();
455
437
 
438
+ // A real table, not a run-on line: fixed-width count/confidence columns,
439
+ // and the label column padded to the widest label actually being shown
440
+ // (capped, so one extreme outlier like the paired MongoDB Atlas label
441
+ // doesn't drag every other row's notes off toward the right edge) so the
442
+ // distinct-value note and any encoding marks start in the same place on
443
+ // every row. The confidence tag now colors its own count too, not just
444
+ // the bracket, so severity reads at a glance down the left edge without
445
+ // having to read the bracket text on every line.
456
446
  const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
447
+ const CONFIDENCE_COLOR = { high: c.red, medium: c.yellow, low: c.dim };
448
+ const CONFIDENCE_TAG = { high: "high", medium: "med ", low: "low " };
449
+ const LABEL_COL_CAP = 40;
450
+ const labelWidth = Math.min(
451
+ LABEL_COL_CAP,
452
+ Math.max(0, ...sorted.map(([, { label }]) => label.length))
453
+ );
454
+ if (sorted.length > 0) {
455
+ push(paint(c.dim, ` ${"COUNT".padStart(4)} CONF ${"RULE".padEnd(labelWidth)}`));
456
+ }
457
457
  for (const [ruleId, { label, confidence, items }] of sorted) {
458
- const tag = confidence === "high" ? paint(c.red, "high") : confidence === "medium" ? paint(c.yellow, "med ") : paint(c.dim, "low ");
458
+ const color = CONFIDENCE_COLOR[confidence] || c.dim;
459
+ const tag = paint(color, CONFIDENCE_TAG[confidence] || "low ");
459
460
  const distinct = distinctCounts[ruleId];
460
461
  const distinctNote = distinct && distinct !== items.length
461
462
  ? paint(c.dim, ` (${distinct} distinct value${distinct === 1 ? "" : "s"}, re-exposed ${items.length - distinct}× across tool output)`)
@@ -469,7 +470,8 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
469
470
  if (encoded) marks.push(`${encoded} base64-wrapped`);
470
471
  if (split) marks.push(`${split} split across lines`);
471
472
  const markNote = marks.length ? paint(c.yellow, ` [${marks.join(", ")}]`) : "";
472
- push(` ${paint(c.bold, String(items.length).padStart(4))} [${tag}] ${label}${distinctNote}${markNote}`);
473
+ const paddedLabel = label.length <= labelWidth ? label.padEnd(labelWidth) : label;
474
+ push(` ${paint(color + c.bold, String(items.length).padStart(4))} [${tag}] ${paddedLabel}${distinctNote}${markNote}`);
473
475
  }
474
476
 
475
477
  push();
package/src/rotation.js CHANGED
@@ -640,6 +640,43 @@ const ROTATION_GUIDANCE = {
640
640
  ],
641
641
  revokeNote: "Revocation is immediate; the key stops authenticating on the next request.",
642
642
  },
643
+ // Fetched https://neon.com/docs/manage/api-keys (2026-09-03): keys are
644
+ // listed and revoked from the Neon console's Account/Organization
645
+ // Settings > API keys page; revocation is immediate per the docs' own
646
+ // "All API requests using the revoked key will fail" line.
647
+ neon_key: {
648
+ label: "Neon API key",
649
+ consolePath: "console.neon.tech > Account/Organization Settings > API keys",
650
+ steps: [
651
+ "Open API keys under Account or Organization Settings in the Neon console",
652
+ "Revoke the leaked key",
653
+ "Create a replacement and update whatever used the old one",
654
+ ],
655
+ revokeNote: "Revocation is immediate; the key stops authenticating on the next request.",
656
+ },
657
+ // Fetched MongoDB Atlas's own API/OpenAPI docs (2026-09-03): Service
658
+ // Accounts are managed from Organization Access Manager > Service
659
+ // Accounts, where a secret can be deleted independently of the account.
660
+ mongodb_atlas_secret: {
661
+ label: "MongoDB Atlas Service Account secret",
662
+ consolePath: "cloud.mongodb.com > Organization Access Manager > Service Accounts",
663
+ steps: [
664
+ "Open Service Accounts under your organization's Access Manager",
665
+ "Delete the leaked client secret from the service account",
666
+ "Create a replacement secret and update whatever used the old one",
667
+ ],
668
+ revokeNote: "Deletion is immediate; the secret stops authenticating on the next request.",
669
+ },
670
+ mongodb_atlas_client_id: {
671
+ label: "MongoDB Atlas Service Account client id (paired with a leaked secret)",
672
+ consolePath: "cloud.mongodb.com > Organization Access Manager > Service Accounts",
673
+ steps: [
674
+ "This is the client id half of the service account also found on this line",
675
+ "Delete the leaked secret from the service account; the id alone cannot authenticate",
676
+ "Create a replacement secret and update whatever used the old one",
677
+ ],
678
+ revokeNote: "The id cannot authenticate alone: deleting the paired secret is what invalidates the pair.",
679
+ },
643
680
 
644
681
  // ── Comms / SaaS ──────────────────────────────────────────────────────
645
682
  // The user-facing support article (support.discord.com article 228383668)
@@ -722,6 +759,19 @@ const ROTATION_GUIDANCE = {
722
759
  ],
723
760
  revokeNote: "The redacted preview cannot distinguish the two prefixes; check the original file for sntrys_ (organization) vs sntryu_ (personal).",
724
761
  },
762
+ // Fetched https://posthog.com/docs/api (2026-09-03): personal API keys
763
+ // are listed and revoked from the user's own Personal API Keys settings
764
+ // page, independent of any single project.
765
+ posthog_key: {
766
+ label: "PostHog personal API key",
767
+ consolePath: "app.posthog.com > Settings > Personal API keys (or the EU/self-hosted equivalent)",
768
+ steps: [
769
+ "Open Personal API Keys under your account settings",
770
+ "Delete the leaked key",
771
+ "Create a replacement and update whatever used the old one",
772
+ ],
773
+ revokeNote: "Deletion is immediate; the key stops authenticating on the next request.",
774
+ },
725
775
 
726
776
  // ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
727
777
  generic_password_assignment: {
package/src/scan.js CHANGED
@@ -15,8 +15,9 @@ const {
15
15
  verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
16
16
  verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
17
17
  verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
18
- verifyFlyioBearerToken,
18
+ verifyFlyioBearerToken, verifyMongoDbAtlasCredential, verifyNeonKey, verifyPostHogKey,
19
19
  } = require("./verify");
20
+ const { c, makePaint } = require("./color");
20
21
 
21
22
  // PlanetScale's id half: 12 lowercase alphanumeric characters, no prefix —
22
23
  // confirmed via planetscale.com/docs/api/reference/service-tokens. Searched
@@ -32,6 +33,18 @@ const PLANETSCALE_ID_RE = /\b[a-z0-9]{12}\b/g;
32
33
  // false ambiguous match.
33
34
  const PLANETSCALE_PAIR_WINDOW = 100;
34
35
 
36
+ // MongoDB Atlas Service Account client id: fully specified by MongoDB's own
37
+ // OpenAPI schema (mdb_sa_id_ + exactly 24 hex characters) — the ONE paired
38
+ // candidate regex in this file precise enough to have a confirmed exact
39
+ // length rather than a shape-only guess, since it carries its own
40
+ // distinguishing prefix too (unlike AWS's secret or PlanetScale's id, which
41
+ // have no prefix of their own and rely entirely on nearby-anchor context).
42
+ const MONGODB_ATLAS_ID_RE = /\bmdb_sa_id_[a-fA-F0-9]{24}\b/g;
43
+ // MongoDB's own docs show the id and secret as sibling fields in the same
44
+ // JSON credentials block or adjacent env vars, not spread across a file —
45
+ // same reasoning as PlanetScale's tighter window, not AWS's wider one.
46
+ const MONGODB_ATLAS_PAIR_WINDOW = 150;
47
+
35
48
  // Never verify more than this many distinct credentials of ONE vendor in a
36
49
  // single scan: a pathological transcript with dozens of distinct
37
50
  // credentials should not turn --verify into a long burst of outbound calls.
@@ -45,10 +58,11 @@ const MAX_VERIFICATIONS_PER_VENDOR = 10;
45
58
  // belong to any Google product; testing it against one product's endpoint
46
59
  // would misreport a valid key for a DIFFERENT product as invalid) and
47
60
  // perplexity_key (no free, side-effect-free endpoint exists at all).
48
- // PlanetScale is ALSO not here despite being verified: it needs pairing
49
- // (see pendingPlanetScaleVerifications below), the same reason AWS isn't
50
- // here either. See verify.js's own header comment for the fuller
51
- // reasoning behind every vendor left out.
61
+ // PlanetScale and MongoDB Atlas are ALSO not here despite being verified:
62
+ // both need pairing (see pendingPlanetScaleVerifications and
63
+ // pendingMongoDbAtlasVerifications below), the same reason AWS isn't here
64
+ // either. See verify.js's own header comment for the fuller reasoning
65
+ // behind every vendor left out.
52
66
  const SIMPLE_VERIFY_FNS = {
53
67
  slack_token: verifySlackToken,
54
68
  openai_key: verifyOpenAiKey,
@@ -81,6 +95,8 @@ const SIMPLE_VERIFY_FNS = {
81
95
  cerebras_key: verifyCerebrasKey,
82
96
  render_key: verifyRenderKey,
83
97
  flyio_bearer_token: verifyFlyioBearerToken,
98
+ neon_key: verifyNeonKey,
99
+ posthog_key: verifyPostHogKey,
84
100
  };
85
101
  const SIMPLE_VERIFY_VENDOR_LABEL = {
86
102
  slack_token: "Slack's auth.test",
@@ -114,6 +130,8 @@ const SIMPLE_VERIFY_VENDOR_LABEL = {
114
130
  cerebras_key: "Cerebras's models endpoint",
115
131
  render_key: "Render's owners endpoint",
116
132
  flyio_bearer_token: "Fly.io's GraphQL API",
133
+ neon_key: "Neon's projects endpoint",
134
+ posthog_key: "PostHog's users endpoint",
117
135
  };
118
136
 
119
137
  // Rule ids that findPairedSecret's window search applies to (see pairing.js):
@@ -237,7 +255,7 @@ function safeName(file) { return path.basename(file); }
237
255
  * absolute path can itself carry a username or a project name the rest of
238
256
  * this report is careful never to print.
239
257
  */
240
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null } = {}) {
258
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null, noColor = false } = {}) {
241
259
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
242
260
  // The decode pass (see decode.js) only applies high-confidence, vendor-
243
261
  // prefixed rules to decoded bytes: random binary that decodes to printable
@@ -271,6 +289,10 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
271
289
  // credential (see the planetscale_secret match branch below): keyed by
272
290
  // the secret value (the confirmed, prefixed anchor) -> { idValue, refs }.
273
291
  const pendingPlanetScaleVerifications = new Map();
292
+ // Same shape again, for MongoDB Atlas Service Account credentials (see
293
+ // the mongodb_atlas_secret match branch below): keyed by the secret value
294
+ // -> { idValue, refs }.
295
+ const pendingMongoDbAtlasVerifications = new Map();
274
296
  // --verify only (see verify.js): ruleId -> (token value -> { refs }), for
275
297
  // every SIMPLE_VERIFY_FNS vendor. Unlike AWS/PlanetScale, none of these
276
298
  // need pairing (the token itself is the complete credential), so this is
@@ -399,6 +421,30 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
399
421
  }
400
422
  }
401
423
  }
424
+ // MongoDB Atlas: same shape as PlanetScale (secret is the
425
+ // confirmed, prefixed anchor; the id is the nearby candidate),
426
+ // except the id here ALSO carries its own distinguishing prefix
427
+ // (mdb_sa_id_) rather than being a bare unprefixed shape — a
428
+ // stronger candidate signal than PlanetScale's or AWS's, but the
429
+ // same pairing mechanism and the same generic pairedOtherPreview/
430
+ // pairedOtherLabel display fields.
431
+ let mongoDbIdFinding = null;
432
+ let rawMongoDbId = null;
433
+ if (!suppressedReason && rule.id === "mongodb_atlas_secret") {
434
+ const pairedId = findNearbyCandidate(line, m[0], m.index, MONGODB_ATLAS_ID_RE, MONGODB_ATLAS_PAIR_WINDOW);
435
+ if (pairedId) {
436
+ const idSuppressedReason = suppressionReason(pairedId, null);
437
+ if (idSuppressedReason && !includeSuppressed) {
438
+ suppressedCount++;
439
+ } else {
440
+ rawMongoDbId = pairedId;
441
+ mongoDbIdFinding = record({ id: "mongodb_atlas_client_id", label: "MongoDB Atlas Service Account client id (paired with secret)" },
442
+ pairedId, relFile, file, lineNo, mtimeMs,
443
+ idSuppressedReason ? "low" : "high", idSuppressedReason,
444
+ { paired: true, pairedOtherPreview: redact(m[0]), pairedOtherLabel: "secret" });
445
+ }
446
+ }
447
+ }
402
448
  // Local, offline JWT expiry (see jwtExpiry.js): only ever reads
403
449
  // the `exp` claim out of the decoded payload, nothing else, and
404
450
  // only for the unsuppressed default `jwt` rule, since a
@@ -413,6 +459,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
413
459
  {
414
460
  ...(pairedSecretPreview ? { pairedSecretPreview } : {}),
415
461
  ...(planetScaleIdFinding ? { pairedOtherPreview: redact(rawPlanetScaleId), pairedOtherLabel: "id" } : {}),
462
+ ...(mongoDbIdFinding ? { pairedOtherPreview: redact(rawMongoDbId), pairedOtherLabel: "id" } : {}),
416
463
  ...(jwtExtra || {}),
417
464
  });
418
465
 
@@ -440,6 +487,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
440
487
  const psEntry = pendingPlanetScaleVerifications.get(m[0]);
441
488
  if (psEntry) psEntry.refs.push({ secretFinding: primaryFinding, idFinding: planetScaleIdFinding });
442
489
  }
490
+ // --verify, MongoDB Atlas: same dedup-by-anchor-value shape as
491
+ // AWS/PlanetScale above, keyed by the secret this time.
492
+ if (verify && mongoDbIdFinding && rawMongoDbId) {
493
+ if (!pendingMongoDbAtlasVerifications.has(m[0]) && pendingMongoDbAtlasVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
494
+ pendingMongoDbAtlasVerifications.set(m[0], { idValue: rawMongoDbId, refs: [] });
495
+ }
496
+ const mdbEntry = pendingMongoDbAtlasVerifications.get(m[0]);
497
+ if (mdbEntry) mdbEntry.refs.push({ secretFinding: primaryFinding, idFinding: mongoDbIdFinding });
498
+ }
443
499
  // --verify, single-token vendors (Slack, OpenAI, Anthropic,
444
500
  // GitHub): none of these need pairing (the value IS the complete
445
501
  // credential), so queue every unsuppressed match directly, same
@@ -629,6 +685,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
629
685
  // actually something to verify, so a plain --verify with nothing to check
630
686
  // never clears a spinner line for no reason.
631
687
  const anyPending = pendingAwsVerifications.size > 0 || pendingPlanetScaleVerifications.size > 0 ||
688
+ pendingMongoDbAtlasVerifications.size > 0 ||
632
689
  [...pendingSimpleVerifications.values()].some((byValue) => byValue.size > 0);
633
690
  if (verify && anyPending && typeof onBeforeVerify === "function") onBeforeVerify();
634
691
 
@@ -642,6 +699,12 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
642
699
  }
643
700
  };
644
701
  const awsAvailable = pendingAwsVerifications.size === 0 || isAwsCliAvailable();
702
+ // stderr, not stdout: color.js's supportsColor checks whichever stream is
703
+ // passed to it, and this table is never written to stdout, so it must
704
+ // check stderr's own TTY status, not borrow stdout's (piping stdout to a
705
+ // file while stderr still reaches a real terminal is a real case: `scan
706
+ // --verify --json > out.json` should still color this table).
707
+ const paint = makePaint(noColor, process.stderr);
645
708
  if (verify && anyPending) {
646
709
  // One disclosure, not one per vendor: this used to print a full
647
710
  // "this is a real network request..." paragraph for EACH vendor in
@@ -653,33 +716,38 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
653
716
  // Deliberately not gated on isTTY like the scan spinner: a script
654
717
  // piping through a pager or into a log still needs this disclosure.
655
718
  //
656
- // Three columns, not two: an earlier version of this table showed only
657
- // the vendor name ("AWS 2"), which answers "how many" but drops the
658
- // one thing a security-conscious user actually wants to check before
659
- // agreeing to real outbound calls, namely WHICH endpoint each vendor's
660
- // credential gets sent to. Every vendor's own SIMPLE_VERIFY_VENDOR_LABEL
661
- // is already written "<Vendor>'s <endpoint>", so split it into the two
662
- // columns rather than maintaining a second, parallel description.
719
+ // Went through two earlier shapes, each missing what mattered: first a
720
+ // bare vendor+count ("AWS 2", which credential?), then vendor+endpoint
721
+ // +count ("AWS sts:get-caller-identity 2", still no way to tell WHICH
722
+ // two). What answers "which needs to be handled" is the same redacted
723
+ // preview (first/last 4 characters) already shown for that finding
724
+ // everywhere else in the report — reusing redact() on the same raw
725
+ // value record() was called with, not a second display convention.
726
+ // One line per vendor+endpoint header, one indented line per credential.
663
727
  const rows = [];
664
728
  if (pendingAwsVerifications.size > 0 && awsAvailable) {
665
- rows.push(["AWS", "sts:get-caller-identity", pendingAwsVerifications.size]);
729
+ rows.push(["AWS", "sts:get-caller-identity", [...pendingAwsVerifications.keys()].map(redact)]);
666
730
  }
667
731
  if (pendingPlanetScaleVerifications.size > 0) {
668
- rows.push(["PlanetScale", "organizations endpoint", pendingPlanetScaleVerifications.size]);
732
+ rows.push(["PlanetScale", "organizations endpoint", [...pendingPlanetScaleVerifications.keys()].map(redact)]);
733
+ }
734
+ if (pendingMongoDbAtlasVerifications.size > 0) {
735
+ rows.push(["MongoDB Atlas", "oauth/token endpoint", [...pendingMongoDbAtlasVerifications.keys()].map(redact)]);
669
736
  }
670
737
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
671
738
  if (byValue.size === 0) continue;
672
739
  const [vendor, endpoint] = SIMPLE_VERIFY_VENDOR_LABEL[ruleId].split("'s ");
673
- rows.push([vendor, endpoint, byValue.size]);
740
+ rows.push([vendor, endpoint, [...byValue.keys()].map(redact)]);
674
741
  }
675
742
  if (rows.length > 0) {
676
- const vendorWidth = Math.max(...rows.map(([vendor]) => vendor.length));
677
- const endpointWidth = Math.max(...rows.map(([, endpoint]) => endpoint.length));
678
743
  const table = rows
679
- .map(([vendor, endpoint, count]) => ` ${vendor.padEnd(vendorWidth)} ${endpoint.padEnd(endpointWidth)} ${count}`)
744
+ .map(([vendor, endpoint, previews]) =>
745
+ ` ${paint(c.bold, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
746
+ previews.map((p) => ` ${p}`).join("\n"))
680
747
  .join("\n");
681
748
  process.stderr.write(
682
- "residoo --verify: checking whether these credentials are still active. Real network " +
749
+ paint(c.yellow + c.bold, "residoo --verify:") +
750
+ " checking whether these credentials are still active. Real network " +
683
751
  "calls, using the exact value found in your transcript, one at a time. Nothing is cached " +
684
752
  "or sent anywhere but the endpoint listed below.\n\n" +
685
753
  table + "\n\n"
@@ -687,7 +755,8 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
687
755
  }
688
756
  if (pendingAwsVerifications.size > 0 && !awsAvailable) {
689
757
  process.stderr.write(
690
- "residoo --verify: the aws CLI was not found on PATH, so the " +
758
+ paint(c.yellow + c.bold, "residoo --verify:") +
759
+ " the aws CLI was not found on PATH, so the " +
691
760
  `${pendingAwsVerifications.size} AWS credential(s) found in this scan could not be checked. ` +
692
761
  "Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) to use --verify.\n"
693
762
  );
@@ -715,6 +784,12 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
715
784
  for (const ref of refs) applyVerifyResult([ref.secretFinding, ref.idFinding], result);
716
785
  }
717
786
  }
787
+ if (verify && pendingMongoDbAtlasVerifications.size > 0) {
788
+ for (const [secretValue, { idValue, refs }] of pendingMongoDbAtlasVerifications) {
789
+ const result = await verifyMongoDbAtlasCredential(idValue, secretValue);
790
+ for (const ref of refs) applyVerifyResult([ref.secretFinding, ref.idFinding], result);
791
+ }
792
+ }
718
793
  if (verify) {
719
794
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
720
795
  if (byValue.size === 0) continue;
package/src/verify.js CHANGED
@@ -305,6 +305,24 @@ function verifyGithubToken(token, opts) {
305
305
  // dozens of apparent matches inside an unrelated real file that just
306
306
  // happened to contain a lot of embedded base64 data. See patterns.js's own
307
307
  // comment on flyio_bearer_token for the measured false-positive rate.
308
+ //
309
+ // A second, independent research pass (11 candidate vendors, each
310
+ // researched and then adversarially cross-checked by a separate reviewer
311
+ // before being trusted) added three more: Neon (verifyNeonKey), MongoDB
312
+ // Atlas Service Account credentials (verifyMongoDbAtlasCredential, a third
313
+ // paired vendor alongside AWS and PlanetScale), and PostHog
314
+ // (verifyPostHogKey). The other eight were rejected, each for a documented,
315
+ // vendor-specific reason rather than lack of trying:
316
+ // - Clerk, Auth0, Upstash, Turso, Railway, Segment, Algolia: no
317
+ // distinguishing, documented credential prefix could be confirmed from
318
+ // the vendor's own current docs (some have a prefix on one credential
319
+ // type but no working verify endpoint for it, or a prefix that isn't
320
+ // actually vendor-specific enough to trust).
321
+ // - Convex: the researcher's own first pass recommended adding it, but
322
+ // the adversarial reviewer refuted that recommendation on independent
323
+ // re-checking of the same sources — the exact reason this project runs
324
+ // research and verification as two separate, disagreeing passes rather
325
+ // than trusting one agent's first read of the docs.
308
326
 
309
327
  function huggingfaceUrl() { return process.env.RESIDOO_TEST_HUGGINGFACE_API_URL || "https://huggingface.co/api/whoami-v2"; }
310
328
  function sendgridUrl() { return process.env.RESIDOO_TEST_SENDGRID_API_URL || "https://api.sendgrid.com/v3/scopes"; }
@@ -428,6 +446,64 @@ function verifyPlanetScaleToken(id, secret, opts) {
428
446
  return verifyByStatusCode("PlanetScale", planetscaleUrl(), () => ({ Authorization: `${id}:${secret}` }), opts);
429
447
  }
430
448
 
449
+ function mongodbAtlasUrl() { return process.env.RESIDOO_TEST_MONGODB_ATLAS_API_URL || "https://cloud.mongodb.com/api/oauth/token"; }
450
+ /**
451
+ * MongoDB Atlas Service Account credentials: also a paired credential (like
452
+ * AWS and PlanetScale), but the actual verify call is an OAuth2 client-
453
+ * credentials token exchange, not a plain bearer-token GET -- POST, Basic-
454
+ * auth-encoded clientId:clientSecret, grant_type=client_credentials in the
455
+ * body -- so it needs its own function rather than verifyByStatusCode,
456
+ * which is GET-only.
457
+ *
458
+ * A bare 403 is NOT treated as "invalid" here: MongoDB Atlas's own docs
459
+ * confirm it also returns 403 when the caller's IP isn't on the service
460
+ * account's access list, which says nothing about whether the credential
461
+ * itself is still alive -- the same "real but out of scope" ambiguity
462
+ * GitLab/CircleCI/Airtable get elsewhere via activeExtra, except MongoDB
463
+ * signals it in the response BODY, not a separate status code. Only the
464
+ * documented invalid_client body is treated as a genuine "this credential
465
+ * is dead" signal; every other 403 stays "error", never a false "invalid" --
466
+ * the false-negative-in-the-dangerous-direction failure this whole module
467
+ * exists to avoid.
468
+ */
469
+ async function verifyMongoDbAtlasCredential(clientId, clientSecret, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
470
+ let res;
471
+ try {
472
+ res = await fetchFn(mongodbAtlasUrl(), {
473
+ method: "POST",
474
+ headers: {
475
+ Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
476
+ "Content-Type": "application/x-www-form-urlencoded",
477
+ },
478
+ body: "grant_type=client_credentials",
479
+ signal: AbortSignal.timeout(timeoutMs),
480
+ });
481
+ } catch (e) {
482
+ return { status: "error", detail: `could not reach MongoDB Atlas (${sanitizeDetail(e && e.message)})` };
483
+ }
484
+ if (res.status === 200) return { status: "active", detail: "MongoDB Atlas accepted this service account" };
485
+ let body = null;
486
+ try {
487
+ body = await res.json();
488
+ } catch {
489
+ // Non-JSON body: fall through to the generic error below.
490
+ }
491
+ if (res.status === 403 && body && body.error === "invalid_client") {
492
+ return { status: "invalid", detail: "MongoDB Atlas rejected this service account (invalid_client)" };
493
+ }
494
+ return { status: "error", detail: `could not verify: HTTP ${res.status} from MongoDB Atlas` };
495
+ }
496
+
497
+ function neonUrl() { return process.env.RESIDOO_TEST_NEON_API_URL || "https://console.neon.tech/api/v2/projects"; }
498
+ function verifyNeonKey(key, opts) {
499
+ return verifyByStatusCode("Neon", neonUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
500
+ }
501
+
502
+ function posthogUrl() { return process.env.RESIDOO_TEST_POSTHOG_API_URL || "https://us.posthog.com/api/users/@me/"; }
503
+ function verifyPostHogKey(key, opts) {
504
+ return verifyByStatusCode("PostHog", posthogUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
505
+ }
506
+
431
507
  function vercelUrl() { return process.env.RESIDOO_TEST_VERCEL_API_URL || "https://api.vercel.com/v2/user"; }
432
508
  function verifyVercelToken(token, opts) {
433
509
  return verifyByStatusCode("Vercel", vercelUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
@@ -557,5 +633,5 @@ module.exports = {
557
633
  verifyCircleciToken, verifyAirtableToken, verifyCloudflareToken, verifyHerokuKey,
558
634
  verifyNetlifyToken, verifyLinearKey, verifyTelegramToken, verifyDiscordWebhook,
559
635
  verifyPlanetScaleToken, verifyVercelToken, verifyCerebrasKey, verifyRenderKey,
560
- verifyFlyioBearerToken,
636
+ verifyFlyioBearerToken, verifyMongoDbAtlasCredential, verifyNeonKey, verifyPostHogKey,
561
637
  };