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