residoo 0.4.3 → 0.4.5
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 +44 -9
- package/package.json +1 -1
- package/src/cli.js +37 -8
- package/src/jwtExpiry.js +48 -0
- package/src/report.js +49 -9
- package/src/rotation.js +56 -11
- package/src/scan.js +152 -5
- package/src/verify.js +266 -0
package/README.md
CHANGED
|
@@ -117,6 +117,28 @@ won't be built into the tool that writes it.
|
|
|
117
117
|
demonstrated usable credential and sorted to the top of its group in the
|
|
118
118
|
Rotation section, ahead of the ones that are, on their own, not yet proven
|
|
119
119
|
exploitable.
|
|
120
|
+
- Decodes a JWT-shaped token's own `exp` claim locally (no network call: the
|
|
121
|
+
claim is inside the signed payload, so it cannot be altered without
|
|
122
|
+
breaking the signature) and reports "valid until" or "expired" next to it
|
|
123
|
+
in the Rotation section, instead of just "last seen." Only `exp` is ever
|
|
124
|
+
read; every other claim in the payload is decoded transiently and
|
|
125
|
+
discarded. See `src/jwtExpiry.js`.
|
|
126
|
+
- **`--verify`** (opt-in, makes a real network call): asks a credential's own
|
|
127
|
+
vendor whether it still authenticates, using the exact value found in your
|
|
128
|
+
transcript. Five vendors today: **AWS** (an access key id found paired with
|
|
129
|
+
its secret, checked via `sts:get-caller-identity`, the same free,
|
|
130
|
+
read-only, permission-less call the AWS CLI and tools like aws-vault use
|
|
131
|
+
for exactly this; shells out to your own `aws` CLI rather than
|
|
132
|
+
reimplementing AWS request signing, since residoo ships zero runtime
|
|
133
|
+
dependencies and a subtly wrong signing implementation would silently
|
|
134
|
+
report real keys as invalid, worse than not checking), and **Slack,
|
|
135
|
+
OpenAI, Anthropic, GitHub** (a direct API call to each vendor's own free
|
|
136
|
+
"list what I can see" endpoint, no CLI needed, no request signing to get
|
|
137
|
+
wrong). A verified-active credential is escalated to "rotate immediately";
|
|
138
|
+
a verified-invalid one is reported as already dead, no action needed, and
|
|
139
|
+
sorted out of the way. Off by default; every environment variable the
|
|
140
|
+
`aws` CLI reads is built from scratch, never inherited, so it can never
|
|
141
|
+
fall back to your own real AWS profile. See `src/verify.js`.
|
|
120
142
|
- With `--include-noisy`, filters the broad generic-secret rules by how
|
|
121
143
|
machine-random the matched value actually looks (a lightweight, offline
|
|
122
144
|
approximation of BPE-tokenization rarity checks): ordinary English, a
|
|
@@ -128,10 +150,10 @@ won't be built into the tool that writes it.
|
|
|
128
150
|
preview, never the real value, including in `--json` mode. A decoded or
|
|
129
151
|
rejoined secret is redacted exactly like a plain one.
|
|
130
152
|
- On an interactive terminal, prints who it is and where it lives before
|
|
131
|
-
scanning starts (`residoo v0.4.
|
|
153
|
+
scanning starts (`residoo v0.4.5 · find secrets your AI coding agent left
|
|
132
154
|
on disk` plus the repo URL), then a live spinner naming the current file
|
|
133
155
|
as it scans. Every report also opens with the exact version and timestamp
|
|
134
|
-
it was run with (`residoo v0.4.
|
|
156
|
+
it was run with (`residoo v0.4.5 · scanned 2026-01-01 12:00`; `--json`
|
|
135
157
|
carries the same as `residooVersion`/`scannedAt`), so a report pasted or
|
|
136
158
|
screenshotted later never leaves you guessing which build produced it.
|
|
137
159
|
When there are findings, the report closes with a "Next steps" pointer to
|
|
@@ -317,7 +339,14 @@ with the way out:
|
|
|
317
339
|
versus how many are already resolved. A machine with a lot of history can
|
|
318
340
|
report hundreds of raw findings that are really a handful of distinct
|
|
319
341
|
values echoed repeatedly; the summary is built around what's actually left
|
|
320
|
-
to triage, not the raw count.
|
|
342
|
+
to triage, not the raw count. A value `--verify` confirmed dead, or a JWT
|
|
343
|
+
whose own signed `exp` claim is already past, is subtracted from "needs
|
|
344
|
+
review" the same way an acked or dismissed one is, since residoo already
|
|
345
|
+
knows it needs no action, not just that nobody has said so yet. This is a
|
|
346
|
+
strictly per-VALUE fact: it is never rolled up into a whole rule's
|
|
347
|
+
confidence tag in the breakdown below, since `--verify` only ever checks
|
|
348
|
+
the specific values it can (a paired AWS credential, a bearer token), and
|
|
349
|
+
a rule's other, unchecked findings say nothing either way.
|
|
321
350
|
- **The rotation list is grouped by credential type**, so the rotation URL
|
|
322
351
|
prints once per type instead of once per finding. Each distinct value's own
|
|
323
352
|
line shows its redacted preview, which file it's in, and when it was last
|
|
@@ -355,7 +384,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
355
384
|
```yaml
|
|
356
385
|
steps:
|
|
357
386
|
- uses: actions/checkout@v4
|
|
358
|
-
- uses: dandovdub/residoo@v0.4.
|
|
387
|
+
- uses: dandovdub/residoo@v0.4.5
|
|
359
388
|
```
|
|
360
389
|
|
|
361
390
|
As a pre-commit hook:
|
|
@@ -363,7 +392,7 @@ As a pre-commit hook:
|
|
|
363
392
|
```yaml
|
|
364
393
|
repos:
|
|
365
394
|
- repo: https://github.com/dandovdub/residoo
|
|
366
|
-
rev: v0.4.
|
|
395
|
+
rev: v0.4.5
|
|
367
396
|
hooks:
|
|
368
397
|
- id: residoo
|
|
369
398
|
```
|
|
@@ -378,10 +407,16 @@ documented in [docs/ci.md](docs/ci.md).
|
|
|
378
407
|
## What it does not do
|
|
379
408
|
|
|
380
409
|
- **No network calls in the default path, and none at all unless you
|
|
381
|
-
explicitly pass `--upload-cloudroam`.** A secret scanner that
|
|
382
|
-
not a tool you should trust with your secrets. Verify this
|
|
383
|
-
|
|
384
|
-
|
|
410
|
+
explicitly pass `--upload-cloudroam` or `--verify`.** A secret scanner that
|
|
411
|
+
phones home is not a tool you should trust with your secrets. Verify this
|
|
412
|
+
yourself: every network-capable call in the codebase lives behind one of
|
|
413
|
+
those two flags. `src/sealvault.js` holds the one `fetch` call reachable
|
|
414
|
+
from `--upload-cloudroam`, and sends only encrypted bytes. `src/verify.js`
|
|
415
|
+
holds everything reachable from `--verify`: a `fetch` call per vendor
|
|
416
|
+
(Slack, OpenAI, Anthropic, GitHub), each sending nothing but the exact
|
|
417
|
+
credential a scan found to that credential's own vendor, plus a subprocess
|
|
418
|
+
call to your own `aws` CLI for AWS credentials, never a `fetch`. Neither
|
|
419
|
+
file's code runs unless you pass the matching flag.
|
|
385
420
|
- **Nothing destructive, ever.** Scanning is read-only. Sealing creates *new*
|
|
386
421
|
files and modifies or deletes nothing, not even the plaintext it just
|
|
387
422
|
encrypted a copy of. That last step is deliberately left to a human.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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
|
@@ -54,9 +54,12 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
|
|
|
54
54
|
Both are recorded in ~/.residoo/rotations.json, the only file residoo
|
|
55
55
|
ever writes outside an explicit --seal.
|
|
56
56
|
|
|
57
|
-
Scanning makes NO network calls and changes nothing on disk.
|
|
58
|
-
redacted in every output format.
|
|
59
|
-
|
|
57
|
+
Scanning makes NO network calls by default and changes nothing on disk.
|
|
58
|
+
Findings are redacted in every output format. The one opt-in exception is
|
|
59
|
+
--verify, which asks a credential's own vendor whether it still
|
|
60
|
+
authenticates (AWS, Slack, OpenAI, Anthropic, GitHub today); see below.
|
|
61
|
+
Sealing (--seal) writes NEW encrypted files only. It never modifies or
|
|
62
|
+
deletes anything that already exists.
|
|
60
63
|
|
|
61
64
|
Usage:
|
|
62
65
|
residoo scan [options]
|
|
@@ -96,6 +99,24 @@ Scan options:
|
|
|
96
99
|
--no-integrity skip the integrity checks (planted hooks, dropper
|
|
97
100
|
files, auto-run tasks, hidden Unicode)
|
|
98
101
|
--no-color disable ANSI colour
|
|
102
|
+
--verify ask the credential's own vendor whether it still
|
|
103
|
+
authenticates, using the exact value found in
|
|
104
|
+
your transcript. THIS MAKES A REAL NETWORK CALL.
|
|
105
|
+
Off by default. Five vendors today:
|
|
106
|
+
AWS: every access key id found paired with its
|
|
107
|
+
secret (see Rotation below) is checked via
|
|
108
|
+
sts:get-caller-identity. Needs the aws CLI on
|
|
109
|
+
PATH; residoo shells out to it rather than
|
|
110
|
+
reimplementing AWS request signing.
|
|
111
|
+
Slack: every token via auth.test.
|
|
112
|
+
OpenAI, Anthropic, GitHub: every key/token via
|
|
113
|
+
that vendor's own models/user listing endpoint.
|
|
114
|
+
All four non-AWS vendors are a direct, dependency-
|
|
115
|
+
free API call, no CLI needed. A verified-invalid
|
|
116
|
+
credential is reported as already dead, not as
|
|
117
|
+
something to rotate; a JWT's own signed exp claim
|
|
118
|
+
is checked locally with no network call at all,
|
|
119
|
+
on by default, not part of --verify.
|
|
99
120
|
|
|
100
121
|
Rotation:
|
|
101
122
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
@@ -125,10 +146,11 @@ Seal options (used with scan):
|
|
|
125
146
|
only, unlike a passphrase, it is not portable to
|
|
126
147
|
another machine.
|
|
127
148
|
--vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
|
|
128
|
-
--upload-cloudroam ALSO upload the sealed vault to CloudRoam.
|
|
129
|
-
|
|
130
|
-
off unless you pass it, and
|
|
131
|
-
Needs CLOUDROAM_API_KEY
|
|
149
|
+
--upload-cloudroam ALSO upload the sealed vault to CloudRoam. One of two
|
|
150
|
+
opt-in features that touch the network (--verify
|
|
151
|
+
above is the other); off unless you pass it, and
|
|
152
|
+
only ciphertext is sent. Needs CLOUDROAM_API_KEY
|
|
153
|
+
(env) plus:
|
|
132
154
|
--connector <id> CloudRoam connector id for the destination
|
|
133
155
|
--bucket <name> destination bucket
|
|
134
156
|
--prefix <p> optional key prefix inside the bucket
|
|
@@ -434,6 +456,13 @@ async function main(argv) {
|
|
|
434
456
|
const includeSuppressed = args.includes("--include-suppressed");
|
|
435
457
|
const failOnFind = args.includes("--fail-on-find");
|
|
436
458
|
const allowAcked = args.includes("--allow-acked");
|
|
459
|
+
// The one flag that makes residoo do something other than read local
|
|
460
|
+
// files: --verify asks the credential's own vendor whether it still
|
|
461
|
+
// authenticates, for every vendor residoo knows how to check today (AWS
|
|
462
|
+
// access key + paired secret via the aws CLI, Slack tokens via a direct
|
|
463
|
+
// API call; see verify.js). Off by default; every other flag here only
|
|
464
|
+
// changes what is READ or how it is DISPLAYED.
|
|
465
|
+
const verify = args.includes("--verify");
|
|
437
466
|
|
|
438
467
|
// --project [dir]: the dir is optional (CI passes ".", a bare --project
|
|
439
468
|
// means the current directory). null means machine mode.
|
|
@@ -543,7 +572,7 @@ async function main(argv) {
|
|
|
543
572
|
}
|
|
544
573
|
|
|
545
574
|
const progress = makeProgressReporter(noColor);
|
|
546
|
-
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
|
|
575
|
+
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress, verify });
|
|
547
576
|
progress.stop();
|
|
548
577
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
549
578
|
const rotation = renderRotation(result.findings, acks, dismissed);
|
package/src/jwtExpiry.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local, offline JWT expiry decoding.
|
|
5
|
+
*
|
|
6
|
+
* Unlike an AWS or vendor API key, a JWT's own payload can carry an `exp`
|
|
7
|
+
* claim, and that claim is inside the signed part of the token: it cannot
|
|
8
|
+
* be altered without invalidating the signature, so decoding it locally is
|
|
9
|
+
* a trustworthy answer to "is this still valid," not a guess, PROVIDED the
|
|
10
|
+
* token is actually validated (signature + expiry) by whatever service
|
|
11
|
+
* accepts it. residoo does not check the signature (it does not know the
|
|
12
|
+
* issuer's key, and would need a network call to ask), so this only ever
|
|
13
|
+
* reports the claimed expiry, never that a token is genuinely live.
|
|
14
|
+
*
|
|
15
|
+
* No network call, no dependency, no vendor to ask: this is the free,
|
|
16
|
+
* zero-risk half of "is this credential still valid" (see verify.js for
|
|
17
|
+
* the opt-in, network-calling AWS half of that same question).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function base64UrlDecode(segment) {
|
|
21
|
+
const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
22
|
+
return Buffer.from(padded, "base64").toString("utf-8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns the token's `exp` claim as milliseconds since epoch, or null when
|
|
27
|
+
* the token is not decodable as a JWT or carries no `exp` claim. Only the
|
|
28
|
+
* `exp` field is ever read out of the payload; every other claim (sub,
|
|
29
|
+
* email, scopes, whatever an issuer put in there) is decoded transiently
|
|
30
|
+
* and discarded, never stored or reported, so a JWT's expiry can be shown
|
|
31
|
+
* without also handling the rest of its payload as sensitive data.
|
|
32
|
+
*/
|
|
33
|
+
function decodeJwtExpiryMs(token) {
|
|
34
|
+
if (typeof token !== "string") return null;
|
|
35
|
+
const parts = token.split(".");
|
|
36
|
+
if (parts.length !== 3) return null;
|
|
37
|
+
let payload;
|
|
38
|
+
try {
|
|
39
|
+
payload = JSON.parse(base64UrlDecode(parts[1]));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const exp = payload && payload.exp;
|
|
44
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
45
|
+
return exp * 1000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { decodeJwtExpiryMs };
|
package/src/report.js
CHANGED
|
@@ -143,9 +143,15 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
143
143
|
// raw values, while these entries dedupe fingerprints (which include the
|
|
144
144
|
// basename, so one value in two differently-named files is two rotations to
|
|
145
145
|
// track). Two counts under one word would read as a contradiction.
|
|
146
|
+
// "pending" keeps its literal ledger meaning here (not yet acked or
|
|
147
|
+
// dismissed): every entry below tagged pending really is, status-wise.
|
|
148
|
+
// confirmedDead is folded into this note, not into the count itself, so
|
|
149
|
+
// this line explains rather than contradicts "Recommended actions"
|
|
150
|
+
// above, which DOES subtract it from what still needs a look.
|
|
146
151
|
const resolvedNote = [
|
|
147
152
|
counts.acked > 0 ? `${counts.acked} acknowledged` : null,
|
|
148
153
|
counts.dismissed > 0 ? `${counts.dismissed} dismissed` : null,
|
|
154
|
+
counts.confirmedDead > 0 ? `${counts.confirmedDead} confirmed inactive` : null,
|
|
149
155
|
].filter(Boolean).join(", ");
|
|
150
156
|
push(paint(c.bold, "Rotation:") +
|
|
151
157
|
` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
|
|
@@ -229,8 +235,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
229
235
|
// the same type are shown as two separate lines on purpose, not
|
|
230
236
|
// collapsed on a guess.
|
|
231
237
|
const lastSeenNote = typeof e.lastSeenMs === "number" ? `last seen ~${ageDays(e.lastSeenMs)}d ago` : null;
|
|
238
|
+
// The one credential type residoo can say "still valid" about with
|
|
239
|
+
// zero network calls: a JWT's own exp claim, inside its signature
|
|
240
|
+
// (see jwtExpiry.js). Not proof it is accepted anywhere (residoo
|
|
241
|
+
// never checks the signature), only that the token's own claimed
|
|
242
|
+
// window has or has not passed.
|
|
243
|
+
const jwtExpiryNote = typeof e.jwtExpiresAtMs === "number"
|
|
244
|
+
? (e.jwtExpiresAtMs < Date.now()
|
|
245
|
+
? `expired ${new Date(e.jwtExpiresAtMs).toISOString().slice(0, 10)}`
|
|
246
|
+
: `valid until ${new Date(e.jwtExpiresAtMs).toISOString().slice(0, 10)}`)
|
|
247
|
+
: null;
|
|
232
248
|
push(` ${STATUS_TAG[e.status]} ${e.preview} ${paint(c.dim, fileNote)}` +
|
|
233
|
-
(lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : "")
|
|
249
|
+
(lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : "") +
|
|
250
|
+
(jwtExpiryNote ? ` ${paint(c.dim, jwtExpiryNote)}` : ""));
|
|
234
251
|
// An access key id and its AWS secret are each meaningless alone (see
|
|
235
252
|
// pairing.js): the id names WHICH key, the secret authenticates it,
|
|
236
253
|
// and an attacker needs both. Called out in red/bold, the same
|
|
@@ -238,11 +255,25 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
238
255
|
// line under it is a demonstrated full working credential, not just a
|
|
239
256
|
// shape that matched a pattern; a plain access-key-id or secret finding
|
|
240
257
|
// with NO pairing note is still worth checking, but nothing here
|
|
241
|
-
// proves it is actually exploitable on its own.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
258
|
+
// proves it is actually exploitable on its own. --verify (see
|
|
259
|
+
// verify.js) can strengthen this to an outright confirmation, or
|
|
260
|
+
// downgrade it to "already dead": both come from a real answer from
|
|
261
|
+
// AWS, not a guess, so they get their own wording rather than folding
|
|
262
|
+
// into the generic pairing line.
|
|
263
|
+
if (e.pairedSecretPreview || e.pairedAccessKeyPreview) {
|
|
264
|
+
const otherHalf = e.pairedSecretPreview
|
|
265
|
+
? `paired with secret ${e.pairedSecretPreview}`
|
|
266
|
+
: `paired with access key ${e.pairedAccessKeyPreview}`;
|
|
267
|
+
if (e.awsVerified === "active") {
|
|
268
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · VERIFIED ACTIVE: AWS accepted these credentials moments ago, rotate immediately`));
|
|
269
|
+
} else if (e.awsVerified === "invalid") {
|
|
270
|
+
push(paint(c.green, ` ✓ ${otherHalf} · already inactive: AWS rejected these credentials, no rotation needed`));
|
|
271
|
+
} else if (e.awsVerified === "error") {
|
|
272
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`) +
|
|
273
|
+
paint(c.dim, ` (could not verify: ${e.awsVerifiedDetail || "unknown error"})`));
|
|
274
|
+
} else {
|
|
275
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`));
|
|
276
|
+
}
|
|
246
277
|
}
|
|
247
278
|
if (e.status === "acked") {
|
|
248
279
|
push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} · ${e.fingerprint}`));
|
|
@@ -386,17 +417,26 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
386
417
|
// everything else is either already handled or a re-exposure of a value
|
|
387
418
|
// already accounted for.
|
|
388
419
|
if (rotation && rotation.counts.distinct > 0) {
|
|
389
|
-
const { pending, distinct, acked, dismissed } = rotation.counts;
|
|
420
|
+
const { pending, distinct, acked, dismissed, confirmedDead } = rotation.counts;
|
|
421
|
+
// confirmedDead is a PER-VALUE fact (a real --verify rejection, or a
|
|
422
|
+
// JWT's own signed exp claim already past), never an aggregate guess
|
|
423
|
+
// about a whole rule (see the Rotation section note: a rule's other,
|
|
424
|
+
// unverified findings say nothing either way). Subtracted here, not
|
|
425
|
+
// folded into `pending` itself, so --fail-on-find/--allow-acked and
|
|
426
|
+
// every other consumer of pending's original meaning are unaffected;
|
|
427
|
+
// this only changes what this one summary line tells a human to do.
|
|
428
|
+
const needsReview = pending - confirmedDead;
|
|
390
429
|
push();
|
|
391
430
|
push(paint(c.bold, "Recommended actions:"));
|
|
392
|
-
if (
|
|
393
|
-
push(` ${paint(c.yellow, "→")} ${
|
|
431
|
+
if (needsReview > 0) {
|
|
432
|
+
push(` ${paint(c.yellow, "→")} ${needsReview} of ${distinct} distinct value${distinct === 1 ? "" : "s"} ${needsReview === 1 ? "needs" : "need"} review: rotate the real ones (residoo ack), dismiss the rest (residoo dismiss)`);
|
|
394
433
|
} else {
|
|
395
434
|
push(` ${paint(c.green, "✓")} Nothing new to review; every distinct value here has already been triaged`);
|
|
396
435
|
}
|
|
397
436
|
const resolvedParts = [
|
|
398
437
|
acked > 0 ? `${acked} acknowledged` : null,
|
|
399
438
|
dismissed > 0 ? `${dismissed} dismissed` : null,
|
|
439
|
+
confirmedDead > 0 ? `${confirmedDead} confirmed inactive (verified rejected, or expired)` : null,
|
|
400
440
|
].filter(Boolean);
|
|
401
441
|
if (resolvedParts.length > 0) {
|
|
402
442
|
push(paint(c.dim, ` ${resolvedParts.join(", ")} already, no action needed (see Rotation below for which)`));
|
package/src/rotation.js
CHANGED
|
@@ -897,6 +897,19 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
897
897
|
// usable credential pair, not just that a secret exists somewhere.
|
|
898
898
|
pairedSecretPreview: null,
|
|
899
899
|
pairedAccessKeyPreview: null,
|
|
900
|
+
// A JWT's own `exp` claim, decoded locally (see jwtExpiry.js): the
|
|
901
|
+
// one credential type residoo can say "still valid" or "expired"
|
|
902
|
+
// about with zero network calls, since expiry is inside the signed
|
|
903
|
+
// payload. null for every non-JWT finding, and for a JWT that
|
|
904
|
+
// failed to decode or carries no exp claim.
|
|
905
|
+
jwtExpiresAtMs: null,
|
|
906
|
+
// --verify only (see verify.js): whether the credential's own
|
|
907
|
+
// vendor (AWS, Slack) still accepts it. null unless the scan was
|
|
908
|
+
// run with --verify AND this value is one residoo knows how to
|
|
909
|
+
// check; residoo makes no network calls otherwise. Same two fields
|
|
910
|
+
// regardless of vendor: the ruleId already says which one answered.
|
|
911
|
+
verified: null,
|
|
912
|
+
verifiedDetail: null,
|
|
900
913
|
};
|
|
901
914
|
byFp.set(st.fingerprint, e);
|
|
902
915
|
}
|
|
@@ -909,7 +922,9 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
909
922
|
// honest, locally-derivable signal for "how stale is this." NOT proof a
|
|
910
923
|
// credential was rotated or revoked, only that residoo hasn't seen it
|
|
911
924
|
// paste anywhere more recently than this. residoo makes no network
|
|
912
|
-
// calls, so
|
|
925
|
+
// calls in the default path, so this alone never checks a provider for
|
|
926
|
+
// whether a key is still live (see verified above for the opt-in
|
|
927
|
+
// exception, and jwtExpiresAtMs for the zero-network JWT case).
|
|
913
928
|
if (typeof f.fileMTimeMs === "number" && (e.lastSeenMs === null || f.fileMTimeMs > e.lastSeenMs)) {
|
|
914
929
|
e.lastSeenMs = f.fileMTimeMs;
|
|
915
930
|
}
|
|
@@ -923,24 +938,54 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
923
938
|
if (e.pairedAccessKeyPreview === null && typeof f.pairedAccessKeyPreview === "string") {
|
|
924
939
|
e.pairedAccessKeyPreview = f.pairedAccessKeyPreview;
|
|
925
940
|
}
|
|
941
|
+
if (e.jwtExpiresAtMs === null && typeof f.jwtExpiresAtMs === "number") {
|
|
942
|
+
e.jwtExpiresAtMs = f.jwtExpiresAtMs;
|
|
943
|
+
}
|
|
944
|
+
if (e.verified === null && typeof f.verified === "string") {
|
|
945
|
+
e.verified = f.verified;
|
|
946
|
+
e.verifiedDetail = typeof f.verifiedDetail === "string" ? f.verifiedDetail : null;
|
|
947
|
+
}
|
|
926
948
|
}
|
|
927
949
|
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// pushes into "N more"
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
950
|
+
// Within a status tier, order by how demonstrated-urgent an entry is, not
|
|
951
|
+
// just its rule id: a real pair (see pairing.js) is a DEMONSTRATED usable
|
|
952
|
+
// credential, and --verify confirming the vendor still accepts it is
|
|
953
|
+
// stronger evidence still; either way this entry must never be the one
|
|
954
|
+
// the display cap (see renderRotationSection) pushes into "N more."
|
|
955
|
+
//
|
|
956
|
+
// isConfirmedDead is the other direction, PROOF rather than a guess:
|
|
957
|
+
// --verify got a real "no" from the vendor, or a JWT's own signed exp
|
|
958
|
+
// claim is already in the past (decoded locally, no network call, always
|
|
959
|
+
// attempted; see jwtExpiry.js). Deliberately NOT "unverified" or "no
|
|
960
|
+
// pairing found": those mean residoo doesn't know, a weaker claim than
|
|
961
|
+
// residoo knows this one specific value needs no action. Sorts LOWER
|
|
962
|
+
// than an ordinary finding within its tier, and (see confirmedDead below)
|
|
963
|
+
// is subtracted from what the report tells a human still needs a look.
|
|
964
|
+
const isConfirmedDead = (e) => e.verified === "invalid" || (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now());
|
|
965
|
+
const priorityScore = (e) => {
|
|
966
|
+
if (e.verified === "active") return -2;
|
|
967
|
+
if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null) return -1;
|
|
968
|
+
if (isConfirmedDead(e)) return 1;
|
|
969
|
+
return 0;
|
|
970
|
+
};
|
|
936
971
|
const entries = [...byFp.values()].sort((a, b) => {
|
|
937
972
|
if (a.status !== b.status) return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
938
|
-
|
|
973
|
+
const pa = priorityScore(a), pb = priorityScore(b);
|
|
974
|
+
if (pa !== pb) return pa - pb;
|
|
939
975
|
if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
|
|
940
976
|
return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
941
977
|
});
|
|
942
978
|
|
|
943
|
-
|
|
979
|
+
// A per-VALUE fact, never rolled up into a per-RULE label: only the
|
|
980
|
+
// specific values residoo actually checked (or that carry their own
|
|
981
|
+
// signed exp claim) ever count here, so this can never overstate what was
|
|
982
|
+
// proven about the rest of a rule's unverified findings. Counted only
|
|
983
|
+
// among PENDING entries: one already acked or dismissed is excluded from
|
|
984
|
+
// "needs review" for its own reason already, and double-subtracting would
|
|
985
|
+
// make the arithmetic in the report not add up.
|
|
986
|
+
const confirmedDead = entries.filter((e) => e.status === "pending" && isConfirmedDead(e)).length;
|
|
987
|
+
|
|
988
|
+
return { counts: { ...counts, confirmedDead }, entries };
|
|
944
989
|
}
|
|
945
990
|
|
|
946
991
|
module.exports = {
|
package/src/scan.js
CHANGED
|
@@ -5,6 +5,33 @@ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
|
|
|
5
5
|
const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
|
|
6
6
|
const { findPairedSecret } = require("./pairing");
|
|
7
7
|
const { looksRandom } = require("./rarity");
|
|
8
|
+
const { decodeJwtExpiryMs } = require("./jwtExpiry");
|
|
9
|
+
const {
|
|
10
|
+
isAwsCliAvailable, verifyAwsCredential,
|
|
11
|
+
verifySlackToken, verifyOpenAiKey, verifyAnthropicKey, verifyGithubToken,
|
|
12
|
+
} = require("./verify");
|
|
13
|
+
|
|
14
|
+
// Never verify more than this many distinct credentials of ONE vendor in a
|
|
15
|
+
// single scan: a pathological transcript with dozens of distinct
|
|
16
|
+
// credentials should not turn --verify into a long burst of outbound calls.
|
|
17
|
+
// Real scans see 0-2 per vendor; this is a backstop, not the expected path.
|
|
18
|
+
const MAX_VERIFICATIONS_PER_VENDOR = 10;
|
|
19
|
+
|
|
20
|
+
// Every vendor whose credential is a single, unpaired bearer token: no
|
|
21
|
+
// AWS-style "two halves make one credential" pairing step, so these all
|
|
22
|
+
// share one collection/verification path below (see pendingSimpleVerifications).
|
|
23
|
+
const SIMPLE_VERIFY_FNS = {
|
|
24
|
+
slack_token: verifySlackToken,
|
|
25
|
+
openai_key: verifyOpenAiKey,
|
|
26
|
+
anthropic_key: verifyAnthropicKey,
|
|
27
|
+
github_pat: verifyGithubToken,
|
|
28
|
+
};
|
|
29
|
+
const SIMPLE_VERIFY_VENDOR_LABEL = {
|
|
30
|
+
slack_token: "Slack's auth.test",
|
|
31
|
+
openai_key: "OpenAI's models endpoint",
|
|
32
|
+
anthropic_key: "Anthropic's models endpoint",
|
|
33
|
+
github_pat: "GitHub's user endpoint",
|
|
34
|
+
};
|
|
8
35
|
|
|
9
36
|
// Rule ids that findPairedSecret's window search applies to (see pairing.js):
|
|
10
37
|
// AWS access key ids and STS session tokens both pair with the same shape
|
|
@@ -127,7 +154,7 @@ function safeName(file) { return path.basename(file); }
|
|
|
127
154
|
* absolute path can itself carry a username or a project name the rest of
|
|
128
155
|
* this report is careful never to print.
|
|
129
156
|
*/
|
|
130
|
-
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null } = {}) {
|
|
157
|
+
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false } = {}) {
|
|
131
158
|
const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
|
|
132
159
|
// The decode pass (see decode.js) only applies high-confidence, vendor-
|
|
133
160
|
// prefixed rules to decoded bytes: random binary that decodes to printable
|
|
@@ -146,11 +173,30 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
146
173
|
// browser-testing run is one leak, not ten) — never written to a report,
|
|
147
174
|
// never leaves this function.
|
|
148
175
|
const distinctByRule = new Map();
|
|
176
|
+
// --verify only (see verify.js): accessKeyValue -> { secretValue, refs }.
|
|
177
|
+
// Keyed by the RAW access key so the map itself dedupes distinct
|
|
178
|
+
// credentials for the AWS call (one call per key, no matter how many
|
|
179
|
+
// times it was echoed) while `refs` accumulates EVERY occurrence's
|
|
180
|
+
// finding-object pair, so the result reaches all of them, not only the
|
|
181
|
+
// first: an access key re-echoed across several lines gets several
|
|
182
|
+
// finding objects, and every one of them needs the same answer. Like
|
|
183
|
+
// distinctByRule above, this lives only for the duration of this scan()
|
|
184
|
+
// call; nothing in it is ever written to a finding until verification has
|
|
185
|
+
// REPLACED the raw values with a status string.
|
|
186
|
+
const pendingAwsVerifications = new Map();
|
|
187
|
+
// --verify only (see verify.js): ruleId -> (token value -> { refs }), for
|
|
188
|
+
// every SIMPLE_VERIFY_FNS vendor. Unlike AWS, none of these need pairing
|
|
189
|
+
// (the token itself is the complete credential), so this is simpler: one
|
|
190
|
+
// entry per distinct value per rule, `refs` accumulating every finding
|
|
191
|
+
// object that value produced.
|
|
192
|
+
const pendingSimpleVerifications = new Map();
|
|
149
193
|
|
|
150
194
|
// One place raw matched text turns into a recorded finding: counts the
|
|
151
195
|
// distinct value and pushes the redacted record. `extra` carries the
|
|
152
196
|
// encoding / split markers for the decode and boundary passes; the raw pass
|
|
153
|
-
// passes none.
|
|
197
|
+
// passes none. Returns the finding object itself so a caller (the pairing
|
|
198
|
+
// and --verify logic) can attach more fields onto it later, after the
|
|
199
|
+
// fields that need real work (an AWS API round-trip) finish.
|
|
154
200
|
const record = (rule, value, relFile, file, lineNo, mtimeMs, confidence, suppressedReason, extra) => {
|
|
155
201
|
if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
|
|
156
202
|
distinctByRule.get(rule.id).add(value);
|
|
@@ -166,6 +212,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
166
212
|
fileMTimeMs: mtimeMs,
|
|
167
213
|
...(extra || {}),
|
|
168
214
|
});
|
|
215
|
+
return findings[findings.length - 1];
|
|
169
216
|
};
|
|
170
217
|
|
|
171
218
|
// One suppression policy for all three passes (raw, decoded, boundary).
|
|
@@ -221,6 +268,8 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
221
268
|
// next to it in the transcript, not just that a secret exists
|
|
222
269
|
// somewhere in the scan.
|
|
223
270
|
let pairedSecretPreview = null;
|
|
271
|
+
let secretFinding = null;
|
|
272
|
+
let rawPairedSecret = null;
|
|
224
273
|
if (!suppressedReason && AWS_PAIR_RULE_IDS.has(rule.id)) {
|
|
225
274
|
const paired = findPairedSecret(line, m[0], m.index);
|
|
226
275
|
if (paired) {
|
|
@@ -229,18 +278,60 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
229
278
|
suppressedCount++;
|
|
230
279
|
} else {
|
|
231
280
|
pairedSecretPreview = redact(paired);
|
|
232
|
-
|
|
281
|
+
rawPairedSecret = paired;
|
|
282
|
+
secretFinding = record({ id: "aws_secret_access_key_paired", label: "AWS Secret Access Key (paired with access key id)" },
|
|
233
283
|
paired, relFile, file, lineNo, mtimeMs,
|
|
234
284
|
pairedSuppressedReason ? "low" : "high", pairedSuppressedReason,
|
|
235
285
|
{ paired: true, pairedAccessKeyPreview: redact(m[0]) });
|
|
236
286
|
}
|
|
237
287
|
}
|
|
238
288
|
}
|
|
239
|
-
|
|
289
|
+
// Local, offline JWT expiry (see jwtExpiry.js): only ever reads
|
|
290
|
+
// the `exp` claim out of the decoded payload, nothing else, and
|
|
291
|
+
// only for the unsuppressed default `jwt` rule, since a
|
|
292
|
+
// suppressed placeholder/example match is not worth decoding.
|
|
293
|
+
const jwtExtra = (!suppressedReason && rule.id === "jwt")
|
|
294
|
+
? { jwtExpiresAtMs: decodeJwtExpiryMs(m[0]) }
|
|
295
|
+
: null;
|
|
296
|
+
const primaryFinding = record(rule, m[0], relFile, file, lineNo,
|
|
240
297
|
mtimeMs,
|
|
241
298
|
resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
|
|
242
299
|
suppressedReason,
|
|
243
|
-
pairedSecretPreview ? { pairedSecretPreview } :
|
|
300
|
+
{ ...(pairedSecretPreview ? { pairedSecretPreview } : {}), ...(jwtExtra || {}) });
|
|
301
|
+
|
|
302
|
+
// --verify only, and only for a DEMONSTRATED pair (both halves
|
|
303
|
+
// present, neither suppressed): queue it for the verification pass
|
|
304
|
+
// that runs once, after every file has been scanned (see below).
|
|
305
|
+
// The Map key dedupes the actual AWS call to one per distinct
|
|
306
|
+
// credential; `refs` still grows on every occurrence, so a key
|
|
307
|
+
// re-echoed across several lines gets several finding objects, and
|
|
308
|
+
// the eventual result is applied to every one of them, not only
|
|
309
|
+
// the first.
|
|
310
|
+
if (verify && secretFinding && rawPairedSecret) {
|
|
311
|
+
if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
312
|
+
pendingAwsVerifications.set(m[0], { secretValue: rawPairedSecret, refs: [] });
|
|
313
|
+
}
|
|
314
|
+
const entry = pendingAwsVerifications.get(m[0]);
|
|
315
|
+
if (entry) entry.refs.push({ akiaFinding: primaryFinding, secretFinding });
|
|
316
|
+
}
|
|
317
|
+
// --verify, single-token vendors (Slack, OpenAI, Anthropic,
|
|
318
|
+
// GitHub): none of these need pairing (the value IS the complete
|
|
319
|
+
// credential), so queue every unsuppressed match directly, same
|
|
320
|
+
// dedup-by-value / accumulate-all-refs shape as the AWS map above,
|
|
321
|
+
// just one level deeper (keyed by rule id too, since several
|
|
322
|
+
// vendors share this path).
|
|
323
|
+
if (verify && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
|
|
324
|
+
let byValue = pendingSimpleVerifications.get(rule.id);
|
|
325
|
+
if (!byValue) {
|
|
326
|
+
byValue = new Map();
|
|
327
|
+
pendingSimpleVerifications.set(rule.id, byValue);
|
|
328
|
+
}
|
|
329
|
+
if (!byValue.has(m[0]) && byValue.size < MAX_VERIFICATIONS_PER_VENDOR) {
|
|
330
|
+
byValue.set(m[0], { refs: [] });
|
|
331
|
+
}
|
|
332
|
+
const entry = byValue.get(m[0]);
|
|
333
|
+
if (entry) entry.refs.push(primaryFinding);
|
|
334
|
+
}
|
|
244
335
|
}
|
|
245
336
|
if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
|
|
246
337
|
}
|
|
@@ -396,6 +487,62 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
396
487
|
if (sourceScannedAnything) sourcesScanned.push(source.id());
|
|
397
488
|
}
|
|
398
489
|
|
|
490
|
+
// --verify: runs once, here, after every file has been scanned, never
|
|
491
|
+
// interleaved with the matching pass above. A real network call per
|
|
492
|
+
// distinct credential, one at a time (not concurrent), so this is the one
|
|
493
|
+
// place a scan's wall-clock time depends on something other than disk
|
|
494
|
+
// I/O; that tradeoff only exists when a caller explicitly asked for it.
|
|
495
|
+
// Same field names (verified/verifiedDetail) regardless of which vendor
|
|
496
|
+
// produced the result: rotation.js and report.js render them identically,
|
|
497
|
+
// and the finding's own ruleId already says which vendor answered.
|
|
498
|
+
const applyVerifyResult = (refs, result) => {
|
|
499
|
+
for (const ref of refs) {
|
|
500
|
+
ref.verified = result.status;
|
|
501
|
+
ref.verifiedDetail = result.detail;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
if (verify && pendingAwsVerifications.size > 0) {
|
|
505
|
+
const applyPair = (refs, result) => {
|
|
506
|
+
for (const ref of refs) {
|
|
507
|
+
applyVerifyResult([ref.akiaFinding, ref.secretFinding], result);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
if (!isAwsCliAvailable()) {
|
|
511
|
+
process.stderr.write(
|
|
512
|
+
"residoo --verify: the aws CLI was not found on PATH, so the " +
|
|
513
|
+
`${pendingAwsVerifications.size} AWS credential(s) found in this scan could not be checked. ` +
|
|
514
|
+
"Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) to use --verify.\n"
|
|
515
|
+
);
|
|
516
|
+
const result = { status: "error", detail: "aws CLI not found on PATH" };
|
|
517
|
+
for (const { refs } of pendingAwsVerifications.values()) applyPair(refs, result);
|
|
518
|
+
} else {
|
|
519
|
+
process.stderr.write(
|
|
520
|
+
`residoo --verify: calling AWS sts:get-caller-identity for ${pendingAwsVerifications.size} ` +
|
|
521
|
+
"credential(s) found in this scan. This is a real network request to AWS, using the exact " +
|
|
522
|
+
"credential found in your transcript, one at a time.\n"
|
|
523
|
+
);
|
|
524
|
+
for (const [accessKeyValue, { secretValue, refs }] of pendingAwsVerifications) {
|
|
525
|
+
const result = verifyAwsCredential(accessKeyValue, secretValue);
|
|
526
|
+
applyPair(refs, result);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (verify) {
|
|
531
|
+
for (const [ruleId, byValue] of pendingSimpleVerifications) {
|
|
532
|
+
if (byValue.size === 0) continue;
|
|
533
|
+
const verifyFn = SIMPLE_VERIFY_FNS[ruleId];
|
|
534
|
+
process.stderr.write(
|
|
535
|
+
`residoo --verify: calling ${SIMPLE_VERIFY_VENDOR_LABEL[ruleId]} for ${byValue.size} ` +
|
|
536
|
+
"token(s) found in this scan. This is a real network request, using the exact " +
|
|
537
|
+
"token found in your transcript, one at a time.\n"
|
|
538
|
+
);
|
|
539
|
+
for (const [value, { refs }] of byValue) {
|
|
540
|
+
const result = await verifyFn(value);
|
|
541
|
+
applyVerifyResult(refs, result);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
399
546
|
const distinctCounts = {};
|
|
400
547
|
for (const [ruleId, set] of distinctByRule) distinctCounts[ruleId] = set.size;
|
|
401
548
|
return { findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount, distinctCounts, unreadableFiles };
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opt-in live credential verification (--verify).
|
|
5
|
+
*
|
|
6
|
+
* Everything else in residoo is detection only: a shape matched a pattern,
|
|
7
|
+
* nothing more, zero network calls, by design (see README's "What it does
|
|
8
|
+
* not do"). This module is the one deliberate exception, and only when a
|
|
9
|
+
* user explicitly passes --verify: it asks the credential's own vendor
|
|
10
|
+
* whether it still authenticates, via whatever free, read-only check that
|
|
11
|
+
* vendor documents for exactly this "is this still alive" question.
|
|
12
|
+
*
|
|
13
|
+
* Two different implementation strategies live in this one file, chosen
|
|
14
|
+
* per vendor by how risky it would be to get wrong:
|
|
15
|
+
*
|
|
16
|
+
* AWS (verifyAwsCredential) shells out to the user's own `aws` CLI rather
|
|
17
|
+
* than hand-rolling AWS SigV4 request signing. Two reasons, not one: first,
|
|
18
|
+
* residoo ships zero runtime dependencies, and a correct SigV4
|
|
19
|
+
* implementation is real, easy-to-get-subtly-wrong cryptographic code this
|
|
20
|
+
* project cannot verify against a live AWS account in CI; a signing bug
|
|
21
|
+
* here would silently report every real key as "invalid," which is actively
|
|
22
|
+
* worse than not verifying at all. Second, the AWS CLI is exactly the
|
|
23
|
+
* client AWS itself maintains and tests against its own service. Every
|
|
24
|
+
* environment variable the aws CLI reads is built from scratch, never
|
|
25
|
+
* inherited from process.env: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY are
|
|
26
|
+
* set to the exact values found in the scan, and AWS_CONFIG_FILE/
|
|
27
|
+
* AWS_SHARED_CREDENTIALS_FILE point at /dev/null so the CLI cannot fall
|
|
28
|
+
* back to the user's own real default profile if the found credential is
|
|
29
|
+
* malformed in some way that would otherwise trigger a fallback.
|
|
30
|
+
*
|
|
31
|
+
* Slack (verifySlackToken) calls the API directly with the built-in fetch
|
|
32
|
+
* instead: unlike AWS, Slack's auth check (auth.test) is a single bearer-
|
|
33
|
+
* token HTTP call with no request signing at all, so there is no signing
|
|
34
|
+
* bug to be worried about, and no CLI most residoo users would already
|
|
35
|
+
* have installed the way they'd have the aws CLI. Direct fetch is both
|
|
36
|
+
* simpler and more portable here; shelling out to a hypothetical "slack
|
|
37
|
+
* CLI" would add a dependency for no safety benefit. This is the pattern
|
|
38
|
+
* for any future vendor: shell out to that vendor's own official CLI only
|
|
39
|
+
* when the auth scheme itself is complex enough to be worth not
|
|
40
|
+
* reimplementing (AWS's SigV4); call directly for a plain bearer token.
|
|
41
|
+
*
|
|
42
|
+
* OpenAI, Anthropic, and GitHub (verifyOpenAiKey, verifyAnthropicKey,
|
|
43
|
+
* verifyGithubToken) share one implementation (verifyByStatusCode): each is
|
|
44
|
+
* a plain GET to a free, side-effect-free, already-authenticated endpoint
|
|
45
|
+
* (that vendor's own "list what I can see" call), where the HTTP status
|
|
46
|
+
* code alone says whether the credential authenticated. Slack needed its
|
|
47
|
+
* own function because auth.test always returns HTTP 200 and signals
|
|
48
|
+
* failure inside the JSON body instead of the status code.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const { spawnSync } = require("child_process");
|
|
52
|
+
|
|
53
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Test-only escape hatch: when RESIDOO_TEST_AWS_CLI is set, every spawnSync
|
|
57
|
+
* call below runs that path instead of "aws" on PATH. Same pattern as
|
|
58
|
+
* keychain.js's RESIDOO_TEST_KEYCHAIN_FILE — crosses a spawned child
|
|
59
|
+
* process boundary (this project's own CLI e2e tests) via env var, so a
|
|
60
|
+
* test can point at a small fixture script and exercise the real spawnSync
|
|
61
|
+
* + argv + env + exit-code + stdout/stderr plumbing without ever spawning
|
|
62
|
+
* the real aws CLI or touching the network. Not a documented flag: no real
|
|
63
|
+
* user has a reason to set this.
|
|
64
|
+
*/
|
|
65
|
+
function awsBinary() {
|
|
66
|
+
return process.env.RESIDOO_TEST_AWS_CLI || "aws";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strip control bytes and cap length: any text here may echo an AWS error message to a terminal. */
|
|
70
|
+
function sanitizeDetail(s) {
|
|
71
|
+
return String(s || "").replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True if an `aws` binary is reachable on PATH and runs. Checked once per
|
|
76
|
+
* scan (not once per credential) so a missing CLI produces one clear
|
|
77
|
+
* message instead of N identical failures.
|
|
78
|
+
*/
|
|
79
|
+
function isAwsCliAvailable(spawnFn = spawnSync) {
|
|
80
|
+
try {
|
|
81
|
+
const r = spawnFn(awsBinary(), ["--version"], {
|
|
82
|
+
timeout: 5000,
|
|
83
|
+
env: { PATH: process.env.PATH || "" },
|
|
84
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
85
|
+
});
|
|
86
|
+
return !r.error && r.status === 0;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ask AWS whether this exact access key id / secret access key pair still
|
|
94
|
+
* authenticates. Returns { status, detail } where status is one of:
|
|
95
|
+
* "active" AWS accepted the credentials (sts:get-caller-identity
|
|
96
|
+
* succeeded, or failed only on a follow-up permission check,
|
|
97
|
+
* which still proves authentication succeeded)
|
|
98
|
+
* "invalid" AWS rejected the credentials outright (revoked, deleted,
|
|
99
|
+
* or never valid)
|
|
100
|
+
* "error" could not determine either way (CLI missing, timeout,
|
|
101
|
+
* network failure, or an AWS error this function does not
|
|
102
|
+
* recognize) — never conflated with "invalid": an inability
|
|
103
|
+
* to check is not evidence the credential is dead.
|
|
104
|
+
* Synchronous: spawnSync itself is synchronous, and calling this from a
|
|
105
|
+
* plain loop (not Promise.all) means verifications run one at a time, not
|
|
106
|
+
* as a burst of concurrent requests against one account.
|
|
107
|
+
*/
|
|
108
|
+
function verifyAwsCredential(accessKeyId, secretAccessKey, { spawnFn = spawnSync, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
109
|
+
let r;
|
|
110
|
+
try {
|
|
111
|
+
r = spawnFn(awsBinary(), ["sts", "get-caller-identity", "--output", "json"], {
|
|
112
|
+
timeout: timeoutMs,
|
|
113
|
+
encoding: "utf-8",
|
|
114
|
+
env: {
|
|
115
|
+
PATH: process.env.PATH || "",
|
|
116
|
+
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
117
|
+
AWS_SECRET_ACCESS_KEY: secretAccessKey,
|
|
118
|
+
AWS_DEFAULT_REGION: "us-east-1",
|
|
119
|
+
AWS_EC2_METADATA_DISABLED: "true",
|
|
120
|
+
AWS_CONFIG_FILE: "/dev/null",
|
|
121
|
+
AWS_SHARED_CREDENTIALS_FILE: "/dev/null",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { status: "error", detail: `aws CLI failed to run (${sanitizeDetail(e && e.message)})` };
|
|
126
|
+
}
|
|
127
|
+
if (r.error) {
|
|
128
|
+
if (r.error.code === "ENOENT") return { status: "error", detail: "aws CLI not found on PATH" };
|
|
129
|
+
return { status: "error", detail: `aws CLI failed to run (${sanitizeDetail(r.error.code || r.error.message)})` };
|
|
130
|
+
}
|
|
131
|
+
if (r.status === 0) {
|
|
132
|
+
return { status: "active", detail: "AWS accepted these credentials (sts:get-caller-identity)" };
|
|
133
|
+
}
|
|
134
|
+
const stderr = String(r.stderr || "");
|
|
135
|
+
if (/InvalidClientTokenId|SignatureDoesNotMatch|UnrecognizedClientException/.test(stderr)) {
|
|
136
|
+
return { status: "invalid", detail: "AWS rejected these credentials" };
|
|
137
|
+
}
|
|
138
|
+
if (/AccessDenied/.test(stderr)) {
|
|
139
|
+
// GetCallerIdentity needs no IAM permissions at all; an AccessDenied
|
|
140
|
+
// here (rare — e.g. an explicit deny policy) still means the
|
|
141
|
+
// credentials themselves authenticated before that policy was checked.
|
|
142
|
+
return { status: "active", detail: "AWS accepted these credentials (denied only on a follow-up permission check)" };
|
|
143
|
+
}
|
|
144
|
+
return { status: "error", detail: `could not verify: ${sanitizeDetail(stderr).slice(0, 120) || `aws exited ${r.status}`}` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Test-only escape hatch, same purpose as RESIDOO_TEST_AWS_CLI above but for
|
|
149
|
+
* an HTTP call instead of a subprocess: when RESIDOO_TEST_SLACK_API_URL is
|
|
150
|
+
* set, verifySlackToken calls that URL instead of Slack's real API, so a
|
|
151
|
+
* test can point at a small local HTTP server and exercise the real fetch +
|
|
152
|
+
* header + JSON-parsing plumbing without ever reaching slack.com.
|
|
153
|
+
*/
|
|
154
|
+
function slackAuthTestUrl() {
|
|
155
|
+
return process.env.RESIDOO_TEST_SLACK_API_URL || "https://slack.com/api/auth.test";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Slack's own documented error codes for auth.test that mean the token
|
|
159
|
+
// itself is dead (revoked, expired, or never valid), not merely rate
|
|
160
|
+
// limited or a transient server problem.
|
|
161
|
+
const SLACK_DEAD_TOKEN_ERRORS = new Set([
|
|
162
|
+
"invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive",
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ask Slack whether this exact token still authenticates, via auth.test
|
|
167
|
+
* (api.slack.com/methods/auth.test): a bearer-token-only call Slack's own
|
|
168
|
+
* docs recommend for checking token validity, needing no scope of its own.
|
|
169
|
+
* Same three-way { status, detail } contract as verifyAwsCredential.
|
|
170
|
+
*/
|
|
171
|
+
async function verifySlackToken(token, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetchFn(slackAuthTestUrl(), {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
177
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
178
|
+
});
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return { status: "error", detail: `could not reach Slack (${sanitizeDetail(e && e.message)})` };
|
|
181
|
+
}
|
|
182
|
+
let body;
|
|
183
|
+
try {
|
|
184
|
+
body = await res.json();
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return { status: "error", detail: `Slack returned a non-JSON response (HTTP ${res.status})` };
|
|
187
|
+
}
|
|
188
|
+
if (body && body.ok === true) {
|
|
189
|
+
return { status: "active", detail: "Slack accepted this token (auth.test)" };
|
|
190
|
+
}
|
|
191
|
+
const err = body && typeof body.error === "string" ? body.error : null;
|
|
192
|
+
if (err && SLACK_DEAD_TOKEN_ERRORS.has(err)) {
|
|
193
|
+
return { status: "invalid", detail: `Slack rejected this token (${sanitizeDetail(err)})` };
|
|
194
|
+
}
|
|
195
|
+
return { status: "error", detail: `could not verify: ${sanitizeDetail(err) || `HTTP ${res.status}`}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shared implementation for every vendor below Slack: a plain GET to a
|
|
200
|
+
* free, side-effect-free, already-authenticated endpoint (each vendor's own
|
|
201
|
+
* "list what I can see" call), where the HTTP status code alone says
|
|
202
|
+
* whether the credential authenticated. 200 is active; 401/403 is a real
|
|
203
|
+
* rejection; anything else (429 rate limited, 5xx, a network failure) is
|
|
204
|
+
* inconclusive, never guessed as either active or invalid. Slack needed its
|
|
205
|
+
* own function above because its auth.test always returns HTTP 200 and
|
|
206
|
+
* signals failure inside the JSON body instead.
|
|
207
|
+
*/
|
|
208
|
+
async function verifyByStatusCode(vendorName, url, buildHeaders, { fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
209
|
+
let res;
|
|
210
|
+
try {
|
|
211
|
+
res = await fetchFn(url, {
|
|
212
|
+
method: "GET",
|
|
213
|
+
headers: buildHeaders(),
|
|
214
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
215
|
+
});
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return { status: "error", detail: `could not reach ${vendorName} (${sanitizeDetail(e && e.message)})` };
|
|
218
|
+
}
|
|
219
|
+
if (res.status === 200) {
|
|
220
|
+
return { status: "active", detail: `${vendorName} accepted this key` };
|
|
221
|
+
}
|
|
222
|
+
if (res.status === 401 || res.status === 403) {
|
|
223
|
+
return { status: "invalid", detail: `${vendorName} rejected this key (HTTP ${res.status})` };
|
|
224
|
+
}
|
|
225
|
+
return { status: "error", detail: `could not verify: HTTP ${res.status} from ${vendorName}` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Test-only escape hatches, same purpose and pattern as
|
|
229
|
+
// RESIDOO_TEST_SLACK_API_URL above: when set, the matching verify function
|
|
230
|
+
// calls that URL instead of the vendor's real one.
|
|
231
|
+
function openAiModelsUrl() {
|
|
232
|
+
return process.env.RESIDOO_TEST_OPENAI_API_URL || "https://api.openai.com/v1/models";
|
|
233
|
+
}
|
|
234
|
+
function anthropicModelsUrl() {
|
|
235
|
+
return process.env.RESIDOO_TEST_ANTHROPIC_API_URL || "https://api.anthropic.com/v1/models";
|
|
236
|
+
}
|
|
237
|
+
function githubUserUrl() {
|
|
238
|
+
return process.env.RESIDOO_TEST_GITHUB_API_URL || "https://api.github.com/user";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** OpenAI: GET /v1/models, a free, read-only call that needs only a valid key, no usage cost. */
|
|
242
|
+
function verifyOpenAiKey(key, opts) {
|
|
243
|
+
return verifyByStatusCode("OpenAI", openAiModelsUrl(), () => ({ Authorization: `Bearer ${key}` }), opts);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Anthropic: GET /v1/models. Two headers, not one, and NOT an Authorization
|
|
248
|
+
* Bearer header: Anthropic's API takes the key as x-api-key, and every
|
|
249
|
+
* request needs an anthropic-version header regardless of endpoint.
|
|
250
|
+
*/
|
|
251
|
+
function verifyAnthropicKey(key, opts) {
|
|
252
|
+
return verifyByStatusCode("Anthropic", anthropicModelsUrl(), () => ({
|
|
253
|
+
"x-api-key": key,
|
|
254
|
+
"anthropic-version": "2023-06-01",
|
|
255
|
+
}), opts);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** GitHub: GET /user with the token, a free, read-only call that needs no scopes. */
|
|
259
|
+
function verifyGithubToken(token, opts) {
|
|
260
|
+
return verifyByStatusCode("GitHub", githubUserUrl(), () => ({ Authorization: `Bearer ${token}` }), opts);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
isAwsCliAvailable, verifyAwsCredential, verifySlackToken,
|
|
265
|
+
verifyOpenAiKey, verifyAnthropicKey, verifyGithubToken,
|
|
266
|
+
};
|