@vellumai/credential-executor 0.10.12-dev.202607250047.75b9ec6 → 0.10.12-staging.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/node_modules/@vellumai/service-contracts/package.json +0 -1
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +1 -69
- package/package.json +1 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +0 -449
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +0 -468
|
@@ -109,77 +109,12 @@ export type RemoteWebPairingTokenResponse =
|
|
|
109
109
|
// only) so both Node and browser callers share one implementation.
|
|
110
110
|
|
|
111
111
|
/** Why a public base URL can't be advertised in a pairing challenge. */
|
|
112
|
-
export type PublicBaseUrlRejection =
|
|
113
|
-
| "unparseable"
|
|
114
|
-
| "loopback"
|
|
115
|
-
| "non-https"
|
|
116
|
-
| "service-website";
|
|
112
|
+
export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
|
|
117
113
|
|
|
118
114
|
export type PublicBaseUrlResult =
|
|
119
115
|
| { ok: true; url: string }
|
|
120
116
|
| { ok: false; reason: PublicBaseUrlRejection };
|
|
121
117
|
|
|
122
|
-
/**
|
|
123
|
-
* Hosts that are a tunnel/ingress vendor's own website, not a user's assistant
|
|
124
|
-
* endpoint. These are the tunnel vendors our docs mention, and their sites are
|
|
125
|
-
* exactly where a lost user grabs a URL from — e.g. a Tailscale admin invite
|
|
126
|
-
* link (`login.tailscale.com/admin/invite/…`), which is https and non-loopback
|
|
127
|
-
* and so clears every other check. Pairing refuses them with a targeted message
|
|
128
|
-
* rather than minting a challenge the scanning device can never reach.
|
|
129
|
-
*/
|
|
130
|
-
export const TUNNEL_PROVIDER_WEBSITE_HOSTS = [
|
|
131
|
-
"login.tailscale.com",
|
|
132
|
-
"tailscale.com",
|
|
133
|
-
"www.tailscale.com",
|
|
134
|
-
"ngrok.com",
|
|
135
|
-
"dashboard.ngrok.com",
|
|
136
|
-
"dash.cloudflare.com",
|
|
137
|
-
"cloudflare.com",
|
|
138
|
-
"www.cloudflare.com",
|
|
139
|
-
] as const;
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* A URL whose exact host is a tunnel/ingress vendor's own website (see
|
|
143
|
-
* {@link TUNNEL_PROVIDER_WEBSITE_HOSTS}). Exact-host only: a user's real
|
|
144
|
-
* Tailscale endpoint (`*.ts.net`) or Cloudflare-fronted domain is never a
|
|
145
|
-
* listed host, so a legitimate address is never mistaken for a vendor site.
|
|
146
|
-
*/
|
|
147
|
-
export function isTunnelProviderWebsiteUrl(url: string): boolean {
|
|
148
|
-
let hostname: string;
|
|
149
|
-
try {
|
|
150
|
-
// WHATWG URL lowercases the hostname during parsing.
|
|
151
|
-
hostname = new URL(url).hostname;
|
|
152
|
-
} catch {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
return (TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(
|
|
156
|
-
hostname,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The display name of the tunnel/ingress vendor a service-website URL points
|
|
162
|
-
* at (`"Tailscale"` / `"ngrok"` / `"Cloudflare"`), or null when the host is not
|
|
163
|
-
* a known vendor site. Drives the "This is <Name>'s website" pairing guidance.
|
|
164
|
-
*/
|
|
165
|
-
export function tunnelProviderWebsiteName(url: string): string | null {
|
|
166
|
-
let hostname: string;
|
|
167
|
-
try {
|
|
168
|
-
hostname = new URL(url).hostname;
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
if (
|
|
173
|
-
!(TUNNEL_PROVIDER_WEBSITE_HOSTS as readonly string[]).includes(hostname)
|
|
174
|
-
) {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
if (hostname.includes("tailscale")) return "Tailscale";
|
|
178
|
-
if (hostname.includes("ngrok")) return "ngrok";
|
|
179
|
-
if (hostname.includes("cloudflare")) return "Cloudflare";
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
118
|
/**
|
|
184
119
|
* A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
|
|
185
120
|
* encodes a loopback address is unreachable from the scanning device.
|
|
@@ -232,9 +167,6 @@ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
|
|
|
232
167
|
if (isLoopbackPublicUrl(normalized)) {
|
|
233
168
|
return { ok: false, reason: "loopback" };
|
|
234
169
|
}
|
|
235
|
-
if (isTunnelProviderWebsiteUrl(normalized)) {
|
|
236
|
-
return { ok: false, reason: "service-website" };
|
|
237
|
-
}
|
|
238
170
|
if (new URL(normalized).protocol !== "https:") {
|
|
239
171
|
return { ok: false, reason: "non-https" };
|
|
240
172
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|