@vellumai/credential-executor 0.10.12-dev.202607250140.9979030 → 0.10.12-staging.2

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.
@@ -17,7 +17,6 @@
17
17
  "./rpc": "./src/rpc.ts",
18
18
  "./attachment-naming": "./src/attachment-naming.ts",
19
19
  "./redacted-credential": "./src/redacted-credential.ts",
20
- "./secret-detection": "./src/secret-detection.ts",
21
20
  "./error": "./src/error.ts"
22
21
  },
23
22
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.10.12-dev.202607250140.9979030",
3
+ "version": "0.10.12-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,449 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- // Self-referencing package import — proves the `./secret-detection` subpath
4
- // export resolves for consumers.
5
- import * as subpathExport from "@vellumai/service-contracts/secret-detection";
6
-
7
- import {
8
- detectSecretsInText,
9
- isCompletePrivateKeyBlock,
10
- PEM_REDACTION_MAX_BODY_LENGTH,
11
- PREFIX_PATTERNS,
12
- PRIVATE_KEY_REDACTION_REGEX,
13
- REDACTION_PREFIX_PATTERNS,
14
- TOKEN_SHAPE,
15
- TOKEN_SHAPE_LABEL,
16
- TOKEN_SHAPE_MAX_LENGTH,
17
- } from "../secret-detection.js";
18
-
19
- // All fixtures below are synthetic lookalike tokens — never real key material.
20
-
21
- function matchingLabels(text: string): string[] {
22
- return PREFIX_PATTERNS.filter((p) => p.regex.test(text)).map((p) => p.label);
23
- }
24
-
25
- const ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789";
26
-
27
- /** Lowercase-alphanumeric filler of the given length (no separators, so it
28
- * can never form another pattern's prefix like `npm_` or `key-`). */
29
- function filler(length: number): string {
30
- return ALNUM.repeat(Math.ceil(length / ALNUM.length)).slice(0, length);
31
- }
32
-
33
- /** One synthetic fixture per provider family, keyed by pattern label. */
34
- const FIXTURES: Record<string, string> = {
35
- "AWS Access Key": "AKIAABCDEFGHIJKLMNOP",
36
- "GitHub Token": `ghp_${filler(36)}`,
37
- "GitHub Fine-Grained PAT": `github_pat_${filler(22)}`,
38
- "GitLab Token": `glpat-${filler(20)}`,
39
- "Stripe Secret Key": `sk_live_${filler(24)}`,
40
- "Stripe Restricted Key": `rk_live_${filler(24)}`,
41
- "Slack Bot Token": `xoxb-1234567890-1234567890-${filler(24)}`,
42
- "Slack User Token": `xoxp-1234567890-1234567890-1234567890-${"0123456789abcdef".repeat(2)}`,
43
- "Slack App Token": "xapp-1-abc123-4567-defghij890",
44
- "Telegram Bot Token": `123456789:${filler(35)}`,
45
- "Anthropic API Key": `sk-ant-${filler(80)}`,
46
- "OpenAI API Key": `sk-${filler(20)}T3BlbkFJ${filler(20)}`,
47
- "OpenAI Project Key": `sk-proj-${filler(40)}`,
48
- "Google API Key": `AIza${filler(35)}`,
49
- "Google OAuth Client Secret": `GOCSPX-${filler(28)}`,
50
- "Twilio API Key": `SK${"0123456789abcdef".repeat(2)}`,
51
- "SendGrid API Key": `SG.${filler(22)}.${filler(43)}`,
52
- "Mailgun API Key": `key-${filler(32)}`,
53
- "npm Token": `npm_${filler(36)}`,
54
- "PyPI API Token": `pypi-${filler(50)}`,
55
- "Private Key": "-----BEGIN OPENSSH PRIVATE KEY-----",
56
- "Linear API Key": `lin_api_${filler(32)}`,
57
- "Notion Integration Token": `ntn_${filler(40)}`,
58
- "OpenRouter API Key": `sk-or-v1-${filler(40)}`,
59
- "Vercel AI Gateway API Key": `vck_${filler(24)}`,
60
- "Fireworks API Key": `fw_${filler(32)}`,
61
- "Perplexity API Key": `pplx-${filler(40)}`,
62
- "Tavily API Key": `tvly-${filler(20)}`,
63
- "Firecrawl API Key": `fc-${filler(20)}`,
64
- };
65
-
66
- describe("subpath export", () => {
67
- test("@vellumai/service-contracts/secret-detection resolves to this module", () => {
68
- expect(subpathExport.PREFIX_PATTERNS).toBe(PREFIX_PATTERNS);
69
- expect(subpathExport.detectSecretsInText).toBe(detectSecretsInText);
70
- });
71
- });
72
-
73
- describe("PREFIX_PATTERNS", () => {
74
- test("every pattern has a fixture", () => {
75
- expect(Object.keys(FIXTURES).sort()).toEqual(
76
- PREFIX_PATTERNS.map((p) => p.label).sort(),
77
- );
78
- });
79
-
80
- test("pattern labels are unique", () => {
81
- const labels = PREFIX_PATTERNS.map((p) => p.label);
82
- expect(new Set(labels).size).toBe(labels.length);
83
- });
84
-
85
- // Label-uniqueness invariant: each fixture matches exactly one pattern, so
86
- // a detected value always maps to an unambiguous label.
87
- for (const [label, fixture] of Object.entries(FIXTURES)) {
88
- test(`${label} fixture matches exactly that pattern`, () => {
89
- expect(matchingLabels(fixture)).toEqual([label]);
90
- });
91
- }
92
-
93
- test("does not match a short vck_ string", () => {
94
- expect(matchingLabels("vck_abc")).toEqual([]);
95
- });
96
- });
97
-
98
- describe("detectSecretsInText — prefix patterns", () => {
99
- for (const [label, fixture] of Object.entries(FIXTURES)) {
100
- test(`detects a ${label} with the right label and span`, () => {
101
- const text = `my key is ${fixture} thanks`;
102
- const start = text.indexOf(fixture);
103
- expect(detectSecretsInText(text)).toEqual([
104
- {
105
- label,
106
- value: fixture,
107
- start,
108
- end: start + fixture.length,
109
- wholeMessage: false,
110
- },
111
- ]);
112
- });
113
- }
114
-
115
- test("does not mutate the shared pattern objects' lastIndex", () => {
116
- detectSecretsInText(`ghp_${filler(36)}`);
117
- for (const p of PREFIX_PATTERNS) {
118
- expect(p.regex.lastIndex).toBe(0);
119
- }
120
- });
121
- });
122
-
123
- describe("detectSecretsInText — placeholder suppression", () => {
124
- test("known placeholder text returns no matches", () => {
125
- expect(detectSecretsInText("your-api-key-here")).toEqual([]);
126
- expect(detectSecretsInText("sk-xxxxxxxxxxxxxxxxxxxxxxxx")).toEqual([]);
127
- });
128
-
129
- test("placeholder-prefixed token-shaped message is suppressed", () => {
130
- expect(detectSecretsInText(`fake_key_${filler(20)}`)).toEqual([]);
131
- expect(detectSecretsInText(`test_api_${filler(16)}`)).toEqual([]);
132
- expect(detectSecretsInText(`dummy-token-${filler(16)}`)).toEqual([]);
133
- });
134
-
135
- test("token-shaped message with a placeholder tail is suppressed", () => {
136
- // TOKEN_SHAPE matches with tail "replace_with_your_key", which is a
137
- // known placeholder.
138
- expect(detectSecretsInText("acme_key_replace_with_your_key")).toEqual([]);
139
- });
140
-
141
- test("placeholder pre-context suppresses a prefix match (fake_ghp_...)", () => {
142
- // The GitHub regex matches starting at `ghp_`, so the `fake_` marker
143
- // sits in the pre-context window rather than the matched value.
144
- expect(detectSecretsInText(`fake_${"ghp_" + filler(36)}`)).toEqual([]);
145
- expect(
146
- detectSecretsInText(`docs use example_ghp_${filler(36)} as a stand-in`),
147
- ).toEqual([]);
148
- });
149
-
150
- test("repeated-character variable portion suppresses a prefix match", () => {
151
- // AKIA followed by 16 repeated placeholder characters.
152
- expect(detectSecretsInText("AKIAXXXXXXXXXXXXXXXX")).toEqual([]);
153
- expect(detectSecretsInText(`set AWS_KEY=AKIA${"X".repeat(16)}`)).toEqual(
154
- [],
155
- );
156
- });
157
-
158
- test("the same tokens without placeholder context still match", () => {
159
- const github = `ghp_${filler(36)}`;
160
- expect(detectSecretsInText(github).map((m) => m.label)).toEqual([
161
- "GitHub Token",
162
- ]);
163
- expect(
164
- detectSecretsInText("AKIAABCDEFGHIJKLMNOP").map((m) => m.label),
165
- ).toEqual(["AWS Access Key"]);
166
- });
167
-
168
- test("token-shaped message with a repeated-character tail is suppressed", () => {
169
- expect(detectSecretsInText(`my_key_${"x".repeat(16)}`)).toEqual([]);
170
- });
171
- });
172
-
173
- describe("detectSecretsInText — whole-message token shape", () => {
174
- test("a whole-message token-shaped value is detected", () => {
175
- const token = `virlo_tkn_${filler(20)}`;
176
- expect(TOKEN_SHAPE.test(token)).toBe(true);
177
- expect(detectSecretsInText(token)).toEqual([
178
- {
179
- label: TOKEN_SHAPE_LABEL,
180
- value: token,
181
- start: 0,
182
- end: token.length,
183
- wholeMessage: true,
184
- },
185
- ]);
186
- });
187
-
188
- test("surrounding whitespace is trimmed and the span covers the token", () => {
189
- const token = `acme_secret_${filler(18)}`;
190
- expect(detectSecretsInText(` ${token} \n`)).toEqual([
191
- {
192
- label: TOKEN_SHAPE_LABEL,
193
- value: token,
194
- start: 2,
195
- end: 2 + token.length,
196
- wholeMessage: true,
197
- },
198
- ]);
199
- });
200
-
201
- test("a normal sentence returns no matches", () => {
202
- expect(
203
- detectSecretsInText("can you review my pull request today?"),
204
- ).toEqual([]);
205
- expect(detectSecretsInText("")).toEqual([]);
206
- });
207
-
208
- test("token shape is not applied when a prefix pattern already matched", () => {
209
- const key = `glpat-${filler(20)}`;
210
- const results = detectSecretsInText(key);
211
- expect(results).toHaveLength(1);
212
- expect(results[0]!.label).toBe("GitLab Token");
213
- });
214
-
215
- test("a token-shaped value over the max length is not matched", () => {
216
- // Same shape as a valid token but a tail longer than the shared max — the
217
- // daemon ingress caps the whole-message heuristic here, so the client must
218
- // too or a >512-char paste would warn client-side yet pass server-side.
219
- const tail = filler(TOKEN_SHAPE_MAX_LENGTH + 1);
220
- const token = `virlo_tkn_${tail}`;
221
- expect(token.length).toBeGreaterThan(TOKEN_SHAPE_MAX_LENGTH);
222
- expect(TOKEN_SHAPE.test(token)).toBe(true);
223
- expect(detectSecretsInText(token)).toEqual([]);
224
- });
225
-
226
- test("a token-shaped value exactly at the max length is still matched", () => {
227
- const prefix = "virlo_tkn_";
228
- const token = `${prefix}${filler(TOKEN_SHAPE_MAX_LENGTH - prefix.length)}`;
229
- expect(token.length).toBe(TOKEN_SHAPE_MAX_LENGTH);
230
- const results = detectSecretsInText(token);
231
- expect(results).toHaveLength(1);
232
- expect(results[0]!.label).toBe(TOKEN_SHAPE_LABEL);
233
- });
234
- });
235
-
236
- describe("REDACTION_PREFIX_PATTERNS — bounded whole-block private key", () => {
237
- const FAKE_PEM_BODY =
238
- "MIIFAKEfakefakefakefakefakefakefakefakefake\n" +
239
- "FAKEfakefakefakefakefakefakefakefakefake==";
240
- const FULL_PEM_BLOCK = `-----BEGIN RSA PRIVATE KEY-----\n${FAKE_PEM_BODY}\n-----END RSA PRIVATE KEY-----`;
241
-
242
- function redactionPrivateKeyRegex(): RegExp {
243
- const entry = REDACTION_PREFIX_PATTERNS.find(
244
- (p) => p.label === "Private Key",
245
- );
246
- expect(entry).toBeDefined();
247
- return new RegExp(entry!.regex.source, "g");
248
- }
249
-
250
- test("the redaction private-key entry is the bounded whole-block matcher", () => {
251
- const entry = REDACTION_PREFIX_PATTERNS.find(
252
- (p) => p.label === "Private Key",
253
- );
254
- expect(entry!.regex.source).toBe(PRIVATE_KEY_REDACTION_REGEX.source);
255
- });
256
-
257
- test("the redaction variant matches the WHOLE block, not just the header", () => {
258
- const regex = redactionPrivateKeyRegex();
259
- const match = regex.exec(FULL_PEM_BLOCK);
260
- expect(match).not.toBeNull();
261
- // The full header→body→footer span, so an in-place replace removes the
262
- // key body and footer, not only the BEGIN line.
263
- expect(match![0]).toBe(FULL_PEM_BLOCK);
264
- });
265
-
266
- test("an in-place replace with the redaction regex leaves no body or footer", () => {
267
- const regex = redactionPrivateKeyRegex();
268
- const text = `before\n${FULL_PEM_BLOCK}\nafter`;
269
- const redacted = text.replace(regex, "[REDACTED]");
270
- expect(redacted).toBe("before\n[REDACTED]\nafter");
271
- // The base64 body and the END footer are gone.
272
- expect(redacted).not.toContain("MIIFAKE");
273
- expect(redacted).not.toContain("-----END");
274
- expect(redacted).not.toContain("-----BEGIN");
275
- });
276
-
277
- test("a footerless key still redacts header-only (truncated-key fallback)", () => {
278
- const regex = redactionPrivateKeyRegex();
279
- const text = `-----BEGIN RSA PRIVATE KEY-----\n${FAKE_PEM_BODY}`;
280
- const match = regex.exec(text);
281
- expect(match).not.toBeNull();
282
- // No END within the bound → optional footer group fails → header-only
283
- // match, preserving the detection-fires guarantee.
284
- expect(match![0]).toBe("-----BEGIN RSA PRIVATE KEY-----");
285
- });
286
-
287
- test("a body longer than the bound falls back to header-only, not a hang", () => {
288
- const regex = redactionPrivateKeyRegex();
289
- // Footer sits just past the body bound, so the whole-block branch can't
290
- // reach it and the match degrades to the header (fail-open on redaction:
291
- // an unbounded key is not a realistic PEM).
292
- const overlongBody = "a".repeat(PEM_REDACTION_MAX_BODY_LENGTH + 100);
293
- const text = `-----BEGIN RSA PRIVATE KEY-----\n${overlongBody}\n-----END RSA PRIVATE KEY-----`;
294
- const match = regex.exec(text);
295
- expect(match).not.toBeNull();
296
- expect(match![0]).toBe("-----BEGIN RSA PRIVATE KEY-----");
297
- });
298
-
299
- test("many footerless BEGIN headers redact fast (bounded, no O(n²) hang)", () => {
300
- const regex = redactionPrivateKeyRegex();
301
- // A pathological paste: thousands of footerless headers, each followed by
302
- // filler that an unbounded matcher would rescan hunting for a footer. The
303
- // body bound caps each rescan, so the whole sweep stays linear.
304
- const chunk = `-----BEGIN RSA PRIVATE KEY-----\n${filler(200)}\n`;
305
- const text = chunk.repeat(5000);
306
- const startedAt = Date.now();
307
- const redacted = text.replace(regex, "[REDACTED]");
308
- const elapsedMs = Date.now() - startedAt;
309
- expect(redacted).toContain("[REDACTED]");
310
- expect(redacted).not.toContain("-----BEGIN");
311
- expect(elapsedMs).toBeLessThan(2000);
312
- });
313
-
314
- test("every non-private-key pattern is shared verbatim with PREFIX_PATTERNS", () => {
315
- for (let i = 0; i < PREFIX_PATTERNS.length; i++) {
316
- if (PREFIX_PATTERNS[i]!.label === "Private Key") {
317
- continue;
318
- }
319
- expect(REDACTION_PREFIX_PATTERNS[i]).toBe(PREFIX_PATTERNS[i]);
320
- }
321
- });
322
-
323
- test("detectSecretsInText still captures the whole block (P1 stays fixed)", () => {
324
- const results = detectSecretsInText(FULL_PEM_BLOCK);
325
- expect(results).toHaveLength(1);
326
- expect(results[0]!.label).toBe("Private Key");
327
- expect(results[0]!.value).toBe(FULL_PEM_BLOCK);
328
- });
329
- });
330
-
331
- describe("detectSecretsInText — PEM private-key blocks", () => {
332
- // Clearly fake PEM material — never a real key.
333
- const FAKE_PEM_BODY =
334
- "MIIFAKEfakefakefakefakefakefakefakefakefake\n" +
335
- "FAKEfakefakefakefakefakefakefakefakefake==";
336
- const FULL_PEM_BLOCK = `-----BEGIN RSA PRIVATE KEY-----\n${FAKE_PEM_BODY}\n-----END RSA PRIVATE KEY-----`;
337
-
338
- test("a complete PEM block is one match spanning header through footer", () => {
339
- const text = `here is my key\n${FULL_PEM_BLOCK}\nplease use it`;
340
- const start = text.indexOf(FULL_PEM_BLOCK);
341
- expect(detectSecretsInText(text)).toEqual([
342
- {
343
- label: "Private Key",
344
- value: FULL_PEM_BLOCK,
345
- start,
346
- end: start + FULL_PEM_BLOCK.length,
347
- wholeMessage: false,
348
- },
349
- ]);
350
- });
351
-
352
- test("a PGP private-key block with the BLOCK suffix is captured whole", () => {
353
- const block = `-----BEGIN PGP PRIVATE KEY BLOCK-----\n${FAKE_PEM_BODY}\n-----END PGP PRIVATE KEY BLOCK-----`;
354
- const results = detectSecretsInText(block);
355
- expect(results).toHaveLength(1);
356
- expect(results[0]!.value).toBe(block);
357
- });
358
-
359
- test("a header with a body but no END footer still fires — header-only span", () => {
360
- // Truncated / partially pasted key: detection must not regress, the
361
- // daemon ingress relies on the header alone to block.
362
- const text = `-----BEGIN RSA PRIVATE KEY-----\n${FAKE_PEM_BODY}`;
363
- const results = detectSecretsInText(text);
364
- expect(results).toEqual([
365
- {
366
- label: "Private Key",
367
- value: "-----BEGIN RSA PRIVATE KEY-----",
368
- start: 0,
369
- end: "-----BEGIN RSA PRIVATE KEY-----".length,
370
- wholeMessage: false,
371
- },
372
- ]);
373
- expect(isCompletePrivateKeyBlock(results[0]!.value)).toBe(false);
374
- });
375
-
376
- test("adjacent complete blocks match separately, not as one giant span", () => {
377
- const text = `${FULL_PEM_BLOCK}\n\n${FULL_PEM_BLOCK}`;
378
- const results = detectSecretsInText(text);
379
- expect(results).toHaveLength(2);
380
- expect(results[0]!.value).toBe(FULL_PEM_BLOCK);
381
- expect(results[1]!.value).toBe(FULL_PEM_BLOCK);
382
- expect(results[1]!.start).toBeGreaterThanOrEqual(results[0]!.end);
383
- });
384
-
385
- test("a footerless header never swallows a later complete block", () => {
386
- // The body is tempered — it cannot cross another BEGIN header — so the
387
- // truncated first key matches header-only and the second key matches
388
- // as its own complete block.
389
- const text = `-----BEGIN EC PRIVATE KEY-----\ntruncated\n\n${FULL_PEM_BLOCK}`;
390
- const results = detectSecretsInText(text);
391
- expect(results.map((r) => r.value)).toEqual([
392
- "-----BEGIN EC PRIVATE KEY-----",
393
- FULL_PEM_BLOCK,
394
- ]);
395
- });
396
-
397
- test("a token-lookalike run inside the block body loses to the block", () => {
398
- // Base64 can legally contain an AKIA + 16-uppercase run; the Private
399
- // Key pattern is first in PREFIX_PATTERNS so the whole-block span wins
400
- // the overlap dedupe instead of being fragmented by the AWS pattern.
401
- const block = `-----BEGIN RSA PRIVATE KEY-----\nAKIAABCDEFGHIJKLMNOP\n${FAKE_PEM_BODY}\n-----END RSA PRIVATE KEY-----`;
402
- const results = detectSecretsInText(block);
403
- expect(results).toHaveLength(1);
404
- expect(results[0]!.label).toBe("Private Key");
405
- expect(results[0]!.value).toBe(block);
406
- });
407
- });
408
-
409
- describe("isCompletePrivateKeyBlock", () => {
410
- test("true for a value ending at the END footer", () => {
411
- expect(
412
- isCompletePrivateKeyBlock(
413
- "-----BEGIN OPENSSH PRIVATE KEY-----\nMIIFAKEfake==\n-----END OPENSSH PRIVATE KEY-----",
414
- ),
415
- ).toBe(true);
416
- });
417
-
418
- test("false for a header-only match and for non-PEM values", () => {
419
- expect(
420
- isCompletePrivateKeyBlock("-----BEGIN OPENSSH PRIVATE KEY-----"),
421
- ).toBe(false);
422
- expect(isCompletePrivateKeyBlock(`ghp_${filler(36)}`)).toBe(false);
423
- });
424
- });
425
-
426
- describe("detectSecretsInText — multiple and overlapping matches", () => {
427
- test("multiple secrets yield sorted, non-overlapping matches", () => {
428
- const aws = "AKIAABCDEFGHIJKLMNOP";
429
- const github = `ghp_${filler(36)}`;
430
- // GitHub appears before AWS in the text but after it in pattern order.
431
- const text = `first ${github} then ${aws} done`;
432
- const results = detectSecretsInText(text);
433
- expect(results.map((r) => r.label)).toEqual([
434
- "GitHub Token",
435
- "AWS Access Key",
436
- ]);
437
- for (let i = 1; i < results.length; i++) {
438
- expect(results[i]!.start).toBeGreaterThanOrEqual(results[i - 1]!.end);
439
- }
440
- });
441
-
442
- test("overlapping spans keep the first pattern in list order", () => {
443
- // The GitHub token's tail embeds an `npm_…` run; GitHub Token precedes
444
- // npm Token in PREFIX_PATTERNS, so only the GitHub match survives.
445
- const value = `ghp_npm_${filler(36)}`;
446
- const results = detectSecretsInText(value);
447
- expect(results.map((r) => r.label)).toEqual(["GitHub Token"]);
448
- });
449
- });
@@ -1,468 +0,0 @@
1
- /**
2
- * Shared prefix-based secret patterns — the single source of truth.
3
- *
4
- * Ingress blocking, tool output scanning, log redaction, and client-side
5
- * composer detection all consume this list. When adding a new integration,
6
- * add its API key pattern here.
7
- *
8
- * This module is intentionally data-only: no imports, no entropy logic,
9
- * no config — safe for hot-path consumers like log serializers, and
10
- * browser-safe so web clients can bundle it directly.
11
- *
12
- * The exported `PATTERNS`/constants are shared verbatim by the daemon and the
13
- * web clients, so the two sides agree on what a secret *looks like*. The web
14
- * composer scanner is a conservative superset pre-filter of the daemon ingress
15
- * check, not an identical twin: the daemon additionally applies a per-user
16
- * secret allowlist and config gating (`secretDetection.enabled` /
17
- * `blockIngress`) on top of these shared patterns, so the client may warn on a
18
- * few values the server would accept (e.g. an allowlisted token). Only the
19
- * shared patterns and length bounds in this module are guaranteed identical
20
- * across client and server.
21
- */
22
-
23
- // ---------------------------------------------------------------------------
24
- // Types
25
- // ---------------------------------------------------------------------------
26
-
27
- export interface SecretPrefixPattern {
28
- /** Human-readable label shown in block notices and redaction tags. */
29
- label: string;
30
- /**
31
- * Regex that matches the token value. Must NOT include the `g` flag or
32
- * capture groups — consumers add those as needed.
33
- */
34
- regex: RegExp;
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Prefix patterns
39
- // ---------------------------------------------------------------------------
40
-
41
- /**
42
- * High-confidence, prefix-based secret patterns.
43
- *
44
- * Each entry matches a known API key / token format by its distinctive
45
- * prefix. Patterns that require surrounding context (entropy analysis,
46
- * keyword proximity, URL structure) do NOT belong here — they stay in
47
- * `secret-scanner.ts` as scanner-only patterns.
48
- *
49
- * **When adding a new third-party integration, add its API key pattern
50
- * here.** If the service uses only opaque OAuth access tokens (no fixed
51
- * prefix), no pattern is needed.
52
- */
53
- /** Detection label for PEM private-key matches. */
54
- export const PRIVATE_KEY_LABEL = "Private Key";
55
-
56
- // PEM private-key header/footer type variants, shared by the Private Key
57
- // pattern and the complete-block check.
58
- const PEM_KEY_TYPE = String.raw`(?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY(?:\s+BLOCK)?`;
59
- const PEM_BEGIN = String.raw`-----BEGIN ${PEM_KEY_TYPE}-----`;
60
- const PEM_END = String.raw`-----END ${PEM_KEY_TYPE}-----`;
61
-
62
- const PEM_COMPLETE_BLOCK = new RegExp(`${PEM_END}$`);
63
-
64
- /**
65
- * Whole-block PEM body atom: one tempered body character. `(?!-----BEGIN)`
66
- * keeps the body from crossing into an adjacent block, so two keys never
67
- * merge into one span.
68
- */
69
- const PEM_BLOCK_TAIL = String.raw`(?:(?!-----BEGIN)[\s\S])`;
70
-
71
- /**
72
- * Upper bound on the PEM body scan distance for the redaction matcher. An
73
- * RSA-8192 PEM is ~6.4 KB, so 16 KiB clears real keys with headroom while
74
- * capping the per-header rescan distance. The bound is what keeps the
75
- * redaction matcher O(n) on large serialized strings full of footerless
76
- * `-----BEGIN … PRIVATE KEY-----` headers: each header rescans at most this
77
- * far looking for a footer instead of to EOF, which is what the unbounded
78
- * whole-block matcher does (its O(n²) worst case).
79
- */
80
- export const PEM_REDACTION_MAX_BODY_LENGTH = 16384;
81
-
82
- /**
83
- * Whole-block private-key matcher for detect-AND-REDACT consumers (log
84
- * serializers, the tool-output scanner, staged-export rewrite).
85
- *
86
- * These consumers replace the matched span in place, so the match must cover
87
- * the ENTIRE key — header → body → footer — or the base64 body and `-----END-----`
88
- * footer survive redaction. This captures the whole block, but with a
89
- * length-BOUNDED body quantifier ({@link PEM_REDACTION_MAX_BODY_LENGTH}) so a
90
- * stream of footerless headers cannot drive the O(n²) worst case the unbounded
91
- * whole-block matcher incurs. The footer group is optional, so a truncated /
92
- * footerless key still matches header-only — preserving the detection-fires
93
- * guarantee ingress blocking relies on. Tempered + lazy + bounded ⇒ no
94
- * catastrophic backtracking.
95
- *
96
- * `detectSecretsInText` (chat) keeps the unbounded whole-block
97
- * {@link PREFIX_PATTERNS} entry, which the store/rewrite path relies on to
98
- * capture arbitrarily large keys in full.
99
- */
100
- export const PRIVATE_KEY_REDACTION_REGEX = new RegExp(
101
- `${PEM_BEGIN}(?:${PEM_BLOCK_TAIL}{0,${PEM_REDACTION_MAX_BODY_LENGTH}}?${PEM_END})?`,
102
- );
103
-
104
- /**
105
- * True when a `Private Key` match is a complete PEM block — it ends at an
106
- * `-----END … PRIVATE KEY-----` footer (the pattern guarantees it starts at
107
- * the BEGIN header). A header-only match means the footer is absent from
108
- * the scanned text (truncated or partial paste), so the match does NOT
109
- * cover the key body: storing or replacing such a value would leave key
110
- * material behind.
111
- */
112
- export function isCompletePrivateKeyBlock(value: string): boolean {
113
- return PEM_COMPLETE_BLOCK.test(value);
114
- }
115
-
116
- export const PREFIX_PATTERNS: SecretPrefixPattern[] = [
117
- // -- Private keys --
118
- // First in the list on purpose: `detectSecretsInText` resolves overlapping
119
- // spans by list order, and a PEM body is base64 — it can embed runs that
120
- // look like other tokens (e.g. `AKIA` + 16 chars). The whole-block match
121
- // must win those overlaps or the block would be fragmented.
122
- {
123
- label: PRIVATE_KEY_LABEL,
124
- // Matches the entire PEM block (header through footer) when the END
125
- // footer is present, so consumers that store or replace the matched
126
- // value handle the whole key. The block tail is optional: a header with
127
- // no footer (truncated or streamed partial paste) still matches alone,
128
- // which ingress blocking relies on. The body is tempered — it never
129
- // crosses another BEGIN header — so adjacent blocks match separately.
130
- regex: new RegExp(`${PEM_BEGIN}(?:${PEM_BLOCK_TAIL}*?${PEM_END})?`),
131
- },
132
-
133
- // -- AWS --
134
- { label: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/ },
135
-
136
- // -- GitHub --
137
- { label: "GitHub Token", regex: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
138
- { label: "GitHub Fine-Grained PAT", regex: /github_pat_[A-Za-z0-9_]{22,}/ },
139
-
140
- // -- GitLab --
141
- { label: "GitLab Token", regex: /glpat-[A-Za-z0-9\-_]{20,}/ },
142
-
143
- // -- Stripe --
144
- { label: "Stripe Secret Key", regex: /sk_live_[A-Za-z0-9]{24,}/ },
145
- { label: "Stripe Restricted Key", regex: /rk_live_[A-Za-z0-9]{24,}/ },
146
-
147
- // -- Slack --
148
- {
149
- label: "Slack Bot Token",
150
- regex: /xoxb-[0-9]{10,}-[0-9]{10,}-[A-Za-z0-9]{24,}/,
151
- },
152
- {
153
- label: "Slack User Token",
154
- regex: /xoxp-[0-9]{10,}-[0-9]{10,}-[0-9]{10,}-[a-f0-9]{32}/,
155
- },
156
- {
157
- label: "Slack App Token",
158
- regex: /xapp-[0-9]+-[A-Za-z0-9]+-[0-9]+-[A-Za-z0-9]+/,
159
- },
160
-
161
- // -- Telegram --
162
- {
163
- label: "Telegram Bot Token",
164
- // Format: <bot_id>:<secret> where bot_id is 8–10 digits, secret is 35 chars
165
- regex: /[0-9]{8,10}:[A-Za-z0-9_-]{35}/,
166
- },
167
-
168
- // -- Anthropic --
169
- { label: "Anthropic API Key", regex: /sk-ant-[A-Za-z0-9\-_]{80,}/ },
170
-
171
- // -- OpenAI --
172
- {
173
- label: "OpenAI API Key",
174
- regex: /sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}/,
175
- },
176
- { label: "OpenAI Project Key", regex: /sk-proj-[A-Za-z0-9\-_]{40,}/ },
177
-
178
- // -- Google --
179
- { label: "Google API Key", regex: /AIza[A-Za-z0-9\-_]{35}/ },
180
- {
181
- label: "Google OAuth Client Secret",
182
- regex: /GOCSPX-[A-Za-z0-9\-_]{28}/,
183
- },
184
-
185
- // -- Twilio --
186
- { label: "Twilio API Key", regex: /SK[0-9a-f]{32}/ },
187
-
188
- // -- SendGrid --
189
- {
190
- label: "SendGrid API Key",
191
- regex: /SG\.[A-Za-z0-9\-_]{22}\.[A-Za-z0-9\-_]{43}/,
192
- },
193
-
194
- // -- Mailgun --
195
- { label: "Mailgun API Key", regex: /key-[A-Za-z0-9]{32}/ },
196
-
197
- // -- npm --
198
- { label: "npm Token", regex: /npm_[A-Za-z0-9]{36}/ },
199
-
200
- // -- PyPI --
201
- { label: "PyPI API Token", regex: /pypi-[A-Za-z0-9\-_]{50,}/ },
202
-
203
- // -- Linear --
204
- { label: "Linear API Key", regex: /lin_api_[A-Za-z0-9]{32,}/ },
205
-
206
- // -- Notion --
207
- { label: "Notion Integration Token", regex: /ntn_[A-Za-z0-9]{40,}/ },
208
-
209
- // -- OpenRouter --
210
- { label: "OpenRouter API Key", regex: /sk-or-v1-[A-Za-z0-9\-_]{40,}/ },
211
-
212
- // -- Vercel AI Gateway --
213
- { label: "Vercel AI Gateway API Key", regex: /vck_[A-Za-z0-9\-_]{24,}/ },
214
-
215
- // -- Fireworks --
216
- { label: "Fireworks API Key", regex: /fw_[A-Za-z0-9]{32,}/ },
217
-
218
- // -- Perplexity --
219
- { label: "Perplexity API Key", regex: /pplx-[A-Za-z0-9]{40,}/ },
220
-
221
- // -- Tavily --
222
- { label: "Tavily API Key", regex: /tvly-[A-Za-z0-9]{20,}/ },
223
-
224
- // -- Firecrawl --
225
- { label: "Firecrawl API Key", regex: /fc-[A-Za-z0-9]{20,}/ },
226
- ];
227
-
228
- /**
229
- * {@link PREFIX_PATTERNS} variant for detect-AND-redact consumers (log
230
- * serializers, the tool-output scanner, staged-export rewrite). Identical to
231
- * `PREFIX_PATTERNS` except the private-key entry uses the length-BOUNDED
232
- * whole-block matcher ({@link PRIVATE_KEY_REDACTION_REGEX}): those consumers
233
- * replace the matched span in place, so the match must cover the whole key
234
- * (header → body → footer) or the body leaks — but they route to the bounded
235
- * matcher to avoid the unbounded whole-block matcher's O(n²) worst case on
236
- * large serialized strings. `detectSecretsInText` (chat) uses `PREFIX_PATTERNS`
237
- * for unbounded full-block capture.
238
- */
239
- export const REDACTION_PREFIX_PATTERNS: SecretPrefixPattern[] =
240
- PREFIX_PATTERNS.map((p) =>
241
- p.label === PRIVATE_KEY_LABEL
242
- ? { label: PRIVATE_KEY_LABEL, regex: PRIVATE_KEY_REDACTION_REGEX }
243
- : p,
244
- );
245
-
246
- // ---------------------------------------------------------------------------
247
- // Token-shape heuristic (whole-message only)
248
- // ---------------------------------------------------------------------------
249
-
250
- /**
251
- * A single token-shaped value: an alphanumeric head, a separator-delimited
252
- * secret keyword infix, and a >=16-char tail (e.g. `virlo_tkn_JF…`). The
253
- * capture group isolates the tail for placeholder checks. Applied only when
254
- * the entire trimmed message is one such token — the whole-message and
255
- * keyword-infix requirements keep false positives near zero without any
256
- * entropy scoring.
257
- */
258
- export const TOKEN_SHAPE =
259
- /^[A-Za-z0-9][A-Za-z0-9_-]*[_-](?:tkn|token|key|secret|api|pat|sk|auth)[_-]([A-Za-z0-9_-]{16,})$/i;
260
-
261
- /**
262
- * Length bounds for the whole-message token-shape heuristic, shared by the
263
- * daemon ingress check and the web composer scanner so both accept and reject
264
- * the same whole-message tokens. Below the min a "token" is too short to be a
265
- * plausible secret; above the max it is almost certainly a paste of something
266
- * else (a base64 blob, a minified line) rather than a single credential.
267
- */
268
- export const TOKEN_SHAPE_MIN_LENGTH = 20;
269
- export const TOKEN_SHAPE_MAX_LENGTH = 512;
270
-
271
- /** Detection label for a whole-message token-shape match, shared by the daemon
272
- * ingress check and the web composer scanner so both report the same
273
- * user-facing type. */
274
- export const TOKEN_SHAPE_LABEL = "Token-shaped value";
275
-
276
- // ---------------------------------------------------------------------------
277
- // Placeholder suppression (ingress semantics: user-typed input, near-zero
278
- // false positives)
279
- // ---------------------------------------------------------------------------
280
-
281
- export const KNOWN_PLACEHOLDERS = new Set([
282
- "your-api-key-here",
283
- "your_api_key_here",
284
- "insert-your-key-here",
285
- "insert_your_key_here",
286
- "replace-with-your-key",
287
- "replace_with_your_key",
288
- "xxx",
289
- "xxxxxxxx",
290
- "test",
291
- "example",
292
- "sample",
293
- "demo",
294
- "placeholder",
295
- "changeme",
296
- "CHANGEME",
297
- "TODO",
298
- "FIXME",
299
- "your-token-here",
300
- "your_token_here",
301
- "my-api-key",
302
- "my_api_key",
303
- ]);
304
-
305
- export const PLACEHOLDER_PREFIXES = [
306
- "sk-test-",
307
- "sk_test_",
308
- "fake_",
309
- "fake-",
310
- "dummy_",
311
- "dummy-",
312
- "test_",
313
- "test-",
314
- "example_",
315
- "example-",
316
- "sample_",
317
- "sample-",
318
- "mock_",
319
- "mock-",
320
- ];
321
-
322
- /**
323
- * Check if the text immediately before a matched value indicates a
324
- * placeholder context (e.g. "fake_", "test_"): true when the pre-context
325
- * window ends with any `PLACEHOLDER_PREFIXES` entry, case-insensitive.
326
- */
327
- export function isPlaceholderContext(preContext: string): boolean {
328
- const lower = preContext.toLowerCase();
329
- for (const prefix of PLACEHOLDER_PREFIXES) {
330
- if (lower.endsWith(prefix)) {
331
- return true;
332
- }
333
- }
334
- return false;
335
- }
336
-
337
- /**
338
- * Check if a matched value is a placeholder/test value that should not be
339
- * reported: exact `KNOWN_PLACEHOLDERS` membership, a `PLACEHOLDER_PREFIXES`
340
- * prefix (both case-insensitive), or a variable portion that is one repeated
341
- * character (e.g. `AKIA` + `X` x 16).
342
- */
343
- export function isPlaceholderValue(value: string): boolean {
344
- const lower = value.toLowerCase();
345
- if (KNOWN_PLACEHOLDERS.has(lower)) {
346
- return true;
347
- }
348
- for (const prefix of PLACEHOLDER_PREFIXES) {
349
- if (lower.startsWith(prefix)) {
350
- return true;
351
- }
352
- }
353
-
354
- // Repeated characters in the variable portion (e.g. "AKIA" + "X" x 16)
355
- // Strip known prefixes to isolate the variable part
356
- const variablePart = value
357
- .replace(
358
- /^(?:AKIA|gh[pousr]_|github_pat_|glpat-|sk_live_|rk_live_|xoxb-|xoxp-|xapp-|sk-ant-|sk-proj-|sk-or-v1-|AIza|GOCSPX-|SK|SG\.|npm_|pypi-|key-|lin_api_|ntn_|fw_|pplx-|-----BEGIN [A-Z ]*PRIVATE KEY-----)/,
359
- "",
360
- )
361
- .replace(/[^A-Za-z0-9]/g, "");
362
- if (variablePart.length >= 8) {
363
- const firstChar = variablePart[0];
364
- if (variablePart.split("").every((c) => c === firstChar)) {
365
- return true;
366
- }
367
- }
368
-
369
- return false;
370
- }
371
-
372
- // ---------------------------------------------------------------------------
373
- // Draft scanner
374
- // ---------------------------------------------------------------------------
375
-
376
- export interface DetectedSecret {
377
- /** Human-readable secret type label, e.g. "Anthropic API Key". */
378
- label: string;
379
- /** The matched secret text. */
380
- value: string;
381
- /** Start offset of the match in the scanned text (inclusive). */
382
- start: number;
383
- /** End offset of the match in the scanned text (exclusive). */
384
- end: number;
385
- /** True when the entire trimmed text is one token-shaped value. */
386
- wholeMessage: boolean;
387
- }
388
-
389
- /**
390
- * Global clones of {@link PREFIX_PATTERNS}, compiled once so the draft
391
- * scanner never mutates the shared pattern objects' `lastIndex` and never
392
- * recompiles on the hot path (the composer scans on every draft change).
393
- */
394
- const GLOBAL_PREFIX_PATTERNS: SecretPrefixPattern[] = PREFIX_PATTERNS.map(
395
- ({ label, regex }) => ({
396
- label,
397
- regex: new RegExp(
398
- regex.source,
399
- regex.flags.includes("g") ? regex.flags : regex.flags + "g",
400
- ),
401
- }),
402
- );
403
-
404
- /**
405
- * Scan a draft message for high-confidence secrets.
406
- *
407
- * Runs every `PREFIX_PATTERNS` entry over the text, suppressing placeholder
408
- * values and matches immediately preceded by a placeholder context (e.g.
409
- * `fake_` before a real-looking token) — the same suppression the daemon
410
- * applies at ingress, so client and server accept the same placeholders.
411
- * When no prefix pattern matches, the trimmed whole message is
412
- * tested against {@link TOKEN_SHAPE} — the same whole-message token-shape
413
- * heuristic the daemon blocks at ingress. Overlapping spans are deduped
414
- * (first pattern in list order wins); results are sorted by `start`.
415
- */
416
- export function detectSecretsInText(text: string): DetectedSecret[] {
417
- const matches: DetectedSecret[] = [];
418
-
419
- for (const { label, regex } of GLOBAL_PREFIX_PATTERNS) {
420
- // Reset lastIndex — the compiled global regexes are shared across calls.
421
- regex.lastIndex = 0;
422
- let match: RegExpExecArray | null;
423
-
424
- while ((match = regex.exec(text)) !== null) {
425
- const value = match[0];
426
-
427
- // Skip placeholders and test values (check both the match and
428
- // a small window before it for placeholder prefixes like "fake_")
429
- const contextStart = Math.max(0, match.index - 10);
430
- const preContext = text.slice(contextStart, match.index);
431
- if (isPlaceholderValue(value) || isPlaceholderContext(preContext)) {
432
- continue;
433
- }
434
- const start = match.index;
435
- const end = start + value.length;
436
- // Overlap dedupe: the first pattern in list order wins.
437
- if (matches.some((m) => start < m.end && end > m.start)) {
438
- continue;
439
- }
440
- matches.push({ label, value, start, end, wholeMessage: false });
441
- }
442
- }
443
-
444
- if (matches.length === 0) {
445
- const trimmed = text.trim();
446
- const shapeMatch =
447
- trimmed.length >= TOKEN_SHAPE_MIN_LENGTH &&
448
- trimmed.length <= TOKEN_SHAPE_MAX_LENGTH
449
- ? TOKEN_SHAPE.exec(trimmed)
450
- : null;
451
- if (
452
- shapeMatch !== null &&
453
- !isPlaceholderValue(trimmed) &&
454
- !isPlaceholderValue(shapeMatch[1]!)
455
- ) {
456
- const start = text.length - text.trimStart().length;
457
- matches.push({
458
- label: TOKEN_SHAPE_LABEL,
459
- value: trimmed,
460
- start,
461
- end: start + trimmed.length,
462
- wholeMessage: true,
463
- });
464
- }
465
- }
466
-
467
- return matches.sort((a, b) => a.start - b.start);
468
- }