gabriel / muse public
test_porcelain_security.py python
310 lines 13.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Security-focused regression tests for all porcelain hardening fixes.
2
3 These tests verify the specific security improvements made during the
4 porcelain hardening pass:
5
6 - ReDoS guard in content-grep (pattern length limit)
7 - Zip-slip prevention in archive and snapshot export
8 - validate_branch_name added to checkout and rebase
9 - sanitize_display applied to all user-sourced echoed strings
10 - Atomic shelf writes (no temp file corruption)
11 - Snapshot ID glob prefix sanitisation
12 """
13
14 from __future__ import annotations
15
16 import datetime
17 import hashlib
18 import json
19 import pathlib
20 import uuid
21
22 import pytest
23 from tests.cli_test_helper import CliRunner
24 from muse.core._types import long_id
25
26 cli = None # argparse migration — CliRunner ignores this arg
27
28 runner = CliRunner()
29
30
31 # ---------------------------------------------------------------------------
32 # Shared repo setup helper
33 # ---------------------------------------------------------------------------
34
35 def _env(root: pathlib.Path) -> Manifest:
36 return {"MUSE_REPO_ROOT": str(root)}
37
38
39 def _init_repo(tmp_path: pathlib.Path, domain: str = "code") -> tuple[pathlib.Path, str]:
40 muse_dir = tmp_path / ".muse"
41 muse_dir.mkdir()
42 repo_id = str(uuid.uuid4())
43 (muse_dir / "repo.json").write_text(json.dumps({
44 "repo_id": repo_id,
45 "domain": domain,
46 "default_branch": "main",
47 "created_at": "2025-01-01T00:00:00+00:00",
48 }), encoding="utf-8")
49 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (muse_dir / "refs" / "heads").mkdir(parents=True)
51 (muse_dir / "snapshots").mkdir()
52 (muse_dir / "commits").mkdir()
53 (muse_dir / "objects").mkdir()
54 return tmp_path, repo_id
55
56
57 def _make_commit(root: pathlib.Path, repo_id: str, message: str = "test") -> str:
58 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
59 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
60
61 ref_file = root / ".muse" / "refs" / "heads" / "main"
62 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
63 manifest: Manifest = {}
64 snap_id = compute_snapshot_id(manifest)
65 committed_at = datetime.datetime.now(datetime.timezone.utc)
66 commit_id = compute_commit_id(
67 parent_ids=[parent_id] if parent_id else [],
68 snapshot_id=snap_id, message=message,
69 committed_at_iso=committed_at.isoformat(),
70 )
71 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
72 write_commit(root, CommitRecord(
73 commit_id=commit_id, repo_id=repo_id, branch="main",
74 snapshot_id=snap_id, message=message, committed_at=committed_at,
75 parent_commit_id=parent_id,
76 ))
77 ref_file.parent.mkdir(parents=True, exist_ok=True)
78 ref_file.write_text(commit_id, encoding="utf-8")
79 return commit_id
80
81
82 # ---------------------------------------------------------------------------
83 # content-grep: ReDoS guard
84 # ---------------------------------------------------------------------------
85
86 class TestContentGrepSecurity:
87 def test_pattern_too_long_rejected(self, tmp_path: pathlib.Path) -> None:
88 root, repo_id = _init_repo(tmp_path)
89 _make_commit(root, repo_id)
90 long_pattern = "a" * 501 # > 500 char limit
91 result = runner.invoke(cli, ["content-grep", long_pattern], env=_env(root))
92 assert result.exit_code != 0
93 assert "too long" in result.output or "Pattern" in result.output
94
95 def test_pattern_exactly_500_chars_accepted(self, tmp_path: pathlib.Path) -> None:
96 root, repo_id = _init_repo(tmp_path)
97 _make_commit(root, repo_id)
98 pattern_500 = "a" * 500
99 result = runner.invoke(cli, ["content-grep", pattern_500], env=_env(root))
100 # No match → exit 1, but not a ReDoS validation failure
101 assert result.exit_code in (0, 1)
102
103 def test_invalid_regex_rejected(self, tmp_path: pathlib.Path) -> None:
104 root, repo_id = _init_repo(tmp_path)
105 _make_commit(root, repo_id)
106 result = runner.invoke(cli, ["content-grep", "[invalid regex"], env=_env(root))
107 assert result.exit_code != 0
108 assert "regex" in result.output.lower() or "invalid" in result.output.lower()
109
110 def test_output_sanitized_no_ansi_injection(self, tmp_path: pathlib.Path) -> None:
111 root, repo_id = _init_repo(tmp_path)
112 content = b"normal line\n\x1b[31mRED\x1b[0m line\nanother\n"
113 obj_id = hashlib.sha256(content).hexdigest()
114 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
115 obj_path.parent.mkdir(parents=True, exist_ok=True)
116 obj_path.write_bytes(content)
117
118 from muse.core.store import SnapshotRecord, CommitRecord, write_snapshot, write_commit
119 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
120
121 manifest = {"file.txt": obj_id}
122 snap_id = compute_snapshot_id(manifest)
123 committed_at = datetime.datetime.now(datetime.timezone.utc)
124 commit_id = compute_commit_id([], snap_id, "test", committed_at.isoformat())
125 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
126 write_commit(root, CommitRecord(
127 commit_id=commit_id, repo_id=repo_id, branch="main",
128 snapshot_id=snap_id, message="test", committed_at=committed_at,
129 parent_commit_id=None,
130 ))
131 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id)
132
133 result = runner.invoke(cli, ["content-grep", "RED"], env=_env(root))
134 if result.exit_code == 0:
135 assert "\x1b" not in result.output
136
137
138 # ---------------------------------------------------------------------------
139 # archive: zip-slip guard
140 # ---------------------------------------------------------------------------
141
142 class TestArchiveSecurity:
143 def test_archive_prefix_with_dotdot_rejected(self, tmp_path: pathlib.Path) -> None:
144 root, repo_id = _init_repo(tmp_path)
145 _make_commit(root, repo_id)
146 result = runner.invoke(cli, ["archive", "--prefix", "../../evil"], env=_env(root))
147 assert result.exit_code != 0
148
149 def test_zip_slip_guard_in_safe_arcname(self) -> None:
150 from muse.cli.commands.archive import _safe_arcname
151 assert _safe_arcname("safe", "../../../etc/passwd") is None
152 assert _safe_arcname("safe", "/etc/passwd") is None
153 assert _safe_arcname("safe", "normal/path.txt") == "safe/normal/path.txt"
154
155
156 # ---------------------------------------------------------------------------
157 # snapshot: glob prefix sanitisation
158 # ---------------------------------------------------------------------------
159
160 class TestSnapshotSecurity:
161 def test_validate_snapshot_id_prefix_strips_metacharacters(self) -> None:
162 from muse.cli.commands.snapshot_cmd import _validate_snapshot_id_prefix
163 prefix = _validate_snapshot_id_prefix("*bad[0-9]?glob*")
164 assert "*" not in prefix
165 assert "[" not in prefix
166 assert "?" not in prefix
167 assert all(c in "0123456789abcdef" for c in prefix)
168
169 def test_snapshot_show_with_glob_meta_no_injection(
170 self, tmp_path: pathlib.Path
171 ) -> None:
172 """Glob metacharacters in the snapshot ID prefix must be sanitised."""
173 root, repo_id = _init_repo(tmp_path)
174 _make_commit(root, repo_id)
175 # The '*' prefix is sanitised to empty string (no hex chars), so the
176 # command finds nothing but must not raise an exception or expose paths.
177 result = runner.invoke(cli, ["snapshot", "show", "*"], env=_env(root))
178 # Should not crash; may exit 0 (empty match) or non-zero (not found)
179 assert "\x1b" not in result.output
180 assert result.exception is None
181
182 def test_safe_arcname_in_snapshot(self) -> None:
183 from muse.cli.commands.snapshot_cmd import _safe_arcname
184 assert _safe_arcname("", "../../../etc/passwd") is None
185 assert _safe_arcname("prefix", "safe.txt") == "prefix/safe.txt"
186
187
188 # ---------------------------------------------------------------------------
189 # checkout: validate_branch_name on switch
190 # ---------------------------------------------------------------------------
191
192 class TestCheckoutSecurity:
193 def test_checkout_invalid_branch_name_rejected(self, tmp_path: pathlib.Path) -> None:
194 root, repo_id = _init_repo(tmp_path)
195 _make_commit(root, repo_id)
196 result = runner.invoke(cli, ["checkout", "../evil"], env=_env(root))
197 assert result.exit_code != 0
198
199 def test_checkout_double_dot_rejected(self, tmp_path: pathlib.Path) -> None:
200 root, repo_id = _init_repo(tmp_path)
201 _make_commit(root, repo_id)
202 result = runner.invoke(cli, ["checkout", ".."], env=_env(root))
203 assert result.exit_code != 0
204
205 def test_checkout_valid_existing_branch_works(self, tmp_path: pathlib.Path) -> None:
206 root, repo_id = _init_repo(tmp_path)
207 _make_commit(root, repo_id)
208 # Create a second branch and switch to it
209 (root / ".muse" / "refs" / "heads" / "dev").write_text(
210 (root / ".muse" / "refs" / "heads" / "main").read_text()
211 )
212 result = runner.invoke(cli, ["checkout", "dev"], env=_env(root), catch_exceptions=False)
213 assert result.exit_code == 0
214
215
216 # ---------------------------------------------------------------------------
217 # rebase: validate_branch_name on upstream/onto
218 # ---------------------------------------------------------------------------
219
220 class TestRebaseSecurity:
221 def test_rebase_invalid_upstream_fails(self, tmp_path: pathlib.Path) -> None:
222 root, repo_id = _init_repo(tmp_path)
223 _make_commit(root, repo_id)
224 result = runner.invoke(cli, ["rebase", "../../../etc/passwd"], env=_env(root))
225 assert result.exit_code != 0
226
227
228 # ---------------------------------------------------------------------------
229 # shelf: atomic write regression
230 # ---------------------------------------------------------------------------
231
232 class TestShelfAtomicWrite:
233 def test_no_temp_files_after_save(self, tmp_path: pathlib.Path) -> None:
234 root, _ = _init_repo(tmp_path)
235 from muse.cli.commands.shelf import _save_shelf, ShelfEntry, _compute_shelf_id
236 raw = {
237 "name": "dev/000", "snapshot": {}, "deleted": [],
238 "snapshot_id": long_id("a" * 64), "parent_commit": long_id("b" * 64),
239 "branch": "main", "created_at": "2025-01-01T00:00:00+00:00",
240 "created_by": "human", "intent_type": "checkpoint", "intent": None,
241 "resumable": False, "tags": [], "expires_at": None, "domain_state": {},
242 }
243 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
244 _save_shelf(root, [entry])
245 assert list((root / ".muse").glob(".shelf_tmp_*")) == []
246 assert (root / ".muse" / "shelf.json").exists()
247
248 def test_shelf_file_contents_after_atomic_write(self, tmp_path: pathlib.Path) -> None:
249 root, _ = _init_repo(tmp_path)
250 from muse.cli.commands.shelf import _save_shelf, _load_shelf, ShelfEntry, _compute_shelf_id
251 raw = {
252 "name": "dev/000", "snapshot": {"a.py": long_id("c" * 64)}, "deleted": [],
253 "snapshot_id": long_id("b" * 64), "parent_commit": long_id("d" * 64),
254 "branch": "main", "created_at": "2025-06-01T12:00:00+00:00",
255 "created_by": "human", "intent_type": "checkpoint", "intent": None,
256 "resumable": False, "tags": [], "expires_at": None, "domain_state": {},
257 }
258 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
259 _save_shelf(root, [entry])
260 loaded = _load_shelf(root)
261 assert len(loaded) == 1
262 assert loaded[0]["name"] == "dev/000"
263
264
265 # ---------------------------------------------------------------------------
266 # show: sanitize_display regression
267 # ---------------------------------------------------------------------------
268
269 class TestShowDisplaySanitize:
270 def test_commit_message_ansi_not_in_output(self, tmp_path: pathlib.Path) -> None:
271 root, repo_id = _init_repo(tmp_path)
272 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
273 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
274
275 snap_id = compute_snapshot_id({})
276 committed_at = datetime.datetime.now(datetime.timezone.utc)
277 # Compute the commit_id from the actual message that will be stored.
278 actual_message = "evil\x1b[31mRED\x1b[0m message"
279 commit_id = compute_commit_id([], snap_id, actual_message, committed_at.isoformat())
280 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={}))
281 write_commit(root, CommitRecord(
282 commit_id=commit_id, repo_id=repo_id, branch="main",
283 snapshot_id=snap_id,
284 message=actual_message,
285 committed_at=committed_at, parent_commit_id=None,
286 author="Alice\x1b[0m",
287 ))
288 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id)
289
290 result = runner.invoke(cli, ["read"], env=_env(root), catch_exceptions=False)
291 assert result.exit_code == 0
292 assert "\x1b" not in result.output
293
294
295 # ---------------------------------------------------------------------------
296 # reflog: operation sanitization regression
297 # ---------------------------------------------------------------------------
298
299 class TestReflogSanitize:
300 def test_operation_ansi_not_in_output(self, tmp_path: pathlib.Path) -> None:
301 root, repo_id = _init_repo(tmp_path)
302 from muse.core.reflog import append_reflog
303 _make_commit(root, repo_id)
304 append_reflog(
305 root, "main",
306 old_id="0" * 64, new_id="a" * 64,
307 author="user", operation="evil\x1b[31mRED\x1b[0m",
308 )
309 result = runner.invoke(cli, ["reflog"], env=_env(root), catch_exceptions=False)
310 assert "\x1b" not in result.output
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago