rechrome 1.40.0 → 1.42.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/rechrome.ts CHANGED
@@ -5,9 +5,10 @@ import { readListeners, writeListeners as writeListenersFile, listenerAddress, i
5
5
  import { file } from "bun";
6
6
  import yargs from "yargs";
7
7
  import { readExtensionTokenFromProfile } from "./extension-token.ts";
8
- import { FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
8
+ import { redact } from "./redact.ts";
9
+ import { READ_SECRET_USAGE, parseReadSecretArgs, readSecretWireArgs, FILL_SECRET_USAGE, SECRET_MASK, parseFillSecretArgs, fillSecretWireArgs, readSecretSource, totpCode, totpWaitMs } from "./fill-secret.ts";
9
10
  import { createHash, randomBytes } from "crypto";
10
- import { mkdirSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
11
+ import { mkdirSync, fstatSync, appendFileSync, existsSync, realpathSync, accessSync, cpSync, unlinkSync, readFileSync, readdirSync, renameSync, readlinkSync, rmdirSync, constants as fsConstants } from "fs";
11
12
  import { hostname, homedir, networkInterfaces } from "os";
12
13
  import { isIPv4 } from "net";
13
14
  import { join, basename, dirname, resolve } from "path";
@@ -642,7 +643,7 @@ export async function getOrCreateUrl(persist = true): Promise<string> {
642
643
  const envRaw = await file(globalEnvFile).text().catch(() => "");
643
644
  const lines = envRaw.trimEnd().split("\n").filter(l => !l.startsWith(`${ENV_KEY}=`));
644
645
  const content = [...lines, newLine, ""].join("\n");
645
- await Bun.write(globalEnvFile, content);
646
+ await writeAtomic(globalEnvFile, content, 0o600); // holds the management key
646
647
  }
647
648
  process.env[ENV_KEY] = url;
648
649
  return url;
@@ -1779,7 +1780,7 @@ async function printProfileUri(selector?: string, listener?: string, opts: { loc
1779
1780
  if (!picked.startsWith(SETUP)) return picked;
1780
1781
  const dir = picked.slice(SETUP.length);
1781
1782
  console.error(`[rech] ${dir} isn't set up yet; running rech setup --profile ${JSON.stringify(dir)} first.`);
1782
- await setup({ profile: dir });
1783
+ await setup({ profile: dir, skipShare: true });
1783
1784
  const after = await readTokenRegistry();
1784
1785
  const key = unregisteredChromeProfiles(after, { [dir]: {} }).length ? undefined : canonicalProfileKeys(after).find(k => after[k].profileDir === dir);
1785
1786
  if (process.exitCode || !key) throw new Error(`Setup didn't register ${dir}; nothing shared. Retry with: rech setup --profile ${JSON.stringify(dir)}`);
@@ -1931,7 +1932,7 @@ async function callServe(
1931
1932
  throwOnFailure = false,
1932
1933
  upload?: UploadPayload,
1933
1934
  secret?: string,
1934
- ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean }> {
1935
+ ): Promise<{ status: number; stdout: string; stderr: string; files?: string[]; existingSession?: boolean; secretOut?: string }> {
1935
1936
  const { key, host, port, protocol, extensionId, extensionToken, profileDirectory, userDataDir, loadExtension } = parseUrl(url);
1936
1937
  // Reuse the caller's identity when provided — computing it shells out to `git` several times,
1937
1938
  // and run() has already done so for its log line. Recomputing here would double those git
@@ -2168,6 +2169,29 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2168
2169
  let secret: string | undefined;
2169
2170
  let upload: UploadPayload | undefined;
2170
2171
  const cmdIdx = args.findIndex(a => !a.startsWith("-s="));
2172
+ if (args[cmdIdx] === "read-secret") {
2173
+ if (args.some(a => a === "--help" || a === "-h")) { console.log(READ_SECRET_USAGE); process.exit(0); }
2174
+ let wire: string[];
2175
+ try {
2176
+ // Only a pipe: a terminal would show the secret, and a file would keep it. Checked before
2177
+ // asking the host for it.
2178
+ const out = fstatSync(1);
2179
+ if (!out.isFIFO() && !out.isSocket()) throw new Error("read-secret writes the secret to stdout, which must be a pipe into the program that stores it (not a terminal or a file)");
2180
+ wire = readSecretWireArgs(parseReadSecretArgs(args.slice(cmdIdx)));
2181
+ } catch (err) {
2182
+ console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${READ_SECRET_USAGE}`);
2183
+ process.exit(2);
2184
+ }
2185
+ const res = await callServe(url, [...args.filter(a => a.startsWith("-s=")), ...wire], overrideEnv, identity);
2186
+ if (res.status !== 0 || typeof res.secretOut !== "string") {
2187
+ process.stderr.write(res.stderr || (res.status === 0 ? "[rech] read-secret: this daemon is older than the client (no value returned)\n" : ""));
2188
+ process.exit(res.status || 1);
2189
+ }
2190
+ // Wait until the pipe has taken all of it before exiting.
2191
+ await new Promise<void>((done, fail) => process.stdout.write(res.secretOut!, e => e ? fail(e) : done()));
2192
+ console.error(`[rech] read-secret: wrote ${res.secretOut.length} characters to stdout`);
2193
+ process.exit(0);
2194
+ }
2171
2195
  if (args[cmdIdx] === "fill-secret") {
2172
2196
  if (args.some(a => a === "--help" || a === "-h")) { console.log(FILL_SECRET_USAGE); process.exit(0); }
2173
2197
  try {
@@ -2188,7 +2212,13 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
2188
2212
  }
2189
2213
  const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
2190
2214
  // The daemon already masks the value; this is a second line of defence for an older daemon.
2191
- const scrub = (s: string) => secret && s ? s.replaceAll(secret, SECRET_MASK) : s;
2215
+ // Then any secret-shaped string (API keys, tokens) in page text, unless RECH_REDACT=0.
2216
+ const scrub = (s: string) => {
2217
+ if (!s) return s;
2218
+ if (secret) s = s.replaceAll(secret, SECRET_MASK);
2219
+ if (process.env.RECH_REDACT === "0") return s;
2220
+ try { return redact(s).text; } catch { return s; } // missing rules must not swallow output
2221
+ };
2192
2222
  const { files, existingSession } = served;
2193
2223
  const status = served.status, stdout = scrub(served.stdout), stderr = scrub(served.stderr);
2194
2224
  if (!opts.bridgeRetried && isBridgeTimeout(args, { status, stderr })) {
@@ -2730,7 +2760,7 @@ export async function daemonInstall(serveUrl: string): Promise<void> {
2730
2760
  const envRaw = await file(globalEnvFile).text().catch(() => "");
2731
2761
  const lines = envRaw.trimEnd().split("\n");
2732
2762
  const filtered = lines.filter(l => !l.startsWith(`${ENV_KEY}=`));
2733
- await Bun.write(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"));
2763
+ await writeAtomic(globalEnvFile, [...filtered, envAssignment(ENV_KEY, keepProfileParam(serveUrl, lines.find(l => l.startsWith(`${ENV_KEY}=`))?.slice(ENV_KEY.length + 1))), ""].join("\n"), 0o600);
2734
2764
 
2735
2765
  const home = HOME;
2736
2766
  const bunBin = Bun.which("bun") ?? process.execPath;
@@ -3338,7 +3368,7 @@ async function rotateKey(name: string): Promise<void> {
3338
3368
  console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
3339
3369
  }
3340
3370
 
3341
- async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void> {
3371
+ async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void> {
3342
3372
  const config = await requireListeners();
3343
3373
  let listener = config.listeners.find(l => l.name === name);
3344
3374
  if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
@@ -3356,7 +3386,12 @@ async function setListener(name: string, opts: { publicUrl?: string; clearPublic
3356
3386
  else policy.hostDirs = [...new Set([...(policy.hostDirs ?? []), ...opts.hostUploadDirs!.map(d => realpathSync(resolve(d)))])];
3357
3387
  listener.uploads = policy;
3358
3388
  }
3389
+ if (opts.readSecrets !== undefined) {
3390
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: read-secret is always available there.`);
3391
+ if (opts.readSecrets) listener.readSecrets = true; else delete listener.readSecrets;
3392
+ }
3359
3393
  await writeListeners(config);
3394
+ if (opts.readSecrets !== undefined) console.log(opts.readSecrets ? `${name}: read-secret on. Anyone with this link can copy the value of any field (saved passwords included) on any site its profiles have open; --allow-domain only checks the site the caller names. Each read is logged without the value` : `${name}: read-secret off`);
3360
3395
  if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
3361
3396
  if (opts.hostUploadDirs?.length || opts.noHostUploads) {
3362
3397
  const dirs = (listener.uploads || {}).hostDirs;
@@ -3427,7 +3462,6 @@ async function saveGlobalUrl(url: string): Promise<string> {
3427
3462
  return writeEnvUrl(GLOBAL_ENV_FILE, url);
3428
3463
  }
3429
3464
 
3430
- /** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
3431
3465
  /** Write a file whole or not at all: a temp file beside it, then rename (atomic on one filesystem). */
3432
3466
  export async function writeAtomic(path: string, data: string, mode?: number): Promise<void> {
3433
3467
  // A symlinked .env.local stays a link: write the file it points to.
@@ -3464,6 +3498,7 @@ export async function ensureProjectDir(dir: string): Promise<void> {
3464
3498
  if (!existsSync(readme)) await writeAtomic(readme, PROJECT_DIR_README);
3465
3499
  }
3466
3500
 
3501
+ /** Replace RECHROME_URL in an env file, keeping its other lines; owner-only, since the URL holds a key. */
3467
3502
  async function writeEnvUrl(envPath: string, url: string): Promise<string> {
3468
3503
  const lines = (await file(envPath).text().catch(() => "")).split("\n").filter(l => l.trim() && !l.startsWith(`${ENV_KEY}=`));
3469
3504
  await writeAtomic(envPath, [...lines, envAssignment(ENV_KEY, url), ""].join("\n"), 0o600);
@@ -3974,7 +4009,7 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
3974
4009
  guide?.close();
3975
4010
  console.log(` Saved token for ${extraDisplay}`);
3976
4011
  }
3977
- if (bridgeVerified && isTTY && !opts.listen) await setupShareStep(ask, profileEmail);
4012
+ if (bridgeVerified && isTTY && !opts.listen && !opts.skipShare) await setupShareStep(ask, profileEmail);
3978
4013
  rl?.close();
3979
4014
  envWatcher?.close();
3980
4015
  if (bridgeVerified)
@@ -4423,7 +4458,7 @@ async function status(): Promise<void> {
4423
4458
  }
4424
4459
 
4425
4460
 
4426
- export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
4461
+ export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean; /** Called from `rech share`, which shares the profile itself. */ skipShare?: boolean };
4427
4462
  export type RechHandlers = {
4428
4463
  serve(): Promise<void> | void;
4429
4464
  status(): Promise<void>;
@@ -4440,7 +4475,7 @@ export type RechHandlers = {
4440
4475
  allowListener(name: string, profiles: string[]): Promise<void>;
4441
4476
  denyListener(name: string, profiles: string[]): Promise<void>;
4442
4477
  rotateKey(name: string): Promise<void>;
4443
- setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean }): Promise<void>;
4478
+ setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean; hostUploadDirs?: string[]; noHostUploads?: boolean; readSecrets?: boolean }): Promise<void>;
4444
4479
  setup(opts: SetupOptions): Promise<void>;
4445
4480
  tray(action?: string): Promise<void>;
4446
4481
  provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
@@ -4473,6 +4508,8 @@ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
4473
4508
  --isolate in a throwaway session, e.g. for a login flow
4474
4509
  rech fill-secret <ref> --from-env VAR | --totp-from-env VAR [--env-file f] [--allow-domain '*.example.com']
4475
4510
  type a password / TOTP code without it reaching argv, logs or output
4511
+ rech read-secret <ref> --allow-domain <host> | <vault command>
4512
+ copy a field's value (e.g. an API secret) into a pipe, never shown or logged
4476
4513
  rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
4477
4514
  rech --version rechrome's version
4478
4515
 
@@ -4588,9 +4625,10 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
4588
4625
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
4589
4626
  .option("uploads", { type: "boolean", describe: "Uploads of images the link holder sends (rech upload) are on by default; --no-uploads turns them off" })
4590
4627
  .option("host-upload-dir", { type: "string", array: true, requiresArg: true, describe: "Off by default. Also let links upload files already on this host, from inside this directory (rech upload --host <path>); repeatable" })
4628
+ .option("read-secrets", { type: "boolean", describe: "Off by default. Let this listener's links copy any field's value (saved passwords too) into a pipe (rech read-secret): only for links you trust with those accounts. --no-read-secrets turns it off" })
4591
4629
  .option("host-uploads", { type: "boolean", describe: "--no-host-uploads turns host-path uploads off again" })
4592
- .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
4593
- a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}) }))
4630
+ .check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined || a.hostUploadDir?.length || a.hostUploads === false || a.readSecrets !== undefined ? true : "Pass --public-url <url>, --clear-public-url, --uploads/--no-uploads, or --host-upload-dir/--no-host-uploads"),
4631
+ a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}), ...(a.hostUploadDir?.length ? { hostUploadDirs: a.hostUploadDir as string[] } : {}), ...(a.hostUploads === false ? { noHostUploads: true } : {}), ...(a.readSecrets !== undefined ? { readSecrets: a.readSecrets } : {}) }))
4594
4632
  .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
4595
4633
  a => handlers.listenerPort(a.name))
4596
4634
  .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
package/redact.js ADDED
@@ -0,0 +1,236 @@
1
+ // @bun
2
+ // redact.ts
3
+ import { readFileSync } from "fs";
4
+ function goToJs(src) {
5
+ let flags = "";
6
+ for (;; ) {
7
+ const m = /^\(\?([is]+)\)/.exec(src);
8
+ if (!m)
9
+ break;
10
+ flags += m[1];
11
+ src = src.slice(m[0].length);
12
+ }
13
+ 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");
14
+ let out = "";
15
+ const closers = [];
16
+ let extra = 0;
17
+ for (let i = 0;i < src.length; i++) {
18
+ const c = src[i];
19
+ if (c === "\\") {
20
+ out += c + (src[i + 1] ?? "");
21
+ i++;
22
+ continue;
23
+ }
24
+ if (c === "[") {
25
+ let j = i + 1;
26
+ if (src[j] === "^")
27
+ j++;
28
+ if (src[j] === "]")
29
+ j++;
30
+ while (j < src.length && src[j] !== "]") {
31
+ if (src[j] === "\\")
32
+ j++;
33
+ j++;
34
+ }
35
+ out += src.slice(i, j + 1);
36
+ i = j;
37
+ continue;
38
+ }
39
+ if (src.startsWith("(?i)", i)) {
40
+ out += "(?i:";
41
+ extra++;
42
+ i += 3;
43
+ continue;
44
+ }
45
+ if (c === "(") {
46
+ closers.push(extra);
47
+ extra = 0;
48
+ out += c;
49
+ continue;
50
+ }
51
+ if (c === ")") {
52
+ out += ")".repeat(extra);
53
+ extra = closers.pop() ?? 0;
54
+ out += c;
55
+ continue;
56
+ }
57
+ if (c === "|" && extra) {
58
+ out += ")".repeat(extra) + "|" + "(?i:".repeat(extra);
59
+ continue;
60
+ }
61
+ out += c;
62
+ }
63
+ out += ")".repeat(extra);
64
+ return { source: out, flags: [...new Set(flags)].join("") };
65
+ }
66
+ function loadGitleaksRules(toml) {
67
+ const c = Bun.TOML.parse(toml);
68
+ const compile = (r) => {
69
+ const j = goToJs(r);
70
+ return new RegExp(j.source, j.flags);
71
+ };
72
+ const gAllow = (c.allowlist?.regexes ?? []).map(compile);
73
+ const gStop = (c.allowlist?.stopwords ?? []).map((s) => s.toLowerCase());
74
+ const rules = [];
75
+ for (const r of c.rules) {
76
+ if (!r.regex)
77
+ continue;
78
+ const allow = [...gAllow];
79
+ const allowMatch = [];
80
+ const allowLine = [];
81
+ const stop = [...gStop];
82
+ for (const a of [...r.allowlists ?? [], ...r.allowlist ? [r.allowlist] : []]) {
83
+ const into = a.regexTarget === "match" ? allowMatch : a.regexTarget === "line" ? allowLine : allow;
84
+ for (const x of a.regexes ?? [])
85
+ into.push(compile(x));
86
+ for (const s of a.stopwords ?? [])
87
+ stop.push(String(s).toLowerCase());
88
+ }
89
+ const j = goToJs(r.regex);
90
+ rules.push({
91
+ id: r.id,
92
+ re: new RegExp(j.source, j.flags + "gd"),
93
+ keywords: (r.keywords ?? []).map((k) => k.toLowerCase()),
94
+ ...r.entropy ? { entropy: r.entropy } : {},
95
+ ...r.secretGroup ? { group: r.secretGroup } : {},
96
+ allow,
97
+ ...allowMatch.length ? { allowMatch } : {},
98
+ ...allowLine.length ? { allowLine } : {},
99
+ stop
100
+ });
101
+ }
102
+ return rules;
103
+ }
104
+ var OWN_RULES = [
105
+ { 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: [] },
106
+ { 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: [] },
107
+ { 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: [] }
108
+ ];
109
+ var cached;
110
+ function secretRules() {
111
+ cached ??= [...loadGitleaksRules(readFileSync(new URL("./secret-rules/gitleaks.toml", import.meta.url), "utf8")), ...OWN_RULES];
112
+ return cached;
113
+ }
114
+ function shannon(s) {
115
+ const f = new Map;
116
+ for (const ch of s)
117
+ f.set(ch, (f.get(ch) ?? 0) + 1);
118
+ let h = 0;
119
+ for (const n of f.values()) {
120
+ const p = n / s.length;
121
+ h -= p * Math.log2(p);
122
+ }
123
+ return h;
124
+ }
125
+ function findKnown(text, known) {
126
+ const forms = new Set;
127
+ for (const v of known) {
128
+ if (v.length < 6)
129
+ continue;
130
+ forms.add(v);
131
+ forms.add(JSON.stringify(v).slice(1, -1));
132
+ }
133
+ const out = [];
134
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) {
135
+ for (let i = text.indexOf(f);i >= 0; i = text.indexOf(f, i + f.length))
136
+ out.push({ id: "known-secret", start: i, end: i + f.length });
137
+ }
138
+ return out;
139
+ }
140
+ var OPAQUE_RUN = /[A-Za-z0-9+/=_-]{4096,}/g;
141
+ function findSecrets(text, opts = {}) {
142
+ if (text.length >= 4096 && OPAQUE_RUN.test(text)) {
143
+ OPAQUE_RUN.lastIndex = 0;
144
+ const out = opts.known?.length ? findKnown(text, opts.known) : [];
145
+ let at = 0;
146
+ for (const m of [...text.matchAll(OPAQUE_RUN), { index: text.length, 0: "" }]) {
147
+ const piece = text.slice(at, m.index);
148
+ if (piece)
149
+ for (const h of findSecrets(piece, { ...opts, known: [] }))
150
+ out.push({ ...h, start: h.start + at, end: h.end + at });
151
+ at = m.index + m[0].length;
152
+ }
153
+ return out;
154
+ }
155
+ OPAQUE_RUN.lastIndex = 0;
156
+ const rules = opts.rules ?? secretRules();
157
+ const lower = text.toLowerCase();
158
+ const out = opts.known?.length ? findKnown(text, opts.known) : [];
159
+ for (const r of rules) {
160
+ if (r.keywords.length && !r.keywords.some((k) => lower.includes(k)))
161
+ continue;
162
+ for (const m of text.matchAll(r.re)) {
163
+ const gi = r.group ?? m.findIndex((g, i) => i > 0 && !!g);
164
+ const secret = gi > 0 ? m[gi] : m[0];
165
+ if (!secret)
166
+ continue;
167
+ if (r.entropy && shannon(secret) < r.entropy)
168
+ continue;
169
+ if (r.allow.some((a) => a.test(secret)))
170
+ continue;
171
+ if (r.allowMatch?.some((a) => a.test(m[0])))
172
+ continue;
173
+ if (r.allowLine) {
174
+ const ls = text.lastIndexOf(`
175
+ `, m.index) + 1;
176
+ const le = text.indexOf(`
177
+ `, m.index + m[0].length);
178
+ if (r.allowLine.some((a) => a.test(text.slice(ls, le < 0 ? undefined : le))))
179
+ continue;
180
+ }
181
+ const sl = secret.toLowerCase();
182
+ if (r.stop.some((s) => sl.includes(s)))
183
+ continue;
184
+ const span = r.group ? m.indices?.[r.group] : undefined;
185
+ if (span)
186
+ out.push({ id: r.id, start: span[0], end: span[1] });
187
+ else {
188
+ let a = m.index;
189
+ let b = m.index + m[0].length;
190
+ const edge = /[\s'"`;=:,]/;
191
+ while (a < b && edge.test(text[a]))
192
+ a++;
193
+ for (;; ) {
194
+ if (b > a && edge.test(text[b - 1]))
195
+ b--;
196
+ else if (b - 2 >= a && /\\[nr]/.test(text.slice(b - 2, b)))
197
+ b -= 2;
198
+ else
199
+ break;
200
+ }
201
+ out.push({ id: r.id, start: a, end: b });
202
+ }
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ function redact(text, opts = {}) {
208
+ const hits = findSecrets(text, opts).sort((a, b) => a.start - b.start || b.end - a.end);
209
+ if (!hits.length)
210
+ return { text, kinds: [] };
211
+ const spans = [];
212
+ for (const h of hits) {
213
+ const last = spans[spans.length - 1];
214
+ if (last && h.start < last.end) {
215
+ if (h.end - h.start > last.end - last.start)
216
+ last.id = h.id;
217
+ last.end = Math.max(last.end, h.end);
218
+ } else
219
+ spans.push({ ...h });
220
+ }
221
+ let out = "";
222
+ let at = 0;
223
+ for (const s of spans) {
224
+ out += text.slice(at, s.start) + `[redacted:${s.id}]`;
225
+ at = s.end;
226
+ }
227
+ return { text: out + text.slice(at), kinds: [...new Set(spans.map((s) => s.id))] };
228
+ }
229
+ export {
230
+ OWN_RULES,
231
+ findSecrets,
232
+ goToJs,
233
+ loadGitleaksRules,
234
+ redact,
235
+ secretRules
236
+ };
package/redact.ts ADDED
@@ -0,0 +1,242 @@
1
+ // Secret redaction, copied from snomiao/slack-term ts/redact.ts (PR #68, 73ae26e); keep
2
+ // the two in sync. rech runs it on every command's stdout/stderr; each secret becomes
3
+ // `[redacted:<rule id>]`. RECH_REDACT=0 turns it off for one call.
4
+ //
5
+ // Rules: gitleaks' default set (secret-rules/gitleaks.toml, v8.30.1, MIT — see
6
+ // secret-rules/gitleaks.LICENSE), run in-process by the JS regex engine, plus
7
+ // our own for what it does not cover (OWN_RULES). Measured 2026-11-02 on
8
+ // 1,000 synthetic messages (tests/redact.test.ts builds the same set):
9
+ // 0.1 ms and ~60 MB RSS per process, against ~0.9 s and ~62 MB per message
10
+ // for spawning the gitleaks binary, with the same catches.
11
+ import { readFileSync } from "node:fs";
12
+
13
+ export type SecretRule = {
14
+ id: string;
15
+ re: RegExp;
16
+ /** Lowercase; the rule runs only when the text contains one of them. */
17
+ keywords: string[];
18
+ /** Minimum Shannon entropy of the secret (gitleaks' `entropy`). */
19
+ entropy?: number;
20
+ /** Which capture group is the secret (gitleaks' `secretGroup`). */
21
+ group?: number;
22
+ /** Allowlist regexes tested against the secret (gitleaks' regexTarget = secret, the default)… */
23
+ allow: RegExp[];
24
+ /** …against the whole match (regexTarget = match)… */
25
+ allowMatch?: RegExp[];
26
+ /** …and against the line the match is on (regexTarget = line). */
27
+ allowLine?: RegExp[];
28
+ stop: string[];
29
+ };
30
+
31
+ /** Go RE2 → JS. A leading `(?i)`/`(?s)` becomes a flag; a mid-pattern `(?i)`
32
+ * a scoped `(?i:…)` to the end of its group (JS has scoped modifiers, not
33
+ * RE2's open-ended ones); `(?P<n>` → `(?<n>`; POSIX classes spelled out;
34
+ * `\z` → `$`. */
35
+ export function goToJs(src: string): { source: string; flags: string } {
36
+ let flags = "";
37
+ for (;;) {
38
+ const m = /^\(\?([is]+)\)/.exec(src);
39
+ if (!m) break;
40
+ flags += m[1];
41
+ src = src.slice(m[0].length);
42
+ }
43
+ src = src.replaceAll("(?P<", "(?<").replaceAll("\\z", "$")
44
+ .replaceAll("[[:alnum:]]", "[A-Za-z0-9]").replaceAll("[:alnum:]", "A-Za-z0-9")
45
+ .replaceAll("[:alpha:]", "A-Za-z").replaceAll("[:digit:]", "0-9").replaceAll("[:space:]", "\\s");
46
+ let out = "";
47
+ const closers: number[] = []; // per open group: the scoped (?i: opened inside it
48
+ let extra = 0;
49
+ for (let i = 0; i < src.length; i++) {
50
+ const c = src[i]!;
51
+ if (c === "\\") { out += c + (src[i + 1] ?? ""); i++; continue; }
52
+ if (c === "[") { // a character class, verbatim
53
+ let j = i + 1;
54
+ if (src[j] === "^") j++;
55
+ if (src[j] === "]") j++;
56
+ while (j < src.length && src[j] !== "]") { if (src[j] === "\\") j++; j++; }
57
+ out += src.slice(i, j + 1);
58
+ i = j;
59
+ continue;
60
+ }
61
+ if (src.startsWith("(?i)", i)) { out += "(?i:"; extra++; i += 3; continue; }
62
+ if (c === "(") { closers.push(extra); extra = 0; out += c; continue; }
63
+ if (c === ")") { out += ")".repeat(extra); extra = closers.pop() ?? 0; out += c; continue; }
64
+ // RE2's flag runs to the group's end across `|`; a JS scoped group must not swallow the
65
+ // alternation, so close it before `|` and reopen it after.
66
+ if (c === "|" && extra) { out += ")".repeat(extra) + "|" + "(?i:".repeat(extra); continue; }
67
+ out += c;
68
+ }
69
+ out += ")".repeat(extra);
70
+ return { source: out, flags: [...new Set(flags)].join("") };
71
+ }
72
+
73
+ type TomlAllow = { regexTarget?: string; regexes?: string[]; stopwords?: string[] };
74
+ type TomlRule = { id: string; regex?: string; keywords?: string[]; entropy?: number; secretGroup?: number; allowlists?: TomlAllow[]; allowlist?: TomlAllow };
75
+
76
+ /** gitleaks' rules TOML → rules. Path-only rules (no regex) are skipped. */
77
+ export function loadGitleaksRules(toml: string): SecretRule[] {
78
+ const c = Bun.TOML.parse(toml) as { rules: TomlRule[]; allowlist?: TomlAllow };
79
+ const compile = (r: string): RegExp => { const j = goToJs(r); return new RegExp(j.source, j.flags); };
80
+ const gAllow = (c.allowlist?.regexes ?? []).map(compile);
81
+ const gStop = (c.allowlist?.stopwords ?? []).map((s) => s.toLowerCase());
82
+ const rules: SecretRule[] = [];
83
+ for (const r of c.rules) {
84
+ if (!r.regex) continue;
85
+ const allow = [...gAllow];
86
+ const allowMatch: RegExp[] = [];
87
+ const allowLine: RegExp[] = [];
88
+ const stop = [...gStop];
89
+ for (const a of [...(r.allowlists ?? []), ...(r.allowlist ? [r.allowlist] : [])]) {
90
+ // Each allowlist is tested against what it names, not always the secret
91
+ // (generic-api-key's api_version exclusion is a regexTarget = match one).
92
+ const into = a.regexTarget === "match" ? allowMatch : a.regexTarget === "line" ? allowLine : allow;
93
+ for (const x of a.regexes ?? []) into.push(compile(x));
94
+ for (const s of a.stopwords ?? []) stop.push(String(s).toLowerCase());
95
+ }
96
+ const j = goToJs(r.regex);
97
+ rules.push({
98
+ id: r.id, re: new RegExp(j.source, j.flags + "gd"), keywords: (r.keywords ?? []).map((k) => k.toLowerCase()),
99
+ ...(r.entropy ? { entropy: r.entropy } : {}), ...(r.secretGroup ? { group: r.secretGroup } : {}), allow,
100
+ ...(allowMatch.length ? { allowMatch } : {}), ...(allowLine.length ? { allowLine } : {}), stop,
101
+ });
102
+ }
103
+ return rules;
104
+ }
105
+
106
+ /** Ours, for what gitleaks' set leaves out. */
107
+ export const OWN_RULES: SecretRule[] = [
108
+ // ?token=… / &sig=… in a URL; `<your token>` and xxx-style placeholders are not secrets.
109
+ { 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: [] },
110
+ // OpenAI / Anthropic / OpenRouter style keys, whatever their exact layout.
111
+ { 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: [] },
112
+ // A 6-digit code only right after a word that says it is one: a bare
113
+ // 6-digit number is far more often an amount, an order or a build.
114
+ { id: "otp-code", re: /(?:code|コード|otp|pin|passcode|認証|確認番号|ワンタイム)[^\d\n]{0,20}(?<![\d.])(\d{6})(?![\d.])/gid, group: 1, keywords: [], allow: [], stop: [] },
115
+ ];
116
+
117
+ let cached: SecretRule[] | undefined;
118
+ /** gitleaks' rules + ours, loaded once. */
119
+ export function secretRules(): SecretRule[] {
120
+ cached ??= [...loadGitleaksRules(readFileSync(new URL("./secret-rules/gitleaks.toml", import.meta.url), "utf8")), ...OWN_RULES];
121
+ return cached;
122
+ }
123
+
124
+ function shannon(s: string): number {
125
+ const f = new Map<string, number>();
126
+ for (const ch of s) f.set(ch, (f.get(ch) ?? 0) + 1);
127
+ let h = 0;
128
+ for (const n of f.values()) { const p = n / s.length; h -= p * Math.log2(p); }
129
+ return h;
130
+ }
131
+
132
+ export type SecretHit = { id: string; start: number; end: number };
133
+
134
+ /** Every span of an exact value the caller knows is secret (its own token, a
135
+ * value it filled in), longest first, plus the value's JSON-string spelling
136
+ * (a `"` or `\\` in it is escaped there). The 4th rule, after rechrome's
137
+ * fill-secret scrub; values shorter than 6 are ignored as unsafe to match. */
138
+ function findKnown(text: string, known: readonly string[]): SecretHit[] {
139
+ const forms = new Set<string>();
140
+ for (const v of known) {
141
+ if (v.length < 6) continue;
142
+ forms.add(v);
143
+ forms.add(JSON.stringify(v).slice(1, -1));
144
+ }
145
+ const out: SecretHit[] = [];
146
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) {
147
+ for (let i = text.indexOf(f); i >= 0; i = text.indexOf(f, i + f.length)) out.push({ id: "known-secret", start: i, end: i + f.length });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ export type RedactOptions = { rules?: SecretRule[]; known?: readonly string[] };
153
+
154
+ /** Unbroken runs of base64/hex characters this long are image data or blobs, not secrets (the
155
+ * longest gitleaks secret is a few hundred characters). Scanning them costs seconds per MB, as
156
+ * short keywords such as `api` or `okta` turn up in random base64 by chance. */
157
+ const OPAQUE_RUN = /[A-Za-z0-9+/=_-]{4096,}/g;
158
+
159
+ /** Every secret span in `text`. */
160
+ export function findSecrets(text: string, opts: RedactOptions = {}): SecretHit[] {
161
+ if (text.length >= 4096 && OPAQUE_RUN.test(text)) {
162
+ OPAQUE_RUN.lastIndex = 0;
163
+ // Scan the text between the runs only, keeping the original offsets; known values are still
164
+ // found everywhere, since an exact match is cheap.
165
+ const out: SecretHit[] = opts.known?.length ? findKnown(text, opts.known) : [];
166
+ let at = 0;
167
+ for (const m of [...text.matchAll(OPAQUE_RUN), { index: text.length, 0: '' } as unknown as RegExpExecArray]) {
168
+ const piece = text.slice(at, m.index);
169
+ if (piece) for (const h of findSecrets(piece, { ...opts, known: [] })) out.push({ ...h, start: h.start + at, end: h.end + at });
170
+ at = m.index + m[0].length;
171
+ }
172
+ return out;
173
+ }
174
+ OPAQUE_RUN.lastIndex = 0;
175
+ const rules = opts.rules ?? secretRules();
176
+ const lower = text.toLowerCase();
177
+ const out: SecretHit[] = opts.known?.length ? findKnown(text, opts.known) : [];
178
+ for (const r of rules) {
179
+ if (r.keywords.length && !r.keywords.some((k) => lower.includes(k))) continue;
180
+ for (const m of text.matchAll(r.re)) {
181
+ // What gitleaks calls the secret — the configured group, else the first
182
+ // that matched — decides entropy and the secret allowlist. What is
183
+ // MASKED is that group only when the rule names it; otherwise the whole
184
+ // match, since the first group can be a harmless fragment (a webhook's
185
+ // UUID, a JWT's fixed header) and the real secret would stay visible.
186
+ // Spans come from the match's own indices (the `d` flag): looking a
187
+ // value up in m[0] finds an EARLIER copy (`?secret=secret`, rechrome #55).
188
+ const gi = r.group ?? m.findIndex((g, i) => i > 0 && !!g);
189
+ const secret = gi > 0 ? m[gi] : m[0];
190
+ if (!secret) continue;
191
+ if (r.entropy && shannon(secret) < r.entropy) continue;
192
+ if (r.allow.some((a) => a.test(secret))) continue;
193
+ if (r.allowMatch?.some((a) => a.test(m[0]))) continue;
194
+ if (r.allowLine) {
195
+ const ls = text.lastIndexOf("\n", m.index) + 1;
196
+ const le = text.indexOf("\n", m.index + m[0].length);
197
+ if (r.allowLine.some((a) => a.test(text.slice(ls, le < 0 ? undefined : le)))) continue;
198
+ }
199
+ const sl = secret.toLowerCase();
200
+ if (r.stop.some((s) => sl.includes(s))) continue;
201
+ const span = r.group ? m.indices?.[r.group] : undefined;
202
+ if (span) out.push({ id: r.id, start: span[0], end: span[1] });
203
+ else {
204
+ // gitleaks' patterns end (and some start) with a boundary — space,
205
+ // quote, `;`, a literal \n — that is context, not secret: keep it.
206
+ let a = m.index;
207
+ let b = m.index + m[0].length;
208
+ const edge = /[\s'"`;=:,]/;
209
+ while (a < b && edge.test(text[a]!)) a++;
210
+ for (;;) {
211
+ if (b > a && edge.test(text[b - 1]!)) b--;
212
+ else if (b - 2 >= a && /\\[nr]/.test(text.slice(b - 2, b))) b -= 2;
213
+ else break;
214
+ }
215
+ out.push({ id: r.id, start: a, end: b });
216
+ }
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /** `text` with every secret replaced by `[redacted:<rule id>]`. Overlapping
223
+ * hits merge into one span, named by the longest. */
224
+ export function redact(text: string, opts: RedactOptions = {}): { text: string; kinds: string[] } {
225
+ const hits = findSecrets(text, opts).sort((a, b) => a.start - b.start || b.end - a.end);
226
+ if (!hits.length) return { text, kinds: [] };
227
+ const spans: SecretHit[] = [];
228
+ for (const h of hits) {
229
+ const last = spans[spans.length - 1];
230
+ if (last && h.start < last.end) {
231
+ if (h.end - h.start > last.end - last.start) last.id = h.id;
232
+ last.end = Math.max(last.end, h.end);
233
+ } else spans.push({ ...h });
234
+ }
235
+ let out = "";
236
+ let at = 0;
237
+ for (const s of spans) {
238
+ out += text.slice(at, s.start) + `[redacted:${s.id}]`;
239
+ at = s.end;
240
+ }
241
+ return { text: out + text.slice(at), kinds: [...new Set(spans.map((s) => s.id))] };
242
+ }