browser-debugger-cli 0.14.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 (167) hide show
  1. package/.claude/skills/bdg/SKILL.md +3 -2
  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 +201 -133
  11. package/dist/commands/cleanup.js +21 -4
  12. package/dist/commands/dom/eval.d.ts +2 -1
  13. package/dist/commands/dom/eval.js +6 -21
  14. package/dist/commands/dom/formInteraction.js +8 -4
  15. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  16. package/dist/commands/dom/helpers/evalResult.js +59 -0
  17. package/dist/commands/dom/helpers/index.d.ts +4 -4
  18. package/dist/commands/dom/helpers/index.js +3 -3
  19. package/dist/commands/dom/helpers/query.d.ts +2 -2
  20. package/dist/commands/dom/helpers/query.js +2 -2
  21. package/dist/commands/dom/helpers/screenshot.d.ts +21 -26
  22. package/dist/commands/dom/helpers/screenshot.js +50 -668
  23. package/dist/commands/dom/screenshot.js +56 -36
  24. package/dist/commands/helpJson.d.ts +1 -1
  25. package/dist/commands/helpJson.js +3 -3
  26. package/dist/commands/helpTopic.js +10 -4
  27. package/dist/commands/network/har.js +18 -14
  28. package/dist/commands/optionBehaviors.js +24 -9
  29. package/dist/commands/shared/CommandRunner.d.ts +5 -0
  30. package/dist/commands/shared/CommandRunner.js +18 -3
  31. package/dist/commands/shared/interrupt.d.ts +40 -0
  32. package/dist/commands/shared/interrupt.js +73 -0
  33. package/dist/commands/shared/optionTypes.d.ts +3 -0
  34. package/dist/commands/shared/outputFile.d.ts +2 -1
  35. package/dist/commands/shared/outputFile.js +7 -4
  36. package/dist/commands/shared/startHelpers.d.ts +26 -3
  37. package/dist/commands/shared/startHelpers.js +145 -23
  38. package/dist/commands/status.js +3 -1
  39. package/dist/commands/stop.js +2 -1
  40. package/dist/commands/types.d.ts +5 -0
  41. package/dist/connection/cdp.js +1 -16
  42. package/dist/connection/chromeIdentity.d.ts +24 -5
  43. package/dist/connection/chromeIdentity.js +53 -22
  44. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  45. package/dist/connection/launcher/flagsBuilder.js +107 -23
  46. package/dist/connection/launcher.d.ts +35 -2
  47. package/dist/connection/launcher.js +99 -12
  48. package/dist/connection/typed-cdp.d.ts +3 -2
  49. package/dist/constants.d.ts +3 -5
  50. package/dist/constants.js +3 -5
  51. package/dist/daemon/SessionController.d.ts +10 -5
  52. package/dist/daemon/SessionController.js +15 -8
  53. package/dist/daemon/ipcServer.js +1 -1
  54. package/dist/daemon/launcher.d.ts +22 -3
  55. package/dist/daemon/launcher.js +45 -8
  56. package/dist/daemon/session/Session.d.ts +5 -1
  57. package/dist/daemon/session/Session.js +9 -8
  58. package/dist/daemon/session/TelemetryStore.d.ts +5 -0
  59. package/dist/daemon/session/TelemetryStore.js +4 -0
  60. package/dist/daemon/session/captureGate.d.ts +59 -0
  61. package/dist/daemon/session/captureGate.js +96 -0
  62. package/dist/daemon/session/chromeConnection.d.ts +16 -1
  63. package/dist/daemon/session/chromeConnection.js +34 -4
  64. package/dist/daemon/session/collectors.d.ts +15 -0
  65. package/dist/daemon/session/collectors.js +39 -2
  66. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  67. package/dist/daemon/session/commandRegistry.js +48 -13
  68. package/dist/daemon/session/downloads.d.ts +32 -0
  69. package/dist/daemon/session/downloads.js +96 -0
  70. package/dist/daemon/session/interactions.d.ts +3 -2
  71. package/dist/daemon/session/interactions.js +7 -2
  72. package/dist/daemon/session/plugins.js +6 -0
  73. package/dist/daemon.js +18520 -17014
  74. package/dist/errors/CommandError.d.ts +2 -0
  75. package/dist/errors/issues.d.ts +1 -1
  76. package/dist/errors/messages.d.ts +81 -0
  77. package/dist/errors/messages.js +198 -6
  78. package/dist/index.js +1446 -1078
  79. package/dist/ipc/client.d.ts +20 -2
  80. package/dist/ipc/client.js +32 -6
  81. package/dist/ipc/protocol/commands.d.ts +36 -2
  82. package/dist/ipc/protocol/commands.js +1 -0
  83. package/dist/ipc/protocol/domTypes.d.ts +24 -1
  84. package/dist/ipc/session/queries.d.ts +3 -0
  85. package/dist/ipc/session/types.d.ts +5 -0
  86. package/dist/ipc/transport/IPCError.d.ts +9 -0
  87. package/dist/ipc/transport/IPCError.js +12 -0
  88. package/dist/ipc/transport/errors.d.ts +2 -1
  89. package/dist/ipc/transport/errors.js +4 -1
  90. package/dist/ipc/transport/index.d.ts +10 -2
  91. package/dist/ipc/transport/index.js +29 -4
  92. package/dist/runtime/dom/actionEffects.d.ts +48 -9
  93. package/dist/runtime/dom/actionEffects.js +269 -34
  94. package/dist/runtime/dom/actionEffectsScripts.d.ts +45 -0
  95. package/dist/runtime/dom/actionEffectsScripts.js +101 -2
  96. package/dist/runtime/dom/captureArea.d.ts +35 -0
  97. package/dist/runtime/dom/captureArea.js +203 -0
  98. package/dist/runtime/dom/elementInfo.d.ts +13 -4
  99. package/dist/runtime/dom/elementInfo.js +12 -3
  100. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  101. package/dist/runtime/dom/evalHelpers.js +40 -12
  102. package/dist/runtime/dom/formDiscovery.d.ts +1 -1
  103. package/dist/runtime/dom/frames.d.ts +2 -1
  104. package/dist/runtime/dom/frames.js +3 -1
  105. package/dist/runtime/page/bdgWorld.d.ts +9 -0
  106. package/dist/runtime/page/bdgWorld.js +11 -0
  107. package/dist/runtime/page/captureEmulation.d.ts +119 -0
  108. package/dist/runtime/page/captureEmulation.js +189 -0
  109. package/dist/runtime/page/captureScroll.d.ts +24 -0
  110. package/dist/runtime/page/captureScroll.js +124 -0
  111. package/dist/runtime/page/emulation.js +6 -5
  112. package/dist/runtime/page/screenshot.d.ts +41 -0
  113. package/dist/runtime/page/screenshot.js +394 -0
  114. package/dist/runtime/page/userAgent.d.ts +86 -2
  115. package/dist/runtime/page/userAgent.js +154 -33
  116. package/dist/session/paths.d.ts +52 -3
  117. package/dist/session/paths.js +179 -7
  118. package/dist/session/portClaims.d.ts +0 -8
  119. package/dist/session/portClaims.js +1 -22
  120. package/dist/session/sessionList.d.ts +5 -1
  121. package/dist/session/sessionList.js +5 -1
  122. package/dist/telemetry/downloads.d.ts +127 -0
  123. package/dist/telemetry/downloads.js +265 -0
  124. package/dist/telemetry/har/builder.d.ts +12 -1
  125. package/dist/telemetry/har/builder.js +32 -9
  126. package/dist/telemetry/har/sanitize.d.ts +28 -0
  127. package/dist/telemetry/har/sanitize.js +184 -0
  128. package/dist/telemetry/har/sanitizeBody.d.ts +78 -0
  129. package/dist/telemetry/har/sanitizeBody.js +541 -0
  130. package/dist/telemetry/har/types.d.ts +2 -0
  131. package/dist/telemetry/network.d.ts +4 -4
  132. package/dist/telemetry/network.js +38 -4
  133. package/dist/telemetry/networkRetention.d.ts +35 -14
  134. package/dist/telemetry/networkRetention.js +62 -26
  135. package/dist/types.d.ts +9 -14
  136. package/dist/ui/OutputBuilder.d.ts +3 -2
  137. package/dist/ui/OutputBuilder.js +4 -3
  138. package/dist/ui/formatters/cdp.d.ts +32 -9
  139. package/dist/ui/formatters/cdp.js +77 -6
  140. package/dist/ui/formatters/details.js +7 -15
  141. package/dist/ui/formatters/preview.d.ts +2 -0
  142. package/dist/ui/formatters/preview.js +7 -1
  143. package/dist/ui/formatters/sessions.d.ts +3 -2
  144. package/dist/ui/formatters/sessions.js +10 -3
  145. package/dist/ui/formatters/status.js +6 -1
  146. package/dist/ui/formatting.d.ts +7 -0
  147. package/dist/ui/formatting.js +13 -0
  148. package/dist/ui/logging/logger.d.ts +1 -1
  149. package/dist/ui/messages/chrome.d.ts +27 -6
  150. package/dist/ui/messages/chrome.js +78 -12
  151. package/dist/ui/messages/commands.d.ts +71 -3
  152. package/dist/ui/messages/commands.js +98 -3
  153. package/dist/ui/messages/networkMessages.d.ts +50 -5
  154. package/dist/ui/messages/networkMessages.js +50 -6
  155. package/dist/ui/messages/session.d.ts +8 -0
  156. package/dist/ui/messages/session.js +10 -0
  157. package/dist/utils/async.d.ts +3 -2
  158. package/dist/utils/async.js +16 -3
  159. package/dist/utils/atomicFile.d.ts +2 -1
  160. package/dist/utils/atomicFile.js +5 -2
  161. package/dist/utils/directories.d.ts +41 -0
  162. package/dist/utils/directories.js +48 -0
  163. package/dist/utils/http.d.ts +11 -4
  164. package/dist/utils/http.js +5 -3
  165. package/package.json +18 -4
  166. /package/dist/{commands/dom → runtime/page}/screenshotResize.d.ts +0 -0
  167. /package/dist/{commands/dom → runtime/page}/screenshotResize.js +0 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Credential redaction in request and response bodies, WebSocket text
3
+ * messages and `name=value` lists, for sanitized HAR exports (see sanitize.ts).
4
+ *
5
+ * Matching is by field name only, so it over-redacts: any primitive whose
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.
16
+ */
17
+ import { SENSITIVE_NAME_SOURCE } from '../../runtime/dom/elementInfo.js';
18
+ import { createLogger } from '../../ui/logging/index.js';
19
+ import { getErrorMessage } from '../../utils/errors.js';
20
+ const log = createLogger('network');
21
+ /** Replaces a credential value */
22
+ export const REDACTED = '[redacted]';
23
+ /**
24
+ * Field names holding credentials: password-like names (shared with form
25
+ * masking), tokens, secrets, keys, sessions, signatures, credentials, bearer,
26
+ * cookie, CSRF and refresh values, `auth` and `sid` as whole words, OAuth,
27
+ * authorization codes and PKCE verifiers
28
+ */
29
+ const SENSITIVE_FIELD = new RegExp(`${SENSITIVE_NAME_SOURCE}|token|secret|api[-_]?key|authoriz|credential|jwt|private[-_]?key|access[-_]?key|session|signature|bearer|cookie|csrf|xsrf|refresh|oauth|(^|[^a-z])(auth|sid)([^a-z]|$)|auth[-_]?code|code[-_]?verifier`, 'i');
30
+ /**
31
+ * A body that is a form whatever its Content-Type: `k=v&k=v`, no whitespace,
32
+ * not JSON (Rails-style names such as `user[password]` included)
33
+ */
34
+ const FORM_BODY = /^(?!\[)[^\s=&{"]+=[^\s&]*(?:&[^\s=&]+=[^\s&]*)*$/;
35
+ /**
36
+ * Text that holds JSON whatever its Content-Type: after an optional BOM,
37
+ * XSSI guard (`)]}'`) and whitespace, an object or array (also after a
38
+ * socket.io packet type such as `42`, `451-` or `42/chat,`, an Engine.io v3
39
+ * length prefix such as `45:`, or a SockJS `a`/`c` frame type) or a
40
+ * server-sent event field
41
+ */
42
+ const JSON_LIKE_START = /^\uFEFF?(?:\)\]\}',?)?\s*(?:(?:\d+:)?(?:\d*-?(?:\/[^,]*,)?|[ac])[[{]|(?:data|event|id|retry):)/;
43
+ /** Content-Types whose bodies are scanned as JSON whatever they start with */
44
+ const JSON_MIME = /json|event-stream/i;
45
+ /**
46
+ * Content-Types (without parameters) of base64 bodies decoded to look for
47
+ * credentials: none, generic binary, JSON, form and server-sent events
48
+ */
49
+ const DECODABLE_MIME = /^(?:application\/octet-stream|binary\/octet-stream|application\/x-www-form-urlencoded|text\/event-stream)?$|json/i;
50
+ /** A bare value (number, `true`, `false`, `null`) or word, up to the next delimiter */
51
+ const BARE = /[^\s,:[\]{}"]+/y;
52
+ /** Character code of `.`, the JWT segment separator */
53
+ const DOT = 0x2e;
54
+ /** Levels of JSON encoded in string values that are decoded */
55
+ const MAX_ENCODED_DEPTH = 3;
56
+ /** {@link REDACTED} as a JSON string */
57
+ const REDACTED_STRING = JSON.stringify(REDACTED);
58
+ /** Strict UTF-8 decoder that keeps a BOM */
59
+ const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
60
+ /**
61
+ * Whether a body field, form field or query parameter name looks like it
62
+ * holds a credential.
63
+ *
64
+ * @param name - Field name
65
+ * @returns True for password, token, secret, key, session and signature names
66
+ */
67
+ export function isSensitiveField(name) {
68
+ return SENSITIVE_FIELD.test(name.replace(/([a-z])([A-Z])/g, '$1_$2'));
69
+ }
70
+ /**
71
+ * Run a redaction, replacing the whole text when it fails, so that a body
72
+ * the sanitizer cannot handle is never exported as captured.
73
+ *
74
+ * @param text - Body or message text
75
+ * @param redact - Redaction of the text
76
+ * @param whole - Text written when the redaction throws
77
+ * @returns Redacted text, or `whole`
78
+ */
79
+ export function redactOrReplaceWhole(text, redact, whole = REDACTED) {
80
+ try {
81
+ return redact(text);
82
+ }
83
+ catch (error) {
84
+ log.debug(`Body replaced whole, sanitizing failed: ${getErrorMessage(error)}`);
85
+ return whole;
86
+ }
87
+ }
88
+ /**
89
+ * Replace every whole JWT (`eyJ` and three base64url segments, not part of a
90
+ * longer word) in text. Scanned by hand: a regular expression overflows the
91
+ * stack on a word of millions of characters.
92
+ *
93
+ * @param text - Text
94
+ * @param replacement - Text written instead of each JWT
95
+ * @returns Text with JWTs replaced; the text unchanged when it has none
96
+ */
97
+ export function redactJwts(text, replacement) {
98
+ if (!text.includes('eyJ'))
99
+ return text;
100
+ const parts = [];
101
+ let copied = 0;
102
+ let at = text.indexOf('eyJ');
103
+ while (at !== -1) {
104
+ const end = isJwtBoundary(text, at - 1) ? jwtEnd(text, at) : undefined;
105
+ if (end !== undefined && isJwtBoundary(text, end)) {
106
+ parts.push(text.slice(copied, at), replacement);
107
+ copied = end;
108
+ }
109
+ at = text.indexOf('eyJ', Math.max(at + 3, copied));
110
+ }
111
+ if (parts.length === 0)
112
+ return text;
113
+ parts.push(text.slice(copied));
114
+ return parts.join('');
115
+ }
116
+ /**
117
+ * Redact credential values in an `&`-separated `name=value` list (a form body,
118
+ * query string or fragment), leaving the other pairs byte for byte.
119
+ *
120
+ * @param text - List like `user=ann&password=hunter2`
121
+ * @param isSensitive - Whether a decoded name holds a credential
122
+ * @param replacement - Value written instead
123
+ * @returns List with credential values replaced
124
+ */
125
+ export function redactPairs(text, isSensitive, replacement) {
126
+ return text
127
+ .split('&')
128
+ .map((pair) => {
129
+ const eq = pair.indexOf('=');
130
+ if (eq === -1)
131
+ return pair;
132
+ if (isSensitive(decodeName(pair.slice(0, eq))))
133
+ return `${pair.slice(0, eq)}=${replacement}`;
134
+ const value = pair.slice(eq + 1);
135
+ const redacted = redactJwts(value, replacement);
136
+ return redacted === value ? pair : `${pair.slice(0, eq + 1)}${redacted}`;
137
+ })
138
+ .join('&');
139
+ }
140
+ /**
141
+ * Redact credential fields of a body or WebSocket text message: multipart
142
+ * parts, form fields (by Content-Type or shape) and JSON fields at any depth.
143
+ *
144
+ * @param text - Body text
145
+ * @param mimeType - Content-Type of the body (empty for a WebSocket message)
146
+ * @returns Body with credential values replaced; the text unchanged when
147
+ * there were none or it is neither JSON, a form nor multipart
148
+ */
149
+ export function redactBody(text, mimeType) {
150
+ if (/multipart\/form-data/i.test(mimeType))
151
+ return redactMultipart(text, mimeType);
152
+ if (/x-www-form-urlencoded/i.test(mimeType))
153
+ return redactPairs(text, isSensitiveField, REDACTED);
154
+ if (JSON_LIKE_START.test(text))
155
+ return redactJsonText(text, 0);
156
+ if (FORM_BODY.test(text))
157
+ return redactPairs(text, isSensitiveField, REDACTED);
158
+ if (JSON_MIME.test(mimeType))
159
+ return redactJsonText(text, 0);
160
+ return redactJwts(text, REDACTED);
161
+ }
162
+ /**
163
+ * Redact credential fields of a base64 body or binary WebSocket message that
164
+ * may be text: one with no, a generic binary, a JSON, a form or an
165
+ * event-stream Content-Type that decodes as UTF-8.
166
+ *
167
+ * @param base64 - Body as base64
168
+ * @param mimeType - Content-Type of the body
169
+ * @returns The redacted body re-encoded, or the input unchanged when it was
170
+ * not decodable text or held no credentials
171
+ */
172
+ export function redactBase64Body(base64, mimeType) {
173
+ if (!DECODABLE_MIME.test(mimeType.split(';')[0]?.trim() ?? ''))
174
+ return base64;
175
+ let decoded;
176
+ try {
177
+ decoded = UTF8.decode(Buffer.from(base64, 'base64'));
178
+ }
179
+ catch (error) {
180
+ log.debug(`Base64 body is not UTF-8: ${getErrorMessage(error)}`);
181
+ return base64;
182
+ }
183
+ const redacted = redactBody(decoded, mimeType);
184
+ return redacted === decoded ? base64 : Buffer.from(redacted, 'utf8').toString('base64');
185
+ }
186
+ /**
187
+ * Decode a form or query name.
188
+ *
189
+ * @param name - Encoded name
190
+ * @returns Decoded name, or the raw name when it is not valid encoding
191
+ */
192
+ function decodeName(name) {
193
+ try {
194
+ return decodeURIComponent(name.replace(/\+/g, ' '));
195
+ }
196
+ catch (error) {
197
+ log.debug(`Field name not decodable: ${getErrorMessage(error)}`);
198
+ return name;
199
+ }
200
+ }
201
+ /**
202
+ * Redact the values of credential parts of a multipart body, leaving the
203
+ * other parts byte for byte.
204
+ *
205
+ * @param text - Body text
206
+ * @param mimeType - Content-Type with the boundary
207
+ * @returns Body with credential part values replaced
208
+ */
209
+ function redactMultipart(text, mimeType) {
210
+ const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(mimeType);
211
+ const boundary = match?.[1] ?? match?.[2];
212
+ if (!boundary)
213
+ return text;
214
+ const delimiter = `--${boundary}`;
215
+ return text.split(delimiter).map(redactPart).join(delimiter);
216
+ }
217
+ /**
218
+ * Redact the value of one multipart part if its name holds a credential, or
219
+ * the JWTs in it.
220
+ *
221
+ * @param part - Text between two boundary delimiters
222
+ * @returns The part, or the part with its value or JWTs replaced
223
+ */
224
+ function redactPart(part) {
225
+ const headerEnd = part.indexOf('\r\n\r\n');
226
+ if (headerEnd === -1)
227
+ return part;
228
+ const name = /;\s*name="([^"]*)"/i.exec(part.slice(0, headerEnd))?.[1];
229
+ if (name === undefined || !isSensitiveField(name))
230
+ return redactJwts(part, REDACTED);
231
+ const valueEnd = part.endsWith('\r\n') ? part.length - 2 : part.length;
232
+ return `${part.slice(0, headerEnd + 4)}${REDACTED}${part.slice(valueEnd)}`;
233
+ }
234
+ /**
235
+ * Replace the values under credential names in JSON-like text, in one linear
236
+ * pass. Every string, number and boolean under such a key is replaced, at
237
+ * any depth (objects and arrays keep their structure), and so is a JWT under
238
+ * any key; `null` and all other text stay byte for byte. A string ends at
239
+ * its closing quote or at the end of its line (JSON strings hold no raw line
240
+ * breaks), so a stray quote hides nothing past its line.
241
+ *
242
+ * @param text - Text holding JSON, possibly truncated or framed
243
+ * @param depth - Levels of string encoding around the text
244
+ * @returns Text with credential values replaced by `"[redacted]"`; the text
245
+ * unchanged when there were none
246
+ */
247
+ function redactJsonText(text, depth) {
248
+ const scan = {
249
+ text,
250
+ parts: [],
251
+ copied: 0,
252
+ containers: [false],
253
+ key: undefined,
254
+ lineEnd: -1,
255
+ depth,
256
+ };
257
+ let index = 0;
258
+ while (index < text.length)
259
+ index = scanToken(scan, index);
260
+ if (scan.parts.length === 0)
261
+ return text;
262
+ scan.parts.push(text.slice(scan.copied));
263
+ return scan.parts.join('');
264
+ }
265
+ /**
266
+ * Read the token at an index: a string, a bracket, a comma or a bare value;
267
+ * whitespace and colons are skipped.
268
+ *
269
+ * @param scan - Scan state
270
+ * @param index - Index of the token's first character
271
+ * @returns Index after the token
272
+ */
273
+ function scanToken(scan, index) {
274
+ const char = scan.text[index];
275
+ if (char === '"')
276
+ return scanString(scan, index);
277
+ if (char === '{' || char === '[') {
278
+ scan.containers.push(takeValue(scan));
279
+ }
280
+ else if (char === '}' || char === ']') {
281
+ if (scan.containers.length > 1)
282
+ scan.containers.pop();
283
+ scan.key = undefined;
284
+ }
285
+ else if (char === ',') {
286
+ scan.key = undefined;
287
+ }
288
+ else {
289
+ return scanBare(scan, index);
290
+ }
291
+ return index + 1;
292
+ }
293
+ /**
294
+ * Read a string: a key (followed by `:`) sets whether the next value is
295
+ * sensitive; a value under a credential name is replaced, JSON encoded in a
296
+ * value is redacted and encoded again, and JWTs in other values are replaced.
297
+ *
298
+ * @param scan - Scan state
299
+ * @param start - Index of the opening quote
300
+ * @returns Index after the string
301
+ */
302
+ function scanString(scan, start) {
303
+ const end = stringEnd(scan, start);
304
+ if (isFollowedByColon(scan.text, end)) {
305
+ scan.key = isSensitiveField(keyName(scan.text, start, end));
306
+ }
307
+ else if (takeValue(scan)) {
308
+ replaceValue(scan, start, end, REDACTED_STRING);
309
+ }
310
+ else {
311
+ const encoded = redactEncodedJson(scan, start, end);
312
+ if (encoded === undefined)
313
+ redactStringJwts(scan, start, end);
314
+ else
315
+ replaceValue(scan, start, end, encoded);
316
+ }
317
+ return end;
318
+ }
319
+ /**
320
+ * Replace the whole JWTs in a string value, keeping the text around them.
321
+ *
322
+ * @param scan - Scan state
323
+ * @param start - Index of the opening quote
324
+ * @param end - Index after the string
325
+ */
326
+ function redactStringJwts(scan, start, end) {
327
+ const contentEnd = end - 1 > start && scan.text[end - 1] === '"' ? end - 1 : end;
328
+ const content = scan.text.slice(start + 1, contentEnd);
329
+ const redacted = redactJwts(content, REDACTED);
330
+ if (redacted !== content)
331
+ replaceValue(scan, start + 1, contentEnd, redacted);
332
+ }
333
+ /**
334
+ * Read a bare value or word up to the next delimiter; a number, `true`,
335
+ * `false` or any bare text right after a credential key is replaced. Other
336
+ * words are not values.
337
+ *
338
+ * @param scan - Scan state
339
+ * @param index - Index of the character
340
+ * @returns Index after the bare text, or after a whitespace or colon character
341
+ */
342
+ function scanBare(scan, index) {
343
+ BARE.lastIndex = index;
344
+ const bare = BARE.exec(scan.text)?.[0];
345
+ if (bare === undefined)
346
+ return index + 1;
347
+ const end = index + bare.length;
348
+ const isLiteral = /^[-\d]/.test(bare) || bare === 'true' || bare === 'false' || bare === 'null';
349
+ if (!isLiteral && scan.key !== true)
350
+ return end;
351
+ if (takeValue(scan) && bare !== 'null')
352
+ replaceValue(scan, index, end, REDACTED_STRING);
353
+ return end;
354
+ }
355
+ /**
356
+ * Whether the value being read is under a credential name; consumes the key.
357
+ *
358
+ * @param scan - Scan state
359
+ * @returns True when its key or an enclosing object or array names a credential
360
+ */
361
+ function takeValue(scan) {
362
+ const sensitive = scan.key === true || scan.containers[scan.containers.length - 1] === true;
363
+ scan.key = undefined;
364
+ return sensitive;
365
+ }
366
+ /**
367
+ * Write a replacement instead of the text between two indices.
368
+ *
369
+ * @param scan - Scan state
370
+ * @param start - Index of the value's first character
371
+ * @param end - Index after the value
372
+ * @param replacement - Text written instead
373
+ */
374
+ function replaceValue(scan, start, end, replacement) {
375
+ scan.parts.push(scan.text.slice(scan.copied, start), replacement);
376
+ scan.copied = end;
377
+ }
378
+ /**
379
+ * Whether the character at an index cannot be part of a JWT's word: outside
380
+ * the text, or not a base64url character, `.` or `-`.
381
+ *
382
+ * @param text - Text
383
+ * @param index - Index (may be -1 or the text length)
384
+ * @returns True at a word boundary
385
+ */
386
+ function isJwtBoundary(text, index) {
387
+ if (index < 0 || index >= text.length)
388
+ return true;
389
+ const code = text.charCodeAt(index);
390
+ return !isBase64UrlCode(code) && code !== DOT;
391
+ }
392
+ /**
393
+ * End of a JWT starting at an index: `eyJ` and at least 5 more base64url
394
+ * characters, a dot, at least 5, a dot, and any number.
395
+ *
396
+ * @param text - Text
397
+ * @param start - Index of `eyJ`
398
+ * @returns Index after the third segment, or undefined when it is no JWT
399
+ */
400
+ function jwtEnd(text, start) {
401
+ const first = segmentEnd(text, start + 3);
402
+ if (first - start < 8 || text[first] !== '.')
403
+ return undefined;
404
+ const second = segmentEnd(text, first + 1);
405
+ if (second - first - 1 < 5 || text[second] !== '.')
406
+ return undefined;
407
+ return segmentEnd(text, second + 1);
408
+ }
409
+ /**
410
+ * End of a run of base64url characters.
411
+ *
412
+ * @param text - Text
413
+ * @param from - Index to start at
414
+ * @returns Index of the first other character, or the text length
415
+ */
416
+ function segmentEnd(text, from) {
417
+ let index = from;
418
+ while (index < text.length && isBase64UrlCode(text.charCodeAt(index)))
419
+ index++;
420
+ return index;
421
+ }
422
+ /**
423
+ * Whether a character code is a base64url character: `A-Z`, `a-z`, `0-9`,
424
+ * `_` or `-`.
425
+ *
426
+ * @param code - UTF-16 code unit
427
+ * @returns True for a base64url character
428
+ */
429
+ function isBase64UrlCode(code) {
430
+ return ((code >= 0x30 && code <= 0x39) ||
431
+ (code >= 0x41 && code <= 0x5a) ||
432
+ (code >= 0x61 && code <= 0x7a) ||
433
+ code === 0x5f ||
434
+ code === 0x2d);
435
+ }
436
+ /**
437
+ * Redact JSON encoded in a string value (a JSON payload, GraphQL variables,
438
+ * a SockJS or socket.io message): decoded text that looks like JSON
439
+ * ({@link JSON_LIKE_START}), up to {@link MAX_ENCODED_DEPTH} levels.
440
+ *
441
+ * @param scan - Scan state
442
+ * @param start - Index of the opening quote
443
+ * @param end - Index after the string
444
+ * @returns The string encoded again with credentials redacted; undefined when
445
+ * it holds no JSON or no credentials, so it stays byte for byte
446
+ */
447
+ function redactEncodedJson(scan, start, end) {
448
+ if (scan.depth >= MAX_ENCODED_DEPTH || end - start < 2 || scan.text[end - 1] !== '"') {
449
+ return undefined;
450
+ }
451
+ if (!/[[{]/.test(scan.text.slice(start + 1, end - 1)))
452
+ return undefined;
453
+ let decoded;
454
+ try {
455
+ decoded = JSON.parse(scan.text.slice(start, end));
456
+ }
457
+ catch (error) {
458
+ log.debug(`String value not decodable: ${getErrorMessage(error)}`);
459
+ return undefined;
460
+ }
461
+ if (!JSON_LIKE_START.test(decoded))
462
+ return undefined;
463
+ const redacted = redactJsonText(decoded, scan.depth + 1);
464
+ return redacted === decoded ? undefined : JSON.stringify(redacted);
465
+ }
466
+ /**
467
+ * End of a JSON string: its closing quote, or the end of its line. Each quote
468
+ * looks back only over the backslashes since the previous quote, and the
469
+ * line end is cached, so the search stays linear.
470
+ *
471
+ * @param scan - Scan state
472
+ * @param start - Index of the opening quote
473
+ * @returns Index after the closing quote, or of the line break (`\r\n` or
474
+ * `\n`) or text end when the line has none
475
+ */
476
+ function stringEnd(scan, start) {
477
+ const { text } = scan;
478
+ let from = start + 1;
479
+ for (;;) {
480
+ const quote = text.indexOf('"', from);
481
+ const lineEnd = nextLineEnd(scan, from);
482
+ if (quote === -1 || lineEnd < quote) {
483
+ return lineEnd > from && text[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd;
484
+ }
485
+ let backslashes = 0;
486
+ while (text[quote - 1 - backslashes] === '\\' && quote - 1 - backslashes > start) {
487
+ backslashes++;
488
+ }
489
+ if (backslashes % 2 === 0)
490
+ return quote + 1;
491
+ from = quote + 1;
492
+ }
493
+ }
494
+ /**
495
+ * Index of the next `\n` at or after an index, or the text length.
496
+ *
497
+ * @param scan - Scan state, whose cached line end is updated
498
+ * @param from - Index to look from
499
+ * @returns Index of the line break
500
+ */
501
+ function nextLineEnd(scan, from) {
502
+ if (scan.lineEnd < from) {
503
+ const lineEnd = scan.text.indexOf('\n', from);
504
+ scan.lineEnd = lineEnd === -1 ? scan.text.length : lineEnd;
505
+ }
506
+ return scan.lineEnd;
507
+ }
508
+ /**
509
+ * Whether the next character after whitespace is a colon.
510
+ *
511
+ * @param text - Text
512
+ * @param index - Index to look from
513
+ * @returns True when a key ends at the index
514
+ */
515
+ function isFollowedByColon(text, index) {
516
+ let next = index;
517
+ while (next < text.length && ' \t\n\r'.includes(text.charAt(next)))
518
+ next++;
519
+ return text[next] === ':';
520
+ }
521
+ /**
522
+ * Name of a key, with JSON escapes decoded.
523
+ *
524
+ * @param text - Text
525
+ * @param start - Index of the opening quote
526
+ * @param end - Index after the closing quote
527
+ * @returns Decoded name, or the raw name when its escapes are not valid
528
+ */
529
+ function keyName(text, start, end) {
530
+ const raw = text.slice(start + 1, end - 1);
531
+ if (!raw.includes('\\'))
532
+ return raw;
533
+ try {
534
+ return JSON.parse(text.slice(start, end));
535
+ }
536
+ catch (error) {
537
+ log.debug(`Key not decodable: ${getErrorMessage(error)}`);
538
+ return raw;
539
+ }
540
+ }
541
+ //# sourceMappingURL=sanitizeBody.js.map
@@ -115,6 +115,8 @@ export interface WebSocketMessage {
115
115
  opcode: number;
116
116
  /** Message payload (base64 for binary messages) */
117
117
  data: string;
118
+ /** Original payload length in characters, when bdg cut `data` at capture */
119
+ _truncatedFrom?: number;
118
120
  }
119
121
  /**
120
122
  * Request object containing detailed info about the request.
@@ -30,7 +30,7 @@ export interface NetworkCollectionOptions {
30
30
  maxBodySize?: number;
31
31
  /** Finished requests kept at most; past it the oldest are dropped (default {@link MAX_NETWORK_REQUESTS}) */
32
32
  maxRequests?: number;
33
- /** Total size of stored response bodies; past it the oldest are evicted (default {@link MAX_TOTAL_BODY_BYTES}) */
33
+ /** Total size of stored request and response bodies; past it the oldest are evicted (default {@link MAX_TOTAL_BODY_BYTES}) */
34
34
  maxTotalBodyBytes?: number;
35
35
  /** Counters of dropped requests and evicted bodies; otherwise the collector keeps private ones */
36
36
  evictions?: NetworkEvictions | undefined;
@@ -52,9 +52,9 @@ export interface NetworkCollectionOptions {
52
52
  * - The newest 10,000 finished requests are kept: past that the oldest finished
53
53
  * ones are dropped (counted in `evictions.requestsDropped`); requests in flight
54
54
  * are tracked separately and never dropped mid-flight
55
- * - Stored response bodies total at most 100MB: past that the oldest bodies are
56
- * replaced by a placeholder (counted in `evictions.bodiesEvicted`), their
57
- * request metadata stays
55
+ * - Stored request (post data) and response bodies total at most 100MB: past
56
+ * that the oldest bodies are replaced by a placeholder (counted in
57
+ * `evictions.bodiesEvicted`), their request metadata stays
58
58
  * - Response bodies are automatically skipped for images, fonts, CSS, and source maps (see DEFAULT_SKIP_BODY_PATTERNS)
59
59
  * - Response bodies larger than 5MB are skipped with a placeholder message
60
60
  * - By default, common tracking/analytics domains are filtered out (use includeAll to disable)
@@ -3,6 +3,7 @@ import { TypedCDPConnection } from '../connection/typed-cdp.js';
3
3
  import { MAX_NETWORK_REQUESTS, MAX_RESPONSE_SIZE, MAX_TOTAL_BODY_BYTES, CHROME_NETWORK_BUFFER_TOTAL, CHROME_NETWORK_BUFFER_PER_RESOURCE, CHROME_POST_DATA_LIMIT, } from '../constants.js';
4
4
  import { attachChildTargets } from './attachedTargets.js';
5
5
  import { createLogger } from '../ui/logging/index.js';
6
+ import { bodyFetchFailedReason, bodyGoneReason } from '../ui/messages/networkMessages.js';
6
7
  import { getErrorMessage } from '../utils/errors.js';
7
8
  import { filterDefined } from '../utils/objects.js';
8
9
  import { shouldExcludeDomain, shouldExcludeUrl, shouldFetchBodyWithReason } from './filters.js';
@@ -21,10 +22,38 @@ function shouldFilterRequest(url, includeAll, networkInclude, networkExclude) {
21
22
  }
22
23
  return false;
23
24
  }
25
+ /** Statuses whose responses never have a body (RFC 9110) */
26
+ const BODYLESS_STATUSES = new Set([204, 205, 304]);
27
+ /**
28
+ * Whether HTTP rules out a body for this response (HEAD, 1xx, 204, 205,
29
+ * 304), so a failed body fetch means nothing was missed.
30
+ *
31
+ * @param request - Finished request
32
+ * @returns True when the response has no body by definition
33
+ */
34
+ function responseHasNoBody(request) {
35
+ const status = request.status ?? 0;
36
+ return (request.method === 'HEAD' || (status >= 100 && status < 200) || BODYLESS_STATUSES.has(status));
37
+ }
38
+ /** Chrome's errors for a body it no longer has (evicted from its buffer, or never kept) */
39
+ const BODY_GONE_ERROR = /No (resource|data found for resource) with given identifier/;
40
+ /**
41
+ * Why a body fetch Chrome refused left no body.
42
+ *
43
+ * @param error - Rejection of `Network.getResponseBody`
44
+ * @returns Reason for `bodyNotCaptured`
45
+ */
46
+ function bodyFetchErrorReason(error) {
47
+ const message = getErrorMessage(error);
48
+ return BODY_GONE_ERROR.test(message) ? bodyGoneReason() : bodyFetchFailedReason(message);
49
+ }
24
50
  /**
25
51
  * Fetch response body for a request with cancellation support: a body that
26
52
  * arrives after its fetch was cancelled (removed from `pendingFetches`: the
27
- * collector stopped, or the request was dropped) is discarded.
53
+ * collector stopped, or the request was dropped) is discarded. A fetch Chrome
54
+ * refuses leaves a skipped-body placeholder with the reason (unless the fetch
55
+ * was cancelled, a body is already stored, or the response has no body by
56
+ * definition).
28
57
  *
29
58
  * @param cdp - CDP connection instance
30
59
  * @param requestId - Request ID to fetch body for
@@ -45,6 +74,11 @@ function fetchResponseBody(cdp, requestId, request, pendingFetches, retention, s
45
74
  })
46
75
  .catch((error) => {
47
76
  log.debug(`Failed to fetch response body for request ${requestId}: ${getErrorMessage(error)}`);
77
+ if (!pendingFetches.has(requestId))
78
+ return;
79
+ if (request.responseBody !== undefined || responseHasNoBody(request))
80
+ return;
81
+ request.responseBody = skippedBodyPlaceholder(bodyFetchErrorReason(error));
48
82
  })
49
83
  .finally(() => {
50
84
  pendingFetches.delete(requestId);
@@ -256,9 +290,9 @@ async function collectChildTargetNetwork(cdp) {
256
290
  * - The newest 10,000 finished requests are kept: past that the oldest finished
257
291
  * ones are dropped (counted in `evictions.requestsDropped`); requests in flight
258
292
  * are tracked separately and never dropped mid-flight
259
- * - Stored response bodies total at most 100MB: past that the oldest bodies are
260
- * replaced by a placeholder (counted in `evictions.bodiesEvicted`), their
261
- * request metadata stays
293
+ * - Stored request (post data) and response bodies total at most 100MB: past
294
+ * that the oldest bodies are replaced by a placeholder (counted in
295
+ * `evictions.bodiesEvicted`), their request metadata stays
262
296
  * - Response bodies are automatically skipped for images, fonts, CSS, and source maps (see DEFAULT_SKIP_BODY_PATTERNS)
263
297
  * - Response bodies larger than 5MB are skipped with a placeholder message
264
298
  * - By default, common tracking/analytics domains are filtered out (use includeAll to disable)