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/README.md +241 -449
- package/package.json +1 -1
- package/src/cli.js +12 -10
- package/src/color.js +36 -0
- package/src/patterns.js +39 -0
- package/src/report.js +26 -24
- package/src/rotation.js +50 -0
- package/src/scan.js +96 -21
- package/src/verify.js +77 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.4.
|
|
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 (
|
|
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.
|
|
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)
|
|
110
|
-
PlanetScale
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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,
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
49
|
-
// (see pendingPlanetScaleVerifications
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
};
|