residoo 0.8.7 → 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 +6 -1
- package/package.json +1 -1
- package/src/cli.js +30 -1
- package/src/patterns.js +87 -0
- package/src/report.js +199 -1
- package/src/rotation.js +130 -0
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ while losing rows, then fixed in public against the classes it was losing
|
|
|
97
97
|
|
|
98
98
|
## What it does
|
|
99
99
|
|
|
100
|
-
- Scans your local AI-agent session transcripts for
|
|
100
|
+
- Scans your local AI-agent session transcripts for 79 high-confidence
|
|
101
101
|
secret patterns: cloud provider keys, private key blocks, OAuth/API
|
|
102
102
|
tokens, database connection strings, and more. See
|
|
103
103
|
[`src/patterns.js`](src/patterns.js).
|
|
@@ -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.
|
|
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/patterns.js
CHANGED
|
@@ -228,6 +228,50 @@ const PATTERNS = [
|
|
|
228
228
|
// collide with any of them.
|
|
229
229
|
{ id: "elevenlabs_key", label: "ElevenLabs API key", confidence: "high",
|
|
230
230
|
re: /\bsk_[a-f0-9]{48}\b/g },
|
|
231
|
+
// Third batch from the 2026-09-04 competitor research pass. NVIDIA's own
|
|
232
|
+
// docs (docs.nvidia.com) confirm the nvapi- prefix ("Keys typically start
|
|
233
|
+
// with nvapi-") without stating an exact length.
|
|
234
|
+
{ id: "nvidia_api_key", label: "NVIDIA API key", confidence: "high",
|
|
235
|
+
re: /\bnvapi-[A-Za-z0-9_-]{40,200}\b/g },
|
|
236
|
+
// Jina AI: prefix confirmed via Jina's own GitHub org/SDKs and broad
|
|
237
|
+
// ecosystem documentation; exact 60-char length taken from noseyparker's
|
|
238
|
+
// own shipped rule, not independently pinned by a Jina spec page.
|
|
239
|
+
{ id: "jina_key", label: "Jina AI API key", confidence: "high",
|
|
240
|
+
re: /\bjina_[A-Za-z0-9]{60}\b/g },
|
|
241
|
+
// Tavily: tvly- prefix confirmed via Tavily's own official SDK repos
|
|
242
|
+
// (tavily-python, tavily-js); exact 32-char length not independently
|
|
243
|
+
// pinned by a dedicated format spec page.
|
|
244
|
+
{ id: "tavily_key", label: "Tavily API key", confidence: "high",
|
|
245
|
+
re: /\btvly-[A-Za-z0-9]{32}\b/g },
|
|
246
|
+
// Firecrawl: fc- prefix appears in Firecrawl's own docs and SDKs, but
|
|
247
|
+
// only as illustrative placeholders -- no page independently confirms
|
|
248
|
+
// the exact 32-lowercase-hex body. Shipped anyway on the combination of
|
|
249
|
+
// the prefix plus a strict hex-only body requirement right after it.
|
|
250
|
+
{ id: "firecrawl_key", label: "Firecrawl API key", confidence: "high",
|
|
251
|
+
re: /\bfc-[a-f0-9]{32}\b/g },
|
|
252
|
+
// Databricks PAT: Databricks' own docs deliberately show only placeholder
|
|
253
|
+
// tokens, no real format. Corroborated by two independent secondary
|
|
254
|
+
// sources instead -- Microsoft Purview's own Sensitive-Information-Type
|
|
255
|
+
// definition for Azure Databricks PATs, and TruffleHog's shipped OSS
|
|
256
|
+
// detector -- both agreeing on dapi + 32 lowercase hex, optionally a
|
|
257
|
+
// trailing -<digits>. Not Databricks-primary-sourced, so flagged medium
|
|
258
|
+
// rather than high like the rest of this file. The strict lowercase-hex
|
|
259
|
+
// body avoids the one real collision risk found during research: Binance
|
|
260
|
+
// also uses a "dapi" naming convention for its COIN-margined futures API
|
|
261
|
+
// (e.g. dapiDataGetTopLongShortPositionRatio), but that never matches
|
|
262
|
+
// since it isn't hex.
|
|
263
|
+
{ id: "databricks_pat", label: "Databricks personal access token", confidence: "medium",
|
|
264
|
+
re: /\bdapi[a-f0-9]{32}(?:-[0-9]+)?\b/g },
|
|
265
|
+
// Sourcegraph: sgp_ prefix confirmed via Sourcegraph's own docs ("Access
|
|
266
|
+
// tokens now begin with the prefix sgp_"); exact body shape (an optional
|
|
267
|
+
// 16-hex instance-id infix, or a local_ marker, before the 40-hex body)
|
|
268
|
+
// taken from noseyparker's worked examples, not independently pinned.
|
|
269
|
+
// NOTE for future additions: Segment's own "Public API Token" also uses
|
|
270
|
+
// an sgp_ prefix (sgp_[a-zA-Z0-9]{64}) -- not added here, but if it ever
|
|
271
|
+
// is, the two rules are already mutually exclusive (hex-only 40 vs.
|
|
272
|
+
// mixed-case 64), just worth testing explicitly at that point.
|
|
273
|
+
{ id: "sourcegraph_token", label: "Sourcegraph access token", confidence: "high",
|
|
274
|
+
re: /\bsgp_(?:[a-fA-F0-9]{16}_|local_)?[a-fA-F0-9]{40}\b/g },
|
|
231
275
|
|
|
232
276
|
// ── Cloud / infra ──────────────────────────────────────────────────────
|
|
233
277
|
{ id: "digitalocean_token", label: "DigitalOcean access token", confidence: "high",
|
|
@@ -452,6 +496,49 @@ const PATTERNS = [
|
|
|
452
496
|
// random-looking segment.
|
|
453
497
|
{ id: "resend_key", label: "Resend API key", confidence: "high",
|
|
454
498
|
re: /\bre_[A-Za-z0-9]{6,12}_[A-Za-z0-9]{18,32}\b/g },
|
|
499
|
+
// Shopify Admin API access token: shpat_/shppa_ prefixes confirmed via
|
|
500
|
+
// Shopify's own current developer docs. Exact body length not stated on
|
|
501
|
+
// that page (a since-404'd Shopify changelog described it, quoted only
|
|
502
|
+
// by secondary sources), hence a generous floor/ceiling bound. Legacy
|
|
503
|
+
// shpca_/shpss_/shpua_ prefixes are real (Shopify community forum) but
|
|
504
|
+
// not on the current primary page, so left out here.
|
|
505
|
+
{ id: "shopify_admin_token", label: "Shopify Admin API access token", confidence: "high",
|
|
506
|
+
re: /\bshp(?:at|pa)_[A-Za-z0-9]{32,40}\b/g },
|
|
507
|
+
// HubSpot private app token: HubSpot's own docs show only a masked
|
|
508
|
+
// example (pat-**-***...), corroborated by real (redacted) examples on
|
|
509
|
+
// HubSpot's own community forum showing the pat-<region>-<UUID-shaped
|
|
510
|
+
// body> structure. Medium confidence: the shape is real but the exact
|
|
511
|
+
// per-segment lengths are inferred, not vendor-pinned.
|
|
512
|
+
{ id: "hubspot_token", label: "HubSpot private app access token", confidence: "medium",
|
|
513
|
+
re: /\bpat-[a-z]{2}\d-[a-f0-9-]{30,60}\b/gi },
|
|
514
|
+
// Grafana service account token: exact shape read directly off Grafana's
|
|
515
|
+
// own docs example token (grafana.com/docs/grafana/latest/administration/service-accounts/),
|
|
516
|
+
// not inferred -- two independently-random underscore-delimited segments
|
|
517
|
+
// behind a distinctive 5-char prefix.
|
|
518
|
+
{ id: "grafana_service_account_token", label: "Grafana service account token", confidence: "high",
|
|
519
|
+
re: /\bglsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8}\b/g },
|
|
520
|
+
// New Relic: NRAK- prefix confirmed via New Relic's own migration-notice
|
|
521
|
+
// doc ("If your API key starts with NRAK, no update is required").
|
|
522
|
+
// Exact body length/charset not published; generously bounded.
|
|
523
|
+
{ id: "new_relic_api_key", label: "New Relic API key", confidence: "high",
|
|
524
|
+
re: /\bNRAK-[A-Z0-9]{27,64}\b/g },
|
|
525
|
+
// Mailchimp: 32 lowercase hex + a literal -us<datacenter digits> suffix,
|
|
526
|
+
// read directly off a live example in Mailchimp's own docs. The suffix
|
|
527
|
+
// is what makes this safe as a default rule -- a bare 32-hex string
|
|
528
|
+
// alone would be indistinguishable from countless unrelated hashes, but
|
|
529
|
+
// the specific "-usN" tail is Mailchimp-specific.
|
|
530
|
+
{ id: "mailchimp_key", label: "Mailchimp API key", confidence: "high",
|
|
531
|
+
re: /\b[0-9a-f]{32}-us[0-9]{1,2}\b/g },
|
|
532
|
+
// Akamai EdgeGrid access_token / client_token: akab- prefix and the
|
|
533
|
+
// hyphen-separated two-segment shape confirmed via Akamai's own current
|
|
534
|
+
// docs (literal examples shown), independently corroborated by a
|
|
535
|
+
// detect-secrets maintainer's own reproduction. Neither source states
|
|
536
|
+
// hard segment-length bounds, so the ranges here are a reasonable
|
|
537
|
+
// envelope, not vendor-pinned. Akamai's client_secret was investigated
|
|
538
|
+
// and NOT added: no distinctive prefix in Akamai's own examples, the
|
|
539
|
+
// same bare/opaque shape already excluded elsewhere in this file.
|
|
540
|
+
{ id: "akamai_edgegrid_token", label: "Akamai EdgeGrid token", confidence: "high",
|
|
541
|
+
re: /\bakab-[a-z0-9]{16,32}-[a-z0-9]{6,32}\b/g },
|
|
455
542
|
];
|
|
456
543
|
|
|
457
544
|
/**
|
package/src/report.js
CHANGED
|
@@ -643,4 +643,202 @@ function renderSarif(result) {
|
|
|
643
643
|
}, null, 2);
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
-
|
|
646
|
+
function escapeHtml(s) {
|
|
647
|
+
return String(s == null ? "" : s)
|
|
648
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
649
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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)} · scanned ${escapeHtml(scannedAt)} · ` +
|
|
751
|
+
`generated locally, never uploaded — safe to share, values below are redacted</div>`;
|
|
752
|
+
|
|
753
|
+
if (findings.length === 0) {
|
|
754
|
+
const body =
|
|
755
|
+
`<div class="clean">✓ 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}×</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">✓ 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 };
|
package/src/rotation.js
CHANGED
|
@@ -572,6 +572,71 @@ const ROTATION_GUIDANCE = {
|
|
|
572
572
|
],
|
|
573
573
|
revokeNote: "A key grants full account access (voices, generations, billing); deletion is immediate.",
|
|
574
574
|
},
|
|
575
|
+
// docs.nvidia.com's own key-management pages point at build.nvidia.com
|
|
576
|
+
// for personal keys; login-walled, hence the path in words.
|
|
577
|
+
nvidia_api_key: {
|
|
578
|
+
label: "NVIDIA API key",
|
|
579
|
+
consolePath: "build.nvidia.com > your profile > API Keys",
|
|
580
|
+
steps: [
|
|
581
|
+
"Open API Keys under your NVIDIA Build profile",
|
|
582
|
+
"Delete the leaked key",
|
|
583
|
+
"Generate a replacement and update whatever used the old one",
|
|
584
|
+
],
|
|
585
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
586
|
+
},
|
|
587
|
+
jina_key: {
|
|
588
|
+
label: "Jina AI API key",
|
|
589
|
+
consolePath: "jina.ai > API keys (jina.ai/api-dashboard/key-manager)",
|
|
590
|
+
steps: [
|
|
591
|
+
"Open the API key manager",
|
|
592
|
+
"Delete the leaked key",
|
|
593
|
+
"Create a replacement and update whatever used the old one",
|
|
594
|
+
],
|
|
595
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
596
|
+
},
|
|
597
|
+
tavily_key: {
|
|
598
|
+
label: "Tavily API key",
|
|
599
|
+
consolePath: "app.tavily.com > API Keys",
|
|
600
|
+
steps: [
|
|
601
|
+
"Open API Keys in the Tavily dashboard",
|
|
602
|
+
"Delete the leaked key",
|
|
603
|
+
"Create a replacement and update whatever used the old one",
|
|
604
|
+
],
|
|
605
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
606
|
+
},
|
|
607
|
+
firecrawl_key: {
|
|
608
|
+
label: "Firecrawl API key",
|
|
609
|
+
consolePath: "firecrawl.dev dashboard > API Keys",
|
|
610
|
+
steps: [
|
|
611
|
+
"Open API Keys in the Firecrawl dashboard",
|
|
612
|
+
"Delete the leaked key",
|
|
613
|
+
"Create a replacement and update whatever used the old one",
|
|
614
|
+
],
|
|
615
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
616
|
+
},
|
|
617
|
+
// Format is not Databricks-primary-sourced (see the comment in
|
|
618
|
+
// src/patterns.js), but the rotation location itself is confirmed
|
|
619
|
+
// directly from Databricks' own docs (docs.databricks.com/aws/en/dev-tools/auth/pat).
|
|
620
|
+
databricks_pat: {
|
|
621
|
+
label: "Databricks personal access token",
|
|
622
|
+
consolePath: "Databricks workspace > User Settings > Developer > Access tokens",
|
|
623
|
+
steps: [
|
|
624
|
+
"Open Access tokens under Developer settings in the workspace",
|
|
625
|
+
"Revoke the leaked token",
|
|
626
|
+
"Create a replacement with the shortest lifetime that works for your use case",
|
|
627
|
+
],
|
|
628
|
+
revokeNote: "Revocation is immediate; anything still using the old token starts failing authentication at once.",
|
|
629
|
+
},
|
|
630
|
+
sourcegraph_token: {
|
|
631
|
+
label: "Sourcegraph access token",
|
|
632
|
+
rotateUrl: "https://sourcegraph.com/docs/cli/how-tos/creating_an_access_token",
|
|
633
|
+
steps: [
|
|
634
|
+
"Sourcegraph instance > user menu > Settings > Access tokens",
|
|
635
|
+
"Delete the leaked token",
|
|
636
|
+
"Create a replacement with the narrowest scopes it needs",
|
|
637
|
+
],
|
|
638
|
+
revokeNote: "Deletion is immediate; anything still using the old token starts failing authentication at once.",
|
|
639
|
+
},
|
|
575
640
|
|
|
576
641
|
// ── Cloud / infra ─────────────────────────────────────────────────────
|
|
577
642
|
// docs.digitalocean.com/reference/api/create-personal-access-token/
|
|
@@ -963,6 +1028,71 @@ const ROTATION_GUIDANCE = {
|
|
|
963
1028
|
],
|
|
964
1029
|
revokeNote: "Deletion is immediate; the key stops authenticating on the next request.",
|
|
965
1030
|
},
|
|
1031
|
+
// shopify.dev/docs/apps/build/authentication-authorization/access-tokens
|
|
1032
|
+
shopify_admin_token: {
|
|
1033
|
+
label: "Shopify Admin API access token",
|
|
1034
|
+
rotateUrl: "https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens",
|
|
1035
|
+
steps: [
|
|
1036
|
+
"For a custom app: Shopify admin > Settings > Apps and sales channels > the app > uninstall/reinstall to reissue",
|
|
1037
|
+
"For a public/private app: revoke access from the Partner Dashboard and reinstall",
|
|
1038
|
+
"Update whatever used the old token",
|
|
1039
|
+
],
|
|
1040
|
+
revokeNote: "This token can read/write store data (orders, customers, products) depending on its scopes -- review recent admin activity, not just API logs.",
|
|
1041
|
+
},
|
|
1042
|
+
hubspot_token: {
|
|
1043
|
+
label: "HubSpot private app access token",
|
|
1044
|
+
consolePath: "HubSpot account > Settings > Integrations > Private Apps",
|
|
1045
|
+
steps: [
|
|
1046
|
+
"Open Private Apps under Integrations",
|
|
1047
|
+
"Open the relevant app and rotate or delete its access token",
|
|
1048
|
+
"Update whatever used the old token",
|
|
1049
|
+
],
|
|
1050
|
+
revokeNote: "Deletion/rotation is immediate; anything still using the old token starts failing authentication at once.",
|
|
1051
|
+
},
|
|
1052
|
+
// grafana.com/docs/grafana/latest/administration/service-accounts/
|
|
1053
|
+
grafana_service_account_token: {
|
|
1054
|
+
label: "Grafana service account token",
|
|
1055
|
+
rotateUrl: "https://grafana.com/docs/grafana/latest/administration/service-accounts/",
|
|
1056
|
+
steps: [
|
|
1057
|
+
"Administration > Users and access > Service accounts",
|
|
1058
|
+
"Open the relevant service account and delete the leaked token",
|
|
1059
|
+
"Add a replacement token and update whatever used the old one",
|
|
1060
|
+
],
|
|
1061
|
+
revokeNote: "Deletion is immediate; anything still using the old token starts failing authentication at once.",
|
|
1062
|
+
},
|
|
1063
|
+
// docs.newrelic.com's own migration notice confirms the NRAK prefix;
|
|
1064
|
+
// key management itself is login-walled.
|
|
1065
|
+
new_relic_api_key: {
|
|
1066
|
+
label: "New Relic API key",
|
|
1067
|
+
consolePath: "one.newrelic.com > API keys (under your user menu)",
|
|
1068
|
+
steps: [
|
|
1069
|
+
"Open API keys",
|
|
1070
|
+
"Delete the leaked key",
|
|
1071
|
+
"Create a replacement and update whatever used the old one",
|
|
1072
|
+
],
|
|
1073
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
1074
|
+
},
|
|
1075
|
+
mailchimp_key: {
|
|
1076
|
+
label: "Mailchimp API key",
|
|
1077
|
+
consolePath: "Mailchimp account > Profile > Extras > API keys",
|
|
1078
|
+
steps: [
|
|
1079
|
+
"Open API keys under Extras in your account profile",
|
|
1080
|
+
"Delete the leaked key",
|
|
1081
|
+
"Create a replacement and update whatever used the old one",
|
|
1082
|
+
],
|
|
1083
|
+
revokeNote: "Deletion is immediate; anything still using the old key starts failing authentication at once.",
|
|
1084
|
+
},
|
|
1085
|
+
// techdocs.akamai.com/developer/docs/authenticate-with-edgegrid
|
|
1086
|
+
akamai_edgegrid_token: {
|
|
1087
|
+
label: "Akamai EdgeGrid token",
|
|
1088
|
+
rotateUrl: "https://techdocs.akamai.com/developer/docs/authenticate-with-edgegrid",
|
|
1089
|
+
steps: [
|
|
1090
|
+
"Akamai Control Center > Identity & access management > API clients",
|
|
1091
|
+
"Open the relevant API client and deactivate/delete the leaked credential",
|
|
1092
|
+
"Create a replacement client credential and update whatever used the old one",
|
|
1093
|
+
],
|
|
1094
|
+
revokeNote: "Deactivation is immediate; anything still using the old credential starts failing authentication at once.",
|
|
1095
|
+
},
|
|
966
1096
|
|
|
967
1097
|
// ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
|
|
968
1098
|
generic_password_assignment: {
|