gabriel / muse public

test_bridge_security.py file-level

at sha256:1 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """Phase 5 — Security hardening tests for ``muse bridge``.
2
3 Verifies:
4 - SHA-1 validation (_validate_git_sha)
5 - Branch name validation (_validate_git_branch_name)
6 - _CatFile rejects non-hex input
7 - No shell=True in bridge.py source
8 - Commit message template uses .format() (not f-string with raw user input)
9 - AttributionMapper strips control characters from handles
10 """
11
12 from __future__ import annotations
13
14 import pathlib
15 import re
16
17 import pytest
18
19 from muse.core.paths import init_repo_dirs
20 from muse.core.types import long_id
21
22
23 # ===========================================================================
24 # _validate_git_sha
25 # ===========================================================================
26
27 class TestValidateGitSha:
28 """Unit tests for _validate_git_sha()."""
29
30 def _fn(self, sha: str) -> bool:
31 from muse.core.bridge.git_primitives import _validate_git_sha
32 return _validate_git_sha(sha)
33
34 def test_valid_40_char_hex_returns_true(self) -> None:
35 assert self._fn("a" * 40) is True
36
37 def test_valid_mixed_hex_returns_true(self) -> None:
38 assert self._fn(f"{'0123456789abcdef' * 2}{'0' * 8}") is True
39
40 def test_short_sha_returns_false(self) -> None:
41 assert self._fn("abc") is False
42
43 def test_empty_string_returns_false(self) -> None:
44 assert self._fn("") is False
45
46 def test_41_chars_returns_false(self) -> None:
47 assert self._fn("a" * 41) is False
48
49 def test_39_chars_returns_false(self) -> None:
50 assert self._fn("a" * 39) is False
51
52 def test_uppercase_hex_returns_false(self) -> None:
53 # git SHA-1 must be lowercase
54 assert self._fn("A" * 40) is False
55
56 def test_sha_with_prefix_returns_false(self) -> None:
57 assert self._fn(long_id("a" * 64)) is False
58
59 def test_sha_with_semicolon_returns_false(self) -> None:
60 assert self._fn(f"{'a' * 39};") is False
61
62 def test_sha_with_space_returns_false(self) -> None:
63 assert self._fn(f"{'a' * 39} ") is False
64
65 def test_sha_with_newline_returns_false(self) -> None:
66 assert self._fn(f"{'a' * 39}\n") is False
67
68
69 # ===========================================================================
70 # _validate_git_branch_name
71 # ===========================================================================
72
73 class TestValidateGitBranchName:
74 """Unit tests for _validate_git_branch_name()."""
75
76 def _fn(self, branch: str) -> bool:
77 from muse.core.bridge.git_primitives import _validate_git_branch_name
78 return _validate_git_branch_name(branch)
79
80 def test_simple_branch_returns_true(self) -> None:
81 assert self._fn("main") is True
82
83 def test_feature_branch_returns_true(self) -> None:
84 assert self._fn("feat/my-feature") is True
85
86 def test_empty_string_returns_false(self) -> None:
87 assert self._fn("") is False
88
89 def test_branch_name_rejects_semicolon(self) -> None:
90 """Semicolon is a shell command separator — must be rejected."""
91 assert self._fn("main;evil") is False
92
93 def test_branch_name_rejects_ampersand(self) -> None:
94 assert self._fn("main&evil") is False
95
96 def test_branch_name_rejects_pipe(self) -> None:
97 assert self._fn("main|evil") is False
98
99 def test_branch_name_rejects_backtick(self) -> None:
100 assert self._fn("main`cmd`") is False
101
102 def test_branch_name_rejects_dollar(self) -> None:
103 """$(...) command substitution must be rejected."""
104 assert self._fn("$(cmd)") is False
105
106 def test_branch_name_rejects_dollar_sign_alone(self) -> None:
107 assert self._fn("main$evil") is False
108
109 def test_branch_name_rejects_space(self) -> None:
110 assert self._fn("main evil") is False
111
112 def test_branch_name_rejects_null_byte(self) -> None:
113 assert self._fn("main\x00evil") is False
114
115 def test_branch_name_rejects_control_char(self) -> None:
116 assert self._fn("main\x1bevil") is False
117
118 def test_branch_name_rejects_open_paren(self) -> None:
119 assert self._fn("main(evil") is False
120
121 def test_branch_name_rejects_close_paren(self) -> None:
122 assert self._fn("main)evil") is False
123
124
125 # ===========================================================================
126 # _CatFile SHA validation
127 # ===========================================================================
128
129 class TestCatFileRejectsInvalidSha:
130 """_CatFile.read() raises ValueError on non-hex input."""
131
132 def test_catfile_rejects_invalid_sha(self, tmp_path: pathlib.Path) -> None:
133 """_CatFile raises ValueError on non-hex input without spawning a process."""
134 from unittest.mock import MagicMock, patch
135 from muse.core.bridge.git_primitives import _CatFile
136
137 # Patch Popen so we don't need a real git repo
138 mock_proc = MagicMock()
139 with patch("subprocess.Popen", return_value=mock_proc):
140 cat = _CatFile(tmp_path)
141 with pytest.raises(ValueError, match="Invalid git SHA-1"):
142 cat.read("not-a-sha")
143
144 def test_catfile_rejects_short_sha(self, tmp_path: pathlib.Path) -> None:
145 from unittest.mock import MagicMock, patch
146 from muse.core.bridge.git_primitives import _CatFile
147
148 mock_proc = MagicMock()
149 with patch("subprocess.Popen", return_value=mock_proc):
150 cat = _CatFile(tmp_path)
151 with pytest.raises(ValueError, match="Invalid git SHA-1"):
152 cat.read("abc123")
153
154 def test_catfile_rejects_sha_with_injection(self, tmp_path: pathlib.Path) -> None:
155 """Shell injection attempt is caught by SHA validation."""
156 from unittest.mock import MagicMock, patch
157 from muse.core.bridge.git_primitives import _CatFile
158
159 mock_proc = MagicMock()
160 with patch("subprocess.Popen", return_value=mock_proc):
161 cat = _CatFile(tmp_path)
162 with pytest.raises(ValueError, match="Invalid git SHA-1"):
163 cat.read(f"{'a' * 39};rm -rf /")
164
165 def test_catfile_accepts_valid_sha(self, tmp_path: pathlib.Path) -> None:
166 """_CatFile.read() passes SHA validation for a well-formed 40-char hex."""
167 from unittest.mock import MagicMock, patch
168 from muse.core.bridge.git_primitives import _CatFile
169
170 mock_proc = MagicMock()
171 # Simulate a "missing" response so read() returns early
172 mock_proc.stdin = MagicMock()
173 mock_proc.stdout = MagicMock()
174 mock_proc.stdout.readline.return_value = b"aaa missing\n"
175
176 with patch("subprocess.Popen", return_value=mock_proc):
177 cat = _CatFile(tmp_path)
178 # Should NOT raise — valid SHA clears validation
179 result = cat.read("a" * 40)
180 assert result == b""
181
182
183 # ===========================================================================
184 # No shell=True in bridge.py
185 # ===========================================================================
186
187 class TestNoShellTrueInSubprocess:
188 """Verify that bridge.py never uses shell=True in subprocess calls."""
189
190 def test_no_shell_true_in_subprocess(self) -> None:
191 """Inspect bridge.py source: 'shell=True' must not appear."""
192 bridge_path = pathlib.Path(__file__).parent.parent / "muse" / "cli" / "commands" / "bridge.py"
193 source = bridge_path.read_text(encoding="utf-8")
194 assert "shell=True" not in source, (
195 "bridge.py contains 'shell=True' — this is a security vulnerability. "
196 "All subprocess calls must use argument lists."
197 )
198
199
200 # ===========================================================================
201 # Commit message template uses .format()
202 # ===========================================================================
203
204 class TestCommitMessageTemplateUsesDotFormat:
205 """Verify commit message uses .format() for safe user-value substitution."""
206
207 def test_commit_message_template_uses_format(self) -> None:
208 """GitExporter.git_commit() uses template.format() not f-string with user input."""
209 exporter_path = pathlib.Path(__file__).parent.parent / "muse" / "core" / "bridge" / "exporter.py"
210 source = exporter_path.read_text(encoding="utf-8")
211 # The template call must be `message_template.format(` not an f-string
212 # that directly embeds user-controlled values.
213 assert "message_template.format(" in source, (
214 "GitExporter.git_commit() must use message_template.format() "
215 "for safe substitution of user-controlled values."
216 )
217
218 def test_commit_message_format_call_is_present(self) -> None:
219 """The .format() call in git_commit accepts commit_id, branch, etc."""
220 # Import the class and inspect the method
221 from muse.core.bridge.exporter import GitExporter
222 import inspect
223 src = inspect.getsource(GitExporter.git_commit)
224 assert ".format(" in src
225
226
227 # ===========================================================================
228 # AttributionMapper — control character rejection
229 # ===========================================================================
230
231 class TestAttributionMapControlCharsRejected:
232 """AttributionMapper strips control characters from handles."""
233
234 def test_attribution_map_control_chars_stripped(self, tmp_path: pathlib.Path) -> None:
235 """Handle with embedded \\x00 has control chars removed, not passed through."""
236 from muse.core.bridge.git_primitives import AttributionMapper
237
238 map_file = tmp_path / "attr.json"
239 # Write a map file where the handle contains a null byte
240 import json
241 map_file.write_text(
242 json.dumps({"[email protected]": "handle\x00injected"}),
243 encoding="utf-8",
244 )
245 mapper = AttributionMapper(map_file)
246 handle = mapper.get_handle("[email protected]")
247 # Null byte and other control chars must be stripped
248 assert "\x00" not in handle
249 assert "handle" in handle
250
251 def test_attribution_map_control_chars_rejected(self, tmp_path: pathlib.Path) -> None:
252 """Handle with \\x1b (ESC) escape code has the control char removed."""
253 from muse.core.bridge.git_primitives import AttributionMapper
254
255 import json
256 map_file = tmp_path / "attr.json"
257 map_file.write_text(
258 json.dumps({"[email protected]": "handle\x1bevil"}),
259 encoding="utf-8",
260 )
261 mapper = AttributionMapper(map_file)
262 handle = mapper.get_handle("[email protected]")
263 assert "\x1b" not in handle
264
265 def test_attribution_map_missing_email_gives_synthetic_handle(
266 self, tmp_path: pathlib.Path
267 ) -> None:
268 """Email not in the map yields a synthetic git-import/<hex8> handle."""
269 from muse.core.bridge.git_primitives import AttributionMapper
270
271 mapper = AttributionMapper(None)
272 handle = mapper.get_handle("[email protected]")
273 assert handle.startswith("git-import/")