residoo 0.8.8 → 0.9.0

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
@@ -116,6 +116,9 @@ while losing rows, then fixed in public against the classes it was losing
116
116
  - Redacts everything in its own output, including `--json`: you get a
117
117
  shape and a first/last-4 preview, never the real value.
118
118
  - `--sarif` emits SARIF 2.1.0 for GitHub code scanning.
119
+ - `--html [path]` writes a self-contained, filterable HTML report with a
120
+ rotation guide per finding — same redaction guarantee as every other
121
+ output, no external CSS/JS, nothing to open it needs the network.
119
122
  - `--seal --keychain` encrypts every transcript with a finding into a
120
123
  local vault. See [docs/architecture.md](docs/architecture.md#sealing-what-it-finds).
121
124
  - Tells you how many **distinct** secrets it found versus how many times
@@ -186,6 +189,8 @@ faith.
186
189
  residoo scan [options]
187
190
 
188
191
  --json machine-readable output (full detail, still redacted)
192
+ --html [path] also write a self-contained HTML report (default:
193
+ residoo-report-<stamp>.html); combines with --json
189
194
  --project [dir] scan a repository checkout instead of this machine
190
195
  (committed transcripts, agent configs, root .env)
191
196
  --include-noisy also run broad, false-positive-prone rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
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/cli.js CHANGED
@@ -5,7 +5,7 @@ const fs = require("fs");
5
5
  const crypto = require("crypto");
6
6
  const { availableSources, ALL_SOURCES } = require("./sources");
7
7
  const { scan, emptyResult } = require("./scan");
8
- const { render, renderIntegrity, renderJson, renderSarif, makeProgressReporter, printIntro } = require("./report");
8
+ const { render, renderIntegrity, renderJson, renderSarif, renderHtml, makeProgressReporter, printIntro } = require("./report");
9
9
  const { checkIntegrity } = require("./integrity");
10
10
  const {
11
11
  ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
@@ -78,6 +78,15 @@ Scan options:
78
78
  GitHub code scanning's Security tab and inline PR
79
79
  annotations. Use --json for the full picture
80
80
  (findings + integrity + rotation) instead.
81
+ --html [path] also write a self-contained HTML report (default:
82
+ residoo-report-<timestamp>.html in the current
83
+ directory) -- a filterable table with a rotation
84
+ guide per finding, safe to screenshot or share:
85
+ same redacted preview as every other output, no
86
+ raw value in any code path, no external CSS/JS/
87
+ fonts, no network access needed to open it.
88
+ Independent of --json/--sarif; can combine with
89
+ either.
81
90
  --project [dir] scan a repository checkout instead of this machine
82
91
  (default dir: current directory). Covers committed
83
92
  agent transcripts, agent config/rules files, and
@@ -340,6 +349,23 @@ async function resolveUnsealSecret(args, vaultDir) {
340
349
  }
341
350
  }
342
351
 
352
+ // --html [path]: writes the self-contained HTML report to disk (default
353
+ // residoo-report-<timestamp>.html in the cwd, same naming convention as
354
+ // --seal's default vault dir) and prints where it went. Independent of
355
+ // which stdout format was chosen (text/--json/--sarif), same relationship
356
+ // --seal already has to those — this is a side effect, not another
357
+ // mutually-exclusive output mode.
358
+ function writeHtmlReport(result, integrity, rotation, args) {
359
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
360
+ // Bare --html is valid (auto-named), so the next token is only taken as a
361
+ // path when it doesn't itself look like another flag — same guard --project
362
+ // uses for the same reason.
363
+ const next = argValue(args, "--html");
364
+ const out = next && !next.startsWith("--") ? path.resolve(next) : path.resolve(`residoo-report-${stamp}.html`);
365
+ fs.writeFileSync(out, renderHtml(result, integrity, rotation));
366
+ process.stdout.write(`HTML report written to ${out}\n`);
367
+ }
368
+
343
369
  async function runSeal(result, args) {
344
370
  const { sealFindings, uploadVaultToCloudRoam } = require("./sealvault");
345
371
 
@@ -955,6 +981,7 @@ async function main(argv) {
955
981
  // a planted repo-level hook in the CWD is exactly as dangerous here.
956
982
  if (integrity) process.stdout.write(renderIntegrity(integrity, { noColor }) + "\n");
957
983
  }
984
+ if (args.includes("--html")) writeHtmlReport(empty, integrity, renderRotation([], acks, dismissed), args);
958
985
  return failOnFind && integrityWarnCount(integrity) > 0 ? 1 : 0;
959
986
  }
960
987
 
@@ -984,6 +1011,8 @@ async function main(argv) {
984
1011
  if (sealExit !== 0) return sealExit;
985
1012
  }
986
1013
 
1014
+ if (args.includes("--html")) writeHtmlReport(result, integrity, rotation, args);
1015
+
987
1016
  // --allow-acked narrows the SECRET gate only: an acknowledged rotation says
988
1017
  // nothing about a planted hook, so integrity warnings always fail. Without
989
1018
  // the flag, acks change what the report says, never what CI does — a gate
package/src/report.js CHANGED
@@ -643,4 +643,202 @@ function renderSarif(result) {
643
643
  }, null, 2);
644
644
  }
645
645
 
646
- module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, makeProgressReporter, printIntro };
646
+ function escapeHtml(s) {
647
+ return String(s == null ? "" : s)
648
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
649
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
650
+ }
651
+
652
+ const HTML_REPORT_STYLE = `
653
+ :root { color-scheme: dark; --bg:#0d1117; --panel:#161b22; --border:#30363d; --text:#c9d1d9;
654
+ --dim:#8b949e; --red:#f85149; --yellow:#d29922; --green:#3fb950; --cyan:#58a6ff; --accent:#238636; }
655
+ * { box-sizing: border-box; }
656
+ body { margin:0; padding:32px; background:var(--bg); color:var(--text);
657
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
658
+ .wrap { max-width: 1100px; margin: 0 auto; }
659
+ h1 { font-size:20px; margin:0 0 4px; }
660
+ .meta { color:var(--dim); font-size:12px; margin-bottom:24px; }
661
+ .cards { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px; }
662
+ .card { background:var(--panel); border:1px solid var(--border); border-radius:8px;
663
+ padding:14px 18px; min-width:140px; }
664
+ .card .n { font-size:24px; font-weight:700; }
665
+ .card .l { color:var(--dim); font-size:12px; margin-top:2px; }
666
+ .clean { background:var(--panel); border:1px solid var(--accent); border-radius:8px;
667
+ padding:20px; color:var(--green); font-weight:600; }
668
+ input#filter { width:100%; padding:10px 12px; margin-bottom:14px; background:var(--panel);
669
+ border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; }
670
+ input#filter:focus { outline:1px solid var(--cyan); }
671
+ table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--border);
672
+ border-radius:8px; overflow:hidden; margin-bottom:24px; }
673
+ th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; }
674
+ th { color:var(--dim); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.03em; }
675
+ tr:last-child td { border-bottom:none; }
676
+ tr.row:hover { background:#1c232c; cursor:pointer; }
677
+ .conf-high { color:var(--red); font-weight:600; } .conf-medium { color:var(--yellow); font-weight:600; }
678
+ .conf-low { color:var(--dim); }
679
+ .status-pending { color:var(--yellow); } .status-acked { color:var(--green); } .status-dismissed { color:var(--dim); }
680
+ code.preview { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; background:#0000004d;
681
+ padding:1px 6px; border-radius:4px; }
682
+ .guide { display:none; background:#0000004d; padding:12px 16px; }
683
+ .guide.open { display:table-row; }
684
+ .guide td { border-bottom:1px solid var(--border); }
685
+ .guide ol { margin:6px 0; padding-left:20px; }
686
+ .guide a { color:var(--cyan); }
687
+ .note { color:var(--dim); font-size:12px; margin-top:2px; }
688
+ .section-title { font-size:15px; font-weight:600; margin:24px 0 10px; }
689
+ .warn-badge { display:inline-block; background:#f8514922; color:var(--red); border:1px solid var(--red);
690
+ border-radius:4px; padding:1px 8px; font-size:11px; font-weight:600; margin-left:8px; }
691
+ .footer { color:var(--dim); font-size:12px; margin-top:32px; border-top:1px solid var(--border); padding-top:16px; }
692
+ `;
693
+
694
+ const HTML_REPORT_SCRIPT = `
695
+ document.getElementById("filter")?.addEventListener("input", function (e) {
696
+ var q = e.target.value.toLowerCase();
697
+ document.querySelectorAll("tr.row").forEach(function (row) {
698
+ var hit = row.getAttribute("data-search").includes(q);
699
+ row.style.display = hit ? "" : "none";
700
+ var g = row.nextElementSibling;
701
+ if (g && g.classList.contains("guide") && !hit) g.classList.remove("open");
702
+ });
703
+ });
704
+ document.querySelectorAll("tr.row").forEach(function (row) {
705
+ row.addEventListener("click", function () {
706
+ var g = row.nextElementSibling;
707
+ if (g && g.classList.contains("guide")) g.classList.toggle("open");
708
+ });
709
+ });
710
+ `;
711
+
712
+ /**
713
+ * Self-contained, single-file HTML report (residoo scan --html). Same data
714
+ * as renderJson (findings deduped by rotation.js into distinct-value rows,
715
+ * plus integrity), presented for the audience --json/--sarif don't serve
716
+ * well: a screenshot for an incident channel, or a non-CLI teammate.
717
+ *
718
+ * Every value shown is `f.preview`/`entry.preview` — already redacted by
719
+ * patterns.js's redact() before it ever reaches this function, the same
720
+ * guarantee every other output format has. Unlike a competitor's own HTML
721
+ * report (which explicitly notes only its HTML mode masks values, and its
722
+ * JSON mode ships full raw secrets "for incident response"), residoo has
723
+ * no output mode, in any format, that ever writes a raw value — this
724
+ * function has no code path that could regress that, since it never
725
+ * receives the raw value in the first place.
726
+ *
727
+ * No external CSS/JS/fonts/images: everything is inlined below, so the
728
+ * file opens correctly with no network access, matching residoo's own
729
+ * "no network calls in the default path" posture for the report itself,
730
+ * not just the scan that produced it.
731
+ */
732
+ function renderHtml(result, integrity = null, rotation = null) {
733
+ const { version } = require("../package.json");
734
+ const findings = result.findings || [];
735
+ const scannedAt = localTimestamp(new Date());
736
+ const distinct = rotation && rotation.counts ? rotation.counts.distinct : 0;
737
+ const pending = rotation && rotation.counts ? rotation.counts.pending : 0;
738
+ const confirmedDead = rotation && rotation.counts ? rotation.counts.confirmedDead || 0 : 0;
739
+ const needsReview = Math.max(0, pending - confirmedDead);
740
+ const byFile = new Set(findings.map((f) => f.file)).size;
741
+ const integrityWarns = integrity ? integrity.findings.filter((f) => f.severity === "warn").length : 0;
742
+
743
+ const head =
744
+ `<!doctype html><html><head><meta charset="utf-8">` +
745
+ `<meta name="viewport" content="width=device-width, initial-scale=1">` +
746
+ `<meta name="robots" content="noindex">` +
747
+ `<title>residoo report -- ${escapeHtml(scannedAt)}</title>` +
748
+ `<style>${HTML_REPORT_STYLE}</style></head><body><div class="wrap">` +
749
+ `<h1>residoo report</h1>` +
750
+ `<div class="meta">v${escapeHtml(version)} &middot; scanned ${escapeHtml(scannedAt)} &middot; ` +
751
+ `generated locally, never uploaded &mdash; safe to share, values below are redacted</div>`;
752
+
753
+ if (findings.length === 0) {
754
+ const body =
755
+ `<div class="clean">&#10003; No exposed secrets found: ${filesScannedLine(result)}</div>` +
756
+ (integrity ? renderIntegrityHtml(integrity) : "") +
757
+ `</div></body></html>`;
758
+ return head + body;
759
+ }
760
+
761
+ const cards =
762
+ `<div class="cards">` +
763
+ card(String(findings.length), `finding${findings.length === 1 ? "" : "s"} across ${byFile} file${byFile === 1 ? "" : "s"}`) +
764
+ card(String(distinct), `distinct value${distinct === 1 ? "" : "s"}`) +
765
+ card(String(needsReview), `need${needsReview === 1 ? "s" : ""} review`) +
766
+ card(String(result.filesScanned), "files scanned") +
767
+ `</div>`;
768
+
769
+ const rows = (rotation && rotation.entries ? [...rotation.entries] : [])
770
+ .sort((a, b) => b.occurrences - a.occurrences)
771
+ .map((e) => rotationRowHtml(e))
772
+ .join("");
773
+
774
+ const table =
775
+ `<input id="filter" type="text" placeholder="Filter by rule, file, or preview...">` +
776
+ `<table><thead><tr><th>Rule</th><th>Status</th><th>Preview</th><th>Seen</th><th>Files</th></tr></thead>` +
777
+ `<tbody>${rows}</tbody></table>`;
778
+
779
+ const integritySection = integrity
780
+ ? `<div class="section-title">Integrity checks${integrityWarns > 0 ? `<span class="warn-badge">${integrityWarns} warning${integrityWarns === 1 ? "" : "s"}</span>` : ""}</div>` +
781
+ renderIntegrityHtml(integrity)
782
+ : "";
783
+
784
+ const footer =
785
+ `<div class="footer">Values are redacted (first/last 4 characters only). Nothing in this file, or in the scan` +
786
+ ` that produced it, ever left this machine. Generated by <code class="preview">residoo scan --html</code> --` +
787
+ ` github.com/dandovdub/residoo</div>`;
788
+
789
+ return head + cards + table + integritySection + footer + `<script>${HTML_REPORT_SCRIPT}</script></div></body></html>`;
790
+ }
791
+
792
+ function card(n, label) {
793
+ return `<div class="card"><div class="n">${escapeHtml(n)}</div><div class="l">${escapeHtml(label)}</div></div>`;
794
+ }
795
+
796
+ function filesScannedLine(result) {
797
+ return `${result.filesScanned} file${result.filesScanned === 1 ? "" : "s"} scanned across ${(result.sourcesScanned || []).join(", ") || "no sources"}`;
798
+ }
799
+
800
+ const HTML_STATUS_LABEL = { pending: "pending", acked: "rotated", dismissed: "dismissed" };
801
+
802
+ function rotationRowHtml(e) {
803
+ const search = escapeHtml(`${e.label} ${e.ruleId} ${e.preview} ${(e.files || []).join(" ")}`.toLowerCase());
804
+ const filesShown = (e.files || []).slice(0, 3).map((f) => safeBasename(f));
805
+ const moreFiles = (e.files || []).length - filesShown.length;
806
+ const g = e.guidance || {};
807
+ const link = g.rotateUrl
808
+ ? `<a href="${escapeHtml(g.rotateUrl)}" target="_blank" rel="noopener">${escapeHtml(g.rotateUrl)}</a>`
809
+ : escapeHtml(g.consolePath || "");
810
+ const steps = (g.steps || []).map((s) => `<li>${escapeHtml(s)}</li>`).join("");
811
+ return (
812
+ `<tr class="row" data-search="${search}">` +
813
+ `<td>${escapeHtml(e.label)}</td>` +
814
+ `<td class="status-${escapeHtml(e.status)}">${escapeHtml(HTML_STATUS_LABEL[e.status] || e.status)}</td>` +
815
+ `<td><code class="preview">${escapeHtml(e.preview)}</code></td>` +
816
+ `<td>${e.occurrences}&times;</td>` +
817
+ `<td>${escapeHtml(filesShown.join(", "))}${moreFiles > 0 ? ` +${moreFiles} more` : ""}</td>` +
818
+ `</tr>` +
819
+ `<tr class="guide"><td colspan="5">` +
820
+ `<div><strong>${escapeHtml(g.label || e.label)}</strong></div>` +
821
+ (link ? `<div class="note">${link}</div>` : "") +
822
+ (steps ? `<ol>${steps}</ol>` : "") +
823
+ (g.revokeNote ? `<div class="note">${escapeHtml(g.revokeNote)}</div>` : "") +
824
+ `</td></tr>`
825
+ );
826
+ }
827
+
828
+ function renderIntegrityHtml(integrity) {
829
+ if (!integrity.findings || integrity.findings.length === 0) {
830
+ return `<div class="clean" style="margin-bottom:24px">&#10003; No integrity findings.</div>`;
831
+ }
832
+ const rows = integrity.findings.map((f) =>
833
+ `<tr><td class="status-${f.severity === "warn" ? "pending" : "dismissed"}">${escapeHtml(f.severity)}</td>` +
834
+ `<td>${escapeHtml(f.kind)}</td><td>${escapeHtml(safeBasename(f.file))}</td>` +
835
+ `<td>${escapeHtml(f.detail || "")}</td></tr>`
836
+ ).join("");
837
+ return (
838
+ `<table><thead><tr><th>Severity</th><th>Kind</th><th>File</th><th>Detail</th></tr></thead>` +
839
+ `<tbody>${rows}</tbody></table>` +
840
+ (integrity.scopeNote ? `<div class="note">${escapeHtml(integrity.scopeNote)}</div>` : "")
841
+ );
842
+ }
843
+
844
+ module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, renderHtml, makeProgressReporter, printIntro };