arbiter-server 0.9.1.dev1__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.
- arbiter_server/__init__.py +1 -0
- arbiter_server/__main__.py +5 -0
- arbiter_server/app.py +44 -0
- arbiter_server/artifacts.py +344 -0
- arbiter_server/cli_errors.py +31 -0
- arbiter_server/config.py +231 -0
- arbiter_server/deploy/docker/arbiter-docker +4477 -0
- arbiter_server/deploy/docker/compose.yaml +101 -0
- arbiter_server/file_protection/__init__.py +20 -0
- arbiter_server/file_protection/posix.py +70 -0
- arbiter_server/file_protection/windows.py +379 -0
- arbiter_server/main.py +2843 -0
- arbiter_server/plugins/__init__.py +36 -0
- arbiter_server/py.typed +1 -0
- arbiter_server/services.py +706 -0
- arbiter_server/storage.py +60 -0
- arbiter_server/version.py +135 -0
- arbiter_server-0.9.1.dev1.dist-info/METADATA +26 -0
- arbiter_server-0.9.1.dev1.dist-info/RECORD +22 -0
- arbiter_server-0.9.1.dev1.dist-info/WHEEL +5 -0
- arbiter_server-0.9.1.dev1.dist-info/entry_points.txt +2 -0
- arbiter_server-0.9.1.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
services:
|
|
2
|
+
arbiter:
|
|
3
|
+
image: ${ARBITER_IMAGE:-python:3.11-slim}
|
|
4
|
+
container_name: ${ARBITER_CONTAINER_NAME:-arbiter-staging}
|
|
5
|
+
user: ${ARBITER_CONTAINER_USER:-10001:10001}
|
|
6
|
+
restart: ${ARBITER_RESTART:-unless-stopped}
|
|
7
|
+
env_file:
|
|
8
|
+
- ${ARBITER_APP_ENV_FILE:-./conf/.env}
|
|
9
|
+
environment:
|
|
10
|
+
# Bind to all container interfaces so Docker's host-loopback port publish can
|
|
11
|
+
# reach the service. The service stays host-local because ports publish
|
|
12
|
+
# only on 127.0.0.1.
|
|
13
|
+
ARBITER_SERVER_HOST: 0.0.0.0
|
|
14
|
+
ARBITER_HOST_BIND: ${ARBITER_HOST_BIND:-127.0.0.1}
|
|
15
|
+
ARBITER_HOST_PORT: ${ARBITER_HOST_PORT:-18025}
|
|
16
|
+
ARBITER_CONTAINER_PORT: ${ARBITER_CONTAINER_PORT:-8025}
|
|
17
|
+
ARBITER_PUBLIC_SCHEME: ${ARBITER_PUBLIC_SCHEME:-http}
|
|
18
|
+
ARBITER_PUBLIC_BASE_URL: ${ARBITER_PUBLIC_BASE_URL:-}
|
|
19
|
+
ARBITER_CONFIG_NAME: ${ARBITER_CONFIG_NAME:-arbiter-server}
|
|
20
|
+
ARBITER_RUNTIME_VENV: ${ARBITER_RUNTIME_VENV:-/tmp/arbiter-venv}
|
|
21
|
+
HOME: ${ARBITER_CONTAINER_HOME:-/tmp/arbiter-home}
|
|
22
|
+
volumes:
|
|
23
|
+
- ${ARBITER_CONFIG_DIR:-./conf}:/config:ro
|
|
24
|
+
- ${ARBITER_REQUIREMENTS_FILE:-./requirements.txt}:/requirements.txt:ro
|
|
25
|
+
- ${ARBITER_WHEELS_DIR:-./wheels}:/wheels:ro
|
|
26
|
+
- ${ARBITER_PLUGIN_DATA_DIR:-./data/plugins}:/data/plugins
|
|
27
|
+
extra_hosts:
|
|
28
|
+
- "host.docker.internal:host-gateway"
|
|
29
|
+
ports:
|
|
30
|
+
- "${ARBITER_HOST_BIND:-127.0.0.1}:${ARBITER_HOST_PORT:-18025}:${ARBITER_CONTAINER_PORT:-8025}"
|
|
31
|
+
networks:
|
|
32
|
+
- arbiter
|
|
33
|
+
command: >
|
|
34
|
+
sh -lc
|
|
35
|
+
'runtime_venv="$${ARBITER_RUNTIME_VENV:-/tmp/arbiter-venv}" &&
|
|
36
|
+
case "$$runtime_venv" in /tmp/arbiter-*) ;; *) echo "error: ARBITER_RUNTIME_VENV must be an Arbiter path under /tmp" >&2; exit 1;; esac &&
|
|
37
|
+
case "$$runtime_venv" in *..*) echo "error: ARBITER_RUNTIME_VENV must not contain .." >&2; exit 1;; esac &&
|
|
38
|
+
case "$$HOME" in /tmp/arbiter-*) ;; *) echo "error: HOME must be an Arbiter path under /tmp" >&2; exit 1;; esac &&
|
|
39
|
+
case "$$HOME" in *..*) echo "error: HOME must not contain .." >&2; exit 1;; esac &&
|
|
40
|
+
rm -rf "$$runtime_venv" &&
|
|
41
|
+
mkdir -p "$$HOME" &&
|
|
42
|
+
python -m venv "$$runtime_venv" &&
|
|
43
|
+
venv_python="$$runtime_venv/bin/python" &&
|
|
44
|
+
if grep -Eq "^[[:space:]]*/source/arbiter(/|$)" /requirements.txt; then
|
|
45
|
+
cp /requirements.txt /tmp/requirements.txt &&
|
|
46
|
+
awk "!/^[[:space:]]*(#|$)/ && !/^[[:space:]]*\\/source\\/arbiter(\\/|$)/ { print }" /tmp/requirements.txt > /tmp/requirements.pinned &&
|
|
47
|
+
awk "/^[[:space:]]*\\/source\\/arbiter(\\/|$)/ { sub(/^[[:space:]]*/, \"\"); sub(/[[:space:]]*$/, \"\"); print }" /tmp/requirements.txt > /tmp/requirements.editable &&
|
|
48
|
+
if [ -s /tmp/requirements.pinned ]; then
|
|
49
|
+
if [ -d /wheels ] && find /wheels -maxdepth 1 -name "*.whl" -print -quit | grep -q .; then
|
|
50
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir --no-index --find-links /wheels -r /tmp/requirements.pinned;
|
|
51
|
+
else
|
|
52
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir -r /tmp/requirements.pinned;
|
|
53
|
+
fi;
|
|
54
|
+
fi &&
|
|
55
|
+
rm -rf /tmp/arbiter-source &&
|
|
56
|
+
mkdir -p /tmp/arbiter-source &&
|
|
57
|
+
while IFS= read -r requirement; do
|
|
58
|
+
source_suffix="$${requirement#/source/arbiter}" &&
|
|
59
|
+
local_requirement="/tmp/arbiter-source$$source_suffix" &&
|
|
60
|
+
if [ -z "$$source_suffix" ]; then
|
|
61
|
+
cp -a /source/arbiter/. /tmp/arbiter-source || exit 1;
|
|
62
|
+
else
|
|
63
|
+
mkdir -p "$${local_requirement%/*}" &&
|
|
64
|
+
cp -a "$$requirement" "$$local_requirement" || exit 1;
|
|
65
|
+
fi;
|
|
66
|
+
done < /tmp/requirements.editable &&
|
|
67
|
+
find /tmp/arbiter-source -type d \( -name "*.egg-info" -o -name "__pycache__" \) -prune -exec rm -rf {} + &&
|
|
68
|
+
while IFS= read -r requirement; do
|
|
69
|
+
source_suffix="$${requirement#/source/arbiter}" &&
|
|
70
|
+
local_requirement="/tmp/arbiter-source$$source_suffix" &&
|
|
71
|
+
if [ -d /wheels ] && find /wheels -maxdepth 1 -name "*.whl" -print -quit | grep -q .; then
|
|
72
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir --find-links /wheels -e "$$local_requirement" || exit 1;
|
|
73
|
+
else
|
|
74
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir -e "$$local_requirement" || exit 1;
|
|
75
|
+
fi;
|
|
76
|
+
done < /tmp/requirements.editable;
|
|
77
|
+
elif [ -d /wheels ] && find /wheels -maxdepth 1 -name "*.whl" -print -quit | grep -q .; then
|
|
78
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir --no-index --find-links /wheels -r /requirements.txt;
|
|
79
|
+
else
|
|
80
|
+
"$$venv_python" -m pip --disable-pip-version-check install --no-cache-dir -r /requirements.txt;
|
|
81
|
+
fi &&
|
|
82
|
+
public_host="$${ARBITER_HOST_BIND:-127.0.0.1}" &&
|
|
83
|
+
case "$$public_host" in 0.0.0.0) public_host=127.0.0.1;; *:*) public_host="[$$public_host]";; esac &&
|
|
84
|
+
set -- "arbiter.server.bind.host=$$ARBITER_SERVER_HOST" "arbiter.server.bind.port=$$ARBITER_CONTAINER_PORT" "arbiter.server.public.scheme=$${ARBITER_PUBLIC_SCHEME:-http}" "arbiter.server.public.host=$$public_host" "arbiter.server.public.port=$${ARBITER_HOST_PORT:-18025}" "arbiter.storage.plugin_data_dir=/data/plugins" "arbiter.deployment_scope=staged" &&
|
|
85
|
+
if [ -n "$${ARBITER_PUBLIC_BASE_URL:-}" ]; then set -- "$$@" "arbiter.server.public.base_url=$$ARBITER_PUBLIC_BASE_URL"; fi &&
|
|
86
|
+
if ! "$$runtime_venv/bin/arbiter-server" --config-dir /config --config-name "$$ARBITER_CONFIG_NAME" config check "$$@"; then
|
|
87
|
+
echo "fatal configuration error; stopping container without Docker restart" >&2;
|
|
88
|
+
exit 0;
|
|
89
|
+
fi &&
|
|
90
|
+
exec "$$runtime_venv/bin/arbiter-server" --config-dir /config --config-name "$$ARBITER_CONFIG_NAME" serve "$$@"'
|
|
91
|
+
|
|
92
|
+
networks:
|
|
93
|
+
arbiter:
|
|
94
|
+
name: ${ARBITER_DOCKER_NETWORK_NAME:-arbiter-staging}
|
|
95
|
+
driver: bridge
|
|
96
|
+
driver_opts:
|
|
97
|
+
com.docker.network.bridge.name: "${ARBITER_DOCKER_BRIDGE_NAME:-arbiter-stg0}"
|
|
98
|
+
ipam:
|
|
99
|
+
config:
|
|
100
|
+
# Override only if the default subnet conflicts with another host network.
|
|
101
|
+
- subnet: "${ARBITER_DOCKER_SUBNET:-172.31.251.0/24}"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ensure_runtime_config_permissions(
|
|
8
|
+
*,
|
|
9
|
+
config_dir: Path,
|
|
10
|
+
env_file: Path | None,
|
|
11
|
+
) -> None:
|
|
12
|
+
if os.name == "nt":
|
|
13
|
+
from .windows import ensure_runtime_config_permissions as ensure_windows
|
|
14
|
+
|
|
15
|
+
ensure_windows(config_dir=config_dir, env_file=env_file)
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
from .posix import ensure_runtime_config_permissions as ensure_posix
|
|
19
|
+
|
|
20
|
+
ensure_posix(config_dir=config_dir, env_file=env_file)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _other_read_write_bits(mode: int) -> int:
|
|
8
|
+
return mode & (stat.S_IROTH | stat.S_IWOTH)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _group_or_other_write_bits(mode: int) -> int:
|
|
12
|
+
return mode & (stat.S_IWGRP | stat.S_IWOTH)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _group_or_other_read_write_bits(mode: int) -> int:
|
|
16
|
+
return mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _directory_group_or_other_write_execute_bits(mode: int) -> int:
|
|
20
|
+
group_bits = mode & stat.S_IWGRP if mode & stat.S_IXGRP else 0
|
|
21
|
+
other_bits = mode & stat.S_IWOTH if mode & stat.S_IXOTH else 0
|
|
22
|
+
return group_bits | other_bits
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_runtime_config_permissions(
|
|
26
|
+
*,
|
|
27
|
+
config_dir: Path,
|
|
28
|
+
env_file: Path | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
for directory in sorted(
|
|
31
|
+
{config_dir, *(path.parent for path in config_dir.rglob("*.yaml"))}
|
|
32
|
+
):
|
|
33
|
+
if not directory.is_dir():
|
|
34
|
+
continue
|
|
35
|
+
if _directory_group_or_other_write_execute_bits(directory.stat().st_mode):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"unsafe config directory permissions: "
|
|
38
|
+
f"{directory} must not be writable by group or others; "
|
|
39
|
+
f"run `chmod go-w {directory}`"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for config_file in sorted(config_dir.rglob("*.yaml")):
|
|
43
|
+
if not config_file.is_file():
|
|
44
|
+
continue
|
|
45
|
+
if _other_read_write_bits(
|
|
46
|
+
config_file.stat().st_mode
|
|
47
|
+
) or _group_or_other_write_bits(config_file.stat().st_mode):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"unsafe config file permissions: "
|
|
50
|
+
f"{config_file} must not be writable by group or others, "
|
|
51
|
+
"or readable by others; "
|
|
52
|
+
f"run `chmod g-w,o-rw {config_file}`"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if env_file is None or not env_file.exists():
|
|
56
|
+
return
|
|
57
|
+
if env_file.parent.exists() and _directory_group_or_other_write_execute_bits(
|
|
58
|
+
env_file.parent.stat().st_mode
|
|
59
|
+
):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"unsafe app env directory permissions: "
|
|
62
|
+
f"{env_file.parent} must not be writable by group or others; "
|
|
63
|
+
f"run `chmod go-w {env_file.parent}`"
|
|
64
|
+
)
|
|
65
|
+
if _group_or_other_read_write_bits(env_file.stat().st_mode):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"unsafe app env file permissions: "
|
|
68
|
+
f"{env_file} must not be readable or writable by group or others; "
|
|
69
|
+
f"run `chmod 600 {env_file}`"
|
|
70
|
+
)
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_WINDOWS_PRINCIPAL_NAMES = {
|
|
11
|
+
"S-1-1-0": "Everyone",
|
|
12
|
+
"S-1-3-4": "Owner Rights",
|
|
13
|
+
"S-1-5-4": "Interactive Users",
|
|
14
|
+
"S-1-5-11": "Authenticated Users",
|
|
15
|
+
"S-1-5-18": "LocalSystem",
|
|
16
|
+
"S-1-5-32-544": "Builtin Administrators",
|
|
17
|
+
"S-1-5-32-545": "Builtin Users",
|
|
18
|
+
"S-1-5-32-546": "Builtin Guests",
|
|
19
|
+
}
|
|
20
|
+
_WINDOWS_ALLOWED_RUNTIME_PERMISSION_SIDS = {
|
|
21
|
+
"S-1-5-18", # LocalSystem
|
|
22
|
+
"S-1-5-32-544", # Builtin Administrators
|
|
23
|
+
}
|
|
24
|
+
_WINDOWS_DOMAIN_USER_RID_PATTERN = re.compile(r"^S-1-5-21-\d+-\d+-\d+-(513|514)$")
|
|
25
|
+
_WINDOWS_FILE_READ_WRITE_ACCESS_MASK = (
|
|
26
|
+
0x00000001 # FILE_READ_DATA
|
|
27
|
+
| 0x00000002 # FILE_WRITE_DATA
|
|
28
|
+
| 0x00000004 # FILE_APPEND_DATA
|
|
29
|
+
| 0x00000008 # FILE_READ_EA
|
|
30
|
+
| 0x00000010 # FILE_WRITE_EA
|
|
31
|
+
| 0x00000080 # FILE_READ_ATTRIBUTES
|
|
32
|
+
| 0x00000100 # FILE_WRITE_ATTRIBUTES
|
|
33
|
+
| 0x00010000 # DELETE
|
|
34
|
+
| 0x00020000 # READ_CONTROL
|
|
35
|
+
| 0x00040000 # WRITE_DAC
|
|
36
|
+
| 0x00080000 # WRITE_OWNER
|
|
37
|
+
| 0x10000000 # GENERIC_ALL
|
|
38
|
+
| 0x40000000 # GENERIC_WRITE
|
|
39
|
+
| 0x80000000 # GENERIC_READ
|
|
40
|
+
)
|
|
41
|
+
_WINDOWS_DIRECTORY_MUTATING_ACCESS_MASK = (
|
|
42
|
+
0x00000002 # FILE_ADD_FILE
|
|
43
|
+
| 0x00000004 # FILE_ADD_SUBDIRECTORY
|
|
44
|
+
| 0x00000040 # FILE_DELETE_CHILD
|
|
45
|
+
| 0x00010000 # DELETE
|
|
46
|
+
| 0x00040000 # WRITE_DAC
|
|
47
|
+
| 0x00080000 # WRITE_OWNER
|
|
48
|
+
| 0x10000000 # GENERIC_ALL
|
|
49
|
+
| 0x40000000 # GENERIC_WRITE
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _WindowsAccessAce:
|
|
55
|
+
sid: str
|
|
56
|
+
mask: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class _WindowsFileSecurity:
|
|
61
|
+
owner_sid: str | None
|
|
62
|
+
access_aces: tuple[_WindowsAccessAce, ...]
|
|
63
|
+
has_null_dacl: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _windows_principal_name(sid: str) -> str:
|
|
67
|
+
if sid in _WINDOWS_PRINCIPAL_NAMES:
|
|
68
|
+
return _WINDOWS_PRINCIPAL_NAMES[sid]
|
|
69
|
+
match = _WINDOWS_DOMAIN_USER_RID_PATTERN.fullmatch(sid)
|
|
70
|
+
if match is None:
|
|
71
|
+
return sid
|
|
72
|
+
rid = match.group(1)
|
|
73
|
+
if rid == "513":
|
|
74
|
+
return "Domain Users"
|
|
75
|
+
return "Domain Guests"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _windows_unallowed_access_reason(
|
|
79
|
+
access_aces: Sequence[_WindowsAccessAce],
|
|
80
|
+
*,
|
|
81
|
+
owner_sid: str | None,
|
|
82
|
+
access_mask: int,
|
|
83
|
+
) -> str | None:
|
|
84
|
+
allowed_sids = set(_WINDOWS_ALLOWED_RUNTIME_PERMISSION_SIDS)
|
|
85
|
+
if owner_sid is not None:
|
|
86
|
+
allowed_sids.add(owner_sid)
|
|
87
|
+
for ace in access_aces:
|
|
88
|
+
if not ace.mask & access_mask:
|
|
89
|
+
continue
|
|
90
|
+
if ace.sid in allowed_sids:
|
|
91
|
+
continue
|
|
92
|
+
principal_name = _windows_principal_name(ace.sid)
|
|
93
|
+
return f"{principal_name} ({ace.sid}) grants access outside the allowlist"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _windows_file_security(path: Path) -> _WindowsFileSecurity:
|
|
98
|
+
import ctypes
|
|
99
|
+
from ctypes import wintypes
|
|
100
|
+
|
|
101
|
+
if os.name != "nt":
|
|
102
|
+
raise OSError("Windows ACL inspection is only available on Windows")
|
|
103
|
+
|
|
104
|
+
class AclSizeInformation(ctypes.Structure):
|
|
105
|
+
_fields_ = [
|
|
106
|
+
("AceCount", wintypes.DWORD),
|
|
107
|
+
("AclBytesInUse", wintypes.DWORD),
|
|
108
|
+
("AclBytesFree", wintypes.DWORD),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
class AceHeader(ctypes.Structure):
|
|
112
|
+
_fields_ = [
|
|
113
|
+
("AceType", wintypes.BYTE),
|
|
114
|
+
("AceFlags", wintypes.BYTE),
|
|
115
|
+
("AceSize", wintypes.WORD),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
class AccessAllowedAce(ctypes.Structure):
|
|
119
|
+
_fields_ = [
|
|
120
|
+
("Header", AceHeader),
|
|
121
|
+
("Mask", wintypes.DWORD),
|
|
122
|
+
("SidStart", wintypes.DWORD),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
se_file_object = 1
|
|
126
|
+
dacl_security_information = 0x00000004
|
|
127
|
+
owner_security_information = 0x00000001
|
|
128
|
+
acl_size_information = 2
|
|
129
|
+
access_allowed_ace_type = 0
|
|
130
|
+
|
|
131
|
+
psid = ctypes.c_void_p
|
|
132
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
133
|
+
if win_dll is None:
|
|
134
|
+
raise OSError("ctypes.WinDLL is not available")
|
|
135
|
+
|
|
136
|
+
def last_windows_error(message: str) -> OSError:
|
|
137
|
+
get_last_error = getattr(ctypes, "get_last_error", lambda: 0)
|
|
138
|
+
return OSError(get_last_error(), message)
|
|
139
|
+
|
|
140
|
+
advapi32 = win_dll("advapi32", use_last_error=True)
|
|
141
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
142
|
+
|
|
143
|
+
get_named_security_info = advapi32.GetNamedSecurityInfoW
|
|
144
|
+
get_named_security_info.argtypes = [
|
|
145
|
+
wintypes.LPWSTR,
|
|
146
|
+
wintypes.DWORD,
|
|
147
|
+
wintypes.DWORD,
|
|
148
|
+
ctypes.POINTER(psid),
|
|
149
|
+
ctypes.POINTER(psid),
|
|
150
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
151
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
152
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
153
|
+
]
|
|
154
|
+
get_named_security_info.restype = wintypes.DWORD
|
|
155
|
+
|
|
156
|
+
get_acl_information = advapi32.GetAclInformation
|
|
157
|
+
get_acl_information.argtypes = [
|
|
158
|
+
ctypes.c_void_p,
|
|
159
|
+
ctypes.c_void_p,
|
|
160
|
+
wintypes.DWORD,
|
|
161
|
+
wintypes.DWORD,
|
|
162
|
+
]
|
|
163
|
+
get_acl_information.restype = wintypes.BOOL
|
|
164
|
+
|
|
165
|
+
get_ace = advapi32.GetAce
|
|
166
|
+
get_ace.argtypes = [
|
|
167
|
+
ctypes.c_void_p,
|
|
168
|
+
wintypes.DWORD,
|
|
169
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
170
|
+
]
|
|
171
|
+
get_ace.restype = wintypes.BOOL
|
|
172
|
+
|
|
173
|
+
convert_sid_to_string_sid = advapi32.ConvertSidToStringSidW
|
|
174
|
+
convert_sid_to_string_sid.argtypes = [
|
|
175
|
+
psid,
|
|
176
|
+
ctypes.POINTER(wintypes.LPWSTR),
|
|
177
|
+
]
|
|
178
|
+
convert_sid_to_string_sid.restype = wintypes.BOOL
|
|
179
|
+
|
|
180
|
+
local_free = kernel32.LocalFree
|
|
181
|
+
local_free.argtypes = [ctypes.c_void_p]
|
|
182
|
+
local_free.restype = ctypes.c_void_p
|
|
183
|
+
|
|
184
|
+
dacl = ctypes.c_void_p()
|
|
185
|
+
owner_sid = ctypes.c_void_p()
|
|
186
|
+
security_descriptor = ctypes.c_void_p()
|
|
187
|
+
result = get_named_security_info(
|
|
188
|
+
str(path),
|
|
189
|
+
se_file_object,
|
|
190
|
+
owner_security_information | dacl_security_information,
|
|
191
|
+
ctypes.byref(owner_sid),
|
|
192
|
+
None,
|
|
193
|
+
ctypes.byref(dacl),
|
|
194
|
+
None,
|
|
195
|
+
ctypes.byref(security_descriptor),
|
|
196
|
+
)
|
|
197
|
+
if result != 0:
|
|
198
|
+
raise OSError(result, f"GetNamedSecurityInfoW failed for {path}")
|
|
199
|
+
try:
|
|
200
|
+
owner_sid_string_value: str | None = None
|
|
201
|
+
if owner_sid:
|
|
202
|
+
owner_sid_string = wintypes.LPWSTR()
|
|
203
|
+
if not convert_sid_to_string_sid(owner_sid, ctypes.byref(owner_sid_string)):
|
|
204
|
+
raise last_windows_error("ConvertSidToStringSidW failed for owner")
|
|
205
|
+
try:
|
|
206
|
+
owner_sid_string_value = owner_sid_string.value
|
|
207
|
+
if owner_sid_string_value is None:
|
|
208
|
+
raise OSError("ConvertSidToStringSidW returned a null owner SID")
|
|
209
|
+
finally:
|
|
210
|
+
local_free(ctypes.cast(owner_sid_string, ctypes.c_void_p))
|
|
211
|
+
|
|
212
|
+
if not dacl:
|
|
213
|
+
return _WindowsFileSecurity(
|
|
214
|
+
owner_sid=owner_sid_string_value,
|
|
215
|
+
access_aces=(),
|
|
216
|
+
has_null_dacl=True,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
acl_info = AclSizeInformation()
|
|
220
|
+
if not get_acl_information(
|
|
221
|
+
dacl,
|
|
222
|
+
ctypes.byref(acl_info),
|
|
223
|
+
ctypes.sizeof(acl_info),
|
|
224
|
+
acl_size_information,
|
|
225
|
+
):
|
|
226
|
+
raise last_windows_error("GetAclInformation failed")
|
|
227
|
+
|
|
228
|
+
access_aces: list[_WindowsAccessAce] = []
|
|
229
|
+
for index in range(acl_info.AceCount):
|
|
230
|
+
ace_pointer = ctypes.c_void_p()
|
|
231
|
+
if not get_ace(dacl, index, ctypes.byref(ace_pointer)):
|
|
232
|
+
raise last_windows_error("GetAce failed")
|
|
233
|
+
if ace_pointer.value is None:
|
|
234
|
+
raise OSError("GetAce returned a null ACE pointer")
|
|
235
|
+
ace = ctypes.cast(
|
|
236
|
+
ace_pointer,
|
|
237
|
+
ctypes.POINTER(AccessAllowedAce),
|
|
238
|
+
).contents
|
|
239
|
+
if ace.Header.AceType != access_allowed_ace_type:
|
|
240
|
+
continue
|
|
241
|
+
sid_pointer = ctypes.c_void_p(
|
|
242
|
+
ace_pointer.value + AccessAllowedAce.SidStart.offset
|
|
243
|
+
)
|
|
244
|
+
sid_string = wintypes.LPWSTR()
|
|
245
|
+
if not convert_sid_to_string_sid(sid_pointer, ctypes.byref(sid_string)):
|
|
246
|
+
raise last_windows_error("ConvertSidToStringSidW failed")
|
|
247
|
+
try:
|
|
248
|
+
if sid_string.value is None:
|
|
249
|
+
raise OSError("ConvertSidToStringSidW returned a null SID")
|
|
250
|
+
access_aces.append(
|
|
251
|
+
_WindowsAccessAce(sid=sid_string.value, mask=int(ace.Mask))
|
|
252
|
+
)
|
|
253
|
+
finally:
|
|
254
|
+
local_free(ctypes.cast(sid_string, ctypes.c_void_p))
|
|
255
|
+
return _WindowsFileSecurity(
|
|
256
|
+
owner_sid=owner_sid_string_value,
|
|
257
|
+
access_aces=tuple(access_aces),
|
|
258
|
+
has_null_dacl=False,
|
|
259
|
+
)
|
|
260
|
+
finally:
|
|
261
|
+
if security_descriptor:
|
|
262
|
+
local_free(security_descriptor)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _windows_unallowed_permission_reason(path: Path, *, access_mask: int) -> str | None:
|
|
266
|
+
security = _windows_file_security(path)
|
|
267
|
+
if security.has_null_dacl:
|
|
268
|
+
return "file has a null DACL, which grants full access"
|
|
269
|
+
return _windows_unallowed_access_reason(
|
|
270
|
+
security.access_aces,
|
|
271
|
+
owner_sid=security.owner_sid,
|
|
272
|
+
access_mask=access_mask,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _windows_icacls_remediation(path: Path) -> str:
|
|
277
|
+
return (
|
|
278
|
+
"repair ACLs from an elevated Command Prompt (cmd.exe) on the Arbiter "
|
|
279
|
+
f'host by running `takeown /F "{path}"`, then '
|
|
280
|
+
f'`icacls "{path}" /inheritance:r '
|
|
281
|
+
'/grant:r "%USERDOMAIN%\\%USERNAME%:F" '
|
|
282
|
+
'/grant:r "*S-1-5-18:F" /grant:r "*S-1-5-32-544:F"`'
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def ensure_runtime_config_permissions(
|
|
287
|
+
*,
|
|
288
|
+
config_dir: Path,
|
|
289
|
+
env_file: Path | None,
|
|
290
|
+
) -> None:
|
|
291
|
+
for directory in sorted(
|
|
292
|
+
{config_dir, *(path.parent for path in config_dir.rglob("*.yaml"))}
|
|
293
|
+
):
|
|
294
|
+
if not directory.is_dir():
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
reason = _windows_unallowed_permission_reason(
|
|
298
|
+
directory,
|
|
299
|
+
access_mask=_WINDOWS_DIRECTORY_MUTATING_ACCESS_MASK,
|
|
300
|
+
)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"unsafe config directory permissions: "
|
|
304
|
+
f"could not verify Windows ACLs for {directory}; "
|
|
305
|
+
"refusing to use runtime config with unverified permissions. "
|
|
306
|
+
f"{_windows_icacls_remediation(directory)}"
|
|
307
|
+
) from exc
|
|
308
|
+
if reason is not None:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
"unsafe config directory permissions: "
|
|
311
|
+
f"{directory} must not grant mutation access outside the owner, "
|
|
312
|
+
f"SYSTEM, or Administrators ({reason}); "
|
|
313
|
+
f"{_windows_icacls_remediation(directory)}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
for config_file in sorted(config_dir.rglob("*.yaml")):
|
|
317
|
+
if not config_file.is_file():
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
reason = _windows_unallowed_permission_reason(
|
|
321
|
+
config_file,
|
|
322
|
+
access_mask=_WINDOWS_FILE_READ_WRITE_ACCESS_MASK,
|
|
323
|
+
)
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
raise ValueError(
|
|
326
|
+
"unsafe config file permissions: "
|
|
327
|
+
f"could not verify Windows ACLs for {config_file}; "
|
|
328
|
+
"refusing to use runtime config with unverified permissions. "
|
|
329
|
+
f"{_windows_icacls_remediation(config_file)}"
|
|
330
|
+
) from exc
|
|
331
|
+
if reason is not None:
|
|
332
|
+
raise ValueError(
|
|
333
|
+
"unsafe config file permissions: "
|
|
334
|
+
f"{config_file} must not grant read/write access outside the "
|
|
335
|
+
f"owner, SYSTEM, or Administrators ({reason}); "
|
|
336
|
+
f"{_windows_icacls_remediation(config_file)}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if env_file is None or not env_file.exists():
|
|
340
|
+
return
|
|
341
|
+
if env_file.parent.exists():
|
|
342
|
+
try:
|
|
343
|
+
reason = _windows_unallowed_permission_reason(
|
|
344
|
+
env_file.parent,
|
|
345
|
+
access_mask=_WINDOWS_DIRECTORY_MUTATING_ACCESS_MASK,
|
|
346
|
+
)
|
|
347
|
+
except OSError as exc:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"unsafe app env directory permissions: "
|
|
350
|
+
f"could not verify Windows ACLs for {env_file.parent}; "
|
|
351
|
+
"refusing to load runtime env file with unverified permissions. "
|
|
352
|
+
f"{_windows_icacls_remediation(env_file.parent)}"
|
|
353
|
+
) from exc
|
|
354
|
+
if reason is not None:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"unsafe app env directory permissions: "
|
|
357
|
+
f"{env_file.parent} must not grant mutation access outside "
|
|
358
|
+
f"owner, SYSTEM, or Administrators ({reason}); "
|
|
359
|
+
f"{_windows_icacls_remediation(env_file.parent)}"
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
reason = _windows_unallowed_permission_reason(
|
|
363
|
+
env_file,
|
|
364
|
+
access_mask=_WINDOWS_FILE_READ_WRITE_ACCESS_MASK,
|
|
365
|
+
)
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
"unsafe app env file permissions: "
|
|
369
|
+
f"could not verify Windows ACLs for {env_file}; "
|
|
370
|
+
"refusing to load runtime env file with unverified permissions. "
|
|
371
|
+
f"{_windows_icacls_remediation(env_file)}"
|
|
372
|
+
) from exc
|
|
373
|
+
if reason is not None:
|
|
374
|
+
raise ValueError(
|
|
375
|
+
"unsafe app env file permissions: "
|
|
376
|
+
f"{env_file} must not grant read/write access outside the "
|
|
377
|
+
f"owner, SYSTEM, or Administrators ({reason}); "
|
|
378
|
+
f"{_windows_icacls_remediation(env_file)}"
|
|
379
|
+
)
|