browser-debugger-cli 0.15.0 → 0.16.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.
Files changed (133) hide show
  1. package/.claude/skills/bdg/SKILL.md +2 -1
  2. package/dist/cdp/methodTarget.d.ts +92 -0
  3. package/dist/cdp/methodTarget.js +159 -0
  4. package/dist/cdp/protocol.d.ts +16 -1
  5. package/dist/cdp/protocol.js +21 -0
  6. package/dist/cdp/schema.d.ts +55 -1
  7. package/dist/cdp/schema.js +134 -25
  8. package/dist/cdp/types.d.ts +3 -1
  9. package/dist/commands/cdp.d.ts +38 -1
  10. package/dist/commands/cdp.js +200 -133
  11. package/dist/commands/cleanup.js +18 -4
  12. package/dist/commands/dom/formInteraction.js +8 -4
  13. package/dist/commands/dom/helpers/index.d.ts +4 -4
  14. package/dist/commands/dom/helpers/index.js +3 -3
  15. package/dist/commands/dom/helpers/query.d.ts +2 -2
  16. package/dist/commands/dom/helpers/query.js +2 -2
  17. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  18. package/dist/commands/dom/helpers/screenshot.js +50 -668
  19. package/dist/commands/dom/screenshot.js +56 -36
  20. package/dist/commands/optionBehaviors.js +18 -8
  21. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  22. package/dist/commands/shared/CommandRunner.js +18 -3
  23. package/dist/commands/shared/interrupt.d.ts +40 -0
  24. package/dist/commands/shared/interrupt.js +73 -0
  25. package/dist/commands/shared/optionTypes.d.ts +2 -0
  26. package/dist/commands/shared/startHelpers.d.ts +26 -3
  27. package/dist/commands/shared/startHelpers.js +145 -23
  28. package/dist/commands/types.d.ts +5 -0
  29. package/dist/connection/cdp.js +1 -16
  30. package/dist/connection/chromeIdentity.d.ts +24 -5
  31. package/dist/connection/chromeIdentity.js +53 -22
  32. package/dist/connection/launcher.d.ts +34 -1
  33. package/dist/connection/launcher.js +98 -10
  34. package/dist/connection/typed-cdp.d.ts +3 -2
  35. package/dist/constants.d.ts +1 -1
  36. package/dist/constants.js +1 -1
  37. package/dist/daemon/SessionController.d.ts +10 -5
  38. package/dist/daemon/SessionController.js +15 -8
  39. package/dist/daemon/ipcServer.js +1 -1
  40. package/dist/daemon/launcher.d.ts +5 -0
  41. package/dist/daemon/launcher.js +8 -1
  42. package/dist/daemon/session/Session.d.ts +5 -1
  43. package/dist/daemon/session/Session.js +9 -8
  44. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  45. package/dist/daemon/session/TelemetryStore.js +4 -0
  46. package/dist/daemon/session/captureGate.d.ts +59 -0
  47. package/dist/daemon/session/captureGate.js +96 -0
  48. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  49. package/dist/daemon/session/chromeConnection.js +34 -4
  50. package/dist/daemon/session/collectors.d.ts +15 -0
  51. package/dist/daemon/session/collectors.js +39 -2
  52. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  53. package/dist/daemon/session/commandRegistry.js +46 -11
  54. package/dist/daemon/session/downloads.d.ts +32 -0
  55. package/dist/daemon/session/downloads.js +96 -0
  56. package/dist/daemon/session/interactions.d.ts +3 -2
  57. package/dist/daemon/session/interactions.js +7 -2
  58. package/dist/daemon/session/plugins.js +6 -0
  59. package/dist/daemon.js +12843 -11482
  60. package/dist/errors/CommandError.d.ts +2 -0
  61. package/dist/errors/issues.d.ts +1 -1
  62. package/dist/errors/messages.d.ts +58 -0
  63. package/dist/errors/messages.js +112 -0
  64. package/dist/index.js +999 -1020
  65. package/dist/ipc/client.d.ts +14 -1
  66. package/dist/ipc/client.js +21 -4
  67. package/dist/ipc/protocol/commands.d.ts +32 -2
  68. package/dist/ipc/protocol/commands.js +1 -0
  69. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  70. package/dist/ipc/session/queries.d.ts +3 -0
  71. package/dist/ipc/session/types.d.ts +5 -0
  72. package/dist/ipc/transport/IPCError.d.ts +9 -0
  73. package/dist/ipc/transport/IPCError.js +12 -0
  74. package/dist/ipc/transport/errors.d.ts +2 -1
  75. package/dist/ipc/transport/errors.js +4 -1
  76. package/dist/ipc/transport/index.d.ts +4 -2
  77. package/dist/ipc/transport/index.js +13 -3
  78. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  79. package/dist/runtime/dom/actionEffects.js +269 -34
  80. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  81. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  82. package/dist/runtime/dom/captureArea.d.ts +35 -0
  83. package/dist/runtime/dom/captureArea.js +203 -0
  84. package/dist/runtime/dom/elementInfo.d.ts +10 -8
  85. package/dist/runtime/dom/elementInfo.js +8 -6
  86. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  87. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  88. package/dist/runtime/page/bdgWorld.js +11 -0
  89. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  90. package/dist/runtime/page/captureEmulation.js +189 -0
  91. package/dist/runtime/page/captureScroll.d.ts +24 -0
  92. package/dist/runtime/page/captureScroll.js +124 -0
  93. package/dist/runtime/page/screenshot.d.ts +41 -0
  94. package/dist/runtime/page/screenshot.js +394 -0
  95. package/dist/session/paths.d.ts +14 -0
  96. package/dist/session/paths.js +25 -0
  97. package/dist/telemetry/downloads.d.ts +127 -0
  98. package/dist/telemetry/downloads.js +265 -0
  99. package/dist/telemetry/har/builder.js +22 -7
  100. package/dist/telemetry/har/sanitize.d.ts +7 -3
  101. package/dist/telemetry/har/sanitize.js +52 -6
  102. package/dist/telemetry/har/sanitizeBody.d.ts +47 -7
  103. package/dist/telemetry/har/sanitizeBody.js +429 -56
  104. package/dist/telemetry/har/types.d.ts +2 -0
  105. package/dist/telemetry/network.d.ts +4 -4
  106. package/dist/telemetry/network.js +38 -4
  107. package/dist/telemetry/networkRetention.d.ts +35 -14
  108. package/dist/telemetry/networkRetention.js +62 -26
  109. package/dist/types.d.ts +9 -14
  110. package/dist/ui/OutputBuilder.d.ts +3 -2
  111. package/dist/ui/OutputBuilder.js +4 -3
  112. package/dist/ui/formatters/cdp.d.ts +32 -9
  113. package/dist/ui/formatters/cdp.js +77 -6
  114. package/dist/ui/formatters/details.js +7 -15
  115. package/dist/ui/formatters/preview.d.ts +2 -0
  116. package/dist/ui/formatters/preview.js +7 -1
  117. package/dist/ui/formatters/status.js +6 -1
  118. package/dist/ui/formatting.d.ts +7 -0
  119. package/dist/ui/formatting.js +13 -0
  120. package/dist/ui/logging/logger.d.ts +1 -1
  121. package/dist/ui/messages/chrome.d.ts +13 -0
  122. package/dist/ui/messages/chrome.js +26 -0
  123. package/dist/ui/messages/commands.d.ts +71 -3
  124. package/dist/ui/messages/commands.js +98 -3
  125. package/dist/ui/messages/networkMessages.d.ts +24 -5
  126. package/dist/ui/messages/networkMessages.js +31 -8
  127. package/dist/utils/async.d.ts +3 -2
  128. package/dist/utils/async.js +16 -3
  129. package/dist/utils/http.d.ts +11 -4
  130. package/dist/utils/http.js +5 -3
  131. package/package.json +18 -4
  132. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  133. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Download tracking: where a session's downloads go, and what became of them.
3
+ */
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import { createLogger } from '../ui/logging/index.js';
7
+ import { downloadsNotRedirectedWarning } from '../ui/messages/commands.js';
8
+ import { getErrorMessage } from '../utils/errors.js';
9
+ const log = createLogger('downloads');
10
+ /** Name given to a download whose suggested name is not a usable file name */
11
+ const FALLBACK_FILE_NAME = 'download';
12
+ /**
13
+ * Track the session's downloads.
14
+ *
15
+ * In a directory, Chrome saves each download under its id (`allowAndName`),
16
+ * and the file is renamed to the suggested name once complete; the name is
17
+ * chosen when the download begins (`report (1).txt` when `report.txt` exists
18
+ * or was chosen for another), so a download still running already reports
19
+ * where it will be. In the browser's place, its own download settings stay
20
+ * and only its events are enabled. Refused downloads are canceled by Chrome
21
+ * and recorded with the reason.
22
+ *
23
+ * Chrome keeps a download behavior only while the connection that set it is
24
+ * open, so {@link attach} applies it again on another connection when the
25
+ * first one is lost. A browser-level connection also receives download
26
+ * events of other tabs (`target=_blank` links, `window.open()`), which do not
27
+ * reach a page's connection.
28
+ */
29
+ export class DownloadTracker {
30
+ record;
31
+ destination;
32
+ reserved = new Set();
33
+ applied = { kind: 'browser' };
34
+ unsubscribe = () => undefined;
35
+ /** Set once stopped: no connection is followed, nor any behavior set, afterwards */
36
+ stopped = false;
37
+ /** Number of the latest attach: an earlier one still running gives way to it */
38
+ latestAttach = 0;
39
+ /** Attaches run one after another */
40
+ attaching = Promise.resolve();
41
+ /**
42
+ * @param record - Session record receiving downloads and the warning
43
+ * @param destination - Where downloads should go
44
+ */
45
+ constructor(record, destination) {
46
+ this.record = record;
47
+ this.destination = destination;
48
+ if (destination.kind === 'refused')
49
+ record.downloadsWarning = destination.reason;
50
+ }
51
+ /**
52
+ * Apply the destination on a connection and follow its download events
53
+ * there (instead of on the previous one). When Chrome refuses it, reports
54
+ * stop claiming bdg's directory (downloads go where the browser puts them)
55
+ * and the record carries a warning.
56
+ *
57
+ * Attaches run one at a time, and the latest wins: one called meanwhile
58
+ * (the browser-level connection lost while it was being set up) makes an
59
+ * earlier one give way without following its connection or warning. After
60
+ * {@link stop}, nothing is followed and no behavior is set.
61
+ *
62
+ * @param cdp - Connection (browser-level when possible)
63
+ * @returns True when Chrome took the destination on this connection
64
+ */
65
+ attach(cdp) {
66
+ const attempt = ++this.latestAttach;
67
+ const run = this.attaching.then(() => this.applyOn(cdp, attempt));
68
+ this.attaching = run.catch(() => undefined);
69
+ return run;
70
+ }
71
+ /** Stop following download events */
72
+ stop() {
73
+ this.stopped = true;
74
+ this.unsubscribe();
75
+ }
76
+ /**
77
+ * Whether an attach was superseded by a later one or by {@link stop}.
78
+ *
79
+ * @param attempt - Number of the attach
80
+ * @returns True when it must give way
81
+ */
82
+ superseded(attempt) {
83
+ return this.stopped || attempt !== this.latestAttach;
84
+ }
85
+ /**
86
+ * Set the behavior on a connection and follow its events, unless the
87
+ * attach was superseded before or while Chrome answered.
88
+ *
89
+ * @param cdp - Connection
90
+ * @param attempt - Number of the attach
91
+ * @returns True when Chrome took the destination and the attach still stands
92
+ */
93
+ async applyOn(cdp, attempt) {
94
+ if (this.superseded(attempt))
95
+ return false;
96
+ this.unsubscribe();
97
+ const error = await setDownloadBehavior(cdp, this.destination);
98
+ if (this.superseded(attempt))
99
+ return false;
100
+ this.applied = error === undefined ? this.destination : { kind: 'browser' };
101
+ if (this.destination.kind === 'directory') {
102
+ this.record.downloadsWarning =
103
+ error === undefined ? undefined : downloadsNotRedirectedWarning(error);
104
+ }
105
+ const handlers = [
106
+ cdp.on('Browser.downloadWillBegin', (event) => this.begin(event)),
107
+ cdp.on('Browser.downloadProgress', (progress) => {
108
+ const download = this.record.downloads.findLast((entry) => entry.guid === progress.guid);
109
+ if (download?.state !== 'inProgress')
110
+ return;
111
+ updateDownload(download, progress, this.applied, this.reserved);
112
+ }),
113
+ ];
114
+ this.unsubscribe = () => handlers.forEach((remove) => remove());
115
+ return error === undefined;
116
+ }
117
+ /**
118
+ * Record a download that began, with the path chosen for it in bdg's directory.
119
+ *
120
+ * @param event - `Browser.downloadWillBegin` parameters
121
+ */
122
+ begin({ guid, url, suggestedFilename }) {
123
+ const dir = this.applied.kind === 'directory' ? this.applied.dir : undefined;
124
+ const target = dir && reserveDownloadPath(dir, suggestedFilename, this.reserved);
125
+ this.record.downloads.push({
126
+ guid,
127
+ url,
128
+ suggestedFilename,
129
+ state: 'inProgress',
130
+ ...(target && { path: target }),
131
+ });
132
+ }
133
+ }
134
+ /**
135
+ * A tracked download as commands report it, as it is now.
136
+ *
137
+ * @param download - Tracked download
138
+ * @returns Copy without Chrome's id
139
+ */
140
+ export function toDownloadInfo(download) {
141
+ const { url, suggestedFilename, path: file, state, bytes, reason } = download;
142
+ return {
143
+ url,
144
+ suggestedFilename,
145
+ ...(file !== undefined && { path: file }),
146
+ state,
147
+ ...(bytes !== undefined && { bytes }),
148
+ ...(reason !== undefined && { reason }),
149
+ };
150
+ }
151
+ /**
152
+ * Set the browser's download behavior for the destination and enable
153
+ * download events.
154
+ *
155
+ * @param cdp - CDP connection
156
+ * @param destination - Where downloads go
157
+ * @returns Why Chrome refused it, or undefined when it took it
158
+ */
159
+ async function setDownloadBehavior(cdp, destination) {
160
+ const behavior = destination.kind === 'directory'
161
+ ? { behavior: 'allowAndName', downloadPath: destination.dir }
162
+ : { behavior: destination.kind === 'refused' ? 'deny' : 'default' };
163
+ try {
164
+ await cdp.send('Browser.setDownloadBehavior', { ...behavior, eventsEnabled: true });
165
+ return undefined;
166
+ }
167
+ catch (error) {
168
+ log.info(`Download behavior not set: ${getErrorMessage(error)}`);
169
+ return getErrorMessage(error);
170
+ }
171
+ }
172
+ /**
173
+ * Apply a progress event: bytes so far, then the final state; a completed
174
+ * download in bdg's directory is renamed from its id to its chosen name.
175
+ *
176
+ * @param download - Download being updated
177
+ * @param progress - Progress event
178
+ * @param destination - Where downloads go
179
+ * @param reserved - Paths chosen for downloads still running
180
+ */
181
+ function updateDownload(download, progress, destination, reserved) {
182
+ download.bytes = progress.receivedBytes;
183
+ if (progress.state === 'inProgress')
184
+ return;
185
+ if (download.path)
186
+ reserved.delete(reservationKey(download.path));
187
+ if (progress.state === 'canceled') {
188
+ delete download.path;
189
+ if (destination.kind === 'refused')
190
+ download.reason = destination.reason;
191
+ }
192
+ else if (destination.kind === 'directory') {
193
+ download.path = saveUnderChosenName(destination.dir, download, reserved);
194
+ }
195
+ else if (progress.filePath) {
196
+ download.path = progress.filePath;
197
+ }
198
+ download.state = progress.state;
199
+ }
200
+ /**
201
+ * Rename a completed download from its id to the name chosen for it (another
202
+ * one when a file took that name meanwhile).
203
+ *
204
+ * @param downloadDir - Directory downloads are saved into
205
+ * @param download - Completed download
206
+ * @param reserved - Paths chosen for downloads still running
207
+ * @returns Path of the file: the chosen one, or its id's when renaming failed
208
+ */
209
+ function saveUnderChosenName(downloadDir, download, reserved) {
210
+ const saved = path.join(downloadDir, download.guid);
211
+ const chosen = download.path && !fs.existsSync(download.path)
212
+ ? download.path
213
+ : reserveDownloadPath(downloadDir, download.suggestedFilename, reserved);
214
+ reserved.delete(reservationKey(chosen));
215
+ try {
216
+ fs.renameSync(saved, chosen);
217
+ return chosen;
218
+ }
219
+ catch (error) {
220
+ log.debug(`Download ${download.guid} kept under its id: ${getErrorMessage(error)}`);
221
+ return saved;
222
+ }
223
+ }
224
+ /**
225
+ * Key of a reserved path: lowercased, so `Report.txt` and `report.txt` do not
226
+ * both get chosen on a case-insensitive file system (macOS, Windows).
227
+ *
228
+ * @param file - Path
229
+ * @returns Key
230
+ */
231
+ function reservationKey(file) {
232
+ return file.toLowerCase();
233
+ }
234
+ /**
235
+ * Choose a free path for a download: its suggested name, or with ` (1)`,
236
+ * ` (2)`… before the extension when a file or another running download has
237
+ * that name.
238
+ *
239
+ * @param downloadDir - Directory downloads are saved into
240
+ * @param suggestedFilename - Name the page or server suggested
241
+ * @param reserved - Paths chosen for downloads still running (the result is added)
242
+ * @returns Absolute path
243
+ */
244
+ export function reserveDownloadPath(downloadDir, suggestedFilename, reserved) {
245
+ const name = safeFileName(suggestedFilename);
246
+ const { name: stem, ext } = path.parse(name);
247
+ for (let copy = 0;; copy++) {
248
+ const candidate = path.join(downloadDir, copy === 0 ? name : `${stem} (${copy})${ext}`);
249
+ if (reserved.has(reservationKey(candidate)) || fs.existsSync(candidate))
250
+ continue;
251
+ reserved.add(reservationKey(candidate));
252
+ return candidate;
253
+ }
254
+ }
255
+ /**
256
+ * A suggested file name reduced to a name in the download directory.
257
+ *
258
+ * @param suggestedFilename - Name the page or server suggested
259
+ * @returns Its last path segment, or {@link FALLBACK_FILE_NAME} when that is empty, `.` or `..`
260
+ */
261
+ function safeFileName(suggestedFilename) {
262
+ const name = path.basename(suggestedFilename.replaceAll('\\', '/'));
263
+ return name === '' || name === '.' || name === '..' ? FALLBACK_FILE_NAME : name;
264
+ }
265
+ //# sourceMappingURL=downloads.js.map
@@ -101,6 +101,7 @@ function buildWebSocketMessage(frame) {
101
101
  time: frame.timestamp / 1000,
102
102
  opcode: frame.opcode,
103
103
  data: frame.payloadData,
104
+ ...(frame.truncatedFrom !== undefined && { _truncatedFrom: frame.truncatedFrom }),
104
105
  };
105
106
  }
106
107
  /**
@@ -119,7 +120,7 @@ function buildRequest(req) {
119
120
  headers: convertHeaders(req.requestHeaders),
120
121
  queryString: extractQueryParams(url),
121
122
  headersSize: estimateRequestHeadersSize(req.method, req.url, req.requestHeaders),
122
- bodySize: req.requestBody ? Buffer.byteLength(req.requestBody, 'utf-8') : 0,
123
+ bodySize: requestBodySize(req),
123
124
  };
124
125
  const postData = buildPostData(req);
125
126
  if (postData) {
@@ -189,7 +190,21 @@ function contentLength(req) {
189
190
  return Number.isInteger(bytes) && bytes >= 0 ? bytes : undefined;
190
191
  }
191
192
  /**
192
- * Build POST data object if request has body.
193
+ * Size of the request body: unknown (-1) when it was evicted at the body budget.
194
+ *
195
+ * @param req - Network request data
196
+ * @returns Body size in bytes, or -1
197
+ */
198
+ function requestBodySize(req) {
199
+ if (!req.requestBody)
200
+ return 0;
201
+ if (skippedBodyReason(req.requestBody) !== undefined)
202
+ return -1;
203
+ return Buffer.byteLength(req.requestBody, 'utf-8');
204
+ }
205
+ /**
206
+ * Build POST data object if request has body. A body bdg did not keep is
207
+ * exported without `text` and with the reason as `comment`.
193
208
  *
194
209
  * @param req - Network request data
195
210
  * @returns POST data object or undefined
@@ -197,11 +212,11 @@ function contentLength(req) {
197
212
  function buildPostData(req) {
198
213
  if (!req.requestBody)
199
214
  return undefined;
200
- const contentType = getHeader(req.requestHeaders, 'content-type') ?? 'text/plain';
201
- return {
202
- mimeType: contentType,
203
- text: req.requestBody,
204
- };
215
+ const mimeType = getHeader(req.requestHeaders, 'content-type') ?? 'text/plain';
216
+ const skipped = skippedBodyReason(req.requestBody);
217
+ if (skipped !== undefined)
218
+ return { mimeType, comment: `Body not captured: ${skipped}` };
219
+ return { mimeType, text: req.requestBody };
205
220
  }
206
221
  /**
207
222
  * Duration between two CDP timing offsets (ms), or -1 if either is unknown.
@@ -9,9 +9,13 @@
9
9
  * `httpOnly`) with the value `[redacted]`, so the export still shows that a
10
10
  * request was authenticated and which cookies were set. It also covers API
11
11
  * key, token and session headers, credential query parameters in URLs
12
- * (`?code=`, `?access_token=`) and credential fields of request bodies
13
- * (sanitizeBody.ts). `headersSize` and `bodySize` stay those of the captured
14
- * request. Response bodies and WebSocket messages are not redacted.
12
+ * (`?code=`, `?access_token=`), and credential fields of request and response
13
+ * bodies and of WebSocket text messages (sanitizeBody.ts), editing only
14
+ * those values. `headersSize`, `bodySize` and `content.size` stay those of the
15
+ * captured request. Base64 bodies are decoded when their type is generic,
16
+ * JSON, form or event-stream, and binary WebSocket messages always; those
17
+ * that are not UTF-8 text, and other binary bodies, are kept. A body or
18
+ * message the sanitizer fails on is replaced whole by {@link REDACTED}.
15
19
  */
16
20
  import type { Entry } from './types.js';
17
21
  /**
@@ -9,11 +9,15 @@
9
9
  * `httpOnly`) with the value `[redacted]`, so the export still shows that a
10
10
  * request was authenticated and which cookies were set. It also covers API
11
11
  * key, token and session headers, credential query parameters in URLs
12
- * (`?code=`, `?access_token=`) and credential fields of request bodies
13
- * (sanitizeBody.ts). `headersSize` and `bodySize` stay those of the captured
14
- * request. Response bodies and WebSocket messages are not redacted.
12
+ * (`?code=`, `?access_token=`), and credential fields of request and response
13
+ * bodies and of WebSocket text messages (sanitizeBody.ts), editing only
14
+ * those values. `headersSize`, `bodySize` and `content.size` stay those of the
15
+ * captured request. Base64 bodies are decoded when their type is generic,
16
+ * JSON, form or event-stream, and binary WebSocket messages always; those
17
+ * that are not UTF-8 text, and other binary bodies, are kept. A body or
18
+ * message the sanitizer fails on is replaced whole by {@link REDACTED}.
15
19
  */
16
- import { REDACTED, isSensitiveField, redactPairs, redactRequestBody, } from './sanitizeBody.js';
20
+ import { REDACTED, isSensitiveField, redactBase64Body, redactBody, redactOrReplaceWhole, redactPairs, } from './sanitizeBody.js';
17
21
  /** {@link REDACTED} as written in a URL */
18
22
  const URL_REDACTED = encodeURIComponent(REDACTED);
19
23
  /** Headers whose values are credentials, lowercased (others match by pattern) */
@@ -36,6 +40,12 @@ const SENSITIVE_HEADER_SEGMENT = /(^|-)(api-?key|apikey|token|secret|jwt|subscri
36
40
  const URL_HEADERS = new Set(['location', 'referer']);
37
41
  /** Query parameter names that hold credentials in URLs only (OAuth codes, signed URLs, API keys) */
38
42
  const SENSITIVE_URL_PARAM = /^(code|sig|key)$/i;
43
+ /** {@link REDACTED} as base64, for a binary body or message replaced whole */
44
+ const BASE64_REDACTED = Buffer.from(REDACTED).toString('base64');
45
+ /** WebSocket opcode of a text message */
46
+ const TEXT_OPCODE = 1;
47
+ /** WebSocket opcode of a binary message (base64 in the HAR) */
48
+ const BINARY_OPCODE = 2;
39
49
  /**
40
50
  * Redact the credentials of a HAR entry.
41
51
  *
@@ -43,7 +53,7 @@ const SENSITIVE_URL_PARAM = /^(code|sig|key)$/i;
43
53
  * @returns Copy of the entry with credential values replaced by {@link REDACTED}
44
54
  */
45
55
  export function sanitizeEntry(entry) {
46
- const { request, response } = entry;
56
+ const { request, response, _webSocketMessages: messages } = entry;
47
57
  const postData = request.postData;
48
58
  return {
49
59
  ...entry,
@@ -54,7 +64,10 @@ export function sanitizeEntry(entry) {
54
64
  headers: request.headers.map(redactHeader),
55
65
  queryString: request.queryString.map(redactQueryParam),
56
66
  ...(postData?.text !== undefined && {
57
- postData: { ...postData, text: redactRequestBody(postData.text, postData.mimeType) },
67
+ postData: {
68
+ ...postData,
69
+ text: redactOrReplaceWhole(postData.text, (text) => redactBody(text, postData.mimeType)),
70
+ },
58
71
  }),
59
72
  },
60
73
  response: {
@@ -62,9 +75,42 @@ export function sanitizeEntry(entry) {
62
75
  cookies: response.cookies.map(redactCookie),
63
76
  headers: response.headers.map(redactHeader),
64
77
  redirectURL: redactUrl(response.redirectURL),
78
+ content: redactContent(response.content),
65
79
  },
80
+ ...(messages && { _webSocketMessages: messages.map(redactWebSocketMessage) }),
66
81
  };
67
82
  }
83
+ /**
84
+ * Redact credential fields of a response body, also of a base64 body whose
85
+ * type is generic or JSON.
86
+ *
87
+ * @param content - HAR response content
88
+ * @returns The content, or a copy with its text redacted; `size` stays as captured
89
+ */
90
+ function redactContent(content) {
91
+ const { text, mimeType } = content;
92
+ if (text === undefined)
93
+ return content;
94
+ const redacted = content.encoding === 'base64'
95
+ ? redactOrReplaceWhole(text, (body) => redactBase64Body(body, mimeType), BASE64_REDACTED)
96
+ : redactOrReplaceWhole(text, (body) => redactBody(body, mimeType));
97
+ return redacted === text ? content : { ...content, text: redacted };
98
+ }
99
+ /**
100
+ * Redact credential fields of a WebSocket message: a text message, or a
101
+ * binary one that decodes as UTF-8 (redacted and encoded again).
102
+ *
103
+ * @param message - HAR WebSocket message
104
+ * @returns The message, or a copy with its data redacted
105
+ */
106
+ function redactWebSocketMessage(message) {
107
+ if (message.opcode !== TEXT_OPCODE && message.opcode !== BINARY_OPCODE)
108
+ return message;
109
+ const data = message.opcode === TEXT_OPCODE
110
+ ? redactOrReplaceWhole(message.data, (text) => redactBody(text, ''))
111
+ : redactOrReplaceWhole(message.data, (text) => redactBase64Body(text, ''), BASE64_REDACTED);
112
+ return data === message.data ? message : { ...message, data };
113
+ }
68
114
  /**
69
115
  * Whether a header carries a credential.
70
116
  *
@@ -1,9 +1,18 @@
1
1
  /**
2
- * Credential redaction in request bodies and `name=value` lists, for
3
- * sanitized HAR exports (see sanitize.ts).
2
+ * Credential redaction in request and response bodies, WebSocket text
3
+ * messages and `name=value` lists, for sanitized HAR exports (see sanitize.ts).
4
4
  *
5
5
  * Matching is by field name only, so it over-redacts: any primitive whose
6
6
  * name looks like a credential (`tokenCount: 5`) is replaced too.
7
+ *
8
+ * JSON is never parsed and re-serialized: a single linear scan replaces the
9
+ * credential values in place, so everything else (64-bit numbers, formatting,
10
+ * duplicate keys, a BOM or `)]}'` prefix) stays byte for byte, and truncated
11
+ * JSON, socket.io and SockJS packets, server-sent events, NDJSON and JSON
12
+ * encoded in string values are covered too. JWTs are redacted under any name.
13
+ *
14
+ * Only JSON syntax is understood: single-quoted strings, unquoted keys,
15
+ * JSONP and `name:value` header lines (STOMP `passcode:`) are not.
7
16
  */
8
17
  /** Replaces a credential value */
9
18
  export declare const REDACTED = "[redacted]";
@@ -15,6 +24,26 @@ export declare const REDACTED = "[redacted]";
15
24
  * @returns True for password, token, secret, key, session and signature names
16
25
  */
17
26
  export declare function isSensitiveField(name: string): boolean;
27
+ /**
28
+ * Run a redaction, replacing the whole text when it fails, so that a body
29
+ * the sanitizer cannot handle is never exported as captured.
30
+ *
31
+ * @param text - Body or message text
32
+ * @param redact - Redaction of the text
33
+ * @param whole - Text written when the redaction throws
34
+ * @returns Redacted text, or `whole`
35
+ */
36
+ export declare function redactOrReplaceWhole(text: string, redact: (text: string) => string, whole?: string): string;
37
+ /**
38
+ * Replace every whole JWT (`eyJ` and three base64url segments, not part of a
39
+ * longer word) in text. Scanned by hand: a regular expression overflows the
40
+ * stack on a word of millions of characters.
41
+ *
42
+ * @param text - Text
43
+ * @param replacement - Text written instead of each JWT
44
+ * @returns Text with JWTs replaced; the text unchanged when it has none
45
+ */
46
+ export declare function redactJwts(text: string, replacement: string): string;
18
47
  /**
19
48
  * Redact credential values in an `&`-separated `name=value` list (a form body,
20
49
  * query string or fragment), leaving the other pairs byte for byte.
@@ -26,13 +55,24 @@ export declare function isSensitiveField(name: string): boolean;
26
55
  */
27
56
  export declare function redactPairs(text: string, isSensitive: (name: string) => boolean, replacement: string): string;
28
57
  /**
29
- * Redact credential fields of a request body: multipart parts, form fields
30
- * (by Content-Type or shape) and JSON fields at any depth.
58
+ * Redact credential fields of a body or WebSocket text message: multipart
59
+ * parts, form fields (by Content-Type or shape) and JSON fields at any depth.
31
60
  *
32
61
  * @param text - Body text
33
- * @param mimeType - Content-Type of the body
62
+ * @param mimeType - Content-Type of the body (empty for a WebSocket message)
34
63
  * @returns Body with credential values replaced; the text unchanged when
35
- * there were none
64
+ * there were none or it is neither JSON, a form nor multipart
65
+ */
66
+ export declare function redactBody(text: string, mimeType: string): string;
67
+ /**
68
+ * Redact credential fields of a base64 body or binary WebSocket message that
69
+ * may be text: one with no, a generic binary, a JSON, a form or an
70
+ * event-stream Content-Type that decodes as UTF-8.
71
+ *
72
+ * @param base64 - Body as base64
73
+ * @param mimeType - Content-Type of the body
74
+ * @returns The redacted body re-encoded, or the input unchanged when it was
75
+ * not decodable text or held no credentials
36
76
  */
37
- export declare function redactRequestBody(text: string, mimeType: string): string;
77
+ export declare function redactBase64Body(base64: string, mimeType: string): string;
38
78
  //# sourceMappingURL=sanitizeBody.d.ts.map