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.
- package/.claude/skills/bdg/SKILL.md +2 -1
- package/dist/cdp/methodTarget.d.ts +92 -0
- package/dist/cdp/methodTarget.js +159 -0
- package/dist/cdp/protocol.d.ts +16 -1
- package/dist/cdp/protocol.js +21 -0
- package/dist/cdp/schema.d.ts +55 -1
- package/dist/cdp/schema.js +134 -25
- package/dist/cdp/types.d.ts +3 -1
- package/dist/commands/cdp.d.ts +38 -1
- package/dist/commands/cdp.js +200 -133
- package/dist/commands/cleanup.js +18 -4
- package/dist/commands/dom/formInteraction.js +8 -4
- package/dist/commands/dom/helpers/index.d.ts +4 -4
- package/dist/commands/dom/helpers/index.js +3 -3
- package/dist/commands/dom/helpers/query.d.ts +2 -2
- package/dist/commands/dom/helpers/query.js +2 -2
- package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
- package/dist/commands/dom/helpers/screenshot.js +50 -668
- package/dist/commands/dom/screenshot.js +56 -36
- package/dist/commands/optionBehaviors.js +18 -8
- package/dist/commands/shared/CommandRunner.d.ts +5 -0
- package/dist/commands/shared/CommandRunner.js +18 -3
- package/dist/commands/shared/interrupt.d.ts +40 -0
- package/dist/commands/shared/interrupt.js +73 -0
- package/dist/commands/shared/optionTypes.d.ts +2 -0
- package/dist/commands/shared/startHelpers.d.ts +26 -3
- package/dist/commands/shared/startHelpers.js +145 -23
- package/dist/commands/types.d.ts +5 -0
- package/dist/connection/cdp.js +1 -16
- package/dist/connection/chromeIdentity.d.ts +24 -5
- package/dist/connection/chromeIdentity.js +53 -22
- package/dist/connection/launcher.d.ts +34 -1
- package/dist/connection/launcher.js +98 -10
- package/dist/connection/typed-cdp.d.ts +3 -2
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/daemon/SessionController.d.ts +10 -5
- package/dist/daemon/SessionController.js +15 -8
- package/dist/daemon/ipcServer.js +1 -1
- package/dist/daemon/launcher.d.ts +5 -0
- package/dist/daemon/launcher.js +8 -1
- package/dist/daemon/session/Session.d.ts +5 -1
- package/dist/daemon/session/Session.js +9 -8
- package/dist/daemon/session/TelemetryStore.d.ts +5 -0
- package/dist/daemon/session/TelemetryStore.js +4 -0
- package/dist/daemon/session/captureGate.d.ts +59 -0
- package/dist/daemon/session/captureGate.js +96 -0
- package/dist/daemon/session/chromeConnection.d.ts +16 -1
- package/dist/daemon/session/chromeConnection.js +34 -4
- package/dist/daemon/session/collectors.d.ts +15 -0
- package/dist/daemon/session/collectors.js +39 -2
- package/dist/daemon/session/commandRegistry.d.ts +14 -1
- package/dist/daemon/session/commandRegistry.js +46 -11
- package/dist/daemon/session/downloads.d.ts +32 -0
- package/dist/daemon/session/downloads.js +96 -0
- package/dist/daemon/session/interactions.d.ts +3 -2
- package/dist/daemon/session/interactions.js +7 -2
- package/dist/daemon/session/plugins.js +6 -0
- package/dist/daemon.js +12843 -11482
- package/dist/errors/CommandError.d.ts +2 -0
- package/dist/errors/issues.d.ts +1 -1
- package/dist/errors/messages.d.ts +58 -0
- package/dist/errors/messages.js +112 -0
- package/dist/index.js +999 -1020
- package/dist/ipc/client.d.ts +14 -1
- package/dist/ipc/client.js +21 -4
- package/dist/ipc/protocol/commands.d.ts +32 -2
- package/dist/ipc/protocol/commands.js +1 -0
- package/dist/ipc/protocol/domTypes.d.ts +24 -1
- package/dist/ipc/session/queries.d.ts +3 -0
- package/dist/ipc/session/types.d.ts +5 -0
- package/dist/ipc/transport/IPCError.d.ts +9 -0
- package/dist/ipc/transport/IPCError.js +12 -0
- package/dist/ipc/transport/errors.d.ts +2 -1
- package/dist/ipc/transport/errors.js +4 -1
- package/dist/ipc/transport/index.d.ts +4 -2
- package/dist/ipc/transport/index.js +13 -3
- package/dist/runtime/dom/actionEffects.d.ts +48 -9
- package/dist/runtime/dom/actionEffects.js +269 -34
- package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
- package/dist/runtime/dom/actionEffectsScripts.js +101 -2
- package/dist/runtime/dom/captureArea.d.ts +35 -0
- package/dist/runtime/dom/captureArea.js +203 -0
- package/dist/runtime/dom/elementInfo.d.ts +10 -8
- package/dist/runtime/dom/elementInfo.js +8 -6
- package/dist/runtime/dom/formDiscovery.d.ts +1 -1
- package/dist/runtime/page/bdgWorld.d.ts +9 -0
- package/dist/runtime/page/bdgWorld.js +11 -0
- package/dist/runtime/page/captureEmulation.d.ts +119 -0
- package/dist/runtime/page/captureEmulation.js +189 -0
- package/dist/runtime/page/captureScroll.d.ts +24 -0
- package/dist/runtime/page/captureScroll.js +124 -0
- package/dist/runtime/page/screenshot.d.ts +41 -0
- package/dist/runtime/page/screenshot.js +394 -0
- package/dist/session/paths.d.ts +14 -0
- package/dist/session/paths.js +25 -0
- package/dist/telemetry/downloads.d.ts +127 -0
- package/dist/telemetry/downloads.js +265 -0
- package/dist/telemetry/har/builder.js +22 -7
- package/dist/telemetry/har/sanitize.d.ts +7 -3
- package/dist/telemetry/har/sanitize.js +52 -6
- package/dist/telemetry/har/sanitizeBody.d.ts +47 -7
- package/dist/telemetry/har/sanitizeBody.js +429 -56
- package/dist/telemetry/har/types.d.ts +2 -0
- package/dist/telemetry/network.d.ts +4 -4
- package/dist/telemetry/network.js +38 -4
- package/dist/telemetry/networkRetention.d.ts +35 -14
- package/dist/telemetry/networkRetention.js +62 -26
- package/dist/types.d.ts +9 -14
- package/dist/ui/OutputBuilder.d.ts +3 -2
- package/dist/ui/OutputBuilder.js +4 -3
- package/dist/ui/formatters/cdp.d.ts +32 -9
- package/dist/ui/formatters/cdp.js +77 -6
- package/dist/ui/formatters/details.js +7 -15
- package/dist/ui/formatters/preview.d.ts +2 -0
- package/dist/ui/formatters/preview.js +7 -1
- package/dist/ui/formatters/status.js +6 -1
- package/dist/ui/formatting.d.ts +7 -0
- package/dist/ui/formatting.js +13 -0
- package/dist/ui/logging/logger.d.ts +1 -1
- package/dist/ui/messages/chrome.d.ts +13 -0
- package/dist/ui/messages/chrome.js +26 -0
- package/dist/ui/messages/commands.d.ts +71 -3
- package/dist/ui/messages/commands.js +98 -3
- package/dist/ui/messages/networkMessages.d.ts +24 -5
- package/dist/ui/messages/networkMessages.js +31 -8
- package/dist/utils/async.d.ts +3 -2
- package/dist/utils/async.js +16 -3
- package/dist/utils/http.d.ts +11 -4
- package/dist/utils/http.js +5 -3
- package/package.json +18 -4
- /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
- /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:
|
|
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
|
-
*
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
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: {
|
|
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
|
|
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
|
|
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
|
|
77
|
+
export declare function redactBase64Body(base64: string, mimeType: string): string;
|
|
38
78
|
//# sourceMappingURL=sanitizeBody.d.ts.map
|