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 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.3 · find secrets your AI coding agent left
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.3 · scanned 2026-01-01 12:00`; `--json`
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.3
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.3
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 phones home is
382
- not a tool you should trust with your secrets. Verify this yourself: the one
383
- `fetch` call in the codebase is in `src/sealvault.js`, reachable only behind
384
- that flag, and sends only encrypted bytes.
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",
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. Findings are
58
- redacted in every output format. Sealing (--seal) writes NEW encrypted
59
- files only. It never modifies or deletes anything that already exists.
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. This is the
129
- only residoo feature that touches the network, it is
130
- off unless you pass it, and only ciphertext is sent.
131
- Needs CLOUDROAM_API_KEY (env) plus:
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);
@@ -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
- if (e.pairedSecretPreview) {
243
- push(paint(c.red + c.bold, ` ⚠ paired with secret ${e.pairedSecretPreview} · full working credential, rotate this one first`));
244
- } else if (e.pairedAccessKeyPreview) {
245
- push(paint(c.red + c.bold, ` ⚠ paired with access key ${e.pairedAccessKeyPreview} · full working credential`));
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 it never checks a provider for whether a key is still live.
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
- // A paired entry is a DEMONSTRATED usable credential (see pairing.js); an
929
- // unpaired access-key-id or secret finding of the same rule and status is
930
- // only a shape that matched a pattern. Sorted first within its status tier
931
- // so a real pair is never the one the display cap (see renderRotationSection)
932
- // pushes into "N more"; the report's own priority order (see the group
933
- // sort just below in renderRotationSection) already applies the same
934
- // "what needs attention most" logic one level up.
935
- const isPaired = (e) => e.pairedSecretPreview !== null || e.pairedAccessKeyPreview !== null;
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
- if (isPaired(a) !== isPaired(b)) return isPaired(a) ? -1 : 1;
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
- record({ id: "aws_secret_access_key_paired", label: "AWS Secret Access Key (paired with access key id)" },
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
- record(rule, m[0], relFile, file, lineNo,
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 } : undefined);
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 };