residoo 0.4.11 → 0.4.13

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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +1 -1
  3. package/src/scan.js +67 -10
package/README.md CHANGED
@@ -47,6 +47,26 @@ scanned here left your machine; residoo makes no network calls.
47
47
  > trufflehog/betterleaks' verification postures, in
48
48
  > [docs/comparison.md](docs/comparison.md).
49
49
 
50
+ ## Benchmark: measured, not claimed
51
+
52
+ A reproducible benchmark against 8 real competing tools, on a synthetic-but-
53
+ pattern-true corpus (72 Claude Code sessions, 45 planted credentials, zero
54
+ real secrets), with live egress monitoring so "no network calls" is
55
+ observed, not just documented. Re-run against every meaningful release,
56
+ most recently v0.4.13:
57
+
58
+ | | residoo | best of the rest |
59
+ |---|---|---|
60
+ | Distinct credentials found (all claimed classes) | **44/45 (98%)** | agentsweep 33/42 (79%) |
61
+ | Precision (false positives) | **100%** (0 of 54 flags wrong) | gitleaks, whatileaked, trufflehog also 100% |
62
+ | Network egress during the scan | **none-observed** | 3 of 8 tools attempt real outbound calls in their *default* mode (trufflehog: 50 connection attempts to github.com, slack.com, api.anthropic.com, gitlab.com, npmjs.org) |
63
+
64
+ No single blended score, on purpose: a blend would hide exactly the class-
65
+ level differences (base64-wrapped, split-across-lines, JSON-nested) the
66
+ benchmark exists to measure. Full per-class breakdown, fairness rules, and
67
+ reproduction steps: [bench/](bench/). Self-run, pending independent
68
+ reproduction; everything needed to rerun it ships in this repo.
69
+
50
70
  ## What it does
51
71
 
52
72
  - Scans your local AI-agent session transcripts for 50 high-confidence
@@ -265,7 +285,7 @@ As a GitHub Action (this repo doubles as a composite action):
265
285
  ```yaml
266
286
  steps:
267
287
  - uses: actions/checkout@v4
268
- - uses: dandovdub/residoo@v0.4.11
288
+ - uses: dandovdub/residoo@v0.4.13
269
289
  ```
270
290
 
271
291
  As a pre-commit hook:
@@ -273,7 +293,7 @@ As a pre-commit hook:
273
293
  ```yaml
274
294
  repos:
275
295
  - repo: https://github.com/dandovdub/residoo
276
- rev: v0.4.11
296
+ rev: v0.4.13
277
297
  hooks:
278
298
  - id: residoo
279
299
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
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/scan.js CHANGED
@@ -237,6 +237,16 @@ const VENDOR_EXAMPLE_VALUES = new Set([
237
237
  /** Matches every finding's own `relFile` convention — never the full path. See SECURITY.md. */
238
238
  function safeName(file) { return path.basename(file); }
239
239
 
240
+ // Same format as report.js's own localTimestamp, duplicated rather than
241
+ // imported: this is a 3-line pure function, and report.js is the
242
+ // presentation layer for stdout while this file's own --verify results
243
+ // table is stderr, the same reasoning pairing.js gives for its own small
244
+ // duplicated helper (looksZeroEntropy) rather than cross-importing.
245
+ function localTimestamp(d) {
246
+ const p2 = (n) => String(n).padStart(2, "0");
247
+ return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}`;
248
+ }
249
+
240
250
  /**
241
251
  * Scan every transcript from every available source.
242
252
  *
@@ -705,6 +715,11 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
705
715
  // file while stderr still reaches a real terminal is a real case: `scan
706
716
  // --verify --json > out.json` should still color this table).
707
717
  const paint = makePaint(noColor, process.stderr);
718
+ // Populated inside the disclosure block below (when there's something to
719
+ // verify), then read again once every verify loop below has finished, to
720
+ // print the results table. Declared out here, not inside that block, so
721
+ // it survives to that second read.
722
+ let verifyRows = [];
708
723
  if (verify && anyPending) {
709
724
  // One disclosure, not one per vendor: this used to print a full
710
725
  // "this is a real network request..." paragraph for EACH vendor in
@@ -724,26 +739,37 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
724
739
  // everywhere else in the report — reusing redact() on the same raw
725
740
  // value record() was called with, not a second display convention.
726
741
  // One line per vendor+endpoint header, one indented line per credential.
727
- const rows = [];
742
+ //
743
+ // Each row also carries a `resultFinding` per credential: a direct
744
+ // reference to the finding object applyVerifyResult mutates below (the
745
+ // access-key/secret finding for AWS, the secret finding for
746
+ // PlanetScale/MongoDB Atlas, the finding itself for a simple vendor).
747
+ // Captured now, read after the verify loops run, so the results table
748
+ // further down needs no second vendor-shape dispatch of its own.
749
+ verifyRows = [];
728
750
  if (pendingAwsVerifications.size > 0 && awsAvailable) {
729
- rows.push(["AWS", "sts:get-caller-identity", [...pendingAwsVerifications.keys()].map(redact)]);
751
+ verifyRows.push(["AWS", "sts:get-caller-identity",
752
+ [...pendingAwsVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].akiaFinding }))]);
730
753
  }
731
754
  if (pendingPlanetScaleVerifications.size > 0) {
732
- rows.push(["PlanetScale", "organizations endpoint", [...pendingPlanetScaleVerifications.keys()].map(redact)]);
755
+ verifyRows.push(["PlanetScale", "organizations endpoint",
756
+ [...pendingPlanetScaleVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].secretFinding }))]);
733
757
  }
734
758
  if (pendingMongoDbAtlasVerifications.size > 0) {
735
- rows.push(["MongoDB Atlas", "oauth/token endpoint", [...pendingMongoDbAtlasVerifications.keys()].map(redact)]);
759
+ verifyRows.push(["MongoDB Atlas", "oauth/token endpoint",
760
+ [...pendingMongoDbAtlasVerifications.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0].secretFinding }))]);
736
761
  }
737
762
  for (const [ruleId, byValue] of pendingSimpleVerifications) {
738
763
  if (byValue.size === 0) continue;
739
764
  const [vendor, endpoint] = SIMPLE_VERIFY_VENDOR_LABEL[ruleId].split("'s ");
740
- rows.push([vendor, endpoint, [...byValue.keys()].map(redact)]);
765
+ verifyRows.push([vendor, endpoint,
766
+ [...byValue.entries()].map(([value, { refs }]) => ({ value, resultFinding: refs[0] }))]);
741
767
  }
742
- if (rows.length > 0) {
743
- const table = rows
744
- .map(([vendor, endpoint, previews]) =>
745
- ` ${paint(c.bold, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
746
- previews.map((p) => ` ${p}`).join("\n"))
768
+ if (verifyRows.length > 0) {
769
+ const table = verifyRows
770
+ .map(([vendor, endpoint, credentials]) =>
771
+ ` ${paint(c.bold + c.cyan, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
772
+ credentials.map(({ value }) => ` ${redact(value)}`).join("\n"))
747
773
  .join("\n");
748
774
  process.stderr.write(
749
775
  paint(c.yellow + c.bold, "residoo --verify:") +
@@ -801,6 +827,37 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
801
827
  }
802
828
  }
803
829
 
830
+ if (verify && verifyRows.length > 0) {
831
+ // Same vendor/endpoint grouping as the disclosure table above, now
832
+ // showing what each call actually found. An ACTIVE credential is a
833
+ // real, present-tense risk, colored the same red/bold the Rotation
834
+ // section below uses for the identical fact ("rotate immediately");
835
+ // "could not verify" gets the same red/bold too, on purpose, matching
836
+ // this project's fail-safe-direction policy of treating "unknown" as
837
+ // "assume risk" rather than as reassuring silence. Stamped with when
838
+ // this check actually ran, so a report read later (pasted into a
839
+ // ticket, screenshotted) doesn't silently imply "still true right now."
840
+ const checkedAt = localTimestamp(new Date());
841
+ const describeResult = (finding) => {
842
+ if (finding.verified === "active") {
843
+ return paint(c.red + c.bold, `⚠ ACTIVE: real working credential`) + paint(c.dim, ` (checked ${checkedAt})`);
844
+ }
845
+ if (finding.verified === "invalid") {
846
+ return paint(c.green, `✓ inactive: vendor rejected it`) + paint(c.dim, ` (checked ${checkedAt})`);
847
+ }
848
+ return paint(c.red + c.bold, `⚠ could not verify`) + paint(c.dim, ` (checked ${checkedAt}${finding.verifiedDetail ? `: ${finding.verifiedDetail}` : ""})`);
849
+ };
850
+ const resultsTable = verifyRows
851
+ .map(([vendor, endpoint, credentials]) =>
852
+ ` ${paint(c.bold + c.cyan, vendor)} ${paint(c.dim, "·")} ${paint(c.dim, endpoint)}\n` +
853
+ credentials.map(({ value, resultFinding }) => ` ${redact(value)} ${describeResult(resultFinding)}`).join("\n"))
854
+ .join("\n");
855
+ process.stderr.write(
856
+ paint(c.yellow + c.bold, "residoo --verify:") + " results\n\n" +
857
+ resultsTable + "\n\n"
858
+ );
859
+ }
860
+
804
861
  const distinctCounts = {};
805
862
  for (const [ruleId, set] of distinctByRule) distinctCounts[ruleId] = set.size;
806
863
  return { findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount, distinctCounts, unreadableFiles };