@sysid/sandbox-runtime-improved 0.0.55-sysid.1 → 0.0.61-sysid.1
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/README.md +43 -2
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/sandbox/credential-mask-files.d.ts +72 -0
- package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
- package/dist/sandbox/credential-mask-files.js +146 -0
- package/dist/sandbox/credential-mask-files.js.map +1 -0
- package/dist/sandbox/credential-sentinel.d.ts +72 -0
- package/dist/sandbox/credential-sentinel.d.ts.map +1 -0
- package/dist/sandbox/credential-sentinel.js +130 -0
- package/dist/sandbox/credential-sentinel.js.map +1 -0
- package/dist/sandbox/domain-pattern.d.ts +36 -0
- package/dist/sandbox/domain-pattern.d.ts.map +1 -0
- package/dist/sandbox/domain-pattern.js +60 -0
- package/dist/sandbox/domain-pattern.js.map +1 -0
- package/dist/sandbox/http-proxy.d.ts +32 -1
- package/dist/sandbox/http-proxy.d.ts.map +1 -1
- package/dist/sandbox/http-proxy.js +10 -2
- package/dist/sandbox/http-proxy.js.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.d.ts +17 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.js +155 -47
- package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
- package/dist/sandbox/listen-in-range.d.ts +12 -0
- package/dist/sandbox/listen-in-range.d.ts.map +1 -0
- package/dist/sandbox/listen-in-range.js +43 -0
- package/dist/sandbox/listen-in-range.js.map +1 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts +13 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/macos-sandbox-utils.js +76 -14
- package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
- package/dist/sandbox/mitm-ca.d.ts +18 -2
- package/dist/sandbox/mitm-ca.d.ts.map +1 -1
- package/dist/sandbox/mitm-ca.js +49 -9
- package/dist/sandbox/mitm-ca.js.map +1 -1
- package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
- package/dist/sandbox/mitm-leaf.js +24 -6
- package/dist/sandbox/mitm-leaf.js.map +1 -1
- package/dist/sandbox/mux-proxy.d.ts +59 -0
- package/dist/sandbox/mux-proxy.d.ts.map +1 -0
- package/dist/sandbox/mux-proxy.js +159 -0
- package/dist/sandbox/mux-proxy.js.map +1 -0
- package/dist/sandbox/request-filter.d.ts +11 -1
- package/dist/sandbox/request-filter.d.ts.map +1 -1
- package/dist/sandbox/request-filter.js.map +1 -1
- package/dist/sandbox/sandbox-config.d.ts +383 -1
- package/dist/sandbox/sandbox-config.d.ts.map +1 -1
- package/dist/sandbox/sandbox-config.js +252 -1
- package/dist/sandbox/sandbox-config.js.map +1 -1
- package/dist/sandbox/sandbox-manager.d.ts +4 -0
- package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
- package/dist/sandbox/sandbox-manager.js +618 -168
- package/dist/sandbox/sandbox-manager.js.map +1 -1
- package/dist/sandbox/sandbox-schemas.d.ts +27 -0
- package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
- package/dist/sandbox/sandbox-utils.d.ts +42 -6
- package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/sandbox-utils.js +115 -27
- package/dist/sandbox/sandbox-utils.js.map +1 -1
- package/dist/sandbox/socks-proxy.d.ts +11 -5
- package/dist/sandbox/socks-proxy.d.ts.map +1 -1
- package/dist/sandbox/socks-proxy.js +13 -81
- package/dist/sandbox/socks-proxy.js.map +1 -1
- package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
- package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
- package/dist/sandbox/tls-terminate-proxy.js +31 -5
- package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
- package/dist/sandbox/windows-sandbox-utils.d.ts +347 -11
- package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/windows-sandbox-utils.js +437 -45
- package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
- package/package.json +7 -4
- package/vendor/seccomp/build.ts +8 -30
- package/vendor/srt-win/build.ts +21 -0
- package/dist/vendor/seccomp/Dockerfile.build +0 -6
- package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
- package/dist/vendor/seccomp/build.ts +0 -91
- package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
- package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
- package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
- package/dist/vendor/srt-win/Cargo.lock +0 -361
- package/dist/vendor/srt-win/Cargo.toml +0 -46
- package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
- package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
- package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
- package/dist/vendor/srt-win/src/job.rs +0 -102
- package/dist/vendor/srt-win/src/launch.rs +0 -732
- package/dist/vendor/srt-win/src/lib.rs +0 -20
- package/dist/vendor/srt-win/src/main.rs +0 -669
- package/dist/vendor/srt-win/src/self_protect.rs +0 -169
- package/dist/vendor/srt-win/src/sid.rs +0 -296
- package/dist/vendor/srt-win/src/token.rs +0 -341
- package/dist/vendor/srt-win/src/util.rs +0 -42
- package/dist/vendor/srt-win/src/wfp.rs +0 -992
- package/dist/vendor/srt-win/src/winsta.rs +0 -209
- package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
- package/vendor/seccomp-src/apply-seccomp.c +0 -280
- package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
- package/vendor/srt-win/Cargo.lock +0 -361
- package/vendor/srt-win/Cargo.toml +0 -46
- package/vendor/srt-win/ci/cleanup.ps1 +0 -49
- package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
- package/vendor/srt-win/ci/smoke.ps1 +0 -307
- package/vendor/srt-win/src/job.rs +0 -102
- package/vendor/srt-win/src/launch.rs +0 -732
- package/vendor/srt-win/src/lib.rs +0 -20
- package/vendor/srt-win/src/main.rs +0 -669
- package/vendor/srt-win/src/self_protect.rs +0 -169
- package/vendor/srt-win/src/sid.rs +0 -296
- package/vendor/srt-win/src/token.rs +0 -341
- package/vendor/srt-win/src/util.rs +0 -42
- package/vendor/srt-win/src/wfp.rs +0 -992
- package/vendor/srt-win/src/winsta.rs +0 -209
- package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { createHttpProxyServer } from './http-proxy.js';
|
|
2
2
|
import { createSocksProxyServer } from './socks-proxy.js';
|
|
3
|
+
import { createMuxProxyServer } from './mux-proxy.js';
|
|
4
|
+
import { listenInRange } from './listen-in-range.js';
|
|
5
|
+
import { SentinelRegistry } from './credential-sentinel.js';
|
|
6
|
+
import { MaskedFileStore, buildMaskedFileBinds, } from './credential-mask-files.js';
|
|
3
7
|
import { createMitmCA, disposeMitmCA } from './mitm-ca.js';
|
|
4
8
|
import { logForDebugging } from '../utils/debug.js';
|
|
5
9
|
import { whichSync } from '../utils/which.js';
|
|
6
10
|
import { getPlatform, getWslVersion } from '../utils/platform.js';
|
|
7
11
|
import * as fs from 'fs';
|
|
8
|
-
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { randomBytes, X509Certificate } from 'node:crypto';
|
|
9
13
|
import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, checkLinuxDependencies, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js';
|
|
10
14
|
import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, } from './macos-sandbox-utils.js';
|
|
11
|
-
import { checkWindowsDependencies, wrapCommandWithSandboxWindows, DEFAULT_WINDOWS_GROUP_NAME, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
|
|
12
|
-
import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, ensureSandboxTmpdir, } from './sandbox-utils.js';
|
|
15
|
+
import { checkWindowsDependencies, wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsDenyPaths, stampWindowsAcl, restoreWindowsAcl, getWindowsSandboxUserStatus, getWindowsSandboxCaCert, WINDOWS_ACL_PATH_OK, WINDOWS_ACL_PARENT_OK, DEFAULT_WINDOWS_GROUP_NAME, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
|
|
16
|
+
import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, normalizePathForSandbox, ensureSandboxTmpdir, } from './sandbox-utils.js';
|
|
13
17
|
import { SandboxViolationStore } from './sandbox-violation-store.js';
|
|
14
|
-
import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy,
|
|
15
|
-
import {
|
|
18
|
+
import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, } from './parent-proxy.js';
|
|
19
|
+
import { matchesDomainPattern } from './domain-pattern.js';
|
|
16
20
|
import { EOL } from 'node:os';
|
|
17
21
|
// ============================================================================
|
|
18
22
|
// Private Module State
|
|
@@ -20,6 +24,7 @@ import { EOL } from 'node:os';
|
|
|
20
24
|
let config;
|
|
21
25
|
let httpProxyServer;
|
|
22
26
|
let socksProxyServer;
|
|
27
|
+
let muxProxyServer;
|
|
23
28
|
let managerContext;
|
|
24
29
|
let initializationPromise;
|
|
25
30
|
let cleanupRegistered = false;
|
|
@@ -30,7 +35,28 @@ let mitmCA;
|
|
|
30
35
|
// the sandbox child env, checked on every CONNECT/request — so a host process
|
|
31
36
|
// dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
|
|
32
37
|
let proxyAuthToken;
|
|
38
|
+
// Windows: the resolved {denyRead, denyWrite} that was actually
|
|
39
|
+
// passed to `srt-win acl stamp` at initialize(). `undefined` means
|
|
40
|
+
// no stamp was applied (gates passing `--holder-pid` to exec —
|
|
41
|
+
// which engages the per-exec dir/file fence — and running `acl
|
|
42
|
+
// restore` at reset()).
|
|
43
|
+
let windowsFsStampedSet;
|
|
44
|
+
// The group reference that was passed to `srt-win acl stamp`.
|
|
45
|
+
// reset() restores against THIS, not the current config — a
|
|
46
|
+
// group change between stamp and restore would otherwise
|
|
47
|
+
// target the wrong group's broker DACL.
|
|
48
|
+
let windowsFsStampedGroup;
|
|
49
|
+
// The RAW config inputs that produced `windowsFsStampedSet`.
|
|
50
|
+
// updateConfig() compares these (not the resolved set) so it never
|
|
51
|
+
// re-expands globs — see `sameWindowsStampSet`.
|
|
52
|
+
let windowsFsRawInputs;
|
|
33
53
|
const sandboxViolationStore = new SandboxViolationStore();
|
|
54
|
+
// Per-session sentinel↔real-value map for masked credentials. Lives only in
|
|
55
|
+
// process memory; never written to disk or logged. Cleared on reset().
|
|
56
|
+
const sentinelRegistry = new SentinelRegistry();
|
|
57
|
+
// Temp dir holding the sentinel-content fake files for masked credential
|
|
58
|
+
// files. Created lazily on first masked file; removed on reset().
|
|
59
|
+
const maskedFileStore = new MaskedFileStore();
|
|
34
60
|
// ============================================================================
|
|
35
61
|
// Private Helper Functions (not exported)
|
|
36
62
|
// ============================================================================
|
|
@@ -48,26 +74,6 @@ function registerCleanup() {
|
|
|
48
74
|
process.once('SIGTERM', cleanupHandler);
|
|
49
75
|
cleanupRegistered = true;
|
|
50
76
|
}
|
|
51
|
-
function matchesDomainPattern(hostname, pattern) {
|
|
52
|
-
const h = hostname.toLowerCase();
|
|
53
|
-
// Bare '*' is deny-all when it appears in deniedDomains. The schema only
|
|
54
|
-
// accepts it there (allowedDomains still rejects it as too broad).
|
|
55
|
-
if (pattern === '*')
|
|
56
|
-
return true;
|
|
57
|
-
// Support wildcard patterns like *.example.com. Never apply wildcard
|
|
58
|
-
// suffix matching to IP literals — an IPv6 zone-ID payload like
|
|
59
|
-
// `::ffff:1.2.3.4%x.allowed.com` would otherwise pass .endsWith() while
|
|
60
|
-
// the OS connects to the bare IP. isValidHost already rejects `%`, but
|
|
61
|
-
// we refuse here too for defence in depth.
|
|
62
|
-
if (pattern.startsWith('*.')) {
|
|
63
|
-
if (isIP(stripBrackets(h)))
|
|
64
|
-
return false;
|
|
65
|
-
const baseDomain = pattern.substring(2).toLowerCase();
|
|
66
|
-
return h.endsWith('.' + baseDomain);
|
|
67
|
-
}
|
|
68
|
-
// Exact match for non-wildcard patterns
|
|
69
|
-
return h === pattern.toLowerCase();
|
|
70
|
-
}
|
|
71
77
|
async function filterNetworkRequest(port, host, sandboxAskCallback) {
|
|
72
78
|
if (!config) {
|
|
73
79
|
logForDebugging('No config available, denying network request');
|
|
@@ -133,6 +139,24 @@ async function filterNetworkRequest(port, host, sandboxAskCallback) {
|
|
|
133
139
|
* Returns the socket path if the host matches any MITM domain pattern,
|
|
134
140
|
* otherwise returns undefined.
|
|
135
141
|
*/
|
|
142
|
+
/**
|
|
143
|
+
* Build the header-mutation callback that substitutes sentinel→real for
|
|
144
|
+
* masked credentials. Returns undefined when no `credentials` block is
|
|
145
|
+
* configured — wiring the seam at all is unnecessary then.
|
|
146
|
+
*
|
|
147
|
+
* Per-host gating happens inside the registry: each sentinel carries its
|
|
148
|
+
* own injectHosts list and substitutes independently, so credential A's
|
|
149
|
+
* sentinel cannot be laundered through credential B's allowed host. The
|
|
150
|
+
* returned closure does not log header values; the registry holds the only
|
|
151
|
+
* copy of the real value.
|
|
152
|
+
*/
|
|
153
|
+
function buildCredentialInjector() {
|
|
154
|
+
if (!config?.credentials)
|
|
155
|
+
return undefined;
|
|
156
|
+
return (headers, destHost) => {
|
|
157
|
+
sentinelRegistry.substituteInHeaders(headers, destHost, matchesDomainPattern);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
136
160
|
function getMitmSocketPath(host) {
|
|
137
161
|
if (!config?.network.mitmProxy) {
|
|
138
162
|
return undefined;
|
|
@@ -147,101 +171,82 @@ function getMitmSocketPath(host) {
|
|
|
147
171
|
return undefined;
|
|
148
172
|
}
|
|
149
173
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
174
|
+
* Per-host TLS-termination opt-out from network.tlsTerminate.excludeDomains.
|
|
175
|
+
* Only consulted by the HTTP proxy when tlsTerminate is enabled; exempted
|
|
176
|
+
* hosts fall back to the opaque CONNECT tunnel (still allowlist-filtered),
|
|
177
|
+
* so mTLS / cert-pinning clients can complete their own handshake.
|
|
153
178
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* port we landed on, so ephemeral is fine.
|
|
179
|
+
* Matches the canonicalized hostname, like the allow/deny filter
|
|
180
|
+
* (filterNetworkRequest) — otherwise a spelling the allowlist accepts after
|
|
181
|
+
* canonicalization (`127.1`, a trailing-dot FQDN) would dodge the exclusion
|
|
182
|
+
* and get terminated anyway.
|
|
159
183
|
*/
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
reject(err ?? new Error('listen error'));
|
|
187
|
-
};
|
|
188
|
-
server.once('error', onError);
|
|
189
|
-
server.once('listening', onListening);
|
|
190
|
-
doListen(range ? port : 0);
|
|
191
|
-
};
|
|
192
|
-
tryNext();
|
|
193
|
-
});
|
|
184
|
+
function shouldTerminateTLSForHost(host) {
|
|
185
|
+
const excludeDomains = config?.network.tlsTerminate?.excludeDomains;
|
|
186
|
+
if (!excludeDomains?.length)
|
|
187
|
+
return true;
|
|
188
|
+
const canonicalHost = canonicalizeHost(host) ?? host;
|
|
189
|
+
for (const pattern of excludeDomains) {
|
|
190
|
+
if (!matchesDomainPattern(canonicalHost, pattern))
|
|
191
|
+
continue;
|
|
192
|
+
logForDebugging(`Host ${host} matches tlsTerminate.excludeDomains pattern ${pattern}; skipping TLS termination`);
|
|
193
|
+
// Masked-credential substitution only happens on the terminated path,
|
|
194
|
+
// so a credential whose injectHosts cover this host can never be
|
|
195
|
+
// injected here — the upstream gets the placeholder. Config validation
|
|
196
|
+
// rejects the fully-contradictory spellings; this flags the partial
|
|
197
|
+
// ones (e.g. default injectHosts = allowedDomains) at the moment they
|
|
198
|
+
// actually bite.
|
|
199
|
+
const masked = sentinelRegistry.namesInjectableAt(canonicalHost, matchesDomainPattern);
|
|
200
|
+
if (masked.length > 0) {
|
|
201
|
+
logForDebugging(`tlsTerminate.excludeDomains: masked credential(s) ${masked.join(', ')} ` +
|
|
202
|
+
`are configured for injection at ${host}, but its connections are ` +
|
|
203
|
+
`not terminated, so the upstream will receive the placeholder`, { level: 'error' });
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
194
208
|
}
|
|
195
|
-
async function
|
|
209
|
+
async function startMuxProxyServer(sandboxAskCallback, portRange) {
|
|
210
|
+
const injectCredentials = buildCredentialInjector();
|
|
196
211
|
httpProxyServer = createHttpProxyServer({
|
|
197
212
|
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
|
198
213
|
getMitmSocketPath,
|
|
199
214
|
mitmCA,
|
|
215
|
+
shouldTerminateTLS: shouldTerminateTLSForHost,
|
|
200
216
|
filterRequest: config?.network.filterRequest,
|
|
217
|
+
// TLS-terminated path always gets the injector; the plain-HTTP path
|
|
218
|
+
// only when explicitly opted in. Without the opt-in, a sentinel sent
|
|
219
|
+
// over plain HTTP reaches the upstream unchanged (fails closed).
|
|
220
|
+
mutateHeaders: injectCredentials,
|
|
221
|
+
mutateHeadersPlaintext: config?.credentials?.allowPlaintextInject
|
|
222
|
+
? injectCredentials
|
|
223
|
+
: undefined,
|
|
201
224
|
parentProxy,
|
|
202
225
|
proxyAuthToken,
|
|
203
226
|
});
|
|
204
|
-
const server = httpProxyServer;
|
|
205
|
-
await listenInRange(server, p => server.listen(p, '127.0.0.1'), portRange, excludePorts);
|
|
206
|
-
const address = server.address();
|
|
207
|
-
if (!address || typeof address !== 'object') {
|
|
208
|
-
throw new Error('Failed to get HTTP proxy server address');
|
|
209
|
-
}
|
|
210
|
-
server.unref();
|
|
211
|
-
logForDebugging(`HTTP proxy listening on localhost:${address.port}`);
|
|
212
|
-
return address.port;
|
|
213
|
-
}
|
|
214
|
-
async function startSocksProxyServer(sandboxAskCallback, portRange, excludePorts) {
|
|
215
227
|
socksProxyServer = createSocksProxyServer({
|
|
216
228
|
filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
|
|
217
229
|
parentProxy,
|
|
218
230
|
proxyAuthToken,
|
|
219
231
|
});
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const port = await wrapper.listen(p, '127.0.0.1');
|
|
235
|
-
wrapper.unref();
|
|
236
|
-
return port;
|
|
237
|
-
}
|
|
238
|
-
catch (err) {
|
|
239
|
-
lastErr = err;
|
|
240
|
-
if (err?.code !== 'EADDRINUSE')
|
|
241
|
-
throw err;
|
|
242
|
-
}
|
|
232
|
+
muxProxyServer = createMuxProxyServer({
|
|
233
|
+
httpServer: httpProxyServer,
|
|
234
|
+
handleSocksConnection: s => socksProxyServer.handleConnection(s),
|
|
235
|
+
httpBackendPortRange: portRange,
|
|
236
|
+
});
|
|
237
|
+
const mux = muxProxyServer;
|
|
238
|
+
// Backend first so the front-end never accepts a connection that would
|
|
239
|
+
// dispatch to an unbound backend. On Windows the backend's port is
|
|
240
|
+
// excluded when binding the front-end in the same WFP range.
|
|
241
|
+
const backendPort = await mux.listenHttpBackend();
|
|
242
|
+
await listenInRange(mux.server, p => mux.server.listen(p, '127.0.0.1'), portRange, backendPort !== undefined ? new Set([backendPort]) : new Set());
|
|
243
|
+
const muxPort = mux.getPort();
|
|
244
|
+
if (muxPort === undefined) {
|
|
245
|
+
throw new Error('Failed to get mux proxy server port');
|
|
243
246
|
}
|
|
244
|
-
|
|
247
|
+
mux.unref();
|
|
248
|
+
logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`);
|
|
249
|
+
return muxPort;
|
|
245
250
|
}
|
|
246
251
|
// ============================================================================
|
|
247
252
|
// Public Module Functions (will be exported via namespace)
|
|
@@ -285,6 +290,90 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
|
|
|
285
290
|
}
|
|
286
291
|
// Register cleanup handlers first time
|
|
287
292
|
registerCleanup();
|
|
293
|
+
// Windows: apply the file-deny stamp set BEFORE any sandboxed
|
|
294
|
+
// child can be spawned. Synchronous (spawnSync) and independent
|
|
295
|
+
// of the network proxies, so do it here rather than inside the
|
|
296
|
+
// initializationPromise. Throws on any failure (including a
|
|
297
|
+
// partial — exit 2 means at least one input was skipped):
|
|
298
|
+
// fail-closed at session start.
|
|
299
|
+
if (getPlatform() === 'windows') {
|
|
300
|
+
// Separate-user opt-in: refuse early when the config asks for
|
|
301
|
+
// it but the account isn't provisioned. Doing this at
|
|
302
|
+
// initialize() (not wrap-time) means the host gets a single
|
|
303
|
+
// actionable error before any per-exec work happens, instead of
|
|
304
|
+
// exit-15 on every command.
|
|
305
|
+
if (runtimeConfig.windows?.asSandboxUser) {
|
|
306
|
+
const u = getWindowsSandboxUserStatus();
|
|
307
|
+
if (!u.provisioned || !u.credPresent) {
|
|
308
|
+
config = undefined;
|
|
309
|
+
throw new Error(`windows.asSandboxUser is set but the sandbox user is not ` +
|
|
310
|
+
`provisioned (user=${u.provisioned}, cred=${u.credPresent}). ` +
|
|
311
|
+
`Run \`npx sandbox-runtime windows-install\` (one UAC ` +
|
|
312
|
+
`prompt) to provision it.`);
|
|
313
|
+
}
|
|
314
|
+
// schannel-level trust under the sandbox user is install-time
|
|
315
|
+
// (cert lifecycle = sandbox-user lifecycle), not per-session.
|
|
316
|
+
// The env-var trust layer covers OpenSSL clients regardless,
|
|
317
|
+
// but System32 curl / IWR / .NET / default-backend git only
|
|
318
|
+
// trust what's in the sandbox user's `CurrentUser\Root` —
|
|
319
|
+
// which `srt-win exec` does not (and must not) write. Gate
|
|
320
|
+
// only on `asSandboxUser`: the same-user path lands on the
|
|
321
|
+
// REAL user's Root, which is out of scope (env-var trust
|
|
322
|
+
// only). Compare thumbprints so a stale install-time CA
|
|
323
|
+
// doesn't pass the gate while schannel rejects the session's
|
|
324
|
+
// proxy-minted leaves.
|
|
325
|
+
if (runtimeConfig.network.tlsTerminate && mitmCA) {
|
|
326
|
+
const installed = getWindowsSandboxCaCert(u);
|
|
327
|
+
const sessionThumb = new X509Certificate(mitmCA.certPem).fingerprint
|
|
328
|
+
.replace(/:/g, '')
|
|
329
|
+
.toUpperCase();
|
|
330
|
+
if (!installed) {
|
|
331
|
+
config = undefined;
|
|
332
|
+
throw new Error(`tlsTerminate with windows.asSandboxUser requires the ` +
|
|
333
|
+
`sandbox to be installed with this CA (thumb=` +
|
|
334
|
+
`${sessionThumb}): run \`srt-win user trust-ca ` +
|
|
335
|
+
`${mitmCA.certPath}\`. Per-exec installs into the ` +
|
|
336
|
+
`sandbox user's Root store are not supported.`);
|
|
337
|
+
}
|
|
338
|
+
if (installed.thumb !== sessionThumb) {
|
|
339
|
+
config = undefined;
|
|
340
|
+
throw new Error(`tlsTerminate with windows.asSandboxUser: the sandbox's ` +
|
|
341
|
+
`installed CA (thumb=${installed.thumb}) doesn't match ` +
|
|
342
|
+
`this session's CA (thumb=${sessionThumb}). Run ` +
|
|
343
|
+
`\`srt-win user trust-ca ${mitmCA.certPath}\` to ` +
|
|
344
|
+
`update it.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const deny = computeWindowsFsDenySet(runtimeConfig);
|
|
350
|
+
if (deny.denyRead.length > 0 || deny.denyWrite.length > 0) {
|
|
351
|
+
const group = getWindowsGroupRef();
|
|
352
|
+
stampWindowsAcl({
|
|
353
|
+
group,
|
|
354
|
+
denyRead: deny.denyRead,
|
|
355
|
+
denyWrite: deny.denyWrite,
|
|
356
|
+
});
|
|
357
|
+
// Only record the set AFTER a successful stamp — the
|
|
358
|
+
// catch below clears `config`, and a non-undefined
|
|
359
|
+
// stampedSet would leave reset()/updateConfig() seeing a
|
|
360
|
+
// stamp that never landed.
|
|
361
|
+
windowsFsStampedSet = deny;
|
|
362
|
+
windowsFsStampedGroup = group;
|
|
363
|
+
logForDebugging(`[Sandbox Windows] file deny stamped: ` +
|
|
364
|
+
`${deny.denyRead.length} denyRead, ${deny.denyWrite.length} denyWrite`);
|
|
365
|
+
}
|
|
366
|
+
windowsFsRawInputs = rawWindowsFsInputs(runtimeConfig);
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
// Best-effort release of whatever WAS stamped before the
|
|
370
|
+
// failure (exit-2 partial stamps the resolvable inputs;
|
|
371
|
+
// harmless if nothing was stamped — no holds for this PID).
|
|
372
|
+
restoreWindowsAcl({ group: getWindowsGroupRef() });
|
|
373
|
+
config = undefined;
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
288
377
|
// Initialize network infrastructure
|
|
289
378
|
initializationPromise = (async () => {
|
|
290
379
|
try {
|
|
@@ -302,27 +391,23 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
|
|
|
302
391
|
config.network.httpProxyPort !== undefined
|
|
303
392
|
? undefined
|
|
304
393
|
: randomBytes(16).toString('hex');
|
|
305
|
-
|
|
394
|
+
// The mux front-end serves both protocols on one port. Each side's
|
|
395
|
+
// reported port is the external override if configured, else the mux
|
|
396
|
+
// port — so the public config.network.{http,socks}ProxyPort contract
|
|
397
|
+
// is unchanged. The mux is skipped only when BOTH are external.
|
|
398
|
+
const needLocalProxy = config.network.httpProxyPort === undefined ||
|
|
399
|
+
config.network.socksProxyPort === undefined;
|
|
400
|
+
const muxPort = needLocalProxy
|
|
401
|
+
? await startMuxProxyServer(sandboxAskCallback, portRange)
|
|
402
|
+
: undefined;
|
|
403
|
+
const httpProxyPort = config.network.httpProxyPort ?? muxPort;
|
|
404
|
+
const socksProxyPort = config.network.socksProxyPort ?? muxPort;
|
|
306
405
|
if (config.network.httpProxyPort !== undefined) {
|
|
307
|
-
// Use external HTTP proxy (don't start a server)
|
|
308
|
-
httpProxyPort = config.network.httpProxyPort;
|
|
309
406
|
logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
|
|
310
407
|
}
|
|
311
|
-
else {
|
|
312
|
-
// Start local HTTP proxy
|
|
313
|
-
httpProxyPort = await startHttpProxyServer(sandboxAskCallback, portRange, new Set());
|
|
314
|
-
}
|
|
315
|
-
let socksProxyPort;
|
|
316
408
|
if (config.network.socksProxyPort !== undefined) {
|
|
317
|
-
// Use external SOCKS proxy (don't start a server)
|
|
318
|
-
socksProxyPort = config.network.socksProxyPort;
|
|
319
409
|
logForDebugging(`Using external SOCKS proxy on port ${socksProxyPort}`);
|
|
320
410
|
}
|
|
321
|
-
else {
|
|
322
|
-
// Start local SOCKS proxy. Skip the port the HTTP proxy
|
|
323
|
-
// already took.
|
|
324
|
-
socksProxyPort = await startSocksProxyServer(sandboxAskCallback, portRange, new Set([httpProxyPort]));
|
|
325
|
-
}
|
|
326
411
|
// Initialize platform-specific infrastructure
|
|
327
412
|
let linuxBridge;
|
|
328
413
|
if (getPlatform() === 'linux') {
|
|
@@ -408,12 +493,99 @@ function checkDependencies(ripgrepConfig) {
|
|
|
408
493
|
}
|
|
409
494
|
return { errors, warnings };
|
|
410
495
|
}
|
|
496
|
+
/**
|
|
497
|
+
* Build the read-deny / env-unset / env-set maps implied by the
|
|
498
|
+
* `credentials` config.
|
|
499
|
+
*
|
|
500
|
+
* Only explicitly declared sources are restricted: `mode: 'deny'` file
|
|
501
|
+
* entries join the read-deny set, `mode: 'deny'` env vars are unset, and
|
|
502
|
+
* `mode: 'mask'` env vars are set to a per-session sentinel registered in
|
|
503
|
+
* {@link sentinelRegistry}. A masked var with no value in the host
|
|
504
|
+
* environment is skipped — there is nothing to protect, and emitting an
|
|
505
|
+
* unset var would change tool behaviour (presence checks would pass where
|
|
506
|
+
* they didn't before).
|
|
507
|
+
*/
|
|
508
|
+
function getCredentialRestrictions(credentials, allowedDomains) {
|
|
509
|
+
if (!credentials) {
|
|
510
|
+
return {
|
|
511
|
+
denyReadPaths: [],
|
|
512
|
+
unsetEnvVars: [],
|
|
513
|
+
setEnvVars: {},
|
|
514
|
+
maskedFileBinds: [],
|
|
515
|
+
maskedFileStoreDir: undefined,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const denyReadPaths = getCredentialDenyReadPaths(credentials);
|
|
519
|
+
const unsetEnvVars = [];
|
|
520
|
+
const setEnvVars = {};
|
|
521
|
+
for (const v of credentials.envVars ?? []) {
|
|
522
|
+
if (v.mode === 'deny') {
|
|
523
|
+
unsetEnvVars.push(v.name);
|
|
524
|
+
}
|
|
525
|
+
else if (v.mode === 'mask') {
|
|
526
|
+
const real = process.env[v.name];
|
|
527
|
+
if (real === undefined)
|
|
528
|
+
continue;
|
|
529
|
+
// Effective injectHosts: per-entry narrows; if unset, default to
|
|
530
|
+
// every reachable host (network.allowedDomains). injectHosts is an
|
|
531
|
+
// *optional narrowing*, not a required allowlist. Trade-off: a
|
|
532
|
+
// masked credential with no injectHosts is injectable at every host
|
|
533
|
+
// the sandbox can reach — narrow it explicitly when the credential
|
|
534
|
+
// should only go to a subset.
|
|
535
|
+
const injectHosts = v.injectHosts ?? allowedDomains ?? [];
|
|
536
|
+
setEnvVars[v.name] = sentinelRegistry.register(v.name, real, injectHosts);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Masked files: read the real bytes on the host, register a sentinel,
|
|
540
|
+
// write it to a fake file in the manager-owned temp dir. Missing/unreadable
|
|
541
|
+
// entries are skipped (same posture as an unset masked env var).
|
|
542
|
+
const files = credentials.files ?? [];
|
|
543
|
+
const maskedFileBinds = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
|
|
544
|
+
return {
|
|
545
|
+
denyReadPaths,
|
|
546
|
+
unsetEnvVars: [...new Set(unsetEnvVars)],
|
|
547
|
+
setEnvVars,
|
|
548
|
+
maskedFileBinds,
|
|
549
|
+
maskedFileStoreDir: maskedFileStore.dirPath,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Pure (side-effect-free) chokepoint for credential file-deny
|
|
554
|
+
* paths — `credentials.files` entries with `mode: 'deny'`. Any
|
|
555
|
+
* code that needs the credential→denyRead contribution routes
|
|
556
|
+
* through here so a comparison predicate can read it without
|
|
557
|
+
* touching {@link sentinelRegistry}.
|
|
558
|
+
*/
|
|
559
|
+
function getCredentialDenyReadPaths(credentials) {
|
|
560
|
+
const files = credentials?.files ?? [];
|
|
561
|
+
return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))];
|
|
562
|
+
}
|
|
563
|
+
/** Order-insensitive string-set equality. */
|
|
564
|
+
function setEq(a, b) {
|
|
565
|
+
if (a.length !== b.length)
|
|
566
|
+
return false;
|
|
567
|
+
const bs = new Set(b);
|
|
568
|
+
return a.every(v => bs.has(v));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Union the explicit `filesystem.denyRead` with credential-derived
|
|
572
|
+
* deny paths. The single source of "what files does this config
|
|
573
|
+
* want read-denied" — all platforms route through here so a new
|
|
574
|
+
* credential kind that contributes deny paths reaches every
|
|
575
|
+
* backend.
|
|
576
|
+
*/
|
|
577
|
+
function unionDenyReadPaths(denyRead, credentialRestrictions) {
|
|
578
|
+
return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])];
|
|
579
|
+
}
|
|
411
580
|
function getFsReadConfig() {
|
|
412
|
-
if (!config) {
|
|
581
|
+
if (!config || config.filesystem.disabled) {
|
|
413
582
|
return { denyOnly: [], allowWithinDeny: [] };
|
|
414
583
|
}
|
|
584
|
+
// Credential deny paths are unioned with the caller's denyRead — never
|
|
585
|
+
// replacing it — so explicit filesystem restrictions always survive.
|
|
586
|
+
const rawDenyRead = unionDenyReadPaths(config.filesystem.denyRead, getCredentialRestrictions(config.credentials, config.network.allowedDomains));
|
|
415
587
|
const denyPaths = [];
|
|
416
|
-
for (const p of
|
|
588
|
+
for (const p of rawDenyRead) {
|
|
417
589
|
const stripped = removeTrailingGlobSuffix(p);
|
|
418
590
|
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
|
419
591
|
// Expand glob to concrete paths on Linux (bubblewrap doesn't support globs)
|
|
@@ -447,6 +619,9 @@ function getFsWriteConfig() {
|
|
|
447
619
|
if (!config) {
|
|
448
620
|
return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
|
|
449
621
|
}
|
|
622
|
+
if (config.filesystem.disabled) {
|
|
623
|
+
return { allowOnly: ['/'], denyWithinAllow: [] };
|
|
624
|
+
}
|
|
450
625
|
// Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
|
|
451
626
|
const allowPaths = config.filesystem.allowWrite
|
|
452
627
|
.map(path => removeTrailingGlobSuffix(path))
|
|
@@ -474,6 +649,91 @@ function getFsWriteConfig() {
|
|
|
474
649
|
denyWithinAllow: denyPaths,
|
|
475
650
|
};
|
|
476
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* Build the Windows file-deny set from `runtimeConfig`. Globs are
|
|
654
|
+
* expanded to concrete file paths (point-in-time — a file
|
|
655
|
+
* appearing after this returns is NOT covered). Throws on any
|
|
656
|
+
* directory match (file-only for now) and on any unsupported
|
|
657
|
+
* config field that would otherwise be silently dropped.
|
|
658
|
+
*
|
|
659
|
+
* `denyRead` ← `filesystem.denyRead` ∪ `credentials`-derived deny
|
|
660
|
+
* paths (via {@link getCredentialDenyReadPaths}).
|
|
661
|
+
* `denyWrite` ← `filesystem.denyWrite`.
|
|
662
|
+
*
|
|
663
|
+
* Not supported on Windows (throws if non-empty so the caller
|
|
664
|
+
* never silently runs with a weaker-than-configured policy):
|
|
665
|
+
* - `filesystem.allowRead` (re-allow within a denied region)
|
|
666
|
+
* - `filesystem.allowWrite` as a write allow-list — the Windows
|
|
667
|
+
* backend is deny-listed only; the sandboxed child writes
|
|
668
|
+
* wherever the host user can, minus `denyWrite`.
|
|
669
|
+
*/
|
|
670
|
+
function computeWindowsFsDenySet(c) {
|
|
671
|
+
const fs = c.filesystem;
|
|
672
|
+
// filesystem.disabled bypasses ALL filesystem rule generation —
|
|
673
|
+
// same as the macOS/Linux wrapWithSandbox path (readConfig /
|
|
674
|
+
// writeConfig left undefined). On Windows this means no ACL
|
|
675
|
+
// stamp; credential FILE denies are dropped along with the rest
|
|
676
|
+
// (credential ENV scrubbing is independent and still applied at
|
|
677
|
+
// wrap time). Returning empty here means initialize() applies no
|
|
678
|
+
// stamp.
|
|
679
|
+
if (fs?.disabled) {
|
|
680
|
+
return { denyRead: [], denyWrite: [] };
|
|
681
|
+
}
|
|
682
|
+
if (fs?.allowRead?.length) {
|
|
683
|
+
throw new Error(`filesystem.allowRead (re-allow within denyRead) is not supported ` +
|
|
684
|
+
`on Windows. Remove the entries or narrow filesystem.denyRead to ` +
|
|
685
|
+
`exclude them.`);
|
|
686
|
+
}
|
|
687
|
+
if (fs?.allowWrite?.length) {
|
|
688
|
+
throw new Error(`filesystem.allowWrite is not supported on Windows — the Windows ` +
|
|
689
|
+
`sandbox is deny-listed only (the child writes wherever the host ` +
|
|
690
|
+
`user can, minus filesystem.denyWrite). Remove the allowWrite ` +
|
|
691
|
+
`entries.`);
|
|
692
|
+
}
|
|
693
|
+
const denyRead = expandWindowsFsDenyPaths([
|
|
694
|
+
...new Set([
|
|
695
|
+
...(fs?.denyRead ?? []),
|
|
696
|
+
...getCredentialDenyReadPaths(c.credentials),
|
|
697
|
+
]),
|
|
698
|
+
]);
|
|
699
|
+
const denyWrite = expandWindowsFsDenyPaths(fs?.denyWrite ?? []);
|
|
700
|
+
return { denyRead, denyWrite };
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Snapshot the raw config fields that feed
|
|
704
|
+
* {@link computeWindowsFsDenySet}. Used by updateConfig() to
|
|
705
|
+
* short-circuit the resolved-set diff (which re-runs glob
|
|
706
|
+
* expansion) when nothing relevant changed.
|
|
707
|
+
*/
|
|
708
|
+
function rawWindowsFsInputs(c) {
|
|
709
|
+
// Keyed exactly on what {@link computeWindowsFsDenySet} reads:
|
|
710
|
+
// disabled, denyRead, denyWrite, and the credential file-deny
|
|
711
|
+
// paths. `network.allowedDomains` does NOT feed file-deny
|
|
712
|
+
// (only mask injectHosts), so a network-only updateConfig
|
|
713
|
+
// hits the cache.
|
|
714
|
+
return {
|
|
715
|
+
disabled: c.filesystem.disabled ?? false,
|
|
716
|
+
denyRead: [...c.filesystem.denyRead],
|
|
717
|
+
denyWrite: [...c.filesystem.denyWrite],
|
|
718
|
+
credFiles: getCredentialDenyReadPaths(c.credentials),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function sameRawWindowsFsInputs(a, b) {
|
|
722
|
+
return (a.disabled === b.disabled &&
|
|
723
|
+
setEq(a.denyRead, b.denyRead) &&
|
|
724
|
+
setEq(a.denyWrite, b.denyWrite) &&
|
|
725
|
+
setEq(a.credFiles, b.credFiles));
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* True when `newConfig`'s file-deny inputs match what was
|
|
729
|
+
* stamped at initialize(). Compares raw inputs only (cheap,
|
|
730
|
+
* order-insensitive); never re-expands globs — updateConfig is
|
|
731
|
+
* warn-only on Windows and the resolved set wouldn't be used.
|
|
732
|
+
*/
|
|
733
|
+
function sameWindowsStampSet(newConfig) {
|
|
734
|
+
return (windowsFsRawInputs !== undefined &&
|
|
735
|
+
sameRawWindowsFsInputs(windowsFsRawInputs, rawWindowsFsInputs(newConfig)));
|
|
736
|
+
}
|
|
477
737
|
function getNetworkRestrictionConfig() {
|
|
478
738
|
if (!config) {
|
|
479
739
|
return {};
|
|
@@ -563,6 +823,22 @@ async function waitForNetworkInitialization() {
|
|
|
563
823
|
}
|
|
564
824
|
async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
565
825
|
const platform = getPlatform();
|
|
826
|
+
// filesystem.disabled bypasses ALL filesystem rule generation. Both
|
|
827
|
+
// platform wrappers treat readConfig/writeConfig === undefined as "no
|
|
828
|
+
// filesystem restrictions" (seatbelt emits `(allow file-write*)`; bwrap
|
|
829
|
+
// skips the `--ro-bind / /` root and all path binds).
|
|
830
|
+
//
|
|
831
|
+
// Precedence: when a caller passes a per-call filesystem override at all,
|
|
832
|
+
// its `disabled` (defaulting to false) wins outright. A global
|
|
833
|
+
// disabled=true must not silently discard a per-call tightening that
|
|
834
|
+
// omits the new key.
|
|
835
|
+
const fsDisabled = customConfig?.filesystem !== undefined
|
|
836
|
+
? (customConfig.filesystem.disabled ?? false)
|
|
837
|
+
: (config?.filesystem.disabled ?? false);
|
|
838
|
+
// Credential env handling is independent of filesystem policy: unsetEnvVars /
|
|
839
|
+
// setEnvVars must be applied even when fsDisabled (the credential file
|
|
840
|
+
// deny-reads are dropped, but env scrubbing still happens).
|
|
841
|
+
const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
|
|
566
842
|
// Get configs - use custom if provided, otherwise fall back to main config
|
|
567
843
|
// If neither exists, defaults to empty arrays (most restrictive)
|
|
568
844
|
// Always include default system write paths (like /dev/null, /tmp/claude)
|
|
@@ -570,52 +846,62 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
570
846
|
// Strip trailing /** and filter remaining globs on Linux (bwrap needs
|
|
571
847
|
// real paths, not globs; macOS subpath matching is also recursive so
|
|
572
848
|
// stripping is harmless there).
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
849
|
+
let writeConfig;
|
|
850
|
+
let readConfig;
|
|
851
|
+
if (!fsDisabled) {
|
|
852
|
+
const stripWriteGlobs = (paths) => paths
|
|
853
|
+
.map(p => removeTrailingGlobSuffix(p))
|
|
854
|
+
.filter(p => {
|
|
855
|
+
if (getPlatform() === 'linux' && containsGlobChars(p)) {
|
|
856
|
+
logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
});
|
|
861
|
+
const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ??
|
|
862
|
+
config?.filesystem.allowWrite ??
|
|
863
|
+
[]);
|
|
864
|
+
writeConfig = {
|
|
865
|
+
allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
|
|
866
|
+
denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ??
|
|
867
|
+
config?.filesystem.denyWrite ??
|
|
868
|
+
[]),
|
|
869
|
+
};
|
|
870
|
+
// Credential deny paths are unioned with the caller's denyRead — never
|
|
871
|
+
// replacing it — so explicit filesystem restrictions always survive.
|
|
872
|
+
const rawDenyRead = unionDenyReadPaths(customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions);
|
|
873
|
+
const expandedDenyRead = [];
|
|
874
|
+
for (const p of rawDenyRead) {
|
|
875
|
+
const stripped = removeTrailingGlobSuffix(p);
|
|
876
|
+
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
|
877
|
+
expandedDenyRead.push(...expandGlobPattern(p));
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
expandedDenyRead.push(stripped);
|
|
881
|
+
}
|
|
596
882
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
883
|
+
const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
|
|
884
|
+
const expandedAllowRead = [];
|
|
885
|
+
for (const p of rawAllowRead) {
|
|
886
|
+
const stripped = removeTrailingGlobSuffix(p);
|
|
887
|
+
if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
|
|
888
|
+
expandedAllowRead.push(...expandGlobPattern(p));
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
expandedAllowRead.push(stripped);
|
|
892
|
+
}
|
|
604
893
|
}
|
|
605
|
-
|
|
606
|
-
|
|
894
|
+
// The TLS-termination CA cert and the trust bundle the env vars point at
|
|
895
|
+
// (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their
|
|
896
|
+
// paths fall under a user-configured denyRead.
|
|
897
|
+
if (mitmCA) {
|
|
898
|
+
expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath);
|
|
607
899
|
}
|
|
900
|
+
readConfig = {
|
|
901
|
+
denyOnly: expandedDenyRead,
|
|
902
|
+
allowWithinDeny: expandedAllowRead,
|
|
903
|
+
};
|
|
608
904
|
}
|
|
609
|
-
// The TLS-termination CA cert must be readable by the child so the trust
|
|
610
|
-
// env vars (NODE_EXTRA_CA_CERTS etc.) resolve, even if its path falls
|
|
611
|
-
// under a user-configured denyRead.
|
|
612
|
-
if (mitmCA) {
|
|
613
|
-
expandedAllowRead.push(mitmCA.certPath);
|
|
614
|
-
}
|
|
615
|
-
const readConfig = {
|
|
616
|
-
denyOnly: expandedDenyRead,
|
|
617
|
-
allowWithinDeny: expandedAllowRead,
|
|
618
|
-
};
|
|
619
905
|
// Check if network config is specified - this determines if we need network restrictions
|
|
620
906
|
// Network restriction is needed when:
|
|
621
907
|
// 1. customConfig has network.allowedDomains defined (even if empty array = block all)
|
|
@@ -649,9 +935,12 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
649
935
|
httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
|
|
650
936
|
socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
|
|
651
937
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
652
|
-
caCertPath: mitmCA?.
|
|
938
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
653
939
|
readConfig,
|
|
654
940
|
writeConfig,
|
|
941
|
+
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
942
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
943
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
655
944
|
allowUnixSockets: getAllowUnixSockets(),
|
|
656
945
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
657
946
|
allowLocalBinding: getAllowLocalBinding(),
|
|
@@ -682,9 +971,13 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
682
971
|
? managerContext?.socksProxyPort
|
|
683
972
|
: undefined,
|
|
684
973
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
685
|
-
caCertPath: mitmCA?.
|
|
974
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
686
975
|
readConfig,
|
|
687
976
|
writeConfig,
|
|
977
|
+
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
978
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
979
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
980
|
+
maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir,
|
|
688
981
|
enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
|
|
689
982
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
690
983
|
binShell,
|
|
@@ -730,13 +1023,104 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
|
|
|
730
1023
|
if (hasNetworkConfig) {
|
|
731
1024
|
await waitForNetworkInitialization();
|
|
732
1025
|
}
|
|
1026
|
+
const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
|
|
1027
|
+
// Per-exec FILE denies (customConfig only — the session-level
|
|
1028
|
+
// config's denies were already stamped at initialize()).
|
|
1029
|
+
// Unlike the session-level set, paths are passed through
|
|
1030
|
+
// VERBATIM (normalized only): no glob expansion, no
|
|
1031
|
+
// existsSync filter. `srt-win exec`'s
|
|
1032
|
+
// `canonicalize_deny_targets` is the authority — it
|
|
1033
|
+
// hard-fails on glob/dir/nonexistent so a missing path is a
|
|
1034
|
+
// visible caller error, not a silent skip (the session-level
|
|
1035
|
+
// expand-and-drop-missing was for tolerant point-in-time
|
|
1036
|
+
// globs at init; per-exec is "deny THIS one command" and a
|
|
1037
|
+
// path that doesn't resolve is a bug the caller must see).
|
|
1038
|
+
//
|
|
1039
|
+
// The dedup against `windowsFsStampedSet` is an OPTIMIZATION,
|
|
1040
|
+
// not a correctness gate: re-stamping a session-held path
|
|
1041
|
+
// under the exec's distinct holder is refcount-safe but
|
|
1042
|
+
// wastes a SetSecurityInfo round-trip. The mask-escalation /
|
|
1043
|
+
// hardlink-alias guard lives in srt-win's `ensure_stamped`
|
|
1044
|
+
// (`refuse_escalation = true`), NOT here — canonical-path
|
|
1045
|
+
// identity and concurrent holders are only visible to Rust.
|
|
1046
|
+
//
|
|
1047
|
+
// filesystem.disabled bypasses ALL filesystem rule generation
|
|
1048
|
+
// — including credential-derived file denies — same ordering
|
|
1049
|
+
// as session-level `computeWindowsFsDenySet` (credential ENV
|
|
1050
|
+
// scrubbing is independent and still applied at wrap time).
|
|
1051
|
+
// allowRead/allowWrite throw, also matching session-level:
|
|
1052
|
+
// the Windows file-deny sandbox is deny-only.
|
|
1053
|
+
const fsCfg = customConfig?.filesystem;
|
|
1054
|
+
let perExecDenyRead = [];
|
|
1055
|
+
let perExecDenyWrite = [];
|
|
1056
|
+
if (!fsCfg?.disabled) {
|
|
1057
|
+
if (fsCfg?.allowRead?.length) {
|
|
1058
|
+
throw new Error(`Per-exec filesystem.allowRead (re-allow within denyRead) is ` +
|
|
1059
|
+
`not supported on Windows. Remove the entries or narrow ` +
|
|
1060
|
+
`filesystem.denyRead to exclude them.`);
|
|
1061
|
+
}
|
|
1062
|
+
if (fsCfg?.allowWrite?.length) {
|
|
1063
|
+
throw new Error(`Per-exec filesystem.allowWrite is not supported on Windows — ` +
|
|
1064
|
+
`the Windows sandbox is deny-listed only (the child writes ` +
|
|
1065
|
+
`wherever the host user can, minus filesystem.denyWrite). ` +
|
|
1066
|
+
`Remove the allowWrite entries.`);
|
|
1067
|
+
}
|
|
1068
|
+
const rawRead = [
|
|
1069
|
+
...(fsCfg?.denyRead ?? []),
|
|
1070
|
+
...getCredentialDenyReadPaths(customConfig?.credentials),
|
|
1071
|
+
];
|
|
1072
|
+
const rawWrite = fsCfg?.denyWrite ?? [];
|
|
1073
|
+
// Skip on the dominant path (no per-exec fs or
|
|
1074
|
+
// credential-file deny) — this used to call
|
|
1075
|
+
// `computeWindowsFsDenySet` (glob walk + statSync per
|
|
1076
|
+
// match) on every exec, including with
|
|
1077
|
+
// `customConfig === undefined`.
|
|
1078
|
+
if (rawRead.length > 0 || rawWrite.length > 0) {
|
|
1079
|
+
const sessRead = new Set(windowsFsStampedSet?.denyRead ?? []);
|
|
1080
|
+
const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? []);
|
|
1081
|
+
const norm = (raw) => [
|
|
1082
|
+
...new Set(raw.map(normalizePathForSandbox)),
|
|
1083
|
+
];
|
|
1084
|
+
perExecDenyRead = norm(rawRead).filter(p => !sessRead.has(p));
|
|
1085
|
+
perExecDenyWrite = norm(rawWrite).filter(p => !sessRead.has(p) && !sessWrite.has(p));
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Per-exec deny rides on argv (`acl stamp` reads stdin, but
|
|
1089
|
+
// exec's stdin belongs to the child). The CreateProcessW
|
|
1090
|
+
// length check lives in `wrapCommandWithSandboxWindows`
|
|
1091
|
+
// where the full argv (incl. shell + user command) is known.
|
|
1092
|
+
//
|
|
1093
|
+
// Credential env restrictions are passed INTO the wrapper so it
|
|
1094
|
+
// can apply them BEFORE merging the proxy env (same precedence
|
|
1095
|
+
// as the macOS/Linux `env -u … VAR=… sandbox-exec` order — the
|
|
1096
|
+
// sandbox's own proxy plumbing must survive a caller listing
|
|
1097
|
+
// e.g. HTTPS_PROXY as a denied credential). The `denyReadPaths`
|
|
1098
|
+
// half of the SESSION-level credentials is already unioned into
|
|
1099
|
+
// the stamp set at initialize() time via
|
|
1100
|
+
// `computeWindowsFsDenySet`.
|
|
733
1101
|
return wrapCommandWithSandboxWindows({
|
|
734
1102
|
command,
|
|
735
1103
|
group: getWindowsGroupRef(),
|
|
1104
|
+
sublayerGuid: config?.windows?.wfpSublayerGuid,
|
|
736
1105
|
httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
|
|
737
1106
|
socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
|
|
738
1107
|
proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
|
|
739
|
-
|
|
1108
|
+
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
1109
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
1110
|
+
// Engage the session-level fence only when this session
|
|
1111
|
+
// actually stamped — keeps `srt-win exec` standalone (no
|
|
1112
|
+
// state-DB dependency) when no file-deny is configured. The
|
|
1113
|
+
// per-exec deny below opens its own fence under the exec's
|
|
1114
|
+
// own PID regardless.
|
|
1115
|
+
holderPid: windowsFsStampedSet ? process.pid : undefined,
|
|
1116
|
+
denyRead: perExecDenyRead,
|
|
1117
|
+
denyWrite: perExecDenyWrite,
|
|
1118
|
+
// Opt-in two-hop separate-user launch. Additive — defaults
|
|
1119
|
+
// false, the same-user deny-only-group path is unchanged.
|
|
1120
|
+
// Provisioning was checked at initialize().
|
|
1121
|
+
asSandboxUser: config?.windows?.asSandboxUser ?? false,
|
|
1122
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
1123
|
+
binShell: parseWindowsBinShell(binShell),
|
|
740
1124
|
});
|
|
741
1125
|
}
|
|
742
1126
|
// macOS/Linux: delegate to the existing string wrapper, then put
|
|
@@ -765,12 +1149,33 @@ function getConfig() {
|
|
|
765
1149
|
*
|
|
766
1150
|
* Filesystem changes (denyRead/denyWrite) are NOT applied live:
|
|
767
1151
|
* macOS bakes them into the seatbelt profile at wrap time, and
|
|
768
|
-
* Windows
|
|
769
|
-
*
|
|
1152
|
+
* Windows applies the ACL stamp once at `initialize()` (a live
|
|
1153
|
+
* swap would mean releasing all of this holder's claims and
|
|
1154
|
+
* re-stamping, which opens an unprotected window). To change FS
|
|
1155
|
+
* restrictions, `reset()` then `initialize()` with the new
|
|
1156
|
+
* config; on Windows, calling this with a config whose file-deny
|
|
1157
|
+
* inputs (`filesystem.denyRead`/`denyWrite`, `credentials.files`)
|
|
1158
|
+
* differ from those passed at `initialize()` logs a warning and
|
|
1159
|
+
* the stamped set stays as-is.
|
|
770
1160
|
*
|
|
771
1161
|
* @param newConfig - The new configuration to use
|
|
772
1162
|
*/
|
|
773
1163
|
function updateConfig(newConfig) {
|
|
1164
|
+
if (getPlatform() === 'windows' &&
|
|
1165
|
+
config &&
|
|
1166
|
+
(newConfig.windows?.groupSid !== config.windows?.groupSid ||
|
|
1167
|
+
newConfig.windows?.groupName !== config.windows?.groupName)) {
|
|
1168
|
+
throw new Error('Changing the Windows sandbox group requires reset() and ' +
|
|
1169
|
+
're-initialize().');
|
|
1170
|
+
}
|
|
1171
|
+
if (getPlatform() === 'windows' &&
|
|
1172
|
+
config &&
|
|
1173
|
+
!sameWindowsStampSet(newConfig)) {
|
|
1174
|
+
logForDebugging(`[Sandbox Windows] updateConfig: the resolved file-deny set ` +
|
|
1175
|
+
`(filesystem.denyRead/denyWrite ∪ credentials.files) changed but ` +
|
|
1176
|
+
`the ACL stamp is session-wide — call reset() then initialize() ` +
|
|
1177
|
+
`to apply. The previously-stamped set stays in effect.`, { level: 'warn' });
|
|
1178
|
+
}
|
|
774
1179
|
// Deep clone the config to avoid mutations. structuredClone cannot clone
|
|
775
1180
|
// functions, so pull filterRequest out, clone the rest, and put it back —
|
|
776
1181
|
// a function reference is immutable in the sense that matters here.
|
|
@@ -907,6 +1312,39 @@ function forceCloseHttpServer(server) {
|
|
|
907
1312
|
});
|
|
908
1313
|
}
|
|
909
1314
|
async function reset() {
|
|
1315
|
+
// Windows: release this session's file-deny stamps. Best-effort
|
|
1316
|
+
// — log anomalies (relocated/missing/tampered/…) rather than
|
|
1317
|
+
// throw, so teardown always completes. The on-disk hash-ACE
|
|
1318
|
+
// marker means a stamp left in place is recoverable later via
|
|
1319
|
+
// `srt-win acl recover`.
|
|
1320
|
+
if (windowsFsStampedSet) {
|
|
1321
|
+
const r = restoreWindowsAcl({
|
|
1322
|
+
group: windowsFsStampedGroup ?? getWindowsGroupRef(),
|
|
1323
|
+
});
|
|
1324
|
+
if (r) {
|
|
1325
|
+
for (const e of r.paths ?? []) {
|
|
1326
|
+
if (!WINDOWS_ACL_PATH_OK.has(e.status)) {
|
|
1327
|
+
const tail = e.status === 'missing'
|
|
1328
|
+
? ' — file no longer exists; snapshot row kept for tracking'
|
|
1329
|
+
: (e.movedTo ? ` (now at '${e.movedTo}')` : '') +
|
|
1330
|
+
' — stamp left in place; resolve and run ' +
|
|
1331
|
+
'`srt-win acl recover` to clear';
|
|
1332
|
+
logForDebugging(`[Sandbox Windows] file-deny restore: '${e.path}' ` +
|
|
1333
|
+
`${e.status}${tail}`, { level: 'warn' });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
for (const e of r.parents ?? []) {
|
|
1337
|
+
if (!WINDOWS_ACL_PARENT_OK.has(e.status)) {
|
|
1338
|
+
logForDebugging(`[Sandbox Windows] file-deny restore: parent ` +
|
|
1339
|
+
`'${e.path}' ${e.status}` +
|
|
1340
|
+
(e.error ? `: ${e.error}` : ''), { level: 'warn' });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
windowsFsStampedSet = undefined;
|
|
1346
|
+
windowsFsStampedGroup = undefined;
|
|
1347
|
+
windowsFsRawInputs = undefined;
|
|
910
1348
|
// Clean up any leftover bwrap mount points. Force past the
|
|
911
1349
|
// active-sandbox counter — reset() means the session is over.
|
|
912
1350
|
cleanupBwrapMountPoints({ force: true });
|
|
@@ -951,6 +1389,13 @@ async function reset() {
|
|
|
951
1389
|
if (mitmCA) {
|
|
952
1390
|
closePromises.push(disposeMitmCA(mitmCA));
|
|
953
1391
|
}
|
|
1392
|
+
if (muxProxyServer) {
|
|
1393
|
+
closePromises.push(muxProxyServer.close().catch((error) => {
|
|
1394
|
+
logForDebugging(`Error closing mux proxy server: ${error.message}`, {
|
|
1395
|
+
level: 'error',
|
|
1396
|
+
});
|
|
1397
|
+
}));
|
|
1398
|
+
}
|
|
954
1399
|
if (httpProxyServer) {
|
|
955
1400
|
closePromises.push(forceCloseHttpServer(httpProxyServer));
|
|
956
1401
|
}
|
|
@@ -965,6 +1410,7 @@ async function reset() {
|
|
|
965
1410
|
// Wait for all servers to close
|
|
966
1411
|
await Promise.all(closePromises);
|
|
967
1412
|
// Clear references
|
|
1413
|
+
muxProxyServer = undefined;
|
|
968
1414
|
httpProxyServer = undefined;
|
|
969
1415
|
proxyAuthToken = undefined;
|
|
970
1416
|
socksProxyServer = undefined;
|
|
@@ -972,6 +1418,8 @@ async function reset() {
|
|
|
972
1418
|
initializationPromise = undefined;
|
|
973
1419
|
parentProxy = undefined;
|
|
974
1420
|
mitmCA = undefined;
|
|
1421
|
+
sentinelRegistry.clear();
|
|
1422
|
+
maskedFileStore.dispose();
|
|
975
1423
|
}
|
|
976
1424
|
function getSandboxViolationStore() {
|
|
977
1425
|
return sandboxViolationStore;
|
|
@@ -1002,7 +1450,7 @@ function annotateStderrWithSandboxFailures(command, stderr) {
|
|
|
1002
1450
|
function getLinuxGlobPatternWarnings() {
|
|
1003
1451
|
// Only warn on Linux/WSL (bubblewrap doesn't support globs)
|
|
1004
1452
|
// macOS supports glob patterns via regex conversion
|
|
1005
|
-
if (getPlatform() !== 'linux' || !config) {
|
|
1453
|
+
if (getPlatform() !== 'linux' || !config || config.filesystem.disabled) {
|
|
1006
1454
|
return [];
|
|
1007
1455
|
}
|
|
1008
1456
|
const globPatterns = [];
|
|
@@ -1053,6 +1501,8 @@ export const SandboxManager = {
|
|
|
1053
1501
|
cleanupAfterCommand,
|
|
1054
1502
|
reset,
|
|
1055
1503
|
getMitmCA: () => mitmCA,
|
|
1504
|
+
getSentinelRegistry: () => sentinelRegistry,
|
|
1505
|
+
getMaskedFileStore: () => maskedFileStore,
|
|
1056
1506
|
getSandboxViolationStore,
|
|
1057
1507
|
annotateStderrWithSandboxFailures,
|
|
1058
1508
|
getLinuxGlobPatternWarnings,
|