browser-commander 0.28.0 → 0.30.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 +7 -20
- package/CHANGELOG.md +12 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/scripts/run-tests.mjs +3 -1
- package/src/browser/browser-profile-files.js +5 -2
- package/src/browser/connector.js +140 -65
- package/src/browser/launcher.js +19 -8
- 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 +33 -25
- 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/commander-actions.js +146 -0
- package/src/traces/debug-session.js +119 -0
- package/src/traces/drain-timer.js +25 -0
- package/src/traces/links.js +44 -5
- package/src/traces/mutation-stream.js +12 -5
- package/src/traces/network.js +65 -13
- package/src/traces/observers.js +50 -46
- package/src/traces/page-capture.js +65 -21
- package/src/traces/recorder.js +79 -28
- 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/src/traces/user-actions.js +405 -0
- package/tests/e2e/issue-160.e2e.test.js +291 -0
- package/tests/e2e/issue-162-recording.e2e.test.js +175 -0
- package/tests/e2e/traces.e2e.test.js +4 -1
- 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 +4 -4
- 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 +4 -4
- package/tests/fixtures/traces/conformance/scenario.json +1 -0
- package/tests/helpers/e2e-browser.js +2 -1
- 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/issue-162-defaults.test.js +223 -0
- package/tests/unit/traces/issue-162-user-actions.test.js +181 -0
- package/tests/unit/traces/network.test.js +55 -3
- package/tests/unit/traces/recorder.test.js +4 -1
|
@@ -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
|
*
|
|
@@ -15,10 +16,13 @@ import { openTraceLinks } from './links.js';
|
|
|
15
16
|
import { createTraceIdentity } from './identity.js';
|
|
16
17
|
import { withDeadline } from './deadline.js';
|
|
17
18
|
import { createMutationStream } from './mutation-stream.js';
|
|
18
|
-
import {
|
|
19
|
+
import { createUserActionStream } from './user-actions.js';
|
|
20
|
+
import { startTraceDrain } from './drain-timer.js';
|
|
21
|
+
import { attachRecorderObservers } from './observers.js';
|
|
19
22
|
import { attachNetwork, validateNetworkOptions } from './network.js';
|
|
20
23
|
import { startRecording } from '../capture/recording.js';
|
|
21
24
|
import { normalizePrivacyOptions, redactValue } from './redaction.js';
|
|
25
|
+
import path from 'node:path';
|
|
22
26
|
import {
|
|
23
27
|
DEFAULT_CAPTURE_TIMEOUT,
|
|
24
28
|
TRACE_CHECKPOINT_REASON,
|
|
@@ -93,13 +97,19 @@ function normalizeTraceOptions(options) {
|
|
|
93
97
|
return {
|
|
94
98
|
...options,
|
|
95
99
|
page: options.page ?? options.commander?.page,
|
|
96
|
-
mode: options.mode ?? TRACE_MODE.
|
|
100
|
+
mode: options.mode ?? TRACE_MODE.CONTINUOUS,
|
|
97
101
|
screenshots: options.screenshots ?? 'checkpoints',
|
|
98
102
|
dom: options.dom ?? {},
|
|
99
103
|
privacy: options.privacy ?? {},
|
|
100
104
|
limits: options.limits ?? {},
|
|
101
|
-
links:
|
|
102
|
-
|
|
105
|
+
links:
|
|
106
|
+
options.links === false || options.links === null
|
|
107
|
+
? null
|
|
108
|
+
: (options.links ?? {
|
|
109
|
+
output: path.join(options.output ?? '', 'trace.lino'),
|
|
110
|
+
dom: 'full',
|
|
111
|
+
}),
|
|
112
|
+
network: options.network ?? { har: true },
|
|
103
113
|
strict: options.strict ?? false,
|
|
104
114
|
captureTimeoutMs: options.captureTimeoutMs ?? DEFAULT_CAPTURE_TIMEOUT,
|
|
105
115
|
commanderVersion: options.commanderVersion ?? null,
|
|
@@ -109,6 +119,7 @@ function normalizeTraceOptions(options) {
|
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
export async function startTrace(options = {}) {
|
|
122
|
+
options = normalizeTraceOptions(options);
|
|
112
123
|
if (options.limits?.rotate) {
|
|
113
124
|
const { startRollingTrace } = await import('./rolling.js');
|
|
114
125
|
return startRollingTrace(options, startTrace);
|
|
@@ -150,8 +161,8 @@ export async function startTrace(options = {}) {
|
|
|
150
161
|
// Otherwise a rejected start (for example `links: {}`) strands the bundle's
|
|
151
162
|
// events handle because no running trace is returned for the caller to stop.
|
|
152
163
|
validateLinksOptions(links);
|
|
153
|
-
const engine = commander?.engine ?? null
|
|
154
|
-
|
|
164
|
+
const engine = commander?.engine ?? null,
|
|
165
|
+
identity = createTraceIdentity({ page });
|
|
155
166
|
|
|
156
167
|
// A continuous trace records changes, and a change is only meaningful against
|
|
157
168
|
// something. Taking the base snapshot automatically is the safe default,
|
|
@@ -166,13 +177,13 @@ export async function startTrace(options = {}) {
|
|
|
166
177
|
// readable export of everything that had happened. The bundle stays
|
|
167
178
|
// authoritative: nothing is written here that is not written there first.
|
|
168
179
|
let linksSink = null;
|
|
169
|
-
const bundle = await
|
|
180
|
+
const bundle = await openRecorderBundle(options, {
|
|
170
181
|
output,
|
|
171
182
|
limits,
|
|
172
183
|
strict,
|
|
173
184
|
now,
|
|
174
185
|
monotonic,
|
|
175
|
-
onEvent: (event) => linksSink?.event(event),
|
|
186
|
+
onEvent: (event, source) => linksSink?.event(event, source),
|
|
176
187
|
});
|
|
177
188
|
const startedAt = new Date(now()).toISOString();
|
|
178
189
|
|
|
@@ -242,6 +253,18 @@ export async function startTrace(options = {}) {
|
|
|
242
253
|
captureTimeoutMs,
|
|
243
254
|
evaluateInPage,
|
|
244
255
|
});
|
|
256
|
+
const userActions = createUserActionStream({
|
|
257
|
+
page,
|
|
258
|
+
record,
|
|
259
|
+
identity,
|
|
260
|
+
note,
|
|
261
|
+
privacyOptions,
|
|
262
|
+
limits,
|
|
263
|
+
enabled: eventSources.includes('interaction'),
|
|
264
|
+
ignoreSelectors: domOptions.ignoreSelectors,
|
|
265
|
+
evaluateInPage,
|
|
266
|
+
captureTimeoutMs,
|
|
267
|
+
});
|
|
245
268
|
|
|
246
269
|
async function screenshot(reason) {
|
|
247
270
|
const wanted =
|
|
@@ -288,7 +311,7 @@ export async function startTrace(options = {}) {
|
|
|
288
311
|
detachNetwork = async () => {},
|
|
289
312
|
video = null;
|
|
290
313
|
try {
|
|
291
|
-
|
|
314
|
+
const observers = attachRecorderObservers({
|
|
292
315
|
commander,
|
|
293
316
|
page,
|
|
294
317
|
eventSources,
|
|
@@ -297,7 +320,14 @@ export async function startTrace(options = {}) {
|
|
|
297
320
|
identity,
|
|
298
321
|
now,
|
|
299
322
|
});
|
|
300
|
-
|
|
323
|
+
detachers = observers.detachers;
|
|
324
|
+
detachNetwork = attachNetwork({
|
|
325
|
+
page,
|
|
326
|
+
network,
|
|
327
|
+
record,
|
|
328
|
+
note,
|
|
329
|
+
privacy: privacyOptions,
|
|
330
|
+
});
|
|
301
331
|
video = options.video
|
|
302
332
|
? await startRecording({
|
|
303
333
|
page,
|
|
@@ -337,7 +367,11 @@ export async function startTrace(options = {}) {
|
|
|
337
367
|
events: eventSources,
|
|
338
368
|
initialCheckpoint: Boolean(baseCheckpoint),
|
|
339
369
|
});
|
|
340
|
-
await mutations.install();
|
|
370
|
+
await Promise.all([mutations.install(), userActions.install()]);
|
|
371
|
+
detachers.push(async () => {
|
|
372
|
+
await userActions.drain();
|
|
373
|
+
await userActions.stop();
|
|
374
|
+
});
|
|
341
375
|
|
|
342
376
|
if (baseCheckpoint) {
|
|
343
377
|
await checkpoint(
|
|
@@ -349,23 +383,10 @@ export async function startTrace(options = {}) {
|
|
|
349
383
|
);
|
|
350
384
|
}
|
|
351
385
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
drainTask = mutations
|
|
357
|
-
.drain(captureState.index)
|
|
358
|
-
.catch((error) => note(error.message))
|
|
359
|
-
.finally(() => {
|
|
360
|
-
drainTask = null;
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
}, 500);
|
|
364
|
-
timer.unref?.();
|
|
365
|
-
detachers.push(async () => {
|
|
366
|
-
clearInterval(timer);
|
|
367
|
-
await drainTask;
|
|
368
|
-
});
|
|
386
|
+
if (domOptions.mutations || eventSources.includes('interaction')) {
|
|
387
|
+
detachers.push(
|
|
388
|
+
startTraceDrain({ mutations, userActions, captureState, note })
|
|
389
|
+
);
|
|
369
390
|
}
|
|
370
391
|
|
|
371
392
|
/**
|
|
@@ -405,12 +426,19 @@ export async function startTrace(options = {}) {
|
|
|
405
426
|
|
|
406
427
|
return {
|
|
407
428
|
path: bundle.root,
|
|
429
|
+
get currentPath() {
|
|
430
|
+
return bundle.currentPath ?? bundle.root;
|
|
431
|
+
},
|
|
432
|
+
get segments() {
|
|
433
|
+
return bundle.segments;
|
|
434
|
+
},
|
|
408
435
|
links: linksSink?.path ?? null,
|
|
409
436
|
mode: recorderMode,
|
|
410
437
|
get bytesWritten() {
|
|
411
438
|
return bundle.bytesWritten;
|
|
412
439
|
},
|
|
413
440
|
checkpoint,
|
|
441
|
+
attachCommander: observers.attachCommander,
|
|
414
442
|
/**
|
|
415
443
|
* Record an event a caller cares about on the same timeline.
|
|
416
444
|
*
|
|
@@ -447,5 +475,28 @@ export async function startTrace(options = {}) {
|
|
|
447
475
|
|
|
448
476
|
function requireTraceSupport(options) {
|
|
449
477
|
validateNetworkOptions(options.network);
|
|
478
|
+
const maxQueued = options.limits.maxQueuedUserActions ?? 5000;
|
|
479
|
+
if (!Number.isSafeInteger(maxQueued) || maxQueued < 1) {
|
|
480
|
+
throw new RangeError(
|
|
481
|
+
'maxQueuedUserActions must be a positive safe integer'
|
|
482
|
+
);
|
|
483
|
+
}
|
|
450
484
|
(options.page ?? options.commander?.page)?.requireFeature?.('tracing');
|
|
451
485
|
}
|
|
486
|
+
|
|
487
|
+
function openRecorderBundle(options, settings) {
|
|
488
|
+
const writer =
|
|
489
|
+
options.openBundle ??
|
|
490
|
+
(options.mode === TRACE_MODE.CONTINUOUS
|
|
491
|
+
? (config) =>
|
|
492
|
+
openRollingBundle(
|
|
493
|
+
config,
|
|
494
|
+
{
|
|
495
|
+
lazy: true,
|
|
496
|
+
maxBytes: settings.limits.maxBundleBytes ?? 256 * 1024 * 1024,
|
|
497
|
+
},
|
|
498
|
+
options
|
|
499
|
+
)
|
|
500
|
+
: openTraceBundle);
|
|
501
|
+
return writer(settings);
|
|
502
|
+
}
|
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;
|