browser-debugger-cli 0.14.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 (69) hide show
  1. package/.claude/skills/bdg/SKILL.md +1 -1
  2. package/dist/commands/cdp.js +1 -0
  3. package/dist/commands/cleanup.js +3 -0
  4. package/dist/commands/dom/eval.d.ts +2 -1
  5. package/dist/commands/dom/eval.js +6 -21
  6. package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
  7. package/dist/commands/dom/helpers/evalResult.js +59 -0
  8. package/dist/commands/helpJson.d.ts +1 -1
  9. package/dist/commands/helpJson.js +3 -3
  10. package/dist/commands/helpTopic.js +10 -4
  11. package/dist/commands/network/har.js +18 -14
  12. package/dist/commands/optionBehaviors.js +8 -3
  13. package/dist/commands/shared/optionTypes.d.ts +1 -0
  14. package/dist/commands/shared/outputFile.d.ts +2 -1
  15. package/dist/commands/shared/outputFile.js +7 -4
  16. package/dist/commands/status.js +3 -1
  17. package/dist/commands/stop.js +2 -1
  18. package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
  19. package/dist/connection/launcher/flagsBuilder.js +107 -23
  20. package/dist/connection/launcher.d.ts +1 -1
  21. package/dist/connection/launcher.js +1 -2
  22. package/dist/constants.d.ts +2 -4
  23. package/dist/constants.js +2 -4
  24. package/dist/daemon/launcher.d.ts +17 -3
  25. package/dist/daemon/launcher.js +37 -7
  26. package/dist/daemon/session/commandRegistry.js +2 -2
  27. package/dist/daemon.js +8107 -7962
  28. package/dist/errors/messages.d.ts +23 -0
  29. package/dist/errors/messages.js +86 -6
  30. package/dist/index.js +555 -166
  31. package/dist/ipc/client.d.ts +6 -1
  32. package/dist/ipc/client.js +11 -2
  33. package/dist/ipc/protocol/commands.d.ts +4 -0
  34. package/dist/ipc/transport/index.d.ts +6 -0
  35. package/dist/ipc/transport/index.js +16 -1
  36. package/dist/runtime/dom/elementInfo.d.ts +7 -0
  37. package/dist/runtime/dom/elementInfo.js +8 -1
  38. package/dist/runtime/dom/evalHelpers.d.ts +24 -4
  39. package/dist/runtime/dom/evalHelpers.js +40 -12
  40. package/dist/runtime/dom/frames.d.ts +2 -1
  41. package/dist/runtime/dom/frames.js +3 -1
  42. package/dist/runtime/page/emulation.js +6 -5
  43. package/dist/runtime/page/userAgent.d.ts +86 -2
  44. package/dist/runtime/page/userAgent.js +154 -33
  45. package/dist/session/paths.d.ts +38 -3
  46. package/dist/session/paths.js +154 -7
  47. package/dist/session/portClaims.d.ts +0 -8
  48. package/dist/session/portClaims.js +1 -22
  49. package/dist/session/sessionList.d.ts +5 -1
  50. package/dist/session/sessionList.js +5 -1
  51. package/dist/telemetry/har/builder.d.ts +12 -1
  52. package/dist/telemetry/har/builder.js +10 -2
  53. package/dist/telemetry/har/sanitize.d.ts +24 -0
  54. package/dist/telemetry/har/sanitize.js +138 -0
  55. package/dist/telemetry/har/sanitizeBody.d.ts +38 -0
  56. package/dist/telemetry/har/sanitizeBody.js +168 -0
  57. package/dist/ui/formatters/sessions.d.ts +3 -2
  58. package/dist/ui/formatters/sessions.js +10 -3
  59. package/dist/ui/messages/chrome.d.ts +14 -6
  60. package/dist/ui/messages/chrome.js +52 -12
  61. package/dist/ui/messages/networkMessages.d.ts +26 -0
  62. package/dist/ui/messages/networkMessages.js +21 -0
  63. package/dist/ui/messages/session.d.ts +8 -0
  64. package/dist/ui/messages/session.js +10 -0
  65. package/dist/utils/atomicFile.d.ts +2 -1
  66. package/dist/utils/atomicFile.js +5 -2
  67. package/dist/utils/directories.d.ts +41 -0
  68. package/dist/utils/directories.js +48 -0
  69. package/package.json +1 -1
@@ -2,52 +2,173 @@
2
2
  * The session's user agent in headless Chrome: regular Chrome's string and
3
3
  * client hints, so sites serve the page a user sees.
4
4
  */
5
+ import { readFileSync } from 'node:fs';
6
+ import os from 'node:os';
7
+ import { createLogger } from '../../ui/logging/index.js';
5
8
  import { getErrorMessage } from '../../utils/errors.js';
6
- /** Reads the client hints Chrome reports, renaming the HeadlessChrome brand */
7
- const USER_AGENT_METADATA_SCRIPT = `(async () => {
8
- const data = navigator.userAgentData;
9
- if (!data) return null;
10
- const values = await data.getHighEntropyValues(
11
- ['architecture', 'bitness', 'model', 'platformVersion', 'fullVersionList', 'wow64']
12
- );
13
- const rename = (list) => (list || []).map((entry) => ({
14
- brand: entry.brand.replace('HeadlessChrome', 'Google Chrome'),
15
- version: entry.version,
16
- }));
17
- return {
18
- brands: rename(values.brands),
19
- fullVersionList: rename(values.fullVersionList),
20
- platform: values.platform,
21
- platformVersion: values.platformVersion,
22
- architecture: values.architecture,
23
- model: values.model,
24
- mobile: values.mobile,
25
- bitness: values.bitness,
26
- wow64: values.wow64,
27
- };
28
- })()`;
9
+ const log = createLogger('session');
10
+ /** Characters Chrome picks from for its made-up ("GREASE") brand name */
11
+ const GREASE_CHARS = [' ', '(', ':', '-', '.', '/', ')', ';', '=', '?', '_'];
12
+ /** Versions Chrome picks from for its made-up brand */
13
+ const GREASE_VERSIONS = ['8', '99', '24'];
14
+ /** Positions of the made-up brand, Chromium and the browser brand, by major version */
15
+ const BRAND_ORDERS = [
16
+ [0, 1, 2],
17
+ [0, 2, 1],
18
+ [1, 0, 2],
19
+ [1, 2, 0],
20
+ [2, 0, 1],
21
+ [2, 1, 0],
22
+ ];
23
+ /** Client-hint platform for each `process.platform` Chrome runs on */
24
+ const HOST_PLATFORMS = {
25
+ darwin: 'macOS',
26
+ win32: 'Windows',
27
+ linux: 'Linux',
28
+ };
29
+ /** Client-hint platform for each platform token of the user agent string */
30
+ const USER_AGENT_PLATFORMS = [
31
+ [/Android/, 'Android'],
32
+ [/CrOS/, 'Chrome OS'],
33
+ [/Macintosh/, 'macOS'],
34
+ [/Windows/, 'Windows'],
35
+ [/Linux|X11/, 'Linux'],
36
+ ];
37
+ /**
38
+ * Chrome's brand list for a version, built the way Chrome builds it
39
+ * (`GenerateBrandVersionList` in Chromium's `user_agent_utils.cc`): a
40
+ * made-up brand, Chromium and the browser's brand, in an order and with a
41
+ * made-up name and version that depend on the major version.
42
+ *
43
+ * @param major - Major version (the seed)
44
+ * @param chromium - Chromium version to list
45
+ * @param browser - Browser brand and version
46
+ * @param greaseSuffix - Appended to the made-up version (`.0.0.0` in full versions)
47
+ * @returns Brand list
48
+ */
49
+ export function chromeBrandList(major, chromium, browser, greaseSuffix = '') {
50
+ const grease = {
51
+ brand: `Not${GREASE_CHARS[major % GREASE_CHARS.length]}A${GREASE_CHARS[(major + 1) % GREASE_CHARS.length]}Brand`,
52
+ version: `${GREASE_VERSIONS[major % GREASE_VERSIONS.length]}${greaseSuffix}`,
53
+ };
54
+ const [greaseAt, chromiumAt, browserAt] = BRAND_ORDERS[major % BRAND_ORDERS.length] ?? [0, 1, 2];
55
+ const list = [];
56
+ list[greaseAt] = grease;
57
+ list[chromiumAt] = { brand: 'Chromium', version: chromium };
58
+ list[browserAt] = browser;
59
+ return list;
60
+ }
61
+ /**
62
+ * The client hints regular Chrome sends, for the browser `Browser.getVersion`
63
+ * describes. The browser brand is Microsoft Edge when the user agent says
64
+ * `Edg/`, Google Chrome otherwise (Chromium and Chrome for Testing look the
65
+ * same over CDP). Edge's Chromium version is only known to the major
66
+ * version. The OS version, architecture and bitness are the host's when it
67
+ * runs the platform the user agent names, empty otherwise (a remote Chrome).
68
+ *
69
+ * @param version - `Browser.getVersion` product and user agent
70
+ * @param host - The daemon's machine
71
+ * @returns Metadata for `Emulation.setUserAgentOverride`
72
+ */
73
+ export function regularChromeMetadata(version, host) {
74
+ const fullVersion = /\/([\d.]+)/.exec(version.product)?.[1] ?? '';
75
+ const major = Number.parseInt(fullVersion, 10) || 0;
76
+ const isEdge = / Edg\//.test(version.userAgent);
77
+ const chromiumVersion = isEdge ? `${major}.0.0.0` : fullVersion;
78
+ const brand = isEdge ? 'Microsoft Edge' : 'Google Chrome';
79
+ const platform = USER_AGENT_PLATFORMS.find(([token]) => token.test(version.userAgent))?.[1] ?? host.platform;
80
+ const isHost = platform === host.platform;
81
+ return {
82
+ brands: chromeBrandList(major, String(major), { brand, version: String(major) }),
83
+ fullVersionList: chromeBrandList(major, chromiumVersion, { brand, version: fullVersion }, '.0.0.0'),
84
+ fullVersion,
85
+ platform,
86
+ platformVersion: isHost ? host.platformVersion : '',
87
+ architecture: isHost ? host.architecture : '',
88
+ bitness: isHost ? host.bitness : '',
89
+ model: '',
90
+ mobile: false,
91
+ wow64: false,
92
+ formFactors: ['Desktop'],
93
+ };
94
+ }
95
+ /** First Windows build of Windows 11, which client hints report as version 13 */
96
+ const WINDOWS_11_BUILD = 22000;
97
+ /**
98
+ * The OS version Chrome reports on Linux or Windows, from `os.release()`:
99
+ * the kernel version's first three numbers on Linux, and on Windows `13.0.0`
100
+ * for Windows 11 and `10.0.0` before it (Chrome reports a Windows API
101
+ * version there, not the OS build).
102
+ *
103
+ * @param platform - `process.platform`
104
+ * @param release - `os.release()`
105
+ * @returns OS version, or empty when unknown
106
+ */
107
+ export function releasePlatformVersion(platform, release) {
108
+ if (platform === 'win32') {
109
+ const build = Number(release.split('.')[2]);
110
+ if (!build)
111
+ return '';
112
+ return build >= WINDOWS_11_BUILD ? '13.0.0' : '10.0.0';
113
+ }
114
+ return /^\d+(\.\d+){0,2}/.exec(release)?.[0] ?? '';
115
+ }
116
+ /**
117
+ * The OS version as Chrome reports it: the macOS product version (from
118
+ * `SystemVersion.plist`, as `sw_vers` prints it), or
119
+ * {@link releasePlatformVersion} elsewhere.
120
+ *
121
+ * @returns OS version, or empty when unknown
122
+ */
123
+ function hostPlatformVersion() {
124
+ if (process.platform !== 'darwin')
125
+ return releasePlatformVersion(process.platform, os.release());
126
+ try {
127
+ const plist = readFileSync('/System/Library/CoreServices/SystemVersion.plist', 'utf8');
128
+ return /<key>ProductVersion<\/key>\s*<string>([\d.]+)<\/string>/.exec(plist)?.[1] ?? '';
129
+ }
130
+ catch (error) {
131
+ log.debug(`macOS version unknown: ${getErrorMessage(error)}`);
132
+ return '';
133
+ }
134
+ }
135
+ /**
136
+ * The daemon's machine as client hints describe it.
137
+ *
138
+ * @returns Platform, OS version, architecture and bitness
139
+ */
140
+ export function hostPlatform() {
141
+ const arch = process.arch;
142
+ return {
143
+ platform: HOST_PLATFORMS[process.platform] ?? '',
144
+ platformVersion: hostPlatformVersion(),
145
+ architecture: arch.startsWith('arm') ? 'arm' : 'x86',
146
+ bitness: arch === 'arm64' || arch === 'x64' ? '64' : '32',
147
+ };
148
+ }
29
149
  /**
30
150
  * Send the user agent and client hints of regular Chrome from headless
31
151
  * Chrome: sites serve "HeadlessChrome" a different page (or a bot
32
152
  * challenge), so the page would not be the one a user sees. Not for a
33
153
  * session emulating a phone, whose emulation sets a mobile user agent.
34
154
  *
155
+ * The client hints are built from `Browser.getVersion` and the host
156
+ * ({@link regularChromeMetadata}) rather than read from the page: the page is
157
+ * still `about:blank`, which has no `navigator.userAgentData`, and an
158
+ * override without metadata empties the client hints.
159
+ *
35
160
  * @param cdp - CDP connection
36
161
  * @param logger - Logger for failures (the session works without it)
162
+ * @param known - `Browser.getVersion` result, when the caller has it
37
163
  */
38
- export async function hideHeadlessUserAgent(cdp, logger) {
164
+ export async function hideHeadlessUserAgent(cdp, logger, known) {
39
165
  try {
40
- const { userAgent } = (await cdp.send('Browser.getVersion'));
41
- if (!userAgent.includes('HeadlessChrome'))
166
+ const version = known ?? (await cdp.send('Browser.getVersion'));
167
+ if (!version.userAgent.includes('HeadlessChrome'))
42
168
  return;
43
- const metadata = (await cdp.send('Runtime.evaluate', {
44
- expression: USER_AGENT_METADATA_SCRIPT,
45
- awaitPromise: true,
46
- returnByValue: true,
47
- }));
48
169
  await cdp.send('Emulation.setUserAgentOverride', {
49
- userAgent: userAgent.replace('HeadlessChrome', 'Chrome'),
50
- ...(metadata.result?.value ? { userAgentMetadata: metadata.result.value } : {}),
170
+ userAgent: version.userAgent.replace('HeadlessChrome', 'Chrome'),
171
+ userAgentMetadata: regularChromeMetadata(version, hostPlatform()),
51
172
  });
52
173
  }
53
174
  catch (error) {
@@ -5,6 +5,7 @@
5
5
  * (or `~/.bdg/sessions/<name>/` for a named session).
6
6
  * WHY: Single source of truth for file locations prevents path inconsistencies.
7
7
  */
8
+ import { type DirTrustKind } from '../utils/directories.js';
8
9
  /**
9
10
  * Session file paths relative to ~/.bdg/
10
11
  * Centralized definition for all session-related files.
@@ -123,12 +124,46 @@ export declare function getDaemonSocketPath(): string;
123
124
  /**
124
125
  * Ensure the session directory exists.
125
126
  *
126
- * Creates ~/.bdg/ if it doesn't exist. Safe to call multiple times (idempotent).
127
- * A path that cannot hold a directory is refused before `mkdir`, which
128
- * would spin on a pseudo-filesystem ({@link makeDirectory}).
127
+ * Creates it, and missing parents (the base directory, `sessions/`), with
128
+ * mode 0700. Safe to call multiple times (idempotent). A path that cannot
129
+ * hold a directory is refused before `mkdir`, which would spin on a
130
+ * pseudo-filesystem ({@link makeDirectory}).
129
131
  *
130
132
  * @throws Error if the directory cannot be created
131
133
  */
132
134
  export declare function ensureSessionDir(): void;
135
+ /** A session directory (or one above it) that cannot be trusted */
136
+ export interface UntrustedSessionDir {
137
+ /** The untrusted directory */
138
+ dir: string;
139
+ /** Why, e.g. `writable by others (mode 777)` */
140
+ reason: string;
141
+ kind: DirTrustKind;
142
+ /** bdg owns it by convention (`~/.bdg`, `sessions/`, `sessions/<name>`) */
143
+ bdgOwned: boolean;
144
+ }
145
+ /**
146
+ * Check that a session directory and the directories above it up to the base
147
+ * directory can be trusted before a daemon is started there or its socket is
148
+ * connected to: another user who can write to one of them could replace the
149
+ * socket (and receive every command) or plant files. Each existing directory
150
+ * must be a real directory (not a symlink), owned by the user, and not
151
+ * writable by others ({@link dirTrustProblem}); group write is accepted, since
152
+ * under umask 002 (per-user groups) older versions created `~/.bdg` 0775.
153
+ * The base directory may be a symlink whose target passes the same rule
154
+ * ({@link chainDirProblem}). A directory owned by another uid (a bind mount,
155
+ * `sudo -E`) is refused.
156
+ *
157
+ * Trusted directories bdg owns (the default `~/.bdg`, `sessions/` and named
158
+ * session directories) that group or others can still use are tightened to
159
+ * 0700; a base directory chosen with `$BDG_SESSION_DIR`, or a symlinked base
160
+ * (opened without following links), is never changed.
161
+ * Missing paths are skipped (as is a path through a file, which fails on its
162
+ * own): {@link ensureSessionDir} creates them 0700.
163
+ *
164
+ * @param dir - Session directory (default: the selected session's)
165
+ * @returns The first untrusted directory and why, or null
166
+ */
167
+ export declare function secureSessionDir(dir?: string): UntrustedSessionDir | null;
133
168
  export {};
134
169
  //# sourceMappingURL=paths.d.ts.map
@@ -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)
@@ -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 { sanitizeEntry } from './sanitize.js';
5
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