@sysid/sandbox-runtime-improved 0.0.56-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/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- 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 +15 -0
- package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/linux-sandbox-utils.js +106 -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 +11 -0
- package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
- package/dist/sandbox/macos-sandbox-utils.js +69 -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 +254 -25
- package/dist/sandbox/sandbox-config.d.ts.map +1 -1
- package/dist/sandbox/sandbox-config.js +200 -13
- 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 +601 -194
- package/dist/sandbox/sandbox-manager.js.map +1 -1
- package/dist/sandbox/sandbox-schemas.d.ts +15 -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') {
|
|
@@ -409,39 +494,96 @@ function checkDependencies(ripgrepConfig) {
|
|
|
409
494
|
return { errors, warnings };
|
|
410
495
|
}
|
|
411
496
|
/**
|
|
412
|
-
* Build the read-deny / env-unset
|
|
497
|
+
* Build the read-deny / env-unset / env-set maps implied by the
|
|
498
|
+
* `credentials` config.
|
|
413
499
|
*
|
|
414
500
|
* Only explicitly declared sources are restricted: `mode: 'deny'` file
|
|
415
|
-
* entries join the read-deny set
|
|
416
|
-
*
|
|
417
|
-
*
|
|
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).
|
|
418
507
|
*/
|
|
419
|
-
function getCredentialRestrictions(credentials) {
|
|
508
|
+
function getCredentialRestrictions(credentials, allowedDomains) {
|
|
420
509
|
if (!credentials) {
|
|
421
|
-
return {
|
|
510
|
+
return {
|
|
511
|
+
denyReadPaths: [],
|
|
512
|
+
unsetEnvVars: [],
|
|
513
|
+
setEnvVars: {},
|
|
514
|
+
maskedFileBinds: [],
|
|
515
|
+
maskedFileStoreDir: undefined,
|
|
516
|
+
};
|
|
422
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).
|
|
423
542
|
const files = credentials.files ?? [];
|
|
424
|
-
const
|
|
425
|
-
const unsetEnvVars = (credentials.envVars ?? [])
|
|
426
|
-
.filter(v => v.mode === 'deny')
|
|
427
|
-
.map(v => v.name);
|
|
543
|
+
const maskedFileBinds = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
|
|
428
544
|
return {
|
|
429
|
-
denyReadPaths
|
|
545
|
+
denyReadPaths,
|
|
430
546
|
unsetEnvVars: [...new Set(unsetEnvVars)],
|
|
547
|
+
setEnvVars,
|
|
548
|
+
maskedFileBinds,
|
|
549
|
+
maskedFileStoreDir: maskedFileStore.dirPath,
|
|
431
550
|
};
|
|
432
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
|
+
}
|
|
433
580
|
function getFsReadConfig() {
|
|
434
|
-
if (!config) {
|
|
581
|
+
if (!config || config.filesystem.disabled) {
|
|
435
582
|
return { denyOnly: [], allowWithinDeny: [] };
|
|
436
583
|
}
|
|
437
584
|
// Credential deny paths are unioned with the caller's denyRead — never
|
|
438
585
|
// replacing it — so explicit filesystem restrictions always survive.
|
|
439
|
-
const rawDenyRead =
|
|
440
|
-
...new Set([
|
|
441
|
-
...config.filesystem.denyRead,
|
|
442
|
-
...getCredentialRestrictions(config.credentials).denyReadPaths,
|
|
443
|
-
]),
|
|
444
|
-
];
|
|
586
|
+
const rawDenyRead = unionDenyReadPaths(config.filesystem.denyRead, getCredentialRestrictions(config.credentials, config.network.allowedDomains));
|
|
445
587
|
const denyPaths = [];
|
|
446
588
|
for (const p of rawDenyRead) {
|
|
447
589
|
const stripped = removeTrailingGlobSuffix(p);
|
|
@@ -477,6 +619,9 @@ function getFsWriteConfig() {
|
|
|
477
619
|
if (!config) {
|
|
478
620
|
return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
|
|
479
621
|
}
|
|
622
|
+
if (config.filesystem.disabled) {
|
|
623
|
+
return { allowOnly: ['/'], denyWithinAllow: [] };
|
|
624
|
+
}
|
|
480
625
|
// Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
|
|
481
626
|
const allowPaths = config.filesystem.allowWrite
|
|
482
627
|
.map(path => removeTrailingGlobSuffix(path))
|
|
@@ -504,6 +649,91 @@ function getFsWriteConfig() {
|
|
|
504
649
|
denyWithinAllow: denyPaths,
|
|
505
650
|
};
|
|
506
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
|
+
}
|
|
507
737
|
function getNetworkRestrictionConfig() {
|
|
508
738
|
if (!config) {
|
|
509
739
|
return {};
|
|
@@ -593,6 +823,22 @@ async function waitForNetworkInitialization() {
|
|
|
593
823
|
}
|
|
594
824
|
async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
595
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);
|
|
596
842
|
// Get configs - use custom if provided, otherwise fall back to main config
|
|
597
843
|
// If neither exists, defaults to empty arrays (most restrictive)
|
|
598
844
|
// Always include default system write paths (like /dev/null, /tmp/claude)
|
|
@@ -600,63 +846,62 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
600
846
|
// Strip trailing /** and filter remaining globs on Linux (bwrap needs
|
|
601
847
|
// real paths, not globs; macOS subpath matching is also recursive so
|
|
602
848
|
// stripping is harmless there).
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
...new Set([
|
|
623
|
-
...(customConfig?.filesystem?.denyRead ??
|
|
624
|
-
config?.filesystem.denyRead ??
|
|
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 ??
|
|
625
868
|
[]),
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
+
}
|
|
637
882
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
+
}
|
|
645
893
|
}
|
|
646
|
-
|
|
647
|
-
|
|
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);
|
|
648
899
|
}
|
|
900
|
+
readConfig = {
|
|
901
|
+
denyOnly: expandedDenyRead,
|
|
902
|
+
allowWithinDeny: expandedAllowRead,
|
|
903
|
+
};
|
|
649
904
|
}
|
|
650
|
-
// The TLS-termination CA cert must be readable by the child so the trust
|
|
651
|
-
// env vars (NODE_EXTRA_CA_CERTS etc.) resolve, even if its path falls
|
|
652
|
-
// under a user-configured denyRead.
|
|
653
|
-
if (mitmCA) {
|
|
654
|
-
expandedAllowRead.push(mitmCA.certPath);
|
|
655
|
-
}
|
|
656
|
-
const readConfig = {
|
|
657
|
-
denyOnly: expandedDenyRead,
|
|
658
|
-
allowWithinDeny: expandedAllowRead,
|
|
659
|
-
};
|
|
660
905
|
// Check if network config is specified - this determines if we need network restrictions
|
|
661
906
|
// Network restriction is needed when:
|
|
662
907
|
// 1. customConfig has network.allowedDomains defined (even if empty array = block all)
|
|
@@ -690,10 +935,12 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
690
935
|
httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
|
|
691
936
|
socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
|
|
692
937
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
693
|
-
caCertPath: mitmCA?.
|
|
938
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
694
939
|
readConfig,
|
|
695
940
|
writeConfig,
|
|
696
941
|
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
942
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
943
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
697
944
|
allowUnixSockets: getAllowUnixSockets(),
|
|
698
945
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
699
946
|
allowLocalBinding: getAllowLocalBinding(),
|
|
@@ -724,10 +971,13 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
|
|
|
724
971
|
? managerContext?.socksProxyPort
|
|
725
972
|
: undefined,
|
|
726
973
|
proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
|
|
727
|
-
caCertPath: mitmCA?.
|
|
974
|
+
caCertPath: mitmCA?.trustBundlePath,
|
|
728
975
|
readConfig,
|
|
729
976
|
writeConfig,
|
|
730
977
|
unsetEnvVars: credentialRestrictions.unsetEnvVars,
|
|
978
|
+
setEnvVars: credentialRestrictions.setEnvVars,
|
|
979
|
+
maskedFileBinds: credentialRestrictions.maskedFileBinds,
|
|
980
|
+
maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir,
|
|
731
981
|
enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
|
|
732
982
|
allowAllUnixSockets: getAllowAllUnixSockets(),
|
|
733
983
|
binShell,
|
|
@@ -773,13 +1023,104 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
|
|
|
773
1023
|
if (hasNetworkConfig) {
|
|
774
1024
|
await waitForNetworkInitialization();
|
|
775
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`.
|
|
776
1101
|
return wrapCommandWithSandboxWindows({
|
|
777
1102
|
command,
|
|
778
1103
|
group: getWindowsGroupRef(),
|
|
1104
|
+
sublayerGuid: config?.windows?.wfpSublayerGuid,
|
|
779
1105
|
httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
|
|
780
1106
|
socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
|
|
781
1107
|
proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
|
|
782
|
-
|
|
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),
|
|
783
1124
|
});
|
|
784
1125
|
}
|
|
785
1126
|
// macOS/Linux: delegate to the existing string wrapper, then put
|
|
@@ -808,12 +1149,33 @@ function getConfig() {
|
|
|
808
1149
|
*
|
|
809
1150
|
* Filesystem changes (denyRead/denyWrite) are NOT applied live:
|
|
810
1151
|
* macOS bakes them into the seatbelt profile at wrap time, and
|
|
811
|
-
* Windows
|
|
812
|
-
*
|
|
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.
|
|
813
1160
|
*
|
|
814
1161
|
* @param newConfig - The new configuration to use
|
|
815
1162
|
*/
|
|
816
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
|
+
}
|
|
817
1179
|
// Deep clone the config to avoid mutations. structuredClone cannot clone
|
|
818
1180
|
// functions, so pull filterRequest out, clone the rest, and put it back —
|
|
819
1181
|
// a function reference is immutable in the sense that matters here.
|
|
@@ -950,6 +1312,39 @@ function forceCloseHttpServer(server) {
|
|
|
950
1312
|
});
|
|
951
1313
|
}
|
|
952
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;
|
|
953
1348
|
// Clean up any leftover bwrap mount points. Force past the
|
|
954
1349
|
// active-sandbox counter — reset() means the session is over.
|
|
955
1350
|
cleanupBwrapMountPoints({ force: true });
|
|
@@ -994,6 +1389,13 @@ async function reset() {
|
|
|
994
1389
|
if (mitmCA) {
|
|
995
1390
|
closePromises.push(disposeMitmCA(mitmCA));
|
|
996
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
|
+
}
|
|
997
1399
|
if (httpProxyServer) {
|
|
998
1400
|
closePromises.push(forceCloseHttpServer(httpProxyServer));
|
|
999
1401
|
}
|
|
@@ -1008,6 +1410,7 @@ async function reset() {
|
|
|
1008
1410
|
// Wait for all servers to close
|
|
1009
1411
|
await Promise.all(closePromises);
|
|
1010
1412
|
// Clear references
|
|
1413
|
+
muxProxyServer = undefined;
|
|
1011
1414
|
httpProxyServer = undefined;
|
|
1012
1415
|
proxyAuthToken = undefined;
|
|
1013
1416
|
socksProxyServer = undefined;
|
|
@@ -1015,6 +1418,8 @@ async function reset() {
|
|
|
1015
1418
|
initializationPromise = undefined;
|
|
1016
1419
|
parentProxy = undefined;
|
|
1017
1420
|
mitmCA = undefined;
|
|
1421
|
+
sentinelRegistry.clear();
|
|
1422
|
+
maskedFileStore.dispose();
|
|
1018
1423
|
}
|
|
1019
1424
|
function getSandboxViolationStore() {
|
|
1020
1425
|
return sandboxViolationStore;
|
|
@@ -1045,7 +1450,7 @@ function annotateStderrWithSandboxFailures(command, stderr) {
|
|
|
1045
1450
|
function getLinuxGlobPatternWarnings() {
|
|
1046
1451
|
// Only warn on Linux/WSL (bubblewrap doesn't support globs)
|
|
1047
1452
|
// macOS supports glob patterns via regex conversion
|
|
1048
|
-
if (getPlatform() !== 'linux' || !config) {
|
|
1453
|
+
if (getPlatform() !== 'linux' || !config || config.filesystem.disabled) {
|
|
1049
1454
|
return [];
|
|
1050
1455
|
}
|
|
1051
1456
|
const globPatterns = [];
|
|
@@ -1096,6 +1501,8 @@ export const SandboxManager = {
|
|
|
1096
1501
|
cleanupAfterCommand,
|
|
1097
1502
|
reset,
|
|
1098
1503
|
getMitmCA: () => mitmCA,
|
|
1504
|
+
getSentinelRegistry: () => sentinelRegistry,
|
|
1505
|
+
getMaskedFileStore: () => maskedFileStore,
|
|
1099
1506
|
getSandboxViolationStore,
|
|
1100
1507
|
annotateStderrWithSandboxFailures,
|
|
1101
1508
|
getLinuxGlobPatternWarnings,
|