@copilotkit/learning 0.0.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.cjs +1134 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +173 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +173 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1126 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
- package/scripts/check-size.mjs +33 -0
- package/src/__tests__/clicks.test.ts +202 -0
- package/src/__tests__/collector.test.ts +307 -0
- package/src/__tests__/http-sink.test.ts +72 -0
- package/src/__tests__/inputs.test.ts +77 -0
- package/src/__tests__/navigation.test.ts +117 -0
- package/src/__tests__/network.test.ts +609 -0
- package/src/__tests__/redact.test.ts +732 -0
- package/src/__tests__/routes.test.ts +26 -0
- package/src/__tests__/trajectory-collector.test.ts +367 -0
- package/src/__tests__/vitest-environment.mjs +23 -0
- package/src/clicks.ts +193 -0
- package/src/collector.ts +199 -0
- package/src/http-sink.ts +38 -0
- package/src/index.ts +30 -0
- package/src/inputs.ts +42 -0
- package/src/navigation.ts +123 -0
- package/src/network-body.ts +237 -0
- package/src/network.ts +394 -0
- package/src/redact.ts +257 -0
- package/src/routes.ts +14 -0
- package/src/trajectory-collector.ts +193 -0
- package/src/trajectory-types.ts +50 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +16 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.mjs +9 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { installClickCapture } from "../clicks";
|
|
3
|
+
import { installInputCapture } from "../inputs";
|
|
4
|
+
import { installNavigationCapture } from "../navigation";
|
|
5
|
+
import { installNetworkCapture } from "../network";
|
|
6
|
+
import type { BodySnapshot } from "../network-body";
|
|
7
|
+
import { createRedactor, isCredentialKey } from "../redact";
|
|
8
|
+
|
|
9
|
+
interface NetworkEvent {
|
|
10
|
+
url: string;
|
|
11
|
+
outcome: string;
|
|
12
|
+
request: { headers: Record<string, string>; body: BodySnapshot };
|
|
13
|
+
response: {
|
|
14
|
+
url?: string;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
body: BodySnapshot;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PAGE_ORIGIN = "http://localhost:3000";
|
|
21
|
+
const realFetch = globalThis.fetch;
|
|
22
|
+
const uninstalls: (() => void)[] = [];
|
|
23
|
+
const isTrusted = () => true;
|
|
24
|
+
// jsdom implements neither navigation nor form submission.
|
|
25
|
+
const prevent = (event: Event) => event.preventDefault();
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
uninstalls.splice(0).forEach((uninstall) => uninstall());
|
|
29
|
+
vi.unstubAllGlobals();
|
|
30
|
+
globalThis.fetch = realFetch;
|
|
31
|
+
document.body.innerHTML = "";
|
|
32
|
+
history.replaceState(null, "", "/");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function setup(
|
|
36
|
+
fakeFetch: typeof fetch = async () => new Response("ok"),
|
|
37
|
+
{ inputs = true } = {},
|
|
38
|
+
) {
|
|
39
|
+
globalThis.fetch = fakeFetch;
|
|
40
|
+
const redact = createRedactor();
|
|
41
|
+
uninstalls.push(redact.watch());
|
|
42
|
+
const network: NetworkEvent[] = [];
|
|
43
|
+
const other: { name: string; value: Record<string, unknown> }[] = [];
|
|
44
|
+
const emit = (name: string, value: Record<string, unknown>) => {
|
|
45
|
+
if (name === "network") network.push(value as unknown as NetworkEvent);
|
|
46
|
+
else other.push({ name, value });
|
|
47
|
+
};
|
|
48
|
+
uninstalls.push(
|
|
49
|
+
installNetworkCapture({ emit, ignoreUrls: [], redact }),
|
|
50
|
+
inputs ? installInputCapture({ emit, isTrusted, redact }) : () => {},
|
|
51
|
+
installClickCapture({ emit, getRoute: () => "/", isTrusted, redact }),
|
|
52
|
+
installNavigationCapture({ emit, redact }),
|
|
53
|
+
);
|
|
54
|
+
return { network, other };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function captured(events: NetworkEvent[], count = 1) {
|
|
58
|
+
await vi.waitFor(() => expect(events).toHaveLength(count));
|
|
59
|
+
return events[count - 1]!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function type(input: HTMLInputElement, value: string) {
|
|
63
|
+
input.value = value;
|
|
64
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function streamed(text: string, contentType = "application/json") {
|
|
68
|
+
return async () =>
|
|
69
|
+
new Response(text, { headers: { "content-type": contentType } });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class FakeXhr extends EventTarget {
|
|
73
|
+
status = 200;
|
|
74
|
+
responseType: XMLHttpRequestResponseType = "text";
|
|
75
|
+
responseText = '{"refresh_token":"xhr-refresh-secret","expires":60}';
|
|
76
|
+
responseURL = "";
|
|
77
|
+
open(_method: string, _url: string | URL) {}
|
|
78
|
+
setRequestHeader() {}
|
|
79
|
+
getAllResponseHeaders() {
|
|
80
|
+
return "Content-Type: application/json\r\n";
|
|
81
|
+
}
|
|
82
|
+
send(_body?: unknown) {}
|
|
83
|
+
finish() {
|
|
84
|
+
this.dispatchEvent(new Event("loadend"));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("credential key matcher", () => {
|
|
89
|
+
it.each([
|
|
90
|
+
"password",
|
|
91
|
+
"newPassword",
|
|
92
|
+
"user_passwd",
|
|
93
|
+
"Pass-Phrase",
|
|
94
|
+
"passcode",
|
|
95
|
+
"client_secret",
|
|
96
|
+
"api_key",
|
|
97
|
+
"X-API-Key",
|
|
98
|
+
"access_token",
|
|
99
|
+
"refreshToken",
|
|
100
|
+
"id.token",
|
|
101
|
+
"auth token",
|
|
102
|
+
"session_token",
|
|
103
|
+
"private-key",
|
|
104
|
+
"credentials",
|
|
105
|
+
"pwd",
|
|
106
|
+
"PASS",
|
|
107
|
+
"token",
|
|
108
|
+
"otp",
|
|
109
|
+
])("matches %s", (key) => expect(isCredentialKey(key)).toBe(true));
|
|
110
|
+
|
|
111
|
+
it.each([
|
|
112
|
+
"name",
|
|
113
|
+
"email",
|
|
114
|
+
"id",
|
|
115
|
+
"tokenCount",
|
|
116
|
+
"tokens",
|
|
117
|
+
"passenger",
|
|
118
|
+
"otpEnabled",
|
|
119
|
+
"",
|
|
120
|
+
])("does not match %s", (key) => expect(isCredentialKey(key)).toBe(false));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("body redaction", () => {
|
|
124
|
+
it("redacts a JSON login body sent with fetch and keeps other fields", async () => {
|
|
125
|
+
const { network } = setup();
|
|
126
|
+
await fetch("/login", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
email: "alice@example.com",
|
|
131
|
+
name: "Alice",
|
|
132
|
+
password: "hunter2-secret",
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
const event = await captured(network);
|
|
136
|
+
expect(event.request.body).toMatchObject({
|
|
137
|
+
status: "complete",
|
|
138
|
+
text: '{"email":"alice@example.com","name":"Alice","password":"[redacted]"}',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("redacts XHR request and response bodies", async () => {
|
|
143
|
+
vi.stubGlobal("XMLHttpRequest", FakeXhr);
|
|
144
|
+
const { network } = setup();
|
|
145
|
+
const xhr = new FakeXhr();
|
|
146
|
+
xhr.open("POST", "/login");
|
|
147
|
+
xhr.send(JSON.stringify({ user: "alice", passwd: "xhr-password-secret" }));
|
|
148
|
+
xhr.finish();
|
|
149
|
+
const event = await captured(network);
|
|
150
|
+
expect(event.request.body.text).toBe(
|
|
151
|
+
'{"user":"alice","passwd":"[redacted]"}',
|
|
152
|
+
);
|
|
153
|
+
expect(event.response.body.text).toBe(
|
|
154
|
+
'{"refresh_token":"[redacted]","expires":60}',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("redacts nested objects and arrays at any depth", async () => {
|
|
159
|
+
const { network } = setup();
|
|
160
|
+
await fetch("/settings", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
id: 7,
|
|
164
|
+
accounts: [
|
|
165
|
+
{ name: "a", apiKey: "nested-api-key-secret", tokenCount: 12 },
|
|
166
|
+
{ name: "b", auth: { "Client-Secret": "deep-client-secret" } },
|
|
167
|
+
],
|
|
168
|
+
credentials: { user: "alice", pin: "1234" },
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
const event = await captured(network);
|
|
172
|
+
expect(JSON.parse(event.request.body.text!)).toEqual({
|
|
173
|
+
id: 7,
|
|
174
|
+
accounts: [
|
|
175
|
+
{ name: "a", apiKey: "[redacted]", tokenCount: 12 },
|
|
176
|
+
{ name: "b", auth: { "Client-Secret": "[redacted]" } },
|
|
177
|
+
],
|
|
178
|
+
credentials: "[redacted]",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("keeps JSON formatting when nothing is redacted", async () => {
|
|
183
|
+
const { network } = setup();
|
|
184
|
+
const body = '{ "email": "alice@example.com", "tokenCount": 3 }';
|
|
185
|
+
await fetch("/profile", { method: "POST", body });
|
|
186
|
+
expect((await captured(network)).request.body.text).toBe(body);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("redacts urlencoded string and URLSearchParams bodies per field", async () => {
|
|
190
|
+
const { network } = setup();
|
|
191
|
+
await fetch("/login", {
|
|
192
|
+
method: "POST",
|
|
193
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
194
|
+
body: "email=alice%40example.com&password=form-secret&name=Alice",
|
|
195
|
+
});
|
|
196
|
+
await fetch("/login", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: new URLSearchParams({
|
|
199
|
+
user: "alice",
|
|
200
|
+
access_token: "params-secret",
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
await captured(network, 2);
|
|
204
|
+
expect(network[0]!.request.body.text).toBe(
|
|
205
|
+
"email=alice%40example.com&password=[redacted]&name=Alice",
|
|
206
|
+
);
|
|
207
|
+
const params = new URLSearchParams(network[1]!.request.body.text);
|
|
208
|
+
expect(params.get("user")).toBe("alice");
|
|
209
|
+
expect(params.get("access_token")).toBe("[redacted]");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("redacts FormData fields", async () => {
|
|
213
|
+
const { network } = setup();
|
|
214
|
+
const form = new FormData();
|
|
215
|
+
form.append("username", "alice");
|
|
216
|
+
form.append("password", "multipart-secret");
|
|
217
|
+
await fetch("/login", { method: "POST", body: form });
|
|
218
|
+
const text = (await captured(network)).request.body.text!;
|
|
219
|
+
expect(text).toContain("alice");
|
|
220
|
+
expect(text).toContain("[redacted]");
|
|
221
|
+
expect(text).not.toContain("multipart-secret");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("redacts tokens in a streamed response body", async () => {
|
|
225
|
+
const { network } = setup(
|
|
226
|
+
streamed(
|
|
227
|
+
'{"access_token":"oauth-access-secret","token_type":"bearer","expires_in":3600}',
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
await fetch("/oauth/token", { method: "POST" });
|
|
231
|
+
const event = await captured(network);
|
|
232
|
+
expect(event.response.body.text).toBe(
|
|
233
|
+
'{"access_token":"[redacted]","token_type":"bearer","expires_in":3600}',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("redacts a value cut off by a size limit", () => {
|
|
238
|
+
const text = createRedactor().body(
|
|
239
|
+
'{"note":"x","access_token":"cut-off-sec',
|
|
240
|
+
);
|
|
241
|
+
expect(text).toContain('"access_token":"[redacted]');
|
|
242
|
+
expect(text).not.toContain("cut-off");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("redacts truncated or invalid JSON with the pair fallback", async () => {
|
|
246
|
+
const { network } = setup(
|
|
247
|
+
streamed(
|
|
248
|
+
`{"note":"${"n".repeat(3900)}","access_token":"cut-off-${"s".repeat(300)}"}`,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
await fetch("/oauth/token", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
body: '{"email":"a@example.com","password":"invalid-json-secret",',
|
|
254
|
+
});
|
|
255
|
+
const event = await captured(network);
|
|
256
|
+
expect(event.request.body.text).toBe(
|
|
257
|
+
'{"email":"a@example.com","password":"[redacted]",',
|
|
258
|
+
);
|
|
259
|
+
expect(event.response.body.text).not.toContain("cut-off");
|
|
260
|
+
expect(event.response.body.text).toContain('"access_token":"[redacted]"');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("does not leak the prefix of a value longer than the snapshot limit", async () => {
|
|
264
|
+
const { network } = setup(streamed(`{"id_token":"${"T".repeat(5000)}"}`));
|
|
265
|
+
await fetch("/login", {
|
|
266
|
+
method: "POST",
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
email: "a@example.com",
|
|
269
|
+
password: "P".repeat(5000),
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
const event = await captured(network);
|
|
273
|
+
expect(event.request.body).toMatchObject({
|
|
274
|
+
status: "complete",
|
|
275
|
+
text: '{"email":"a@example.com","password":"[redacted]"}',
|
|
276
|
+
});
|
|
277
|
+
expect(event.response.body.text).not.toMatch(/TT/);
|
|
278
|
+
expect(event.response.body.text).toContain("[redacted]");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("remembered password values", () => {
|
|
283
|
+
it("redacts a typed password under a custom key in bodies and URLs", async () => {
|
|
284
|
+
document.body.innerHTML = '<input id="secret-field" type="password">';
|
|
285
|
+
const { network } = setup();
|
|
286
|
+
type(document.querySelector("input")!, "c0rrect horse!");
|
|
287
|
+
await fetch("/login", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
body: JSON.stringify({ pw: "c0rrect horse!", email: "a@example.com" }),
|
|
290
|
+
});
|
|
291
|
+
await fetch("/login?pw=c0rrect%20horse!&next=%2Fhome");
|
|
292
|
+
await fetch("/login", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: new URLSearchParams({ pw: "c0rrect horse!" }),
|
|
295
|
+
});
|
|
296
|
+
await captured(network, 3);
|
|
297
|
+
expect(network[0]!.request.body.text).toBe(
|
|
298
|
+
'{"pw":"[redacted]","email":"a@example.com"}',
|
|
299
|
+
);
|
|
300
|
+
expect(network[1]!.url).toBe(
|
|
301
|
+
`${PAGE_ORIGIN}/login?pw=[redacted]&next=%2Fhome`,
|
|
302
|
+
);
|
|
303
|
+
expect(network[2]!.request.body.text).toBe("pw=[redacted]");
|
|
304
|
+
expect(JSON.stringify(network)).not.toMatch(/horse/);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("URL redaction", () => {
|
|
309
|
+
it("redacts credential query values in network URLs and keeps the rest", async () => {
|
|
310
|
+
const { network } = setup();
|
|
311
|
+
await fetch(
|
|
312
|
+
"/search?q=deals&password=query-secret&access_token=tok&page=2",
|
|
313
|
+
);
|
|
314
|
+
const event = await captured(network);
|
|
315
|
+
expect(event.url).toBe(
|
|
316
|
+
`${PAGE_ORIGIN}/search?q=deals&password=[redacted]&access_token=[redacted]&page=2`,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("redacts credential query and hash values in navigation", () => {
|
|
321
|
+
const { other } = setup();
|
|
322
|
+
history.pushState(null, "", "/callback?access_token=nav-secret&state=s1");
|
|
323
|
+
history.pushState(null, "", "/done?view=full#id_token=hash-secret&x=1");
|
|
324
|
+
const navigation = other.filter(({ name }) => name === "navigation");
|
|
325
|
+
expect(navigation.map(({ value }) => value.to)).toEqual([
|
|
326
|
+
`${location.origin}/callback?access_token=[redacted]&state=s1`,
|
|
327
|
+
`${location.origin}/done?view=full#id_token=[redacted]&x=1`,
|
|
328
|
+
]);
|
|
329
|
+
expect(navigation[1]!.value.from).toBe(
|
|
330
|
+
`${location.origin}/callback?access_token=[redacted]&state=s1`,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("strips userinfo from successful and failed fetch URLs", async () => {
|
|
335
|
+
const native = vi
|
|
336
|
+
.fn<typeof fetch>()
|
|
337
|
+
.mockResolvedValueOnce(new Response("ok"))
|
|
338
|
+
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
|
339
|
+
const { network } = setup(native);
|
|
340
|
+
await fetch("https://alice:userinfo-secret@api.example.com/a");
|
|
341
|
+
await fetch("https://bob:other-secret@api.example.com/b").catch(() => {});
|
|
342
|
+
await captured(network, 2);
|
|
343
|
+
expect(network.map(({ url, outcome }) => [url, outcome])).toEqual(
|
|
344
|
+
expect.arrayContaining([
|
|
345
|
+
["https://api.example.com/a", "ok"],
|
|
346
|
+
["https://api.example.com/b", "error"],
|
|
347
|
+
]),
|
|
348
|
+
);
|
|
349
|
+
expect(JSON.stringify(network)).not.toMatch(/secret|alice|bob/);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("password-like fields", () => {
|
|
354
|
+
it("keeps redacting after a show-password toggle switches the type to text", () => {
|
|
355
|
+
document.body.innerHTML = '<input id="pw" type="password">';
|
|
356
|
+
const { other } = setup();
|
|
357
|
+
const input = document.querySelector("input")!;
|
|
358
|
+
type(input, "toggle-secret");
|
|
359
|
+
input.type = "text";
|
|
360
|
+
input.setAttribute("value", "toggle-secret-2");
|
|
361
|
+
type(input, "toggle-secret-2");
|
|
362
|
+
input.click();
|
|
363
|
+
expect(other.map(({ name }) => name)).toEqual(["input", "input", "click"]);
|
|
364
|
+
for (const { value } of other)
|
|
365
|
+
expect(value.target).toMatchObject({ value: "[redacted]" });
|
|
366
|
+
expect(other[2]!.value.target).toMatchObject({
|
|
367
|
+
attributes: { value: "[redacted]" },
|
|
368
|
+
});
|
|
369
|
+
expect(JSON.stringify(other)).not.toContain("toggle-secret");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it.each(["current-password", "new-password", "one-time-code"])(
|
|
373
|
+
"redacts a text input with autocomplete=%s",
|
|
374
|
+
(autocomplete) => {
|
|
375
|
+
document.body.innerHTML = `<input type="text" autocomplete="${autocomplete}">`;
|
|
376
|
+
const { other } = setup();
|
|
377
|
+
type(document.querySelector("input")!, "autocomplete-secret");
|
|
378
|
+
expect(other[0]!.value.target).toMatchObject({ value: "[redacted]" });
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
it("still captures ordinary text inputs raw", () => {
|
|
383
|
+
document.body.innerHTML = '<input name="email" type="email">';
|
|
384
|
+
const { other } = setup();
|
|
385
|
+
type(document.querySelector("input")!, "alice@example.com");
|
|
386
|
+
expect(other[0]!.value.target).toMatchObject({
|
|
387
|
+
value: "alice@example.com",
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("credential headers", () => {
|
|
393
|
+
it("redacts the additional credential headers and keeps their names", async () => {
|
|
394
|
+
const { network } = setup();
|
|
395
|
+
const credentials = [
|
|
396
|
+
"x-auth-token",
|
|
397
|
+
"x-access-token",
|
|
398
|
+
"x-csrf-token",
|
|
399
|
+
"api-key",
|
|
400
|
+
"x-goog-api-key",
|
|
401
|
+
"x-amz-security-token",
|
|
402
|
+
];
|
|
403
|
+
await fetch("/api", {
|
|
404
|
+
headers: {
|
|
405
|
+
...Object.fromEntries(
|
|
406
|
+
credentials.map((name) => [name, "header-secret"]),
|
|
407
|
+
),
|
|
408
|
+
"x-request-id": "req-1",
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
const event = await captured(network);
|
|
412
|
+
expect(event.request.headers).toEqual({
|
|
413
|
+
...Object.fromEntries(credentials.map((name) => [name, "[redacted]"])),
|
|
414
|
+
"x-request-id": "req-1",
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("credentials inside string values", () => {
|
|
420
|
+
it.each([
|
|
421
|
+
["JSON in a string", { payload: JSON.stringify({ password: "LEAK1" }) }],
|
|
422
|
+
["GraphQL", { query: 'mutation { login(email: "a", password: "LEAK2") }' }],
|
|
423
|
+
["a form in a string", { body: "user=a&password=LEAK3" }],
|
|
424
|
+
["a URL in a string", { next: "https://x.test/cb?access_token=LEAK4" }],
|
|
425
|
+
[
|
|
426
|
+
"tool-call arguments",
|
|
427
|
+
{
|
|
428
|
+
tool_calls: [
|
|
429
|
+
{
|
|
430
|
+
function: {
|
|
431
|
+
name: "login",
|
|
432
|
+
arguments: JSON.stringify({ user: "a", passwd: "LEAK5" }),
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
])("redacts %s and keeps the rest", (_, value) => {
|
|
439
|
+
const text = createRedactor().body(JSON.stringify(value))!;
|
|
440
|
+
expect(text).not.toMatch(/LEAK/);
|
|
441
|
+
expect(text).toContain("[redacted]");
|
|
442
|
+
expect(JSON.parse(text)).toBeTruthy();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("redacts GraphQL and YAML-style pairs in plain text", () => {
|
|
446
|
+
const redact = createRedactor();
|
|
447
|
+
expect(redact.body('mutation { login(user: "a", password:"LEAK") }')).toBe(
|
|
448
|
+
'mutation { login(user: "a", password:"[redacted]") }',
|
|
449
|
+
);
|
|
450
|
+
expect(redact.body("user: a\npassword: LEAK\n")).toBe(
|
|
451
|
+
"user: a\npassword: [redacted]\n",
|
|
452
|
+
);
|
|
453
|
+
expect(redact.body('password=ab"LEAK')).not.toMatch(/LEAK/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("keeps booleans, null, and number literals exactly", () => {
|
|
457
|
+
const body =
|
|
458
|
+
'{"id":12345678901234567890,"hasPassword":true,"password":null,"secret":"s","n":1.50}';
|
|
459
|
+
expect(createRedactor().body(body)).toBe(
|
|
460
|
+
'{"id":12345678901234567890,"hasPassword":true,"password":null,"secret":"[redacted]","n":1.50}',
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("credential key boundaries", () => {
|
|
466
|
+
it.each([
|
|
467
|
+
"isSecret",
|
|
468
|
+
"x-hasura-admin-secret",
|
|
469
|
+
"IDToken",
|
|
470
|
+
"id_token",
|
|
471
|
+
"p%61ssword",
|
|
472
|
+
"user[password]",
|
|
473
|
+
"x-api-token",
|
|
474
|
+
"x-refresh-token",
|
|
475
|
+
"apikey",
|
|
476
|
+
])("matches %s", (key) => expect(isCredentialKey(key)).toBe(true));
|
|
477
|
+
|
|
478
|
+
it.each([
|
|
479
|
+
"secretary",
|
|
480
|
+
"invalidToken",
|
|
481
|
+
"validToken",
|
|
482
|
+
"bidToken",
|
|
483
|
+
"passwordExpiresAt",
|
|
484
|
+
"accessTokenExpiresIn",
|
|
485
|
+
"credentialId",
|
|
486
|
+
"apiKeyId",
|
|
487
|
+
"passwordPolicy",
|
|
488
|
+
"token_type",
|
|
489
|
+
"max_tokens",
|
|
490
|
+
])("does not match %s", (key) => expect(isCredentialKey(key)).toBe(false));
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("redaction before the 4 KiB cut", () => {
|
|
494
|
+
it("catches a remembered value that straddles the cut in a streamed body", async () => {
|
|
495
|
+
document.body.innerHTML = '<input type="password">';
|
|
496
|
+
const { network } = setup(
|
|
497
|
+
streamed("a".repeat(4090) + "S3cretPassw0rd", "text/plain"),
|
|
498
|
+
);
|
|
499
|
+
type(document.querySelector("input")!, "S3cretPassw0rd");
|
|
500
|
+
await fetch("/x");
|
|
501
|
+
expect((await captured(network)).response.body.text).not.toMatch(/S3cr/);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("does not pull an unredacted value into the window after a redaction", async () => {
|
|
505
|
+
document.body.innerHTML = '<input type="password">';
|
|
506
|
+
const head = `token=${"x".repeat(600)}¬e=`;
|
|
507
|
+
const { network } = setup(
|
|
508
|
+
streamed(
|
|
509
|
+
`${head}${"a".repeat(4090 - head.length)}S3cretPassw0rd&z=1`,
|
|
510
|
+
"application/x-www-form-urlencoded",
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
type(document.querySelector("input")!, "S3cretPassw0rd");
|
|
514
|
+
await fetch("/x");
|
|
515
|
+
const text = (await captured(network)).response.body.text!;
|
|
516
|
+
expect(text).not.toMatch(/S3cr|xxxx/);
|
|
517
|
+
expect(text).toContain("token=[redacted]");
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("text bodies that are not valid UTF-8 or use other text types", () => {
|
|
522
|
+
it("decodes a declared text body lossily and redacts it instead of sending base64", async () => {
|
|
523
|
+
const bytes = new Uint8Array([
|
|
524
|
+
...new TextEncoder().encode('{"password":"LEAK","n":"'),
|
|
525
|
+
0xe9,
|
|
526
|
+
0x22,
|
|
527
|
+
0x7d,
|
|
528
|
+
]);
|
|
529
|
+
const { network } = setup(
|
|
530
|
+
async () =>
|
|
531
|
+
new Response(bytes, {
|
|
532
|
+
headers: { "content-type": "application/json; charset=iso-8859-1" },
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
await fetch("/x");
|
|
536
|
+
expect((await captured(network)).response.body).toMatchObject({
|
|
537
|
+
encoding: "utf-8",
|
|
538
|
+
text: '{"password":"[redacted]","n":"�"}',
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("treats application/graphql as text", async () => {
|
|
543
|
+
const { network } = setup();
|
|
544
|
+
await fetch(
|
|
545
|
+
new Request("http://localhost:3000/graphql", {
|
|
546
|
+
method: "POST",
|
|
547
|
+
body: '{"password":"LEAK"}',
|
|
548
|
+
headers: { "content-type": "application/graphql" },
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
expect((await captured(network)).request.body).toMatchObject({
|
|
552
|
+
encoding: "utf-8",
|
|
553
|
+
text: '{"password":"[redacted]"}',
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("password fields seen before any interaction", () => {
|
|
559
|
+
it("redacts a field toggled to text before it was first observed", async () => {
|
|
560
|
+
const { network, other } = setup();
|
|
561
|
+
const input = document.createElement("input");
|
|
562
|
+
input.type = "password";
|
|
563
|
+
document.body.append(input);
|
|
564
|
+
input.type = "text";
|
|
565
|
+
type(input, "Hunter2Secret");
|
|
566
|
+
await fetch("/x", {
|
|
567
|
+
method: "POST",
|
|
568
|
+
body: JSON.stringify({ pw: "Hunter2Secret" }),
|
|
569
|
+
});
|
|
570
|
+
expect(JSON.stringify(other)).not.toContain("Hunter2Secret");
|
|
571
|
+
expect(JSON.stringify(await captured(network))).not.toContain(
|
|
572
|
+
"Hunter2Secret",
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("redacts a password field that existed at start and was toggled later", async () => {
|
|
577
|
+
document.body.innerHTML = '<input type="password">';
|
|
578
|
+
const { other } = setup();
|
|
579
|
+
const input = document.querySelector("input")!;
|
|
580
|
+
input.type = "text";
|
|
581
|
+
await Promise.resolve();
|
|
582
|
+
type(input, "Hunter4Secret");
|
|
583
|
+
expect(JSON.stringify(other)).not.toContain("Hunter4Secret");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("remembers password values when input capture is off", async () => {
|
|
587
|
+
document.body.innerHTML = '<input type="password">';
|
|
588
|
+
const { network, other } = setup(undefined, { inputs: false });
|
|
589
|
+
type(document.querySelector("input")!, "Hunter3Secret");
|
|
590
|
+
await fetch("/x", {
|
|
591
|
+
method: "POST",
|
|
592
|
+
body: JSON.stringify({ pw: "Hunter3Secret" }),
|
|
593
|
+
});
|
|
594
|
+
expect(other).toEqual([]);
|
|
595
|
+
expect((await captured(network)).request.body.text).toBe(
|
|
596
|
+
'{"pw":"[redacted]"}',
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("matches remembered one-time codes only as whole numbers", async () => {
|
|
601
|
+
document.body.innerHTML = '<input autocomplete="one-time-code">';
|
|
602
|
+
const { network } = setup(
|
|
603
|
+
streamed('{"id":99123456001,"ts":1727123456789,"code":"123456"}'),
|
|
604
|
+
);
|
|
605
|
+
type(document.querySelector("input")!, "123456");
|
|
606
|
+
await fetch("/x");
|
|
607
|
+
expect((await captured(network)).response.body.text).toBe(
|
|
608
|
+
'{"id":99123456001,"ts":1727123456789,"code":"[redacted]"}',
|
|
609
|
+
);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
describe("click attributes", () => {
|
|
614
|
+
it("redacts credentials in URL-valued attributes", () => {
|
|
615
|
+
document.body.innerHTML =
|
|
616
|
+
'<a href="https://u:p@x.test/reset?token=LEAK&page=2#access_token=LEAK2">reset</a><form action="/login?password=LEAK3"><button formaction="/go?api_key=LEAK4">Go</button></form>';
|
|
617
|
+
const { other } = setup();
|
|
618
|
+
document.addEventListener("click", prevent);
|
|
619
|
+
uninstalls.push(() => document.removeEventListener("click", prevent));
|
|
620
|
+
document.querySelector("a")!.click();
|
|
621
|
+
document.querySelector("button")!.click();
|
|
622
|
+
const attributes = other
|
|
623
|
+
.filter(({ name }) => name === "click")
|
|
624
|
+
.map(({ value }) => (value.target as { attributes: object }).attributes);
|
|
625
|
+
expect(attributes[0]).toMatchObject({
|
|
626
|
+
href: "https://x.test/reset?token=[redacted]&page=2#access_token=[redacted]",
|
|
627
|
+
});
|
|
628
|
+
expect(attributes[1]).toMatchObject({
|
|
629
|
+
formaction: "/go?api_key=[redacted]",
|
|
630
|
+
});
|
|
631
|
+
expect(JSON.stringify(other)).not.toMatch(/LEAK|u:p@/);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
describe("credential header names", () => {
|
|
636
|
+
it("redacts headers whose name is a credential key", async () => {
|
|
637
|
+
const { network } = setup();
|
|
638
|
+
const names = [
|
|
639
|
+
"x-hasura-admin-secret",
|
|
640
|
+
"apikey",
|
|
641
|
+
"x-refresh-token",
|
|
642
|
+
"private-token",
|
|
643
|
+
"x-xsrf-token",
|
|
644
|
+
];
|
|
645
|
+
await fetch("/x", {
|
|
646
|
+
headers: Object.fromEntries(names.map((name) => [name, "LEAK"])),
|
|
647
|
+
});
|
|
648
|
+
expect((await captured(network)).request.headers).toEqual(
|
|
649
|
+
Object.fromEntries(names.map((name) => [name, "[redacted]"])),
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
describe("review follow-ups", () => {
|
|
655
|
+
it.each([
|
|
656
|
+
['{"url":"https://u:LEAK@x.test/"}', '{"url":"https://x.test/"}'],
|
|
657
|
+
['{"db":"postgres://u:pw@LEAK@h/db"}', '{"db":"postgres://h/db"}'],
|
|
658
|
+
["see https://u:LEAK@x.test/a", "see https://x.test/a"],
|
|
659
|
+
])("strips URL userinfo anywhere in %s", (body, expected) => {
|
|
660
|
+
expect(createRedactor().body(body)).toBe(expected);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("redacts authorization, cookie, bearer, JWT, and CSRF keys", () => {
|
|
664
|
+
const text = createRedactor().body(
|
|
665
|
+
JSON.stringify({
|
|
666
|
+
headers: { Authorization: "Bearer LEAK", Cookie: "sid=LEAK" },
|
|
667
|
+
bearerToken: "LEAK",
|
|
668
|
+
jwt: "LEAK",
|
|
669
|
+
csrfToken: "LEAK",
|
|
670
|
+
authorizationUrl: "https://x.test/auth",
|
|
671
|
+
cookieConsent: "yes",
|
|
672
|
+
}),
|
|
673
|
+
)!;
|
|
674
|
+
expect(text).not.toMatch(/LEAK/);
|
|
675
|
+
expect(JSON.parse(text)).toMatchObject({
|
|
676
|
+
authorizationUrl: "https://x.test/auth",
|
|
677
|
+
cookieConsent: "yes",
|
|
678
|
+
});
|
|
679
|
+
expect(
|
|
680
|
+
createRedactor().url("https://x.test/p?a=1&authorization=LEAK&jwt=LEAK2"),
|
|
681
|
+
).toBe("https://x.test/p?a=1&authorization=[redacted]&jwt=[redacted]");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("redacts attributes named like credentials", () => {
|
|
685
|
+
document.body.innerHTML =
|
|
686
|
+
'<button data-api-key="sk-LEAK" data-token="LEAK2" data-id="7">Go</button>';
|
|
687
|
+
const { other } = setup();
|
|
688
|
+
document.querySelector("button")!.click();
|
|
689
|
+
expect(other[0]!.value.target).toMatchObject({
|
|
690
|
+
attributes: {
|
|
691
|
+
"data-api-key": "[redacted]",
|
|
692
|
+
"data-token": "[redacted]",
|
|
693
|
+
"data-id": "7",
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("normalizes header names and redacts subscription keys and x-token", async () => {
|
|
699
|
+
const { network } = setup();
|
|
700
|
+
const names = ["x-csrftoken", "ocp-apim-subscription-key", "x-token"];
|
|
701
|
+
await fetch("/x", {
|
|
702
|
+
headers: {
|
|
703
|
+
...Object.fromEntries(names.map((name) => [name, "LEAK"])),
|
|
704
|
+
"x-request-id": "rid",
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
expect((await captured(network)).request.headers).toEqual({
|
|
708
|
+
...Object.fromEntries(names.map((name) => [name, "[redacted]"])),
|
|
709
|
+
"x-request-id": "rid",
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("redacts JSON nested four levels deep in strings", () => {
|
|
714
|
+
let value: unknown = { password: "LEAK" };
|
|
715
|
+
for (let level = 0; level < 4; level++)
|
|
716
|
+
value = { inner: JSON.stringify(value) };
|
|
717
|
+
const text = createRedactor().body(JSON.stringify(value))!;
|
|
718
|
+
expect(text).not.toMatch(/LEAK/);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("redacts an auth scheme together with its credential", () => {
|
|
722
|
+
expect(
|
|
723
|
+
createRedactor().body("Authorization: Bearer LEAK\nAccept: */*"),
|
|
724
|
+
).toBe("Authorization: [redacted]\nAccept: */*");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("leaves no stray backslash when a value is cut mid-escape", () => {
|
|
728
|
+
expect(createRedactor().body('{"password":"a\\')).toBe(
|
|
729
|
+
'{"password":"[redacted]"',
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
});
|