rechrome 1.40.0 → 1.41.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.ts +2 -1
- package/package.json +5 -2
- package/rechrome.js +14 -7
- package/rechrome.ts +14 -7
- 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/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
|
+
}
|
package/scoped-upload.js
CHANGED
|
@@ -214,13 +214,14 @@ async function readHostFile(path, hostDirs, limit) {
|
|
|
214
214
|
const inside = (real) => roots.some((root) => real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
215
215
|
const resolveInside = async () => {
|
|
216
216
|
let real;
|
|
217
|
+
const refused = () => new Error("Host upload path is not an existing file in the listener's allowed directories");
|
|
217
218
|
try {
|
|
218
219
|
real = await realpath(path);
|
|
219
220
|
} catch {
|
|
220
|
-
throw
|
|
221
|
+
throw refused();
|
|
221
222
|
}
|
|
222
223
|
if (!inside(real))
|
|
223
|
-
throw
|
|
224
|
+
throw refused();
|
|
224
225
|
return real;
|
|
225
226
|
};
|
|
226
227
|
const handle = await open(await resolveInside(), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
@@ -292,7 +293,7 @@ async function receiveUpload(options) {
|
|
|
292
293
|
const failed = (r) => r.status !== 0 || /^### Error\b/m.test(r.stdout);
|
|
293
294
|
const script = async (mode) => {
|
|
294
295
|
const file = join(dir, randomBytes(8).toString("hex") + ".js");
|
|
295
|
-
await writeFile(file, uploadScript(mode, p.target, path, p), { mode: 384, flag: "wx" });
|
|
296
|
+
await writeFile(file, uploadScript(mode, p.target, path, { ...p, data: bytes.toString("base64") }), { mode: 384, flag: "wx" });
|
|
296
297
|
return options.run(["run-code", "--filename", file]);
|
|
297
298
|
};
|
|
298
299
|
const mode = p.mode ?? "auto";
|
package/scoped-upload.ts
CHANGED
|
@@ -224,8 +224,10 @@ export async function readHostFile(path: string, hostDirs: string[] | undefined,
|
|
|
224
224
|
const inside = (real: string) => roots.some(root => real.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
225
225
|
const resolveInside = async () => {
|
|
226
226
|
let real: string;
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
// One answer for "missing" and "outside", so a link holder can't probe which host paths exist.
|
|
228
|
+
const refused = () => new Error('Host upload path is not an existing file in the listener\'s allowed directories');
|
|
229
|
+
try { real = await realpath(path); } catch { throw refused(); }
|
|
230
|
+
if (!inside(real)) throw refused();
|
|
229
231
|
return real;
|
|
230
232
|
};
|
|
231
233
|
// Open once, then prove the opened file is the one an allowed path names now (same device and
|
|
@@ -293,7 +295,8 @@ export async function receiveUpload(options: {
|
|
|
293
295
|
const failed = (r: UploadResult) => r.status !== 0 || /^### Error\b/m.test(r.stdout);
|
|
294
296
|
const script = async (mode: Exclude<UploadMode, 'chooser'>) => {
|
|
295
297
|
const file = join(dir!, randomBytes(8).toString('hex') + '.js');
|
|
296
|
-
|
|
298
|
+
// drop/paste embed the bytes in the page script; a --host upload sent none, so use what was read.
|
|
299
|
+
await writeFile(file, uploadScript(mode, p.target, path, { ...p, data: bytes.toString('base64') }), { mode: 0o600, flag: 'wx' });
|
|
297
300
|
return options.run(['run-code', '--filename', file]);
|
|
298
301
|
};
|
|
299
302
|
const mode = p.mode ?? 'auto';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Zachary Rice
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|