residoo 0.4.2 → 0.4.3

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
@@ -110,7 +110,13 @@ won't be built into the tool that writes it.
110
110
  a rule on its own) with a nearby confirmed access key id, and reports both
111
111
  at high confidence: the pairing is the vendor-specific signal, not the
112
112
  shape alone. Ambiguous pairings (more than one candidate nearby) are
113
- reported as nothing rather than a guess. See `src/pairing.js`.
113
+ reported as nothing rather than a guess. See `src/pairing.js`. An access key
114
+ id alone cannot authenticate anything; it takes the paired secret too. So
115
+ when a scan turns up several access-key-id findings, the one with an actual
116
+ secret sitting next to it in the transcript is called out in red as a
117
+ demonstrated usable credential and sorted to the top of its group in the
118
+ Rotation section, ahead of the ones that are, on their own, not yet proven
119
+ exploitable.
114
120
  - With `--include-noisy`, filters the broad generic-secret rules by how
115
121
  machine-random the matched value actually looks (a lightweight, offline
116
122
  approximation of BPE-tokenization rarity checks): ordinary English, a
@@ -122,10 +128,10 @@ won't be built into the tool that writes it.
122
128
  preview, never the real value, including in `--json` mode. A decoded or
123
129
  rejoined secret is redacted exactly like a plain one.
124
130
  - On an interactive terminal, prints who it is and where it lives before
125
- scanning starts (`residoo v0.4.2 · find secrets your AI coding agent left
131
+ scanning starts (`residoo v0.4.3 · find secrets your AI coding agent left
126
132
  on disk` plus the repo URL), then a live spinner naming the current file
127
133
  as it scans. Every report also opens with the exact version and timestamp
128
- it was run with (`residoo v0.4.2 · scanned 2026-01-01 12:00`; `--json`
134
+ it was run with (`residoo v0.4.3 · scanned 2026-01-01 12:00`; `--json`
129
135
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
130
136
  screenshotted later never leaves you guessing which build produced it.
131
137
  When there are findings, the report closes with a "Next steps" pointer to
@@ -349,7 +355,7 @@ As a GitHub Action (this repository doubles as a composite action):
349
355
  ```yaml
350
356
  steps:
351
357
  - uses: actions/checkout@v4
352
- - uses: dandovdub/residoo@v0.4.2
358
+ - uses: dandovdub/residoo@v0.4.3
353
359
  ```
354
360
 
355
361
  As a pre-commit hook:
@@ -357,7 +363,7 @@ As a pre-commit hook:
357
363
  ```yaml
358
364
  repos:
359
365
  - repo: https://github.com/dandovdub/residoo
360
- rev: v0.4.2
366
+ rev: v0.4.3
361
367
  hooks:
362
368
  - id: residoo
363
369
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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/report.js CHANGED
@@ -231,6 +231,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
231
231
  const lastSeenNote = typeof e.lastSeenMs === "number" ? `last seen ~${ageDays(e.lastSeenMs)}d ago` : null;
232
232
  push(` ${STATUS_TAG[e.status]} ${e.preview} ${paint(c.dim, fileNote)}` +
233
233
  (lastSeenNote ? ` ${paint(c.dim, lastSeenNote)}` : ""));
234
+ // An access key id and its AWS secret are each meaningless alone (see
235
+ // pairing.js): the id names WHICH key, the secret authenticates it,
236
+ // and an attacker needs both. Called out in red/bold, the same
237
+ // treatment as the ordering advisory above, because a value with this
238
+ // line under it is a demonstrated full working credential, not just a
239
+ // shape that matched a pattern; a plain access-key-id or secret finding
240
+ // 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`));
246
+ }
234
247
  if (e.status === "acked") {
235
248
  push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} · ${e.fingerprint}`));
236
249
  } else if (e.status === "dismissed") {
package/src/rotation.js CHANGED
@@ -889,6 +889,14 @@ function renderRotation(findings, acks, dismissed = {}) {
889
889
  ackedAt: st.ackedAt,
890
890
  ackNote: st.ackNote,
891
891
  lastSeenMs: null,
892
+ // An access key id and an AWS secret are only dangerous TOGETHER
893
+ // (see pairing.js): one is useless to an attacker without the
894
+ // other. These carry the OTHER half's redacted preview when
895
+ // scan.js found one sitting next to this value, so a report with
896
+ // several access-key-id findings can say which one is an actual
897
+ // usable credential pair, not just that a secret exists somewhere.
898
+ pairedSecretPreview: null,
899
+ pairedAccessKeyPreview: null,
892
900
  };
893
901
  byFp.set(st.fingerprint, e);
894
902
  }
@@ -905,10 +913,29 @@ function renderRotation(findings, acks, dismissed = {}) {
905
913
  if (typeof f.fileMTimeMs === "number" && (e.lastSeenMs === null || f.fileMTimeMs > e.lastSeenMs)) {
906
914
  e.lastSeenMs = f.fileMTimeMs;
907
915
  }
916
+ // Take the first pairing seen across this fingerprint's occurrences: if
917
+ // the same value ever appeared next to its pair on ANY line, that's
918
+ // enough to flag it, even if a later re-echo of the same value elsewhere
919
+ // (e.g. Claude confirming "got it") dropped the neighboring secret.
920
+ if (e.pairedSecretPreview === null && typeof f.pairedSecretPreview === "string") {
921
+ e.pairedSecretPreview = f.pairedSecretPreview;
922
+ }
923
+ if (e.pairedAccessKeyPreview === null && typeof f.pairedAccessKeyPreview === "string") {
924
+ e.pairedAccessKeyPreview = f.pairedAccessKeyPreview;
925
+ }
908
926
  }
909
927
 
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;
910
936
  const entries = [...byFp.values()].sort((a, b) => {
911
937
  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;
912
939
  if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
913
940
  return a.fingerprint < b.fingerprint ? -1 : 1;
914
941
  });
package/src/scan.js CHANGED
@@ -211,14 +211,16 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
211
211
  if (suppressedReason && !includeSuppressed) {
212
212
  suppressedCount++;
213
213
  } else {
214
- record(rule, m[0], relFile, file, lineNo,
215
- mtimeMs,
216
- resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
217
- suppressedReason);
218
- // Feature 3: paired-secret detection (see pairing.js). Only
219
- // attempted for an UNSUPPRESSED access-key finding pairing a
220
- // vendor-example or placeholder access key with a random-looking
221
- // neighbor would be a false amplification, not a real finding.
214
+ // Feature 3: paired-secret detection (see pairing.js), computed
215
+ // BEFORE the access-key-id finding is recorded so that finding can
216
+ // carry the paired secret's own redacted preview. An access key id
217
+ // alone cannot authenticate anything (see pairing.js's docstring);
218
+ // it is only a usable credential once its secret is known too, so
219
+ // a report showing several access-key-id findings needs to say,
220
+ // on each one's own line, which one actually has a secret sitting
221
+ // next to it in the transcript, not just that a secret exists
222
+ // somewhere in the scan.
223
+ let pairedSecretPreview = null;
222
224
  if (!suppressedReason && AWS_PAIR_RULE_IDS.has(rule.id)) {
223
225
  const paired = findPairedSecret(line, m[0], m.index);
224
226
  if (paired) {
@@ -226,12 +228,19 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
226
228
  if (pairedSuppressedReason && !includeSuppressed) {
227
229
  suppressedCount++;
228
230
  } else {
231
+ pairedSecretPreview = redact(paired);
229
232
  record({ id: "aws_secret_access_key_paired", label: "AWS Secret Access Key (paired with access key id)" },
230
233
  paired, relFile, file, lineNo, mtimeMs,
231
- pairedSuppressedReason ? "low" : "high", pairedSuppressedReason, { paired: true });
234
+ pairedSuppressedReason ? "low" : "high", pairedSuppressedReason,
235
+ { paired: true, pairedAccessKeyPreview: redact(m[0]) });
232
236
  }
233
237
  }
234
238
  }
239
+ record(rule, m[0], relFile, file, lineNo,
240
+ mtimeMs,
241
+ resolveConfidence(rule.id, m[0], rule.confidence, suppressedReason),
242
+ suppressedReason,
243
+ pairedSecretPreview ? { pairedSecretPreview } : undefined);
235
244
  }
236
245
  if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
237
246
  }