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
|
@@ -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
|
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
|
|
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
|
|
19
|
-
/**
|
|
20
|
-
|
|
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
|
|
130
|
+
if (eq === -1)
|
|
46
131
|
return pair;
|
|
47
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
*
|
|
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 -
|
|
119
|
-
* @
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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(`
|
|
141
|
-
return
|
|
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
|
-
*
|
|
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
|
|
150
|
-
* @param
|
|
151
|
-
* @
|
|
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
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
56
|
-
* replaced by a placeholder (counted in
|
|
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
|
|
260
|
-
* replaced by a placeholder (counted in
|
|
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)
|