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/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 +148 -16
- 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.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 (
|
|
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):
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
+
verifyRows.push([vendor, endpoint,
|
|
766
|
+
[...byValue.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0] }))]);
|
|
675
767
|
}
|
|
676
|
-
if (
|
|
677
|
-
const table =
|
|
678
|
-
.map(([vendor, endpoint,
|
|
679
|
-
` ${vendor} · ${endpoint}\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:
|
|
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:
|
|
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 };
|