diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19b2f6a..b7973e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -419,6 +419,7 @@ jobs: shell: pwsh run: | Copy-Item scripts/prepare-windows-runtime.ps1 "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" + Copy-Item scripts/conformance/windows_standard_user.py "$env:RUNNER_TEMP/windows-standard-user.py" New-Item -ItemType Directory -Path "$env:RUNNER_TEMP/runtime-tests" -Force | Out-Null Copy-Item scripts/tests/windows-runtime.tests.ps1 "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" - name: Verify the previous Windows test result @@ -516,7 +517,7 @@ jobs: $actual = git rev-parse HEAD if ($actual -ne $env:SELECTED_COMMIT) { throw 'Checked-out source differs from the requested build.' } $env:GITHUB_SHA = $actual - python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json + python "$env:RUNNER_TEMP/windows-standard-user.py" scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json exit $LASTEXITCODE - name: Upload Windows stable assets if: always() diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index bcc3746..044a022 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -462,6 +462,14 @@ settings files. platforms. Actual job timings determine whether the target is met; cache input changes and first compilation must be reported separately. A configured timeout or parallel scheduling alone is not evidence of a ten-minute successful build. +- **Windows host privileges:** The first hosted run exposed administrator-token + dependence in ordinary-window persistence fixtures and runtime discovery. + Persistence tests now explicitly select ordinary-window state while retaining + privileged isolation tests. Native conformance launches under a restricted copy + of the runner's own token, verifies that elevation was removed and preserves + the desktop, environment and child exit status. It does not weaken the product's + administrator isolation or create a separate user account. Native launch tests + cover elevation, literal argument passing and successful/failed child exit. - **Revisit condition:** Retain only changes whose complete CI run and package checks pass. Reconsider codegen partitioning if artifact size or runtime measurements regress, and remove redundant caches if restore/save cost grows. diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py new file mode 100644 index 0000000..ea65dd0 --- /dev/null +++ b/scripts/conformance/tests/test_windows_standard_user.py @@ -0,0 +1,37 @@ +import os +import unittest + +from scripts.conformance.windows_standard_user import WindowsTokens, run_python + + +@unittest.skipUnless(os.name == "nt", "requires native Windows process tokens") +class StandardUserLaunchTests(unittest.TestCase): + def test_child_is_unelevated_and_preserves_arguments_and_failure_code(self): + script = """ +import sys +from scripts.conformance.windows_standard_user import WindowsTokens +api = WindowsTokens() +token = api.current_token() +try: + assert not api.elevated(token), 'child retained administrator elevation' +finally: + api.kernel.CloseHandle(token) +assert sys.argv[1:] == ['path with spaces\\\\', '中文', 'literal & symbol'] +raise SystemExit(73) +""" + self.assertEqual(run_python(["-c", script, "path with spaces\\", "中文", + "literal & symbol"]), 73) + + def test_successful_child_does_not_change_parent_elevation(self): + api = WindowsTokens() + token = api.current_token() + try: + before = api.elevated(token) + self.assertEqual(run_python(["-c", "raise SystemExit(0)"]), 0) + self.assertEqual(api.elevated(token), before) + finally: + api.kernel.CloseHandle(token) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py new file mode 100644 index 0000000..00eb654 --- /dev/null +++ b/scripts/conformance/windows_standard_user.py @@ -0,0 +1,153 @@ +"""Run native conformance as an ordinary user on elevated Windows runners. + +Use a restricted copy of the current user token, retaining the same desktop, +profile and environment. Product administrator isolation stays enabled. +""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes as wt +import os +import subprocess +import sys + + +class SidAndAttributes(ctypes.Structure): + _fields_ = [("Sid", wt.LPVOID), ("Attributes", wt.DWORD)] + + +class StartupInfo(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), + ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), + ] + + +class ProcessInfo(ctypes.Structure): + _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)] + + +class WindowsTokens: + def __init__(self): + if os.name != "nt": + raise RuntimeError("the ordinary-user launcher requires Windows") + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.security = ctypes.WinDLL("advapi32", use_last_error=True) + signatures = [ + (self.kernel, "GetCurrentProcess", wt.HANDLE, []), + (self.kernel, "GetStdHandle", wt.HANDLE, [wt.DWORD]), + (self.kernel, "CloseHandle", wt.BOOL, [wt.HANDLE]), + (self.kernel, "LocalFree", wt.HANDLE, [wt.HANDLE]), + (self.kernel, "WaitForSingleObject", wt.DWORD, [wt.HANDLE, wt.DWORD]), + (self.kernel, "GetExitCodeProcess", wt.BOOL, + [wt.HANDLE, ctypes.POINTER(wt.DWORD)]), + (self.security, "OpenProcessToken", wt.BOOL, + [wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.HANDLE)]), + (self.security, "GetTokenInformation", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD)]), + (self.security, "SetTokenInformation", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD]), + (self.security, "GetLengthSid", wt.DWORD, [wt.LPVOID]), + (self.security, "ConvertStringSidToSidW", wt.BOOL, + [wt.LPCWSTR, ctypes.POINTER(wt.LPVOID)]), + (self.security, "CreateRestrictedToken", wt.BOOL, + [wt.HANDLE, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.LPVOID, + wt.DWORD, wt.LPVOID, ctypes.POINTER(wt.HANDLE)]), + (self.security, "CreateProcessAsUserW", wt.BOOL, + [wt.HANDLE, wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL, + wt.DWORD, wt.LPVOID, wt.LPCWSTR, ctypes.POINTER(StartupInfo), + ctypes.POINTER(ProcessInfo)]), + ] + for library, name, result, arguments in signatures: + function = getattr(library, name) + function.restype, function.argtypes = result, arguments + + @staticmethod + def require(success): + if not success: + raise ctypes.WinError(ctypes.get_last_error()) + + def current_token(self): + token = wt.HANDLE() + # QUERY | DUPLICATE | ASSIGN_PRIMARY, for a restricted copy of ourselves. + self.require(self.security.OpenProcessToken( + self.kernel.GetCurrentProcess(), 0x000B, ctypes.byref(token))) + return token + + def elevated(self, token): + value, size = wt.DWORD(), wt.DWORD() + self.require(self.security.GetTokenInformation( + token, 20, ctypes.byref(value), ctypes.sizeof(value), ctypes.byref(size))) + return bool(value.value) + + def restrict(self, token): + restricted, sid = wt.HANDLE(), wt.LPVOID() + # LUA_TOKEN removes administrator membership and elevated privileges. + self.require(self.security.CreateRestrictedToken( + token, 0x4, 0, None, 0, None, 0, None, ctypes.byref(restricted))) + try: + self.require(self.security.ConvertStringSidToSidW( + "S-1-16-8192", ctypes.byref(sid))) # Medium integrity. + label = SidAndAttributes(sid, 0x20) # SE_GROUP_INTEGRITY. + self.require(self.security.SetTokenInformation( + restricted, 25, ctypes.byref(label), + ctypes.sizeof(label) + self.security.GetLengthSid(sid))) + if self.elevated(restricted): + raise RuntimeError("restricted token is still elevated; refusing conformance") + return restricted + except BaseException: + self.kernel.CloseHandle(restricted) + raise + finally: + if sid: + self.kernel.LocalFree(sid) + + def run(self, token, arguments): + command = ctypes.create_unicode_buffer(subprocess.list2cmdline(arguments)) + startup = StartupInfo() + startup.cb, startup.dwFlags = ctypes.sizeof(startup), 0x100 + startup.hStdInput = self.kernel.GetStdHandle(-10) + startup.hStdOutput = self.kernel.GetStdHandle(-11) + startup.hStdError = self.kernel.GetStdHandle(-12) + process = ProcessInfo() + self.require(self.security.CreateProcessAsUserW( + token, arguments[0], command, None, None, True, 0, None, os.getcwd(), + ctypes.byref(startup), ctypes.byref(process))) + try: + if self.kernel.WaitForSingleObject(process.hProcess, 0xFFFFFFFF) != 0: + raise ctypes.WinError(ctypes.get_last_error()) + code = wt.DWORD() + self.require(self.kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code))) + return code.value + finally: + self.kernel.CloseHandle(process.hThread) + self.kernel.CloseHandle(process.hProcess) + + +def run_python(arguments): + api = WindowsTokens() + token, restricted = api.current_token(), None + try: + command = [sys.executable, *arguments] + if not api.elevated(token): + return subprocess.run(command, check=False).returncode + restricted = api.restrict(token) + print("Running native conformance with a verified non-elevated token", flush=True) + return api.run(restricted, command) + finally: + if restricted: + api.kernel.CloseHandle(restricted) + api.kernel.CloseHandle(token) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit("usage: windows_standard_user.py [arguments...]") + raise SystemExit(run_python(sys.argv[1:]))