codiedev 0.9.80 → 0.9.81

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.
@@ -0,0 +1,645 @@
1
+ "use strict";
2
+ // Secret/PII scrubber for session transcripts.
3
+ //
4
+ // Runs server-side over the decoded transcript before it is stored at rest
5
+ // (see /api/captureSession) and over LLM-generated summary fields before they
6
+ // are persisted. Design goal: catch secrets (recall) while redacting as little
7
+ // benign content as possible (fidelity). See spec-transcript-scrubber.md.
8
+ //
9
+ // Principles:
10
+ // - Lead with precise, prefix/structured detectors (near-zero false positives).
11
+ // - No free-floating entropy rules — entropy is only considered inside
12
+ // secret-named assignment contexts.
13
+ // - Every redaction is tagged [REDACTED:<rule>] so false positives are auditable.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.GENERIC_RULES = void 0;
16
+ exports.shannonEntropy = shannonEntropy;
17
+ exports.scrubSecrets = scrubSecrets;
18
+ exports.redactSpans = redactSpans;
19
+ exports.looksLikeTestValue = looksLikeTestValue;
20
+ exports.secretFindings = secretFindings;
21
+ exports.possibleSecrets = possibleSecrets;
22
+ exports.redactPossible = redactPossible;
23
+ const scrubProviderRules_1 = require("./scrubProviderRules");
24
+ /** Shannon entropy in bits per character, as Gitleaks measures it. */
25
+ function shannonEntropy(s) {
26
+ if (!s)
27
+ return 0;
28
+ const counts = new Map();
29
+ for (const ch of s)
30
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
31
+ let h = 0;
32
+ for (const n of counts.values()) {
33
+ const p = n / s.length;
34
+ h -= p * Math.log2(p);
35
+ }
36
+ return h;
37
+ }
38
+ // A secret-named assignment whose KEY ends in one of these is metadata ABOUT a
39
+ // secret (its vault name, file, ARN, URL…), not the secret itself.
40
+ const SECRET_REF_KEY = /_(?:NAME|ID|ARN|FILE|PATH|URL|URI|HOST|REGION|BUCKET|PREFIX|ENABLED|TYPE|VERSION|COUNT)$/i;
41
+ // Does this assignment value look like an actual secret rather than a label,
42
+ // dictionary word, or placeholder? Real secrets are long and/or mixed
43
+ // alphanumeric; benign values are short words, lowercase kebab, or placeholders.
44
+ function looksSecret(v) {
45
+ // --- reject non-secret shapes (the fidelity lever) ---
46
+ if (v.includes("<") || v.includes(">"))
47
+ return false; // <your-token>
48
+ if (/^(your[-_]|changeme|change-me|placeholder|example|none|null|undefined|redacted|true|false)/i.test(v))
49
+ return false;
50
+ if (/[${}]/.test(v) || v.includes("$("))
51
+ return false; // ${VAR} / $(cmd) refs
52
+ // Code punctuation — real secrets never contain these; source code does.
53
+ // (React hooks `useState(...)`, TS types `string;`, arg lists `a, b)`.)
54
+ if (/[()[\]{};,]/.test(v))
55
+ return false;
56
+ if (/:\/\//.test(v) || /^https?:/i.test(v))
57
+ return false; // URLs
58
+ if (/^[~./]|^[A-Za-z]:\\/.test(v))
59
+ return false; // file path (/, ./, ~/, C:\)
60
+ if (/\.(pem|key|json|crt|cer|txt|log|env|conf|config|ya?ml|pub|p12|pfx|sh|js|ts|py)$/i.test(v))
61
+ return false; // path with a known file extension
62
+ if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(v))
63
+ return false; // dotted code identifier
64
+ if (/^[A-Za-z-]+$/.test(v))
65
+ return false; // all letters/hyphens, no digit → identifier / word / type
66
+ // --- require secret entropy ---
67
+ if (/^[0-9a-f]{16,}$/i.test(v))
68
+ return true; // hex token
69
+ if (v.length >= 12 && /[0-9]/.test(v) && /[A-Za-z]/.test(v))
70
+ return true; // mixed alnum w/ a digit
71
+ if (v.length >= 20 && /[+/=]/.test(v))
72
+ return true; // base64 blob
73
+ return false;
74
+ }
75
+ // Documentation/sample values that share a real key's shape (same list as the
76
+ // phiScan audit). Rejected so docs and fixtures are not redacted.
77
+ const PLACEHOLDER = /x{6,}|\.\.\.|your|example|placeholder|changeme|redacted|dummy|fake|<|\$\{|\*{4,}/i;
78
+ const notPlaceholder = (v) => !PLACEHOLDER.test(v);
79
+ const notTestValue = (v) => notPlaceholder(v) && !looksLikeTestValue(v);
80
+ // URL passwords that are clearly not real: `user:password@`, `:pass@`, `:secret@`.
81
+ const URL_PASSWORD_PLACEHOLDER = /^(?:pass(?:word)?|secret|pwd|test|admin|root|guest)$/i;
82
+ // Order matters only for readability; overlap resolution (below) is by span,
83
+ // not by rule order. All regexes are global.
84
+ const RULES = [
85
+ // PEM private-key blocks (any key type), multiline.
86
+ {
87
+ name: "pem-private-key",
88
+ re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
89
+ },
90
+ // JWTs (three base64url segments). Also covers `Bearer eyJ...`.
91
+ {
92
+ name: "jwt",
93
+ re: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
94
+ },
95
+ // OpenAI project keys: anchored on the literal sk-proj- prefix, so the
96
+ // permissive body (which may contain -/_) can't match kebab identifiers.
97
+ { name: "openai-key", re: /\bsk-proj-[A-Za-z0-9_-]{20,}/g },
98
+ // OpenAI classic keys: alphanumeric body only, so `sk-feature-branch-...`
99
+ // (hyphenated kebab) is rejected — the hyphen breaks the run below 20.
100
+ { name: "openai-key", re: /\bsk-[A-Za-z0-9]{20,}\b/g },
101
+ // Stripe secret/restricted keys.
102
+ { name: "stripe-key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
103
+ // GitHub tokens: ghp_ gho_ ghu_ ghs_ ghr_ (+ fine-grained github_pat_).
104
+ { name: "github-token", re: /\bgh[posru]_[A-Za-z0-9]{36,}/g },
105
+ { name: "github-token", re: /\bgithub_pat_[A-Za-z0-9_]{40,}/g },
106
+ // AWS access key ids.
107
+ { name: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
108
+ // Google API keys.
109
+ { name: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
110
+ // Slack tokens.
111
+ { name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
112
+ // Formats the phiScan secret audit (convex/lib/secretAudit.ts on
113
+ // feat/phi-count-scan) found surviving in stored transcripts. Same shapes as
114
+ // its SURVIVOR_RULES so the audit and the scrubber agree on what a key is.
115
+ { name: "gitlab-token", re: /\bgl(?:pat|dt|rt|ptt|cbt|oas|soat|ffct|imt)-[A-Za-z0-9_-]{20,}/g, validate: notPlaceholder },
116
+ // sk-ant-api03-… has hyphens, so the classic openai-key rule never matched it.
117
+ { name: "anthropic-key", re: /\bsk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{40,}/g, validate: notPlaceholder },
118
+ // Convex deploy keys: `prod:happy-otter-123|<base64>`.
119
+ { name: "convex-deploy-key", re: /\b(?:prod|dev|preview):[a-z0-9-]+\|[A-Za-z0-9+/=]{40,}/g, validate: notPlaceholder },
120
+ { name: "linear-key", re: /\blin_(?:api|oauth)_[A-Za-z0-9]{30,}/g, validate: notPlaceholder },
121
+ // Our own keys: cdv_ (CLI), cdvm_ (model gateway), cdvci_, cdvi_, cdvd_,
122
+ // cdvx_, cdvh_, cdvr_, cdva_, cdvs_. Every body is 40+ random alnum/hex
123
+ // (lib/secureTokens.ts, lib/dataApiToken.ts); the 32+ floor keeps display
124
+ // prefixes like `cdvi_01234567…` and seed names like `cdv_seed` intact.
125
+ { name: "codiedev-key", re: /\bcdv(?:m|ci|i|d|x|h|r|a|s)?_[A-Za-z0-9]{32,}\b/g, validate: notPlaceholder },
126
+ // Browser sign-in tokens (convex/oauth.ts): access cdv_at_, refresh cdv_rt_,
127
+ // device code cdv_dc_, each + base64url of 32 random bytes (43 chars). The
128
+ // public client id cdv_client_ is not a secret.
129
+ { name: "codiedev-key", re: /\bcdv_(?:at|rt|dc)_[A-Za-z0-9_-]{40,}/g, validate: notPlaceholder },
130
+ { name: "fireworks-key", re: /\bfw_[A-Za-z0-9]{20,}/g, validate: notPlaceholder },
131
+ { name: "slack-webhook", re: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{20,}/g, validate: notPlaceholder },
132
+ { name: "npm-token", re: /\bnpm_[A-Za-z0-9]{36}\b/g, validate: notPlaceholder },
133
+ { name: "sendgrid-key", re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, validate: notPlaceholder },
134
+ { name: "twilio-key", re: /\bSK[0-9a-f]{32}\b/g, validate: notPlaceholder },
135
+ // Azure: redact the key/signature value, keep the connection string readable.
136
+ { name: "azure-storage-key", re: /AccountKey=([A-Za-z0-9+/=]{40,})/g, group: 1, validate: notPlaceholder },
137
+ { name: "azure-sas", re: /[?&]sig=([A-Za-z0-9%+/=]{30,})/g, group: 1, validate: notPlaceholder },
138
+ // Passwords inside DB / basic-auth URLs. Only the password is redacted so the
139
+ // host, user and database stay readable for summaries and debugging.
140
+ {
141
+ name: "url-password",
142
+ re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|mssql|sqlserver|amqps?|https?):\/\/[^:\s/"'@\\]+:([^@\s"'/\\]{6,})@[A-Za-z0-9.-]+/g,
143
+ group: 1,
144
+ validate: (v) => notPlaceholder(v) && !URL_PASSWORD_PLACEHOLDER.test(v),
145
+ },
146
+ // Generic Bearer tokens (non-JWT covered above) — redact only the token.
147
+ { name: "bearer-token", re: /\bBearer\s+([A-Za-z0-9._-]{20,})/g, group: 1 },
148
+ // Secret-named assignments: redact the value of KEY/SECRET/PASSWORD/etc.
149
+ // Name must look secret-y; value must be >= 8 non-space chars. This is the
150
+ // only entropy-ish rule and it is gated on the assignment name so benign
151
+ // env assignments (NODE_ENV=production, PORT=3000, LOG_LEVEL=debug) are safe.
152
+ {
153
+ name: "secret-assignment",
154
+ // The secret keyword must END the key name (it abuts the : or =), so the
155
+ // secret-word as a SUBSTRING of a larger code identifier does NOT match:
156
+ // `Last_Password_Change=`, `PasswordHash=`, `class ApiKeyController : Base`.
157
+ // Compound forms (SECRET_KEY, CLIENT_SECRET…) are listed before their parts.
158
+ // Quotes around the key or value may be JSON-escaped (\"KEY\": \"value\") —
159
+ // transcripts embed JSON-encoded tool results, so both forms must match;
160
+ // the value run excludes backslash so it stops at an escaped closing quote.
161
+ re: /\b([A-Za-z0-9_-]*?(?:SECRET[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|ACCESS[_-]?TOKEN|AUTH[_-]?TOKEN|REFRESH[_-]?TOKEN|API[_-]?KEY|PASSWORD|PASSWD|SECRET|TOKEN))\\?['"]?\s*[:=]\s*(?:\\?['"])?([^\s'"\\]{8,})(?:\\?['"])?/gi,
162
+ group: 2,
163
+ validate: (v, m) => !SECRET_REF_KEY.test(m[1]) &&
164
+ !/public/i.test(m[1]) && // .NET `PublicKeyToken=`, `PublicKey=` — public, not secret
165
+ // Pagination cursors (Jira nextPageToken, S3 ContinuationToken…) are
166
+ // high-entropy but not secrets — the one confirmed FP of the shadow week.
167
+ !/(?:page|continuation)[_-]?token$/i.test(m[1]) &&
168
+ m.input[m.index - 1] !== ":" && // ARN/path segment `arn:…:secret:<name>` — names a secret
169
+ looksSecret(v),
170
+ },
171
+ // Formats Gitleaks doesn't give a prefix rule for, seen in our sessions.
172
+ { name: "supabase-token", re: /\bsbp_[a-f0-9]{40}\b/g, validate: notTestValue },
173
+ { name: "groq-key", re: /\bgsk_[A-Za-z0-9]{48,56}\b/g, validate: notTestValue },
174
+ { name: "xai-key", re: /\bxai-[A-Za-z0-9]{70,90}\b/g, validate: notTestValue },
175
+ { name: "replicate-token", re: /\br8_[A-Za-z0-9]{37}\b/g, validate: notTestValue },
176
+ // Provider-prefix formats ported from Gitleaks (scrubProviderRules.ts).
177
+ ...scrubProviderRules_1.PROVIDER_RULES.map((r) => ({
178
+ name: r.name,
179
+ re: r.re,
180
+ ...(r.group !== undefined ? { group: r.group } : {}),
181
+ ...(r.firstGroup ? { firstGroup: true } : {}),
182
+ keywords: r.keywords,
183
+ fallback: true,
184
+ validate: (v) => notTestValue(v) && (r.entropy === undefined || shannonEntropy(v) >= r.entropy),
185
+ })),
186
+ ];
187
+ // Every rule keyword, found in one case-insensitive pass over the text
188
+ // instead of one search per keyword.
189
+ const ALL_KEYWORDS = [...new Set(RULES.flatMap((r) => r.keywords ?? []))].sort((a, b) => b.length - a.length);
190
+ const KEYWORD_RE = new RegExp(ALL_KEYWORDS.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gi");
191
+ const KEYWORD_PARTS = new Map(ALL_KEYWORDS.map((k) => [k, ALL_KEYWORDS.filter((p) => k.includes(p))]));
192
+ function keywordsIn(input) {
193
+ const found = new Set();
194
+ if (!ALL_KEYWORDS.length)
195
+ return found;
196
+ KEYWORD_RE.lastIndex = 0;
197
+ let m;
198
+ while ((m = KEYWORD_RE.exec(input)) !== null) {
199
+ // A keyword inside a longer one (sk_ in sk_live_) is present too.
200
+ for (const k of KEYWORD_PARTS.get(m[0].toLowerCase()) ?? [])
201
+ found.add(k);
202
+ if (found.size === ALL_KEYWORDS.length)
203
+ break;
204
+ }
205
+ return found;
206
+ }
207
+ function scrubSecrets(input, opts = {}) {
208
+ if (!input)
209
+ return { text: input, redactions: [] };
210
+ // 1) Collect every candidate span across all rules.
211
+ const candidates = [];
212
+ const fallbacks = [];
213
+ const generic = [];
214
+ // A caller's rules are few; they skip the keyword pre-check.
215
+ const extra = (opts.extraRules ?? []).map((r) => ({ name: r.name, re: new RegExp(r.re.source, r.re.flags.includes("g") ? r.re.flags : r.re.flags + "g"), validate: notTestValue }));
216
+ const present = keywordsIn(input);
217
+ for (const rule of extra.length ? [...RULES, ...extra] : RULES) {
218
+ if (rule.keywords?.length && !rule.keywords.some((k) => present.has(k)))
219
+ continue;
220
+ rule.re.lastIndex = 0;
221
+ let m;
222
+ while ((m = rule.re.exec(input)) !== null) {
223
+ let start = m.index;
224
+ let end = m.index + m[0].length;
225
+ const groupIndex = rule.firstGroup ? m.findIndex((g, i) => i > 0 && g) : rule.group;
226
+ if (rule.firstGroup && groupIndex === -1) {
227
+ if (m[0].length === 0)
228
+ rule.re.lastIndex++;
229
+ continue; // no secret group matched: never redact the whole match
230
+ }
231
+ if (groupIndex !== undefined && groupIndex > 0) {
232
+ const gv = m[groupIndex];
233
+ if (gv === undefined || gv === "")
234
+ continue;
235
+ if (rule.validate && !rule.validate(gv, m))
236
+ continue;
237
+ // Locate the group within the match (it appears after the name/prefix).
238
+ const rel = m[0].lastIndexOf(gv);
239
+ if (rel >= 0) {
240
+ start = m.index + rel;
241
+ end = start + gv.length;
242
+ }
243
+ }
244
+ else if (rule.validate && !rule.validate(m[0], m)) {
245
+ if (m[0].length === 0)
246
+ rule.re.lastIndex++;
247
+ continue;
248
+ }
249
+ if (end > start)
250
+ (rule.fallback ? fallbacks : exports.GENERIC_RULES.has(rule.name) ? generic : candidates).push({ rule: rule.name, start, end });
251
+ // Guard against zero-width matches looping forever.
252
+ if (m[0].length === 0)
253
+ rule.re.lastIndex++;
254
+ }
255
+ }
256
+ if (candidates.length === 0 && fallbacks.length === 0 && generic.length === 0)
257
+ return { text: input, redactions: [] };
258
+ // 2) Resolve overlaps: earlier start wins; on tie, longer span wins. Most
259
+ // specific first: hand-written provider rules, then ported provider
260
+ // rules, then the generic ones (a secret-named variable, a bearer
261
+ // header, a JWT) only where nothing more specific matched.
262
+ const pick = (list, taken) => {
263
+ list.sort((a, b) => a.start - b.start || b.end - a.end);
264
+ const out = [];
265
+ let cursor = -1;
266
+ let t = 0;
267
+ for (const c of list) {
268
+ if (c.start < cursor)
269
+ continue;
270
+ while (t < taken.length && taken[t].end <= c.start)
271
+ t++;
272
+ if (t < taken.length && taken[t].start < c.end)
273
+ continue;
274
+ out.push(c);
275
+ cursor = c.end;
276
+ }
277
+ return out;
278
+ };
279
+ let chosen = pick(candidates, []);
280
+ for (const tier of [fallbacks, generic]) {
281
+ if (tier.length)
282
+ chosen = [...chosen, ...pick(tier, chosen)].sort((a, b) => a.start - b.start);
283
+ }
284
+ return { text: redactSpans(input, chosen), redactions: chosen };
285
+ }
286
+ /** Rewrite `input` with each span replaced by `[REDACTED:<rule>]`. Spans must
287
+ * be sorted by start and not overlap. One forward pass: repeated
288
+ * slice+concat would copy the whole string per redaction (quadratic). */
289
+ function redactSpans(input, spans) {
290
+ const parts = [];
291
+ let last = 0;
292
+ for (const c of spans) {
293
+ parts.push(input.slice(last, c.start), `[REDACTED:${c.rule}]`);
294
+ last = c.end;
295
+ }
296
+ parts.push(input.slice(last));
297
+ return parts.join("");
298
+ }
299
+ // Rules without a provider-specific format: the only ones that misfire on
300
+ // test values, and the ones whose findings need a person to check.
301
+ exports.GENERIC_RULES = new Set(["secret-assignment", "bearer-token", "jwt"]);
302
+ const SEQUENTIAL = /0123456|1234567|2345678|3456789|abcdefg|bcdefgh|qwertyu/i;
303
+ const WORDISH = /^[A-Za-z]{2,}$/;
304
+ /** Values that share a key's shape but are test or sample values: words
305
+ * joined by _ or - with only digits or one repeated character between
306
+ * (`cdv_local_pilot_token_2026`, `…_token_000000`), a long counting run
307
+ * (`sk-1234567890…`), or a long repeated tail. Still removed from sessions;
308
+ * just not listed as a key to rotate. */
309
+ function looksLikeTestValue(value) {
310
+ if (SEQUENTIAL.test(value))
311
+ return true;
312
+ if (/(.)\1{9,}$/.test(value))
313
+ return true;
314
+ const parts = value.split(/[_-]+/).filter(Boolean);
315
+ if (parts.length < 3)
316
+ return false;
317
+ const words = parts.filter((p) => WORDISH.test(p)).length;
318
+ const filler = parts.filter((p) => /^\d+$/.test(p) || /^(.)\1*$/.test(p)).length;
319
+ return words >= 2 && words + filler === parts.length;
320
+ }
321
+ const TEST_FILE = /(?:\.test\.|\.spec\.|__tests__|(?:^|\/)tests?\/|fixtures?\/|__mocks__)/i;
322
+ // `NAME=`, `NAME: `, `"NAME": "`, `export NAME=`, `--name=` just before the value.
323
+ const NAME_BEFORE = /([A-Za-z_][A-Za-z0-9_.-]{0,63})\??["']?\s*[:=]\s*\??["']?(?:Bearer\s+)?$/;
324
+ function nameBefore(input, start, lineStart) {
325
+ const window = input.slice(Math.max(lineStart, start - 160), start).replace(/\\[nrt]/g, " ");
326
+ const m = window.match(NAME_BEFORE)?.[1];
327
+ if (!m || /^(?:https?|Bearer|Authorization)$/i.test(m))
328
+ return m === "Authorization" ? m : undefined;
329
+ return m;
330
+ }
331
+ const FILE_PATH_FIELD = /"(?:file_path|filePath|path|target_file)"\s*:\s*"([^"]{1,400})"/g;
332
+ const REDIRECT = /(?:>>?|\btee(?:\s+-a)?)\s*['"]?([^\s'"<>|;&()]{2,300})/g;
333
+ const CONFIG_FILE = /((?:[\w.~-]+\/)*\.env(?:\.[\w-]+)?|(?:[\w.~-]+\/)*[\w.-]+\.(?:env|ya?ml|toml|ini|cfg|conf|properties|tfvars|npmrc|netrc|pgpass))(?![\w/])/g;
334
+ const lastTwo = (p) => p.replace(/\\\//g, "/").split("/").filter(Boolean).slice(-2).join("/");
335
+ function lastMatch(re, text) {
336
+ let last;
337
+ for (const m of text.matchAll(re))
338
+ last = m[1];
339
+ return last;
340
+ }
341
+ /** The file this line writes or reads, if it names one. */
342
+ function fileOf(input, start, lineStart) {
343
+ const lineEnd = input.indexOf("\n", start);
344
+ const line = input.slice(lineStart, lineEnd === -1 ? input.length : lineEnd);
345
+ const before = input.slice(Math.max(lineStart, start - 400), start);
346
+ const hit = lastMatch(FILE_PATH_FIELD, line) ?? lastMatch(REDIRECT, before) ?? lastMatch(CONFIG_FILE, before);
347
+ if (!hit || hit.startsWith("/dev/") || hit === "&1" || hit === "&2")
348
+ return undefined;
349
+ const short = lastTwo(hit);
350
+ return short && short.length <= 120 ? short : undefined;
351
+ }
352
+ // Transcript shapes (one JSON object per line):
353
+ // Claude Code: {"type":"user"|"assistant","message":{"role","content":[{type:"tool_use",name,input}|{type:"tool_result"}|text]}}
354
+ // Codex: {"type":"response_item","payload":{"type":"message",role}|{"type":"function_call",name,arguments}|{"type":"function_call_output"}}
355
+ // Others: matched by the same generic markers (role, *tool*call*, *result*/*output*).
356
+ const TOOL_CALL_TYPE = /^(?:tool_use|function_call|custom_tool_call|tool_call|toolCall|tool_request|tool\.execution_start|server_tool_use|mcp_tool_use)$/;
357
+ const TOOL_OUTPUT_TYPE = /^(?:tool_result|function_call_output|custom_tool_call_output|tool_output|toolResult|tool\.execution_complete|mcp_tool_result)$/;
358
+ const SHELL_TOOL = /bash|shell|exec|terminal|run_command|run_terminal/i;
359
+ const GIT_COMMIT = /\bgit\s+(?:[^\n]*\s)?(?:commit|push)\b/;
360
+ /** Classify one redaction by the nearest enclosing tool call, tool result or
361
+ * message role in its transcript line. Non-JSON lines are "unknown". */
362
+ function locate(input, r, cache) {
363
+ const lineStart = input.lastIndexOf("\n", r.start - 1) + 1;
364
+ let parsed = cache.get(lineStart);
365
+ if (parsed === undefined) {
366
+ const lineEnd = input.indexOf("\n", r.end);
367
+ try {
368
+ parsed = JSON.parse(input.slice(lineStart, lineEnd === -1 ? input.length : lineEnd));
369
+ }
370
+ catch {
371
+ parsed = null;
372
+ }
373
+ cache.set(lineStart, parsed);
374
+ }
375
+ if (parsed === null || typeof parsed !== "object")
376
+ return { where: "unknown" };
377
+ // Search for an alphanumeric run from the secret: JSON escaping (\n, \")
378
+ // differs between the raw line and the parsed strings.
379
+ const runs = input.slice(r.start, r.end).match(/[A-Za-z0-9]{8,}/g);
380
+ const needle = runs ? runs.reduce((a, b) => (b.length > a.length ? b : a)) : null;
381
+ if (!needle)
382
+ return { where: "unknown" };
383
+ let path = null;
384
+ const walk = (node, trail) => {
385
+ if (path)
386
+ return;
387
+ if (typeof node === "string") {
388
+ if (node.includes(needle))
389
+ path = trail;
390
+ return;
391
+ }
392
+ if (!node || typeof node !== "object")
393
+ return;
394
+ if (Array.isArray(node)) {
395
+ node.forEach((c) => walk(c, [...trail, { node: c }]));
396
+ return;
397
+ }
398
+ for (const [k, c] of Object.entries(node))
399
+ walk(c, [...trail, { node: c, key: k }]);
400
+ };
401
+ walk(parsed, [{ node: parsed }]);
402
+ if (!path)
403
+ return { where: "unknown" };
404
+ const frames = path;
405
+ for (let i = frames.length - 1; i >= 0; i--) {
406
+ const n = frames[i].node;
407
+ if (!n || typeof n !== "object" || Array.isArray(n))
408
+ continue;
409
+ const type = typeof n.type === "string" ? n.type : "";
410
+ const tool = typeof n.name === "string" ? n.name : typeof n.toolName === "string" ? n.toolName : undefined;
411
+ if (TOOL_CALL_TYPE.test(type)) {
412
+ const args = JSON.stringify(n.input ?? n.arguments ?? n.args ?? "");
413
+ if (tool && SHELL_TOOL.test(tool) && GIT_COMMIT.test(args))
414
+ return { where: "commit", tool };
415
+ return tool ? { where: "tool_call", tool } : { where: "tool_call" };
416
+ }
417
+ if (TOOL_OUTPUT_TYPE.test(type))
418
+ return tool ? { where: "tool_output", tool } : { where: "tool_output" };
419
+ if (n.role === "user" || (type === "user" && i === 0))
420
+ return { where: "prompt" };
421
+ if (n.role === "assistant" || (type === "assistant" && i === 0))
422
+ return { where: "assistant" };
423
+ }
424
+ return { where: "unknown" };
425
+ }
426
+ const MAX_LOCATIONS = 8;
427
+ /** One finding per distinct (rule, last-4) in a scrub result, with every
428
+ * distinct place it appeared. */
429
+ function secretFindings(input, redactions, opts = {}) {
430
+ const seen = new Map();
431
+ const cache = new Map();
432
+ for (const r of redactions) {
433
+ let value = input.slice(r.start, r.end);
434
+ // A PEM block ends in a fixed footer; take the hint from the key body.
435
+ if (r.rule === "pem-private-key")
436
+ value = value.split("-----END")[0];
437
+ const hint = value.replace(/[^A-Za-z0-9]/g, "").slice(-4);
438
+ if (!hint)
439
+ continue;
440
+ const lineStart = input.lastIndexOf("\n", r.start - 1) + 1;
441
+ const name = nameBefore(input, r.start, lineStart);
442
+ const file = fileOf(input, r.start, lineStart);
443
+ if (looksLikeTestValue(value))
444
+ continue;
445
+ if (exports.GENERIC_RULES.has(r.rule) && file && TEST_FILE.test(file))
446
+ continue;
447
+ if (opts.skip?.({ rule: r.rule, value, ...(name ? { name } : {}), ...(file ? { file } : {}) }))
448
+ continue;
449
+ const key = `${r.rule}\u0000${hint}`;
450
+ const finding = seen.get(key) ?? { rule: r.rule, hint, locations: [] };
451
+ if (name && !finding.name)
452
+ finding.name = name;
453
+ if (file && !finding.file)
454
+ finding.file = file;
455
+ seen.set(key, finding);
456
+ const loc = locate(input, r, cache);
457
+ if (finding.locations.length < MAX_LOCATIONS && !finding.locations.some((l) => l.where === loc.where && l.tool === loc.tool)) {
458
+ finding.locations.push(loc);
459
+ }
460
+ }
461
+ return [...seen.values()];
462
+ }
463
+ // Key-ish names the secret-assignment rule doesn't cover (it already handles
464
+ // API_KEY, SECRET, TOKEN, PASSWORD…). The keyword must end the name.
465
+ const KEYISH_ASSIGNMENT = /\b([A-Za-z][A-Za-z0-9_-]*?(?:KEY|CREDENTIALS?|CREDS?|AUTH|SIGNATURE|SIGNING[_-]?KEY|SALT|DSN|PASS|PWD|PASSPHRASE|CONN(?:ECTION)?[_-]?STRING|COOKIE))\\?['"]?\s*[:=]\s*(?:\\?['"])?([A-Za-z0-9+/=_.-]{20,200})(?:\\?['"])?/gi;
466
+ // Names that hold random-looking values that aren't secrets.
467
+ const NOT_SECRET_NAME = /(?:cache|idempoten|partition|sort|primary|foreign|composite|unique|hash|etag|checksum|digest|storage|object|row|item|react|list|map|lookup|index|public|publishable|pub|page|continuation|cursor|nonce|trace|span|request|correlation|commit|sha|fingerprint|kid|key[_-]?id|keyof|monkey|turkey|hotkey|shortcut)/i;
468
+ // Unknown vendor tokens: `acme_live_…`, `xyz_prod_…`, `foo_sk_…`. pk_ is public.
469
+ const PREFIXED_TOKEN = /\b((?!pk_)[a-z][a-z0-9]{1,9}_(?:live|prod|secret|sk|key|token|api)_)([A-Za-z0-9]{24,})\b/g;
470
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
471
+ function shannon(v) {
472
+ const counts = new Map();
473
+ for (const c of v)
474
+ counts.set(c, (counts.get(c) ?? 0) + 1);
475
+ let h = 0;
476
+ for (const n of counts.values()) {
477
+ const p = n / v.length;
478
+ h -= p * Math.log2(p);
479
+ }
480
+ return h;
481
+ }
482
+ /** Random enough to be a credential, and not a known non-secret shape. */
483
+ function randomLooking(v) {
484
+ if (v.length < 20 || UUID.test(v))
485
+ return false;
486
+ if (/^\d+$/.test(v) || /^[A-Za-z]+$/.test(v))
487
+ return false;
488
+ if (/[._-]{2}|^[._-]|[._-]$/.test(v))
489
+ return false;
490
+ if (/\.[a-z]{2,4}$/i.test(v) || /^[a-z]+(?:[._-][a-z]+)+$/i.test(v))
491
+ return false; // file names, dotted/kebab words
492
+ if (PLACEHOLDER.test(v))
493
+ return false;
494
+ // Slugs and ids: `openai/gpt-5-mini` (base64 never mixes / and -), and
495
+ // lowercase names like `local-team_app-4870`.
496
+ if (v.includes("/") && v.includes("-"))
497
+ return false;
498
+ if (/^[a-z0-9]+(?:[_-][a-z0-9]+){2,}$/.test(v))
499
+ return false;
500
+ if (!/[0-9]/.test(v) || !/[A-Za-z]/.test(v))
501
+ return false;
502
+ return shannon(v) >= 3.5;
503
+ }
504
+ function shapeOf(v) {
505
+ if (/^[0-9a-f]+$/i.test(v))
506
+ return "hex";
507
+ if (/[+/=]/.test(v))
508
+ return "base64";
509
+ return "alnum";
510
+ }
511
+ // Long runs with letters AND digits get masked in snippets; names like
512
+ // `partnerCredentials` stay readable.
513
+ const LONG_RUN = /[A-Za-z0-9+/=_-]{16,}/g;
514
+ const maskRuns = (s) => s.replace(LONG_RUN, (r) => (/[0-9]/.test(r) && /[A-Za-z]/.test(r) ? "••••" : r));
515
+ /** The decoded string (prompt text, command, tool output…) the value sits
516
+ * in, when its transcript line is JSON; else the raw line. */
517
+ function containingText(text, start, end, cache) {
518
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
519
+ const lineEndRaw = text.indexOf("\n", end);
520
+ const raw = text.slice(lineStart, lineEndRaw === -1 ? text.length : lineEndRaw);
521
+ let parsed = cache.get(lineStart);
522
+ if (parsed === undefined) {
523
+ try {
524
+ parsed = JSON.parse(raw);
525
+ }
526
+ catch {
527
+ parsed = null;
528
+ }
529
+ cache.set(lineStart, parsed);
530
+ }
531
+ const value = text.slice(start, end);
532
+ let hit = null;
533
+ const walk = (node) => {
534
+ if (hit !== null)
535
+ return;
536
+ if (typeof node === "string") {
537
+ if (!node.includes(value))
538
+ return;
539
+ // Codex function_call arguments are JSON in a string: look inside.
540
+ if (/^[[{]/.test(node.trim())) {
541
+ try {
542
+ walk(JSON.parse(node));
543
+ }
544
+ catch { /* not JSON */ }
545
+ }
546
+ if (hit === null)
547
+ hit = node;
548
+ return;
549
+ }
550
+ if (node && typeof node === "object")
551
+ for (const c of Object.values(node))
552
+ walk(c);
553
+ };
554
+ if (parsed !== null)
555
+ walk(parsed);
556
+ return hit ?? raw;
557
+ }
558
+ /** ~80 characters around a value, the value shown as ••••last4 and every
559
+ * other random-looking run masked, so the snippet can't carry a secret. */
560
+ function snippetAround(text, start, end, hint, cache) {
561
+ const value = text.slice(start, end);
562
+ const host = containingText(text, start, end, cache);
563
+ const at = host.indexOf(value);
564
+ const from = Math.max(0, at - 40);
565
+ const to = Math.min(host.length, at + value.length + 40);
566
+ const before = maskRuns(host.slice(from, at));
567
+ const after = maskRuns(host.slice(at + value.length, to));
568
+ return `${from > 0 ? "…" : ""}${before}••••${hint}${after}${to < host.length ? "…" : ""}`.replace(/\s+/g, " ").trim();
569
+ }
570
+ const MAX_POSSIBLE = 20;
571
+ function possibleSecrets(text, limit = MAX_POSSIBLE) {
572
+ if (!text)
573
+ return [];
574
+ const found = new Map();
575
+ const cache = new Map();
576
+ const add = (label, prefix, value, start) => {
577
+ const end = start + value.length;
578
+ const hint = value.replace(/[^A-Za-z0-9]/g, "").slice(-4);
579
+ const key = `${label.toLowerCase()}\u0000${hint}\u0000${value.length}`;
580
+ let item = found.get(key);
581
+ if (!item) {
582
+ if (found.size >= limit)
583
+ return;
584
+ item = { label, prefix, hint, length: value.length, shape: shapeOf(value), snippet: snippetAround(text, start, end, hint, cache), locations: [], spans: [] };
585
+ found.set(key, item);
586
+ }
587
+ item.spans.push({ start, end });
588
+ const loc = locate(text, { rule: "possible", start, end }, cache);
589
+ if (item.locations.length < MAX_LOCATIONS && !item.locations.some((l) => l.where === loc.where && l.tool === loc.tool))
590
+ item.locations.push(loc);
591
+ };
592
+ KEYISH_ASSIGNMENT.lastIndex = 0;
593
+ let m;
594
+ while ((m = KEYISH_ASSIGNMENT.exec(text)) !== null) {
595
+ const [whole, rawName, value] = m;
596
+ // `\nAZURE_KEY=` in JSON-escaped text: the n belongs to the escape.
597
+ const name = text[m.index - 1] === "\\" ? rawName.replace(/^(?:u00[0-9a-fA-F]{2}|[ntr](?=[A-Z]))/, "") : rawName;
598
+ // A signed URL's signature parameter (`?…&signature=`) expires with the URL.
599
+ const before = text.slice(Math.max(0, m.index - 7), m.index);
600
+ if (/sig(?:nature)?$/i.test(name) && /(?:[?&]|\\u0026|\\u003f)$/i.test(before + rawName.slice(0, rawName.length - name.length)))
601
+ continue;
602
+ if (NOT_SECRET_NAME.test(name) || SECRET_REF_KEY.test(name))
603
+ continue;
604
+ if (text[m.index - 1] === ":" || text[m.index - 1] === ".")
605
+ continue; // arn:…, obj.key
606
+ if (!randomLooking(value))
607
+ continue;
608
+ add(name, undefined, value, m.index + whole.lastIndexOf(value));
609
+ }
610
+ PREFIXED_TOKEN.lastIndex = 0;
611
+ while ((m = PREFIXED_TOKEN.exec(text)) !== null) {
612
+ const [whole, prefix, body] = m;
613
+ if (!randomLooking(body))
614
+ continue;
615
+ const start = m.index;
616
+ // Already found under its name (`X-Acme-Key: acme_live_…`): one item,
617
+ // keep the name and note the prefix.
618
+ const named = [...found.values()].find((f) => f.spans.some((sp) => sp.start < start + whole.length && start < sp.end));
619
+ if (named) {
620
+ named.prefix ??= prefix;
621
+ continue;
622
+ }
623
+ add(prefix.replace(/_$/, ""), prefix, whole, start);
624
+ }
625
+ return [...found.values()];
626
+ }
627
+ /** Redact every occurrence of an already-reviewed possible secret. */
628
+ function redactPossible(text, match, rule = "reviewed-secret") {
629
+ const hits = possibleSecrets(text, Infinity).filter((p) => p.label.toLowerCase() === match.label.toLowerCase() && p.hint === match.hint && p.length === match.length);
630
+ const spans = hits.flatMap((h) => h.spans).sort((a, b) => a.start - b.start);
631
+ if (!spans.length)
632
+ return { text, redactions: [] };
633
+ const parts = [];
634
+ let last = 0;
635
+ const redactions = [];
636
+ for (const s of spans) {
637
+ if (s.start < last)
638
+ continue;
639
+ parts.push(text.slice(last, s.start), `[REDACTED:${rule}]`);
640
+ redactions.push({ rule, start: s.start, end: s.end });
641
+ last = s.end;
642
+ }
643
+ parts.push(text.slice(last));
644
+ return { text: parts.join(""), redactions };
645
+ }
@@ -0,0 +1,14 @@
1
+ export interface ProviderRule {
2
+ name: string;
3
+ re: RegExp;
4
+ /** Capture group holding the secret; the whole match when absent. */
5
+ group?: number;
6
+ /** The secret is the first capture group that matched (Gitleaks' default). */
7
+ firstGroup?: boolean;
8
+ /** Minimum Shannon entropy of the secret, as Gitleaks checks it. */
9
+ entropy?: number;
10
+ /** Lowercase strings one of which must appear for the rule to run. */
11
+ keywords: string[];
12
+ }
13
+ export declare const PROVIDER_RULES: ProviderRule[];
14
+ export declare const PROVIDER_RULE_LABELS: Record<string, string>;