slack-term 1.48.0 → 1.49.1

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/ts/inbox.ts ADDED
@@ -0,0 +1,191 @@
1
+ // .slack-term/ inbox and outbox: one file per message, written tmp + rename.
2
+ //
3
+ // inbox/<channel>-<ts>.yml a text-only notification (who, where, text,
4
+ // permalink; attachments only counted). Secrets are redacted before it is
5
+ // written — `slack read <permalink>` fetches the real thing live.
6
+ // Handled = moved to archived/: by `slack done`, or by an outbox message
7
+ // sent with in_reply_to: <its ts>.
8
+ // outbox/<name>.yml {to, text, in_reply_to?}. `slack outbox flush` sends
9
+ // them (behind the --code gate) and moves each to sent/ with its ts and
10
+ // permalink; a failure stays in outbox/ with `error` and `attempts`.
11
+ import { existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { redact } from "./redact.ts";
14
+ import { atomicWrite, readSettings, registeredScopes, setAlias, whoSubscribes, type Alias, type Scope } from "./scope.ts";
15
+
16
+ export type InboxEntry = {
17
+ ts: string;
18
+ channel: string;
19
+ channel_name?: string;
20
+ /** The thread root; absent for a top-level message. */
21
+ thread_ts?: string;
22
+ user: string;
23
+ user_name?: string;
24
+ text: string;
25
+ /** Rule ids of what was redacted from `text`. */
26
+ redacted?: string[];
27
+ /** Attachments are never downloaded, only counted. */
28
+ files?: number;
29
+ permalink: string;
30
+ /** The subscription that routed it here. */
31
+ alias: string;
32
+ received_at: string;
33
+ };
34
+
35
+ /** What a stream hands over (its StreamMatch, plus the attachment count). */
36
+ export type Incoming = {
37
+ channel: { id: string; name: string };
38
+ ts: string;
39
+ thread_ts: string | null;
40
+ user: { id: string; name: string };
41
+ text: string;
42
+ permalink: string;
43
+ files?: number;
44
+ };
45
+
46
+ const TS_RE = /^\d{10}\.\d{6}$/;
47
+
48
+ export function inboxFileName(channel: string, ts: string): string {
49
+ return `${channel}-${ts}.yml`;
50
+ }
51
+
52
+ function yaml(v: unknown): string {
53
+ return Bun.YAML.stringify(v, null, 2) + "\n";
54
+ }
55
+
56
+ /** Write one entry, redacted. False when it is already there (inbox/ or archived/). */
57
+ export function writeInbox(scope: Scope, e: Omit<InboxEntry, "redacted">, known: readonly string[] = []): boolean {
58
+ const name = inboxFileName(e.channel, e.ts);
59
+ if (existsSync(join(scope.dir, "inbox", name)) || existsSync(join(scope.dir, "archived", name))) return false;
60
+ const r = redact(e.text, { known });
61
+ const entry: InboxEntry = { ...e, text: r.text, ...(r.kinds.length ? { redacted: r.kinds } : {}) };
62
+ atomicWrite(join(scope.dir, "inbox", name), yaml(entry));
63
+ return true;
64
+ }
65
+
66
+ /** Fan one incoming message out to every scope that subscribes it. A new
67
+ * top-level message in an `auto_subscribe` channel nobody subscribes is
68
+ * first subscribed (as `t-<ts>`) in the scopes that ask for it. Returns the
69
+ * scope dirs it was written to. */
70
+ export function deliver(m: Incoming, scopes = registeredScopes(), now = new Date(), known: readonly string[] = []): string[] {
71
+ const root = m.thread_ts ?? m.ts;
72
+ let subs: Alias[] = whoSubscribes(m.channel.id, root, scopes);
73
+ if (!subs.length && !m.thread_ts) {
74
+ for (const s of scopes) {
75
+ if (!readSettings(s).auto_subscribe?.includes(m.channel.id)) continue;
76
+ // settings.yml: auto_subscribe is the owner's own rule, so it writes as the owner.
77
+ setAlias(s, `t-${m.ts}`, { channel: m.channel.id, thread_ts: m.ts, note: "auto: new top-level message" }, { shared: true, asOwner: true });
78
+ }
79
+ subs = whoSubscribes(m.channel.id, root, scopes);
80
+ }
81
+ const out: string[] = [];
82
+ const seen = new Set<string>();
83
+ for (const a of subs) {
84
+ if (seen.has(a.scope.dir)) continue; // one file per scope, however many aliases match
85
+ seen.add(a.scope.dir);
86
+ const wrote = writeInbox(a.scope, {
87
+ ts: m.ts, channel: m.channel.id, channel_name: m.channel.name,
88
+ ...(m.thread_ts ? { thread_ts: m.thread_ts } : {}),
89
+ user: m.user.id, user_name: m.user.name, text: m.text,
90
+ ...(m.files ? { files: m.files } : {}),
91
+ permalink: m.permalink, alias: a.name, received_at: now.toISOString(),
92
+ }, known);
93
+ if (wrote) out.push(a.scope.dir);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export type Listed<T> = { file: string; entry: T; mtimeMs: number };
99
+
100
+ function listDir<T>(dir: string, parse: (v: unknown) => T | undefined): Listed<T>[] {
101
+ let names: string[];
102
+ try {
103
+ names = readdirSync(dir).filter((f) => f.endsWith(".yml"));
104
+ } catch {
105
+ return [];
106
+ }
107
+ const out: Listed<T>[] = [];
108
+ for (const f of names.sort()) {
109
+ const p = join(dir, f);
110
+ try {
111
+ const entry = parse(Bun.YAML.parse(readFileSync(p, "utf8")));
112
+ if (entry) out.push({ file: p, entry, mtimeMs: statSync(p).mtimeMs });
113
+ } catch { /* half-moved or hand-broken: skip */ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function asInbox(v: unknown): InboxEntry | undefined {
119
+ const o = v as Partial<InboxEntry> | null;
120
+ return o && typeof o.ts === "string" && typeof o.channel === "string" && typeof o.text === "string" ? (o as InboxEntry) : undefined;
121
+ }
122
+
123
+ export function listInbox(scope: Scope): Listed<InboxEntry>[] {
124
+ return listDir(join(scope.dir, "inbox"), asInbox).sort((a, b) => Number(a.entry.ts) - Number(b.entry.ts));
125
+ }
126
+
127
+ /** Move an inbox entry to archived/: by file name/path, or by message ts. */
128
+ export function markDone(scope: Scope, ref: string): string | undefined {
129
+ const items = listInbox(scope);
130
+ const hit = TS_RE.test(ref)
131
+ ? items.find((i) => i.entry.ts === ref)
132
+ : items.find((i) => i.file === ref || i.file.endsWith(`/${ref}`) || i.file.endsWith(`/${ref}.yml`));
133
+ if (!hit) return undefined;
134
+ const to = join(scope.dir, "archived", hit.file.slice(hit.file.lastIndexOf("/") + 1));
135
+ renameSync(hit.file, to);
136
+ return to;
137
+ }
138
+
139
+ // --- outbox ---
140
+
141
+ export type OutboxEntry = {
142
+ /** alias | permalink | #chan:<ts> | C…:<ts> */
143
+ to: string;
144
+ text: string;
145
+ /** ts of the inbox message this answers: archived once this is sent. */
146
+ in_reply_to?: string;
147
+ attempts?: number;
148
+ error?: string;
149
+ };
150
+
151
+ function asOutbox(v: unknown): OutboxEntry | undefined {
152
+ const o = v as Partial<OutboxEntry> | null;
153
+ return o && typeof o.to === "string" && typeof o.text === "string" ? (o as OutboxEntry) : undefined;
154
+ }
155
+
156
+ export function listOutbox(scope: Scope): Listed<OutboxEntry>[] {
157
+ return listDir(join(scope.dir, "outbox"), asOutbox);
158
+ }
159
+
160
+ /** Queue one message. The file name sorts by time, so flush keeps the order. */
161
+ export function addOutbox(scope: Scope, e: OutboxEntry, now = new Date()): { file: string; redacted: string[] } {
162
+ const p = join(scope.dir, "outbox", `${now.toISOString().replace(/[:.]/g, "-")}-${process.pid}.yml`);
163
+ // Redacted before it is written, like the inbox: a secret never reaches
164
+ // the disk — nor, then, Slack.
165
+ const r = redact(e.text);
166
+ atomicWrite(p, yaml({ ...e, text: r.text }));
167
+ return { file: p, redacted: r.kinds };
168
+ }
169
+
170
+ /** After Slack returned a ts: the sent/ record (text redacted), the outbox
171
+ * file gone, and the answered inbox entry archived. */
172
+ export function recordSent(scope: Scope, item: Listed<OutboxEntry>, sent: { ts: string; permalink: string; channel: string; thread_ts: string }, now = new Date()): string {
173
+ const name = item.file.slice(item.file.lastIndexOf("/") + 1);
174
+ const r = redact(item.entry.text);
175
+ const { error: _e, ...rest } = item.entry;
176
+ atomicWrite(join(scope.dir, "sent", name), yaml({ ...rest, text: r.text, ...(r.kinds.length ? { redacted: r.kinds } : {}), ...sent, sent_at: now.toISOString() }));
177
+ unlinkSync(item.file);
178
+ if (item.entry.in_reply_to) markDone(scope, item.entry.in_reply_to);
179
+ return join(scope.dir, "sent", name);
180
+ }
181
+
182
+ /** A failed send stays in outbox/, with why and how often. */
183
+ export function recordFailure(item: Listed<OutboxEntry>, error: string): void {
184
+ atomicWrite(item.file, yaml({ ...item.entry, attempts: (item.entry.attempts ?? 0) + 1, error }));
185
+ }
186
+
187
+ /** Inbox entries older than the scope's `nudge_after_min` (default 30). */
188
+ export function overdue(scope: Scope, nowMs = Date.now()): Listed<InboxEntry>[] {
189
+ const min = readSettings(scope).nudge_after_min ?? 30;
190
+ return listInbox(scope).filter((i) => nowMs - i.mtimeMs >= min * 60_000);
191
+ }
package/ts/locales.ts ADDED
@@ -0,0 +1,113 @@
1
+ // Language packs: locales/<lang>.json in the package, overridden key by key by
2
+ // the nearest .slack-term/locales/<lang>.json. They hold only what slack-term
3
+ // posts to PEOPLE (slack ask's copy) and the patterns it looks for in their
4
+ // messages (the choice-question hint); the CLI's own text is English, in code.
5
+ //
6
+ // Read at call time and cached by mtime, so editing a pack takes effect on the
7
+ // next command (and the next access in a long-running one): no rebuild.
8
+ import { readdirSync, readFileSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { findScope } from "./scope.ts";
12
+
13
+ export type Pack = {
14
+ lang: string;
15
+ /** Slack / POSIX locales that pick this pack (`ja` matches ja-JP, ja_JP.UTF-8). */
16
+ locale_prefixes?: string[];
17
+ ask?: Record<string, string>;
18
+ /** Regex sources (flags iu) for "this text is a choice question". */
19
+ choice_patterns?: string[];
20
+ };
21
+
22
+ // fileURLToPath, not .pathname: a path with spaces or non-ASCII stays
23
+ // percent-encoded in .pathname, and a Windows one comes out as /C:/….
24
+ const PACKAGE_DIR = fileURLToPath(new URL("../locales/", import.meta.url));
25
+
26
+ const cache = new Map<string, { mtimeMs: number; pack: Pack | undefined }>();
27
+
28
+ function readPack(path: string): Pack | undefined {
29
+ let mtimeMs: number;
30
+ try {
31
+ mtimeMs = statSync(path).mtimeMs;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ const hit = cache.get(path);
36
+ if (hit && hit.mtimeMs === mtimeMs) return hit.pack;
37
+ let pack: Pack | undefined;
38
+ try {
39
+ const v = JSON.parse(readFileSync(path, "utf8")) as Pack;
40
+ pack = v && typeof v.lang === "string" ? v : undefined;
41
+ } catch {
42
+ pack = undefined; // a broken override must not take the CLI down: the package pack stands
43
+ }
44
+ cache.set(path, { mtimeMs, pack });
45
+ return pack;
46
+ }
47
+
48
+ function overrideDir(): string | undefined {
49
+ const s = findScope();
50
+ return s.global ? undefined : join(s.dir, "locales");
51
+ }
52
+
53
+ /** Every language with a pack, package or override. */
54
+ export function packLangs(): string[] {
55
+ const out = new Set<string>();
56
+ for (const d of [PACKAGE_DIR, overrideDir()]) {
57
+ if (!d) continue;
58
+ try {
59
+ for (const f of readdirSync(d)) if (f.endsWith(".json")) out.add(f.slice(0, -5));
60
+ } catch { /* no such dir */ }
61
+ }
62
+ return [...out].sort();
63
+ }
64
+
65
+ /** The pack for `lang`: the package's, with the override's keys on top. */
66
+ export function pack(lang: string): Pack | undefined {
67
+ const base = readPack(join(PACKAGE_DIR, `${lang}.json`));
68
+ const od = overrideDir();
69
+ const over = od ? readPack(join(od, `${lang}.json`)) : undefined;
70
+ if (!over) return base;
71
+ if (!base) return over;
72
+ return {
73
+ ...base, ...over,
74
+ ask: { ...base.ask, ...over.ask },
75
+ choice_patterns: over.choice_patterns ?? base.choice_patterns ?? [],
76
+ };
77
+ }
78
+
79
+ /** One ask string, `{name}` placeholders filled. Keys may carry a ` (WIRE)`
80
+ * note in the JSON; looked up with or without it. */
81
+ export function askString(lang: string, key: string, vars: Record<string, string | number> = {}): string {
82
+ const a = pack(lang)?.ask ?? {};
83
+ const raw = a[key] ?? a[`${key} (WIRE)`] ?? pack("en")?.ask?.[key] ?? pack("en")?.ask?.[`${key} (WIRE)`];
84
+ if (raw === undefined) throw new Error(`language pack ${lang}: no ask.${key}`);
85
+ return raw.replace(/\{(\w+)\}/g, (m, k: string) => (k in vars ? String(vars[k]) : m));
86
+ }
87
+
88
+ /** The pack whose locale prefix `locale` names, or undefined. */
89
+ export function langOfLocale(locale: string | undefined): string | undefined {
90
+ const v = (locale ?? "").toLowerCase();
91
+ if (!v) return undefined;
92
+ for (const l of packLangs()) {
93
+ for (const p of pack(l)?.locale_prefixes ?? [l]) {
94
+ const q = p.toLowerCase();
95
+ if (v === q || (v.startsWith(q) && /^[_.\-@]/.test(v.slice(q.length)))) return l;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ /** Compiled choice patterns of every pack: a choice question is spotted in
102
+ * any language, whatever the sender's own. */
103
+ export function choicePatterns(): { lang: string; re: RegExp }[] {
104
+ const out: { lang: string; re: RegExp }[] = [];
105
+ for (const l of packLangs()) {
106
+ for (const src of pack(l)?.choice_patterns ?? []) {
107
+ try {
108
+ out.push({ lang: l, re: new RegExp(src, "iu") });
109
+ } catch { /* a broken pattern in an override is skipped, not fatal */ }
110
+ }
111
+ }
112
+ return out;
113
+ }
package/ts/readback.ts ADDED
@@ -0,0 +1,102 @@
1
+ // Did Slack store what we sent? Ported from the earlier thread tool's read-back check
2
+ // (2026-10-14 / 10-30), for `pinlog set`.
3
+ //
4
+ // Slack rewrites text when it stores it:
5
+ // * unicode emoji become :shortcodes: (🧭 → :compass:, 1️⃣ → :one:, a skin
6
+ // tone as :+1::skin-tone-4:); the rich_text block Slack generates keeps
7
+ // the mapping as {type: "emoji", name, unicode: "1f9ed"};
8
+ // * & < > that are not markup become &amp; &lt; &gt;;
9
+ // * a bare URL becomes <url>; a bare domain or e-mail <http://d|d> / <mailto:a|a>.
10
+ // So a read-back is compared normalised, never byte for byte: Slack's own
11
+ // wrappers undone (an explicit <url|label> is kept), shortcodes mapped back to
12
+ // emoji through the message's rich_text, variation selectors and skin tones
13
+ // dropped, whitespace runs as one space.
14
+
15
+ type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
16
+
17
+ const EMOJI_SHORTCODE = /:([a-z0-9_+'-]+):((?::skin-tone-[2-6]:)?)/g;
18
+ // No "<" inside: Slack escapes one in a link as &lt;, and allowing it makes
19
+ // the match quadratic on "<a:<a:<a:…".
20
+ const SLACK_LINK = /<([A-Za-z][A-Za-z0-9+.-]*:[^|<>\s]*)(?:\|([^<>]*))?>/g;
21
+ const ENTITY = /&(amp|lt|gt);/g;
22
+ const VARIATION = /[︎️]/gu;
23
+ const SKIN_TONE = /[\u{1F3FB}-\u{1F3FF}]/gu;
24
+ const SC = String.raw`:[a-z0-9_+'-]+:(?::skin-tone-[2-6]:)?`;
25
+ const EMOJI_BASE = String.raw`©®‼⁉™ℹ←-⇿⌀-⏿Ⓜ■-➿⤀-⥿⬀-⯿〰〽㊗㊙\u{1F000}-\u{1FAFF}`;
26
+ const PICT = String.raw`[${EMOJI_BASE}][\u{E0020}-\u{E007F}]*`;
27
+ const PIECE = new RegExp(
28
+ String.raw`(?<sc>${SC}(?:‍${SC})*)|(?<emoji>[0-9#*]⃣|[\u{1F1E6}-\u{1F1FF}]{2}|${PICT}(?:‍${PICT})*)|(?<char>[\s\S])`,
29
+ "gu",
30
+ );
31
+
32
+ /** {shortcode name: emoji} from the rich_text emoji elements of a message. */
33
+ export function emojiMap(blocks: Json | undefined): Map<string, string> {
34
+ const out = new Map<string, string>();
35
+ const stack: Json[] = [blocks ?? null];
36
+ let seen = 0;
37
+ while (stack.length && seen++ < 100_000) {
38
+ const o = stack.pop();
39
+ if (Array.isArray(o)) stack.push(...o);
40
+ else if (o && typeof o === "object") {
41
+ const name = o.name;
42
+ const uni = o.unicode;
43
+ if (o.type === "emoji" && typeof name === "string" && typeof uni === "string") {
44
+ try {
45
+ out.set(name.split("::")[0]!, uni.split("-").filter(Boolean).map((h) => String.fromCodePoint(parseInt(h, 16))).join(""));
46
+ } catch { /* a bad code point: no mapping */ }
47
+ }
48
+ stack.push(...Object.values(o));
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function unlink(all: string, url: string, label: string | undefined): string {
55
+ if (label === undefined) return url; // <url>: Slack's wrapper around a bare URL
56
+ // Slack's own links around a bare domain / e-mail. Any other <url|label> is
57
+ // explicit: <https://d|d> ≠ <http://d|d>, and /x ≠ /x/.
58
+ for (const auto of ["http://", "mailto:"]) {
59
+ if (url.slice(0, auto.length).toLowerCase() === auto && label === url.slice(auto.length)) return label;
60
+ }
61
+ return all;
62
+ }
63
+
64
+ export function normText(text: string, names: Map<string, string>): string {
65
+ let s = text.normalize("NFC");
66
+ s = s.replace(SLACK_LINK, (all, url: string, label: string | undefined) => unlink(all, url, label));
67
+ s = s.replace(ENTITY, (_, e: string) => ({ amp: "&", lt: "<", gt: ">" })[e as "amp" | "lt" | "gt"]);
68
+ s = s.replace(EMOJI_SHORTCODE, (all, name: string) => names.get(name) ?? all);
69
+ s = s.replace(VARIATION, "").replace(SKIN_TONE, "");
70
+ return s.split(/\s+/).filter(Boolean).join(" ");
71
+ }
72
+
73
+ function tokens(s: string): [string, string][] {
74
+ return [...s.matchAll(PIECE)].map((m) => {
75
+ const g = m.groups!;
76
+ return g.sc !== undefined ? ["sc", g.sc] : g.emoji !== undefined ? ["emoji", g.emoji] : ["char", g.char!];
77
+ });
78
+ }
79
+
80
+ /** True when the stored message (its text and rich_text blocks) carries `sent`. */
81
+ export function storedAsSent(sent: string, msg: { text?: Json; blocks?: Json }): boolean {
82
+ const names = emojiMap(msg.blocks);
83
+ const a = normText(sent, names);
84
+ const b = normText(typeof msg.text === "string" ? msg.text : "", names);
85
+ if (a === b) return true;
86
+ // A :shortcode: no rich_text element maps back stays as :name:. Then compare
87
+ // piece by piece: such a stored shortcode may stand for exactly one emoji we
88
+ // sent; every other piece must be equal.
89
+ EMOJI_SHORTCODE.lastIndex = 0;
90
+ if (!EMOJI_SHORTCODE.test(b)) return false;
91
+ const ta = tokens(a);
92
+ const tb = tokens(b);
93
+ return ta.length === tb.length && ta.every(([ka, va], i) => va === tb[i]![1] || (tb[i]![0] === "sc" && ka === "emoji"));
94
+ }
95
+
96
+ /** Where two normalised texts first differ, for the error line. */
97
+ export function firstDifference(sent: string, stored: string): string {
98
+ let i = 0;
99
+ while (i < sent.length && i < stored.length && sent[i] === stored[i]) i++;
100
+ const lo = Math.max(0, i - 10);
101
+ return `char ${i}: sent ${JSON.stringify(sent.slice(lo, i + 30))}, stored ${JSON.stringify(stored.slice(lo, i + 30))}`;
102
+ }
package/ts/redact.ts ADDED
@@ -0,0 +1,225 @@
1
+ // Secret redaction for anything slack-term writes to disk (the .slack-term/
2
+ // inbox/outbox): each secret becomes `[redacted:<rule id>]`, and the original
3
+ // never touches the file. `slack read` fetches the real text live.
4
+ //
5
+ // Rules: gitleaks' default set (ts/vendor/gitleaks.toml, v8.30.1, MIT — see
6
+ // ts/vendor/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 (?i) lasts to the end of its group, across `|`: close the scoped
65
+ // groups before the bar and reopen them after it, or the text before the
66
+ // first (?i) would bind to every alternative (rechrome 4b5f849:
67
+ // curl-auth-header missed -H 'Authorization: …').
68
+ if (c === "|" && extra) { out += ")".repeat(extra) + "|" + "(?i:".repeat(extra); continue; }
69
+ out += c;
70
+ }
71
+ out += ")".repeat(extra);
72
+ return { source: out, flags: [...new Set(flags)].join("") };
73
+ }
74
+
75
+ type TomlAllow = { regexTarget?: string; regexes?: string[]; stopwords?: string[] };
76
+ type TomlRule = { id: string; regex?: string; keywords?: string[]; entropy?: number; secretGroup?: number; allowlists?: TomlAllow[]; allowlist?: TomlAllow };
77
+
78
+ /** gitleaks' rules TOML → rules. Path-only rules (no regex) are skipped. */
79
+ export function loadGitleaksRules(toml: string): SecretRule[] {
80
+ const c = Bun.TOML.parse(toml) as { rules: TomlRule[]; allowlist?: TomlAllow };
81
+ const compile = (r: string): RegExp => { const j = goToJs(r); return new RegExp(j.source, j.flags); };
82
+ const gAllow = (c.allowlist?.regexes ?? []).map(compile);
83
+ const gStop = (c.allowlist?.stopwords ?? []).map((s) => s.toLowerCase());
84
+ const rules: SecretRule[] = [];
85
+ for (const r of c.rules) {
86
+ if (!r.regex) continue;
87
+ const allow = [...gAllow];
88
+ const allowMatch: RegExp[] = [];
89
+ const allowLine: RegExp[] = [];
90
+ const stop = [...gStop];
91
+ for (const a of [...(r.allowlists ?? []), ...(r.allowlist ? [r.allowlist] : [])]) {
92
+ // Each allowlist is tested against what it names, not always the secret
93
+ // (generic-api-key's api_version exclusion is a regexTarget = match one).
94
+ const into = a.regexTarget === "match" ? allowMatch : a.regexTarget === "line" ? allowLine : allow;
95
+ for (const x of a.regexes ?? []) into.push(compile(x));
96
+ for (const s of a.stopwords ?? []) stop.push(String(s).toLowerCase());
97
+ }
98
+ const j = goToJs(r.regex);
99
+ rules.push({
100
+ id: r.id, re: new RegExp(j.source, j.flags + "gd"), keywords: (r.keywords ?? []).map((k) => k.toLowerCase()),
101
+ ...(r.entropy ? { entropy: r.entropy } : {}), ...(r.secretGroup ? { group: r.secretGroup } : {}), allow,
102
+ ...(allowMatch.length ? { allowMatch } : {}), ...(allowLine.length ? { allowLine } : {}), stop,
103
+ });
104
+ }
105
+ return rules;
106
+ }
107
+
108
+ /** Ours, for what gitleaks' set leaves out. */
109
+ export const OWN_RULES: SecretRule[] = [
110
+ // ?token=… / &sig=… in a URL; `<your token>` and xxx-style placeholders are not secrets.
111
+ { 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: [] },
112
+ // OpenAI / Anthropic / OpenRouter style keys, whatever their exact layout.
113
+ { id: "sk-key", re: /\b(sk-(?:proj-|ant-|or-v1-|svcacct-)?[A-Za-z0-9_-]{20,})/gd, group: 1, keywords: ["sk-"], entropy: 3.5, allow: [], stop: [] },
114
+ // A 6-digit code only right after a word that says it is one: a bare
115
+ // 6-digit number is far more often an amount, an order or a build.
116
+ { id: "otp-code", re: /(?:code|コード|otp|pin|passcode|認証|確認番号|ワンタイム)[^\d\n]{0,20}(?<![\d.])(\d{6})(?![\d.])/gid, group: 1, keywords: [], allow: [], stop: [] },
117
+ ];
118
+
119
+ let cached: SecretRule[] | undefined;
120
+ /** gitleaks' rules + ours, loaded once. */
121
+ export function secretRules(): SecretRule[] {
122
+ cached ??= [...loadGitleaksRules(readFileSync(new URL("./vendor/gitleaks.toml", import.meta.url), "utf8")), ...OWN_RULES];
123
+ return cached;
124
+ }
125
+
126
+ function shannon(s: string): number {
127
+ const f = new Map<string, number>();
128
+ for (const ch of s) f.set(ch, (f.get(ch) ?? 0) + 1);
129
+ let h = 0;
130
+ for (const n of f.values()) { const p = n / s.length; h -= p * Math.log2(p); }
131
+ return h;
132
+ }
133
+
134
+ export type SecretHit = { id: string; start: number; end: number };
135
+
136
+ /** Every span of an exact value the caller knows is secret (its own token, a
137
+ * value it filled in), longest first, plus the value's JSON-string spelling
138
+ * (a `"` or `\\` in it is escaped there). The 4th rule, after rechrome's
139
+ * fill-secret scrub; values shorter than 6 are ignored as unsafe to match. */
140
+ function findKnown(text: string, known: readonly string[]): SecretHit[] {
141
+ const forms = new Set<string>();
142
+ for (const v of known) {
143
+ if (v.length < 6) continue;
144
+ forms.add(v);
145
+ forms.add(JSON.stringify(v).slice(1, -1));
146
+ }
147
+ const out: SecretHit[] = [];
148
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) {
149
+ 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 });
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export type RedactOptions = { rules?: SecretRule[]; known?: readonly string[] };
155
+
156
+ /** Every secret span in `text`. */
157
+ export function findSecrets(text: string, opts: RedactOptions = {}): SecretHit[] {
158
+ const rules = opts.rules ?? secretRules();
159
+ const lower = text.toLowerCase();
160
+ const out: SecretHit[] = opts.known?.length ? findKnown(text, opts.known) : [];
161
+ for (const r of rules) {
162
+ if (r.keywords.length && !r.keywords.some((k) => lower.includes(k))) continue;
163
+ for (const m of text.matchAll(r.re)) {
164
+ // What gitleaks calls the secret — the configured group, else the first
165
+ // that matched — decides entropy and the secret allowlist. What is
166
+ // MASKED is that group only when the rule names it; otherwise the whole
167
+ // match, since the first group can be a harmless fragment (a webhook's
168
+ // UUID, a JWT's fixed header) and the real secret would stay visible.
169
+ // Spans come from the match's own indices (the `d` flag): looking a
170
+ // value up in m[0] finds an EARLIER copy (`?secret=secret`, rechrome #55).
171
+ const gi = r.group ?? m.findIndex((g, i) => i > 0 && !!g);
172
+ const secret = gi > 0 ? m[gi] : m[0];
173
+ if (!secret) continue;
174
+ if (r.entropy && shannon(secret) < r.entropy) continue;
175
+ if (r.allow.some((a) => a.test(secret))) continue;
176
+ if (r.allowMatch?.some((a) => a.test(m[0]))) continue;
177
+ if (r.allowLine) {
178
+ const ls = text.lastIndexOf("\n", m.index) + 1;
179
+ const le = text.indexOf("\n", m.index + m[0].length);
180
+ if (r.allowLine.some((a) => a.test(text.slice(ls, le < 0 ? undefined : le)))) continue;
181
+ }
182
+ const sl = secret.toLowerCase();
183
+ if (r.stop.some((s) => sl.includes(s))) continue;
184
+ const span = r.group ? m.indices?.[r.group] : undefined;
185
+ if (span) out.push({ id: r.id, start: span[0], end: span[1] });
186
+ else {
187
+ // gitleaks' patterns end (and some start) with a boundary — space,
188
+ // quote, `;`, a literal \n — that is context, not secret: keep it.
189
+ let a = m.index;
190
+ let b = m.index + m[0].length;
191
+ const edge = /[\s'"`;=:,]/;
192
+ while (a < b && edge.test(text[a]!)) a++;
193
+ for (;;) {
194
+ if (b > a && edge.test(text[b - 1]!)) b--;
195
+ else if (b - 2 >= a && /\\[nr]/.test(text.slice(b - 2, b))) b -= 2;
196
+ else break;
197
+ }
198
+ out.push({ id: r.id, start: a, end: b });
199
+ }
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** `text` with every secret replaced by `[redacted:<rule id>]`. Overlapping
206
+ * hits merge into one span, named by the longest. */
207
+ export function redact(text: string, opts: RedactOptions = {}): { text: string; kinds: string[] } {
208
+ const hits = findSecrets(text, opts).sort((a, b) => a.start - b.start || b.end - a.end);
209
+ if (!hits.length) return { text, kinds: [] };
210
+ const spans: SecretHit[] = [];
211
+ for (const h of hits) {
212
+ const last = spans[spans.length - 1];
213
+ if (last && h.start < last.end) {
214
+ if (h.end - h.start > last.end - last.start) last.id = h.id;
215
+ last.end = Math.max(last.end, h.end);
216
+ } else spans.push({ ...h });
217
+ }
218
+ let out = "";
219
+ let at = 0;
220
+ for (const s of spans) {
221
+ out += text.slice(at, s.start) + `[redacted:${s.id}]`;
222
+ at = s.end;
223
+ }
224
+ return { text: out + text.slice(at), kinds: [...new Set(spans.map((s) => s.id))] };
225
+ }