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.
- package/.claude/skills/bdg/SKILL.md +1 -1
- package/dist/commands/cdp.js +1 -0
- package/dist/commands/cleanup.js +3 -0
- package/dist/commands/dom/eval.d.ts +2 -1
- package/dist/commands/dom/eval.js +6 -21
- package/dist/commands/dom/helpers/evalResult.d.ts +36 -0
- package/dist/commands/dom/helpers/evalResult.js +59 -0
- package/dist/commands/helpJson.d.ts +1 -1
- package/dist/commands/helpJson.js +3 -3
- package/dist/commands/helpTopic.js +10 -4
- package/dist/commands/network/har.js +18 -14
- package/dist/commands/optionBehaviors.js +8 -3
- package/dist/commands/shared/optionTypes.d.ts +1 -0
- package/dist/commands/shared/outputFile.d.ts +2 -1
- package/dist/commands/shared/outputFile.js +7 -4
- package/dist/commands/status.js +3 -1
- package/dist/commands/stop.js +2 -1
- package/dist/connection/launcher/flagsBuilder.d.ts +46 -0
- package/dist/connection/launcher/flagsBuilder.js +107 -23
- package/dist/connection/launcher.d.ts +1 -1
- package/dist/connection/launcher.js +1 -2
- package/dist/constants.d.ts +2 -4
- package/dist/constants.js +2 -4
- package/dist/daemon/launcher.d.ts +17 -3
- package/dist/daemon/launcher.js +37 -7
- package/dist/daemon/session/commandRegistry.js +2 -2
- package/dist/daemon.js +8107 -7962
- package/dist/errors/messages.d.ts +23 -0
- package/dist/errors/messages.js +86 -6
- package/dist/index.js +555 -166
- package/dist/ipc/client.d.ts +6 -1
- package/dist/ipc/client.js +11 -2
- package/dist/ipc/protocol/commands.d.ts +4 -0
- package/dist/ipc/transport/index.d.ts +6 -0
- package/dist/ipc/transport/index.js +16 -1
- package/dist/runtime/dom/elementInfo.d.ts +7 -0
- package/dist/runtime/dom/elementInfo.js +8 -1
- package/dist/runtime/dom/evalHelpers.d.ts +24 -4
- package/dist/runtime/dom/evalHelpers.js +40 -12
- package/dist/runtime/dom/frames.d.ts +2 -1
- package/dist/runtime/dom/frames.js +3 -1
- package/dist/runtime/page/emulation.js +6 -5
- package/dist/runtime/page/userAgent.d.ts +86 -2
- package/dist/runtime/page/userAgent.js +154 -33
- package/dist/session/paths.d.ts +38 -3
- package/dist/session/paths.js +154 -7
- package/dist/session/portClaims.d.ts +0 -8
- package/dist/session/portClaims.js +1 -22
- package/dist/session/sessionList.d.ts +5 -1
- package/dist/session/sessionList.js +5 -1
- package/dist/telemetry/har/builder.d.ts +12 -1
- package/dist/telemetry/har/builder.js +10 -2
- package/dist/telemetry/har/sanitize.d.ts +24 -0
- package/dist/telemetry/har/sanitize.js +138 -0
- package/dist/telemetry/har/sanitizeBody.d.ts +38 -0
- package/dist/telemetry/har/sanitizeBody.js +168 -0
- package/dist/ui/formatters/sessions.d.ts +3 -2
- package/dist/ui/formatters/sessions.js +10 -3
- package/dist/ui/messages/chrome.d.ts +14 -6
- package/dist/ui/messages/chrome.js +52 -12
- package/dist/ui/messages/networkMessages.d.ts +26 -0
- package/dist/ui/messages/networkMessages.js +21 -0
- package/dist/ui/messages/session.d.ts +8 -0
- package/dist/ui/messages/session.js +10 -0
- package/dist/utils/atomicFile.d.ts +2 -1
- package/dist/utils/atomicFile.js +5 -2
- package/dist/utils/directories.d.ts +41 -0
- package/dist/utils/directories.js +48 -0
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
170
|
+
userAgent: version.userAgent.replace('HeadlessChrome', 'Chrome'),
|
|
171
|
+
userAgentMetadata: regularChromeMetadata(version, hostPlatform()),
|
|
51
172
|
});
|
|
52
173
|
}
|
|
53
174
|
catch (error) {
|
package/dist/session/paths.d.ts
CHANGED
|
@@ -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
|
|
127
|
-
*
|
|
128
|
-
* would spin on a
|
|
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
|
package/dist/session/paths.js
CHANGED
|
@@ -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 =
|
|
61
|
-
if (override
|
|
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
|
|
170
|
-
*
|
|
171
|
-
* would spin on a
|
|
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
|
|
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
|