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
@@ -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
  import { SENSITIVE_NAME_SOURCE } from '../../runtime/dom/elementInfo.js';
9
18
  import { createLogger } from '../../ui/logging/index.js';
@@ -13,11 +22,41 @@ const log = createLogger('network');
13
22
  export const REDACTED = '[redacted]';
14
23
  /**
15
24
  * Field names holding credentials: password-like names (shared with form
16
- * masking), tokens, secrets, keys, sessions, signatures and credentials
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)
17
33
  */
18
- const SENSITIVE_FIELD = new RegExp(`${SENSITIVE_NAME_SOURCE}|token|secret|api[-_]?key|authoriz|credential|jwt|private[-_]?key|access[-_]?key|session|signature`, 'i');
19
- /** A body that is a form whatever its Content-Type: `k=v&k=v`, no whitespace, not JSON */
20
- const FORM_BODY = /^[^\s=&{["]+=[^\s&]*(?:&[^\s=&]+=[^\s&]*)*$/;
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 });
21
60
  /**
22
61
  * Whether a body field, form field or query parameter name looks like it
23
62
  * holds a credential.
@@ -26,7 +65,53 @@ const FORM_BODY = /^[^\s=&{["]+=[^\s&]*(?:&[^\s=&]+=[^\s&]*)*$/;
26
65
  * @returns True for password, token, secret, key, session and signature names
27
66
  */
28
67
  export function isSensitiveField(name) {
29
- return SENSITIVE_FIELD.test(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('');
30
115
  }
31
116
  /**
32
117
  * Redact credential values in an `&`-separated `name=value` list (a form body,
@@ -42,28 +127,61 @@ export function redactPairs(text, isSensitive, replacement) {
42
127
  .split('&')
43
128
  .map((pair) => {
44
129
  const eq = pair.indexOf('=');
45
- if (eq === -1 || !isSensitive(decodeName(pair.slice(0, eq))))
130
+ if (eq === -1)
46
131
  return pair;
47
- return `${pair.slice(0, eq)}=${replacement}`;
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}`;
48
137
  })
49
138
  .join('&');
50
139
  }
51
140
  /**
52
- * Redact credential fields of a request body: multipart parts, form fields
53
- * (by Content-Type or shape) and JSON fields at any depth.
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.
54
143
  *
55
144
  * @param text - Body text
56
- * @param mimeType - Content-Type of the body
145
+ * @param mimeType - Content-Type of the body (empty for a WebSocket message)
57
146
  * @returns Body with credential values replaced; the text unchanged when
58
- * there were none
147
+ * there were none or it is neither JSON, a form nor multipart
59
148
  */
60
- export function redactRequestBody(text, mimeType) {
149
+ export function redactBody(text, mimeType) {
61
150
  if (/multipart\/form-data/i.test(mimeType))
62
151
  return redactMultipart(text, mimeType);
63
- if (/x-www-form-urlencoded/i.test(mimeType) || FORM_BODY.test(text)) {
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))
64
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;
65
182
  }
66
- return redactJsonBody(text);
183
+ const redacted = redactBody(decoded, mimeType);
184
+ return redacted === decoded ? base64 : Buffer.from(redacted, 'utf8').toString('base64');
67
185
  }
68
186
  /**
69
187
  * Decode a form or query name.
@@ -97,10 +215,11 @@ function redactMultipart(text, mimeType) {
97
215
  return text.split(delimiter).map(redactPart).join(delimiter);
98
216
  }
99
217
  /**
100
- * Redact the value of one multipart part if its name holds a credential.
218
+ * Redact the value of one multipart part if its name holds a credential, or
219
+ * the JWTs in it.
101
220
  *
102
221
  * @param part - Text between two boundary delimiters
103
- * @returns The part, or the part with its value replaced
222
+ * @returns The part, or the part with its value or JWTs replaced
104
223
  */
105
224
  function redactPart(part) {
106
225
  const headerEnd = part.indexOf('\r\n\r\n');
@@ -108,61 +227,315 @@ function redactPart(part) {
108
227
  return part;
109
228
  const name = /;\s*name="([^"]*)"/i.exec(part.slice(0, headerEnd))?.[1];
110
229
  if (name === undefined || !isSensitiveField(name))
111
- return part;
230
+ return redactJwts(part, REDACTED);
112
231
  const valueEnd = part.endsWith('\r\n') ? part.length - 2 : part.length;
113
232
  return `${part.slice(0, headerEnd + 4)}${REDACTED}${part.slice(valueEnd)}`;
114
233
  }
115
234
  /**
116
- * Redact credential fields of a JSON body.
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.
117
241
  *
118
- * @param text - Body text
119
- * @returns Re-serialized JSON when a field was redacted, the text unchanged
120
- * when none was or it is not JSON, and {@link REDACTED} for JSON too deep
121
- * to walk
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
122
246
  */
123
- function redactJsonBody(text) {
124
- if (!/^\s*[[{]/.test(text))
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)
125
261
  return text;
126
- let parsed;
127
- try {
128
- parsed = JSON.parse(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));
129
279
  }
130
- catch (error) {
131
- log.debug(`Request body not JSON: ${getErrorMessage(error)}`);
132
- return error instanceof SyntaxError ? text : REDACTED;
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;
133
450
  }
451
+ if (!/[[{]/.test(scan.text.slice(start + 1, end - 1)))
452
+ return undefined;
453
+ let decoded;
134
454
  try {
135
- const hits = { count: 0 };
136
- const redacted = redactJson(parsed, false, hits);
137
- return hits.count > 0 ? JSON.stringify(redacted) : text;
455
+ decoded = JSON.parse(scan.text.slice(start, end));
138
456
  }
139
457
  catch (error) {
140
- log.debug(`Request body redacted whole: ${getErrorMessage(error)}`);
141
- return REDACTED;
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;
142
492
  }
143
493
  }
144
494
  /**
145
- * Copy of parsed JSON with the primitives under credential names replaced.
146
- * Objects and arrays keep their structure; every string, number and boolean
147
- * inside a credential-named field is replaced, at any depth.
495
+ * Index of the next `\n` at or after an index, or the text length.
148
496
  *
149
- * @param value - Parsed JSON value
150
- * @param sensitive - Whether an enclosing field name holds a credential
151
- * @param hits - Counter of replaced values
152
- * @returns Redacted copy
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
153
500
  */
154
- function redactJson(value, sensitive, hits) {
155
- if (Array.isArray(value))
156
- return value.map((item) => redactJson(item, sensitive, hits));
157
- if (value !== null && typeof value === 'object') {
158
- return Object.fromEntries(Object.entries(value).map(([key, field]) => [
159
- key,
160
- redactJson(field, sensitive || isSensitiveField(key), hits),
161
- ]));
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;
162
539
  }
163
- if (!sensitive || !['string', 'number', 'boolean'].includes(typeof value))
164
- return value;
165
- hits.count++;
166
- return REDACTED;
167
540
  }
168
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)