@sysid/sandbox-runtime-improved 0.0.51-sysid.1 → 0.0.54-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.
Files changed (72) hide show
  1. package/README.md +5 -2
  2. package/dist/cli.js +91 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  9. package/dist/sandbox/http-proxy.js +31 -6
  10. package/dist/sandbox/http-proxy.js.map +1 -1
  11. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  12. package/dist/sandbox/linux-sandbox-utils.js +17 -11
  13. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  14. package/dist/sandbox/macos-sandbox-utils.d.ts +1 -0
  15. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  16. package/dist/sandbox/macos-sandbox-utils.js +16 -2
  17. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  18. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  19. package/dist/sandbox/mitm-leaf.js +18 -5
  20. package/dist/sandbox/mitm-leaf.js.map +1 -1
  21. package/dist/sandbox/sandbox-config.d.ts +53 -0
  22. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  23. package/dist/sandbox/sandbox-config.js +46 -0
  24. package/dist/sandbox/sandbox-config.js.map +1 -1
  25. package/dist/sandbox/sandbox-manager.d.ts +4 -0
  26. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  27. package/dist/sandbox/sandbox-manager.js +172 -40
  28. package/dist/sandbox/sandbox-manager.js.map +1 -1
  29. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  30. package/dist/sandbox/sandbox-utils.js +12 -3
  31. package/dist/sandbox/sandbox-utils.js.map +1 -1
  32. package/dist/sandbox/tls-terminate-proxy.d.ts +29 -0
  33. package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
  34. package/dist/sandbox/tls-terminate-proxy.js +71 -7
  35. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  36. package/dist/sandbox/windows-sandbox-utils.d.ts +222 -0
  37. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -0
  38. package/dist/sandbox/windows-sandbox-utils.js +433 -0
  39. package/dist/sandbox/windows-sandbox-utils.js.map +1 -0
  40. package/dist/vendor/srt-win/Cargo.lock +361 -0
  41. package/dist/vendor/srt-win/Cargo.toml +46 -0
  42. package/dist/vendor/srt-win/ci/cleanup.ps1 +49 -0
  43. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +446 -0
  44. package/dist/vendor/srt-win/ci/smoke.ps1 +307 -0
  45. package/dist/vendor/srt-win/src/job.rs +102 -0
  46. package/dist/vendor/srt-win/src/launch.rs +732 -0
  47. package/dist/vendor/srt-win/src/lib.rs +20 -0
  48. package/dist/vendor/srt-win/src/main.rs +669 -0
  49. package/dist/vendor/srt-win/src/self_protect.rs +169 -0
  50. package/dist/vendor/srt-win/src/sid.rs +296 -0
  51. package/dist/vendor/srt-win/src/token.rs +341 -0
  52. package/dist/vendor/srt-win/src/util.rs +42 -0
  53. package/dist/vendor/srt-win/src/wfp.rs +992 -0
  54. package/dist/vendor/srt-win/src/winsta.rs +209 -0
  55. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +259 -0
  56. package/package.json +2 -2
  57. package/vendor/srt-win/Cargo.lock +361 -0
  58. package/vendor/srt-win/Cargo.toml +46 -0
  59. package/vendor/srt-win/ci/cleanup.ps1 +49 -0
  60. package/vendor/srt-win/ci/smoke-exec.ps1 +446 -0
  61. package/vendor/srt-win/ci/smoke.ps1 +307 -0
  62. package/vendor/srt-win/src/job.rs +102 -0
  63. package/vendor/srt-win/src/launch.rs +732 -0
  64. package/vendor/srt-win/src/lib.rs +20 -0
  65. package/vendor/srt-win/src/main.rs +669 -0
  66. package/vendor/srt-win/src/self_protect.rs +169 -0
  67. package/vendor/srt-win/src/sid.rs +296 -0
  68. package/vendor/srt-win/src/token.rs +341 -0
  69. package/vendor/srt-win/src/util.rs +42 -0
  70. package/vendor/srt-win/src/wfp.rs +992 -0
  71. package/vendor/srt-win/src/winsta.rs +209 -0
  72. package/vendor/srt-win/tests/sd_access_check_matrix.rs +259 -0
@@ -0,0 +1,732 @@
1
+ //! `srt-win exec`: build the deny-only-group restricted token,
2
+ //! self-protect the broker, spawn the target suspended under a
3
+ //! locked-down job + non-interactive desktop + mitigation-policy
4
+ //! stack + explicit handle whitelist, resume, wait, propagate exit
5
+ //! code.
6
+ //!
7
+ //! Stateless — no marker file, no proxy thread, no FS-deny
8
+ //! handling here. Network egress for the child reaches the host's
9
+ //! JS-side proxies (whose ports the caller passes) via the WFP
10
+ //! loopback permit installed by `srt-win wfp install`.
11
+
12
+ use anyhow::{anyhow, Context, Result};
13
+ use std::ffi::c_void;
14
+ use std::mem::{size_of, zeroed};
15
+ use std::path::Path;
16
+ use windows::core::{PCWSTR, PWSTR};
17
+ use windows::Win32::Foundation::{
18
+ CloseHandle, SetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT,
19
+ WAIT_OBJECT_0,
20
+ };
21
+ use windows::Win32::System::Console::{
22
+ GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
23
+ };
24
+ use windows::Win32::System::Threading::{
25
+ CreateProcessAsUserW, DeleteProcThreadAttributeList, GetExitCodeProcess,
26
+ InitializeProcThreadAttributeList, ResumeThread, TerminateProcess,
27
+ UpdateProcThreadAttribute, WaitForSingleObject, CREATE_SUSPENDED,
28
+ CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, INFINITE,
29
+ LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION,
30
+ PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
31
+ PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, STARTUPINFOEXW, STARTUPINFOW,
32
+ };
33
+
34
+ use crate::job::Job;
35
+ use crate::self_protect;
36
+ use crate::sid::{self, GroupState};
37
+ use crate::token::{self, open_self_token, to_primary};
38
+ use crate::util::{pcwstr, wstr};
39
+ use crate::winsta::WinStaDesk;
40
+
41
+ // ─── RAII handle wrappers ───────────────────────────────────────────
42
+
43
+ /// Owns a kernel `HANDLE`; `CloseHandle` on drop. For tokens and
44
+ /// the like — anything where the only cleanup is `CloseHandle`.
45
+ struct OwnedHandle(HANDLE);
46
+ impl OwnedHandle {
47
+ fn raw(&self) -> HANDLE { self.0 }
48
+ }
49
+ impl Drop for OwnedHandle {
50
+ fn drop(&mut self) {
51
+ if !self.0.is_invalid() {
52
+ unsafe { let _ = CloseHandle(self.0); }
53
+ }
54
+ }
55
+ }
56
+
57
+ /// Owns a freshly-spawned (suspended) child. If dropped before
58
+ /// [`defuse`] is called, terminates the child — so an error
59
+ /// between `CreateProcessAsUserW` and `WaitForSingleObject`
60
+ /// can't orphan a suspended process that's not yet in the job.
61
+ /// Always closes both handles on drop.
62
+ struct SpawnedChild {
63
+ pi: PROCESS_INFORMATION,
64
+ armed: bool,
65
+ }
66
+ impl SpawnedChild {
67
+ fn new(pi: PROCESS_INFORMATION) -> Self {
68
+ Self { pi, armed: true }
69
+ }
70
+ fn process(&self) -> HANDLE { self.pi.hProcess }
71
+ fn thread(&self) -> HANDLE { self.pi.hThread }
72
+ /// Disarm the terminate-on-drop. Call after the child has been
73
+ /// assigned to the job AND resumed — past that point
74
+ /// `KILL_ON_JOB_CLOSE` covers cleanup.
75
+ fn defuse(&mut self) { self.armed = false; }
76
+ }
77
+ impl Drop for SpawnedChild {
78
+ fn drop(&mut self) {
79
+ unsafe {
80
+ if self.armed {
81
+ let _ = TerminateProcess(self.pi.hProcess, 1);
82
+ }
83
+ let _ = CloseHandle(self.pi.hThread);
84
+ let _ = CloseHandle(self.pi.hProcess);
85
+ }
86
+ }
87
+ }
88
+
89
+ // ─── Process-creation mitigation-policy bits ────────────────────────
90
+ //
91
+ // The `windows` crate exposes `PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY`
92
+ // but not the per-bit DWORD64 macros (they're winnt.h preprocessor
93
+ // `#define`s, still absent as of 0.62). Each policy occupies a 4-bit
94
+ // slot in the u64; `..._ALWAYS_ON` flips bit 0 of its slot.
95
+ //
96
+ // Only mitigations that don't break Node/Python JIT or mingw-built
97
+ // shells are enabled here. Specifically NOT enabled:
98
+ // - `IMAGE_LOAD_PREFER_SYSTEM32` — flips DLL search-order so System32
99
+ // wins over the EXE's directory; breaks the cygwin1.dll /
100
+ // msys-2.0.dll resolution model.
101
+ // - `CONTROL_FLOW_GUARD_ALWAYS_ON` — forces CFG even when the EXE
102
+ // wasn't built with `/guard:cf`; stock mingw-built `bash.exe`
103
+ // dies in `dofork`. CFG is defense-in-depth, not a primary
104
+ // boundary.
105
+
106
+ /// Bit 32 — block legacy AppInit / IME / Winsock-LSP DLL injection
107
+ /// and `SetWindowsHookEx`.
108
+ const MITIGATION_EXTENSION_POINT_DISABLE: u64 = 1u64 << 32;
109
+ /// Bit 48 — block GDI from loading non-system fonts (historic
110
+ /// kernel font-parser RCE surface; sandbox children are
111
+ /// console/network workloads).
112
+ const MITIGATION_FONT_DISABLE: u64 = 1u64 << 48;
113
+ /// Bit 52 — refuse `LoadLibrary` from UNC / network paths.
114
+ const MITIGATION_IMAGE_LOAD_NO_REMOTE: u64 = 1u64 << 52;
115
+ /// Bit 56 — refuse `LoadLibrary` of any image whose mandatory label
116
+ /// is Low IL.
117
+ const MITIGATION_IMAGE_LOAD_NO_LOW_LABEL: u64 = 1u64 << 56;
118
+
119
+ /// Inputs to a single `srt-win exec` invocation.
120
+ pub struct ExecSpec<'a> {
121
+ /// Discriminator group SID (already resolved by the caller from
122
+ /// `--name` / `--group-sid`).
123
+ pub group_sid: &'a str,
124
+ /// Skip the "is `group_sid` enabled in the broker's token"
125
+ /// pre-flight check. **Fail-open** — the WFP fence relies on
126
+ /// the broker having the group enabled; with this set the
127
+ /// child may run with weaker isolation if the install was
128
+ /// incomplete. Surfaced as an explicit CLI flag (not an env
129
+ /// var) so the bypass is intentional and not accidentally
130
+ /// inherited from a parent's environment. Used only by CI
131
+ /// runners that create the group in-job and cannot
132
+ /// logout/login mid-run.
133
+ pub skip_group_check: bool,
134
+ /// Target executable.
135
+ pub target_exe: &'a Path,
136
+ /// Target argv (everything after the target).
137
+ pub target_args: &'a [String],
138
+ }
139
+
140
+ /// Run the target under the sandbox and return its exit code.
141
+ pub fn run(spec: &ExecSpec<'_>) -> Result<u32> {
142
+ // 1) Pre-flight: the group must be enabled in the broker's
143
+ // token. `Absent` means the user hasn't logged out + back
144
+ // in since `group create`. `DenyOnly` means we're already
145
+ // inside a sandbox child — refuse.
146
+ match sid::group_state_for_self(spec.group_sid)? {
147
+ GroupState::Enabled => {}
148
+ GroupState::Absent if spec.skip_group_check => {
149
+ eprintln!(
150
+ "srt-win: WARNING: --skip-group-check is set and \
151
+ group {} is absent from the broker's TokenGroups. \
152
+ The WFP fence may not be in effect for this \
153
+ process tree. This bypass is intended ONLY for \
154
+ ephemeral CI runners.",
155
+ spec.group_sid
156
+ );
157
+ }
158
+ GroupState::Absent => {
159
+ return Err(anyhow!(
160
+ "group {} is not present in the broker's \
161
+ TokenGroups. Log out and back in to refresh group \
162
+ membership, then retry. (Run `srt-win group status` \
163
+ to confirm.) Pass --skip-group-check to bypass in CI.",
164
+ spec.group_sid
165
+ ));
166
+ }
167
+ GroupState::DenyOnly => {
168
+ return Err(anyhow!(
169
+ "group {} is deny-only in this token — the broker \
170
+ itself is running inside a sandbox child. Refusing \
171
+ to launch.",
172
+ spec.group_sid
173
+ ));
174
+ }
175
+ GroupState::Present => {
176
+ return Err(anyhow!(
177
+ "group {} is present but neither enabled nor \
178
+ deny-only (unexpected token attribute state).",
179
+ spec.group_sid
180
+ ));
181
+ }
182
+ }
183
+
184
+ // 2) Self-protect: rewrite the broker process DACL so the
185
+ // child can't `OpenProcess` us. Best-effort — log on
186
+ // failure but don't abort, since a broker without
187
+ // self-protect is still strictly safer than no sandbox.
188
+ if let Err(e) = self_protect::install_broker_dacl(spec.group_sid) {
189
+ eprintln!("srt-win: WARNING: install_broker_dacl: {e:#}");
190
+ }
191
+
192
+ // 3) Restricted token. Each handle is RAII-owned so any `?`
193
+ // below closes whatever was already opened.
194
+ let self_tok = OwnedHandle(open_self_token()?);
195
+ let restricted = OwnedHandle(
196
+ token::make_sandbox_token(self_tok.raw(), spec.group_sid)
197
+ .context("make_sandbox_token")?,
198
+ );
199
+ let primary = OwnedHandle(
200
+ to_primary(restricted.raw()).context("to_primary")?,
201
+ );
202
+
203
+ // 4) Job.
204
+ let job = Job::new().context("Job::new")?;
205
+
206
+ // 5) Window station + desktop.
207
+ let mut winsta = WinStaDesk::new().context("WinStaDesk::new")?;
208
+
209
+ // 6) Env block — verbatim passthrough of the broker's own
210
+ // environment (the TS caller populates it with the full proxy
211
+ // set; see `build_env_block`).
212
+ let mut env = build_env_block();
213
+
214
+ // 7) Command line + application name.
215
+ let cmdline = build_cmdline(spec.target_exe, spec.target_args);
216
+ let mut cmdline_w = wstr(&cmdline);
217
+ let app_w = wstr(&spec.target_exe.display().to_string());
218
+
219
+ // 8) PROC_THREAD_ATTRIBUTE_LIST: mitigation policy + explicit
220
+ // handle whitelist.
221
+ let mitigation: u64 = MITIGATION_EXTENSION_POINT_DISABLE
222
+ | MITIGATION_FONT_DISABLE
223
+ | MITIGATION_IMAGE_LOAD_NO_REMOTE
224
+ | MITIGATION_IMAGE_LOAD_NO_LOW_LABEL;
225
+ let mut handle_list = collect_inheritable_std_handles();
226
+ if handle_list.is_empty() {
227
+ return Err(anyhow!(
228
+ "no std handle is inheritable; refusing to spawn. \
229
+ srt-win exec requires the broker have at least one \
230
+ console-attached stdio stream."
231
+ ));
232
+ }
233
+ let mut attrs = ProcThreadAttrs::new(2)?;
234
+ attrs.set_mitigation_policy(&mitigation)?;
235
+ attrs.set_handle_list(&mut handle_list)?;
236
+
237
+ // 9) STARTUPINFOEXW.
238
+ let mut six: STARTUPINFOEXW = unsafe { zeroed() };
239
+ six.StartupInfo.cb = size_of::<STARTUPINFOEXW>() as u32;
240
+ six.lpAttributeList = attrs.list();
241
+ six.StartupInfo.lpDesktop = PWSTR(winsta.desktop_name_ptr());
242
+
243
+ // 10) Spawn suspended.
244
+ let mut pi: PROCESS_INFORMATION = unsafe { zeroed() };
245
+ unsafe {
246
+ CreateProcessAsUserW(
247
+ Some(primary.raw()),
248
+ pcwstr(&app_w),
249
+ Some(PWSTR(cmdline_w.as_mut_ptr())),
250
+ None,
251
+ None,
252
+ // Must be TRUE for `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`
253
+ // to take effect (documented Vista-era quirk: with
254
+ // FALSE the kernel ignores the attribute entirely).
255
+ true,
256
+ CREATE_SUSPENDED
257
+ | CREATE_UNICODE_ENVIRONMENT
258
+ | EXTENDED_STARTUPINFO_PRESENT,
259
+ Some(env.as_mut_ptr() as *const c_void),
260
+ // Inherit cwd.
261
+ PCWSTR::null(),
262
+ // STARTUPINFOEXW is layout-compatible (StartupInfo is
263
+ // first member); EXTENDED_STARTUPINFO_PRESENT tells the
264
+ // kernel to read past it for lpAttributeList.
265
+ &six.StartupInfo as *const STARTUPINFOW,
266
+ &mut pi,
267
+ )
268
+ .with_context(|| {
269
+ format!(
270
+ "CreateProcessAsUserW({})",
271
+ spec.target_exe.display()
272
+ )
273
+ })?;
274
+ }
275
+
276
+ // The child exists, suspended, NOT yet in the job. Wrap it
277
+ // in a guard so any `?` from here to `defuse()` terminates
278
+ // it — `KILL_ON_JOB_CLOSE` can't help until after `assign`.
279
+ let mut child = SpawnedChild::new(pi);
280
+
281
+ // 11) Assign to job → resume. ResumeThread returns the
282
+ // previous suspend count, or u32::MAX on failure — a
283
+ // failure here would leave the child suspended in the
284
+ // job and `WaitForSingleObject(INFINITE)` below would
285
+ // hang the broker forever. Check before defusing the
286
+ // terminate-on-drop guard.
287
+ job.assign(child.process())?;
288
+ let prev_suspend = unsafe { ResumeThread(child.thread()) };
289
+ if prev_suspend == u32::MAX {
290
+ return Err(anyhow!(
291
+ "ResumeThread: {}",
292
+ std::io::Error::last_os_error()
293
+ ));
294
+ }
295
+ // From here the job owns lifetime; disarm terminate-on-drop.
296
+ child.defuse();
297
+
298
+ // 12) Wait + collect exit code.
299
+ let rc = unsafe { WaitForSingleObject(child.process(), INFINITE) };
300
+ if rc != WAIT_OBJECT_0 {
301
+ eprintln!("srt-win: WaitForSingleObject returned 0x{:x}", rc.0);
302
+ }
303
+ let mut code: u32 = 0;
304
+ unsafe {
305
+ GetExitCodeProcess(child.process(), &mut code)
306
+ .context("GetExitCodeProcess")?;
307
+ }
308
+ // `child` (closes hProcess/hThread), `primary`/`restricted`/
309
+ // `self_tok` (CloseHandle) all drop here.
310
+ // Keep `attrs` (its backing buffer + the borrowed `mitigation`
311
+ // and `handle_list`), `winsta`, and `job` alive until here.
312
+ // The kernel snapshots the attribute list at CreateProcess
313
+ // time, but DeleteProcThreadAttributeList (in attrs.drop) may
314
+ // re-read pointers; and the WS+desktop must outlive the
315
+ // child's attach during process creation.
316
+ drop(attrs);
317
+ drop(handle_list);
318
+ drop(winsta);
319
+ drop(job);
320
+ Ok(code)
321
+ }
322
+
323
+ // ─── Environment block ──────────────────────────────────────────────
324
+
325
+ /// Build a `CREATE_UNICODE_ENVIRONMENT` block from the broker's own
326
+ /// environment, **verbatim**.
327
+ ///
328
+ /// `srt-win exec` is a dumb passthrough for proxy configuration: it
329
+ /// does NOT synthesize `HTTP_PROXY` / `ALL_PROXY` / `NO_PROXY` and has
330
+ /// no `--http-proxy` / `--socks-proxy` flags. The single source of
331
+ /// proxy env is the TS `generateProxyEnvVars`, which the caller merges
332
+ /// into the environment it spawns `srt-win exec` with; this function
333
+ /// just forwards that environment to the child. No proxy value is
334
+ /// invented, no inherited var is stripped or blanked.
335
+ ///
336
+ /// Entries are sorted case-insensitively by name for block ordering —
337
+ /// not the strict case-insensitive Unicode collation the
338
+ /// `CreateProcess` docs describe, but in practice the loader and
339
+ /// `GetEnvironmentVariableW` don't enforce ordering; `cmd /c set` and
340
+ /// every consumer we've tested work regardless. Names are NOT folded
341
+ /// or deduplicated, so if both `HTTP_PROXY` and `http_proxy` are
342
+ /// present both survive into the child.
343
+ ///
344
+ /// The only adjustment on top of the verbatim copy is restoring the
345
+ /// missing-case variants of `*_PROXY` variables (see
346
+ /// [`add_proxy_case_twins`]) — casing repair of caller-provided
347
+ /// values, not proxy synthesis. Nothing else is added: consumers that
348
+ /// need the broker's identity (e.g. the self-protect probe) discover
349
+ /// it by walking the parent-process chain rather than via an
350
+ /// environment variable.
351
+ fn build_env_block() -> Vec<u16> {
352
+ let mut entries: Vec<(String, String)> = std::env::vars().collect();
353
+
354
+ add_proxy_case_twins(&mut entries);
355
+
356
+ // Order the block case-insensitively by name; values pass through
357
+ // verbatim. No dedup — case-variant duplicates are preserved.
358
+ entries.sort_by_cached_key(|(k, _)| k.to_ascii_uppercase());
359
+
360
+ // Encode: `KEY=VALUE\0`… `\0`.
361
+ let mut out: Vec<u16> = Vec::new();
362
+ for (k, v) in entries {
363
+ out.extend(k.encode_utf16());
364
+ out.push(b'=' as u16);
365
+ out.extend(v.encode_utf16());
366
+ out.push(0);
367
+ }
368
+ out.push(0);
369
+ out
370
+ }
371
+
372
+ /// Re-add the missing-case variants of `*_PROXY` variables (the host
373
+ /// spawn layer collapses case-duplicate keys) so that Cygwin/MSYS2
374
+ /// programs — which see a case-sensitive environment — still find
375
+ /// them. For every entry whose name ends with `_PROXY`
376
+ /// (case-insensitively), the all-uppercase and all-lowercase forms of
377
+ /// that name are appended where missing, with the same value. Existing
378
+ /// keys are never overwritten and nothing is added for names that are
379
+ /// not `*_PROXY`.
380
+ fn add_proxy_case_twins(entries: &mut Vec<(String, String)>) {
381
+ let mut names: std::collections::HashSet<String> =
382
+ entries.iter().map(|(k, _)| k.clone()).collect();
383
+ let mut twins: Vec<(String, String)> = Vec::new();
384
+ for (k, v) in entries.iter() {
385
+ if !k.to_ascii_uppercase().ends_with("_PROXY") {
386
+ continue;
387
+ }
388
+ for form in [k.to_ascii_uppercase(), k.to_ascii_lowercase()] {
389
+ if !names.contains(&form) {
390
+ names.insert(form.clone());
391
+ twins.push((form, v.clone()));
392
+ }
393
+ }
394
+ }
395
+ entries.extend(twins);
396
+ }
397
+
398
+ // ─── Command-line quoting ───────────────────────────────────────────
399
+
400
+ /// MSVCRT / `CommandLineToArgvW` quoting for one argument.
401
+ /// Public so `main.rs`'s self-elevate path can rebuild
402
+ /// `lpParameters` from `std::env::args()`.
403
+ pub fn quote_arg(a: &str) -> String {
404
+ if !a.is_empty()
405
+ && !a
406
+ .chars()
407
+ .any(|c| matches!(c, ' ' | '\t' | '"' | '\\'))
408
+ {
409
+ return a.to_string();
410
+ }
411
+ let mut out = String::with_capacity(a.len() + 2);
412
+ out.push('"');
413
+ let mut backslashes = 0usize;
414
+ for c in a.chars() {
415
+ match c {
416
+ '\\' => {
417
+ backslashes += 1;
418
+ out.push('\\');
419
+ }
420
+ '"' => {
421
+ // Double the run of backslashes, then escape the
422
+ // quote.
423
+ for _ in 0..backslashes {
424
+ out.push('\\');
425
+ }
426
+ out.push('\\');
427
+ out.push('"');
428
+ backslashes = 0;
429
+ }
430
+ _ => {
431
+ backslashes = 0;
432
+ out.push(c);
433
+ }
434
+ }
435
+ }
436
+ // Trailing backslash run before the closing quote must double.
437
+ for _ in 0..backslashes {
438
+ out.push('\\');
439
+ }
440
+ out.push('"');
441
+ out
442
+ }
443
+
444
+ fn target_is_cmd(exe: &Path) -> bool {
445
+ exe.file_name()
446
+ .and_then(|n| n.to_str())
447
+ .map(|s| {
448
+ s.eq_ignore_ascii_case("cmd.exe") || s.eq_ignore_ascii_case("cmd")
449
+ })
450
+ .unwrap_or(false)
451
+ }
452
+
453
+ /// Build the full command line.
454
+ ///
455
+ /// **Non-cmd targets:** every arg is MSVCRT-quoted via
456
+ /// [`quote_arg`] so `CommandLineToArgvW` in the child recovers
457
+ /// the exact argv.
458
+ ///
459
+ /// **`cmd.exe` targets:** cmd does NOT use `CommandLineToArgvW`;
460
+ /// it parses `lpCommandLine` itself. With `/s`, it strips the
461
+ /// first and last `"` of the post-`/c` portion and runs what's
462
+ /// between *verbatim* under cmd's own rules. The caller is
463
+ /// expected to include `/s`; without it cmd falls back to the
464
+ /// legacy "if exactly two quotes and they wrap a runnable
465
+ /// command, strip them; otherwise leave alone" heuristic, and
466
+ /// the wrapper quote may not strip cleanly. (Batch 03's
467
+ /// `wrapWithSandboxArgv` always passes `/d /s /c`.) So we:
468
+ /// 1. Emit the exe + flags up to and including `/c|/k|/r`
469
+ /// using `quote_arg` (these are simple tokens; quoting is
470
+ /// a no-op unless the exe path has spaces).
471
+ /// 2. Join the remaining argv elements with single spaces —
472
+ /// this is the user's cmd command string, reconstructed.
473
+ /// 3. Wrap that in ONE outer `"…"` pair for `/s` to strip.
474
+ ///
475
+ /// The post-`/c` content is **passed through unmodified**. We
476
+ /// do NOT caret-escape `& | < > ^ ( )` and do NOT touch `"` —
477
+ /// the contract is "this is a cmd.exe command string" and the
478
+ /// caller (batch-03's `wrapWithSandboxArgv`) supplies it as
479
+ /// such. `&` chains commands, `"…"` quotes — exactly as the
480
+ /// user typed. The child IS the sandbox, so cmd metachars here
481
+ /// are the user's tool, not an escape vector. (The Phase-6 N1
482
+ /// host-shell injection concern was about the OUTER spawn,
483
+ /// which is solved by argv-mode in batch 03; this is the inner
484
+ /// sandboxed cmd.)
485
+ ///
486
+ /// An earlier revision per-arg-doubled `"` → `""`, which cmd
487
+ /// treats as a quote-state *toggle*, not a literal — that
488
+ /// mis-parsed payloads containing `&` and was reverted.
489
+ pub fn build_cmdline(exe: &Path, args: &[String]) -> String {
490
+ let cmd_split = if target_is_cmd(exe) {
491
+ args.iter().position(|a| {
492
+ matches!(a.to_ascii_lowercase().as_str(), "/c" | "/k" | "/r")
493
+ })
494
+ } else {
495
+ None
496
+ };
497
+ let mut s = quote_arg(&exe.display().to_string());
498
+ match cmd_split {
499
+ Some(p) => {
500
+ for a in &args[..=p] {
501
+ s.push(' ');
502
+ s.push_str(&quote_arg(a));
503
+ }
504
+ // One outer pair of quotes around the whole post-/c
505
+ // command for `/s` to strip; contents verbatim.
506
+ s.push_str(" \"");
507
+ s.push_str(&args[p + 1..].join(" "));
508
+ s.push('"');
509
+ }
510
+ None => {
511
+ for a in args {
512
+ s.push(' ');
513
+ s.push_str(&quote_arg(a));
514
+ }
515
+ }
516
+ }
517
+ s
518
+ }
519
+
520
+ // ─── PROC_THREAD_ATTRIBUTE_LIST helper ──────────────────────────────
521
+
522
+ /// RAII wrapper over an opaque `LPPROC_THREAD_ATTRIBUTE_LIST`.
523
+ /// `Drop` calls `DeleteProcThreadAttributeList`. The values passed
524
+ /// to [`set_*`] must outlive `self` — the kernel reads them by
525
+ /// pointer at `CreateProcess` time.
526
+ struct ProcThreadAttrs {
527
+ storage: Vec<u8>,
528
+ }
529
+
530
+ impl ProcThreadAttrs {
531
+ fn new(count: u32) -> Result<Self> {
532
+ let mut size = 0usize;
533
+ // Sizing call — expected to fail with
534
+ // ERROR_INSUFFICIENT_BUFFER and write the required size.
535
+ unsafe {
536
+ let _ = InitializeProcThreadAttributeList(
537
+ None, count, None, &mut size,
538
+ );
539
+ }
540
+ if size == 0 {
541
+ return Err(anyhow!(
542
+ "InitializeProcThreadAttributeList sizing returned 0"
543
+ ));
544
+ }
545
+ let mut storage = vec![0u8; size];
546
+ unsafe {
547
+ InitializeProcThreadAttributeList(
548
+ Some(LPPROC_THREAD_ATTRIBUTE_LIST(
549
+ storage.as_mut_ptr() as *mut c_void,
550
+ )),
551
+ count,
552
+ None,
553
+ &mut size,
554
+ )
555
+ .context("InitializeProcThreadAttributeList")?;
556
+ }
557
+ Ok(Self { storage })
558
+ }
559
+
560
+ fn list(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
561
+ LPPROC_THREAD_ATTRIBUTE_LIST(self.storage.as_mut_ptr() as *mut c_void)
562
+ }
563
+
564
+ fn set_mitigation_policy(&mut self, policy: &u64) -> Result<()> {
565
+ unsafe {
566
+ UpdateProcThreadAttribute(
567
+ self.list(),
568
+ 0,
569
+ PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY as usize,
570
+ Some(policy as *const u64 as *const c_void),
571
+ size_of::<u64>(),
572
+ None,
573
+ None,
574
+ )
575
+ .context("UpdateProcThreadAttribute(MITIGATION_POLICY)")
576
+ }
577
+ }
578
+
579
+ /// `UpdateProcThreadAttribute(HANDLE_LIST)` requires at least
580
+ /// one entry — Windows rejects an empty list with
581
+ /// `ERROR_BAD_LENGTH`. The caller is expected to have filtered
582
+ /// already.
583
+ fn set_handle_list(&mut self, handles: &mut [HANDLE]) -> Result<()> {
584
+ debug_assert!(!handles.is_empty());
585
+ unsafe {
586
+ UpdateProcThreadAttribute(
587
+ self.list(),
588
+ 0,
589
+ PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
590
+ Some(handles.as_ptr() as *const c_void),
591
+ std::mem::size_of_val(handles),
592
+ None,
593
+ None,
594
+ )
595
+ .context("UpdateProcThreadAttribute(HANDLE_LIST)")
596
+ }
597
+ }
598
+ }
599
+
600
+ impl Drop for ProcThreadAttrs {
601
+ fn drop(&mut self) {
602
+ unsafe {
603
+ DeleteProcThreadAttributeList(self.list());
604
+ }
605
+ }
606
+ }
607
+
608
+ /// Mark this process's std handles inheritable and return the ones
609
+ /// that succeeded. We need at least one entry to satisfy the
610
+ /// kernel's `HANDLE_LIST` length check; the std handles are the
611
+ /// natural minimal set since the child attaches to the same
612
+ /// console anyway.
613
+ fn collect_inheritable_std_handles() -> Vec<HANDLE> {
614
+ let mut out = Vec::with_capacity(3);
615
+ for which in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
616
+ let h = match unsafe { GetStdHandle(which) } {
617
+ Ok(h) => h,
618
+ Err(_) => continue,
619
+ };
620
+ if h.0.is_null() || (h.0 as isize) == -1 {
621
+ continue;
622
+ }
623
+ // Best-effort: a detached broker may have non-inheritable
624
+ // (or pseudo) handles here; skip rather than fail.
625
+ let r = unsafe {
626
+ SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAG_INHERIT)
627
+ };
628
+ if r.is_ok() {
629
+ out.push(h);
630
+ }
631
+ }
632
+ out
633
+ }
634
+
635
+ #[cfg(test)]
636
+ mod tests {
637
+ use super::*;
638
+
639
+ #[test]
640
+ fn quote_arg_simple() {
641
+ assert_eq!(quote_arg("foo"), "foo");
642
+ assert_eq!(quote_arg(""), "\"\"");
643
+ assert_eq!(quote_arg("a b"), "\"a b\"");
644
+ }
645
+
646
+ #[test]
647
+ fn quote_arg_backslash_quote() {
648
+ // a\"b → "a\\\"b"
649
+ assert_eq!(quote_arg(r#"a\"b"#), r#""a\\\"b""#);
650
+ // trailing backslashes double before closing quote
651
+ assert_eq!(quote_arg(r"a\"), r#""a\\""#);
652
+ assert_eq!(quote_arg(r"a\\"), r#""a\\\\""#);
653
+ }
654
+
655
+ #[test]
656
+ fn build_cmdline_cmd_passthrough() {
657
+ let exe = Path::new(r"C:\Windows\System32\cmd.exe");
658
+ // post-/c content is wrapped once in "…" for /s to strip;
659
+ // inner quotes and metachars are NOT touched.
660
+ let line = build_cmdline(
661
+ exe,
662
+ &["/d".into(), "/s".into(), "/c".into(),
663
+ r#"echo "x & y""#.into()],
664
+ );
665
+ assert_eq!(
666
+ line,
667
+ r#""C:\Windows\System32\cmd.exe" /d /s /c "echo "x & y"""#
668
+ );
669
+ // Multiple post-/c argv elements are joined with a space.
670
+ let line2 = build_cmdline(
671
+ exe,
672
+ &["/c".into(), "echo".into(), "a".into(), "&".into(),
673
+ "echo".into(), "b".into()],
674
+ );
675
+ assert_eq!(
676
+ line2,
677
+ r#""C:\Windows\System32\cmd.exe" /c "echo a & echo b""#
678
+ );
679
+ }
680
+
681
+ #[test]
682
+ fn build_cmdline_cmd_no_split_when_no_c_flag() {
683
+ // cmd.exe without /c|/k|/r → MSVCRT quoting throughout.
684
+ let exe = Path::new("cmd.exe");
685
+ let line = build_cmdline(exe, &["/?".into()]);
686
+ assert_eq!(line, r#"cmd.exe /?"#);
687
+ }
688
+
689
+ #[test]
690
+ fn build_cmdline_non_cmd_uses_msvcrt_quoting() {
691
+ let exe = Path::new(r"C:\foo\bar.exe");
692
+ let args = vec![r#"a "b"#.into()];
693
+ let line = build_cmdline(exe, &args);
694
+ assert!(line.ends_with(r#""a \"b""#), "got: {line}");
695
+ }
696
+
697
+ #[test]
698
+ fn proxy_case_twins_suffix_rule_covers_any_proxy_var() {
699
+ let mut entries = vec![
700
+ // Any *_PROXY name is twinned, not just the standard trio.
701
+ (
702
+ "GRPC_PROXY".to_string(),
703
+ "socks5h://localhost:60081".to_string(),
704
+ ),
705
+ // Mixed-case input → BOTH canonical forms appended.
706
+ ("Http_Proxy".to_string(), "http://localhost:60080".to_string()),
707
+ // Names that merely contain or extend the suffix are not.
708
+ ("FOO_PROXYX".to_string(), "x".to_string()),
709
+ ("PATH".to_string(), r"C:\Windows".to_string()),
710
+ ];
711
+ add_proxy_case_twins(&mut entries);
712
+ let matching = |name: &str| {
713
+ entries
714
+ .iter()
715
+ .filter(|(k, _)| k == name)
716
+ .collect::<Vec<_>>()
717
+ };
718
+ assert_eq!(matching("grpc_proxy").len(), 1);
719
+ assert_eq!(matching("grpc_proxy")[0].1, "socks5h://localhost:60081");
720
+ assert_eq!(matching("GRPC_PROXY").len(), 1);
721
+ // Mixed-case original is preserved AND both canonical forms added.
722
+ assert_eq!(matching("Http_Proxy").len(), 1);
723
+ assert_eq!(matching("HTTP_PROXY").len(), 1);
724
+ assert_eq!(matching("http_proxy").len(), 1);
725
+ assert_eq!(matching("http_proxy")[0].1, "http://localhost:60080");
726
+ // Non-matching names untouched, nothing appended for them.
727
+ assert_eq!(matching("FOO_PROXYX").len(), 1);
728
+ assert!(matching("foo_proxyx").is_empty());
729
+ assert_eq!(matching("PATH").len(), 1);
730
+ assert!(matching("path").is_empty());
731
+ }
732
+ }