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 +11 -5
- package/package.json +1 -1
- package/src/report.js +13 -0
- 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
|
@@ -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
|
-
|
|
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
|
}
|