residoo 0.4.1 → 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.1 · 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.1 · 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.1
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.1
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.1",
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
@@ -193,15 +193,23 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
193
193
  const MAX_SHOWN = 12;
194
194
  let shownCount = 0;
195
195
  let elided = 0;
196
+ // Tracked separately from `elided` so the closing line can say WHICH rule
197
+ // types got cut, not just how many entries. The cap is global across all
198
+ // groups (a report is a summary), so on a rule-heavy scan the elided
199
+ // remainder almost always spans multiple types, not more of whichever
200
+ // group happened to print right above the "N more" line; without this a
201
+ // reader sees that line directly under (say) an Anthropic group and
202
+ // reasonably reads it as "N more Anthropic keys," which it usually is not.
203
+ const elidedRuleLabels = new Set();
196
204
  for (const g of groupList) {
197
- if (shownCount >= MAX_SHOWN) { elided += g.entries.length; continue; }
205
+ if (shownCount >= MAX_SHOWN) { elided += g.entries.length; elidedRuleLabels.add(g.label); continue; }
198
206
  push();
199
207
  const g0 = g.entries[0];
200
208
  const where = g0.guidance.rotateUrl ? `rotate: ${g0.guidance.rotateUrl}` : `where: ${g0.guidance.consolePath}`;
201
209
  push(` ${paint(c.bold, g.label)}`);
202
210
  push(paint(c.dim, ` ${where}`));
203
211
  for (const e of g.entries) {
204
- if (shownCount >= MAX_SHOWN) { elided++; continue; }
212
+ if (shownCount >= MAX_SHOWN) { elided++; elidedRuleLabels.add(g.label); continue; }
205
213
  shownCount++;
206
214
  // e.files always has exactly one entry: the fingerprint is derived
207
215
  // from this same basename (see fingerprintFinding in rotation.js), so
@@ -223,6 +231,19 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
223
231
  const lastSeenNote = typeof e.lastSeenMs === "number" ? `last seen ~${ageDays(e.lastSeenMs)}d ago` : null;
224
232
  push(` ${STATUS_TAG[e.status]} ${e.preview} ${paint(c.dim, fileNote)}` +
225
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
+ }
226
247
  if (e.status === "acked") {
227
248
  push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} · ${e.fingerprint}`));
228
249
  } else if (e.status === "dismissed") {
@@ -234,7 +255,8 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
234
255
  }
235
256
  if (elided > 0) {
236
257
  push();
237
- push(paint(c.dim, ` and ${elided} more; see --json for the full list`));
258
+ const typeNote = elidedRuleLabels.size > 1 ? ` across ${elidedRuleLabels.size} rule types` : "";
259
+ push(paint(c.dim, ` … and ${elided} more${typeNote}; see --json for the full list`));
238
260
  }
239
261
  push();
240
262
  push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · rotated: residoo ack <fp> · not a secret: residoo dismiss <fp>`));
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
  }