residoo 0.4.10 → 0.4.12

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.10",
3
+ "version": "0.4.12",
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):
@@ -219,6 +237,16 @@ const VENDOR_EXAMPLE_VALUES = new Set([
219
237
  /** Matches every finding's own `relFile` convention — never the full path. See SECURITY.md. */
220
238
  function safeName(file) { return path.basename(file); }
221
239
 
240
+ // Same format as report.js's own localTimestamp, duplicated rather than
241
+ // imported: this is a 3-line pure function, and report.js is the
242
+ // presentation layer for stdout while this file's own --verify results
243
+ // table is stderr, the same reasoning pairing.js gives for its own small
244
+ // duplicated helper (looksZeroEntropy) rather than cross-importing.
245
+ function localTimestamp(d) {
246
+ const p2 = (n) => String(n).padStart(2, "0");
247
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
248
+ }
249
+
222
250
  /**
223
251
  * Scan every transcript from every available source.
224
252
  *
@@ -237,7 +265,7 @@ function safeName(file) { return path.basename(file); }
237
265
  * absolute path can itself carry a username or a project name the rest of
238
266
  * this report is careful never to print.
239
267
  */
240
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null } = {}) {
268
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null, noColor = false } = {}) {
241
269
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
242
270
  // The decode pass (see decode.js) only applies high-confidence, vendor-
243
271
  // prefixed rules to decoded bytes: random binary that decodes to printable
@@ -271,6 +299,10 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
271
299
  // credential (see the planetscale_secret match branch below): keyed by
272
300
  // the secret value (the confirmed, prefixed anchor) -> { idValue, refs }.
273
301
  const pendingPlanetScaleVerifications = new Map();
302
+ // Same shape again, for MongoDB Atlas Service Account credentials (see
303
+ // the mongodb_atlas_secret match branch below): keyed by the secret value
304
+ // -> { idValue, refs }.
305
+ const pendingMongoDbAtlasVerifications = new Map();
274
306
  // --verify only (see verify.js): ruleId -> (token value -> { refs }), for
275
307
  // every SIMPLE_VERIFY_FNS vendor. Unlike AWS/PlanetScale, none of these
276
308
  // need pairing (the token itself is the complete credential), so this is
@@ -399,6 +431,30 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
399
431
  }
400
432
  }
401
433
  }
434
+ // MongoDB Atlas: same shape as PlanetScale (secret is the
435
+ // confirmed, prefixed anchor; the id is the nearby candidate),
436
+ // except the id here ALSO carries its own distinguishing prefix
437
+ // (mdb_sa_id_) rather than being a bare unprefixed shape — a
438
+ // stronger candidate signal than PlanetScale's or AWS's, but the
439
+ // same pairing mechanism and the same generic pairedOtherPreview/
440
+ // pairedOtherLabel display fields.
441
+ let mongoDbIdFinding = null;
442
+ let rawMongoDbId = null;
443
+ if (!suppressedReason && rule.id === "mongodb_atlas_secret") {
444
+ const pairedId = findNearbyCandidate(line, m[0], m.index, MONGODB_ATLAS_ID_RE, MONGODB_ATLAS_PAIR_WINDOW);
445
+ if (pairedId) {
446
+ const idSuppressedReason = suppressionReason(pairedId, null);
447
+ if (idSuppressedReason && !includeSuppressed) {
448
+ suppressedCount++;
449
+ } else {
450
+ rawMongoDbId = pairedId;
451
+ mongoDbIdFinding = record({ id: "mongodb_atlas_client_id", label: "MongoDB Atlas Service Account client id (paired with secret)" },
452
+ pairedId, relFile, file, lineNo, mtimeMs,
453
+ idSuppressedReason ? "low" : "high", idSuppressedReason,
454
+ { paired: true, pairedOtherPreview: redact(m[0]), pairedOtherLabel: "secret" });
455
+ }
456
+ }
457
+ }
402
458
  // Local, offline JWT expiry (see jwtExpiry.js): only ever reads
403
459
  // the `exp` claim out of the decoded payload, nothing else, and
404
460
  // only for the unsuppressed default `jwt` rule, since a
@@ -413,6 +469,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
413
469
  {
414
470
  ...(pairedSecretPreview ? { pairedSecretPreview } : {}),
415
471
  ...(planetScaleIdFinding ? { pairedOtherPreview: redact(rawPlanetScaleId), pairedOtherLabel: "id" } : {}),
472
+ ...(mongoDbIdFinding ? { pairedOtherPreview: redact(rawMongoDbId), pairedOtherLabel: "id" } : {}),
416
473
  ...(jwtExtra || {}),
417
474
  });
418
475
 
@@ -440,6 +497,15 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
440
497
  const psEntry = pendingPlanetScaleVerifications.get(m[0]);
441
498
  if (psEntry) psEntry.refs.push({ secretFinding: primaryFinding, idFinding: planetScaleIdFinding });
442
499
  }
500
+ // --verify, MongoDB Atlas: same dedup-by-anchor-value shape as
501
+ // AWS/PlanetScale above, keyed by the secret this time.
502
+ if (verify && mongoDbIdFinding && rawMongoDbId) {
503
+ if (!pendingMongoDbAtlasVerifications.has(m[0]) && pendingMongoDbAtlasVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
504
+ pendingMongoDbAtlasVerifications.set(m[0], { idValue: rawMongoDbId, refs: [] });
505
+ }
506
+ const mdbEntry = pendingMongoDbAtlasVerifications.get(m[0]);
507
+ if (mdbEntry) mdbEntry.refs.push({ secretFinding: primaryFinding, idFinding: mongoDbIdFinding });
508
+ }
443
509
  // --verify, single-token vendors (Slack, OpenAI, Anthropic,
444
510
  // GitHub): none of these need pairing (the value IS the complete
445
511
  // credential), so queue every unsuppressed match directly, same
@@ -629,6 +695,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
629
695
  // actually something to verify, so a plain --verify with nothing to check
630
696
  // never clears a spinner line for no reason.
631
697
  const anyPending = pendingAwsVerifications.size > 0 || pendingPlanetScaleVerifications.size > 0 ||
698
+ pendingMongoDbAtlasVerifications.size > 0 ||
632
699
  [...pendingSimpleVerifications.values()].some((byValue) => byValue.size > 0);
633
700
  if (verify && anyPending && typeof onBeforeVerify === "function") onBeforeVerify();
634
701
 
@@ -642,6 +709,17 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
642
709
  }
643
710
  };
644
711
  const awsAvailable = pendingAwsVerifications.size === 0 || isAwsCliAvailable();
712
+ // stderr, not stdout: color.js's supportsColor checks whichever stream is
713
+ // passed to it, and this table is never written to stdout, so it must
714
+ // check stderr's own TTY status, not borrow stdout's (piping stdout to a
715
+ // file while stderr still reaches a real terminal is a real case: `scan
716
+ // --verify --json > out.json` should still color this table).
717
+ const paint = makePaint(noColor, process.stderr);
718
+ // Populated inside the disclosure block below (when there's something to
719
+ // verify), then read again once every verify loop below has finished, to
720
+ // print the results table. Declared out here, not inside that block, so
721
+ // it survives to that second read.
722
+ let verifyRows = [];
645
723
  if (verify && anyPending) {
646
724
  // One disclosure, not one per vendor: this used to print a full
647
725
  // "this is a real network request..." paragraph for EACH vendor in
@@ -661,25 +739,41 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
661
739
  // everywhere else in the report — reusing redact() on the same raw
662
740
  // value record() was called with, not a second display convention.
663
741
  // One line per vendor+endpoint header, one indented line per credential.
664
- const rows = [];
742
+ //
743
+ // Each row also carries a `resultFinding` per credential: a direct
744
+ // reference to the finding object applyVerifyResult mutates below (the
745
+ // access-key/secret finding for AWS, the secret finding for
746
+ // PlanetScale/MongoDB Atlas, the finding itself for a simple vendor).
747
+ // Captured now, read after the verify loops run, so the results table
748
+ // further down needs no second vendor-shape dispatch of its own.
749
+ verifyRows = [];
665
750
  if (pendingAwsVerifications.size > 0 && awsAvailable) {
666
- rows.push(["AWS", "sts:get-caller-identity", [...pendingAwsVerifications.keys()].map(redact)]);
751
+ verifyRows.push(["AWS", "sts:get-caller-identity",
752
+ [...pendingAwsVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].akiaFinding }))]);
667
753
  }
668
754
  if (pendingPlanetScaleVerifications.size > 0) {
669
- rows.push(["PlanetScale", "organizations endpoint", [...pendingPlanetScaleVerifications.keys()].map(redact)]);
755
+ verifyRows.push(["PlanetScale", "organizations endpoint",
756
+ [...pendingPlanetScaleVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].secretFinding }))]);
757
+ }
758
+ if (pendingMongoDbAtlasVerifications.size > 0) {
759
+ verifyRows.push(["MongoDB Atlas", "oauth/token endpoint",
760
+ [...pendingMongoDbAtlasVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].secretFinding }))]);
670
761
  }
671
762
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
672
763
  if (byValue.size === 0) continue;
673
764
  const [vendor, endpoint] = SIMPLE_VERIFY_VENDOR_LABEL[ruleId].split("'s ");
674
- rows.push([vendor, endpoint, [...byValue.keys()].map(redact)]);
765
+ verifyRows.push([vendor, endpoint,
766
+ [...byValue.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0] }))]);
675
767
  }
676
- if (rows.length > 0) {
677
- const table = rows
678
- .map(([vendor, endpoint, previews]) =>
679
- ` ${vendor} · ${endpoint}\n` + previews.map((p) => ` ${p}`).join("\n"))
768
+ if (verifyRows.length > 0) {
769
+ const table = verifyRows
770
+ .map(([vendor, endpoint, credentials]) =>
771
+ ` ${paint(c.bold + c.cyan, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
772
+ credentials.map(({ value }) => ` ${redact(value)}`).join("\n"))
680
773
  .join("\n");
681
774
  process.stderr.write(
682
- "residoo --verify: checking whether these credentials are still active. Real network " +
775
+ paint(c.yellow + c.bold, "residoo --verify:") +
776
+ " checking whether these credentials are still active. Real network " +
683
777
  "calls, using the exact value found in your transcript, one at a time. Nothing is cached " +
684
778
  "or sent anywhere but the endpoint listed below.\n\n" +
685
779
  table + "\n\n"
@@ -687,7 +781,8 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
687
781
  }
688
782
  if (pendingAwsVerifications.size > 0 && !awsAvailable) {
689
783
  process.stderr.write(
690
- "residoo --verify: the aws CLI was not found on PATH, so the " +
784
+ paint(c.yellow + c.bold, "residoo --verify:") +
785
+ " the aws CLI was not found on PATH, so the " +
691
786
  `${pendingAwsVerifications.size} AWS credential(s) found in this scan could not be checked. ` +
692
787
  "Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) to use --verify.\n"
693
788
  );
@@ -715,6 +810,12 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
715
810
  for (const ref of refs) applyVerifyResult([ref.secretFinding, ref.idFinding], result);
716
811
  }
717
812
  }
813
+ if (verify && pendingMongoDbAtlasVerifications.size > 0) {
814
+ for (const [secretValue, { idValue, refs }] of pendingMongoDbAtlasVerifications) {
815
+ const result = await verifyMongoDbAtlasCredential(idValue, secretValue);
816
+ for (const ref of refs) applyVerifyResult([ref.secretFinding, ref.idFinding], result);
817
+ }
818
+ }
718
819
  if (verify) {
719
820
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
720
821
  if (byValue.size === 0) continue;
@@ -726,6 +827,37 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
726
827
  }
727
828
  }
728
829
 
830
+ if (verify && verifyRows.length > 0) {
831
+ // Same vendor/endpoint grouping as the disclosure table above, now
832
+ // showing what each call actually found. An ACTIVE credential is a
833
+ // real, present-tense risk, colored the same red/bold the Rotation
834
+ // section below uses for the identical fact ("rotate immediately");
835
+ // "could not verify" gets the same red/bold too, on purpose, matching
836
+ // this project's fail-safe-direction policy of treating "unknown" as
837
+ // "assume risk" rather than as reassuring silence. Stamped with when
838
+ // this check actually ran, so a report read later (pasted into a
839
+ // ticket, screenshotted) doesn't silently imply "still true right now."
840
+ const checkedAt = localTimestamp(new Date());
841
+ const describeResult = (finding) => {
842
+ if (finding.verified === "active") {
843
+ return paint(c.red + c.bold, `⚠ ACTIVE: real working credential`) + paint(c.dim, ` (checked ${checkedAt})`);
844
+ }
845
+ if (finding.verified === "invalid") {
846
+ return paint(c.green, `✓ inactive: vendor rejected it`) + paint(c.dim, ` (checked ${checkedAt})`);
847
+ }
848
+ return paint(c.red + c.bold, `⚠ could not verify`) + paint(c.dim, ` (checked ${checkedAt}${finding.verifiedDetail ? `: ${finding.verifiedDetail}` : ""})`);
849
+ };
850
+ const resultsTable = verifyRows
851
+ .map(([vendor, endpoint, credentials]) =>
852
+ ` ${paint(c.bold + c.cyan, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
853
+ credentials.map(({ value, resultFinding }) => ` ${redact(value)} ${describeResult(resultFinding)}`).join("\n"))
854
+ .join("\n");
855
+ process.stderr.write(
856
+ paint(c.yellow + c.bold, "residoo --verify:") + " results\n\n" +
857
+ resultsTable + "\n\n"
858
+ );
859
+ }
860
+
729
861
  const distinctCounts = {};
730
862
  for (const [ruleId, set] of distinctByRule) distinctCounts[ruleId] = set.size;
731
863
  return { findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount, distinctCounts, unreadableFiles };