saferun-cli 0.0.2__py3-none-any.whl

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 (51) hide show
  1. saferun/__init__.py +3 -0
  2. saferun/__main__.py +5 -0
  3. saferun/cli.py +533 -0
  4. saferun/dockerdiag.py +65 -0
  5. saferun/doctor.py +159 -0
  6. saferun/engine/__init__.py +7 -0
  7. saferun/engine/analyzer.py +30 -0
  8. saferun/engine/context.py +27 -0
  9. saferun/engine/engine.py +100 -0
  10. saferun/engine/model.py +146 -0
  11. saferun/engine/node.py +145 -0
  12. saferun/engine/osv.py +291 -0
  13. saferun/engine/osvdb.py +367 -0
  14. saferun/engine/paths.py +41 -0
  15. saferun/engine/pysyntax.py +135 -0
  16. saferun/engine/rules/python.yml +103 -0
  17. saferun/engine/semgrep.py +173 -0
  18. saferun/engine/tools.py +167 -0
  19. saferun/engine/verdict.py +47 -0
  20. saferun/hostos/__init__.py +188 -0
  21. saferun/hostos/_win.py +316 -0
  22. saferun/hostos/acl.py +172 -0
  23. saferun/hostos/fsops.py +41 -0
  24. saferun/hostos/process.py +82 -0
  25. saferun/hostos/winfs.py +164 -0
  26. saferun/runcmd.py +139 -0
  27. saferun/sandbox/__init__.py +11 -0
  28. saferun/sandbox/analyzer.py +192 -0
  29. saferun/sandbox/attestation.py +110 -0
  30. saferun/sandbox/config.py +121 -0
  31. saferun/sandbox/controls.py +104 -0
  32. saferun/sandbox/copy.py +338 -0
  33. saferun/sandbox/copy_windows.py +281 -0
  34. saferun/sandbox/cosign_tool.py +134 -0
  35. saferun/sandbox/docker.py +167 -0
  36. saferun/sandbox/images.py +110 -0
  37. saferun/sandbox/profiles.py +40 -0
  38. saferun/sandbox/reaper.py +127 -0
  39. saferun/sandbox/runner.py +268 -0
  40. saferun/sandbox/secrets.py +91 -0
  41. saferun/sandbox/signature.py +117 -0
  42. saferun/sandbox/verify.py +75 -0
  43. saferun/sandbox/workspace.py +92 -0
  44. saferun/setup.py +559 -0
  45. saferun/ui.py +133 -0
  46. saferun_cli-0.0.2.dist-info/METADATA +136 -0
  47. saferun_cli-0.0.2.dist-info/RECORD +51 -0
  48. saferun_cli-0.0.2.dist-info/WHEEL +5 -0
  49. saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
  50. saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
  51. saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,188 @@
1
+ """The only place SafeRun branches on the operating system (apart from the Windows copy engine, sandbox/copy_windows.py).
2
+
3
+ POSIX (Linux, macOS) behaviour is the original behaviour of SafeRun and stays implemented where it always was (fcntl locks, O_NOFOLLOW /
4
+ dir_fd copy, permission bits). This package adds what a native Windows host needs, plus path and endpoint rules that differ per platform.
5
+ Nothing here weakens a check: where Windows cannot offer a POSIX guarantee, the function fails closed or says so (docs/platforms.md).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ IS_WINDOWS = sys.platform == "win32"
15
+ IS_MACOS = sys.platform == "darwin"
16
+
17
+ if IS_WINDOWS:
18
+ from . import _win
19
+
20
+
21
+ def user_data_base() -> Path:
22
+ """Directory that holds `saferun/…` (analyzer lock, OSV database). XDG_DATA_HOME wins on POSIX; Linux default is unchanged."""
23
+ if IS_WINDOWS:
24
+ return Path(os.environ.get("LOCALAPPDATA") or os.path.join(os.path.expanduser("~"), "AppData", "Local"))
25
+ env = os.environ.get("XDG_DATA_HOME")
26
+ if env:
27
+ return Path(env)
28
+ home = os.path.expanduser("~")
29
+ if IS_MACOS:
30
+ return Path(home) / "Library" / "Application Support"
31
+ return Path(home) / ".local" / "share"
32
+
33
+
34
+ # Environment variables a child process needs on Windows (it cannot start, resolve DNS or load DLLs without them). Nothing secret.
35
+ WINDOWS_BASE_ENV = ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "SYSTEMDRIVE")
36
+
37
+
38
+ def base_env_names() -> tuple[str, ...]:
39
+ return WINDOWS_BASE_ENV if IS_WINDOWS else ()
40
+
41
+
42
+ def private_home_env(home: str) -> dict[str, str]:
43
+ """Variables that point a child's profile / temp locations at `home` (Windows names; POSIX uses HOME / XDG_* set by the callers)."""
44
+ if not IS_WINDOWS:
45
+ return {}
46
+ return {"USERPROFILE": home, "APPDATA": home, "LOCALAPPDATA": home, "TEMP": home, "TMP": home}
47
+
48
+
49
+ def enable_ansi() -> None:
50
+ """Make colour / cursor escapes work in cmd.exe and PowerShell consoles (Windows 10+), and never crash on a non-UTF-8 code page."""
51
+ if not IS_WINDOWS:
52
+ return
53
+ for fd in (-11, -12):
54
+ _win.enable_ansi(fd)
55
+ for stream in (sys.stdout, sys.stderr):
56
+ try:
57
+ stream.reconfigure(errors="replace")
58
+ except (AttributeError, ValueError):
59
+ pass
60
+
61
+
62
+ # ---- Docker endpoint and mount rules ------------------------------------------------------------------------------
63
+ _NPIPE_RE = re.compile(r"^npipe:/{2,4}\./pipe/[A-Za-z0-9_.-]{1,128}$") # host must be "." = this machine; no \\server\pipe\x
64
+
65
+
66
+ def docker_endpoint_is_local(host: str) -> bool:
67
+ """POSIX: only a unix:// socket (unchanged). Windows: only a LOCAL named pipe; unix://, tcp://, ssh:// and remote pipes are refused."""
68
+ if IS_WINDOWS:
69
+ return bool(_NPIPE_RE.match(host))
70
+ return host.startswith("unix://")
71
+
72
+
73
+ _DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:\\")
74
+
75
+
76
+ def mount_source_problem(path: str) -> str | None:
77
+ """Windows only: a bind-mount source must be a plain local drive path. UNC / device / extended paths could reach a remote machine."""
78
+ if not IS_WINDOWS:
79
+ return None
80
+ if not _DRIVE_PATH_RE.match(path) or "\\\\?\\" in path or "\\\\.\\" in path:
81
+ return "only a local drive path (C:\\...) can be mounted; network, device and extended-length paths are refused"
82
+ if _win.is_remote_drive(path):
83
+ return "the path is on a network drive; Docker would resolve it on another machine"
84
+ return None
85
+
86
+
87
+ # ---- files owned and readable only by the current user (analyzer lock, workspace ownership) -------------------------
88
+ def _trusted_sids() -> tuple[frozenset[str], str]:
89
+ from . import acl
90
+ _tok, user, owner = _win.current_token()
91
+ return frozenset({user, owner, acl.SYSTEM, acl.ADMINISTRATORS}), user
92
+
93
+
94
+ def private_path_problem(path: Path, *, directory: bool) -> str | None:
95
+ """Windows counterpart of the POSIX "owned by me, mode 0700/0600, not a symlink" test; None if the path can be trusted.
96
+ The object must not be a reparse point, must be owned by the current user (or the account's default owner / Administrators), and its
97
+ DACL must grant nothing to any other principal than the user, SYSTEM and Administrators. Anything unreadable is NOT trusted."""
98
+ from . import acl
99
+ try:
100
+ h = _win.open_handle(str(path), _win.READ_CONTROL | _win.FILE_READ_ATTRIBUTES, _win.SHARE_READ | _win.SHARE_WRITE | _win.SHARE_DELETE,
101
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
102
+ except OSError as e:
103
+ return f"cannot examine {path}: {e}"
104
+ try:
105
+ info = _win.file_info(h)
106
+ if info["attrs"] & _win.ATTR_REPARSE_POINT:
107
+ return f"{path} is a reparse point (symlink or junction), not a real {'directory' if directory else 'file'}"
108
+ if bool(info["attrs"] & _win.ATTR_DIRECTORY) != directory:
109
+ return f"{path} is not a {'directory' if directory else 'regular file'}"
110
+ trusted, _user = _trusted_sids()
111
+ desc = acl.parse_descriptor(_win.security_descriptor(h))
112
+ if desc.owner not in trusted:
113
+ return f"{path} is owned by {desc.owner}, not by the current user"
114
+ # OWNER RIGHTS (S-1-3-4) is how Windows (and tools such as pytest's temp directories) pin the owner's own rights. It applies to the
115
+ # owner only, and the owner was verified above, so it grants nothing to a third party.
116
+ other = acl.only_trusted_principals(desc, trusted | {acl.OWNER_RIGHTS})
117
+ if other:
118
+ return f"{path} grants access to {other}; only the current user, SYSTEM and Administrators may have access (icacls {path} /inheritance:r /grant:r %USERNAME%:F)"
119
+ except (OSError, acl.AclUnverifiable) as e:
120
+ return f"cannot verify the permissions of {path}: {e}"
121
+ finally:
122
+ _win.close(h)
123
+ return None
124
+
125
+
126
+ def restrict_to_owner(path: Path) -> None:
127
+ """Windows: make `path` accessible to the current user and SYSTEM only (protected DACL). The POSIX code uses chmod 0700 / 0600."""
128
+ _win.restrict_to_user(str(path), _win.current_token()[1])
129
+
130
+
131
+ def ensure_private_dir(path: Path) -> bool:
132
+ """Create `path` owner-only (POSIX 0700, Windows protected DACL) if it is missing; missing ancestors are created normally. Returns True if it was
133
+ created. An EXISTING directory is never changed. Needed because SafeRun's per-user directory also holds the analyzer lock, whose trust rules
134
+ demand an owner-only directory: whichever SafeRun code creates it first must not leave it wider than that."""
135
+ if path.exists() or path.is_symlink():
136
+ return False
137
+ path.parent.mkdir(parents=True, exist_ok=True)
138
+ path.mkdir(mode=0o700)
139
+ if IS_WINDOWS:
140
+ restrict_to_owner(path)
141
+ else:
142
+ os.chmod(path, 0o700) # the umask may have narrowed it, never widened; make it exact
143
+ return True
144
+
145
+
146
+ def owned_by_current_user(path: Path) -> bool:
147
+ """Windows counterpart of `st_uid == os.getuid()` (used before the reaper deletes a leftover workspace)."""
148
+ from . import acl
149
+ try:
150
+ h = _win.open_handle(str(path), _win.READ_CONTROL, _win.SHARE_READ | _win.SHARE_WRITE | _win.SHARE_DELETE,
151
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
152
+ except OSError:
153
+ return False
154
+ try:
155
+ _tok, user, owner = _win.current_token() # `owner` is the account's default owner: Administrators for an elevated token
156
+ return acl.parse_descriptor(_win.security_descriptor(h)).owner in (user, owner)
157
+ except (OSError, acl.AclUnverifiable):
158
+ return False
159
+ finally:
160
+ _win.close(h)
161
+
162
+
163
+ # ---- workspace liveness lock ---------------------------------------------------------------------------------------
164
+ def create_workspace_lock(path: Path) -> int:
165
+ """Windows: file held with share mode 0 until the returned fd is closed (or the process dies)."""
166
+ return _win.create_exclusive_lock(str(path))
167
+
168
+
169
+ def workspace_lock_state(path: str) -> str:
170
+ return _win.lock_state(path)
171
+
172
+
173
+ def long_path(path) -> str:
174
+ """Windows: the extended-length form, so deletion works for long paths and names such as CON / trailing dots. Elsewhere: unchanged."""
175
+ return _win.ext(str(path)) if IS_WINDOWS else str(path)
176
+
177
+
178
+ def rmtree_onexc(func, path, exc):
179
+ """shutil.rmtree error handler for Windows: clear the read-only flag and retry once. Any other failure is raised, never hidden."""
180
+ import stat
181
+ if isinstance(exc, PermissionError):
182
+ try:
183
+ os.chmod(path, stat.S_IWRITE)
184
+ func(path)
185
+ return
186
+ except OSError:
187
+ pass
188
+ raise exc
saferun/hostos/_win.py ADDED
@@ -0,0 +1,316 @@
1
+ """Thin ctypes layer over the Win32 / NT calls SafeRun needs. Imported only on Windows; nothing here decides policy (see acl.py).
2
+
3
+ Every function declares argtypes/restype: without them 64-bit handles are truncated to 32 bits.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import ctypes
8
+ import msvcrt
9
+ import ntpath
10
+ import os
11
+ from ctypes import wintypes as wt
12
+
13
+ from . import acl
14
+
15
+ _k32 = ctypes.WinDLL("kernel32", use_last_error=True)
16
+ _adv = ctypes.WinDLL("advapi32", use_last_error=True)
17
+ _nt = ctypes.WinDLL("ntdll", use_last_error=True)
18
+
19
+ HANDLE = ctypes.c_void_p
20
+ INVALID = ctypes.c_void_p(-1).value
21
+
22
+ GENERIC_READ, GENERIC_WRITE = 0x80000000, 0x40000000
23
+ FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, READ_CONTROL, DELETE = 0x1, 0x80, 0x20000, 0x10000
24
+ SHARE_READ, SHARE_WRITE, SHARE_DELETE = 1, 2, 4
25
+ CREATE_NEW, OPEN_EXISTING = 1, 3
26
+ FLAG_BACKUP_SEMANTICS, FLAG_OPEN_REPARSE_POINT, ATTR_NORMAL = 0x02000000, 0x00200000, 0x80
27
+ ATTR_DIRECTORY, ATTR_REPARSE_POINT = 0x10, 0x400
28
+ TAG_SYMLINK = 0xA000000C
29
+ ERROR_SHARING_VIOLATION, ERROR_FILE_NOT_FOUND, ERROR_HANDLE_EOF = 32, 2, 38
30
+ DRIVE_REMOTE = 4
31
+
32
+
33
+ class FILETIME(ctypes.Structure):
34
+ _fields_ = [("lo", wt.DWORD), ("hi", wt.DWORD)]
35
+
36
+
37
+ class BY_HANDLE_FILE_INFORMATION(ctypes.Structure):
38
+ _fields_ = [("attrs", wt.DWORD), ("created", FILETIME), ("accessed", FILETIME), ("written", FILETIME),
39
+ ("volume", wt.DWORD), ("size_hi", wt.DWORD), ("size_lo", wt.DWORD), ("nlink", wt.DWORD),
40
+ ("index_hi", wt.DWORD), ("index_lo", wt.DWORD)]
41
+
42
+
43
+ class FILE_BASIC_INFO(ctypes.Structure):
44
+ _fields_ = [("created", ctypes.c_int64), ("accessed", ctypes.c_int64), ("written", ctypes.c_int64),
45
+ ("changed", ctypes.c_int64), ("attrs", wt.DWORD)]
46
+
47
+
48
+ class FILE_ID_INFO(ctypes.Structure):
49
+ _fields_ = [("volume", ctypes.c_uint64), ("file_id", ctypes.c_ubyte * 16)]
50
+
51
+
52
+ class FILE_ATTRIBUTE_TAG_INFO(ctypes.Structure):
53
+ _fields_ = [("attrs", wt.DWORD), ("tag", wt.DWORD)]
54
+
55
+
56
+ class WIN32_FIND_STREAM_DATA(ctypes.Structure):
57
+ _fields_ = [("size", ctypes.c_int64), ("name", ctypes.c_wchar * (260 + 36))]
58
+
59
+
60
+ def _fn(dll, name, restype, *argtypes):
61
+ f = getattr(dll, name)
62
+ f.restype, f.argtypes = restype, list(argtypes)
63
+ return f
64
+
65
+
66
+ CreateFileW = _fn(_k32, "CreateFileW", HANDLE, wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p, wt.DWORD, wt.DWORD, HANDLE)
67
+ CloseHandle = _fn(_k32, "CloseHandle", wt.BOOL, HANDLE)
68
+ GetFileInformationByHandle = _fn(_k32, "GetFileInformationByHandle", wt.BOOL, HANDLE, ctypes.POINTER(BY_HANDLE_FILE_INFORMATION))
69
+ GetFileInformationByHandleEx = _fn(_k32, "GetFileInformationByHandleEx", wt.BOOL, HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD)
70
+ GetFinalPathNameByHandleW = _fn(_k32, "GetFinalPathNameByHandleW", wt.DWORD, HANDLE, wt.LPWSTR, wt.DWORD, wt.DWORD)
71
+ GetDriveTypeW = _fn(_k32, "GetDriveTypeW", wt.UINT, wt.LPCWSTR)
72
+ FindFirstStreamW = _fn(_k32, "FindFirstStreamW", HANDLE, wt.LPCWSTR, ctypes.c_int, ctypes.c_void_p, wt.DWORD)
73
+ FindNextStreamW = _fn(_k32, "FindNextStreamW", wt.BOOL, HANDLE, ctypes.c_void_p)
74
+ FindClose = _fn(_k32, "FindClose", wt.BOOL, HANDLE)
75
+ CreateJobObjectW = _fn(_k32, "CreateJobObjectW", HANDLE, ctypes.c_void_p, wt.LPCWSTR)
76
+ SetInformationJobObject = _fn(_k32, "SetInformationJobObject", wt.BOOL, HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD)
77
+ AssignProcessToJobObject = _fn(_k32, "AssignProcessToJobObject", wt.BOOL, HANDLE, HANDLE)
78
+ TerminateJobObject = _fn(_k32, "TerminateJobObject", wt.BOOL, HANDLE, wt.UINT)
79
+ GetStdHandle = _fn(_k32, "GetStdHandle", HANDLE, wt.DWORD)
80
+ GetConsoleMode = _fn(_k32, "GetConsoleMode", wt.BOOL, HANDLE, ctypes.POINTER(wt.DWORD))
81
+ SetConsoleMode = _fn(_k32, "SetConsoleMode", wt.BOOL, HANDLE, wt.DWORD)
82
+ LocalFree = _fn(_k32, "LocalFree", HANDLE, HANDLE)
83
+ GetCurrentProcess = _fn(_k32, "GetCurrentProcess", HANDLE)
84
+ OpenProcessToken = _adv.OpenProcessToken
85
+ OpenProcessToken.restype, OpenProcessToken.argtypes = wt.BOOL, [HANDLE, wt.DWORD, ctypes.POINTER(HANDLE)]
86
+ GetTokenInformation = _fn(_adv, "GetTokenInformation", wt.BOOL, HANDLE, ctypes.c_int, ctypes.c_void_p, wt.DWORD, ctypes.POINTER(wt.DWORD))
87
+ GetKernelObjectSecurity = _fn(_adv, "GetKernelObjectSecurity", wt.BOOL, HANDLE, wt.DWORD, ctypes.c_void_p, wt.DWORD, ctypes.POINTER(wt.DWORD))
88
+ ConvertStringSecurityDescriptorToSecurityDescriptorW = _fn(
89
+ _adv, "ConvertStringSecurityDescriptorToSecurityDescriptorW", wt.BOOL, wt.LPCWSTR, wt.DWORD, ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p)
90
+ SetFileSecurityW = _fn(_adv, "SetFileSecurityW", wt.BOOL, wt.LPCWSTR, wt.DWORD, ctypes.c_void_p)
91
+ NtResumeProcess = _fn(_nt, "NtResumeProcess", ctypes.c_long, HANDLE)
92
+
93
+
94
+ def ext(path: str) -> str:
95
+ """Extended-length form of an absolute path (no MAX_PATH limit, no Win32 name normalisation)."""
96
+ # os.path.abspath() asks Win32 to normalise the name, which turns a final component such as CON into the device path \\.\CON.
97
+ # An already absolute path is only cleaned up as a string; only a relative one needs the working directory.
98
+ path = ntpath.normpath(path) if ntpath.isabs(path) else os.path.abspath(path)
99
+ if path.startswith("\\\\?\\"):
100
+ return path
101
+ return "\\\\?\\UNC\\" + path[2:] if path.startswith("\\\\") else "\\\\?\\" + path
102
+
103
+
104
+ def oserror(code: int | None = None) -> OSError:
105
+ code = ctypes.get_last_error() if code is None else code
106
+ return ctypes.WinError(code)
107
+
108
+
109
+ def open_handle(path: str, access: int, share: int, disposition: int = OPEN_EXISTING, flags: int = 0) -> int:
110
+ h = CreateFileW(ext(path), access, share, None, disposition, flags, None)
111
+ if h is None or h == INVALID:
112
+ raise oserror()
113
+ return h
114
+
115
+
116
+ def close(handle: int | None) -> None:
117
+ if handle:
118
+ CloseHandle(handle)
119
+
120
+
121
+ def file_info(handle: int) -> dict:
122
+ """Identity and metadata of an OPEN handle (never of a path): attributes, 128-bit file id, size, links, times, reparse tag."""
123
+ bh = BY_HANDLE_FILE_INFORMATION()
124
+ if not GetFileInformationByHandle(handle, ctypes.byref(bh)):
125
+ raise oserror()
126
+ basic = FILE_BASIC_INFO()
127
+ if not GetFileInformationByHandleEx(handle, 0, ctypes.byref(basic), ctypes.sizeof(basic)):
128
+ raise oserror()
129
+ fid = FILE_ID_INFO()
130
+ if GetFileInformationByHandleEx(handle, 18, ctypes.byref(fid), ctypes.sizeof(fid)):
131
+ ident = (fid.volume, bytes(fid.file_id))
132
+ else: # file systems without 128-bit ids: the 64-bit index
133
+ ident = (bh.volume, (bh.index_hi << 32) | bh.index_lo)
134
+ tag = 0
135
+ if bh.attrs & ATTR_REPARSE_POINT:
136
+ t = FILE_ATTRIBUTE_TAG_INFO()
137
+ if not GetFileInformationByHandleEx(handle, 9, ctypes.byref(t), ctypes.sizeof(t)):
138
+ raise oserror()
139
+ tag = t.tag
140
+ return {"attrs": bh.attrs, "ident": ident, "size": (bh.size_hi << 32) | bh.size_lo, "nlink": bh.nlink,
141
+ "written": basic.written, "changed": basic.changed, "tag": tag}
142
+
143
+
144
+ def final_path(handle: int) -> str:
145
+ buf = ctypes.create_unicode_buffer(32768)
146
+ n = GetFinalPathNameByHandleW(handle, buf, len(buf), 0)
147
+ if n == 0 or n >= len(buf):
148
+ raise oserror()
149
+ p = buf.value
150
+ return p[4:] if p.startswith("\\\\?\\") and not p.startswith("\\\\?\\UNC\\") else p
151
+
152
+
153
+ def is_remote_drive(path: str) -> bool:
154
+ p = os.path.abspath(path)
155
+ return p.startswith("\\\\") or GetDriveTypeW(os.path.splitdrive(p)[0] + "\\") == DRIVE_REMOTE
156
+
157
+
158
+ def stream_names(path: str) -> list[str]:
159
+ """Names of all data streams of a file or directory, e.g. ['::$DATA', ':Zone.Identifier:$DATA']."""
160
+ data = WIN32_FIND_STREAM_DATA()
161
+ h = FindFirstStreamW(ext(path), 0, ctypes.byref(data), 0)
162
+ if h is None or h == INVALID:
163
+ err = ctypes.get_last_error()
164
+ if err == ERROR_HANDLE_EOF:
165
+ return []
166
+ raise oserror(err)
167
+ try:
168
+ names = [data.name]
169
+ while True:
170
+ if FindNextStreamW(h, ctypes.byref(data)):
171
+ names.append(data.name)
172
+ continue
173
+ err = ctypes.get_last_error()
174
+ if err != ERROR_HANDLE_EOF: # enumeration ended on an error, not at the end: the list may be incomplete, so fail closed
175
+ raise oserror(err)
176
+ return names
177
+ finally:
178
+ FindClose(h)
179
+
180
+
181
+ def security_descriptor(handle: int) -> bytes:
182
+ """Owner + DACL of an open handle (needs READ_CONTROL), self-relative."""
183
+ needed = wt.DWORD(0)
184
+ GetKernelObjectSecurity(handle, 0x5, None, 0, ctypes.byref(needed))
185
+ for _ in range(3):
186
+ buf = ctypes.create_string_buffer(max(needed.value, 64))
187
+ if GetKernelObjectSecurity(handle, 0x5, buf, len(buf), ctypes.byref(needed)):
188
+ return buf.raw[:needed.value]
189
+ raise oserror()
190
+
191
+
192
+ def _sid_at(address: int) -> str:
193
+ count = ctypes.string_at(address + 1, 1)[0]
194
+ return acl.sid_to_str(ctypes.string_at(address, 8 + 4 * count))[0]
195
+
196
+
197
+ def _token_info(token: int, cls: int) -> ctypes.Array:
198
+ n = wt.DWORD(0)
199
+ GetTokenInformation(token, cls, None, 0, ctypes.byref(n))
200
+ buf = ctypes.create_string_buffer(n.value)
201
+ if not GetTokenInformation(token, cls, buf, n.value, ctypes.byref(n)):
202
+ raise oserror()
203
+ return buf
204
+
205
+
206
+ class _SidAndAttributes(ctypes.Structure):
207
+ _fields_ = [("sid", ctypes.c_void_p), ("attrs", wt.DWORD)]
208
+
209
+
210
+ def current_token() -> tuple[acl.Token, str, str]:
211
+ """(token with enabled / deny-only groups, user SID, default owner SID) of this process."""
212
+ tok = HANDLE()
213
+ if not OpenProcessToken(GetCurrentProcess(), 0x8, ctypes.byref(tok)): # TOKEN_QUERY
214
+ raise oserror()
215
+ try:
216
+ user = _sid_at(ctypes.cast(_token_info(tok, 1), ctypes.POINTER(ctypes.c_void_p))[0])
217
+ owner = _sid_at(ctypes.cast(_token_info(tok, 4), ctypes.POINTER(ctypes.c_void_p))[0])
218
+ buf = _token_info(tok, 2)
219
+ count = ctypes.cast(buf, ctypes.POINTER(wt.DWORD))[0]
220
+ first = ctypes.addressof(buf) + ctypes.sizeof(ctypes.c_void_p) # groups start after the count, padded to pointer size
221
+ groups = (_SidAndAttributes * count).from_address(first)
222
+ enabled = {_sid_at(g.sid) for g in groups if g.attrs & 0x4}
223
+ deny_only = {_sid_at(g.sid) for g in groups if g.attrs & 0x10}
224
+ finally:
225
+ CloseHandle(tok)
226
+ return (acl.Token(frozenset(enabled | {user, acl.EVERYONE, acl.AUTHENTICATED_USERS}), frozenset(deny_only)), user, owner)
227
+
228
+
229
+ def restrict_to_user(path: str, user_sid: str) -> None:
230
+ """Replace the DACL of `path` with a protected one granting full control to `user_sid` and SYSTEM only."""
231
+ sddl = f"D:P(A;OICI;FA;;;{user_sid})(A;OICI;FA;;;SY)"
232
+ psd = ctypes.c_void_p()
233
+ if not ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(psd), None):
234
+ raise oserror()
235
+ try:
236
+ if not SetFileSecurityW(ext(path), 0x4, psd): # DACL_SECURITY_INFORMATION
237
+ raise oserror()
238
+ finally:
239
+ LocalFree(psd)
240
+
241
+
242
+ def create_exclusive_lock(path: str) -> int:
243
+ """Create `path` held with share mode 0: no one else can open it while this fd lives; the OS releases it if we die. -> C fd"""
244
+ h = open_handle(path, GENERIC_READ | GENERIC_WRITE, 0, CREATE_NEW, ATTR_NORMAL)
245
+ try:
246
+ return msvcrt.open_osfhandle(h, os.O_RDWR | os.O_NOINHERIT)
247
+ except BaseException:
248
+ CloseHandle(h)
249
+ raise
250
+
251
+
252
+ def lock_state(path: str) -> str:
253
+ """'busy' (a live owner holds it), 'free' (no holder) or 'absent'."""
254
+ try:
255
+ h = open_handle(path, GENERIC_READ, 0)
256
+ except OSError as e:
257
+ code = getattr(e, "winerror", None)
258
+ if code == ERROR_SHARING_VIOLATION:
259
+ return "busy"
260
+ if code == ERROR_FILE_NOT_FOUND or isinstance(e, FileNotFoundError):
261
+ return "absent"
262
+ raise
263
+ CloseHandle(h)
264
+ return "free"
265
+
266
+
267
+ def enable_ansi(stream_fd: int) -> bool:
268
+ """Turn on ANSI escape processing for the console behind std handle -11 (stdout) / -12 (stderr). False if not a console."""
269
+ h = GetStdHandle(wt.DWORD(stream_fd))
270
+ mode = wt.DWORD()
271
+ if not h or h == INVALID or not GetConsoleMode(h, ctypes.byref(mode)):
272
+ return False
273
+ return bool(SetConsoleMode(h, mode.value | 0x0004)) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
274
+
275
+
276
+ # ---- job objects -------------------------------------------------------------------------------------------------
277
+ class _BasicLimit(ctypes.Structure):
278
+ _fields_ = [("per_process_time", ctypes.c_int64), ("per_job_time", ctypes.c_int64), ("flags", wt.DWORD),
279
+ ("min_ws", ctypes.c_size_t), ("max_ws", ctypes.c_size_t), ("active", wt.DWORD), ("affinity", ctypes.c_size_t),
280
+ ("priority", wt.DWORD), ("sched", wt.DWORD)]
281
+
282
+
283
+ class _IoCounters(ctypes.Structure):
284
+ _fields_ = [(n, ctypes.c_uint64) for n in ("ro", "wo", "oo", "rb", "wb", "ob")]
285
+
286
+
287
+ class _ExtendedLimit(ctypes.Structure):
288
+ _fields_ = [("basic", _BasicLimit), ("io", _IoCounters), ("proc_mem", ctypes.c_size_t), ("job_mem", ctypes.c_size_t),
289
+ ("peak_proc", ctypes.c_size_t), ("peak_job", ctypes.c_size_t)]
290
+
291
+
292
+ def create_kill_on_close_job() -> int:
293
+ job = CreateJobObjectW(None, None)
294
+ if not job:
295
+ raise oserror()
296
+ info = _ExtendedLimit()
297
+ info.basic.flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
298
+ if not SetInformationJobObject(job, 9, ctypes.byref(info), ctypes.sizeof(info)):
299
+ err = oserror()
300
+ CloseHandle(job)
301
+ raise err
302
+ return job
303
+
304
+
305
+ def assign_to_job(job: int, process_handle: int) -> None:
306
+ if not AssignProcessToJobObject(job, process_handle):
307
+ raise oserror()
308
+
309
+
310
+ def resume_process(process_handle: int) -> None:
311
+ if NtResumeProcess(process_handle) < 0:
312
+ raise OSError("could not resume the suspended process")
313
+
314
+
315
+ def terminate_job(job: int) -> None:
316
+ TerminateJobObject(job, 1)
saferun/hostos/acl.py ADDED
@@ -0,0 +1,172 @@
1
+ """Windows security descriptors and effective-access evaluation, in pure Python (no Windows API), so the logic is unit-testable everywhere.
2
+
3
+ The Windows side (`_win.py`) only fetches the raw self-relative descriptor of an open handle and the caller's token; everything that
4
+ decides something lives here and FAILS CLOSED: a descriptor or ACE this module does not fully understand raises `AclUnverifiable`,
5
+ and callers treat that as "private / not trusted".
6
+
7
+ Access evaluation follows the Windows access-check algorithm for a DACL: ACEs are evaluated in stored order (the stored DACL already has
8
+ inherited ACEs in effective position); INHERIT_ONLY ACEs are ignored; an ACE applies when its SID is in the token; a deny ACE removes only
9
+ rights not yet granted by an earlier ACE; a NULL DACL grants everyone everything and an empty DACL grants nothing.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ from dataclasses import dataclass, field
15
+
16
+ EVERYONE = "S-1-1-0"
17
+ AUTHENTICATED_USERS = "S-1-5-11"
18
+ SYSTEM = "S-1-5-18"
19
+ ADMINISTRATORS = "S-1-5-32-544"
20
+ USERS = "S-1-5-32-545"
21
+ OWNER_RIGHTS = "S-1-3-4" # an ACE for it applies to the object's owner only (whoever that is)
22
+
23
+ FILE_READ_DATA = FILE_LIST_DIRECTORY = 0x0001
24
+ FILE_TRAVERSE = 0x0020
25
+ READ_FILE = FILE_READ_DATA
26
+ READ_DIRECTORY = FILE_LIST_DIRECTORY | FILE_TRAVERSE # the Windows counterpart of the POSIX "other r-x" test for directories
27
+ _GENERIC = ((0x80000000, 0x120089), (0x40000000, 0x120116), (0x20000000, 0x1200A0), (0x10000000, 0x1F01FF)) # read, write, execute, all
28
+ _ACCESS_ALLOWED, _ACCESS_DENIED = 0, 1
29
+ _INHERIT_ONLY = 0x08
30
+ SE_DACL_PRESENT, SE_DACL_PROTECTED = 0x0004, 0x1000
31
+
32
+
33
+ class AclUnverifiable(ValueError):
34
+ """The security descriptor cannot be fully understood, so no conclusion may be drawn from it."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Ace:
39
+ allow: bool
40
+ mask: int
41
+ sid: str
42
+ inherit_only: bool = False
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Descriptor:
47
+ owner: str | None
48
+ dacl: tuple[Ace, ...] | None # None: no DACL at all (everyone has full access)
49
+ protected: bool = False # inheritance from the parent is blocked
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Token:
54
+ """The SIDs of a (possibly hypothetical) security token. `deny_only` SIDs match deny ACEs only, as in a real filtered token."""
55
+ sids: frozenset[str]
56
+ deny_only: frozenset[str] = field(default_factory=frozenset)
57
+
58
+
59
+ def sid_to_str(data: bytes, offset: int = 0) -> tuple[str, int]:
60
+ """(`S-1-...`, length in bytes) of the binary SID at `offset`."""
61
+ try:
62
+ revision, count = data[offset], data[offset + 1]
63
+ if revision != 1 or count > 15:
64
+ raise AclUnverifiable("unsupported SID revision or size")
65
+ authority = int.from_bytes(data[offset + 2: offset + 8], "big")
66
+ subs = struct.unpack_from(f"<{count}I", data, offset + 8)
67
+ except (IndexError, struct.error):
68
+ raise AclUnverifiable("truncated SID") from None
69
+ return "S-1-" + "-".join(str(x) for x in (authority, *subs)), 8 + 4 * count
70
+
71
+
72
+ def _expand(mask: int) -> int:
73
+ for generic, specific in _GENERIC:
74
+ if mask & generic:
75
+ mask = (mask & ~generic) | specific
76
+ return mask
77
+
78
+
79
+ def parse_descriptor(data: bytes) -> Descriptor:
80
+ """Parse a self-relative SECURITY_DESCRIPTOR. Raises AclUnverifiable for anything unexpected (conditional or object ACEs, bad offsets)."""
81
+ try:
82
+ revision, _sbz, control, off_owner, _off_group, _off_sacl, off_dacl = struct.unpack_from("<BBHIIII", data, 0)
83
+ except struct.error:
84
+ raise AclUnverifiable("security descriptor too short") from None
85
+ if revision != 1 or not control & 0x8000: # SE_SELF_RELATIVE
86
+ raise AclUnverifiable("not a self-relative security descriptor")
87
+ owner = sid_to_str(data, off_owner)[0] if off_owner else None
88
+ if not control & SE_DACL_PRESENT or not off_dacl:
89
+ return Descriptor(owner, None, bool(control & SE_DACL_PROTECTED))
90
+ try:
91
+ _rev, _sbz1, acl_size, count, _sbz2 = struct.unpack_from("<BBHHH", data, off_dacl)
92
+ except struct.error:
93
+ raise AclUnverifiable("truncated ACL") from None
94
+ if off_dacl + acl_size > len(data):
95
+ raise AclUnverifiable("ACL larger than the descriptor")
96
+ aces, pos = [], off_dacl + 8
97
+ for _ in range(count):
98
+ try:
99
+ kind, flags, size = struct.unpack_from("<BBH", data, pos)
100
+ mask, = struct.unpack_from("<I", data, pos + 4)
101
+ except struct.error:
102
+ raise AclUnverifiable("truncated ACE") from None
103
+ if kind not in (_ACCESS_ALLOWED, _ACCESS_DENIED) or size < 12 or pos + size > off_dacl + acl_size:
104
+ raise AclUnverifiable(f"unsupported or malformed ACE (type {kind})")
105
+ sid, sid_len = sid_to_str(data, pos + 8)
106
+ if 8 + sid_len > size:
107
+ raise AclUnverifiable("SID longer than its ACE")
108
+ aces.append(Ace(kind == _ACCESS_ALLOWED, _expand(mask), sid, bool(flags & _INHERIT_ONLY)))
109
+ pos += size
110
+ return Descriptor(owner, tuple(aces), bool(control & SE_DACL_PROTECTED))
111
+
112
+
113
+ def effective_access(dacl: tuple[Ace, ...] | None, token: Token) -> int:
114
+ """Rights `token` is granted by `dacl` (restricted to bits that appear in an ACE; callers test the bits they need)."""
115
+ if dacl is None:
116
+ return 0xFFFFFFFF
117
+ granted = denied = 0
118
+ for ace in dacl:
119
+ if ace.inherit_only:
120
+ continue
121
+ if ace.allow:
122
+ if ace.sid in token.sids:
123
+ granted |= ace.mask & ~denied
124
+ elif ace.sid in token.sids or ace.sid in token.deny_only:
125
+ denied |= ace.mask & ~granted
126
+ return granted
127
+
128
+
129
+ def can(dacl, token: Token, wanted: int) -> bool:
130
+ return effective_access(dacl, token) & wanted == wanted
131
+
132
+
133
+ def _principal_token(sid: str) -> Token:
134
+ """A hypothetical user holding `sid`, plus the groups every authenticated user is in."""
135
+ if sid == EVERYONE:
136
+ return Token(frozenset({EVERYONE}))
137
+ return Token(frozenset({sid, EVERYONE, AUTHENTICATED_USERS}))
138
+
139
+
140
+ def readers(dacl, wanted: int) -> list[Token]:
141
+ """Tokens that can read according to `dacl`: a hypothetical user for each SID with an allow ACE for the wanted rights."""
142
+ if dacl is None:
143
+ return [_principal_token(EVERYONE)]
144
+ out = []
145
+ for ace in dacl:
146
+ if ace.allow and not ace.inherit_only and ace.mask & wanted:
147
+ tok = _principal_token(ace.sid)
148
+ if can(dacl, tok, wanted):
149
+ out.append(tok)
150
+ return out
151
+
152
+
153
+ def at_least_as_readable(file_dacl, baseline_dacl, caller: Token, *, directory: bool) -> bool:
154
+ """True iff everyone who can read the project root (the caller, using the real token with its groups, and every principal the root's
155
+ DACL grants read to) can also read this entry. A file narrower than the tree around it is deliberately private: the Windows
156
+ counterpart of "not world-readable" on POSIX. Callers must treat AclUnverifiable (and any other failure) as private."""
157
+ wanted = READ_DIRECTORY if directory else READ_FILE
158
+ # The root itself is always evaluated with the directory rights, whatever the entry is.
159
+ for tok in (caller, *readers(baseline_dacl, READ_DIRECTORY)):
160
+ if can(baseline_dacl, tok, READ_DIRECTORY) and not can(file_dacl, tok, wanted):
161
+ return False
162
+ return True
163
+
164
+
165
+ def only_trusted_principals(desc: Descriptor, trusted: frozenset[str]) -> str | None:
166
+ """None if no principal outside `trusted` is granted anything; else the first offending SID. A missing DACL grants everyone access."""
167
+ if desc.dacl is None:
168
+ return EVERYONE
169
+ for ace in desc.dacl:
170
+ if ace.allow and not ace.inherit_only and ace.mask and ace.sid not in trusted:
171
+ return ace.sid
172
+ return None