gabriel / muse public
test_porcelain_security.py python
322 lines 14.0 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 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 NULL_COMMIT_ID, long_id, blob_id, fake_id
23 from muse.core.object_store import object_path
24 from muse.core.paths import heads_dir, muse_dir, shelf_dir
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 dot_muse = muse_dir(tmp_path)
41 dot_muse.mkdir()
42 repo_id = fake_id("repo")
43 (dot_muse / "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 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (dot_muse / "refs" / "heads").mkdir(parents=True)
51 (dot_muse / "snapshots").mkdir()
52 (dot_muse / "commits").mkdir()
53 (dot_muse / "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 = heads_dir(root) / "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( parent_ids=[parent_id] if parent_id else [],
67 snapshot_id=snap_id,
68 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.stderr or "Pattern" in result.stderr
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.stderr.lower() or "invalid" in result.stderr.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 = blob_id(content)
114 obj_path = object_path(root, obj_id)
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( parent_ids=[],
125 snapshot_id=snap_id,
126 message="test",
127 committed_at_iso=committed_at.isoformat(),
128 )
129 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
130 write_commit(root, CommitRecord(
131 commit_id=commit_id, repo_id=repo_id, branch="main",
132 snapshot_id=snap_id, message="test", committed_at=committed_at,
133 parent_commit_id=None,
134 ))
135 (heads_dir(root) / "main").write_text(commit_id)
136
137 result = runner.invoke(cli, ["content-grep", "RED"], env=_env(root))
138 if result.exit_code == 0:
139 assert "\x1b" not in result.output
140
141
142 # ---------------------------------------------------------------------------
143 # archive: zip-slip guard
144 # ---------------------------------------------------------------------------
145
146 class TestArchiveSecurity:
147 def test_archive_prefix_with_dotdot_rejected(self, tmp_path: pathlib.Path) -> None:
148 root, repo_id = _init_repo(tmp_path)
149 _make_commit(root, repo_id)
150 result = runner.invoke(cli, ["archive", "--prefix", "../../malicious"], env=_env(root))
151 assert result.exit_code != 0
152
153 def test_zip_slip_guard_in_safe_arcname(self) -> None:
154 from muse.cli.commands.archive import _safe_arcname
155 assert _safe_arcname("safe", "../../../etc/passwd") is None
156 assert _safe_arcname("safe", "/etc/passwd") is None
157 assert _safe_arcname("safe", "normal/path.txt") == "safe/normal/path.txt"
158
159
160 # ---------------------------------------------------------------------------
161 # snapshot: glob prefix sanitisation
162 # ---------------------------------------------------------------------------
163
164 class TestSnapshotSecurity:
165 def test_validate_snapshot_id_prefix_strips_metacharacters(self) -> None:
166 from muse.cli.commands.snapshot_cmd import _validate_snapshot_id_prefix
167 prefix = _validate_snapshot_id_prefix("*bad[0-9]?glob*")
168 assert "*" not in prefix
169 assert "[" not in prefix
170 assert "?" not in prefix
171 assert all(c in "0123456789abcdef" for c in prefix)
172
173 def test_snapshot_show_with_glob_meta_no_injection(
174 self, tmp_path: pathlib.Path
175 ) -> None:
176 """Glob metacharacters in the snapshot ID prefix must be sanitised."""
177 root, repo_id = _init_repo(tmp_path)
178 _make_commit(root, repo_id)
179 # The '*' prefix is sanitised to empty string (no hex chars), so the
180 # command finds nothing but must not raise an exception or expose paths.
181 result = runner.invoke(cli, ["snapshot", "show", "*"], env=_env(root))
182 # Should not crash; may exit 0 (empty match) or non-zero (not found)
183 assert "\x1b" not in result.output
184 assert result.exception is None
185
186 def test_safe_arcname_in_snapshot(self) -> None:
187 from muse.cli.commands.snapshot_cmd import _safe_arcname
188 assert _safe_arcname("", "../../../etc/passwd") is None
189 assert _safe_arcname("prefix", "safe.txt") == "prefix/safe.txt"
190
191
192 # ---------------------------------------------------------------------------
193 # checkout: validate_branch_name on switch
194 # ---------------------------------------------------------------------------
195
196 class TestCheckoutSecurity:
197 def test_checkout_invalid_branch_name_rejected(self, tmp_path: pathlib.Path) -> None:
198 root, repo_id = _init_repo(tmp_path)
199 _make_commit(root, repo_id)
200 result = runner.invoke(cli, ["checkout", "../traversal"], env=_env(root))
201 assert result.exit_code != 0
202
203 def test_checkout_double_dot_rejected(self, tmp_path: pathlib.Path) -> None:
204 root, repo_id = _init_repo(tmp_path)
205 _make_commit(root, repo_id)
206 result = runner.invoke(cli, ["checkout", ".."], env=_env(root))
207 assert result.exit_code != 0
208
209 def test_checkout_valid_existing_branch_works(self, tmp_path: pathlib.Path) -> None:
210 root, repo_id = _init_repo(tmp_path)
211 _make_commit(root, repo_id)
212 # Create a second branch and switch to it
213 (heads_dir(root) / "dev").write_text(
214 (heads_dir(root) / "main").read_text()
215 )
216 result = runner.invoke(cli, ["checkout", "dev"], env=_env(root), catch_exceptions=False)
217 assert result.exit_code == 0
218
219
220 # ---------------------------------------------------------------------------
221 # rebase: validate_branch_name on upstream/onto
222 # ---------------------------------------------------------------------------
223
224 class TestRebaseSecurity:
225 def test_rebase_invalid_upstream_fails(self, tmp_path: pathlib.Path) -> None:
226 root, repo_id = _init_repo(tmp_path)
227 _make_commit(root, repo_id)
228 result = runner.invoke(cli, ["rebase", "../../../etc/passwd"], env=_env(root))
229 assert result.exit_code != 0
230
231
232 # ---------------------------------------------------------------------------
233 # shelf: atomic write regression
234 # ---------------------------------------------------------------------------
235
236 class TestShelfAtomicWrite:
237 def test_no_temp_files_after_save(self, tmp_path: pathlib.Path) -> None:
238 root, _ = _init_repo(tmp_path)
239 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id
240 from muse.core.store import write_shelf_entry
241 raw = {
242 "name": "dev/000", "snapshot": {}, "deleted": [],
243 "snapshot_id": long_id("a" * 64), "parent_commit": long_id("b" * 64),
244 "branch": "main", "created_at": "2025-01-01T00:00:00+00:00",
245 "created_by": "human", "intent_type": "checkpoint", "intent": None,
246 "resumable": False, "tags": [], "expires_at": None, "domain_state": {},
247 }
248 shelf_id = _compute_shelf_id(raw)
249 entry = ShelfEntry(id=shelf_id, **raw) # type: ignore[misc]
250 write_shelf_entry(root, entry) # type: ignore[arg-type]
251 assert list(muse_dir(root).glob(".muse-tmp-*")) == []
252 assert (shelf_dir(root)).is_dir()
253
254 def test_shelf_file_contents_after_atomic_write(self, tmp_path: pathlib.Path) -> None:
255 root, _ = _init_repo(tmp_path)
256 from muse.cli.commands.shelf import _load_shelf, ShelfEntry, _compute_shelf_id
257 from muse.core.store import write_shelf_entry
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 shelf_id = _compute_shelf_id(raw)
266 entry = ShelfEntry(id=shelf_id, **raw) # type: ignore[misc]
267 write_shelf_entry(root, entry) # type: ignore[arg-type]
268 loaded = _load_shelf(root)
269 assert len(loaded) == 1
270 assert loaded[0]["name"] == "dev/000"
271
272
273 # ---------------------------------------------------------------------------
274 # show: sanitize_display regression
275 # ---------------------------------------------------------------------------
276
277 class TestShowDisplaySanitize:
278 def test_commit_message_ansi_not_in_output(self, tmp_path: pathlib.Path) -> None:
279 root, repo_id = _init_repo(tmp_path)
280 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
281 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
282
283 snap_id = compute_snapshot_id({})
284 committed_at = datetime.datetime.now(datetime.timezone.utc)
285 # Compute the commit_id from the actual message that will be stored.
286 actual_message = "malicious\x1b[31mRED\x1b[0m message"
287 commit_id = compute_commit_id( parent_ids=[],
288 snapshot_id=snap_id,
289 message=actual_message,
290 committed_at_iso=committed_at.isoformat(),
291 author="Alice\x1b[0m",)
292 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={}))
293 write_commit(root, CommitRecord(
294 commit_id=commit_id, repo_id=repo_id, branch="main",
295 snapshot_id=snap_id,
296 message=actual_message,
297 committed_at=committed_at, parent_commit_id=None,
298 author="Alice\x1b[0m",
299 ))
300 (heads_dir(root) / "main").write_text(commit_id)
301
302 result = runner.invoke(cli, ["read"], env=_env(root), catch_exceptions=False)
303 assert result.exit_code == 0
304 assert "\x1b" not in result.output
305
306
307 # ---------------------------------------------------------------------------
308 # reflog: operation sanitization regression
309 # ---------------------------------------------------------------------------
310
311 class TestReflogSanitize:
312 def test_operation_ansi_not_in_output(self, tmp_path: pathlib.Path) -> None:
313 root, repo_id = _init_repo(tmp_path)
314 from muse.core.reflog import append_reflog
315 _make_commit(root, repo_id)
316 append_reflog(
317 root, "main",
318 old_id=NULL_COMMIT_ID, new_id="a" * 64,
319 author="user", operation="malicious\x1b[31mRED\x1b[0m",
320 )
321 result = runner.invoke(cli, ["reflog"], env=_env(root), catch_exceptions=False)
322 assert "\x1b" not in result.output
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago