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/README.md +19 -0
- package/fill-secret.js +108 -0
- package/fill-secret.ts +97 -1
- package/listeners.js +6 -2
- package/listeners.ts +6 -3
- package/package.json +5 -2
- package/rechrome.js +52 -14
- package/rechrome.ts +52 -14
- package/redact.js +236 -0
- package/redact.ts +242 -0
- package/scoped-upload.js +4 -3
- package/scoped-upload.ts +6 -3
- package/secret-rules/gitleaks.LICENSE +21 -0
- package/secret-rules/gitleaks.toml +3209 -0
- package/serve.js +18 -1
- package/serve.ts +18 -1
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|