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.
- saferun/__init__.py +3 -0
- saferun/__main__.py +5 -0
- saferun/cli.py +533 -0
- saferun/dockerdiag.py +65 -0
- saferun/doctor.py +159 -0
- saferun/engine/__init__.py +7 -0
- saferun/engine/analyzer.py +30 -0
- saferun/engine/context.py +27 -0
- saferun/engine/engine.py +100 -0
- saferun/engine/model.py +146 -0
- saferun/engine/node.py +145 -0
- saferun/engine/osv.py +291 -0
- saferun/engine/osvdb.py +367 -0
- saferun/engine/paths.py +41 -0
- saferun/engine/pysyntax.py +135 -0
- saferun/engine/rules/python.yml +103 -0
- saferun/engine/semgrep.py +173 -0
- saferun/engine/tools.py +167 -0
- saferun/engine/verdict.py +47 -0
- saferun/hostos/__init__.py +188 -0
- saferun/hostos/_win.py +316 -0
- saferun/hostos/acl.py +172 -0
- saferun/hostos/fsops.py +41 -0
- saferun/hostos/process.py +82 -0
- saferun/hostos/winfs.py +164 -0
- saferun/runcmd.py +139 -0
- saferun/sandbox/__init__.py +11 -0
- saferun/sandbox/analyzer.py +192 -0
- saferun/sandbox/attestation.py +110 -0
- saferun/sandbox/config.py +121 -0
- saferun/sandbox/controls.py +104 -0
- saferun/sandbox/copy.py +338 -0
- saferun/sandbox/copy_windows.py +281 -0
- saferun/sandbox/cosign_tool.py +134 -0
- saferun/sandbox/docker.py +167 -0
- saferun/sandbox/images.py +110 -0
- saferun/sandbox/profiles.py +40 -0
- saferun/sandbox/reaper.py +127 -0
- saferun/sandbox/runner.py +268 -0
- saferun/sandbox/secrets.py +91 -0
- saferun/sandbox/signature.py +117 -0
- saferun/sandbox/verify.py +75 -0
- saferun/sandbox/workspace.py +92 -0
- saferun/setup.py +559 -0
- saferun/ui.py +133 -0
- saferun_cli-0.0.2.dist-info/METADATA +136 -0
- saferun_cli-0.0.2.dist-info/RECORD +51 -0
- saferun_cli-0.0.2.dist-info/WHEEL +5 -0
- saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
- saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
- 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
|