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