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 +11 -5
- package/package.json +1 -1
- package/src/report.js +25 -3
- package/src/rotation.js +27 -0
- package/src/scan.js +18 -9
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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,
|
|
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
|
}
|