@vellumai/cli 0.10.12-staging.3 → 0.11.0-dev.202607290043.3b26b6b
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 +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +9 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +449 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +468 -0
- package/package.json +1 -1
- package/src/__tests__/drain.test.ts +337 -0
- package/src/__tests__/flags.test.ts +11 -11
- package/src/__tests__/local-hatch-lock.test.ts +163 -0
- package/src/__tests__/sleep.test.ts +171 -2
- package/src/__tests__/wake.test.ts +38 -1
- package/src/commands/flags.ts +3 -3
- package/src/commands/pair.ts +2 -2
- package/src/commands/sleep.ts +151 -2
- package/src/commands/wake.ts +47 -5
- package/src/lib/drain.ts +346 -0
- package/src/lib/flag-args.test.ts +3 -3
- package/src/lib/flag-args.ts +2 -2
- package/src/lib/hatch-local.ts +98 -86
- package/src/lib/local-hatch-lock.ts +322 -0
- package/src/lib/local.ts +20 -2
- package/src/lib/provider-secrets.ts +4 -0
- package/src/shared/provider-env-vars.ts +2 -0
|
@@ -18,8 +18,16 @@ describe("isChannelId", () => {
|
|
|
18
18
|
expect(isChannelId("vellum")).toBe(true);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
test("includes discord", () => {
|
|
22
|
+
// Discord is canonical vocabulary ahead of its ingress implementation:
|
|
23
|
+
// the gateway's inbound list and the admission-policy seed both derive
|
|
24
|
+
// from CHANNEL_IDS, so it must be here for a Discord message to be
|
|
25
|
+
// routable and to carry an admission floor at all.
|
|
26
|
+
expect(isChannelId("discord")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
21
29
|
test("rejects unknown strings and non-string values", () => {
|
|
22
|
-
expect(isChannelId("
|
|
30
|
+
expect(isChannelId("mastodon")).toBe(false);
|
|
23
31
|
expect(isChannelId("")).toBe(false);
|
|
24
32
|
expect(isChannelId(undefined)).toBe(false);
|
|
25
33
|
expect(isChannelId(null)).toBe(false);
|
|
@@ -0,0 +1,449 @@
|
|
|
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
|
+
});
|