@sysid/sandbox-runtime-improved 0.0.52-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 (67) 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/linux-sandbox-utils.d.ts.map +1 -1
  9. package/dist/sandbox/linux-sandbox-utils.js +17 -11
  10. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  11. package/dist/sandbox/macos-sandbox-utils.d.ts +1 -0
  12. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  13. package/dist/sandbox/macos-sandbox-utils.js +16 -2
  14. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  15. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  16. package/dist/sandbox/mitm-leaf.js +18 -5
  17. package/dist/sandbox/mitm-leaf.js.map +1 -1
  18. package/dist/sandbox/sandbox-config.d.ts +53 -0
  19. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  20. package/dist/sandbox/sandbox-config.js +46 -0
  21. package/dist/sandbox/sandbox-config.js.map +1 -1
  22. package/dist/sandbox/sandbox-manager.d.ts +4 -0
  23. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  24. package/dist/sandbox/sandbox-manager.js +172 -40
  25. package/dist/sandbox/sandbox-manager.js.map +1 -1
  26. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  27. package/dist/sandbox/sandbox-utils.js +12 -3
  28. package/dist/sandbox/sandbox-utils.js.map +1 -1
  29. package/dist/sandbox/tls-terminate-proxy.js +18 -4
  30. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  31. package/dist/sandbox/windows-sandbox-utils.d.ts +222 -0
  32. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -0
  33. package/dist/sandbox/windows-sandbox-utils.js +433 -0
  34. package/dist/sandbox/windows-sandbox-utils.js.map +1 -0
  35. package/dist/vendor/srt-win/Cargo.lock +361 -0
  36. package/dist/vendor/srt-win/Cargo.toml +46 -0
  37. package/dist/vendor/srt-win/ci/cleanup.ps1 +49 -0
  38. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +446 -0
  39. package/dist/vendor/srt-win/ci/smoke.ps1 +307 -0
  40. package/dist/vendor/srt-win/src/job.rs +102 -0
  41. package/dist/vendor/srt-win/src/launch.rs +732 -0
  42. package/dist/vendor/srt-win/src/lib.rs +20 -0
  43. package/dist/vendor/srt-win/src/main.rs +669 -0
  44. package/dist/vendor/srt-win/src/self_protect.rs +169 -0
  45. package/dist/vendor/srt-win/src/sid.rs +296 -0
  46. package/dist/vendor/srt-win/src/token.rs +341 -0
  47. package/dist/vendor/srt-win/src/util.rs +42 -0
  48. package/dist/vendor/srt-win/src/wfp.rs +992 -0
  49. package/dist/vendor/srt-win/src/winsta.rs +209 -0
  50. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +259 -0
  51. package/package.json +2 -2
  52. package/vendor/srt-win/Cargo.lock +361 -0
  53. package/vendor/srt-win/Cargo.toml +46 -0
  54. package/vendor/srt-win/ci/cleanup.ps1 +49 -0
  55. package/vendor/srt-win/ci/smoke-exec.ps1 +446 -0
  56. package/vendor/srt-win/ci/smoke.ps1 +307 -0
  57. package/vendor/srt-win/src/job.rs +102 -0
  58. package/vendor/srt-win/src/launch.rs +732 -0
  59. package/vendor/srt-win/src/lib.rs +20 -0
  60. package/vendor/srt-win/src/main.rs +669 -0
  61. package/vendor/srt-win/src/self_protect.rs +169 -0
  62. package/vendor/srt-win/src/sid.rs +296 -0
  63. package/vendor/srt-win/src/token.rs +341 -0
  64. package/vendor/srt-win/src/util.rs +42 -0
  65. package/vendor/srt-win/src/wfp.rs +992 -0
  66. package/vendor/srt-win/src/winsta.rs +209 -0
  67. package/vendor/srt-win/tests/sd_access_check_matrix.rs +259 -0
@@ -0,0 +1,209 @@
1
+ //! Non-interactive window station + desktop for the sandbox child.
2
+ //!
3
+ //! Creates a per-broker anonymous window station with a single
4
+ //! desktop attached, and exposes the `<wsname>\desk` path that
5
+ //! `STARTUPINFOW.lpDesktop` consumes. The sandbox child spawns onto
6
+ //! this WS+desktop and so cannot enumerate or message top-level
7
+ //! windows on the user's interactive `WinSta0`.
8
+ //!
9
+ //! The kernel reference-counts a window station by attached
10
+ //! processes. The broker keeps both handles open from creation
11
+ //! until after the child exits — dropping `WinStaDesk` then
12
+ //! releases the kernel objects.
13
+ //!
14
+ //! We do NOT permanently re-home the broker via
15
+ //! `SetProcessWindowStation`; we briefly attach to the new WS to
16
+ //! create the desktop on it, then restore.
17
+
18
+ use anyhow::{anyhow, Context, Result};
19
+ use std::ffi::c_void;
20
+ use windows::core::PCWSTR;
21
+ use windows::Win32::Foundation::HANDLE;
22
+ use windows::Win32::System::StationsAndDesktops::{
23
+ CloseDesktop, CloseWindowStation, CreateDesktopW, CreateWindowStationW,
24
+ GetProcessWindowStation, GetUserObjectInformationW,
25
+ SetProcessWindowStation, DESKTOP_CONTROL_FLAGS, HDESK, HWINSTA, UOI_NAME,
26
+ };
27
+
28
+ use crate::util::wstr;
29
+
30
+ // winuser.h: WINSTA_ALL_ACCESS = 0x37F; DESKTOP_ALL_ACCESS = 0x1FF.
31
+ // OR with STANDARD_RIGHTS_REQUIRED so the broker holds full control
32
+ // on the objects it just created.
33
+ const STANDARD_RIGHTS_REQUIRED: u32 = 0x000F_0000;
34
+ const WS_ALL_ACCESS: u32 = STANDARD_RIGHTS_REQUIRED | 0x0000_037F;
35
+ const DESK_ALL_ACCESS: u32 = STANDARD_RIGHTS_REQUIRED | 0x0000_01FF;
36
+
37
+ /// RAII holder for the sandbox window station + its desktop, plus
38
+ /// the wide `<wsname>\desk` buffer that backs
39
+ /// `STARTUPINFOW.lpDesktop`.
40
+ pub struct WinStaDesk {
41
+ winsta: HWINSTA,
42
+ desktop: HDESK,
43
+ /// `STARTUPINFOW.lpDesktop` is `PWSTR` (mutable wide pointer per
44
+ /// the API contract), so we keep the buffer here and hand out a
45
+ /// raw pointer via [`desktop_name_ptr`]. Null-terminated.
46
+ desk_path: Vec<u16>,
47
+ }
48
+
49
+ impl WinStaDesk {
50
+ /// Create an anonymous WS with a single `desk` desktop.
51
+ ///
52
+ /// A NULL name lets the kernel mint a unique anonymous name —
53
+ /// passing an explicit name into the WS namespace
54
+ /// (`\Sessions\<n>\Windows\WindowStations`) requires admin on
55
+ /// Vista+, which a non-elevated broker doesn't have. The
56
+ /// kernel-generated name is recovered via
57
+ /// `GetUserObjectInformationW(UOI_NAME)`.
58
+ pub fn new() -> Result<Self> {
59
+ // 1) Anonymous WS, default DACL.
60
+ let winsta = unsafe {
61
+ CreateWindowStationW(PCWSTR::null(), 0, WS_ALL_ACCESS, None)
62
+ .context("CreateWindowStationW(NULL)")?
63
+ };
64
+ let ws_name = match object_name(HANDLE(winsta.0)) {
65
+ Ok(n) => n,
66
+ Err(e) => {
67
+ unsafe {
68
+ let _ = CloseWindowStation(winsta);
69
+ }
70
+ return Err(e.context("UOI_NAME on new window station"));
71
+ }
72
+ };
73
+
74
+ // 2) Attach to it just long enough to create the desktop.
75
+ // `CreateDesktopW` targets the *calling process's* current
76
+ // WS; we must point at the new one, create, then restore
77
+ // — even on the error path so the broker isn't left
78
+ // re-homed.
79
+ let prev = match unsafe { GetProcessWindowStation() } {
80
+ Ok(p) => p,
81
+ Err(e) => {
82
+ unsafe {
83
+ let _ = CloseWindowStation(winsta);
84
+ }
85
+ return Err(anyhow!(
86
+ "GetProcessWindowStation (snapshot): {e}"
87
+ ));
88
+ }
89
+ };
90
+ if let Err(e) = unsafe { SetProcessWindowStation(winsta) } {
91
+ unsafe {
92
+ let _ = CloseWindowStation(winsta);
93
+ }
94
+ return Err(anyhow!(
95
+ "SetProcessWindowStation({ws_name}): {e}"
96
+ ));
97
+ }
98
+
99
+ let desk_name_w = wstr("desk");
100
+ let desktop_result = unsafe {
101
+ CreateDesktopW(
102
+ PCWSTR(desk_name_w.as_ptr()),
103
+ PCWSTR::null(),
104
+ None,
105
+ DESKTOP_CONTROL_FLAGS(0),
106
+ DESK_ALL_ACCESS,
107
+ None,
108
+ )
109
+ };
110
+
111
+ // Always restore the broker's WS — a broker stuck on the
112
+ // sandbox WS is a fatal state. If both CreateDesktopW and
113
+ // restore failed, report both.
114
+ let restore = unsafe { SetProcessWindowStation(prev) };
115
+
116
+ let desktop = match (desktop_result, &restore) {
117
+ (Ok(d), Ok(())) => d,
118
+ (Ok(d), Err(re)) => {
119
+ // Desktop created but we can't restore the broker.
120
+ // Close what we made; the restore failure is the
121
+ // error that matters.
122
+ unsafe {
123
+ let _ = CloseDesktop(d);
124
+ let _ = CloseWindowStation(winsta);
125
+ }
126
+ return Err(anyhow!(
127
+ "SetProcessWindowStation(restore previous): {re} \
128
+ (after successful CreateDesktopW on {ws_name})"
129
+ ));
130
+ }
131
+ (Err(de), restore_r) => {
132
+ unsafe {
133
+ let _ = CloseWindowStation(winsta);
134
+ }
135
+ return Err(match restore_r {
136
+ Ok(()) => anyhow!(
137
+ "CreateDesktopW(desk) on {ws_name}: {de}"
138
+ ),
139
+ Err(re) => anyhow!(
140
+ "CreateDesktopW(desk) on {ws_name}: {de}; AND \
141
+ SetProcessWindowStation(restore) also \
142
+ failed: {re}"
143
+ ),
144
+ });
145
+ }
146
+ };
147
+
148
+ // `<wsname>\desk` — backslash separator.
149
+ let desk_path = wstr(&format!("{ws_name}\\desk"));
150
+
151
+ Ok(Self { winsta, desktop, desk_path })
152
+ }
153
+
154
+ /// Pointer to the wide name buffer for `STARTUPINFOW.lpDesktop`.
155
+ /// Caller must keep `self` alive until after
156
+ /// `CreateProcessAsUserW` returns.
157
+ pub fn desktop_name_ptr(&mut self) -> *mut u16 {
158
+ self.desk_path.as_mut_ptr()
159
+ }
160
+ }
161
+
162
+ impl Drop for WinStaDesk {
163
+ fn drop(&mut self) {
164
+ unsafe {
165
+ // Desktop first (it references the WS), then WS.
166
+ let _ = CloseDesktop(self.desktop);
167
+ let _ = CloseWindowStation(self.winsta);
168
+ }
169
+ }
170
+ }
171
+
172
+ /// Read a user-object's `UOI_NAME` (returned as a wide
173
+ /// NUL-terminated string).
174
+ fn object_name(h: HANDLE) -> Result<String> {
175
+ let mut needed = 0u32;
176
+ // Sizing call — expected to fail with ERROR_INSUFFICIENT_BUFFER
177
+ // and write the required byte count.
178
+ unsafe {
179
+ let _ = GetUserObjectInformationW(
180
+ h, UOI_NAME, None, 0, Some(&mut needed),
181
+ );
182
+ }
183
+ if needed == 0 {
184
+ return Err(anyhow!(
185
+ "GetUserObjectInformationW sizing returned 0"
186
+ ));
187
+ }
188
+ let mut buf = vec![0u8; needed as usize];
189
+ unsafe {
190
+ GetUserObjectInformationW(
191
+ h,
192
+ UOI_NAME,
193
+ Some(buf.as_mut_ptr() as *mut c_void),
194
+ needed,
195
+ Some(&mut needed),
196
+ )
197
+ .context("GetUserObjectInformationW(UOI_NAME)")?;
198
+ }
199
+ // SAFETY: `buf` is `needed` bytes, even-length (UTF-16);
200
+ // reinterpret as u16.
201
+ let wide = unsafe {
202
+ std::slice::from_raw_parts(
203
+ buf.as_ptr() as *const u16,
204
+ (needed as usize) / 2,
205
+ )
206
+ };
207
+ let end = wide.iter().position(|&c| c == 0).unwrap_or(wide.len());
208
+ Ok(String::from_utf16_lossy(&wide[..end]))
209
+ }
@@ -0,0 +1,259 @@
1
+ //! Proof that the WFP filter security descriptors discriminate the
2
+ //! three token states (group **enabled** / **deny-only** /
3
+ //! **absent**) the way `wfp::install_filters` relies on.
4
+ //!
5
+ //! WFP's `ALE_USER_ID` `FWP_MATCH_EQUAL` condition runs `AccessCheck`
6
+ //! on the connecting token against the filter's SD; the filter
7
+ //! matches iff the check grants. This test calls `AccessCheck`
8
+ //! directly — no live WFP engine — so it pins the SD semantics
9
+ //! independent of the network stack.
10
+ //!
11
+ //! Expected 3×3 matrix (`true` = AccessCheck grants → filter would
12
+ //! match):
13
+ //!
14
+ //! | filter-0 SD | filter-1 SD | filter-3 SD
15
+ //! | (D;;;G)(A;;;WD) | (A;;;G) | (A;;;WD)
16
+ //! -------------+----------------------+-----------------+------------
17
+ //! G enabled | DENY (deny ACE hits) | GRANT | GRANT
18
+ //! G deny-only | DENY (deny ACE hits | DENY (deny-only | GRANT
19
+ //! | — deny-only SIDs | SIDs ignored |
20
+ //! | match DENY ACEs) | by ALLOW ACEs) |
21
+ //! G absent | GRANT (allow WD) | DENY (no grant) | GRANT
22
+ //!
23
+ //! The "G enabled" and "G deny-only" rows use `BUILTIN\Administrators`
24
+ //! (`S-1-5-32-544`) as the test group, since the CI runner's token
25
+ //! reliably carries it enabled; deny-only is produced via
26
+ //! `CreateRestrictedToken(SidsToDisable=[Admins])`. The "G absent"
27
+ //! row uses an unmapped well-known-shape SID (`S-1-5-32-9999`) so
28
+ //! the same process token is genuinely a non-member.
29
+
30
+ #![cfg(windows)]
31
+
32
+ use std::mem::size_of;
33
+ use windows::core::BOOL;
34
+ use windows::Win32::Foundation::{
35
+ CloseHandle, GENERIC_ALL, GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE,
36
+ HANDLE,
37
+ };
38
+ use windows::Win32::Security::{
39
+ AccessCheck, CreateRestrictedToken, DuplicateTokenEx, SecurityImpersonation,
40
+ TokenImpersonation, GENERIC_MAPPING, PRIVILEGE_SET, PSECURITY_DESCRIPTOR,
41
+ SID_AND_ATTRIBUTES, TOKEN_ALL_ACCESS, TOKEN_DUPLICATE, TOKEN_QUERY,
42
+ };
43
+ use windows::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW;
44
+ use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
45
+
46
+ use srt_win::sid::LocalPsid;
47
+ use srt_win::util::wstr;
48
+ use srt_win::wfp::{sddl_group, sddl_nonmember, SDDL_EVERYONE};
49
+
50
+ /// `BUILTIN\Administrators`. Reliably present-and-enabled on the
51
+ /// GitHub-hosted Windows runner's token. If this test runs on a
52
+ /// non-admin host the "enabled" row's filter-1 assertion will fail —
53
+ /// in that case the test bails with a skip message rather than a
54
+ /// false negative.
55
+ const ADMINS_SID: &str = "S-1-5-32-544";
56
+
57
+ /// Well-formed RID under `BUILTIN` that maps to nothing. Stands in
58
+ /// for "a group this token has never heard of".
59
+ const ABSENT_GROUP_SID: &str = "S-1-5-32-9999";
60
+
61
+ /// `CC` in SDDL is `ADS_RIGHT_DS_CREATE_CHILD` = bit 0. The filters
62
+ /// only need a single bit; `AccessCheck` is asked for that bit.
63
+ const DESIRED_ACCESS: u32 = 1;
64
+
65
+ /// Open an impersonation-level duplicate of the current process
66
+ /// token — `AccessCheck` requires an impersonation token.
67
+ fn own_impersonation_token() -> HANDLE {
68
+ unsafe {
69
+ let mut primary = HANDLE::default();
70
+ OpenProcessToken(
71
+ GetCurrentProcess(),
72
+ TOKEN_QUERY | TOKEN_DUPLICATE,
73
+ &mut primary,
74
+ )
75
+ .expect("OpenProcessToken");
76
+ let mut imp = HANDLE::default();
77
+ DuplicateTokenEx(
78
+ primary,
79
+ TOKEN_ALL_ACCESS,
80
+ None,
81
+ SecurityImpersonation,
82
+ TokenImpersonation,
83
+ &mut imp,
84
+ )
85
+ .expect("DuplicateTokenEx");
86
+ let _ = CloseHandle(primary);
87
+ imp
88
+ }
89
+ }
90
+
91
+ /// Build an impersonation token in which `group_sid` is flipped to
92
+ /// `SE_GROUP_USE_FOR_DENY_ONLY` via `CreateRestrictedToken`.
93
+ fn deny_only_token(group_sid: &str) -> HANDLE {
94
+ let psid = LocalPsid::from_string(group_sid).expect("group sid");
95
+ unsafe {
96
+ let mut primary = HANDLE::default();
97
+ OpenProcessToken(
98
+ GetCurrentProcess(),
99
+ TOKEN_ALL_ACCESS,
100
+ &mut primary,
101
+ )
102
+ .expect("OpenProcessToken");
103
+ let disable = [SID_AND_ATTRIBUTES {
104
+ Sid: psid.as_psid(),
105
+ Attributes: 0,
106
+ }];
107
+ let mut restricted = HANDLE::default();
108
+ CreateRestrictedToken(
109
+ primary,
110
+ windows::Win32::Security::CREATE_RESTRICTED_TOKEN_FLAGS(0),
111
+ Some(&disable),
112
+ None,
113
+ None,
114
+ &mut restricted,
115
+ )
116
+ .expect("CreateRestrictedToken");
117
+ let _ = CloseHandle(primary);
118
+ // AccessCheck needs impersonation level.
119
+ let mut imp = HANDLE::default();
120
+ DuplicateTokenEx(
121
+ restricted,
122
+ TOKEN_ALL_ACCESS,
123
+ None,
124
+ SecurityImpersonation,
125
+ TokenImpersonation,
126
+ &mut imp,
127
+ )
128
+ .expect("DuplicateTokenEx(restricted)");
129
+ let _ = CloseHandle(restricted);
130
+ imp
131
+ }
132
+ }
133
+
134
+ /// Run `AccessCheck(token, SD-from-sddl, DESIRED_ACCESS)` and return
135
+ /// whether it granted.
136
+ fn check(token: HANDLE, sddl: &str) -> bool {
137
+ let w = wstr(sddl);
138
+ let mut psd = PSECURITY_DESCRIPTOR::default();
139
+ unsafe {
140
+ ConvertStringSecurityDescriptorToSecurityDescriptorW(
141
+ windows::core::PCWSTR(w.as_ptr()),
142
+ 1, // SDDL_REVISION_1
143
+ &mut psd,
144
+ None,
145
+ )
146
+ .expect("ConvertStringSecurityDescriptorToSecurityDescriptorW");
147
+ }
148
+ // GENERIC_MAPPING is required by the API but irrelevant here:
149
+ // DESIRED_ACCESS has no generic bits set, so MapGenericMask
150
+ // never consults this table.
151
+ let mapping = GENERIC_MAPPING {
152
+ GenericRead: GENERIC_READ.0,
153
+ GenericWrite: GENERIC_WRITE.0,
154
+ GenericExecute: GENERIC_EXECUTE.0,
155
+ GenericAll: GENERIC_ALL.0,
156
+ };
157
+ let mut priv_set = PRIVILEGE_SET::default();
158
+ let mut priv_set_len = size_of::<PRIVILEGE_SET>() as u32;
159
+ let mut granted: u32 = 0;
160
+ let mut status = BOOL(0);
161
+ let r = unsafe {
162
+ AccessCheck(
163
+ psd,
164
+ token,
165
+ DESIRED_ACCESS,
166
+ &mapping,
167
+ Some(&mut priv_set),
168
+ &mut priv_set_len,
169
+ &mut granted,
170
+ &mut status,
171
+ )
172
+ };
173
+ unsafe {
174
+ let _ = windows::Win32::Foundation::LocalFree(Some(
175
+ windows::Win32::Foundation::HLOCAL(psd.0),
176
+ ));
177
+ }
178
+ r.expect("AccessCheck");
179
+ status.as_bool()
180
+ }
181
+
182
+ #[test]
183
+ fn sd_access_check_matrix() {
184
+ // Bail (don't fail) if Admins isn't enabled on this token — the
185
+ // "enabled" row can't be exercised on a non-admin host.
186
+ match srt_win::sid::group_state_for_self(ADMINS_SID).unwrap() {
187
+ srt_win::sid::GroupState::Enabled => {}
188
+ other => {
189
+ eprintln!(
190
+ "skipping sd_access_check_matrix: Admins is {other:?}, \
191
+ need Enabled (run elevated)"
192
+ );
193
+ return;
194
+ }
195
+ }
196
+
197
+ // SDs keyed on Admins (for enabled / deny-only rows).
198
+ let sd0_admins = sddl_nonmember(ADMINS_SID);
199
+ let sd1_admins = sddl_group(ADMINS_SID);
200
+ // SDs keyed on a group this token does not carry (for absent row).
201
+ let sd0_absent = sddl_nonmember(ABSENT_GROUP_SID);
202
+ let sd1_absent = sddl_group(ABSENT_GROUP_SID);
203
+ // Filter-3 SD is group-independent.
204
+ let sd3 = SDDL_EVERYONE;
205
+
206
+ let tok_enabled = own_impersonation_token();
207
+ let tok_denyonly = deny_only_token(ADMINS_SID);
208
+
209
+ // ── enabled row ──────────────────────────────────────────────
210
+ // Filter 0: DENY-Admins hits → denied.
211
+ assert!(
212
+ !check(tok_enabled, &sd0_admins),
213
+ "enabled vs filter-0: DENY ACE on the group should hit"
214
+ );
215
+ // Filter 1: ALLOW-Admins grants.
216
+ assert!(
217
+ check(tok_enabled, &sd1_admins),
218
+ "enabled vs filter-1: ALLOW ACE on enabled group should grant"
219
+ );
220
+ // Filter 3: ALLOW-Everyone grants.
221
+ assert!(check(tok_enabled, sd3), "enabled vs filter-3");
222
+
223
+ // ── deny-only row ────────────────────────────────────────────
224
+ // Filter 0: deny-only SIDs DO match DENY ACEs → denied. This is
225
+ // the load-bearing semantic that lets filter 0 distinguish
226
+ // "non-member" from "sandboxed member".
227
+ assert!(
228
+ !check(tok_denyonly, &sd0_admins),
229
+ "deny-only vs filter-0: deny-only group must still match the DENY ACE"
230
+ );
231
+ // Filter 1: deny-only SIDs are ignored by ALLOW ACEs → no grant.
232
+ assert!(
233
+ !check(tok_denyonly, &sd1_admins),
234
+ "deny-only vs filter-1: deny-only group must NOT satisfy the ALLOW ACE"
235
+ );
236
+ // Filter 3: ALLOW-Everyone still grants (Everyone is enabled).
237
+ assert!(check(tok_denyonly, sd3), "deny-only vs filter-3");
238
+
239
+ // ── absent row ───────────────────────────────────────────────
240
+ // Filter 0: DENY misses (group not in token) → ALLOW-Everyone
241
+ // grants. This is what lets services / SYSTEM / non-member
242
+ // users through filter 0 untouched.
243
+ assert!(
244
+ check(tok_enabled, &sd0_absent),
245
+ "absent vs filter-0: non-member token should fall through to ALLOW WD"
246
+ );
247
+ // Filter 1: ALLOW-<absent-group> can't grant.
248
+ assert!(
249
+ !check(tok_enabled, &sd1_absent),
250
+ "absent vs filter-1: no grant when group not in token"
251
+ );
252
+ // Filter 3: ALLOW-Everyone grants.
253
+ assert!(check(tok_enabled, sd3), "absent vs filter-3");
254
+
255
+ unsafe {
256
+ let _ = CloseHandle(tok_enabled);
257
+ let _ = CloseHandle(tok_denyonly);
258
+ }
259
+ }