gabriel / muse public
test_security_ownership.py python
268 lines 9.0 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago
1 """Security tests — repository ownership check (CVE-2022-24765 equivalent).
2
3 Verifies that find_repo_root() raises UntrustedRepositoryError when the
4 .muse/ directory is owned by a different UID, and that escape hatches
5 (MUSE_SAFE_DIRS env var, ~/.muse/config.toml safe_dirs) work correctly.
6 """
7
8 from __future__ import annotations
9
10 import os
11 import pathlib
12 from unittest.mock import patch
13
14 import pytest
15
16 from muse.core.errors import UntrustedRepositoryError
17 from muse.core.repo import find_repo_root, _check_repo_ownership
18 from muse.core.paths import muse_dir
19
20
21 # ---------------------------------------------------------------------------
22 # Helpers
23 # ---------------------------------------------------------------------------
24
25 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
26 """Create a minimal fake repo at tmp_path with a real .muse/ dir."""
27 dot_muse = muse_dir(tmp_path)
28 dot_muse.mkdir(parents=True)
29 return tmp_path
30
31
32 # ---------------------------------------------------------------------------
33 # Control 1: ownership check
34 # ---------------------------------------------------------------------------
35
36
37 def test_trusted_own_repo(tmp_path: pathlib.Path) -> None:
38 """Current user owns .muse/ → find_repo_root() succeeds."""
39 root = _make_repo(tmp_path)
40 # The tmp dir is created by pytest under the current user — ownership matches.
41 with patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False):
42 result = find_repo_root()
43 assert result is not None
44 assert result.resolve() == root.resolve()
45
46
47 def test_untrusted_other_uid(tmp_path: pathlib.Path) -> None:
48 """.muse/ owned by different UID → raises UntrustedRepositoryError."""
49 root = _make_repo(tmp_path)
50 current_uid = os.getuid()
51 other_uid = current_uid + 1
52
53 real_path_stat = pathlib.Path.stat
54
55 def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result:
56 result = real_path_stat(self, **kwargs)
57 if self.resolve() == muse_dir(root).resolve():
58 return os.stat_result((
59 result.st_mode, result.st_ino, result.st_dev,
60 result.st_nlink, other_uid, result.st_gid,
61 result.st_size, result.st_atime, result.st_mtime, result.st_ctime,
62 ))
63 return result
64
65 with (
66 patch.object(pathlib.Path, "stat", fake_path_stat),
67 patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False),
68 patch.dict(os.environ, {"MUSE_SAFE_DIRS": ""}, clear=False),
69 ):
70 with pytest.raises(UntrustedRepositoryError) as exc_info:
71 find_repo_root()
72
73 err = exc_info.value
74 assert err.owner_uid == other_uid
75 assert err.current_uid == current_uid
76 assert str(root) in str(err)
77
78
79 def test_root_bypasses_ownership(tmp_path: pathlib.Path) -> None:
80 """uid==0 → no ownership check performed."""
81 root = _make_repo(tmp_path)
82 # Simulate running as root.
83 with (
84 patch("os.getuid", return_value=0),
85 patch.dict(os.environ, {"MUSE_REPO_ROOT": str(root)}, clear=False),
86 ):
87 result = find_repo_root()
88 assert result is not None
89
90
91 def test_muse_safe_dirs_env(tmp_path: pathlib.Path) -> None:
92 """MUSE_SAFE_DIRS containing the path → bypasses ownership check."""
93 root = _make_repo(tmp_path)
94 current_uid = os.getuid()
95 other_uid = current_uid + 1
96
97 real_path_stat = pathlib.Path.stat
98
99 def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result:
100 result = real_path_stat(self, **kwargs)
101 if self.resolve() == muse_dir(root).resolve():
102 return os.stat_result((
103 result.st_mode, result.st_ino, result.st_dev,
104 result.st_nlink, other_uid, result.st_gid,
105 result.st_size, result.st_atime, result.st_mtime, result.st_ctime,
106 ))
107 return result
108
109 with (
110 patch.object(pathlib.Path, "stat", fake_path_stat),
111 patch.dict(os.environ, {
112 "MUSE_REPO_ROOT": str(root),
113 "MUSE_SAFE_DIRS": str(root.resolve()),
114 }, clear=False),
115 ):
116 result = find_repo_root()
117
118 assert result is not None
119
120
121 def test_muse_safe_dirs_multi(tmp_path: pathlib.Path) -> None:
122 """Multiple paths in MUSE_SAFE_DIRS (colon-separated) → correct path is trusted."""
123 root = _make_repo(tmp_path)
124 current_uid = os.getuid()
125 other_uid = current_uid + 1
126
127 real_path_stat = pathlib.Path.stat
128
129 def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result:
130 result = real_path_stat(self, **kwargs)
131 if self.resolve() == muse_dir(root).resolve():
132 return os.stat_result((
133 result.st_mode, result.st_ino, result.st_dev,
134 result.st_nlink, other_uid, result.st_gid,
135 result.st_size, result.st_atime, result.st_mtime, result.st_ctime,
136 ))
137 return result
138
139 multi_dirs = f"/tmp/something_else:{root.resolve()}:/tmp/another"
140 with (
141 patch.object(pathlib.Path, "stat", fake_path_stat),
142 patch.dict(os.environ, {
143 "MUSE_REPO_ROOT": str(root),
144 "MUSE_SAFE_DIRS": multi_dirs,
145 }, clear=False),
146 ):
147 result = find_repo_root()
148
149 assert result is not None
150
151
152 def test_trust_add_cli(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
153 """muse trust add /tmp/myrepo → appears in muse trust list output."""
154 import subprocess
155 import sys
156
157 fake_config = tmp_path / "config.toml"
158
159 monkeypatch.setattr(
160 "muse.cli.config._GLOBAL_CONFIG_FILE",
161 fake_config,
162 )
163
164 from muse.cli.config import add_global_safe_dir, get_global_safe_dirs
165 add_global_safe_dir("/tmp/myrepo")
166 dirs = get_global_safe_dirs()
167 assert "/tmp/myrepo" in dirs
168
169
170 def test_trust_remove_cli(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
171 """add then remove → no longer in list."""
172 fake_config = tmp_path / "config.toml"
173
174 monkeypatch.setattr(
175 "muse.cli.config._GLOBAL_CONFIG_FILE",
176 fake_config,
177 )
178
179 from muse.cli.config import add_global_safe_dir, remove_global_safe_dir, get_global_safe_dirs
180 add_global_safe_dir("/tmp/testrepo")
181 assert "/tmp/testrepo" in get_global_safe_dirs()
182 remove_global_safe_dir("/tmp/testrepo")
183 assert "/tmp/testrepo" not in get_global_safe_dirs()
184
185
186 def test_trust_list_empty(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
187 """No trusted dirs → get_global_safe_dirs() returns []."""
188 fake_config = tmp_path / "config.toml"
189
190 monkeypatch.setattr(
191 "muse.cli.config._GLOBAL_CONFIG_FILE",
192 fake_config,
193 )
194
195 from muse.cli.config import get_global_safe_dirs
196 dirs = get_global_safe_dirs()
197 assert dirs == []
198
199
200 def test_global_config_safe_dirs(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
201 """safe_dirs in ~/.muse/config.toml → bypasses ownership check."""
202 root = _make_repo(tmp_path)
203 current_uid = os.getuid()
204 other_uid = current_uid + 1
205
206 fake_config = tmp_path / "global_config.toml"
207 monkeypatch.setattr(
208 "muse.cli.config._GLOBAL_CONFIG_FILE",
209 fake_config,
210 )
211
212 # Write a trust entry for the root.
213 from muse.cli.config import add_global_safe_dir
214 add_global_safe_dir(str(root.resolve()))
215
216 real_path_stat = pathlib.Path.stat
217
218 def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result:
219 result = real_path_stat(self, **kwargs)
220 if self.resolve() == muse_dir(root).resolve():
221 return os.stat_result((
222 result.st_mode, result.st_ino, result.st_dev,
223 result.st_nlink, other_uid, result.st_gid,
224 result.st_size, result.st_atime, result.st_mtime, result.st_ctime,
225 ))
226 return result
227
228 with (
229 patch.object(pathlib.Path, "stat", fake_path_stat),
230 patch.dict(os.environ, {
231 "MUSE_REPO_ROOT": str(root),
232 "MUSE_SAFE_DIRS": "",
233 }, clear=False),
234 ):
235 result = find_repo_root()
236
237 assert result is not None
238
239
240 def test_error_message_contains_fix_command(tmp_path: pathlib.Path) -> None:
241 """Error message includes 'muse trust add'."""
242 root = _make_repo(tmp_path)
243 current_uid = os.getuid()
244 other_uid = current_uid + 1
245
246 real_path_stat = pathlib.Path.stat
247
248 def fake_path_stat(self: pathlib.Path, **kwargs: int) -> os.stat_result:
249 result = real_path_stat(self, **kwargs)
250 if self.resolve() == muse_dir(root).resolve():
251 return os.stat_result((
252 result.st_mode, result.st_ino, result.st_dev,
253 result.st_nlink, other_uid, result.st_gid,
254 result.st_size, result.st_atime, result.st_mtime, result.st_ctime,
255 ))
256 return result
257
258 with (
259 patch.object(pathlib.Path, "stat", fake_path_stat),
260 patch.dict(os.environ, {
261 "MUSE_REPO_ROOT": str(root),
262 "MUSE_SAFE_DIRS": "",
263 }, clear=False),
264 ):
265 with pytest.raises(UntrustedRepositoryError) as exc_info:
266 find_repo_root()
267
268 assert "muse trust add" in str(exc_info.value)
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago