Files
safetynet/apps/inspector/windows_appcontainer.py
T
2026-10-13 10:34:48 +02:00

272 lines
17 KiB
Python

"""Capability-free Windows AppContainer process launcher.
The HTTP broker must stay outside this boundary. Only approved immutable runtime,
parser source and model directories receive read/execute access. Documents travel
through anonymous pipes, never a temporary plaintext file. No network capability
or loopback exemption is assigned. Windows supplies the package-private storage.
"""
import ctypes
import hashlib
import os
import subprocess
import sys
import threading
from pathlib import Path
from ctypes import wintypes as W
class SandboxError(RuntimeError):
pass
def _verify_read_acl(path, sid):
"""Read the DACL directly; startup never needs WRITE_DAC or icacls."""
a = ctypes.WinDLL('advapi32', use_last_error=True)
k = ctypes.WinDLL('kernel32', use_last_error=True)
a.GetNamedSecurityInfoW.argtypes = [W.LPWSTR, W.DWORD, W.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
a.GetNamedSecurityInfoW.restype = W.DWORD
a.GetAce.argtypes = [ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
a.GetAce.restype = W.BOOL
a.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]; a.EqualSid.restype = W.BOOL
k.LocalFree.argtypes = [ctypes.c_void_p]; k.LocalFree.restype = ctypes.c_void_p
dacl = ctypes.c_void_p(); descriptor = ctypes.c_void_p()
if a.GetNamedSecurityInfoW(str(path), 1, 4, None, None, ctypes.byref(dacl), None, ctypes.byref(descriptor)):
raise SandboxError('Approved runtime ACL could not be inspected')
try:
if not dacl: raise SandboxError('Approved runtime has unrestricted access')
count = ctypes.c_ushort.from_address(dacl.value + 4).value
allow = 0
required = 0x1200a9 # FILE_GENERIC_READ | FILE_GENERIC_EXECUTE
for index in range(count):
ace = ctypes.c_void_p()
if not a.GetAce(dacl, index, ctypes.byref(ace)): raise SandboxError('Runtime ACL is invalid')
kind = ctypes.c_ubyte.from_address(ace.value).value
flags = ctypes.c_ubyte.from_address(ace.value + 1).value
if flags & 8: continue # INHERIT_ONLY does not apply to this object.
mask = ctypes.c_uint32.from_address(ace.value + 4).value
# Conservative: a relevant deny ACE requires administrator review.
if kind == 1 and mask & (required | 0x90000000):
raise SandboxError('Approved runtime has conflicting deny permissions')
if kind == 0 and a.EqualSid(ctypes.c_void_p(ace.value + 8), sid):
if mask & 0x10000000: mask |= 0x1f01ff
if mask & 0x80000000: mask |= 0x120089
if mask & 0x20000000: mask |= 0x1200a0
allow |= mask
if allow & required != required:
raise SandboxError('Runtime ACL provisioning is required before inspector startup')
if allow & 0x400d0116: # write/append/EA/attributes/delete/WRITE_DAC/WRITE_OWNER
raise SandboxError('AppContainer runtime access must be read-only')
finally:
k.LocalFree(descriptor)
def _api():
if sys.platform != 'win32':
raise SandboxError('AppContainer requires Windows')
k = ctypes.WinDLL('kernel32', use_last_error=True)
u = ctypes.WinDLL('userenv', use_last_error=True)
a = ctypes.WinDLL('advapi32', use_last_error=True)
for name, args, result in [
('CloseHandle', [W.HANDLE], W.BOOL),
('CreatePipe', [ctypes.POINTER(W.HANDLE), ctypes.POINTER(W.HANDLE), ctypes.c_void_p, W.DWORD], W.BOOL),
('SetHandleInformation', [W.HANDLE, W.DWORD, W.DWORD], W.BOOL),
('InitializeProcThreadAttributeList', [ctypes.c_void_p, W.DWORD, W.DWORD, ctypes.POINTER(ctypes.c_size_t)], W.BOOL),
('UpdateProcThreadAttribute', [ctypes.c_void_p, W.DWORD, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
('DeleteProcThreadAttributeList', [ctypes.c_void_p], None),
('CreateProcessW', [W.LPCWSTR, W.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, W.BOOL, W.DWORD, ctypes.c_void_p, W.LPCWSTR, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
('WaitForSingleObject', [W.HANDLE, W.DWORD], W.DWORD),
('TerminateProcess', [W.HANDLE, W.UINT], W.BOOL),
('GetExitCodeProcess', [W.HANDLE, ctypes.POINTER(W.DWORD)], W.BOOL),
('LocalFree', [ctypes.c_void_p], ctypes.c_void_p),
('CreateJobObjectW', [ctypes.c_void_p, W.LPCWSTR], W.HANDLE),
('SetInformationJobObject', [W.HANDLE, ctypes.c_int, ctypes.c_void_p, W.DWORD], W.BOOL),
('AssignProcessToJobObject', [W.HANDLE, W.HANDLE], W.BOOL),
('ResumeThread', [W.HANDLE], W.DWORD),
]:
fn = getattr(k, name); fn.argtypes = args; fn.restype = result
u.CreateAppContainerProfile.argtypes = [W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
u.CreateAppContainerProfile.restype = ctypes.c_long
u.DeriveAppContainerSidFromAppContainerName.argtypes = [W.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
u.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
a.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(W.LPWSTR)]
a.ConvertSidToStringSidW.restype = W.BOOL
a.FreeSid.argtypes = [ctypes.c_void_p]; a.FreeSid.restype = ctypes.c_void_p
return k, u, a
class AppContainer:
"""Broker-owned launcher. Caller supplies trusted deployment directories."""
def __init__(self, read_directories, *, provision=False):
self.k, u, self.a = _api()
roots_input = [Path(p).absolute() for p in read_directories]
for root in roots_input:
for component in [root, *root.parents]:
if component.lstat().st_file_attributes & 0x400:
raise SandboxError('Reparse point in approved sandbox root')
roots = tuple(sorted({str(p.resolve(strict=True)) for p in roots_input}))
if not roots or any(not Path(p).is_dir() or Path(p).parent == Path(p) for p in roots):
raise SandboxError('Invalid approved sandbox runtime directories')
self.name = 'SafetyNet.Parser.' + hashlib.sha256('\0'.join(roots).encode()).hexdigest()[:24]
self.sid = ctypes.c_void_p()
result = u.CreateAppContainerProfile(self.name, 'SafetyNet parser', 'Offline content inspection', None, 0, ctypes.byref(self.sid))
if result != 0 and u.DeriveAppContainerSidFromAppContainerName(self.name, ctypes.byref(self.sid)) != 0:
raise SandboxError('AppContainer profile could not be established')
sid_text = W.LPWSTR()
if not self.a.ConvertSidToStringSidW(self.sid, ctypes.byref(sid_text)):
raise SandboxError('AppContainer identity unavailable')
try:
self.sid_string = sid_text.value
finally:
self.k.LocalFree(sid_text)
# Package SID grants are specific to this deployment, never Everyone or
# ALL APPLICATION PACKAGES. No writable permission is added to source.
for root in roots:
# Reject junctions/symlinks before recursive grants; /L alone is not
# sufficient evidence that every possible reparse target stays local.
entries = [Path(root)]
for directory, folders, files in os.walk(root, followlinks=False):
for name in folders + files:
entry = Path(directory) / name
if entry.lstat().st_file_attributes & 0x400:
raise SandboxError('Reparse point in approved sandbox runtime')
entries.append(entry)
if provision:
completed = subprocess.run(['icacls.exe', root, '/grant:r', '*' + self.sid_string + ':(RX)', '/T', '/L', '/Q'],
capture_output=True, timeout=60, creationflags=subprocess.CREATE_NO_WINDOW)
if completed.returncode:
raise SandboxError('Approved runtime access could not be established')
for entry in entries:
_verify_read_acl(entry, self.sid)
self.roots = roots
def run(self, executable, arguments, data=b'', environment=None, timeout=30, max_output=8*1024*1024):
"""One bounded pipe exchange. Child descendants remain in broker Job."""
import msvcrt
executable = str(Path(executable).resolve(strict=True))
if not any(Path(executable).is_relative_to(Path(root)) for root in self.roots):
raise SandboxError('Executable is outside approved runtime')
class SA(ctypes.Structure):
_fields_ = [('length', W.DWORD), ('descriptor', ctypes.c_void_p), ('inherit', W.BOOL)]
class SI(ctypes.Structure):
_fields_ = [('cb', W.DWORD), ('reserved', W.LPWSTR), ('desktop', W.LPWSTR), ('title', W.LPWSTR),
('x', W.DWORD), ('y', W.DWORD), ('xs', W.DWORD), ('ys', W.DWORD), ('xc', W.DWORD), ('yc', W.DWORD),
('fill', W.DWORD), ('flags', W.DWORD), ('show', W.WORD), ('reserved2size', W.WORD),
('reserved2', ctypes.c_void_p), ('stdin', W.HANDLE), ('stdout', W.HANDLE), ('stderr', W.HANDLE)]
class SIX(ctypes.Structure):
_fields_ = [('startup', SI), ('attributes', ctypes.c_void_p)]
class PI(ctypes.Structure):
_fields_ = [('process', W.HANDLE), ('thread', W.HANDLE), ('pid', W.DWORD), ('tid', W.DWORD)]
class SC(ctypes.Structure):
_fields_ = [('sid', ctypes.c_void_p), ('capabilities', ctypes.c_void_p), ('count', W.DWORD), ('reserved', W.DWORD)]
security = SA(ctypes.sizeof(SA), None, True)
handles = []
attribute = None
process = PI()
job = None
owned_files = []
try:
ir, iw, or_, ow = W.HANDLE(), W.HANDLE(), W.HANDLE(), W.HANDLE()
for read, write in [(ir, iw), (or_, ow)]:
if not self.k.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(security), 0):
raise SandboxError('Sandbox pipes unavailable')
handles.extend([read.value, write.value])
for parent_handle in [iw, or_]:
if not self.k.SetHandleInformation(parent_handle, 1, 0):
raise SandboxError('Sandbox pipe inheritance unavailable')
size = ctypes.c_size_t()
self.k.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
attribute = ctypes.create_string_buffer(size.value)
if not self.k.InitializeProcThreadAttributeList(attribute, 2, 0, ctypes.byref(size)):
raise SandboxError('Sandbox launch attributes unavailable')
caps = SC(self.sid, None, 0, 0)
inheritance = (W.HANDLE * 2)(ir.value, ow.value)
for key, value in [(0x20009, caps), (0x20002, inheritance)]:
if not self.k.UpdateProcThreadAttribute(attribute, 0, key, ctypes.byref(value), ctypes.sizeof(value), None, None):
raise SandboxError('Sandbox launch boundary unavailable')
startup = SIX(); startup.startup.cb = ctypes.sizeof(SIX)
startup.startup.flags = 0x100; startup.startup.stdin = ir; startup.startup.stdout = ow; startup.startup.stderr = ow
startup.attributes = ctypes.cast(attribute, ctypes.c_void_p)
# Explicit environment: no broker credential or inherited deployment secrets.
env = {'SystemRoot': os.environ.get('SystemRoot', 'C:\\Windows'), 'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONIOENCODING':'utf-8'}
for key in ['USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP','WINDIR']:
if key in os.environ: env[key] = os.environ[key]
allowed = {'EMBEDDING_MODEL_DIR','EMBEDDING_WORKER_PATH','SEARCHI_FORCE_CPU','GGML_CPU_ONLY','GGML_DISABLE_VULKAN'}
if any(key not in allowed or not isinstance(value,str) or '\0' in value for key,value in (environment or {}).items()):
raise SandboxError('Unapproved AppContainer environment field')
env.update(environment or {})
environment_block = ctypes.create_unicode_buffer('\0'.join(f'{key}={value}' for key, value in sorted(env.items())) + '\0\0')
command = ctypes.create_unicode_buffer(subprocess.list2cmdline([executable, *arguments]))
if not self.k.CreateProcessW(executable, command, None, None, True, 0x80000|0x400|0x8000000|0x4,
environment_block, self.roots[0], ctypes.byref(startup), ctypes.byref(process)):
raise SandboxError('AppContainer process launch failed: ' + str(ctypes.get_last_error()))
# Suspended launch avoids an execution window before descendant cleanup.
class BasicLimits(ctypes.Structure):
_fields_ = [('processTime', ctypes.c_int64), ('jobTime', ctypes.c_int64), ('flags', W.DWORD),
('minWorking', ctypes.c_size_t), ('maxWorking', ctypes.c_size_t), ('processes', W.DWORD),
('affinity', ctypes.c_size_t), ('priority', W.DWORD), ('scheduling', W.DWORD)]
class Extended(ctypes.Structure):
_fields_ = [('basic', BasicLimits), ('io', ctypes.c_uint64 * 6), ('processMemory', ctypes.c_size_t), ('jobMemory', ctypes.c_size_t), ('peakProcess', ctypes.c_size_t), ('peakJob', ctypes.c_size_t)]
limits = Extended(); limits.basic.flags = 0x2000 | 0x8; limits.basic.processes = 2
job = self.k.CreateJobObjectW(None, None)
if not job or not self.k.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)) or not self.k.AssignProcessToJobObject(job, process.process):
self.k.TerminateProcess(process.process, 1)
raise SandboxError('AppContainer descendant containment unavailable')
if self.k.ResumeThread(process.thread) == 0xffffffff:
raise SandboxError('AppContainer launch resume failed')
self.k.CloseHandle(process.thread); process.thread = None
for child_handle in [ir.value, ow.value]:
self.k.CloseHandle(child_handle); handles.remove(child_handle)
writer = os.fdopen(msvcrt.open_osfhandle(iw.value, os.O_WRONLY|os.O_BINARY), 'wb', buffering=0)
reader = os.fdopen(msvcrt.open_osfhandle(or_.value, os.O_RDONLY|os.O_BINARY), 'rb', buffering=0)
handles.remove(iw.value); handles.remove(or_.value); owned_files = [writer, reader]
chunks = []; exceeded = threading.Event(); errors = []
def write():
try:
position = 0
while position < len(data):
count = writer.write(memoryview(data)[position:])
if not count: raise OSError()
position += count
except OSError:
pass
finally:
writer.close()
def read():
total = 0
try:
while True:
chunk = reader.read(65536)
if not chunk: break
total += len(chunk)
if total > max_output:
exceeded.set(); self.k.TerminateProcess(process.process, 1); break
chunks.append(chunk)
except OSError:
errors.append(True)
threads = [threading.Thread(target=write, daemon=True), threading.Thread(target=read, daemon=True)]
for thread in threads: thread.start()
if self.k.WaitForSingleObject(process.process, int(timeout*1000)) != 0:
self.k.TerminateProcess(process.process, 1)
self.k.WaitForSingleObject(process.process, 5000)
raise SandboxError('AppContainer inspection deadline exceeded')
self.k.CloseHandle(job);job=None
for thread in threads: thread.join(2)
if exceeded.is_set() or errors or any(thread.is_alive() for thread in threads):
raise SandboxError('AppContainer response failed bounded exchange')
code = W.DWORD()
if not self.k.GetExitCodeProcess(process.process, ctypes.byref(code)):
raise SandboxError('AppContainer process status unavailable')
return code.value, b''.join(chunks)
finally:
if job: self.k.CloseHandle(job);job=None
for file in owned_files:
if not file.closed: file.close()
for handle in handles: self.k.CloseHandle(handle)
if job: self.k.CloseHandle(job)
if process.thread: self.k.CloseHandle(process.thread)
if process.process: self.k.CloseHandle(process.process)
if attribute is not None: self.k.DeleteProcThreadAttributeList(attribute)