residoo 0.4.3 → 0.4.4
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 +30 -8
- package/package.json +1 -1
- package/src/cli.js +28 -8
- package/src/jwtExpiry.js +48 -0
- package/src/report.js +31 -6
- package/src/rotation.js +40 -10
- package/src/scan.js +88 -5
- package/src/verify.js +132 -0
package/README.md
CHANGED
|
@@ -117,6 +117,24 @@ 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): for an AWS access key
|
|
127
|
+
id found paired with its secret, asks AWS itself whether the pair still
|
|
128
|
+
authenticates via `sts:get-caller-identity`, the same free, read-only,
|
|
129
|
+
permission-less call the AWS CLI and tools like aws-vault use for exactly
|
|
130
|
+
this. Shells out to your own `aws` CLI rather than reimplementing AWS
|
|
131
|
+
request signing (residoo ships zero runtime dependencies, and a subtly
|
|
132
|
+
wrong signing implementation would silently report real keys as invalid,
|
|
133
|
+
worse than not checking). A verified-active pair is escalated to "rotate
|
|
134
|
+
immediately"; a verified-invalid one is reported as already dead, no
|
|
135
|
+
action needed, and sorted out of the way. Off by default; every environment
|
|
136
|
+
variable the `aws` CLI reads is built from scratch, never inherited, so it
|
|
137
|
+
can never fall back to your own real AWS profile. See `src/verify.js`.
|
|
120
138
|
- With `--include-noisy`, filters the broad generic-secret rules by how
|
|
121
139
|
machine-random the matched value actually looks (a lightweight, offline
|
|
122
140
|
approximation of BPE-tokenization rarity checks): ordinary English, a
|
|
@@ -128,10 +146,10 @@ won't be built into the tool that writes it.
|
|
|
128
146
|
preview, never the real value, including in `--json` mode. A decoded or
|
|
129
147
|
rejoined secret is redacted exactly like a plain one.
|
|
130
148
|
- On an interactive terminal, prints who it is and where it lives before
|
|
131
|
-
scanning starts (`residoo v0.4.
|
|
149
|
+
scanning starts (`residoo v0.4.4 · find secrets your AI coding agent left
|
|
132
150
|
on disk` plus the repo URL), then a live spinner naming the current file
|
|
133
151
|
as it scans. Every report also opens with the exact version and timestamp
|
|
134
|
-
it was run with (`residoo v0.4.
|
|
152
|
+
it was run with (`residoo v0.4.4 · scanned 2026-01-01 12:00`; `--json`
|
|
135
153
|
carries the same as `residooVersion`/`scannedAt`), so a report pasted or
|
|
136
154
|
screenshotted later never leaves you guessing which build produced it.
|
|
137
155
|
When there are findings, the report closes with a "Next steps" pointer to
|
|
@@ -355,7 +373,7 @@ As a GitHub Action (this repository doubles as a composite action):
|
|
|
355
373
|
```yaml
|
|
356
374
|
steps:
|
|
357
375
|
- uses: actions/checkout@v4
|
|
358
|
-
- uses: dandovdub/residoo@v0.4.
|
|
376
|
+
- uses: dandovdub/residoo@v0.4.4
|
|
359
377
|
```
|
|
360
378
|
|
|
361
379
|
As a pre-commit hook:
|
|
@@ -363,7 +381,7 @@ As a pre-commit hook:
|
|
|
363
381
|
```yaml
|
|
364
382
|
repos:
|
|
365
383
|
- repo: https://github.com/dandovdub/residoo
|
|
366
|
-
rev: v0.4.
|
|
384
|
+
rev: v0.4.4
|
|
367
385
|
hooks:
|
|
368
386
|
- id: residoo
|
|
369
387
|
```
|
|
@@ -378,10 +396,14 @@ documented in [docs/ci.md](docs/ci.md).
|
|
|
378
396
|
## What it does not do
|
|
379
397
|
|
|
380
398
|
- **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
|
-
`fetch` call in the codebase is in `src/sealvault.js`,
|
|
384
|
-
|
|
399
|
+
explicitly pass `--upload-cloudroam` or `--verify`.** A secret scanner that
|
|
400
|
+
phones home is not a tool you should trust with your secrets. Verify this
|
|
401
|
+
yourself: the one `fetch` call in the codebase is in `src/sealvault.js`,
|
|
402
|
+
reachable only behind `--upload-cloudroam`, and sends only encrypted bytes.
|
|
403
|
+
`--verify` is the other opt-in exception, and makes no `fetch` call at all:
|
|
404
|
+
it shells out to your own `aws` CLI with the exact AWS credential a scan
|
|
405
|
+
found, asking AWS's own `sts:get-caller-identity` whether it still
|
|
406
|
+
authenticates (see `src/verify.js`). Neither runs unless you pass the flag.
|
|
385
407
|
- **Nothing destructive, ever.** Scanning is read-only. Sealing creates *new*
|
|
386
408
|
files and modifies or deletes nothing, not even the plaintext it just
|
|
387
409
|
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.4",
|
|
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,11 @@ 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 AWS itself whether a found AWS credential still
|
|
60
|
+
authenticates; see below. Sealing (--seal) writes NEW encrypted files
|
|
61
|
+
only. It never modifies or deletes anything that already exists.
|
|
60
62
|
|
|
61
63
|
Usage:
|
|
62
64
|
residoo scan [options]
|
|
@@ -96,6 +98,17 @@ Scan options:
|
|
|
96
98
|
--no-integrity skip the integrity checks (planted hooks, dropper
|
|
97
99
|
files, auto-run tasks, hidden Unicode)
|
|
98
100
|
--no-color disable ANSI colour
|
|
101
|
+
--verify for every AWS access key id found paired with its
|
|
102
|
+
secret (see Rotation below), ask AWS itself
|
|
103
|
+
whether the pair still authenticates, via
|
|
104
|
+
sts:get-caller-identity, using the exact
|
|
105
|
+
credential found in your transcript. THIS MAKES A
|
|
106
|
+
REAL NETWORK CALL TO AWS. Off by default. Needs
|
|
107
|
+
the aws CLI on PATH; residoo shells out to it
|
|
108
|
+
rather than reimplementing AWS request signing.
|
|
109
|
+
Only AWS is covered today. A verified-invalid
|
|
110
|
+
credential is reported as already dead, not as
|
|
111
|
+
something to rotate.
|
|
99
112
|
|
|
100
113
|
Rotation:
|
|
101
114
|
residoo explain <rule-id> full rotation runbook for one detection rule
|
|
@@ -125,10 +138,11 @@ Seal options (used with scan):
|
|
|
125
138
|
only, unlike a passphrase, it is not portable to
|
|
126
139
|
another machine.
|
|
127
140
|
--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
|
|
141
|
+
--upload-cloudroam ALSO upload the sealed vault to CloudRoam. One of two
|
|
142
|
+
opt-in features that touch the network (--verify
|
|
143
|
+
above is the other); off unless you pass it, and
|
|
144
|
+
only ciphertext is sent. Needs CLOUDROAM_API_KEY
|
|
145
|
+
(env) plus:
|
|
132
146
|
--connector <id> CloudRoam connector id for the destination
|
|
133
147
|
--bucket <name> destination bucket
|
|
134
148
|
--prefix <p> optional key prefix inside the bucket
|
|
@@ -434,6 +448,12 @@ async function main(argv) {
|
|
|
434
448
|
const includeSuppressed = args.includes("--include-suppressed");
|
|
435
449
|
const failOnFind = args.includes("--fail-on-find");
|
|
436
450
|
const allowAcked = args.includes("--allow-acked");
|
|
451
|
+
// The one flag that makes residoo do something other than read local
|
|
452
|
+
// files: --verify shells out to the user's own `aws` CLI with any AWS
|
|
453
|
+
// access key + paired secret this scan finds, to ask AWS itself whether
|
|
454
|
+
// they still authenticate (see verify.js). Off by default; every other
|
|
455
|
+
// flag here only changes what is READ or how it is DISPLAYED.
|
|
456
|
+
const verifyAws = args.includes("--verify");
|
|
437
457
|
|
|
438
458
|
// --project [dir]: the dir is optional (CI passes ".", a bare --project
|
|
439
459
|
// means the current directory). null means machine mode.
|
|
@@ -543,7 +563,7 @@ async function main(argv) {
|
|
|
543
563
|
}
|
|
544
564
|
|
|
545
565
|
const progress = makeProgressReporter(noColor);
|
|
546
|
-
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
|
|
566
|
+
const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress, verifyAws });
|
|
547
567
|
progress.stop();
|
|
548
568
|
const integrity = wantsIntegrity ? runIntegrity() : null;
|
|
549
569
|
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
|
@@ -229,8 +229,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
229
229
|
// the same type are shown as two separate lines on purpose, not
|
|
230
230
|
// collapsed on a guess.
|
|
231
231
|
const lastSeenNote = typeof e.lastSeenMs === "number" ? `last seen ~${ageDays(e.lastSeenMs)}d ago` : null;
|
|
232
|
+
// The one credential type residoo can say "still valid" about with
|
|
233
|
+
// zero network calls: a JWT's own exp claim, inside its signature
|
|
234
|
+
// (see jwtExpiry.js). Not proof it is accepted anywhere (residoo
|
|
235
|
+
// never checks the signature), only that the token's own claimed
|
|
236
|
+
// window has or has not passed.
|
|
237
|
+
const jwtExpiryNote = typeof e.jwtExpiresAtMs === "number"
|
|
238
|
+
? (e.jwtExpiresAtMs < Date.now()
|
|
239
|
+
? `expired ${new Date(e.jwtExpiresAtMs).toISOString().slice(0, 10)}`
|
|
240
|
+
: `valid until ${new Date(e.jwtExpiresAtMs).toISOString().slice(0, 10)}`)
|
|
241
|
+
: null;
|
|
232
242
|
push(` ${STATUS_TAG[e.status]} ${e.preview} ${paint(c.dim, fileNote)}` +
|
|
233
|
-
(lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : "")
|
|
243
|
+
(lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : "") +
|
|
244
|
+
(jwtExpiryNote ? ` ${paint(c.dim, jwtExpiryNote)}` : ""));
|
|
234
245
|
// An access key id and its AWS secret are each meaningless alone (see
|
|
235
246
|
// pairing.js): the id names WHICH key, the secret authenticates it,
|
|
236
247
|
// and an attacker needs both. Called out in red/bold, the same
|
|
@@ -238,11 +249,25 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
|
|
|
238
249
|
// line under it is a demonstrated full working credential, not just a
|
|
239
250
|
// shape that matched a pattern; a plain access-key-id or secret finding
|
|
240
251
|
// with NO pairing note is still worth checking, but nothing here
|
|
241
|
-
// proves it is actually exploitable on its own.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
252
|
+
// proves it is actually exploitable on its own. --verify (see
|
|
253
|
+
// verify.js) can strengthen this to an outright confirmation, or
|
|
254
|
+
// downgrade it to "already dead": both come from a real answer from
|
|
255
|
+
// AWS, not a guess, so they get their own wording rather than folding
|
|
256
|
+
// into the generic pairing line.
|
|
257
|
+
if (e.pairedSecretPreview || e.pairedAccessKeyPreview) {
|
|
258
|
+
const otherHalf = e.pairedSecretPreview
|
|
259
|
+
? `paired with secret ${e.pairedSecretPreview}`
|
|
260
|
+
: `paired with access key ${e.pairedAccessKeyPreview}`;
|
|
261
|
+
if (e.awsVerified === "active") {
|
|
262
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · VERIFIED ACTIVE: AWS accepted these credentials moments ago, rotate immediately`));
|
|
263
|
+
} else if (e.awsVerified === "invalid") {
|
|
264
|
+
push(paint(c.green, ` ✓ ${otherHalf} · already inactive: AWS rejected these credentials, no rotation needed`));
|
|
265
|
+
} else if (e.awsVerified === "error") {
|
|
266
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`) +
|
|
267
|
+
paint(c.dim, ` (could not verify: ${e.awsVerifiedDetail || "unknown error"})`));
|
|
268
|
+
} else {
|
|
269
|
+
push(paint(c.red + c.bold, ` ⚠ ${otherHalf} · full working credential, rotate this one first`));
|
|
270
|
+
}
|
|
246
271
|
}
|
|
247
272
|
if (e.status === "acked") {
|
|
248
273
|
push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} · ${e.fingerprint}`));
|
package/src/rotation.js
CHANGED
|
@@ -897,6 +897,18 @@ 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 AWS itself accepted this
|
|
907
|
+
// exact credential. null unless the scan was run with --verify AND
|
|
908
|
+
// this value is one half of a demonstrated pair; residoo makes no
|
|
909
|
+
// network calls otherwise.
|
|
910
|
+
awsVerified: null,
|
|
911
|
+
awsVerifiedDetail: null,
|
|
900
912
|
};
|
|
901
913
|
byFp.set(st.fingerprint, e);
|
|
902
914
|
}
|
|
@@ -909,7 +921,9 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
909
921
|
// honest, locally-derivable signal for "how stale is this." NOT proof a
|
|
910
922
|
// credential was rotated or revoked, only that residoo hasn't seen it
|
|
911
923
|
// paste anywhere more recently than this. residoo makes no network
|
|
912
|
-
// calls, so
|
|
924
|
+
// calls in the default path, so this alone never checks a provider for
|
|
925
|
+
// whether a key is still live (see awsVerified above for the opt-in
|
|
926
|
+
// exception, and jwtExpiresAtMs for the zero-network JWT case).
|
|
913
927
|
if (typeof f.fileMTimeMs === "number" && (e.lastSeenMs === null || f.fileMTimeMs > e.lastSeenMs)) {
|
|
914
928
|
e.lastSeenMs = f.fileMTimeMs;
|
|
915
929
|
}
|
|
@@ -923,19 +937,35 @@ function renderRotation(findings, acks, dismissed = {}) {
|
|
|
923
937
|
if (e.pairedAccessKeyPreview === null && typeof f.pairedAccessKeyPreview === "string") {
|
|
924
938
|
e.pairedAccessKeyPreview = f.pairedAccessKeyPreview;
|
|
925
939
|
}
|
|
940
|
+
if (e.jwtExpiresAtMs === null && typeof f.jwtExpiresAtMs === "number") {
|
|
941
|
+
e.jwtExpiresAtMs = f.jwtExpiresAtMs;
|
|
942
|
+
}
|
|
943
|
+
if (e.awsVerified === null && typeof f.awsVerified === "string") {
|
|
944
|
+
e.awsVerified = f.awsVerified;
|
|
945
|
+
e.awsVerifiedDetail = typeof f.awsVerifiedDetail === "string" ? f.awsVerifiedDetail : null;
|
|
946
|
+
}
|
|
926
947
|
}
|
|
927
948
|
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// pushes into "N more"
|
|
933
|
-
// sort
|
|
934
|
-
//
|
|
935
|
-
|
|
949
|
+
// Within a status tier, order by how demonstrated-urgent an entry is, not
|
|
950
|
+
// just its rule id: a real pair (see pairing.js) is a DEMONSTRATED usable
|
|
951
|
+
// credential, and --verify confirming AWS still accepts it is stronger
|
|
952
|
+
// evidence still; either way this entry must never be the one the display
|
|
953
|
+
// cap (see renderRotationSection) pushes into "N more." Proven dead
|
|
954
|
+
// credentials sort the other direction, LOWER than an ordinary unpaired
|
|
955
|
+
// finding: --verify confirming AWS rejected it, or a JWT's own signed exp
|
|
956
|
+
// claim already in the past, is proof this specific value needs no
|
|
957
|
+
// action, not just an absence of proof it does.
|
|
958
|
+
const priorityScore = (e) => {
|
|
959
|
+
if (e.awsVerified === "active") return -2;
|
|
960
|
+
if (e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null) return -1;
|
|
961
|
+
if (e.awsVerified === "invalid") return 1;
|
|
962
|
+
if (e.jwtExpiresAtMs !== null && e.jwtExpiresAtMs < Date.now()) return 1;
|
|
963
|
+
return 0;
|
|
964
|
+
};
|
|
936
965
|
const entries = [...byFp.values()].sort((a, b) => {
|
|
937
966
|
if (a.status !== b.status) return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
938
|
-
|
|
967
|
+
const pa = priorityScore(a), pb = priorityScore(b);
|
|
968
|
+
if (pa !== pb) return pa - pb;
|
|
939
969
|
if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
|
|
940
970
|
return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
941
971
|
});
|
package/src/scan.js
CHANGED
|
@@ -5,6 +5,14 @@ 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 { isAwsCliAvailable, verifyAwsCredential } = require("./verify");
|
|
10
|
+
|
|
11
|
+
// Never verify more than this many distinct AWS pairs in one scan: a
|
|
12
|
+
// pathological transcript with dozens of distinct paired credentials should
|
|
13
|
+
// not turn --verify into a long burst of outbound AWS calls. Real scans see
|
|
14
|
+
// 0-2; this is a backstop, not the expected path.
|
|
15
|
+
const MAX_AWS_VERIFICATIONS = 10;
|
|
8
16
|
|
|
9
17
|
// Rule ids that findPairedSecret's window search applies to (see pairing.js):
|
|
10
18
|
// AWS access key ids and STS session tokens both pair with the same shape
|
|
@@ -127,7 +135,7 @@ function safeName(file) { return path.basename(file); }
|
|
|
127
135
|
* absolute path can itself carry a username or a project name the rest of
|
|
128
136
|
* this report is careful never to print.
|
|
129
137
|
*/
|
|
130
|
-
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null } = {}) {
|
|
138
|
+
async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verifyAws = false } = {}) {
|
|
131
139
|
const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
|
|
132
140
|
// The decode pass (see decode.js) only applies high-confidence, vendor-
|
|
133
141
|
// prefixed rules to decoded bytes: random binary that decodes to printable
|
|
@@ -146,11 +154,24 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
146
154
|
// browser-testing run is one leak, not ten) — never written to a report,
|
|
147
155
|
// never leaves this function.
|
|
148
156
|
const distinctByRule = new Map();
|
|
157
|
+
// --verify only (see verify.js): accessKeyValue -> { secretValue, refs }.
|
|
158
|
+
// Keyed by the RAW access key so the map itself dedupes distinct
|
|
159
|
+
// credentials for the AWS call (one call per key, no matter how many
|
|
160
|
+
// times it was echoed) while `refs` accumulates EVERY occurrence's
|
|
161
|
+
// finding-object pair, so the result reaches all of them, not only the
|
|
162
|
+
// first: an access key re-echoed across several lines gets several
|
|
163
|
+
// finding objects, and every one of them needs the same answer. Like
|
|
164
|
+
// distinctByRule above, this lives only for the duration of this scan()
|
|
165
|
+
// call; nothing in it is ever written to a finding until verification has
|
|
166
|
+
// REPLACED the raw values with a status string.
|
|
167
|
+
const pendingAwsVerifications = new Map();
|
|
149
168
|
|
|
150
169
|
// One place raw matched text turns into a recorded finding: counts the
|
|
151
170
|
// distinct value and pushes the redacted record. `extra` carries the
|
|
152
171
|
// encoding / split markers for the decode and boundary passes; the raw pass
|
|
153
|
-
// passes none.
|
|
172
|
+
// passes none. Returns the finding object itself so a caller (the pairing
|
|
173
|
+
// and --verify logic) can attach more fields onto it later, after the
|
|
174
|
+
// fields that need real work (an AWS API round-trip) finish.
|
|
154
175
|
const record = (rule, value, relFile, file, lineNo, mtimeMs, confidence, suppressedReason, extra) => {
|
|
155
176
|
if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
|
|
156
177
|
distinctByRule.get(rule.id).add(value);
|
|
@@ -166,6 +187,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
166
187
|
fileMTimeMs: mtimeMs,
|
|
167
188
|
...(extra || {}),
|
|
168
189
|
});
|
|
190
|
+
return findings[findings.length - 1];
|
|
169
191
|
};
|
|
170
192
|
|
|
171
193
|
// One suppression policy for all three passes (raw, decoded, boundary).
|
|
@@ -221,6 +243,8 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
221
243
|
// next to it in the transcript, not just that a secret exists
|
|
222
244
|
// somewhere in the scan.
|
|
223
245
|
let pairedSecretPreview = null;
|
|
246
|
+
let secretFinding = null;
|
|
247
|
+
let rawPairedSecret = null;
|
|
224
248
|
if (!suppressedReason && AWS_PAIR_RULE_IDS.has(rule.id)) {
|
|
225
249
|
const paired = findPairedSecret(line, m[0], m.index);
|
|
226
250
|
if (paired) {
|
|
@@ -229,18 +253,42 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
229
253
|
suppressedCount++;
|
|
230
254
|
} else {
|
|
231
255
|
pairedSecretPreview = redact(paired);
|
|
232
|
-
|
|
256
|
+
rawPairedSecret = paired;
|
|
257
|
+
secretFinding = record({ id: "aws_secret_access_key_paired", label: "AWS Secret Access Key (paired with access key id)" },
|
|
233
258
|
paired, relFile, file, lineNo, mtimeMs,
|
|
234
259
|
pairedSuppressedReason ? "low" : "high", pairedSuppressedReason,
|
|
235
260
|
{ paired: true, pairedAccessKeyPreview: redact(m[0]) });
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
263
|
}
|
|
239
|
-
|
|
264
|
+
// Local, offline JWT expiry (see jwtExpiry.js): only ever reads
|
|
265
|
+
// the `exp` claim out of the decoded payload, nothing else, and
|
|
266
|
+
// only for the unsuppressed default `jwt` rule, since a
|
|
267
|
+
// suppressed placeholder/example match is not worth decoding.
|
|
268
|
+
const jwtExtra = (!suppressedReason && rule.id === "jwt")
|
|
269
|
+
? { jwtExpiresAtMs: decodeJwtExpiryMs(m[0]) }
|
|
270
|
+
: null;
|
|
271
|
+
const akiaFinding = record(rule, m[0], relFile, file, lineNo,
|
|
240
272
|
mtimeMs,
|
|
241
273
|
resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
|
|
242
274
|
suppressedReason,
|
|
243
|
-
pairedSecretPreview ? { pairedSecretPreview } :
|
|
275
|
+
{ ...(pairedSecretPreview ? { pairedSecretPreview } : {}), ...(jwtExtra || {}) });
|
|
276
|
+
|
|
277
|
+
// --verify only, and only for a DEMONSTRATED pair (both halves
|
|
278
|
+
// present, neither suppressed): queue it for the verification pass
|
|
279
|
+
// that runs once, after every file has been scanned (see below).
|
|
280
|
+
// The Map key dedupes the actual AWS call to one per distinct
|
|
281
|
+
// credential; `refs` still grows on every occurrence, so a key
|
|
282
|
+
// re-echoed across several lines gets several finding objects, and
|
|
283
|
+
// the eventual result is applied to every one of them, not only
|
|
284
|
+
// the first.
|
|
285
|
+
if (verifyAws && secretFinding && rawPairedSecret) {
|
|
286
|
+
if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size < MAX_AWS_VERIFICATIONS) {
|
|
287
|
+
pendingAwsVerifications.set(m[0], { secretValue: rawPairedSecret, refs: [] });
|
|
288
|
+
}
|
|
289
|
+
const entry = pendingAwsVerifications.get(m[0]);
|
|
290
|
+
if (entry) entry.refs.push({ akiaFinding, secretFinding });
|
|
291
|
+
}
|
|
244
292
|
}
|
|
245
293
|
if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
|
|
246
294
|
}
|
|
@@ -396,6 +444,41 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
|
|
|
396
444
|
if (sourceScannedAnything) sourcesScanned.push(source.id());
|
|
397
445
|
}
|
|
398
446
|
|
|
447
|
+
// --verify: runs once, here, after every file has been scanned, never
|
|
448
|
+
// interleaved with the matching pass above. A real network call per
|
|
449
|
+
// distinct pairing, one at a time (not concurrent), so this is the one
|
|
450
|
+
// place a scan's wall-clock time depends on something other than disk
|
|
451
|
+
// I/O; that tradeoff only exists when a caller explicitly asked for it.
|
|
452
|
+
if (verifyAws && pendingAwsVerifications.size > 0) {
|
|
453
|
+
const applyResult = (refs, result) => {
|
|
454
|
+
for (const ref of refs) {
|
|
455
|
+
ref.akiaFinding.awsVerified = result.status;
|
|
456
|
+
ref.akiaFinding.awsVerifiedDetail = result.detail;
|
|
457
|
+
ref.secretFinding.awsVerified = result.status;
|
|
458
|
+
ref.secretFinding.awsVerifiedDetail = result.detail;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
if (!isAwsCliAvailable()) {
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
"residoo --verify: the aws CLI was not found on PATH, so the " +
|
|
464
|
+
`${pendingAwsVerifications.size} AWS credential(s) found in this scan could not be checked. ` +
|
|
465
|
+
"Install it (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) to use --verify.\n"
|
|
466
|
+
);
|
|
467
|
+
const result = { status: "error", detail: "aws CLI not found on PATH" };
|
|
468
|
+
for (const { refs } of pendingAwsVerifications.values()) applyResult(refs, result);
|
|
469
|
+
} else {
|
|
470
|
+
process.stderr.write(
|
|
471
|
+
`residoo --verify: calling AWS sts:get-caller-identity for ${pendingAwsVerifications.size} ` +
|
|
472
|
+
"credential(s) found in this scan. This is a real network request to AWS, using the exact " +
|
|
473
|
+
"credential found in your transcript, one at a time.\n"
|
|
474
|
+
);
|
|
475
|
+
for (const [accessKeyValue, { secretValue, refs }] of pendingAwsVerifications) {
|
|
476
|
+
const result = verifyAwsCredential(accessKeyValue, secretValue);
|
|
477
|
+
applyResult(refs, result);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
399
482
|
const distinctCounts = {};
|
|
400
483
|
for (const [ruleId, set] of distinctByRule) distinctCounts[ruleId] = set.size;
|
|
401
484
|
return { findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount, distinctCounts, unreadableFiles };
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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 takes an AWS access key id and its
|
|
10
|
+
* paired secret (see pairing.js) and asks AWS itself whether they still
|
|
11
|
+
* authenticate, via sts:get-caller-identity, the same free, read-only,
|
|
12
|
+
* permission-less call AWS's own docs and tools like the AWS CLI and
|
|
13
|
+
* aws-vault use for exactly this "is this credential still alive" check.
|
|
14
|
+
*
|
|
15
|
+
* Implemented by shelling out to the user's own `aws` CLI rather than
|
|
16
|
+
* hand-rolling AWS SigV4 request signing. Two reasons, not one: first,
|
|
17
|
+
* residoo ships zero runtime dependencies, and a correct SigV4
|
|
18
|
+
* implementation is real, easy-to-get-subtly-wrong cryptographic code this
|
|
19
|
+
* project cannot verify against a live AWS account in CI; a signing bug
|
|
20
|
+
* here would silently report every real key as "invalid," which is actively
|
|
21
|
+
* worse than not verifying at all. Second, the AWS CLI is exactly the
|
|
22
|
+
* client AWS itself maintains and tests against its own service, so
|
|
23
|
+
* whether a credential is live is answered by AWS's own tooling, not a
|
|
24
|
+
* reimplementation of it.
|
|
25
|
+
*
|
|
26
|
+
* Every environment variable the aws CLI reads is built from scratch here,
|
|
27
|
+
* never inherited from process.env: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
|
|
28
|
+
* are set to the exact values found in the scan, and AWS_CONFIG_FILE/
|
|
29
|
+
* AWS_SHARED_CREDENTIALS_FILE point at /dev/null so the CLI cannot fall
|
|
30
|
+
* back to the user's own real default profile if the found credential is
|
|
31
|
+
* malformed in some way that would otherwise trigger a fallback. A failed
|
|
32
|
+
* verification must never silently become "verified as the operator's own
|
|
33
|
+
* real AWS account" instead.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const { spawnSync } = require("child_process");
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Test-only escape hatch: when RESIDOO_TEST_AWS_CLI is set, every spawnSync
|
|
42
|
+
* call below runs that path instead of "aws" on PATH. Same pattern as
|
|
43
|
+
* keychain.js's RESIDOO_TEST_KEYCHAIN_FILE — crosses a spawned child
|
|
44
|
+
* process boundary (this project's own CLI e2e tests) via env var, so a
|
|
45
|
+
* test can point at a small fixture script and exercise the real spawnSync
|
|
46
|
+
* + argv + env + exit-code + stdout/stderr plumbing without ever spawning
|
|
47
|
+
* the real aws CLI or touching the network. Not a documented flag: no real
|
|
48
|
+
* user has a reason to set this.
|
|
49
|
+
*/
|
|
50
|
+
function awsBinary() {
|
|
51
|
+
return process.env.RESIDOO_TEST_AWS_CLI || "aws";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Strip control bytes and cap length: any text here may echo an AWS error message to a terminal. */
|
|
55
|
+
function sanitizeDetail(s) {
|
|
56
|
+
return String(s || "").replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* True if an `aws` binary is reachable on PATH and runs. Checked once per
|
|
61
|
+
* scan (not once per credential) so a missing CLI produces one clear
|
|
62
|
+
* message instead of N identical failures.
|
|
63
|
+
*/
|
|
64
|
+
function isAwsCliAvailable(spawnFn = spawnSync) {
|
|
65
|
+
try {
|
|
66
|
+
const r = spawnFn(awsBinary(), ["--version"], {
|
|
67
|
+
timeout: 5000,
|
|
68
|
+
env: { PATH: process.env.PATH || "" },
|
|
69
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
70
|
+
});
|
|
71
|
+
return !r.error && r.status === 0;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ask AWS whether this exact access key id / secret access key pair still
|
|
79
|
+
* authenticates. Returns { status, detail } where status is one of:
|
|
80
|
+
* "active" AWS accepted the credentials (sts:get-caller-identity
|
|
81
|
+
* succeeded, or failed only on a follow-up permission check,
|
|
82
|
+
* which still proves authentication succeeded)
|
|
83
|
+
* "invalid" AWS rejected the credentials outright (revoked, deleted,
|
|
84
|
+
* or never valid)
|
|
85
|
+
* "error" could not determine either way (CLI missing, timeout,
|
|
86
|
+
* network failure, or an AWS error this function does not
|
|
87
|
+
* recognize) — never conflated with "invalid": an inability
|
|
88
|
+
* to check is not evidence the credential is dead.
|
|
89
|
+
* Synchronous: spawnSync itself is synchronous, and calling this from a
|
|
90
|
+
* plain loop (not Promise.all) means verifications run one at a time, not
|
|
91
|
+
* as a burst of concurrent requests against one account.
|
|
92
|
+
*/
|
|
93
|
+
function verifyAwsCredential(accessKeyId, secretAccessKey, { spawnFn = spawnSync, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
94
|
+
let r;
|
|
95
|
+
try {
|
|
96
|
+
r = spawnFn(awsBinary(), ["sts", "get-caller-identity", "--output", "json"], {
|
|
97
|
+
timeout: timeoutMs,
|
|
98
|
+
encoding: "utf-8",
|
|
99
|
+
env: {
|
|
100
|
+
PATH: process.env.PATH || "",
|
|
101
|
+
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
102
|
+
AWS_SECRET_ACCESS_KEY: secretAccessKey,
|
|
103
|
+
AWS_DEFAULT_REGION: "us-east-1",
|
|
104
|
+
AWS_EC2_METADATA_DISABLED: "true",
|
|
105
|
+
AWS_CONFIG_FILE: "/dev/null",
|
|
106
|
+
AWS_SHARED_CREDENTIALS_FILE: "/dev/null",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { status: "error", detail: `aws CLI failed to run (${sanitizeDetail(e && e.message)})` };
|
|
111
|
+
}
|
|
112
|
+
if (r.error) {
|
|
113
|
+
if (r.error.code === "ENOENT") return { status: "error", detail: "aws CLI not found on PATH" };
|
|
114
|
+
return { status: "error", detail: `aws CLI failed to run (${sanitizeDetail(r.error.code || r.error.message)})` };
|
|
115
|
+
}
|
|
116
|
+
if (r.status === 0) {
|
|
117
|
+
return { status: "active", detail: "AWS accepted these credentials (sts:get-caller-identity)" };
|
|
118
|
+
}
|
|
119
|
+
const stderr = String(r.stderr || "");
|
|
120
|
+
if (/InvalidClientTokenId|SignatureDoesNotMatch|UnrecognizedClientException/.test(stderr)) {
|
|
121
|
+
return { status: "invalid", detail: "AWS rejected these credentials" };
|
|
122
|
+
}
|
|
123
|
+
if (/AccessDenied/.test(stderr)) {
|
|
124
|
+
// GetCallerIdentity needs no IAM permissions at all; an AccessDenied
|
|
125
|
+
// here (rare — e.g. an explicit deny policy) still means the
|
|
126
|
+
// credentials themselves authenticated before that policy was checked.
|
|
127
|
+
return { status: "active", detail: "AWS accepted these credentials (denied only on a follow-up permission check)" };
|
|
128
|
+
}
|
|
129
|
+
return { status: "error", detail: `could not verify: ${sanitizeDetail(stderr).slice(0, 120) || `aws exited ${r.status}`}` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { isAwsCliAvailable, verifyAwsCredential };
|