rechrome 1.46.0 → 1.48.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/fill-secret.js CHANGED
@@ -2,9 +2,239 @@
2
2
  // fill-secret.ts
3
3
  import { createHmac } from "crypto";
4
4
  import { connect } from "net";
5
- import { readdirSync, readFileSync } from "fs";
5
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
6
6
  import { homedir } from "os";
7
7
  import { join } from "path";
8
+
9
+ // redact.ts
10
+ import { readFileSync } from "fs";
11
+ function goToJs(src) {
12
+ let flags = "";
13
+ for (;; ) {
14
+ const m = /^\(\?([is]+)\)/.exec(src);
15
+ if (!m)
16
+ break;
17
+ flags += m[1];
18
+ src = src.slice(m[0].length);
19
+ }
20
+ src = src.replaceAll("(?P<", "(?<").replaceAll("\\z", "$").replaceAll("[[:alnum:]]", "[A-Za-z0-9]").replaceAll("[:alnum:]", "A-Za-z0-9").replaceAll("[:alpha:]", "A-Za-z").replaceAll("[:digit:]", "0-9").replaceAll("[:space:]", "\\s");
21
+ let out = "";
22
+ const closers = [];
23
+ let extra = 0;
24
+ for (let i = 0;i < src.length; i++) {
25
+ const c = src[i];
26
+ if (c === "\\") {
27
+ out += c + (src[i + 1] ?? "");
28
+ i++;
29
+ continue;
30
+ }
31
+ if (c === "[") {
32
+ let j = i + 1;
33
+ if (src[j] === "^")
34
+ j++;
35
+ if (src[j] === "]")
36
+ j++;
37
+ while (j < src.length && src[j] !== "]") {
38
+ if (src[j] === "\\")
39
+ j++;
40
+ j++;
41
+ }
42
+ out += src.slice(i, j + 1);
43
+ i = j;
44
+ continue;
45
+ }
46
+ if (src.startsWith("(?i)", i)) {
47
+ out += "(?i:";
48
+ extra++;
49
+ i += 3;
50
+ continue;
51
+ }
52
+ if (c === "(") {
53
+ closers.push(extra);
54
+ extra = 0;
55
+ out += c;
56
+ continue;
57
+ }
58
+ if (c === ")") {
59
+ out += ")".repeat(extra);
60
+ extra = closers.pop() ?? 0;
61
+ out += c;
62
+ continue;
63
+ }
64
+ if (c === "|" && extra) {
65
+ out += ")".repeat(extra) + "|" + "(?i:".repeat(extra);
66
+ continue;
67
+ }
68
+ out += c;
69
+ }
70
+ out += ")".repeat(extra);
71
+ return { source: out, flags: [...new Set(flags)].join("") };
72
+ }
73
+ function loadGitleaksRules(toml) {
74
+ const c = Bun.TOML.parse(toml);
75
+ const compile = (r) => {
76
+ const j = goToJs(r);
77
+ return new RegExp(j.source, j.flags);
78
+ };
79
+ const gAllow = (c.allowlist?.regexes ?? []).map(compile);
80
+ const gStop = (c.allowlist?.stopwords ?? []).map((s) => s.toLowerCase());
81
+ const rules = [];
82
+ for (const r of c.rules) {
83
+ if (!r.regex)
84
+ continue;
85
+ const allow = [...gAllow];
86
+ const allowMatch = [];
87
+ const allowLine = [];
88
+ const stop = [...gStop];
89
+ for (const a of [...r.allowlists ?? [], ...r.allowlist ? [r.allowlist] : []]) {
90
+ const into = a.regexTarget === "match" ? allowMatch : a.regexTarget === "line" ? allowLine : allow;
91
+ for (const x of a.regexes ?? [])
92
+ into.push(compile(x));
93
+ for (const s of a.stopwords ?? [])
94
+ stop.push(String(s).toLowerCase());
95
+ }
96
+ const j = goToJs(r.regex);
97
+ rules.push({
98
+ id: r.id,
99
+ re: new RegExp(j.source, j.flags + "gd"),
100
+ keywords: (r.keywords ?? []).map((k) => k.toLowerCase()),
101
+ ...r.entropy ? { entropy: r.entropy } : {},
102
+ ...r.secretGroup ? { group: r.secretGroup } : {},
103
+ allow,
104
+ ...allowMatch.length ? { allowMatch } : {},
105
+ ...allowLine.length ? { allowLine } : {},
106
+ stop
107
+ });
108
+ }
109
+ return rules;
110
+ }
111
+ var OWN_RULES = [
112
+ { id: "url-secret-param", re: /[?&](?:access_token|token|key|api_key|apikey|secret|client_secret|password|passwd|pwd|sig|signature)=([^&\s#<>"'|]{6,})/gid, group: 1, keywords: ["="], allow: [/^(?:x+|\*+|\.\.\.|your[-_]?\w*|xxx\w*)$/i], stop: [] },
113
+ { id: "sk-key", re: /\b(sk-(?:proj-|ant-|or-v1-|svcacct-)?[A-Za-z0-9_-]{20,})/gd, group: 1, keywords: ["sk-"], entropy: 3.5, allow: [], stop: [] },
114
+ { id: "otp-code", re: /(?:code|\u30B3\u30FC\u30C9|otp|pin|passcode|\u8A8D\u8A3C|\u78BA\u8A8D\u756A\u53F7|\u30EF\u30F3\u30BF\u30A4\u30E0)[^\d\n]{0,20}(?<![\d.])(\d{6})(?![\d.])/gid, group: 1, keywords: [], allow: [], stop: [] }
115
+ ];
116
+ var cached;
117
+ function secretRules() {
118
+ cached ??= [...loadGitleaksRules(readFileSync(new URL("./secret-rules/gitleaks.toml", import.meta.url), "utf8")), ...OWN_RULES];
119
+ return cached;
120
+ }
121
+ function shannon(s) {
122
+ const f = new Map;
123
+ for (const ch of s)
124
+ f.set(ch, (f.get(ch) ?? 0) + 1);
125
+ let h = 0;
126
+ for (const n of f.values()) {
127
+ const p = n / s.length;
128
+ h -= p * Math.log2(p);
129
+ }
130
+ return h;
131
+ }
132
+ function findKnown(text, known) {
133
+ const forms = new Set;
134
+ for (const v of known) {
135
+ if (v.length < 6)
136
+ continue;
137
+ forms.add(v);
138
+ forms.add(JSON.stringify(v).slice(1, -1));
139
+ }
140
+ const out = [];
141
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) {
142
+ for (let i = text.indexOf(f);i >= 0; i = text.indexOf(f, i + f.length))
143
+ out.push({ id: "known-secret", start: i, end: i + f.length });
144
+ }
145
+ return out;
146
+ }
147
+ var OPAQUE_RUN = /[A-Za-z0-9+/=_-]{4096,}/g;
148
+ function findSecrets(text, opts = {}) {
149
+ if (text.length >= 4096 && OPAQUE_RUN.test(text)) {
150
+ OPAQUE_RUN.lastIndex = 0;
151
+ const out = opts.known?.length ? findKnown(text, opts.known) : [];
152
+ let at = 0;
153
+ for (const m of [...text.matchAll(OPAQUE_RUN), { index: text.length, 0: "" }]) {
154
+ const piece = text.slice(at, m.index);
155
+ if (piece)
156
+ for (const h of findSecrets(piece, { ...opts, known: [] }))
157
+ out.push({ ...h, start: h.start + at, end: h.end + at });
158
+ at = m.index + m[0].length;
159
+ }
160
+ return out;
161
+ }
162
+ OPAQUE_RUN.lastIndex = 0;
163
+ const rules = opts.rules ?? secretRules();
164
+ const lower = text.toLowerCase();
165
+ const out = opts.known?.length ? findKnown(text, opts.known) : [];
166
+ for (const r of rules) {
167
+ if (r.keywords.length && !r.keywords.some((k) => lower.includes(k)))
168
+ continue;
169
+ for (const m of text.matchAll(r.re)) {
170
+ const gi = r.group ?? m.findIndex((g, i) => i > 0 && !!g);
171
+ const secret = gi > 0 ? m[gi] : m[0];
172
+ if (!secret)
173
+ continue;
174
+ if (r.entropy && shannon(secret) < r.entropy)
175
+ continue;
176
+ if (r.allow.some((a) => a.test(secret)))
177
+ continue;
178
+ if (r.allowMatch?.some((a) => a.test(m[0])))
179
+ continue;
180
+ if (r.allowLine) {
181
+ const ls = text.lastIndexOf(`
182
+ `, m.index) + 1;
183
+ const le = text.indexOf(`
184
+ `, m.index + m[0].length);
185
+ if (r.allowLine.some((a) => a.test(text.slice(ls, le < 0 ? undefined : le))))
186
+ continue;
187
+ }
188
+ const sl = secret.toLowerCase();
189
+ if (r.stop.some((s) => sl.includes(s)))
190
+ continue;
191
+ const span = r.group ? m.indices?.[r.group] : undefined;
192
+ if (span)
193
+ out.push({ id: r.id, start: span[0], end: span[1] });
194
+ else {
195
+ let a = m.index;
196
+ let b = m.index + m[0].length;
197
+ const edge = /[\s'"`;=:,]/;
198
+ while (a < b && edge.test(text[a]))
199
+ a++;
200
+ for (;; ) {
201
+ if (b > a && edge.test(text[b - 1]))
202
+ b--;
203
+ else if (b - 2 >= a && /\\[nr]/.test(text.slice(b - 2, b)))
204
+ b -= 2;
205
+ else
206
+ break;
207
+ }
208
+ out.push({ id: r.id, start: a, end: b });
209
+ }
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+ function redact(text, opts = {}) {
215
+ const hits = findSecrets(text, opts).sort((a, b) => a.start - b.start || b.end - a.end);
216
+ if (!hits.length)
217
+ return { text, kinds: [] };
218
+ const spans = [];
219
+ for (const h of hits) {
220
+ const last = spans[spans.length - 1];
221
+ if (last && h.start < last.end) {
222
+ if (h.end - h.start > last.end - last.start)
223
+ last.id = h.id;
224
+ last.end = Math.max(last.end, h.end);
225
+ } else
226
+ spans.push({ ...h });
227
+ }
228
+ let out = "";
229
+ let at = 0;
230
+ for (const s of spans) {
231
+ out += text.slice(at, s.start) + `[redacted:${s.id}]`;
232
+ at = s.end;
233
+ }
234
+ return { text: out + text.slice(at), kinds: [...new Set(spans.map((s) => s.id))] };
235
+ }
236
+
237
+ // fill-secret.ts
8
238
  var SECRET_MASK = "***";
9
239
  var SECRET_MASK_TTL_MS = 15 * 60000;
10
240
  var MIN_MASK_LENGTH = 6;
@@ -266,7 +496,7 @@ function daemonSessionSockets(session, env = process.env) {
266
496
  if (!/^[0-9a-f]{16}$/.test(h))
267
497
  continue;
268
498
  try {
269
- const cfg = JSON.parse(readFileSync(join(base, h, `${session}.session`), "utf8"));
499
+ const cfg = JSON.parse(readFileSync2(join(base, h, `${session}.session`), "utf8"));
270
500
  if ((cfg?.name ?? session) === session && typeof cfg?.socketPath === "string" && cfg.socketPath)
271
501
  out.push(cfg.socketPath);
272
502
  } catch {}
@@ -317,7 +547,7 @@ async function runCodeOnSession(opts) {
317
547
  for (const path of candidates) {
318
548
  let reply;
319
549
  try {
320
- reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...opts.json ? { json: true } : {} });
550
+ reply = await socketRequest(path, "run", { args: { _: ["run-code", opts.code] }, cwd: opts.cwd, ...opts.json ? { json: true } : {} }, opts.timeoutMs);
321
551
  } catch (e) {
322
552
  errors.push(e instanceof Error ? e.message : String(e));
323
553
  continue;
@@ -329,6 +559,22 @@ async function runCodeOnSession(opts) {
329
559
  }
330
560
  throw new Error(`could not reach this session's browser (${errors.join("; ")}); run \`rech open <url>\` first`);
331
561
  }
562
+ async function runCliOnSession(opts, cliArgs) {
563
+ const candidates = [...new Set([...sessionSocketCandidates(opts.socketRoot, opts.session), ...daemonSessionSockets(opts.session)])];
564
+ for (const path of candidates) {
565
+ let reply;
566
+ try {
567
+ reply = await socketRequest(path, "run", { args: { _: cliArgs }, cwd: opts.cwd });
568
+ } catch {
569
+ continue;
570
+ }
571
+ if (reply.error)
572
+ throw new Error(reply.error);
573
+ const r = reply.result;
574
+ return { isError: !!r?.isError, text: typeof r === "string" ? r : typeof r?.text === "string" ? r.text : "" };
575
+ }
576
+ throw new Error("no browser is open in this session");
577
+ }
332
578
  function fillSecretOnSession(opts) {
333
579
  return runCodeOnSession({ ...opts, code: buildFillCode(opts.ref, opts.value, opts.allowDomains, opts.submit) });
334
580
  }
@@ -434,13 +680,27 @@ async function readSecretOnSession(opts) {
434
680
  throw new Error("read-secret: the page returned no value");
435
681
  return out;
436
682
  }
437
- var READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> | <command>
683
+ var READ_STORAGE_USAGE = `Usage: rech read-storage <local|session> <key> [json-path] --allow-domain <glob> [--goto <url>] | <command>
438
684
  Writes the storage value, raw and without a newline, to stdout, which must be a pipe into the
439
685
  program that stores it (never a terminal or a file). Never logged; masked in later outputs.
440
686
  <local|session> localStorage or sessionStorage
441
687
  <key> the storage key, e.g. localConfig_v2
442
688
  [json-path] optional: a path into the JSON value, e.g. teams.T0123.token or teams[T0123].token
443
- --allow-domain GLOB required: the page's host must match, e.g. app.slack.com`;
689
+ --allow-domain GLOB required: the page's host must match, e.g. app.slack.com
690
+ --goto URL first navigate the attached tab to URL (host must match --allow-domain),
691
+ so the value is read from it, e.g. --goto https://app.slack.com/client/T0`;
692
+ function checkGotoUrl(gotoUrl, allowDomains) {
693
+ let u;
694
+ try {
695
+ u = new URL(gotoUrl);
696
+ } catch {
697
+ throw new Error("--goto must be an http(s) URL");
698
+ }
699
+ if (!/^https?:$/.test(u.protocol))
700
+ throw new Error("--goto must be an http(s) URL");
701
+ if (!allowDomains.some((d) => domainGlobToRegExp(d).test(u.hostname.toLowerCase())))
702
+ throw new Error("--goto URL host must match --allow-domain");
703
+ }
444
704
  function parseStoragePath(path) {
445
705
  return path.match(/[^.[\]]+/g) ?? [];
446
706
  }
@@ -450,6 +710,7 @@ function parseReadStorageArgs(args) {
450
710
  throw new Error("Expected read-storage");
451
711
  const pos = [];
452
712
  const allowDomains = [];
713
+ let gotoUrl;
453
714
  for (let i = 0;i < rest.length; i++) {
454
715
  const a = rest[i];
455
716
  if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
@@ -460,6 +721,10 @@ function parseReadStorageArgs(args) {
460
721
  domainGlobToRegExp(d.trim());
461
722
  allowDomains.push(d.trim());
462
723
  }
724
+ } else if (a === "--goto" || a.startsWith("--goto=")) {
725
+ gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
726
+ if (!gotoUrl)
727
+ throw new Error("--goto got an empty URL");
463
728
  } else if (a.startsWith("-"))
464
729
  throw new Error(`read-storage: unknown option ${a}`);
465
730
  else
@@ -474,10 +739,12 @@ function parseReadStorageArgs(args) {
474
739
  throw new Error("read-storage: a storage key is required");
475
740
  if (!allowDomains.length)
476
741
  throw new Error("read-storage needs --allow-domain: name the site the value must come from");
477
- return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains };
742
+ if (gotoUrl)
743
+ checkGotoUrl(gotoUrl, allowDomains);
744
+ return { area, key, path: rawPath ? parseStoragePath(rawPath) : [], allowDomains, gotoUrl };
478
745
  }
479
746
  function readStorageWireArgs(req) {
480
- return ["read-storage", req.area, req.key, ...req.path.length ? [req.path.join(".")] : [], ...req.allowDomains.map((d) => `--allow-domain=${d}`)];
747
+ return ["read-storage", req.area, req.key, ...req.path.length ? [req.path.join(".")] : [], ...req.allowDomains.map((d) => `--allow-domain=${d}`), ...req.gotoUrl ? [`--goto=${req.gotoUrl}`] : []];
481
748
  }
482
749
  function parseReadStorageWire(args) {
483
750
  const [cmd, area, key, ...rest] = args;
@@ -485,11 +752,14 @@ function parseReadStorageWire(args) {
485
752
  throw new Error("read-storage needs <local|session> <key>");
486
753
  const allowDomains = [];
487
754
  let path = [];
755
+ let gotoUrl;
488
756
  for (const a of rest) {
489
757
  if (a.startsWith("--allow-domain=")) {
490
758
  const d = a.slice("--allow-domain=".length);
491
759
  domainGlobToRegExp(d);
492
760
  allowDomains.push(d);
761
+ } else if (a.startsWith("--goto=")) {
762
+ gotoUrl = a.slice("--goto=".length);
493
763
  } else if (!a.startsWith("-") && !path.length)
494
764
  path = parseStoragePath(a);
495
765
  else
@@ -497,31 +767,42 @@ function parseReadStorageWire(args) {
497
767
  }
498
768
  if (!allowDomains.length)
499
769
  throw new Error("read-storage needs --allow-domain");
500
- return { area, key, path, allowDomains };
770
+ if (gotoUrl !== undefined)
771
+ checkGotoUrl(gotoUrl, allowDomains);
772
+ return { area, key, path, allowDomains, gotoUrl };
501
773
  }
502
774
  function buildReadStorageCode(area, key, path, allowDomains) {
503
775
  const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
504
776
  const store = area === "local" ? "localStorage" : "sessionStorage";
505
777
  return `async page => {
506
- // One evaluate: the host checked is the origin read, with no navigation in between.
507
- const out = await page.evaluate(({ key, path }) => {
508
- const host = String(location.hostname).toLowerCase();
778
+ const allowed = ${JSON.stringify(patterns)};
779
+ const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
780
+ const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
781
+ // The caller has foregrounded the --allow-domain tab; read from whichever of this tab's frames
782
+ // is on the allowed host (the top frame can be a blank wrapper over the bridge).
783
+ const frame = page.frames().find(f => ok(hostOf(f.url())));
784
+ const host = frame ? hostOf(frame.url()) : "";
785
+ if (!frame)
786
+ throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (host " + JSON.stringify(hostOf(page.url())) + ")");
787
+ // Re-check the host inside the same evaluate that reads, so a navigation between the frame
788
+ // pick and the read can't hand over another origin's storage.
789
+ const out = await frame.evaluate(({ key, path, allowed }) => {
790
+ const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(document.URL || ""));
791
+ const h = m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : "";
792
+ if (!h || !allowed.some(p => new RegExp(p, "i").test(h))) return { moved: true };
509
793
  const raw = window.${store}.getItem(key);
510
- if (raw === null) return { host, missing: true };
511
- if (!path.length) return { host, value: raw };
794
+ if (raw === null) return { missing: true };
795
+ if (!path.length) return { value: raw };
512
796
  let v;
513
- try { v = JSON.parse(raw); } catch { return { host, notjson: true }; }
797
+ try { v = JSON.parse(raw); } catch { return { notjson: true }; }
514
798
  for (const seg of path) {
515
- if (v == null || typeof v !== "object") return { host, nopath: true };
799
+ if (v == null || typeof v !== "object") return { nopath: true };
516
800
  v = v[seg];
517
801
  }
518
- if (v === undefined) return { host, nopath: true };
519
- return { host, value: typeof v === "string" ? v : JSON.stringify(v) };
520
- }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)} });
521
- const host = out.host;
522
- const allowed = ${JSON.stringify(patterns)};
523
- if (!allowed.some(p => new RegExp(p, "i").test(host)))
524
- throw new Error("read-storage refused: the page host " + JSON.stringify(host) + " is not allowed by --allow-domain");
802
+ if (v === undefined) return { nopath: true };
803
+ return { value: typeof v === "string" ? v : JSON.stringify(v) };
804
+ }, { key: ${JSON.stringify(key)}, path: ${JSON.stringify(path)}, allowed });
805
+ if (out.moved) throw new Error("read-storage refused: the foreground tab is not on an --allow-domain host (it navigated away)");
525
806
  if (out.missing) throw new Error("read-storage: no ${store} key " + JSON.stringify(${JSON.stringify(key)}) + " on " + host);
526
807
  if (out.notjson) throw new Error("read-storage: the value is not JSON, so the path can't apply");
527
808
  if (out.nopath) throw new Error("read-storage: the json-path is not present in the value");
@@ -529,27 +810,41 @@ function buildReadStorageCode(area, key, path, allowDomains) {
529
810
  }`;
530
811
  }
531
812
  async function readStorageOnSession(opts) {
532
- const { isError, text } = await runCodeOnSession({ ...opts, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
533
- if (isError)
534
- throw new Error(text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
535
- `)[0] : "read-storage: could not read the value"));
536
- let raw;
537
- try {
538
- raw = JSON.parse(text).result;
539
- } catch {
540
- raw = text.match(/### Result\n([^\n]*)/)?.[1];
813
+ if (opts.gotoUrl) {
814
+ await runCliOnSession(opts, ["goto", opts.gotoUrl]).catch(() => {});
815
+ await new Promise((r) => setTimeout(r, 2500));
541
816
  }
542
- let o = raw;
543
- for (let i = 0;i < 2 && typeof o === "string"; i++) {
544
- try {
545
- o = JSON.parse(o);
546
- } catch {
547
- o = undefined;
817
+ let last = "";
818
+ for (let attempt = 0;attempt < 3; attempt++) {
819
+ const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadStorageCode(opts.area, opts.key, opts.path, opts.allowDomains), json: true });
820
+ if (!isError) {
821
+ let raw;
822
+ try {
823
+ raw = JSON.parse(text).result;
824
+ } catch {
825
+ raw = text.match(/### Result\n([^\n]*)/)?.[1];
826
+ }
827
+ let o = raw;
828
+ for (let i = 0;i < 2 && typeof o === "string"; i++) {
829
+ try {
830
+ o = JSON.parse(o);
831
+ } catch {
832
+ o = undefined;
833
+ }
834
+ }
835
+ if (o && typeof o.host === "string" && typeof o.value === "string")
836
+ return o;
837
+ throw new Error("read-storage: the page returned no value");
548
838
  }
839
+ last = text.match(/read-storage(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
840
+ `)[0] : "read-storage: could not read the value");
841
+ if (!/foreground tab is not on/.test(last))
842
+ break;
843
+ await new Promise((r) => setTimeout(r, 1000));
549
844
  }
550
- if (!o || typeof o.host !== "string" || typeof o.value !== "string")
551
- throw new Error("read-storage: the page returned no value");
552
- return o;
845
+ if (/foreground tab is not on/.test(last))
846
+ last += ". Put the site on the active tab first: rech goto <url on that host>, then rerun read-storage.";
847
+ throw new Error(last || "read-storage: could not read the value");
553
848
  }
554
849
  var READ_COOKIE_USAGE = `Usage: rech read-cookie <name> <domain> --allow-domain <domain> | <command>
555
850
  Writes the cookie's value, raw and without a newline, to stdout, which must be a pipe into the
@@ -656,6 +951,194 @@ async function readCookieOnSession(opts) {
656
951
  throw new Error("read-cookie: the browser returned no value");
657
952
  return out;
658
953
  }
954
+ var REDACTED_HEADERS = [
955
+ "set-cookie",
956
+ "cookie",
957
+ "authorization",
958
+ "proxy-authorization",
959
+ "www-authenticate",
960
+ "proxy-authenticate",
961
+ "x-api-key",
962
+ "x-csrf-token",
963
+ "x-xsrf-token"
964
+ ];
965
+ var REDACTED_HEADER_PATTERN = "[-_](?:token|secret|api[-_]?key)$";
966
+ var URL_HEADERS = ["location", "content-location"];
967
+ var HEADER_REDACTION = "<redacted>";
968
+ var READ_HEADERS_DEFAULT_SECONDS = 5;
969
+ var READ_HEADERS_MAX_SECONDS = 50;
970
+ var READ_HEADERS_USAGE = `Usage: rech read-headers --allow-domain <host> [--allow-domain <host>]... [--seconds N] [--goto <url>]
971
+ Captures the status code and response headers (NO bodies) of every request on an --allow-domain
972
+ host over a capture window, and writes them as a JSON evidence file in the output dir (an array of
973
+ {url, status, headers}). Credential header values (cookie, set-cookie, authorization,
974
+ *-authenticate, x-api-key, *-token, *-secret, ...) are redacted, as are URL query values,
975
+ fragments and userinfo; then the file goes through the secret scanner.
976
+ --allow-domain GLOB required: capture only responses whose host matches, e.g. app.example.com
977
+ --seconds N capture window in seconds (default ${READ_HEADERS_DEFAULT_SECONDS}, max ${READ_HEADERS_MAX_SECONDS})
978
+ --goto URL first navigate the attached tab to URL (host must match --allow-domain),
979
+ then capture, so a page load's requests are captured (within the window)`;
980
+ function hostFromUrl(u) {
981
+ const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(String(u || ""));
982
+ return m ? m[1].replace(/^[^@]*@/, "").replace(/:\d+$/, "").toLowerCase() : "";
983
+ }
984
+ function redactResponse(e, names, pattern, urlHeaders, mask) {
985
+ const maskUrl = (u) => {
986
+ u = u.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, `$1${mask}@`);
987
+ const hash = u.indexOf("#");
988
+ const frag = hash >= 0 ? `#${mask}` : "";
989
+ if (hash >= 0)
990
+ u = u.slice(0, hash);
991
+ const q = u.indexOf("?");
992
+ if (q < 0)
993
+ return u + frag;
994
+ const query = u.slice(q + 1).split("&").map((p) => p.includes("=") ? `${p.slice(0, p.indexOf("="))}=${mask}` : p ? mask : p).join("&");
995
+ return `${u.slice(0, q)}?${query}${frag}`;
996
+ };
997
+ const credential = new RegExp(pattern, "i");
998
+ const raw = e.headers && typeof e.headers === "object" ? e.headers : {};
999
+ const headers = {};
1000
+ for (const k of Object.keys(raw)) {
1001
+ const n = k.toLowerCase();
1002
+ headers[k] = names.includes(n) || credential.test(n) ? mask : urlHeaders.includes(n) ? maskUrl(String(raw[k])) : String(raw[k]);
1003
+ }
1004
+ const status = Number(e.status);
1005
+ return { url: maskUrl(String(e.url ?? "")), status: Number.isFinite(status) ? status : 0, headers };
1006
+ }
1007
+ function readHeadersEvidence(captured, mask) {
1008
+ return redact(mask(JSON.stringify(captured.map(redactCaptured), null, 2))).text + `
1009
+ `;
1010
+ }
1011
+ function redactCaptured(e) {
1012
+ return redactResponse(e, REDACTED_HEADERS, REDACTED_HEADER_PATTERN, URL_HEADERS, HEADER_REDACTION);
1013
+ }
1014
+ function redactHeaders(headers) {
1015
+ return redactCaptured({ headers }).headers;
1016
+ }
1017
+ function validateSeconds(seconds) {
1018
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > READ_HEADERS_MAX_SECONDS)
1019
+ throw new Error(`read-headers: --seconds must be an integer between 1 and ${READ_HEADERS_MAX_SECONDS}`);
1020
+ return seconds;
1021
+ }
1022
+ function parseReadHeadersArgs(args) {
1023
+ const [cmd, ...rest] = args;
1024
+ if (cmd !== "read-headers")
1025
+ throw new Error("Expected read-headers");
1026
+ const allowDomains = [];
1027
+ let seconds = READ_HEADERS_DEFAULT_SECONDS;
1028
+ let gotoUrl;
1029
+ for (let i = 0;i < rest.length; i++) {
1030
+ const a = rest[i];
1031
+ if (a === "--allow-domain" || a.startsWith("--allow-domain=")) {
1032
+ const v = a === "--allow-domain" ? rest[++i] : a.slice("--allow-domain=".length);
1033
+ if (!v)
1034
+ throw new Error("--allow-domain got an empty host");
1035
+ for (const d of v.split(",")) {
1036
+ domainGlobToRegExp(d.trim());
1037
+ allowDomains.push(d.trim());
1038
+ }
1039
+ } else if (a === "--seconds" || a.startsWith("--seconds=")) {
1040
+ const v = a === "--seconds" ? rest[++i] : a.slice("--seconds=".length);
1041
+ if (!v)
1042
+ throw new Error("--seconds needs a value");
1043
+ seconds = validateSeconds(Number(v));
1044
+ } else if (a === "--goto" || a.startsWith("--goto=")) {
1045
+ gotoUrl = a === "--goto" ? rest[++i] : a.slice("--goto=".length);
1046
+ if (!gotoUrl)
1047
+ throw new Error("--goto got an empty URL");
1048
+ } else if (a.startsWith("-"))
1049
+ throw new Error(`read-headers: unknown option ${a}`);
1050
+ else
1051
+ throw new Error(`read-headers: unexpected argument ${a}`);
1052
+ }
1053
+ if (!allowDomains.length)
1054
+ throw new Error("read-headers needs --allow-domain: name the host to capture responses from");
1055
+ if (gotoUrl)
1056
+ checkGotoUrl(gotoUrl, allowDomains);
1057
+ return { allowDomains, seconds, gotoUrl };
1058
+ }
1059
+ function readHeadersWireArgs(req) {
1060
+ return ["read-headers", ...req.allowDomains.map((d) => `--allow-domain=${d}`), `--seconds=${req.seconds}`, ...req.gotoUrl ? [`--goto=${req.gotoUrl}`] : []];
1061
+ }
1062
+ function parseReadHeadersWire(args) {
1063
+ const [cmd, ...rest] = args;
1064
+ if (cmd !== "read-headers")
1065
+ throw new Error("read-headers expected");
1066
+ const allowDomains = [];
1067
+ let seconds = READ_HEADERS_DEFAULT_SECONDS;
1068
+ let gotoUrl;
1069
+ for (const a of rest) {
1070
+ if (a.startsWith("--allow-domain=")) {
1071
+ const d = a.slice("--allow-domain=".length);
1072
+ domainGlobToRegExp(d);
1073
+ allowDomains.push(d);
1074
+ } else if (a.startsWith("--seconds=")) {
1075
+ seconds = validateSeconds(Number(a.slice("--seconds=".length)));
1076
+ } else if (a.startsWith("--goto=")) {
1077
+ gotoUrl = a.slice("--goto=".length);
1078
+ } else
1079
+ throw new Error(`read-headers: unexpected argument ${a.startsWith("-") ? a : "(positional)"}`);
1080
+ }
1081
+ if (!allowDomains.length)
1082
+ throw new Error("read-headers needs --allow-domain");
1083
+ if (gotoUrl)
1084
+ checkGotoUrl(gotoUrl, allowDomains);
1085
+ return { allowDomains, seconds, gotoUrl };
1086
+ }
1087
+ function buildReadHeadersCode(allowDomains, seconds, gotoUrl) {
1088
+ const patterns = allowDomains.map((d) => domainGlobToRegExp(d).source);
1089
+ return `async page => {
1090
+ const allowed = ${JSON.stringify(patterns)};
1091
+ const redact = ${redactResponse.toString()};
1092
+ const hostOf = (u) => { const m = /^[a-z][a-z0-9+.-]*:\\/\\/([^/?#]+)/i.exec(String(u || "")); return m ? m[1].replace(/^[^@]*@/, "").replace(/:\\d+$/, "").toLowerCase() : ""; };
1093
+ const ok = (h) => h && allowed.some(p => new RegExp(p, "i").test(h));
1094
+ const captured = [];
1095
+ const onResponse = async (response) => {
1096
+ try {
1097
+ const url = String(response.url());
1098
+ const host = hostOf(url);
1099
+ if (!ok(host)) return;
1100
+ let headers = {};
1101
+ try { headers = await response.allHeaders(); } catch { try { headers = response.headers(); } catch { headers = {}; } }
1102
+ captured.push(redact({ url, status: response.status(), headers }, ${JSON.stringify(REDACTED_HEADERS)}, ${JSON.stringify(REDACTED_HEADER_PATTERN)}, ${JSON.stringify(URL_HEADERS)}, ${JSON.stringify(HEADER_REDACTION)}));
1103
+ } catch {}
1104
+ };
1105
+ page.on("response", onResponse);
1106
+ // The window includes the --goto: the whole capture fits in the one request.
1107
+ const endAt = Date.now() + ${seconds} * 1000;
1108
+ try {
1109
+ ${gotoUrl ? `try { await page.goto(${JSON.stringify(gotoUrl)}, { waitUntil: "load", timeout: ${seconds * 1000} }); } catch {}` : ""}
1110
+ await page.waitForTimeout(Math.max(0, endAt - Date.now()));
1111
+ } finally {
1112
+ try { page.off("response", onResponse); } catch {}
1113
+ }
1114
+ return JSON.stringify({ host: ${JSON.stringify(allowDomains[0] ?? "")}, count: captured.length, captured });
1115
+ }`;
1116
+ }
1117
+ async function readHeadersOnSession(opts) {
1118
+ const timeoutMs = opts.seconds * 1000 + 15000;
1119
+ const { isError, text } = await runCodeOnSession({ socketRoot: opts.socketRoot, session: opts.session, cwd: opts.cwd, code: buildReadHeadersCode(opts.allowDomains, opts.seconds, opts.gotoUrl), json: true, timeoutMs });
1120
+ if (isError)
1121
+ throw new Error(text.match(/read-headers(?: refused)?: [^\n]*/)?.[0] ?? (/no browser is open|could not reach/.test(text) ? text.trim().split(`
1122
+ `)[0] : "read-headers: could not capture responses"));
1123
+ let raw;
1124
+ try {
1125
+ raw = JSON.parse(text).result;
1126
+ } catch {
1127
+ raw = text.match(/### Result\n([^\n]*)/)?.[1];
1128
+ }
1129
+ let out = raw;
1130
+ for (let i = 0;i < 2 && typeof out === "string"; i++) {
1131
+ try {
1132
+ out = JSON.parse(out);
1133
+ } catch {
1134
+ out = undefined;
1135
+ }
1136
+ }
1137
+ if (!out || !Array.isArray(out.captured))
1138
+ throw new Error("read-headers: the browser returned no capture");
1139
+ const captured = out.captured.map((e) => redactCaptured(e && typeof e === "object" ? e : {}));
1140
+ return { host: typeof out.host === "string" ? out.host : opts.allowDomains[0] ?? "", captured };
1141
+ }
659
1142
  var PASSWORD_FIELDS = 'input[type=password], input[autocomplete~="current-password" i], input[autocomplete~="new-password" i], input[name*="pass" i], input[id*="pass" i]';
660
1143
  var PASSWORD_VALUES_CODE = `async page => {
661
1144
  const values = [];
@@ -704,24 +1187,34 @@ function maskPasswordLines(text) {
704
1187
  }
705
1188
  export {
706
1189
  FILL_SECRET_USAGE,
1190
+ HEADER_REDACTION,
707
1191
  MIN_MASK_LENGTH,
708
1192
  READ_COOKIE_USAGE,
1193
+ READ_HEADERS_DEFAULT_SECONDS,
1194
+ READ_HEADERS_MAX_SECONDS,
1195
+ READ_HEADERS_USAGE,
709
1196
  READ_SECRET_USAGE,
710
1197
  READ_STORAGE_USAGE,
1198
+ REDACTED_HEADERS,
1199
+ REDACTED_HEADER_PATTERN,
711
1200
  SECRET_MASK,
712
1201
  SECRET_MASK_TTL_MS,
713
1202
  SecretMasker,
1203
+ URL_HEADERS,
714
1204
  base32Decode,
715
1205
  buildFillCode,
716
1206
  buildReadCode,
717
1207
  buildReadCookieCode,
1208
+ buildReadHeadersCode,
718
1209
  buildReadStorageCode,
1210
+ checkGotoUrl,
719
1211
  daemonSessionSockets,
720
1212
  domainGlobToRegExp,
721
1213
  fillSecretOnSession,
722
1214
  fillSecretWireArgs,
723
1215
  hasSnapshot,
724
1216
  hostAllowed,
1217
+ hostFromUrl,
725
1218
  maskPasswordLines,
726
1219
  maskable,
727
1220
  parseDotenv,
@@ -729,6 +1222,8 @@ export {
729
1222
  parseFillSecretWire,
730
1223
  parseReadCookieArgs,
731
1224
  parseReadCookieWire,
1225
+ parseReadHeadersArgs,
1226
+ parseReadHeadersWire,
732
1227
  parseReadSecretArgs,
733
1228
  parseReadSecretWire,
734
1229
  parseReadStorageArgs,
@@ -738,11 +1233,18 @@ export {
738
1233
  passwordValuesOnSession,
739
1234
  readCookieOnSession,
740
1235
  readCookieWireArgs,
1236
+ readHeadersEvidence,
1237
+ readHeadersOnSession,
1238
+ readHeadersWireArgs,
741
1239
  readSecretOnSession,
742
1240
  readSecretSource,
743
1241
  readSecretWireArgs,
744
1242
  readStorageOnSession,
745
1243
  readStorageWireArgs,
1244
+ redactCaptured,
1245
+ redactHeaders,
1246
+ redactResponse,
1247
+ runCliOnSession,
746
1248
  runCodeOnSession,
747
1249
  sessionSocketCandidates,
748
1250
  socketRequest,