@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
package/src/network.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { createBodyCapture } from "./network-body";
|
|
2
|
+
import type { BodySnapshot } from "./network-body";
|
|
3
|
+
import { createRedactor, isCredentialKey } from "./redact";
|
|
4
|
+
import type { Redactor } from "./redact";
|
|
5
|
+
import { REDACTED } from "./types";
|
|
6
|
+
import type { Emit } from "./types";
|
|
7
|
+
|
|
8
|
+
type Outcome = "ok" | "error" | "aborted";
|
|
9
|
+
interface RequestParts {
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
interface ResponseParts {
|
|
15
|
+
url?: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: Promise<BodySnapshot>;
|
|
18
|
+
}
|
|
19
|
+
const emptyBody = () => Promise.resolve<BodySnapshot>({ status: "empty" });
|
|
20
|
+
const unavailableBody = (reason: string) =>
|
|
21
|
+
Promise.resolve<BodySnapshot>({ status: "unavailable", reason });
|
|
22
|
+
const toAbsolute = (url: string) => new URL(url, location.href).href;
|
|
23
|
+
// Credentials carry no product signal and must never leave the browser, even
|
|
24
|
+
// with full capture. The header name stays, so the event still shows it was sent.
|
|
25
|
+
// Names the credential-key matcher misses; it covers `authorization`, `cookie`,
|
|
26
|
+
// `x-api-key`, `x-csrf-token`, and similar.
|
|
27
|
+
const CREDENTIAL_HEADERS = new Set([
|
|
28
|
+
"x-amz-security-token",
|
|
29
|
+
"private-token",
|
|
30
|
+
"x-token",
|
|
31
|
+
]);
|
|
32
|
+
const headerValue = (name: string, value: string) =>
|
|
33
|
+
CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name)
|
|
34
|
+
? REDACTED
|
|
35
|
+
: value;
|
|
36
|
+
const headerValues = (headers: Headers) =>
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
Array.from(headers.entries(), ([name, value]) => [
|
|
39
|
+
name,
|
|
40
|
+
headerValue(name, value),
|
|
41
|
+
]),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Checks the name, not instanceof: errors can come from another realm.
|
|
45
|
+
function isAbortError(error: unknown) {
|
|
46
|
+
return (
|
|
47
|
+
typeof error === "object" &&
|
|
48
|
+
error !== null &&
|
|
49
|
+
"name" in error &&
|
|
50
|
+
error.name === "AbortError"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Records browser-visible request/response data without consuming the app's bodies. */
|
|
55
|
+
export function installNetworkCapture(params: {
|
|
56
|
+
emit: Emit;
|
|
57
|
+
routes?: string[];
|
|
58
|
+
ignoreUrls: (string | RegExp)[];
|
|
59
|
+
redact?: Redactor;
|
|
60
|
+
}) {
|
|
61
|
+
let active = true;
|
|
62
|
+
const { redact = createRedactor() } = params;
|
|
63
|
+
const bodies = createBodyCapture(redact);
|
|
64
|
+
const ignoreUrls = params.ignoreUrls.map((pattern) =>
|
|
65
|
+
typeof pattern === "string"
|
|
66
|
+
? toAbsolute(pattern)
|
|
67
|
+
: new RegExp(pattern.source, pattern.flags),
|
|
68
|
+
);
|
|
69
|
+
const isIgnored = (url: string) =>
|
|
70
|
+
ignoreUrls.some((pattern) => {
|
|
71
|
+
if (typeof pattern === "string") return url.startsWith(pattern);
|
|
72
|
+
pattern.lastIndex = 0;
|
|
73
|
+
return pattern.test(url);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const record: RecordFn = (
|
|
77
|
+
transport,
|
|
78
|
+
request,
|
|
79
|
+
requestBody,
|
|
80
|
+
startedAt,
|
|
81
|
+
status,
|
|
82
|
+
outcome,
|
|
83
|
+
response,
|
|
84
|
+
) => {
|
|
85
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
86
|
+
const completedAt = Date.now();
|
|
87
|
+
void Promise.all([
|
|
88
|
+
requestBody,
|
|
89
|
+
response?.body ?? unavailableBody("no-response"),
|
|
90
|
+
])
|
|
91
|
+
.then(([sent, received]) => {
|
|
92
|
+
if (!active) return;
|
|
93
|
+
const href = redact.url(request.url);
|
|
94
|
+
const url = new URL(href);
|
|
95
|
+
params.emit("network", {
|
|
96
|
+
transport,
|
|
97
|
+
method: request.method,
|
|
98
|
+
url: href,
|
|
99
|
+
origin: url.origin,
|
|
100
|
+
route: url.pathname,
|
|
101
|
+
status,
|
|
102
|
+
durationMs,
|
|
103
|
+
completedAt,
|
|
104
|
+
outcome,
|
|
105
|
+
request: { headers: request.headers, body: sent },
|
|
106
|
+
response: {
|
|
107
|
+
...(response?.url ? { url: redact.url(response.url) } : {}),
|
|
108
|
+
headers: response?.headers ?? {},
|
|
109
|
+
body: received,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
/* Capture must never affect the host request. */
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const originalFetch = globalThis.fetch;
|
|
119
|
+
const wrappedFetch: typeof fetch = (input, init) => {
|
|
120
|
+
if (!active) return originalFetch.call(globalThis, input, init);
|
|
121
|
+
let request: RequestParts;
|
|
122
|
+
let ignored: boolean;
|
|
123
|
+
try {
|
|
124
|
+
const source =
|
|
125
|
+
typeof Request !== "undefined" && input instanceof Request
|
|
126
|
+
? input
|
|
127
|
+
: undefined;
|
|
128
|
+
request = {
|
|
129
|
+
url: toAbsolute(source ? source.url : String(input)),
|
|
130
|
+
method: (init?.method ?? source?.method ?? "GET").toUpperCase(),
|
|
131
|
+
headers: headerValues(new Headers(init?.headers ?? source?.headers)),
|
|
132
|
+
};
|
|
133
|
+
ignored = isIgnored(request.url);
|
|
134
|
+
} catch {
|
|
135
|
+
return originalFetch.call(globalThis, input, init);
|
|
136
|
+
}
|
|
137
|
+
if (ignored) return originalFetch.call(globalThis, input, init);
|
|
138
|
+
const startedAt = performance.now();
|
|
139
|
+
let requestBody: Promise<BodySnapshot>;
|
|
140
|
+
try {
|
|
141
|
+
requestBody =
|
|
142
|
+
init?.body != null
|
|
143
|
+
? bodies.body(init.body, request.headers["content-type"])
|
|
144
|
+
: typeof Request !== "undefined" && input instanceof Request
|
|
145
|
+
? bodies.read(input.clone().body, request.headers["content-type"])
|
|
146
|
+
: emptyBody();
|
|
147
|
+
} catch {
|
|
148
|
+
requestBody = unavailableBody("request-clone-failed");
|
|
149
|
+
}
|
|
150
|
+
const failed = (error: unknown) =>
|
|
151
|
+
record(
|
|
152
|
+
"fetch",
|
|
153
|
+
request,
|
|
154
|
+
requestBody,
|
|
155
|
+
startedAt,
|
|
156
|
+
null,
|
|
157
|
+
isAbortError(error) ? "aborted" : "error",
|
|
158
|
+
);
|
|
159
|
+
let promise: Promise<Response>;
|
|
160
|
+
try {
|
|
161
|
+
promise = originalFetch.call(globalThis, input, init);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
failed(error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
void promise
|
|
167
|
+
.then((response) => {
|
|
168
|
+
if (!active) return;
|
|
169
|
+
let responseBody: Promise<BodySnapshot>;
|
|
170
|
+
try {
|
|
171
|
+
responseBody =
|
|
172
|
+
response.type === "opaque" || response.type === "opaqueredirect"
|
|
173
|
+
? unavailableBody("opaque-response")
|
|
174
|
+
: bodies.read(
|
|
175
|
+
response.clone().body,
|
|
176
|
+
response.headers.get("content-type") ?? "",
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
responseBody = unavailableBody("response-clone-failed");
|
|
180
|
+
}
|
|
181
|
+
record(
|
|
182
|
+
"fetch",
|
|
183
|
+
request,
|
|
184
|
+
requestBody,
|
|
185
|
+
startedAt,
|
|
186
|
+
response.status,
|
|
187
|
+
"ok",
|
|
188
|
+
{
|
|
189
|
+
url: response.url,
|
|
190
|
+
headers: headerValues(response.headers),
|
|
191
|
+
body: responseBody,
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
}, failed)
|
|
195
|
+
.catch(() => {});
|
|
196
|
+
// Return the identical native Promise, Response, and rejection to the caller.
|
|
197
|
+
return promise;
|
|
198
|
+
};
|
|
199
|
+
if (typeof originalFetch === "function") globalThis.fetch = wrappedFetch;
|
|
200
|
+
const uninstallXhr = patchXhr(isIgnored, record, bodies);
|
|
201
|
+
return () => {
|
|
202
|
+
active = false;
|
|
203
|
+
bodies.stop();
|
|
204
|
+
if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;
|
|
205
|
+
uninstallXhr();
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type RecordFn = (
|
|
210
|
+
transport: "fetch" | "xhr",
|
|
211
|
+
request: RequestParts,
|
|
212
|
+
requestBody: Promise<BodySnapshot>,
|
|
213
|
+
startedAt: number,
|
|
214
|
+
status: number | null,
|
|
215
|
+
outcome: Outcome,
|
|
216
|
+
response?: ResponseParts,
|
|
217
|
+
) => void;
|
|
218
|
+
|
|
219
|
+
function patchXhr(
|
|
220
|
+
isIgnored: (url: string) => boolean,
|
|
221
|
+
record: RecordFn,
|
|
222
|
+
bodies: ReturnType<typeof createBodyCapture>,
|
|
223
|
+
) {
|
|
224
|
+
if (typeof XMLHttpRequest !== "function") return () => {};
|
|
225
|
+
const proto = XMLHttpRequest.prototype;
|
|
226
|
+
const originalOpen = proto.open;
|
|
227
|
+
const originalSend = proto.send;
|
|
228
|
+
const originalSetHeader = proto.setRequestHeader;
|
|
229
|
+
const requests = new WeakMap<XMLHttpRequest, RequestParts>();
|
|
230
|
+
const listeners = new Set<() => void>();
|
|
231
|
+
// open() on an in-flight XHR ends it without `loadend`; its listeners must not
|
|
232
|
+
// report the next request with the old request's details.
|
|
233
|
+
const inFlight = new WeakMap<XMLHttpRequest, () => void>();
|
|
234
|
+
let active = true;
|
|
235
|
+
const wrappedOpen = function (
|
|
236
|
+
this: XMLHttpRequest,
|
|
237
|
+
method: string,
|
|
238
|
+
url: string | URL,
|
|
239
|
+
...rest: unknown[]
|
|
240
|
+
) {
|
|
241
|
+
inFlight.get(this)?.();
|
|
242
|
+
const result = Reflect.apply(originalOpen, this, [method, url, ...rest]);
|
|
243
|
+
try {
|
|
244
|
+
requests.set(this, {
|
|
245
|
+
url: toAbsolute(String(url)),
|
|
246
|
+
method: method.toUpperCase(),
|
|
247
|
+
headers: {},
|
|
248
|
+
});
|
|
249
|
+
} catch {
|
|
250
|
+
requests.delete(this);
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
};
|
|
254
|
+
const wrappedSetHeader = function (
|
|
255
|
+
this: XMLHttpRequest,
|
|
256
|
+
name: string,
|
|
257
|
+
value: string,
|
|
258
|
+
) {
|
|
259
|
+
const result = originalSetHeader.call(this, name, value);
|
|
260
|
+
const request = requests.get(this);
|
|
261
|
+
if (active && request) {
|
|
262
|
+
try {
|
|
263
|
+
const headers = new Headers(request.headers);
|
|
264
|
+
headers.append(name, value);
|
|
265
|
+
request.headers = headerValues(headers);
|
|
266
|
+
} catch {
|
|
267
|
+
/* Preserve successful native calls even if observation fails. */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
const wrappedSend = function (
|
|
273
|
+
this: XMLHttpRequest,
|
|
274
|
+
body?: Document | XMLHttpRequestBodyInit | null,
|
|
275
|
+
) {
|
|
276
|
+
const request = requests.get(this);
|
|
277
|
+
if (!active || !request || isIgnored(request.url))
|
|
278
|
+
return originalSend.call(this, body);
|
|
279
|
+
const startedAt = performance.now();
|
|
280
|
+
let requestBody: Promise<BodySnapshot>;
|
|
281
|
+
try {
|
|
282
|
+
requestBody =
|
|
283
|
+
typeof Document !== "undefined" && body instanceof Document
|
|
284
|
+
? Promise.resolve(
|
|
285
|
+
bodies.text(new XMLSerializer().serializeToString(body)),
|
|
286
|
+
)
|
|
287
|
+
: bodies.body(
|
|
288
|
+
body as BodyInit | null | undefined,
|
|
289
|
+
request.headers["content-type"],
|
|
290
|
+
);
|
|
291
|
+
} catch {
|
|
292
|
+
requestBody = unavailableBody("request-body-unavailable");
|
|
293
|
+
}
|
|
294
|
+
let aborted = false;
|
|
295
|
+
const onAbort = () => {
|
|
296
|
+
aborted = true;
|
|
297
|
+
};
|
|
298
|
+
const cleanup = () => {
|
|
299
|
+
this.removeEventListener("abort", onAbort);
|
|
300
|
+
this.removeEventListener("loadend", onEnd);
|
|
301
|
+
listeners.delete(cleanup);
|
|
302
|
+
if (inFlight.get(this) === cleanup) inFlight.delete(this);
|
|
303
|
+
};
|
|
304
|
+
const onEnd = () => {
|
|
305
|
+
cleanup();
|
|
306
|
+
if (!active) return;
|
|
307
|
+
const outcome = aborted ? "aborted" : this.status === 0 ? "error" : "ok";
|
|
308
|
+
const headers: Record<string, string> = {};
|
|
309
|
+
try {
|
|
310
|
+
for (const line of this.getAllResponseHeaders().split(/\r?\n/)) {
|
|
311
|
+
const split = line.indexOf(":");
|
|
312
|
+
if (split > 0) {
|
|
313
|
+
const name = line.slice(0, split).toLowerCase();
|
|
314
|
+
headers[name] = headerValue(name, line.slice(split + 1).trim());
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
/* Cross-origin or failed requests can hide response headers. */
|
|
319
|
+
}
|
|
320
|
+
let responseBody: Promise<BodySnapshot>;
|
|
321
|
+
try {
|
|
322
|
+
if (this.responseType === "" || this.responseType === "text") {
|
|
323
|
+
responseBody = Promise.resolve(
|
|
324
|
+
bodies.text(
|
|
325
|
+
this.responseText,
|
|
326
|
+
outcome === "ok" ? "complete" : "interrupted",
|
|
327
|
+
outcome === "ok" ? undefined : `request-${outcome}`,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
} else if (this.responseType === "json")
|
|
331
|
+
responseBody = Promise.resolve(
|
|
332
|
+
bodies.text(JSON.stringify(this.response)),
|
|
333
|
+
);
|
|
334
|
+
else if (this.responseType === "document")
|
|
335
|
+
responseBody = this.responseXML
|
|
336
|
+
? Promise.resolve(
|
|
337
|
+
bodies.text(
|
|
338
|
+
new XMLSerializer().serializeToString(this.responseXML),
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
: unavailableBody("empty-document");
|
|
342
|
+
else
|
|
343
|
+
responseBody = bodies.body(
|
|
344
|
+
this.response as BodyInit,
|
|
345
|
+
headers["content-type"] ?? "application/octet-stream",
|
|
346
|
+
);
|
|
347
|
+
} catch {
|
|
348
|
+
responseBody = unavailableBody("response-body-unavailable");
|
|
349
|
+
}
|
|
350
|
+
record(
|
|
351
|
+
"xhr",
|
|
352
|
+
request,
|
|
353
|
+
requestBody,
|
|
354
|
+
startedAt,
|
|
355
|
+
outcome === "ok" ? this.status : null,
|
|
356
|
+
outcome,
|
|
357
|
+
{
|
|
358
|
+
url: this.responseURL,
|
|
359
|
+
headers,
|
|
360
|
+
body: responseBody,
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
this.addEventListener("abort", onAbort);
|
|
365
|
+
this.addEventListener("loadend", onEnd);
|
|
366
|
+
listeners.add(cleanup);
|
|
367
|
+
inFlight.set(this, cleanup);
|
|
368
|
+
try {
|
|
369
|
+
return originalSend.call(this, body);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
cleanup();
|
|
372
|
+
record(
|
|
373
|
+
"xhr",
|
|
374
|
+
request,
|
|
375
|
+
requestBody,
|
|
376
|
+
startedAt,
|
|
377
|
+
null,
|
|
378
|
+
isAbortError(error) ? "aborted" : "error",
|
|
379
|
+
);
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
proto.open = wrappedOpen;
|
|
384
|
+
proto.send = wrappedSend;
|
|
385
|
+
proto.setRequestHeader = wrappedSetHeader;
|
|
386
|
+
return () => {
|
|
387
|
+
active = false;
|
|
388
|
+
for (const cleanup of listeners) cleanup();
|
|
389
|
+
if (proto.open === wrappedOpen) proto.open = originalOpen;
|
|
390
|
+
if (proto.send === wrappedSend) proto.send = originalSend;
|
|
391
|
+
if (proto.setRequestHeader === wrappedSetHeader)
|
|
392
|
+
proto.setRequestHeader = originalSetHeader;
|
|
393
|
+
};
|
|
394
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { REDACTED } from "./types";
|
|
2
|
+
|
|
3
|
+
// Passwords and unambiguous credentials never leave the browser, even with full
|
|
4
|
+
// capture. Keys are split into lowercase words (`accessToken`, `access_token`,
|
|
5
|
+
// `Access-Token` all become "access token"). These match anywhere in the joined words:
|
|
6
|
+
const CREDENTIAL =
|
|
7
|
+
/password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;
|
|
8
|
+
// These match only as whole words (`secret` but not `secretary`, `id token` but
|
|
9
|
+
// not `invalid token`), as the last word (`Set-Cookie` but not `cookieConsent`),
|
|
10
|
+
// or as the entire key.
|
|
11
|
+
const CREDENTIAL_WORD =
|
|
12
|
+
/ (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;
|
|
13
|
+
// Metadata about a credential is not the credential: `passwordExpiresAt`, `credentialId`.
|
|
14
|
+
const METADATA =
|
|
15
|
+
/ (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;
|
|
16
|
+
const PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;
|
|
17
|
+
// A JSON string, possibly cut off, with the `:` that makes it a key.
|
|
18
|
+
const TOKEN = /"((?:\\[\s\S]|[^"\\])*)("?)(\s*:\s*)?/g;
|
|
19
|
+
// A lone trailing backslash belongs to a cut-off string.
|
|
20
|
+
const STRING = /"(?:\\[\s\S]?|[^"\\])*"?/y;
|
|
21
|
+
// `key=value` and `key: value` outside JSON strings: forms, query strings,
|
|
22
|
+
// GraphQL, YAML. A `:` needs a space or end after it, so `host:port` is no pair.
|
|
23
|
+
// The key length bound keeps scanning long unbroken text linear.
|
|
24
|
+
// An auth scheme (`Bearer x`) belongs to the value.
|
|
25
|
+
const PAIR =
|
|
26
|
+
/([\w$.%+[\]-]{1,64})(\s*(?:=|:(?=\s|$))\s*)((?:(?:basic|bearer|digest|token) +)?[^\s&,;)}\]]*)/gi;
|
|
27
|
+
// `scheme://user:pass@` anywhere in text; up to the last `@` before the host.
|
|
28
|
+
// The scheme length bound keeps scanning long words linear.
|
|
29
|
+
const USERINFO = /([a-z][\w+.-]{0,31}:\/\/)[^\s/?#"]*@/gi;
|
|
30
|
+
/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */
|
|
31
|
+
export const TEXT_LIMIT = 16 * 1024;
|
|
32
|
+
const REMEMBERED_VALUES = 20;
|
|
33
|
+
|
|
34
|
+
export const isCredentialKey = (key: string) => {
|
|
35
|
+
const words = ` ${key
|
|
36
|
+
.replace(/%([\da-f]{2})/gi, (_, hex: string) =>
|
|
37
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
38
|
+
)
|
|
39
|
+
.replace(/([a-z\d])(?=[A-Z])/g, "$1 ")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.split(/[^a-z\d]+/)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ")} `;
|
|
44
|
+
return (
|
|
45
|
+
!METADATA.test(words) &&
|
|
46
|
+
(CREDENTIAL.test(words.replace(/ /g, "")) || CREDENTIAL_WORD.test(words))
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const decode = (body: string) => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(`"${body}"`) as string;
|
|
53
|
+
} catch {
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */
|
|
59
|
+
const endOfValue = (text: string, start: number) => {
|
|
60
|
+
let depth = 0;
|
|
61
|
+
for (let i = start; i < text.length; i++) {
|
|
62
|
+
const char = text[i]!;
|
|
63
|
+
if (char === '"') {
|
|
64
|
+
STRING.lastIndex = i;
|
|
65
|
+
STRING.test(text);
|
|
66
|
+
i = STRING.lastIndex - 1;
|
|
67
|
+
} else if (char === "{" || char === "[") {
|
|
68
|
+
depth++;
|
|
69
|
+
continue;
|
|
70
|
+
} else if (char === "}" || char === "]") {
|
|
71
|
+
if (--depth < 0) return i;
|
|
72
|
+
} else {
|
|
73
|
+
if (!depth && (char === "," || /\s/.test(char))) return i;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!depth) return i + 1;
|
|
77
|
+
}
|
|
78
|
+
return text.length;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Redacts credential values in JSON (valid or cut off) and in key/value text,
|
|
83
|
+
* and recurses into string values that hold JSON, forms, or URLs. Unchanged
|
|
84
|
+
* parts keep their exact original text.
|
|
85
|
+
*/
|
|
86
|
+
const redactText = (text: string, depth = 0): string => {
|
|
87
|
+
const token = new RegExp(TOKEN);
|
|
88
|
+
let pending = false;
|
|
89
|
+
const pairs = (part: string) => {
|
|
90
|
+
const segment = part.replace(USERINFO, "$1");
|
|
91
|
+
return segment.replace(
|
|
92
|
+
PAIR,
|
|
93
|
+
(pair, key: string, separator: string, value: string, offset: number) => {
|
|
94
|
+
if (!isCredentialKey(key)) return pair;
|
|
95
|
+
// The value may continue in a following string: `password:"x"`.
|
|
96
|
+
if (offset + pair.length === segment.length) {
|
|
97
|
+
pending = true;
|
|
98
|
+
if (!value) return pair;
|
|
99
|
+
}
|
|
100
|
+
return key + separator + REDACTED;
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
let out = "";
|
|
105
|
+
let last = 0;
|
|
106
|
+
let match: RegExpExecArray | null;
|
|
107
|
+
while ((match = token.exec(text))) {
|
|
108
|
+
const [raw, body, closed, colon] = match;
|
|
109
|
+
pending = false;
|
|
110
|
+
out += pairs(text.slice(last, match.index));
|
|
111
|
+
let end = token.lastIndex;
|
|
112
|
+
let replacement = raw;
|
|
113
|
+
const value = body!.includes("\\") ? decode(body!) : body!;
|
|
114
|
+
if (colon) {
|
|
115
|
+
if (isCredentialKey(value)) {
|
|
116
|
+
const valueEnd = endOfValue(text, end);
|
|
117
|
+
// Booleans and null say whether a credential exists, not what it is.
|
|
118
|
+
if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {
|
|
119
|
+
replacement += `"${REDACTED}"`;
|
|
120
|
+
end = valueEnd;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} else if (pending) replacement = `"${REDACTED}"`;
|
|
124
|
+
else if (depth < 4 && /[=:"]/.test(value)) {
|
|
125
|
+
const redacted = redactText(value, depth + 1);
|
|
126
|
+
if (redacted !== value)
|
|
127
|
+
replacement = JSON.stringify(redacted).slice(
|
|
128
|
+
0,
|
|
129
|
+
closed ? undefined : -1,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
out += replacement;
|
|
133
|
+
last = token.lastIndex = end;
|
|
134
|
+
}
|
|
135
|
+
return out + pairs(text.slice(last));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Per-session redaction state: password-like fields and the values typed into them. */
|
|
139
|
+
export function createRedactor() {
|
|
140
|
+
const seen = new WeakSet<Element>();
|
|
141
|
+
// Latest value per field, so each keystroke replaces the previous prefix.
|
|
142
|
+
const remembered = new Map<Element, string>();
|
|
143
|
+
let observer: MutationObserver | undefined;
|
|
144
|
+
|
|
145
|
+
const toggled = (records: MutationRecord[]) => {
|
|
146
|
+
for (const record of records)
|
|
147
|
+
if (record.oldValue?.toLowerCase() === "password")
|
|
148
|
+
seen.add(record.target as Element);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Replaces remembered values, raw and in their JSON and URL encodings. */
|
|
152
|
+
const scrub = (text: string) => {
|
|
153
|
+
for (const value of [...remembered.values()].sort(
|
|
154
|
+
(a, b) => b.length - a.length,
|
|
155
|
+
))
|
|
156
|
+
for (const form of [
|
|
157
|
+
value,
|
|
158
|
+
JSON.stringify(value).slice(1, -1),
|
|
159
|
+
encodeURIComponent(value),
|
|
160
|
+
new URLSearchParams({ v: value }).toString().slice(2),
|
|
161
|
+
])
|
|
162
|
+
text = /^\d+$/.test(form)
|
|
163
|
+
? // One-time codes must not match inside longer numbers.
|
|
164
|
+
text.replace(RegExp(`(?<!\\d)${form}(?!\\d)`, "g"), REDACTED)
|
|
165
|
+
: text.split(form).join(REDACTED);
|
|
166
|
+
return text;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** True for current, former (show-password toggles), and autocomplete password fields. */
|
|
170
|
+
const isPassword = (element: Element) => {
|
|
171
|
+
if (observer) toggled(observer.takeRecords());
|
|
172
|
+
if (
|
|
173
|
+
!(element instanceof HTMLInputElement) ||
|
|
174
|
+
!(
|
|
175
|
+
element.type === "password" ||
|
|
176
|
+
seen.has(element) ||
|
|
177
|
+
isCredentialKey(element.name) ||
|
|
178
|
+
PASSWORD_AUTOCOMPLETE.test(element.getAttribute("autocomplete") ?? "")
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return false;
|
|
182
|
+
seen.add(element);
|
|
183
|
+
if (element.value.length >= 4) {
|
|
184
|
+
remembered.delete(element);
|
|
185
|
+
remembered.set(element, element.value);
|
|
186
|
+
if (remembered.size > REMEMBERED_VALUES)
|
|
187
|
+
remembered.delete(remembered.keys().next().value!);
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
isPassword,
|
|
194
|
+
/**
|
|
195
|
+
* Tracks password fields for the session: existing ones, `type` toggles away
|
|
196
|
+
* from "password", and typed values even when input capture is off.
|
|
197
|
+
*/
|
|
198
|
+
watch() {
|
|
199
|
+
// Each part is optional: a partial DOM (tests, embedded webviews, hardened
|
|
200
|
+
// pages) must not stop capture from starting or break the host app.
|
|
201
|
+
try {
|
|
202
|
+
for (const input of document.querySelectorAll("input[type=password]"))
|
|
203
|
+
seen.add(input);
|
|
204
|
+
} catch {
|
|
205
|
+
// Fields are still caught on first sight by isPassword.
|
|
206
|
+
}
|
|
207
|
+
let current: MutationObserver | undefined;
|
|
208
|
+
try {
|
|
209
|
+
current = new MutationObserver(toggled);
|
|
210
|
+
current.observe(document, {
|
|
211
|
+
subtree: true,
|
|
212
|
+
attributeFilter: ["type"],
|
|
213
|
+
attributeOldValue: true,
|
|
214
|
+
});
|
|
215
|
+
observer = current;
|
|
216
|
+
} catch {
|
|
217
|
+
current = undefined;
|
|
218
|
+
}
|
|
219
|
+
const remember = (event: Event) => {
|
|
220
|
+
try {
|
|
221
|
+
const [target] = event.composedPath();
|
|
222
|
+
if (target instanceof Element) isPassword(target);
|
|
223
|
+
} catch {
|
|
224
|
+
// Capture must never break an edit in the host app.
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
for (const type of ["input", "change"])
|
|
228
|
+
window.addEventListener(type, remember, true);
|
|
229
|
+
return () => {
|
|
230
|
+
current?.disconnect();
|
|
231
|
+
if (current !== undefined && observer === current) observer = undefined;
|
|
232
|
+
for (const type of ["input", "change"])
|
|
233
|
+
window.removeEventListener(type, remember, true);
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
field: (key: string, value: string) =>
|
|
237
|
+
isCredentialKey(key) ? REDACTED : scrub(redactText(value)),
|
|
238
|
+
/** Returns the redacted body text, or undefined if redaction failed. */
|
|
239
|
+
body(text: string) {
|
|
240
|
+
try {
|
|
241
|
+
return scrub(redactText(text.slice(0, TEXT_LIMIT)));
|
|
242
|
+
} catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
/** Drops userinfo and redacts credential values; also applied to element attributes. */
|
|
247
|
+
url(value: string) {
|
|
248
|
+
try {
|
|
249
|
+
return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));
|
|
250
|
+
} catch {
|
|
251
|
+
return REDACTED;
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export type Redactor = ReturnType<typeof createRedactor>;
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Returns the original pathname. Capture no longer masks or categorizes routes. */
|
|
2
|
+
export function toRoute(pathname: string, _templates: string[] = []) {
|
|
3
|
+
return pathname;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Resolves a URL without removing path segments, query parameters, or fragments. */
|
|
7
|
+
export function toOriginAndRoute(
|
|
8
|
+
url: string,
|
|
9
|
+
_templates: string[] = [],
|
|
10
|
+
base: string = globalThis.location?.href ?? "http://localhost/",
|
|
11
|
+
) {
|
|
12
|
+
const parsed = new URL(url, base);
|
|
13
|
+
return { url: parsed.href, origin: parsed.origin, route: parsed.pathname };
|
|
14
|
+
}
|