browser-commander 0.28.0 → 0.29.0
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/.jscpd-baseline.json +5 -19
- package/CHANGELOG.md +6 -0
- package/README.md +20 -10
- package/package.json +1 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/persistent-session.js +110 -38
- package/src/browser/session-adoption.js +96 -0
- package/src/browser/session-worker.js +12 -0
- package/src/cli/args.js +3 -0
- package/src/cli/commands.js +3 -0
- package/src/cli/high-level-methods.js +7 -0
- package/src/cli/sessions.js +3 -0
- package/src/core/commander-features.js +6 -1
- package/src/core/navigation-safety.js +1 -0
- package/src/core/page-trigger-manager.js +173 -134
- package/src/index.js +2 -0
- package/src/interactions/overlays.js +59 -14
- package/src/traces/bundle.js +41 -3
- package/src/traces/checkpoints.js +7 -3
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +8 -2
- package/src/traces/network.js +65 -13
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +33 -3
- package/src/traces/redaction.js +89 -6
- package/src/traces/rolling-bundle.js +240 -0
- package/src/traces/rolling.js +10 -113
- package/src/traces/stop.js +1 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +1 -1
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +2 -2
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +4 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +1 -1
- package/tests/fixtures/traces/conformance/expected/trace.lino +2 -2
- package/tests/unit/browser/issue-160-connector.test.js +157 -0
- package/tests/unit/core/interrupted-navigation.test.js +32 -0
- package/tests/unit/core/trigger-stop-race.test.js +80 -0
- package/tests/unit/interactions/overlays.test.js +58 -0
- package/tests/unit/traces/issue-160-privacy.test.js +110 -0
- package/tests/unit/traces/issue-160-rotation.test.js +140 -0
- package/tests/unit/traces/network.test.js +55 -3
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
RECORDER_GLOBAL,
|
|
21
21
|
stopMutationRecorderInPage,
|
|
22
22
|
} from './page-capture.js';
|
|
23
|
-
import { REDACTED } from './redaction.js';
|
|
23
|
+
import { redactValue, REDACTED } from './redaction.js';
|
|
24
24
|
import { TRACE_DROP_REASON, TRACE_EVENT } from './schema.js';
|
|
25
25
|
|
|
26
26
|
/** In-page records kept before the recorder starts counting drops instead. */
|
|
@@ -62,6 +62,8 @@ export function createMutationStream(options) {
|
|
|
62
62
|
const recorderOptions = {
|
|
63
63
|
globalName: RECORDER_GLOBAL,
|
|
64
64
|
redactSelectors: privacyOptions.redactSelectors,
|
|
65
|
+
redactAttributes: privacyOptions.redactAttributes,
|
|
66
|
+
useDefaults: privacyOptions.useDefaults,
|
|
65
67
|
redacted: REDACTED,
|
|
66
68
|
ignoreSelectors: domOptions.ignoreSelectors ?? [],
|
|
67
69
|
maxQueued: limits.maxQueuedMutations ?? DEFAULT_MAX_QUEUED_MUTATIONS,
|
|
@@ -195,13 +197,17 @@ export function createMutationStream(options) {
|
|
|
195
197
|
// frame; one ordered file is what a replay reads.
|
|
196
198
|
batches.sort((left, right) => (left.at ?? 0) - (right.at ?? 0));
|
|
197
199
|
|
|
198
|
-
const member = await bundle.writeMutations(
|
|
200
|
+
const member = await bundle.writeMutations(
|
|
201
|
+
index,
|
|
202
|
+
redactValue(batches, privacyOptions)
|
|
203
|
+
);
|
|
199
204
|
if (member) {
|
|
200
205
|
await record(TRACE_EVENT.MUTATIONS, {
|
|
201
206
|
member,
|
|
202
207
|
batches: batches.length,
|
|
203
208
|
checkpoint: index,
|
|
204
209
|
frames: drained.length,
|
|
210
|
+
truncated: bundle.mutationTruncated,
|
|
205
211
|
});
|
|
206
212
|
}
|
|
207
213
|
return member;
|
package/src/traces/network.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { withDeadline } from './deadline.js';
|
|
4
|
+
import {
|
|
5
|
+
normalizePrivacyOptions,
|
|
6
|
+
redactText,
|
|
7
|
+
redactHeaders as privateHeaders,
|
|
8
|
+
} from './redaction.js';
|
|
4
9
|
|
|
5
10
|
const PRIVATE_HEADERS = new Set([
|
|
6
11
|
'cookie',
|
|
@@ -9,11 +14,20 @@ const PRIVATE_HEADERS = new Set([
|
|
|
9
14
|
'set-cookie',
|
|
10
15
|
]);
|
|
11
16
|
export function redactHeaders(headers = {}) {
|
|
12
|
-
return
|
|
13
|
-
Object.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
return privateHeaders(
|
|
18
|
+
Object.fromEntries(
|
|
19
|
+
Object.entries(headers).map(([key, value]) => [
|
|
20
|
+
key.toLowerCase(),
|
|
21
|
+
PRIVATE_HEADERS.has(key.toLowerCase()) ? '[redacted]' : value,
|
|
22
|
+
])
|
|
23
|
+
),
|
|
24
|
+
normalizePrivacyOptions()
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isTextBody(contentType = '') {
|
|
29
|
+
return /^(?:text\/|application\/(?:[\w.+-]*json|[\w.+-]*xml|javascript|x-www-form-urlencoded))|multipart\/form-data/i.test(
|
|
30
|
+
contentType
|
|
17
31
|
);
|
|
18
32
|
}
|
|
19
33
|
|
|
@@ -81,10 +95,12 @@ export function networkHar(events) {
|
|
|
81
95
|
content: {
|
|
82
96
|
size: response.body?.size ?? 0,
|
|
83
97
|
mimeType: response.contentType ?? '',
|
|
84
|
-
...(response.body
|
|
98
|
+
...(response.body?.data !== undefined
|
|
85
99
|
? {
|
|
86
100
|
text: response.body.data,
|
|
87
|
-
encoding
|
|
101
|
+
...(response.body.encoding === 'utf8'
|
|
102
|
+
? {}
|
|
103
|
+
: { encoding: 'base64' }),
|
|
88
104
|
_truncated: response.body.truncated,
|
|
89
105
|
}
|
|
90
106
|
: {}),
|
|
@@ -131,7 +147,13 @@ export function validateNetworkOptions(network) {
|
|
|
131
147
|
);
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
|
-
export function attachNetwork({
|
|
150
|
+
export function attachNetwork({
|
|
151
|
+
page,
|
|
152
|
+
network,
|
|
153
|
+
record,
|
|
154
|
+
note,
|
|
155
|
+
privacy = normalizePrivacyOptions(),
|
|
156
|
+
}) {
|
|
135
157
|
validateNetworkOptions(network);
|
|
136
158
|
if (!network) {
|
|
137
159
|
return async () => {};
|
|
@@ -172,7 +194,12 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
172
194
|
['document', 'xhr', 'fetch'].includes(request.resourceType?.()) &&
|
|
173
195
|
request.postData?.()
|
|
174
196
|
? {
|
|
175
|
-
postData: Buffer.from(
|
|
197
|
+
postData: Buffer.from(
|
|
198
|
+
redactText(request.postData(), privacy, {
|
|
199
|
+
kind: 'network',
|
|
200
|
+
name: 'postData',
|
|
201
|
+
})
|
|
202
|
+
)
|
|
176
203
|
.subarray(0, maxBodyBytes)
|
|
177
204
|
.toString('utf8'),
|
|
178
205
|
postDataTruncated:
|
|
@@ -210,9 +237,26 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
210
237
|
options.bodies &&
|
|
211
238
|
['document', 'xhr', 'fetch'].includes(resourceType)
|
|
212
239
|
) {
|
|
213
|
-
|
|
214
|
-
if (Number.isFinite(length) &&
|
|
215
|
-
|
|
240
|
+
let length = Number(headers['content-length']);
|
|
241
|
+
if (!Number.isFinite(length) && request.sizes) {
|
|
242
|
+
const sizes = await withDeadline(
|
|
243
|
+
request.sizes(),
|
|
244
|
+
5000,
|
|
245
|
+
'network sizes'
|
|
246
|
+
).catch(() => ({}));
|
|
247
|
+
length = sizes.responseBodySize ?? NaN;
|
|
248
|
+
}
|
|
249
|
+
if (!isTextBody(payload.contentType ?? '')) {
|
|
250
|
+
payload.body = {
|
|
251
|
+
size: Number.isFinite(length) ? length : null,
|
|
252
|
+
omitted: 'binary',
|
|
253
|
+
};
|
|
254
|
+
} else if (Number.isFinite(length) && length > maxBodyBytes) {
|
|
255
|
+
payload.body = {
|
|
256
|
+
size: length,
|
|
257
|
+
truncated: true,
|
|
258
|
+
omitted: 'size limit',
|
|
259
|
+
};
|
|
216
260
|
} else {
|
|
217
261
|
try {
|
|
218
262
|
const data = Buffer.from(
|
|
@@ -225,7 +269,15 @@ export function attachNetwork({ page, network, record, note }) {
|
|
|
225
269
|
payload.body = {
|
|
226
270
|
size: data.length,
|
|
227
271
|
truncated: data.length > maxBodyBytes,
|
|
228
|
-
|
|
272
|
+
encoding: 'utf8',
|
|
273
|
+
data: Buffer.from(
|
|
274
|
+
redactText(data.toString('utf8'), privacy, {
|
|
275
|
+
kind: 'network',
|
|
276
|
+
name: 'responseBody',
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
.subarray(0, maxBodyBytes)
|
|
280
|
+
.toString('utf8'),
|
|
229
281
|
};
|
|
230
282
|
} catch (error) {
|
|
231
283
|
payload.bodyError = error.message;
|
|
@@ -28,6 +28,7 @@ export function captureSnapshotInPage(options) {
|
|
|
28
28
|
const {
|
|
29
29
|
redactSelectors = [],
|
|
30
30
|
redactAttributes = [],
|
|
31
|
+
useDefaults = true,
|
|
31
32
|
redacted = '[redacted]',
|
|
32
33
|
html: wantHtml = true,
|
|
33
34
|
liveControlState = true,
|
|
@@ -121,6 +122,13 @@ export function captureSnapshotInPage(options) {
|
|
|
121
122
|
// `<input>` otherwise shows the value the server sent, not the one the user
|
|
122
123
|
// typed.
|
|
123
124
|
const carryLiveState = (original, copy, secret) => {
|
|
125
|
+
if (
|
|
126
|
+
useDefaults &&
|
|
127
|
+
original.localName === 'meta' &&
|
|
128
|
+
/token|csrf|xsrf|secret/i.test(original.getAttribute('name') || '')
|
|
129
|
+
) {
|
|
130
|
+
copy.setAttribute('content', redacted);
|
|
131
|
+
}
|
|
124
132
|
for (const attribute of redactAttributes) {
|
|
125
133
|
if (copy.hasAttribute && copy.hasAttribute(attribute)) {
|
|
126
134
|
copy.setAttribute(attribute, redacted);
|
|
@@ -197,8 +205,13 @@ export function captureSnapshotInPage(options) {
|
|
|
197
205
|
const clone = cloneWithState(document.documentElement);
|
|
198
206
|
|
|
199
207
|
html = `<!DOCTYPE html>\n${clone?.outerHTML ?? ''}`;
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
const encoded = new globalThis.TextEncoder().encode(html);
|
|
209
|
+
if (maxHtmlBytes > 0 && encoded.length > maxHtmlBytes) {
|
|
210
|
+
const marker = '<!-- trace HTML truncated -->';
|
|
211
|
+
const prefix = new globalThis.TextDecoder().decode(
|
|
212
|
+
encoded.slice(0, Math.max(0, maxHtmlBytes - marker.length - 3))
|
|
213
|
+
);
|
|
214
|
+
html = `${prefix}${marker}`;
|
|
202
215
|
truncated = true;
|
|
203
216
|
}
|
|
204
217
|
}
|
|
@@ -281,6 +294,8 @@ export function installMutationRecorderInPage(options) {
|
|
|
281
294
|
const {
|
|
282
295
|
globalName = '__browserCommanderTrace__',
|
|
283
296
|
redactSelectors = [],
|
|
297
|
+
redactAttributes = [],
|
|
298
|
+
useDefaults = true,
|
|
284
299
|
redacted = '[redacted]',
|
|
285
300
|
maxQueued = 5000,
|
|
286
301
|
ignoreSelectors = [],
|
|
@@ -296,8 +311,6 @@ export function installMutationRecorderInPage(options) {
|
|
|
296
311
|
queue: [],
|
|
297
312
|
dropped: 0,
|
|
298
313
|
sequence: 0,
|
|
299
|
-
// Every document gets its own name, so a batch drained from an iframe is
|
|
300
|
-
// never mistaken for one drained from the page that holds it.
|
|
301
314
|
frameId: `frame-${Math.random().toString(36).slice(2, 10)}`,
|
|
302
315
|
frameName: window.name || null,
|
|
303
316
|
mainFrame: window.top === window,
|
|
@@ -317,7 +330,11 @@ export function installMutationRecorderInPage(options) {
|
|
|
317
330
|
}
|
|
318
331
|
});
|
|
319
332
|
};
|
|
320
|
-
const isSecret = (node) =>
|
|
333
|
+
const isSecret = (node) =>
|
|
334
|
+
matchesAny(node, redactSelectors) ||
|
|
335
|
+
(useDefaults &&
|
|
336
|
+
node?.localName === 'meta' &&
|
|
337
|
+
/token|csrf|xsrf|secret|password/i.test(node.getAttribute('name') ?? ''));
|
|
321
338
|
const isIgnored = (node) => matchesAny(node, ignoreSelectors);
|
|
322
339
|
const sanitized = (node) => {
|
|
323
340
|
const clone = node.cloneNode(true);
|
|
@@ -334,6 +351,20 @@ export function installMutationRecorderInPage(options) {
|
|
|
334
351
|
}
|
|
335
352
|
}
|
|
336
353
|
}
|
|
354
|
+
for (const element of [clone, ...clone.querySelectorAll('*')]) {
|
|
355
|
+
for (const attribute of redactAttributes) {
|
|
356
|
+
if (element.hasAttribute(attribute)) {
|
|
357
|
+
element.setAttribute(attribute, redacted);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
useDefaults &&
|
|
362
|
+
element.localName === 'meta' &&
|
|
363
|
+
/token|csrf|xsrf|secret/i.test(element.getAttribute('name') || '')
|
|
364
|
+
) {
|
|
365
|
+
element.setAttribute('content', redacted);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
337
368
|
return clone;
|
|
338
369
|
};
|
|
339
370
|
|
|
@@ -346,11 +377,9 @@ export function installMutationRecorderInPage(options) {
|
|
|
346
377
|
steps.unshift(`#${at.id}`);
|
|
347
378
|
break;
|
|
348
379
|
}
|
|
349
|
-
const siblings = at.parentElement
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
)
|
|
353
|
-
: [];
|
|
380
|
+
const siblings = [...(at.parentElement?.children ?? [])].filter(
|
|
381
|
+
(c) => c.localName === at.localName
|
|
382
|
+
);
|
|
354
383
|
const nth =
|
|
355
384
|
siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(at) + 1})` : '';
|
|
356
385
|
steps.unshift(at.localName + nth);
|
|
@@ -399,6 +428,23 @@ export function installMutationRecorderInPage(options) {
|
|
|
399
428
|
};
|
|
400
429
|
};
|
|
401
430
|
|
|
431
|
+
// Mutation targets identify an existing node. Only additions need markup.
|
|
432
|
+
const identify = (node) => {
|
|
433
|
+
if (!node || isIgnored(node)) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
const element = node.nodeType === 1 ? node : node.parentElement;
|
|
437
|
+
return {
|
|
438
|
+
type: node.nodeType === 3 ? 'text' : 'element',
|
|
439
|
+
path: pathOf(element),
|
|
440
|
+
tag: element?.localName,
|
|
441
|
+
id: element?.id || null,
|
|
442
|
+
visible:
|
|
443
|
+
Boolean(element?.getClientRects().length) &&
|
|
444
|
+
window.getComputedStyle(element).visibility !== 'hidden',
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
|
|
402
448
|
/**
|
|
403
449
|
* Describe a node that is no longer where it was.
|
|
404
450
|
*
|
|
@@ -458,13 +504,15 @@ export function installMutationRecorderInPage(options) {
|
|
|
458
504
|
.map((record) => {
|
|
459
505
|
const entry = {
|
|
460
506
|
kind: record.type,
|
|
461
|
-
target:
|
|
507
|
+
target: identify(record.target),
|
|
462
508
|
};
|
|
463
509
|
if (record.type === 'attributes') {
|
|
464
510
|
entry.attribute = record.attributeName;
|
|
465
511
|
const secret =
|
|
466
512
|
isSecret(record.target) ||
|
|
467
|
-
|
|
513
|
+
redactAttributes.includes(record.attributeName) ||
|
|
514
|
+
(useDefaults &&
|
|
515
|
+
/token|csrf|xsrf|secret|password/i.test(record.attributeName));
|
|
468
516
|
entry.before = secret ? redacted : record.oldValue;
|
|
469
517
|
entry.after =
|
|
470
518
|
secret || !record.target.getAttribute
|
|
@@ -485,8 +533,8 @@ export function installMutationRecorderInPage(options) {
|
|
|
485
533
|
entry.removed = [...record.removedNodes]
|
|
486
534
|
.filter((node) => !isIgnored(node))
|
|
487
535
|
.map((node) => describeAt(node, record));
|
|
488
|
-
entry.previous =
|
|
489
|
-
entry.next =
|
|
536
|
+
entry.previous = identify(record.previousSibling);
|
|
537
|
+
entry.next = identify(record.nextSibling);
|
|
490
538
|
}
|
|
491
539
|
return entry;
|
|
492
540
|
}),
|
|
@@ -511,7 +559,6 @@ export function installMutationRecorderInPage(options) {
|
|
|
511
559
|
// into a field and then leaving it fires `input` for each keystroke and
|
|
512
560
|
// `change` for the value it ended at, and a checkbox fires both for the
|
|
513
561
|
// one click. Replaying a state that is already on screen shows nothing, so
|
|
514
|
-
// a repeat of what this element last reported is not recorded.
|
|
515
562
|
const repeats = (element, property, after) => {
|
|
516
563
|
const seen = lastLive.get(element) || {};
|
|
517
564
|
const asText = JSON.stringify(after === undefined ? null : after);
|
|
@@ -526,16 +573,13 @@ export function installMutationRecorderInPage(options) {
|
|
|
526
573
|
const liveEntry = (target, property, after, before) => ({
|
|
527
574
|
kind: 'live-state',
|
|
528
575
|
property,
|
|
529
|
-
target:
|
|
576
|
+
target: identify(target),
|
|
530
577
|
before: before === undefined ? null : before,
|
|
531
578
|
after,
|
|
532
579
|
});
|
|
533
580
|
|
|
534
581
|
const pushLive = (entry) => {
|
|
535
|
-
//
|
|
536
|
-
// replaying, so consecutive scrolls of one element collapse into the
|
|
537
|
-
// position it ended at. Every other property keeps each step, because
|
|
538
|
-
// "stepwise replay must show each state" (issue #93).
|
|
582
|
+
// Consecutive scrolls collapse; other state changes retain each step.
|
|
539
583
|
const last = state.queue[state.queue.length - 1];
|
|
540
584
|
const only = last && last.records.length === 1 ? last.records[0] : null;
|
|
541
585
|
if (
|
|
@@ -608,7 +652,7 @@ export function installMutationRecorderInPage(options) {
|
|
|
608
652
|
pushLive({
|
|
609
653
|
kind: 'live-state',
|
|
610
654
|
property: 'scroll',
|
|
611
|
-
target: scrolling ?
|
|
655
|
+
target: scrolling ? identify(scrolling) : null,
|
|
612
656
|
before: null,
|
|
613
657
|
after: scrolling
|
|
614
658
|
? { top: scrolling.scrollTop, left: scrolling.scrollLeft }
|
package/src/traces/recorder.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { abortTraceStart } from './start-cleanup.js';
|
|
2
2
|
import { createTraceStop } from './stop.js';
|
|
3
|
+
import { openRollingBundle } from './rolling-bundle.js';
|
|
3
4
|
/**
|
|
4
5
|
* The trace recorder (issue #87).
|
|
5
6
|
*
|
|
@@ -166,13 +167,13 @@ export async function startTrace(options = {}) {
|
|
|
166
167
|
// readable export of everything that had happened. The bundle stays
|
|
167
168
|
// authoritative: nothing is written here that is not written there first.
|
|
168
169
|
let linksSink = null;
|
|
169
|
-
const bundle = await
|
|
170
|
+
const bundle = await openRecorderBundle(options, {
|
|
170
171
|
output,
|
|
171
172
|
limits,
|
|
172
173
|
strict,
|
|
173
174
|
now,
|
|
174
175
|
monotonic,
|
|
175
|
-
onEvent: (event) => linksSink?.event(event),
|
|
176
|
+
onEvent: (event, source) => linksSink?.event(event, source),
|
|
176
177
|
});
|
|
177
178
|
const startedAt = new Date(now()).toISOString();
|
|
178
179
|
|
|
@@ -297,7 +298,13 @@ export async function startTrace(options = {}) {
|
|
|
297
298
|
identity,
|
|
298
299
|
now,
|
|
299
300
|
});
|
|
300
|
-
detachNetwork = attachNetwork({
|
|
301
|
+
detachNetwork = attachNetwork({
|
|
302
|
+
page,
|
|
303
|
+
network,
|
|
304
|
+
record,
|
|
305
|
+
note,
|
|
306
|
+
privacy: privacyOptions,
|
|
307
|
+
});
|
|
301
308
|
video = options.video
|
|
302
309
|
? await startRecording({
|
|
303
310
|
page,
|
|
@@ -405,6 +412,12 @@ export async function startTrace(options = {}) {
|
|
|
405
412
|
|
|
406
413
|
return {
|
|
407
414
|
path: bundle.root,
|
|
415
|
+
get currentPath() {
|
|
416
|
+
return bundle.currentPath ?? bundle.root;
|
|
417
|
+
},
|
|
418
|
+
get segments() {
|
|
419
|
+
return bundle.segments;
|
|
420
|
+
},
|
|
408
421
|
links: linksSink?.path ?? null,
|
|
409
422
|
mode: recorderMode,
|
|
410
423
|
get bytesWritten() {
|
|
@@ -449,3 +462,20 @@ function requireTraceSupport(options) {
|
|
|
449
462
|
validateNetworkOptions(options.network);
|
|
450
463
|
(options.page ?? options.commander?.page)?.requireFeature?.('tracing');
|
|
451
464
|
}
|
|
465
|
+
|
|
466
|
+
function openRecorderBundle(options, settings) {
|
|
467
|
+
const writer =
|
|
468
|
+
options.openBundle ??
|
|
469
|
+
(options.mode === TRACE_MODE.CONTINUOUS
|
|
470
|
+
? (config) =>
|
|
471
|
+
openRollingBundle(
|
|
472
|
+
config,
|
|
473
|
+
{
|
|
474
|
+
lazy: true,
|
|
475
|
+
maxBytes: settings.limits.maxBundleBytes ?? 256 * 1024 * 1024,
|
|
476
|
+
},
|
|
477
|
+
options
|
|
478
|
+
)
|
|
479
|
+
: openTraceBundle);
|
|
480
|
+
return writer(settings);
|
|
481
|
+
}
|
package/src/traces/redaction.js
CHANGED
|
@@ -13,6 +13,7 @@ export const REDACTED = '[redacted]';
|
|
|
13
13
|
/** Controls whose value is a secret unless the caller says otherwise. */
|
|
14
14
|
export const DEFAULT_REDACT_SELECTORS = Object.freeze([
|
|
15
15
|
'input[type=password]',
|
|
16
|
+
'input[type=hidden]',
|
|
16
17
|
'[data-private]',
|
|
17
18
|
'[data-bc-redact]',
|
|
18
19
|
]);
|
|
@@ -42,8 +43,74 @@ export const DEFAULT_REDACT_QUERY_PARAMS = Object.freeze([
|
|
|
42
43
|
'session',
|
|
43
44
|
'signature',
|
|
44
45
|
'token',
|
|
46
|
+
'csrf',
|
|
47
|
+
'xsrf',
|
|
48
|
+
'_xsrf',
|
|
45
49
|
]);
|
|
46
50
|
|
|
51
|
+
/** Match names rather than values, including framework-specific token names. */
|
|
52
|
+
export function sensitiveName(name, privacy) {
|
|
53
|
+
return (
|
|
54
|
+
Boolean(privacy.useDefaults) &&
|
|
55
|
+
/password|passwd|token|csrf|xsrf|otp|secret|api[_-]?key|authorization/i.test(
|
|
56
|
+
name
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function redactFields(value, privacy) {
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return value.map((entry) => redactFields(entry, privacy));
|
|
64
|
+
}
|
|
65
|
+
if (value && typeof value === 'object') {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(value).map(([key, entry]) => [
|
|
68
|
+
key,
|
|
69
|
+
sensitiveName(key, privacy) ? REDACTED : redactFields(entry, privacy),
|
|
70
|
+
])
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function redactStructuredText(value, privacy) {
|
|
77
|
+
if (!privacy.useDefaults) {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(value);
|
|
82
|
+
if (parsed && typeof parsed === 'object') {
|
|
83
|
+
return JSON.stringify(redactFields(parsed, privacy));
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* HTML, forms and script assignments are handled below. */
|
|
87
|
+
}
|
|
88
|
+
return value
|
|
89
|
+
.replace(
|
|
90
|
+
/(\b(?:["']?[\w-]+["']?)\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\d+(?:\.\d+)?)/g,
|
|
91
|
+
(match, prefix) =>
|
|
92
|
+
sensitiveName(prefix, privacy)
|
|
93
|
+
? `${prefix}${JSON.stringify(REDACTED)}`
|
|
94
|
+
: match
|
|
95
|
+
)
|
|
96
|
+
.replace(/(^|[&?])([^=&\s]+)=([^&\s]*)/g, (match, start, key) => {
|
|
97
|
+
let name = key;
|
|
98
|
+
try {
|
|
99
|
+
name = decodeURIComponent(key.replaceAll('+', ' '));
|
|
100
|
+
} catch {
|
|
101
|
+
/* Keep malformed names. */
|
|
102
|
+
}
|
|
103
|
+
return sensitiveName(name, privacy)
|
|
104
|
+
? `${start}${key}=${REDACTED}`
|
|
105
|
+
: match;
|
|
106
|
+
})
|
|
107
|
+
.replace(
|
|
108
|
+
/(Content-Disposition:[^\r\n]*?;\s*name="([^"]+)"[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n)([\s\S]*?)(?=\r?\n--)/gi,
|
|
109
|
+
(match, header, name) =>
|
|
110
|
+
sensitiveName(name, privacy) ? `${header}${REDACTED}` : match
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
47
114
|
function lowerSet(values) {
|
|
48
115
|
return [...new Set(values.map((value) => String(value).toLowerCase()))];
|
|
49
116
|
}
|
|
@@ -75,6 +142,7 @@ export function normalizePrivacyOptions(privacy = {}) {
|
|
|
75
142
|
}
|
|
76
143
|
|
|
77
144
|
return {
|
|
145
|
+
useDefaults,
|
|
78
146
|
redactSelectors: lowerSet([
|
|
79
147
|
...(useDefaults ? DEFAULT_REDACT_SELECTORS : []),
|
|
80
148
|
...redactSelectors,
|
|
@@ -107,7 +175,8 @@ export function redactText(value, privacy, context = {}) {
|
|
|
107
175
|
return value;
|
|
108
176
|
}
|
|
109
177
|
|
|
110
|
-
let text =
|
|
178
|
+
let text =
|
|
179
|
+
context.kind === 'url' ? value : redactStructuredText(value, privacy);
|
|
111
180
|
for (const pattern of privacy.redactPatterns || []) {
|
|
112
181
|
// A caller's regex may be stateful; resetting keeps one trace's output
|
|
113
182
|
// from depending on the previous record's match position.
|
|
@@ -148,7 +217,10 @@ export function redactUrl(url, privacy) {
|
|
|
148
217
|
parsed.password = parsed.password ? REDACTED : '';
|
|
149
218
|
}
|
|
150
219
|
for (const key of [...parsed.searchParams.keys()]) {
|
|
151
|
-
if (
|
|
220
|
+
if (
|
|
221
|
+
privacy.redactQueryParams.includes(key.toLowerCase()) ||
|
|
222
|
+
sensitiveName(key, privacy)
|
|
223
|
+
) {
|
|
152
224
|
parsed.searchParams.set(key, REDACTED);
|
|
153
225
|
}
|
|
154
226
|
}
|
|
@@ -157,7 +229,10 @@ export function redactUrl(url, privacy) {
|
|
|
157
229
|
const fragment = new URLSearchParams(parsed.hash.slice(1));
|
|
158
230
|
let changed = false;
|
|
159
231
|
for (const key of [...fragment.keys()]) {
|
|
160
|
-
if (
|
|
232
|
+
if (
|
|
233
|
+
privacy.redactQueryParams.includes(key.toLowerCase()) ||
|
|
234
|
+
sensitiveName(key, privacy)
|
|
235
|
+
) {
|
|
161
236
|
fragment.set(key, REDACTED);
|
|
162
237
|
changed = true;
|
|
163
238
|
}
|
|
@@ -190,9 +265,11 @@ export function redactHeaders(headers, privacy) {
|
|
|
190
265
|
|
|
191
266
|
const result = {};
|
|
192
267
|
for (const [name, value] of Object.entries(headers)) {
|
|
193
|
-
result[name] =
|
|
194
|
-
|
|
195
|
-
|
|
268
|
+
result[name] =
|
|
269
|
+
privacy.redactAttributes.includes(name.toLowerCase()) ||
|
|
270
|
+
sensitiveName(name, privacy)
|
|
271
|
+
? REDACTED
|
|
272
|
+
: redactText(String(value), privacy, { kind: 'header', name });
|
|
196
273
|
}
|
|
197
274
|
return result;
|
|
198
275
|
}
|
|
@@ -209,6 +286,12 @@ export function redactHeaders(headers, privacy) {
|
|
|
209
286
|
* @returns {*} The value with strings redacted
|
|
210
287
|
*/
|
|
211
288
|
export function redactValue(value, privacy, key = '') {
|
|
289
|
+
if (
|
|
290
|
+
sensitiveName(key, privacy) ||
|
|
291
|
+
privacy.redactAttributes.includes(key.toLowerCase())
|
|
292
|
+
) {
|
|
293
|
+
return REDACTED;
|
|
294
|
+
}
|
|
212
295
|
if (typeof value === 'string') {
|
|
213
296
|
if (privacy.redactAttributes.includes(key.toLowerCase())) {
|
|
214
297
|
return REDACTED;
|