browser-debugger-cli 0.13.0 → 0.15.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 (159) hide show
  1. package/.claude/skills/bdg/SKILL.md +101 -187
  2. package/README.md +4 -4
  3. package/dist/commands/cdp.js +1 -0
  4. package/dist/commands/cleanup.js +3 -0
  5. package/dist/commands/console.js +5 -1
  6. package/dist/commands/dom/a11y.d.ts +1 -1
  7. package/dist/commands/dom/a11y.js +20 -20
  8. package/dist/commands/dom/eval.d.ts +3 -1
  9. package/dist/commands/dom/eval.js +8 -5
  10. package/dist/commands/dom/formInteraction.js +1 -1
  11. package/dist/commands/dom/get.js +25 -7
  12. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  13. package/dist/commands/dom/helpers/evalResult.js +59 -0
  14. package/dist/commands/dom/index.js +7 -2
  15. package/dist/commands/dom/query.d.ts +2 -1
  16. package/dist/commands/dom/query.js +5 -3
  17. package/dist/commands/dom/screenshot.js +1 -0
  18. package/dist/commands/helpJson.d.ts +1 -1
  19. package/dist/commands/helpJson.js +4 -4
  20. package/dist/commands/helpTopic.js +10 -4
  21. package/dist/commands/network/har.js +18 -14
  22. package/dist/commands/network/list.js +46 -3
  23. package/dist/commands/optionBehaviors.d.ts +25 -2
  24. package/dist/commands/optionBehaviors.js +60 -42
  25. package/dist/commands/peek.js +3 -0
  26. package/dist/commands/shared/CommandRunner.js +13 -13
  27. package/dist/commands/shared/daemonErrorHandler.js +2 -2
  28. package/dist/commands/shared/dataFetcher.d.ts +4 -2
  29. package/dist/commands/shared/dataFetcher.js +11 -3
  30. package/dist/commands/shared/handleValidationError.js +3 -3
  31. package/dist/commands/shared/optionTypes.d.ts +15 -3
  32. package/dist/commands/shared/outputFile.d.ts +2 -1
  33. package/dist/commands/shared/outputFile.js +7 -4
  34. package/dist/commands/shared/startHelpers.js +3 -3
  35. package/dist/commands/status.js +3 -1
  36. package/dist/commands/stop.js +2 -1
  37. package/dist/connection/chromeIdentity.d.ts +8 -2
  38. package/dist/connection/chromeIdentity.js +85 -13
  39. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  40. package/dist/connection/launcher/flagsBuilder.js +107 -23
  41. package/dist/connection/launcher.d.ts +1 -1
  42. package/dist/connection/launcher.js +1 -2
  43. package/dist/constants.d.ts +31 -5
  44. package/dist/constants.js +37 -5
  45. package/dist/daemon/SessionController.js +2 -0
  46. package/dist/daemon/launcher.d.ts +17 -3
  47. package/dist/daemon/launcher.js +37 -7
  48. package/dist/daemon/session/Session.d.ts +2 -1
  49. package/dist/daemon/session/Session.js +10 -2
  50. package/dist/daemon/session/TelemetryStore.d.ts +7 -0
  51. package/dist/daemon/session/TelemetryStore.js +6 -0
  52. package/dist/daemon/session/commandRegistry.js +25 -7
  53. package/dist/daemon/session/matchedStylesReset.d.ts +26 -0
  54. package/dist/daemon/session/matchedStylesReset.js +46 -0
  55. package/dist/daemon/session/plugins.js +1 -0
  56. package/dist/daemon/session/triggeredRequests.d.ts +0 -5
  57. package/dist/daemon/session/triggeredRequests.js +13 -7
  58. package/dist/daemon.js +8742 -8315
  59. package/dist/errors/messages.d.ts +31 -0
  60. package/dist/errors/messages.js +96 -6
  61. package/dist/index.js +1129 -548
  62. package/dist/ipc/client.d.ts +6 -1
  63. package/dist/ipc/client.js +11 -2
  64. package/dist/ipc/protocol/commands.d.ts +8 -0
  65. package/dist/ipc/protocol/inspectTypes.d.ts +5 -2
  66. package/dist/ipc/session/types.d.ts +5 -1
  67. package/dist/ipc/transport/index.d.ts +6 -0
  68. package/dist/ipc/transport/index.js +16 -1
  69. package/dist/program.d.ts +14 -0
  70. package/dist/program.js +53 -0
  71. package/dist/runtime/dom/elementGeometry.d.ts +23 -0
  72. package/dist/runtime/dom/elementGeometry.js +17 -15
  73. package/dist/runtime/dom/elementInfo.d.ts +13 -4
  74. package/dist/runtime/dom/elementInfo.js +15 -5
  75. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  76. package/dist/runtime/dom/evalHelpers.js +40 -12
  77. package/dist/runtime/dom/frameScopedConnection.d.ts +7 -0
  78. package/dist/runtime/dom/frameScopedConnection.js +2 -2
  79. package/dist/runtime/dom/frames.d.ts +2 -1
  80. package/dist/runtime/dom/frames.js +3 -1
  81. package/dist/runtime/dom/inspect.d.ts +17 -3
  82. package/dist/runtime/dom/inspect.js +40 -26
  83. package/dist/runtime/dom/inspectModel.d.ts +3 -3
  84. package/dist/runtime/dom/inspectRules.d.ts +29 -3
  85. package/dist/runtime/dom/inspectRules.js +205 -11
  86. package/dist/runtime/dom/layout.d.ts +0 -2
  87. package/dist/runtime/dom/layout.js +1 -2
  88. package/dist/runtime/dom/reactEventHelpers.d.ts +4 -1
  89. package/dist/runtime/dom/reactEventHelpers.js +9 -2
  90. package/dist/runtime/dom/targetNode.d.ts +10 -6
  91. package/dist/runtime/dom/targetNode.js +15 -8
  92. package/dist/runtime/page/emulation.js +6 -5
  93. package/dist/runtime/page/userAgent.d.ts +86 -2
  94. package/dist/runtime/page/userAgent.js +154 -33
  95. package/dist/session/paths.d.ts +38 -3
  96. package/dist/session/paths.js +154 -7
  97. package/dist/session/portClaims.d.ts +0 -8
  98. package/dist/session/portClaims.js +1 -22
  99. package/dist/session/sessionList.d.ts +5 -1
  100. package/dist/session/sessionList.js +5 -1
  101. package/dist/telemetry/a11y.d.ts +15 -1
  102. package/dist/telemetry/a11y.js +83 -0
  103. package/dist/telemetry/har/builder.d.ts +12 -1
  104. package/dist/telemetry/har/builder.js +11 -3
  105. package/dist/telemetry/har/sanitize.d.ts +24 -0
  106. package/dist/telemetry/har/sanitize.js +138 -0
  107. package/dist/telemetry/har/sanitizeBody.d.ts +38 -0
  108. package/dist/telemetry/har/sanitizeBody.js +168 -0
  109. package/dist/telemetry/network.d.ts +13 -16
  110. package/dist/telemetry/network.js +30 -52
  111. package/dist/telemetry/networkRetention.d.ts +83 -0
  112. package/dist/telemetry/networkRetention.js +117 -0
  113. package/dist/types.d.ts +26 -0
  114. package/dist/ui/OutputBuilder.d.ts +10 -0
  115. package/dist/ui/OutputBuilder.js +12 -0
  116. package/dist/ui/formatters/a11y.d.ts +5 -7
  117. package/dist/ui/formatters/a11y.js +7 -61
  118. package/dist/ui/formatters/console/chronological.js +4 -4
  119. package/dist/ui/formatters/console/follow.d.ts +4 -2
  120. package/dist/ui/formatters/console/follow.js +6 -3
  121. package/dist/ui/formatters/console/json.d.ts +3 -6
  122. package/dist/ui/formatters/console/json.js +9 -13
  123. package/dist/ui/formatters/console/shared.d.ts +17 -2
  124. package/dist/ui/formatters/console/shared.js +17 -0
  125. package/dist/ui/formatters/console/summarize.d.ts +2 -2
  126. package/dist/ui/formatters/console/summarize.js +22 -7
  127. package/dist/ui/formatters/console.d.ts +1 -1
  128. package/dist/ui/formatters/console.js +1 -5
  129. package/dist/ui/formatters/details.js +1 -1
  130. package/dist/ui/formatters/dom.d.ts +13 -4
  131. package/dist/ui/formatters/dom.js +25 -7
  132. package/dist/ui/formatters/layout.js +2 -1
  133. package/dist/ui/formatters/longValues.d.ts +14 -0
  134. package/dist/ui/formatters/longValues.js +23 -0
  135. package/dist/ui/formatters/networkList.d.ts +8 -2
  136. package/dist/ui/formatters/networkList.js +11 -2
  137. package/dist/ui/formatters/preview.d.ts +4 -1
  138. package/dist/ui/formatters/preview.js +55 -13
  139. package/dist/ui/formatters/sessions.d.ts +3 -2
  140. package/dist/ui/formatters/sessions.js +10 -3
  141. package/dist/ui/formatters/status.js +7 -0
  142. package/dist/ui/formatters/triggeredRequests.js +2 -1
  143. package/dist/ui/messages/chrome.d.ts +34 -7
  144. package/dist/ui/messages/chrome.js +81 -15
  145. package/dist/ui/messages/commands.d.ts +29 -8
  146. package/dist/ui/messages/commands.js +36 -8
  147. package/dist/ui/messages/networkMessages.d.ts +50 -0
  148. package/dist/ui/messages/networkMessages.js +66 -0
  149. package/dist/ui/messages/session.d.ts +8 -0
  150. package/dist/ui/messages/session.js +10 -0
  151. package/dist/utils/atomicFile.d.ts +2 -1
  152. package/dist/utils/atomicFile.js +5 -2
  153. package/dist/utils/directories.d.ts +41 -0
  154. package/dist/utils/directories.js +48 -0
  155. package/dist/utils/http.d.ts +9 -2
  156. package/dist/utils/http.js +4 -3
  157. package/dist/utils/strings.d.ts +19 -0
  158. package/dist/utils/strings.js +16 -0
  159. package/package.json +2 -2
@@ -9,7 +9,8 @@ import * as fs from 'fs';
9
9
  import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import { createLogger, logDebugError } from '../ui/logging/index.js';
12
- import { makeDirectory } from '../utils/directories.js';
12
+ import { dirTrustProblem, makeDirectory, } from '../utils/directories.js';
13
+ import { getErrorMessage } from '../utils/errors.js';
13
14
  const log = createLogger('session');
14
15
  /**
15
16
  * Session file paths relative to ~/.bdg/
@@ -57,12 +58,21 @@ export const SESSION_STATE_FILES = [
57
58
  * @returns Full path to the base session directory
58
59
  */
59
60
  export function getSessionBaseDir() {
60
- const override = process.env[SESSION_DIR_OVERRIDE_ENV];
61
- if (override && override.trim().length > 0) {
61
+ const override = sessionDirOverride();
62
+ if (override !== null) {
62
63
  return path.isAbsolute(override) ? override : path.resolve(override);
63
64
  }
64
65
  return path.join(os.homedir(), '.bdg');
65
66
  }
67
+ /**
68
+ * The base session directory the user chose with `$BDG_SESSION_DIR`.
69
+ *
70
+ * @returns The variable's value, or null when unset or blank (`~/.bdg`)
71
+ */
72
+ function sessionDirOverride() {
73
+ const override = process.env[SESSION_DIR_OVERRIDE_ENV];
74
+ return override && override.trim().length > 0 ? override : null;
75
+ }
66
76
  /**
67
77
  * The selected session name (`--session` / `BDG_SESSION`), lower-cased:
68
78
  * session names are case-insensitive.
@@ -163,16 +173,153 @@ export function getSessionFilePath(fileType) {
163
173
  export function getDaemonSocketPath() {
164
174
  return getSessionFilePath('DAEMON_SOCKET');
165
175
  }
176
+ /** Mode of the session directories bdg creates: only the user can enter them */
177
+ const PRIVATE_DIR_MODE = 0o700;
178
+ /** Permission bits that give group or others any access */
179
+ const GROUP_OTHER_ACCESS = 0o077;
166
180
  /**
167
181
  * Ensure the session directory exists.
168
182
  *
169
- * Creates ~/.bdg/ if it doesn't exist. Safe to call multiple times (idempotent).
170
- * A path that cannot hold a directory is refused before `mkdir`, which
171
- * would spin on a pseudo-filesystem ({@link makeDirectory}).
183
+ * Creates it, and missing parents (the base directory, `sessions/`), with
184
+ * mode 0700. Safe to call multiple times (idempotent). A path that cannot
185
+ * hold a directory is refused before `mkdir`, which would spin on a
186
+ * pseudo-filesystem ({@link makeDirectory}).
172
187
  *
173
188
  * @throws Error if the directory cannot be created
174
189
  */
175
190
  export function ensureSessionDir() {
176
- makeDirectory(getSessionDir());
191
+ makeDirectory(getSessionDir(), PRIVATE_DIR_MODE);
192
+ }
193
+ /** Session directories need not keep group write out (umask 002 made them 0775) */
194
+ const SESSION_DIR_TRUST = { allowGroupWrite: true };
195
+ /**
196
+ * Directories whose trust a session directory depends on: the base
197
+ * directory and each directory from it down to the session directory
198
+ * (`<base>`, `<base>/sessions`, `<base>/sessions/<name>`), or the directory
199
+ * alone when it is outside the base.
200
+ *
201
+ * @param dir - Session directory
202
+ * @returns Directories, outermost first
203
+ */
204
+ function sessionDirChain(dir) {
205
+ const base = getSessionBaseDir();
206
+ const relative = path.relative(base, dir);
207
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
208
+ return [{ dir, bdgOwned: false, isBase: false }];
209
+ }
210
+ const chain = [{ dir: base, bdgOwned: sessionDirOverride() === null, isBase: true }];
211
+ let current = base;
212
+ for (const part of relative.split(path.sep).filter(Boolean)) {
213
+ current = path.join(current, part);
214
+ chain.push({ dir: current, bdgOwned: true, isBase: false });
215
+ }
216
+ return chain;
217
+ }
218
+ /**
219
+ * Remove group and other access from a directory of the user's (best
220
+ * effort). The directory is opened without following a symlink and changed
221
+ * through that descriptor, so a path swapped after the trust check is not
222
+ * affected.
223
+ *
224
+ * @param dir - Trusted directory
225
+ */
226
+ function tightenDir(dir) {
227
+ if (process.platform === 'win32')
228
+ return;
229
+ let fd;
230
+ try {
231
+ const { O_RDONLY, O_DIRECTORY, O_NOFOLLOW } = fs.constants;
232
+ fd = fs.openSync(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
233
+ const stat = fs.fstatSync(fd);
234
+ if (stat.uid !== process.getuid?.() || (stat.mode & GROUP_OTHER_ACCESS) === 0)
235
+ return;
236
+ fs.fchmodSync(fd, PRIVATE_DIR_MODE);
237
+ log.debug(`Session directory ${dir} restricted to mode 700`);
238
+ }
239
+ catch (error) {
240
+ log.debug(`Session directory ${dir} not restricted: ${getErrorMessage(error)}`);
241
+ }
242
+ finally {
243
+ if (fd !== undefined)
244
+ fs.closeSync(fd);
245
+ }
246
+ }
247
+ /**
248
+ * Why a directory of the chain cannot be trusted. The base directory alone
249
+ * may be a symlink (`~/.bdg` kept with dotfiles or on another disk): its
250
+ * target is checked instead. `sessions/` and session directories must be
251
+ * real directories.
252
+ *
253
+ * @param entry - Directory of the chain
254
+ * @returns The problem, or null when it can be trusted
255
+ * @throws Error from `lstat`/`realpath` (e.g. `ENOENT`)
256
+ */
257
+ function chainDirProblem(entry) {
258
+ const problem = dirTrustProblem(entry.dir, SESSION_DIR_TRUST);
259
+ if (problem?.kind !== 'symlink' || !entry.isBase)
260
+ return problem;
261
+ const target = fs.realpathSync(entry.dir);
262
+ const targetProblem = dirTrustProblem(target, SESSION_DIR_TRUST);
263
+ return (targetProblem && {
264
+ ...targetProblem,
265
+ reason: `it links to ${target}, which is ${targetProblem.reason}`,
266
+ });
267
+ }
268
+ /**
269
+ * Accept a trusted directory: tighten it to 0700 when bdg owns it, leave a
270
+ * directory the user chose as it is (noted in the debug log when group or
271
+ * others can use it).
272
+ *
273
+ * @param entry - Trusted directory
274
+ */
275
+ function acceptTrustedDir(entry) {
276
+ if (entry.bdgOwned) {
277
+ tightenDir(entry.dir);
278
+ return;
279
+ }
280
+ const mode = fs.statSync(entry.dir, { throwIfNoEntry: false })?.mode ?? 0;
281
+ if ((mode & GROUP_OTHER_ACCESS) === 0)
282
+ return;
283
+ log.debug(`Session directory ${entry.dir} is open to group or others; left as is (chosen with ${SESSION_DIR_OVERRIDE_ENV})`);
284
+ }
285
+ /**
286
+ * Check that a session directory and the directories above it up to the base
287
+ * directory can be trusted before a daemon is started there or its socket is
288
+ * connected to: another user who can write to one of them could replace the
289
+ * socket (and receive every command) or plant files. Each existing directory
290
+ * must be a real directory (not a symlink), owned by the user, and not
291
+ * writable by others ({@link dirTrustProblem}); group write is accepted, since
292
+ * under umask 002 (per-user groups) older versions created `~/.bdg` 0775.
293
+ * The base directory may be a symlink whose target passes the same rule
294
+ * ({@link chainDirProblem}). A directory owned by another uid (a bind mount,
295
+ * `sudo -E`) is refused.
296
+ *
297
+ * Trusted directories bdg owns (the default `~/.bdg`, `sessions/` and named
298
+ * session directories) that group or others can still use are tightened to
299
+ * 0700; a base directory chosen with `$BDG_SESSION_DIR`, or a symlinked base
300
+ * (opened without following links), is never changed.
301
+ * Missing paths are skipped (as is a path through a file, which fails on its
302
+ * own): {@link ensureSessionDir} creates them 0700.
303
+ *
304
+ * @param dir - Session directory (default: the selected session's)
305
+ * @returns The first untrusted directory and why, or null
306
+ */
307
+ export function secureSessionDir(dir = getSessionDir()) {
308
+ for (const entry of sessionDirChain(dir)) {
309
+ let problem;
310
+ try {
311
+ problem = chainDirProblem(entry);
312
+ }
313
+ catch (error) {
314
+ const code = error.code;
315
+ if (code === 'ENOENT' || code === 'ENOTDIR')
316
+ continue;
317
+ problem = { reason: getErrorMessage(error), kind: 'not-directory' };
318
+ }
319
+ if (problem !== null)
320
+ return { dir: entry.dir, ...problem, bdgOwned: entry.bdgOwned };
321
+ acceptTrustedDir(entry);
322
+ }
323
+ return null;
177
324
  }
178
325
  //# sourceMappingURL=paths.js.map
@@ -30,14 +30,6 @@ export declare function readPortFile(portPath: string): number | null;
30
30
  * @returns `$BDG_PORT_REGISTRY_DIR`, else `<os temp dir>/bdg-ports-<uid>`
31
31
  */
32
32
  export declare function getPortRegistryDir(): string;
33
- /**
34
- * Whether a path is a directory the current user can trust: a real directory
35
- * (not a symlink), owned by the user, not writable by group or others.
36
- *
37
- * @param dir - Directory
38
- * @returns Why it cannot be trusted, or null if it can
39
- */
40
- export declare function untrustedDirReason(dir: string): string | null;
41
33
  /**
42
34
  * Session directories other than the selected one that bdg knows of: those of
43
35
  * this base directory, and those of any base directory found in the
@@ -20,7 +20,7 @@ import * as path from 'path';
20
20
  import { getSessionBaseDir, getSessionDir, listSessionDirs, sessionFilePathIn, } from './paths.js';
21
21
  import { createLogger, logDebugError } from '../ui/logging/index.js';
22
22
  import { delay } from '../utils/async.js';
23
- import { makeDirectory } from '../utils/directories.js';
23
+ import { makeDirectory, untrustedDirReason } from '../utils/directories.js';
24
24
  const log = createLogger('session');
25
25
  /** Lock file guarding port selection, in the port registry (or base session) directory */
26
26
  const PORT_LOCK_FILE = 'port.lock';
@@ -33,8 +33,6 @@ const LOCK_WAIT_MS = 5000;
33
33
  /** Lock age after which its holder is assumed dead */
34
34
  const STALE_LOCK_MS = 10000;
35
35
  const LOCK_POLL_MS = 25;
36
- /** Permission bits that let other users write */
37
- const GROUP_OTHER_WRITE = 0o022;
38
36
  /**
39
37
  * Read a port number from a `port.txt` file.
40
38
  *
@@ -64,25 +62,6 @@ export function getPortRegistryDir() {
64
62
  const uid = process.getuid?.();
65
63
  return path.join(os.tmpdir(), uid === undefined ? 'bdg-ports' : `bdg-ports-${uid}`);
66
64
  }
67
- /**
68
- * Whether a path is a directory the current user can trust: a real directory
69
- * (not a symlink), owned by the user, not writable by group or others.
70
- *
71
- * @param dir - Directory
72
- * @returns Why it cannot be trusted, or null if it can
73
- */
74
- export function untrustedDirReason(dir) {
75
- const stat = fs.lstatSync(dir);
76
- if (!stat.isDirectory())
77
- return 'not a directory';
78
- const uid = process.getuid?.();
79
- if (uid !== undefined && stat.uid !== uid)
80
- return `owned by uid ${stat.uid}`;
81
- if (process.platform !== 'win32' && (stat.mode & GROUP_OTHER_WRITE) !== 0) {
82
- return `writable by others (mode ${(stat.mode & 0o777).toString(8)})`;
83
- }
84
- return null;
85
- }
86
65
  /**
87
66
  * The port registry directory, created (mode 0700) if missing, if it can be
88
67
  * trusted (see {@link untrustedDirReason}).
@@ -11,8 +11,10 @@ import { type SessionDirEntry } from './paths.js';
11
11
  * `unresponsive`), died and left its Chrome running (`crashed`) or only
12
12
  * files (`stale`), or exited after the session ended without `bdg stop`
13
13
  * (`ended`: Chrome crashed or was closed, the page was closed, `--timeout`).
14
+ * `untrusted`: something listens on its socket, but its directory is not
15
+ * safe to use, so it is not asked.
14
16
  */
15
- export type RunningSessionState = 'active' | 'starting' | 'ending' | 'unresponsive' | 'crashed' | 'stale' | 'ended';
17
+ export type RunningSessionState = 'active' | 'starting' | 'ending' | 'unresponsive' | 'crashed' | 'stale' | 'ended' | 'untrusted';
16
18
  /**
17
19
  * One session in the list.
18
20
  */
@@ -33,6 +35,8 @@ export interface RunningSessionInfo {
33
35
  endReason?: UnexpectedEndReason;
34
36
  /** When an `ended` session ended (epoch ms) */
35
37
  endedAt?: number;
38
+ /** Why an `untrusted` session's directory is not safe to use */
39
+ untrusted?: string;
36
40
  }
37
41
  /**
38
42
  * Summarize a daemon's status response.
@@ -7,7 +7,7 @@ import { getStatus } from '../ipc/client.js';
7
7
  import { isSessionChrome, readLiveDaemonPid } from './cleanup/staleSession.js';
8
8
  import { probeDaemonSocket } from './daemonSocket.js';
9
9
  import { readLastSessionEnd } from './lastSession.js';
10
- import { SESSION_STATE_FILES, getNamedSessionDir, listSessionDirs, sessionFilePathIn, } from './paths.js';
10
+ import { SESSION_STATE_FILES, getNamedSessionDir, listSessionDirs, secureSessionDir, sessionFilePathIn, } from './paths.js';
11
11
  import { readPidFromFile } from './pid.js';
12
12
  import { readPortFile } from './portClaims.js';
13
13
  import { isValidSessionName, normalizeSessionName } from './sessionName.js';
@@ -48,6 +48,10 @@ async function describeSession(entry) {
48
48
  const probe = await probeDaemonSocket(socketPath);
49
49
  if (probe !== 'alive')
50
50
  return describeWithoutSocket(entry, probe);
51
+ const untrusted = secureSessionDir(dir);
52
+ if (untrusted) {
53
+ return { name, state: 'untrusted', untrusted: `${untrusted.dir}: ${untrusted.reason}` };
54
+ }
51
55
  try {
52
56
  const response = await getStatus(socketPath);
53
57
  if (response.status === 'ok' && response.data)
@@ -1,5 +1,5 @@
1
1
  import type { Protocol } from '../connection/typed-cdp.js';
2
- import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, NodeRef } from '../types.js';
2
+ import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, ListedA11yTree, NodeRef } from '../types.js';
3
3
  /**
4
4
  * Builds accessibility tree from raw CDP nodes.
5
5
  *
@@ -14,6 +14,20 @@ import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, NodeRef } f
14
14
  * @throws Error if no root node found
15
15
  */
16
16
  export declare function buildTreeFromRawNodes(rawNodes: Protocol.Accessibility.AXNode[]): A11yTree;
17
+ /**
18
+ * The nodes `dom a11y tree` lists, depth-first from the root (then nodes not
19
+ * under it, such as frame content): the first `limit` (0 = all) no deeper
20
+ * than `maxDepth`. Noise ({@link isTreeNoise}) is counted as skipped and its
21
+ * children move up a level, so the budget goes to meaningful nodes; nodes cut
22
+ * by the limit or depth are counted as omitted. Every node of the tree is
23
+ * listed, omitted or skipped.
24
+ *
25
+ * @param tree - Accessibility tree
26
+ * @param limit - Nodes to list (0 = all listable ones)
27
+ * @param maxDepth - Deepest level to list (0 = root only; undefined = all)
28
+ * @returns Listed nodes with their depth, the tree size, and how many were cut or skipped
29
+ */
30
+ export declare function listA11yTree(tree: A11yTree, limit: number, maxDepth?: number): ListedA11yTree;
17
31
  /**
18
32
  * Collects the full accessibility tree from the page via IPC.
19
33
  *
@@ -47,6 +47,89 @@ export function buildTreeFromRawNodes(rawNodes) {
47
47
  const root = nodes.get(rootRaw.nodeId);
48
48
  return { root, nodes, count: nodes.size };
49
49
  }
50
+ /** Roles that only lay out their children and say nothing themselves */
51
+ const LAYOUT_ROLES = new Set([
52
+ 'generic',
53
+ 'none',
54
+ 'presentation',
55
+ 'LayoutTable',
56
+ 'LayoutTableRow',
57
+ 'LayoutTableCell',
58
+ ]);
59
+ /**
60
+ * Whether a node is left out of `dom a11y tree`: text boxes, blank text,
61
+ * text that repeats its parent's name, and nameless layout wrappers.
62
+ *
63
+ * @param node - Accessibility node
64
+ * @param parentName - Name of the nearest named ancestor
65
+ * @returns True when the node says nothing of its own
66
+ */
67
+ function isTreeNoise(node, parentName) {
68
+ return (node.role === 'InlineTextBox' ||
69
+ (node.role === 'StaticText' && (node.name === parentName || !node.name?.trim())) ||
70
+ (LAYOUT_ROLES.has(node.role) && !node.name));
71
+ }
72
+ /**
73
+ * The nodes `dom a11y tree` lists, depth-first from the root (then nodes not
74
+ * under it, such as frame content): the first `limit` (0 = all) no deeper
75
+ * than `maxDepth`. Noise ({@link isTreeNoise}) is counted as skipped and its
76
+ * children move up a level, so the budget goes to meaningful nodes; nodes cut
77
+ * by the limit or depth are counted as omitted. Every node of the tree is
78
+ * listed, omitted or skipped.
79
+ *
80
+ * @param tree - Accessibility tree
81
+ * @param limit - Nodes to list (0 = all listable ones)
82
+ * @param maxDepth - Deepest level to list (0 = root only; undefined = all)
83
+ * @returns Listed nodes with their depth, the tree size, and how many were cut or skipped
84
+ */
85
+ export function listA11yTree(tree, limit, maxDepth) {
86
+ const nodes = [];
87
+ const visited = new Set();
88
+ let omitted = 0;
89
+ let skipped = 0;
90
+ const visit = (node, depth, parentName) => {
91
+ if (visited.has(node.nodeId))
92
+ return;
93
+ visited.add(node.nodeId);
94
+ const listable = !isTreeNoise(node, parentName);
95
+ if (listable) {
96
+ const fits = (limit === 0 || nodes.length < limit) && (maxDepth ?? depth) >= depth;
97
+ if (fits)
98
+ nodes.push(listedNode(node, depth));
99
+ else
100
+ omitted++;
101
+ }
102
+ else {
103
+ skipped++;
104
+ }
105
+ for (const childId of node.childIds ?? []) {
106
+ const child = tree.nodes.get(childId);
107
+ if (child)
108
+ visit(child, listable ? depth + 1 : depth, node.name ?? parentName);
109
+ }
110
+ };
111
+ visit(tree.root, 0, undefined);
112
+ for (const node of tree.nodes.values())
113
+ visit(node, 0, undefined);
114
+ return {
115
+ nodes,
116
+ count: tree.count,
117
+ ...(omitted > 0 && { omitted }),
118
+ ...(skipped > 0 && { skipped }),
119
+ };
120
+ }
121
+ /**
122
+ * A node as the tree lists it: its depth instead of child ids (its children
123
+ * follow it, one level deeper).
124
+ *
125
+ * @param node - Accessibility node
126
+ * @param depth - Its level in the listing
127
+ * @returns Listed node
128
+ */
129
+ function listedNode(node, depth) {
130
+ const { childIds: _childIds, ...rest } = node;
131
+ return { ...rest, depth };
132
+ }
50
133
  /**
51
134
  * Ids of a node's children as shown: ignored children are replaced by their
52
135
  * own non-ignored descendants; ids missing from the tree are dropped.
@@ -18,11 +18,22 @@ export interface HARMetadata {
18
18
  /** Target title (optional) */
19
19
  targetTitle?: string;
20
20
  }
21
+ /**
22
+ * Options for HAR generation.
23
+ */
24
+ export interface HAROptions {
25
+ /** Keep credentials as captured instead of redacting them (see sanitize.ts) */
26
+ includeSensitive?: boolean;
27
+ }
21
28
  /**
22
29
  * Build HAR 1.2 format from network telemetry data.
23
30
  *
31
+ * Credentials are redacted (`log.comment` says so) unless
32
+ * `options.includeSensitive` is set.
33
+ *
24
34
  * @param requests - Array of network requests collected during session
25
35
  * @param metadata - Metadata for HAR creator/browser info
36
+ * @param options - Whether to keep credentials
26
37
  * @returns Complete HAR object
27
38
  *
28
39
  * @remarks
@@ -39,5 +50,5 @@ export interface HARMetadata {
39
50
  * fs.writeFileSync('capture.har', JSON.stringify(har, null, 2));
40
51
  * ```
41
52
  */
42
- export declare function buildHAR(requests: NetworkRequest[], metadata: HARMetadata): HAR;
53
+ export declare function buildHAR(requests: NetworkRequest[], metadata: HARMetadata, options?: HAROptions): HAR;
43
54
  //# sourceMappingURL=builder.d.ts.map
@@ -2,7 +2,9 @@
2
2
  * HAR (HTTP Archive) builder for transforming network telemetry to HAR 1.2 format.
3
3
  */
4
4
  import { createRequire } from 'node:module';
5
- import { skippedBodyReason } from '../network.js';
5
+ import { sanitizeEntry } from './sanitize.js';
6
+ import { skippedBodyReason } from '../networkRetention.js';
7
+ import { harSanitizedComment } from '../../ui/messages/networkMessages.js';
6
8
  /**
7
9
  * Loads Node builtins on first use: importing `node:http` in an ES module
8
10
  * reads all its exports, which loads undici and zlib (about 8 ms of every CLI
@@ -14,8 +16,12 @@ const DEFAULT_HTTP_VERSION = 'HTTP/1.1';
14
16
  /**
15
17
  * Build HAR 1.2 format from network telemetry data.
16
18
  *
19
+ * Credentials are redacted (`log.comment` says so) unless
20
+ * `options.includeSensitive` is set.
21
+ *
17
22
  * @param requests - Array of network requests collected during session
18
23
  * @param metadata - Metadata for HAR creator/browser info
24
+ * @param options - Whether to keep credentials
19
25
  * @returns Complete HAR object
20
26
  *
21
27
  * @remarks
@@ -32,8 +38,9 @@ const DEFAULT_HTTP_VERSION = 'HTTP/1.1';
32
38
  * fs.writeFileSync('capture.har', JSON.stringify(har, null, 2));
33
39
  * ```
34
40
  */
35
- export function buildHAR(requests, metadata) {
36
- const entries = [...requests].sort((a, b) => a.timestamp - b.timestamp).map(buildEntry);
41
+ export function buildHAR(requests, metadata, options = {}) {
42
+ const built = [...requests].sort((a, b) => a.timestamp - b.timestamp).map(buildEntry);
43
+ const entries = options.includeSensitive ? built : built.map(sanitizeEntry);
37
44
  const log = {
38
45
  version: '1.2',
39
46
  creator: {
@@ -42,6 +49,7 @@ export function buildHAR(requests, metadata) {
42
49
  comment: 'Browser Debugger CLI - https://github.com/szymdzum/browser-debugger-cli',
43
50
  },
44
51
  entries,
52
+ ...(!options.includeSensitive && { comment: harSanitizedComment() }),
45
53
  };
46
54
  if (metadata.chromeVersion) {
47
55
  log.browser = {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Credential redaction for HAR exports.
3
+ *
4
+ * HAR files are made to be shared (bug reports, tickets), so `bdg network har`
5
+ * redacts credentials by default, as Chrome DevTools does since Chrome 130.
6
+ * Chrome's sanitized export drops the `Cookie`, `Set-Cookie` and
7
+ * `Authorization` headers and empties the `cookies` arrays; bdg instead keeps
8
+ * every header, cookie and parameter name (and cookie attributes such as
9
+ * `httpOnly`) with the value `[redacted]`, so the export still shows that a
10
+ * request was authenticated and which cookies were set. It also covers API
11
+ * key, token and session headers, credential query parameters in URLs
12
+ * (`?code=`, `?access_token=`) and credential fields of request bodies
13
+ * (sanitizeBody.ts). `headersSize` and `bodySize` stay those of the captured
14
+ * request. Response bodies and WebSocket messages are not redacted.
15
+ */
16
+ import type { Entry } from './types.js';
17
+ /**
18
+ * Redact the credentials of a HAR entry.
19
+ *
20
+ * @param entry - Entry built from the captured request
21
+ * @returns Copy of the entry with credential values replaced by {@link REDACTED}
22
+ */
23
+ export declare function sanitizeEntry(entry: Entry): Entry;
24
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Credential redaction for HAR exports.
3
+ *
4
+ * HAR files are made to be shared (bug reports, tickets), so `bdg network har`
5
+ * redacts credentials by default, as Chrome DevTools does since Chrome 130.
6
+ * Chrome's sanitized export drops the `Cookie`, `Set-Cookie` and
7
+ * `Authorization` headers and empties the `cookies` arrays; bdg instead keeps
8
+ * every header, cookie and parameter name (and cookie attributes such as
9
+ * `httpOnly`) with the value `[redacted]`, so the export still shows that a
10
+ * request was authenticated and which cookies were set. It also covers API
11
+ * key, token and session headers, credential query parameters in URLs
12
+ * (`?code=`, `?access_token=`) and credential fields of request bodies
13
+ * (sanitizeBody.ts). `headersSize` and `bodySize` stay those of the captured
14
+ * request. Response bodies and WebSocket messages are not redacted.
15
+ */
16
+ import { REDACTED, isSensitiveField, redactPairs, redactRequestBody, } from './sanitizeBody.js';
17
+ /** {@link REDACTED} as written in a URL */
18
+ const URL_REDACTED = encodeURIComponent(REDACTED);
19
+ /** Headers whose values are credentials, lowercased (others match by pattern) */
20
+ const SENSITIVE_HEADERS = new Set([
21
+ 'authorization',
22
+ 'proxy-authorization',
23
+ 'authentication',
24
+ 'cookie',
25
+ 'set-cookie',
26
+ ]);
27
+ /** Custom headers carrying keys or tokens (`X-Api-Key`, `X-Auth-Token`, `X-CSRF-Token`) */
28
+ const SENSITIVE_CUSTOM_HEADER = /^x-.*(key|token|secret|auth)/i;
29
+ /**
30
+ * Headers with a credential segment between hyphens (`api-key`,
31
+ * `private-token`, `cf-access-jwt-assertion`, `ocp-apim-subscription-key`,
32
+ * `session-id`); `www-authenticate` and `proxy-authenticate` do not match
33
+ */
34
+ const SENSITIVE_HEADER_SEGMENT = /(^|-)(api-?key|apikey|token|secret|jwt|subscription-key|session(-?id)?)(-|$)/i;
35
+ /** Headers whose values are URLs that may carry credential parameters */
36
+ const URL_HEADERS = new Set(['location', 'referer']);
37
+ /** Query parameter names that hold credentials in URLs only (OAuth codes, signed URLs, API keys) */
38
+ const SENSITIVE_URL_PARAM = /^(code|sig|key)$/i;
39
+ /**
40
+ * Redact the credentials of a HAR entry.
41
+ *
42
+ * @param entry - Entry built from the captured request
43
+ * @returns Copy of the entry with credential values replaced by {@link REDACTED}
44
+ */
45
+ export function sanitizeEntry(entry) {
46
+ const { request, response } = entry;
47
+ const postData = request.postData;
48
+ return {
49
+ ...entry,
50
+ request: {
51
+ ...request,
52
+ url: redactUrl(request.url),
53
+ cookies: request.cookies.map(redactCookie),
54
+ headers: request.headers.map(redactHeader),
55
+ queryString: request.queryString.map(redactQueryParam),
56
+ ...(postData?.text !== undefined && {
57
+ postData: { ...postData, text: redactRequestBody(postData.text, postData.mimeType) },
58
+ }),
59
+ },
60
+ response: {
61
+ ...response,
62
+ cookies: response.cookies.map(redactCookie),
63
+ headers: response.headers.map(redactHeader),
64
+ redirectURL: redactUrl(response.redirectURL),
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Whether a header carries a credential.
70
+ *
71
+ * @param name - Header name (any case)
72
+ * @returns True for auth, cookie, API key, token, secret and session headers
73
+ */
74
+ function isSensitiveHeader(name) {
75
+ return (SENSITIVE_HEADERS.has(name.toLowerCase()) ||
76
+ SENSITIVE_CUSTOM_HEADER.test(name) ||
77
+ SENSITIVE_HEADER_SEGMENT.test(name));
78
+ }
79
+ /**
80
+ * Redact a header's value if it carries a credential, or the credential
81
+ * parameters of a `Location` or `Referer` URL.
82
+ *
83
+ * @param header - HAR header
84
+ * @returns The header, or a copy with its value redacted
85
+ */
86
+ function redactHeader(header) {
87
+ if (isSensitiveHeader(header.name))
88
+ return { ...header, value: REDACTED };
89
+ if (!URL_HEADERS.has(header.name.toLowerCase()))
90
+ return header;
91
+ const value = redactUrl(header.value);
92
+ return value === header.value ? header : { ...header, value };
93
+ }
94
+ /**
95
+ * Redact a cookie's value, keeping its name and attributes.
96
+ *
97
+ * @param cookie - HAR cookie
98
+ * @returns Copy with the value redacted
99
+ */
100
+ function redactCookie(cookie) {
101
+ return { ...cookie, value: REDACTED };
102
+ }
103
+ /**
104
+ * Whether a URL query or fragment parameter holds a credential.
105
+ *
106
+ * @param name - Decoded parameter name
107
+ * @returns True for credential field names, `code`, `sig` and `key`
108
+ */
109
+ function isSensitiveParam(name) {
110
+ return isSensitiveField(name) || SENSITIVE_URL_PARAM.test(name);
111
+ }
112
+ /**
113
+ * Redact a parsed query parameter if it holds a credential.
114
+ *
115
+ * @param param - HAR query parameter
116
+ * @returns The parameter, or a copy with its value redacted
117
+ */
118
+ function redactQueryParam(param) {
119
+ return isSensitiveParam(param.name) ? { ...param, value: REDACTED } : param;
120
+ }
121
+ /**
122
+ * Redact credential parameter values in a URL's query and fragment
123
+ * (`#access_token=` of the OAuth implicit flow), leaving the rest byte for byte.
124
+ *
125
+ * @param url - URL (empty for none)
126
+ * @returns URL with credential values replaced by the URL-encoded {@link REDACTED}
127
+ */
128
+ function redactUrl(url) {
129
+ const hash = url.indexOf('#');
130
+ const beforeHash = hash === -1 ? url : url.slice(0, hash);
131
+ const fragment = hash === -1 ? '' : `#${redactPairs(url.slice(hash + 1), isSensitiveParam, URL_REDACTED)}`;
132
+ const question = beforeHash.indexOf('?');
133
+ if (question === -1)
134
+ return beforeHash + fragment;
135
+ const query = redactPairs(beforeHash.slice(question + 1), isSensitiveParam, URL_REDACTED);
136
+ return `${beforeHash.slice(0, question)}?${query}${fragment}`;
137
+ }
138
+ //# sourceMappingURL=sanitize.js.map