gabriel / muse public
test_security_path_traversal.py python
689 lines 26.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Phase 2.1 — Path traversal security tests.
2
3 Covers every attack vector identified in the muse-powered security recon:
4
5 1. validate_workspace_path / validate_path_prefix — unit tests for the new
6 validation primitives that gate all workspace-relative path inputs.
7 2. hash-object — special-file guard (named pipes, sockets), null-byte
8 injection, symlink following (documented behaviour), very long paths.
9 3. ls-files --path-prefix — null-byte and glob metacharacter injection.
10 4. check-attr — traversal sequences, null bytes, absolute paths, control
11 characters, CRLF-poisoned stdin, very long paths.
12 5. check-ignore — same surface as check-attr.
13 6. verify-object --stdin — CRLF line endings must not embed \\r in IDs.
14 7. apply_mpack / unpack-objects — zip-slip attack via malicious manifest keys;
15 malicious object IDs in pack bundles.
16
17 Design principles
18 -----------------
19 - Every test is hermetic: uses tmp_path, writes only what it needs.
20 - No datetime.now() — pinned UTC timestamps wherever commits are needed.
21 - No synthetic IDs — compute_commit_id / compute_snapshot_id used throughout.
22 - Stress: 10 000-path batch, 100 000-char path, 100-entry malicious manifest.
23 """
24
25 from __future__ import annotations
26
27 import datetime
28 import json
29 import os
30 import pathlib
31 import sys
32
33 import pytest
34
35 from tests.cli_test_helper import CliRunner
36 from muse.core.errors import ExitCode
37 from muse.core.object_store import write_object
38 from muse.core.pack import MPackBundle, apply_mpack
39 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
40 from muse.core.store import CommitRecord, SnapshotDict, SnapshotRecord, write_commit, write_snapshot
41 from muse.core.validation import (
42 validate_path_prefix,
43 validate_workspace_path,
44 )
45 from muse.core._types import Manifest, blob_id, fake_id, long_id
46
47 cli = None # argparse migration — CliRunner ignores this
48 runner = CliRunner()
49
50 _REPO_ID = "security-test"
51 _BASE_DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
52
53
54 # ---------------------------------------------------------------------------
55 # Repo helpers
56 # ---------------------------------------------------------------------------
57
58
59 def _init_repo(root: pathlib.Path) -> pathlib.Path:
60 muse = root / ".muse"
61 for d in ("commits", "snapshots", "objects", "refs/heads"):
62 (muse / d).mkdir(parents=True, exist_ok=True)
63 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
64 (muse / "repo.json").write_text(
65 json.dumps({"repo_id": _REPO_ID, "domain": "generic"}), encoding="utf-8"
66 )
67 return root
68
69
70 def _make_commit(root: pathlib.Path, idx: int = 0, parent_id: str | None = None) -> str:
71 manifest: Manifest = {f"file_{idx}.py": "a" * 64}
72 snap_id = compute_snapshot_id(manifest)
73 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
74 dt = _BASE_DT + datetime.timedelta(hours=idx)
75 parent_ids = [parent_id] if parent_id else []
76 commit_id = compute_commit_id(
77 repo_id=_REPO_ID,
78 parent_ids=parent_ids,
79 snapshot_id=snap_id,
80 message=f"commit {idx}",
81 committed_at_iso=dt.isoformat(),
82 )
83 write_commit(root, CommitRecord(
84 commit_id=commit_id, repo_id=_REPO_ID, created_on_branch="main",
85 snapshot_id=snap_id, message=f"commit {idx}", committed_at=dt,
86 parent_commit_id=parent_id,
87 ))
88 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8")
89 return commit_id
90
91
92 def _env(root: pathlib.Path) -> Manifest:
93 return {"MUSE_REPO_ROOT": str(root)}
94
95
96 # ===========================================================================
97 # 1. validate_workspace_path — unit tests
98 # ===========================================================================
99
100
101 class TestValidateWorkspacePath:
102 def test_valid_simple_path(self) -> None:
103 assert validate_workspace_path("tracks/drums.mid") == "tracks/drums.mid"
104
105 def test_valid_filename_only(self) -> None:
106 assert validate_workspace_path("README.md") == "README.md"
107
108 def test_valid_with_dots_in_name(self) -> None:
109 assert validate_workspace_path("src/my.module.py") == "src/my.module.py"
110
111 def test_valid_with_tab(self) -> None:
112 # Tab is the only non-printable char we allow.
113 assert validate_workspace_path("path\twith\ttabs") == "path\twith\ttabs"
114
115 def test_rejects_empty(self) -> None:
116 with pytest.raises(ValueError, match="empty"):
117 validate_workspace_path("")
118
119 def test_rejects_null_byte(self) -> None:
120 with pytest.raises(ValueError, match="null byte"):
121 validate_workspace_path("foo\x00bar")
122
123 def test_rejects_null_byte_prefix_injection(self) -> None:
124 """Classic null-byte injection: foo\\x00../../etc/passwd."""
125 with pytest.raises(ValueError, match="null byte"):
126 validate_workspace_path("tracks/song.mid\x00../../etc/passwd")
127
128 def test_rejects_dotdot_relative(self) -> None:
129 with pytest.raises(ValueError, match="traversal"):
130 validate_workspace_path("../../../etc/passwd")
131
132 def test_rejects_dotdot_in_middle(self) -> None:
133 with pytest.raises(ValueError, match="traversal"):
134 validate_workspace_path("tracks/../../../etc/passwd")
135
136 def test_rejects_dotdot_alone(self) -> None:
137 with pytest.raises(ValueError, match="traversal"):
138 validate_workspace_path("..")
139
140 def test_rejects_absolute_posix(self) -> None:
141 with pytest.raises(ValueError, match="absolute"):
142 validate_workspace_path("/etc/passwd")
143
144 def test_rejects_absolute_windows_backslash(self) -> None:
145 with pytest.raises(ValueError, match="absolute"):
146 validate_workspace_path("\\windows\\path")
147
148 def test_rejects_windows_drive_letter(self) -> None:
149 with pytest.raises(ValueError, match="absolute"):
150 validate_workspace_path("C:\\Users\\evil")
151
152 def test_rejects_control_character_cr(self) -> None:
153 with pytest.raises(ValueError, match="control character"):
154 validate_workspace_path("foo\rbar")
155
156 def test_rejects_control_character_lf(self) -> None:
157 with pytest.raises(ValueError, match="control character"):
158 validate_workspace_path("foo\nbar")
159
160 def test_rejects_control_character_esc(self) -> None:
161 with pytest.raises(ValueError, match="control character"):
162 validate_workspace_path("foo\x1bbar")
163
164 def test_rejects_ansi_escape_sequence(self) -> None:
165 """ESC[31m — terminal colour injection."""
166 with pytest.raises(ValueError, match="control character"):
167 validate_workspace_path("\x1b[31mevil\x1b[0m")
168
169 def test_rejects_very_long_path(self) -> None:
170 with pytest.raises(ValueError, match="too long"):
171 validate_workspace_path("a/" * 2500) # > 4096 chars
172
173 def test_accepts_path_at_max_length(self) -> None:
174 # 4096 chars exactly — must pass.
175 p = "a" * 4096
176 assert validate_workspace_path(p) == p
177
178 def test_rejects_path_one_over_max(self) -> None:
179 with pytest.raises(ValueError, match="too long"):
180 validate_workspace_path("a" * 4097)
181
182
183 # ===========================================================================
184 # 2. validate_path_prefix — unit tests
185 # ===========================================================================
186
187
188 class TestValidatePathPrefix:
189 def test_valid_prefix(self) -> None:
190 assert validate_path_prefix("src/") == "src/"
191
192 def test_valid_empty_prefix(self) -> None:
193 # Empty prefix matches everything — it's a valid no-op filter.
194 assert validate_path_prefix("") == ""
195
196 def test_rejects_null_byte(self) -> None:
197 with pytest.raises(ValueError, match="null byte"):
198 validate_path_prefix("src/\x00evil")
199
200 def test_rejects_glob_star(self) -> None:
201 with pytest.raises(ValueError, match="glob metacharacters"):
202 validate_path_prefix("src/*.py")
203
204 def test_rejects_glob_question(self) -> None:
205 with pytest.raises(ValueError, match="glob metacharacters"):
206 validate_path_prefix("src/?")
207
208 def test_rejects_glob_bracket(self) -> None:
209 with pytest.raises(ValueError, match="glob metacharacters"):
210 validate_path_prefix("src/[ab]")
211
212 def test_rejects_control_character(self) -> None:
213 with pytest.raises(ValueError, match="control character"):
214 validate_path_prefix("src/\x1b[evil")
215
216
217 # ===========================================================================
218 # 3. hash-object — special-file guard, null bytes, symlinks, long paths
219 # ===========================================================================
220
221
222 class TestHashObjectSecurity:
223 def test_rejects_named_pipe(self, tmp_path: pathlib.Path) -> None:
224 """A named pipe (FIFO) must be rejected — opening it would block forever."""
225 fifo = tmp_path / "evil.fifo"
226 os.mkfifo(fifo)
227 result = runner.invoke(cli, ["hash-object", str(fifo)])
228 assert result.exit_code != 0
229 assert "not a regular file" in result.output.lower() or "regular" in result.output.lower()
230
231 def test_rejects_directory(self, tmp_path: pathlib.Path) -> None:
232 result = runner.invoke(cli, ["hash-object", str(tmp_path)])
233 assert result.exit_code != 0
234
235 def test_rejects_nonexistent_path(self, tmp_path: pathlib.Path) -> None:
236 result = runner.invoke(cli, ["hash-object", str(tmp_path / "ghost.txt")])
237 assert result.exit_code != 0
238
239 def test_regular_file_accepted(self, tmp_path: pathlib.Path) -> None:
240 f = tmp_path / "hello.txt"
241 f.write_bytes(b"hello muse")
242 result = runner.invoke(cli, ["hash-object", "--json", str(f)])
243 assert result.exit_code == 0
244 data = json.loads(result.output)
245 assert data["object_id"].startswith("sha256:")
246 assert len(data["object_id"]) == 71
247
248 def test_symlink_to_regular_file_accepted(self, tmp_path: pathlib.Path) -> None:
249 """Symlinks to regular files are followed — consistent with git hash-object."""
250 real = tmp_path / "real.txt"
251 real.write_bytes(b"real content")
252 link = tmp_path / "link.txt"
253 link.symlink_to(real)
254 result = runner.invoke(cli, ["hash-object", str(link)])
255 # By design: hash-object follows symlinks to regular files.
256 assert result.exit_code == 0
257
258 def test_symlink_to_nonexistent_rejected(self, tmp_path: pathlib.Path) -> None:
259 link = tmp_path / "dangling.txt"
260 link.symlink_to(tmp_path / "ghost.txt")
261 result = runner.invoke(cli, ["hash-object", str(link)])
262 assert result.exit_code != 0
263
264 def test_very_long_path_rejected(self, tmp_path: pathlib.Path) -> None:
265 """A 100 000-char path argument must not stack-overflow — it simply won't exist."""
266 long_path = str(tmp_path) + "/" + "a" * 100_000
267 result = runner.invoke(cli, ["hash-object", long_path])
268 # The file doesn't exist so exit code is non-zero — no crash.
269 assert result.exit_code != 0
270 assert "exit_code" not in result.output # no Python traceback leaked
271
272 def test_write_stores_object_in_repo(self, tmp_path: pathlib.Path) -> None:
273 _init_repo(tmp_path)
274 f = tmp_path / "data.bin"
275 f.write_bytes(b"secret content")
276 result = runner.invoke(
277 cli, ["hash-object", "--write", "--json", str(f)],
278 env=_env(tmp_path),
279 )
280 assert result.exit_code == 0
281 data = json.loads(result.output)
282 assert data["stored"] is True
283
284 @pytest.mark.skipif(sys.platform == "win32", reason="block devices not on Windows")
285 def test_rejects_char_device(self) -> None:
286 """/dev/null is a char device — must be rejected."""
287 null_dev = pathlib.Path("/dev/null")
288 if not null_dev.exists():
289 pytest.skip("/dev/null not available")
290 result = runner.invoke(cli, ["hash-object", str(null_dev)])
291 assert result.exit_code != 0
292
293
294 # ===========================================================================
295 # 4. ls-files — prefix injection
296 # ===========================================================================
297
298
299 class TestLsFilesPrefix:
300 def test_null_byte_in_prefix_rejected(self, tmp_path: pathlib.Path) -> None:
301 _init_repo(tmp_path)
302 _make_commit(tmp_path)
303 result = runner.invoke(
304 cli, ["ls-files", "--path-prefix", "src/\x00evil"],
305 env=_env(tmp_path),
306 )
307 assert result.exit_code != 0
308
309 def test_glob_star_in_prefix_rejected(self, tmp_path: pathlib.Path) -> None:
310 _init_repo(tmp_path)
311 _make_commit(tmp_path)
312 result = runner.invoke(
313 cli, ["ls-files", "--path-prefix", "src/*.py"],
314 env=_env(tmp_path),
315 )
316 assert result.exit_code != 0
317
318 def test_clean_prefix_accepted(self, tmp_path: pathlib.Path) -> None:
319 _init_repo(tmp_path)
320 _make_commit(tmp_path)
321 result = runner.invoke(
322 cli, ["ls-files", "--path-prefix", "file_"],
323 env=_env(tmp_path),
324 )
325 assert result.exit_code == 0
326
327 def test_empty_prefix_returns_all(self, tmp_path: pathlib.Path) -> None:
328 _init_repo(tmp_path)
329 _make_commit(tmp_path)
330 result = runner.invoke(
331 cli, ["ls-files", "--path-prefix", ""],
332 env=_env(tmp_path),
333 )
334 # Empty prefix is valid and returns all files.
335 assert result.exit_code == 0
336
337
338 # ===========================================================================
339 # 5. check-attr — traversal, null bytes, absolute paths, CRLF stdin
340 # ===========================================================================
341
342
343 class TestCheckAttrSecurity:
344 def _repo_with_attrs(self, root: pathlib.Path) -> pathlib.Path:
345 _init_repo(root)
346 (root / ".museattributes").write_text(
347 '[rules]\n[[rules.entries]]\npath_pattern = "*"\ndimension = "*"\n'
348 'strategy = "auto"\ncomment = ""\n',
349 encoding="utf-8",
350 )
351 return root
352
353 def test_traversal_path_rejected(self, tmp_path: pathlib.Path) -> None:
354 self._repo_with_attrs(tmp_path)
355 result = runner.invoke(
356 cli, ["check-attr", "../../../etc/passwd"],
357 env=_env(tmp_path),
358 )
359 assert result.exit_code != 0
360 out = result.output
361 assert "traversal" in out.lower() or "invalid" in out.lower()
362
363 def test_null_byte_path_rejected(self, tmp_path: pathlib.Path) -> None:
364 self._repo_with_attrs(tmp_path)
365 result = runner.invoke(
366 cli, ["check-attr", "foo\x00../../etc/passwd"],
367 env=_env(tmp_path),
368 )
369 assert result.exit_code != 0
370
371 def test_absolute_path_rejected(self, tmp_path: pathlib.Path) -> None:
372 self._repo_with_attrs(tmp_path)
373 result = runner.invoke(
374 cli, ["check-attr", "/etc/passwd"],
375 env=_env(tmp_path),
376 )
377 assert result.exit_code != 0
378
379 def test_ansi_escape_in_path_rejected(self, tmp_path: pathlib.Path) -> None:
380 """ESC[31m in a path argument must be rejected before it reaches output."""
381 self._repo_with_attrs(tmp_path)
382 result = runner.invoke(
383 cli, ["check-attr", "\x1b[31mevil\x1b[0m"],
384 env=_env(tmp_path),
385 )
386 assert result.exit_code != 0
387
388 def test_valid_path_accepted(self, tmp_path: pathlib.Path) -> None:
389 self._repo_with_attrs(tmp_path)
390 result = runner.invoke(
391 cli, ["check-attr", "tracks/drums.mid"],
392 env=_env(tmp_path),
393 )
394 assert result.exit_code == 0
395
396 def test_very_long_path_rejected(self, tmp_path: pathlib.Path) -> None:
397 self._repo_with_attrs(tmp_path)
398 long_path = "a/" * 2500 # > 4096 chars
399 result = runner.invoke(
400 cli, ["check-attr", long_path],
401 env=_env(tmp_path),
402 )
403 assert result.exit_code != 0
404
405 def test_crlf_stdin_stripped(self, tmp_path: pathlib.Path) -> None:
406 """CRLF-terminated stdin lines must not embed \\r in path strings."""
407 self._repo_with_attrs(tmp_path)
408 # "tracks/ok.mid\r\n" — the \\r must be stripped before path use.
409 crlf_input = "tracks/ok.mid\r\n"
410 result = runner.invoke(
411 cli, ["check-attr", "--stdin"],
412 input=crlf_input,
413 env=_env(tmp_path),
414 )
415 assert result.exit_code == 0
416 # The path in output must not contain \\r.
417 assert "\r" not in result.output
418
419 def test_10k_paths_stress(self, tmp_path: pathlib.Path) -> None:
420 """10 000 valid paths must be processed without crash or timeout."""
421 self._repo_with_attrs(tmp_path)
422 paths = [f"track_{i:05d}.mid" for i in range(10_000)]
423 stdin_data = "\n".join(paths)
424 result = runner.invoke(
425 cli, ["check-attr", "--stdin"],
426 input=stdin_data,
427 env=_env(tmp_path),
428 )
429 assert result.exit_code == 0
430
431
432 # ===========================================================================
433 # 6. check-ignore — same surface as check-attr
434 # ===========================================================================
435
436
437 class TestCheckIgnoreSecurity:
438 def _repo(self, root: pathlib.Path) -> pathlib.Path:
439 _init_repo(root)
440 # .museignore is TOML format — not a gitignore-style file.
441 (root / ".museignore").write_text(
442 '[global]\npatterns = ["build/", "*.bin"]\n',
443 encoding="utf-8",
444 )
445 return root
446
447 def test_traversal_path_rejected(self, tmp_path: pathlib.Path) -> None:
448 self._repo(tmp_path)
449 result = runner.invoke(
450 cli, ["check-ignore", "../../../etc/passwd"],
451 env=_env(tmp_path),
452 )
453 assert result.exit_code != 0
454
455 def test_null_byte_path_rejected(self, tmp_path: pathlib.Path) -> None:
456 self._repo(tmp_path)
457 result = runner.invoke(
458 cli, ["check-ignore", "build/\x00../../etc/cron.d/evil"],
459 env=_env(tmp_path),
460 )
461 assert result.exit_code != 0
462
463 def test_absolute_path_rejected(self, tmp_path: pathlib.Path) -> None:
464 self._repo(tmp_path)
465 result = runner.invoke(
466 cli, ["check-ignore", "/absolute/path"],
467 env=_env(tmp_path),
468 )
469 assert result.exit_code != 0
470
471 def test_valid_path_accepted(self, tmp_path: pathlib.Path) -> None:
472 self._repo(tmp_path)
473 result = runner.invoke(
474 cli, ["check-ignore", "build/output.bin"],
475 env=_env(tmp_path),
476 )
477 assert result.exit_code == 0
478
479 def test_crlf_stdin_does_not_embed_cr(self, tmp_path: pathlib.Path) -> None:
480 self._repo(tmp_path)
481 crlf = "build/out.bin\r\n"
482 result = runner.invoke(
483 cli, ["check-ignore", "--stdin"],
484 input=crlf,
485 env=_env(tmp_path),
486 )
487 assert result.exit_code == 0
488 assert "\r" not in result.output
489
490 def test_dotdot_in_middle_rejected(self, tmp_path: pathlib.Path) -> None:
491 self._repo(tmp_path)
492 result = runner.invoke(
493 cli, ["check-ignore", "build/../../../etc/shadow"],
494 env=_env(tmp_path),
495 )
496 assert result.exit_code != 0
497
498
499 # ===========================================================================
500 # 7. verify-object --stdin — CRLF line endings
501 # ===========================================================================
502
503
504 class TestVerifyObjectStdinCRLF:
505 def test_crlf_id_rejected_cleanly(self, tmp_path: pathlib.Path) -> None:
506 """A CRLF-terminated object ID must produce a clear error, not a crash."""
507 _init_repo(tmp_path)
508 content = b"test content"
509 oid = blob_id(content)
510 write_object(tmp_path, oid, content)
511
512 # Pass the ID with a trailing \\r before the newline.
513 crlf_input = oid + "\r\n"
514 result = runner.invoke(
515 cli, ["verify-object", "--stdin"],
516 input=crlf_input,
517 env=_env(tmp_path),
518 )
519 # After the fix: \\r is stripped, so the ID is valid and the object passes.
520 assert result.exit_code == 0
521
522 def test_lf_only_works(self, tmp_path: pathlib.Path) -> None:
523 _init_repo(tmp_path)
524 content = b"lf content"
525 oid = blob_id(content)
526 write_object(tmp_path, oid, content)
527
528 result = runner.invoke(
529 cli, ["verify-object", "--stdin"],
530 input=oid + "\n",
531 env=_env(tmp_path),
532 )
533 assert result.exit_code == 0
534
535
536 # ===========================================================================
537 # 8. apply_mpack / unpack-objects — zip-slip via manifest keys
538 # ===========================================================================
539
540
541 class TestPackZipSlip:
542 def _minimal_bundle(self, manifest: Manifest) -> MPackBundle:
543 snap_id = compute_snapshot_id(manifest)
544 snap_dict: SnapshotDict = {
545 "snapshot_id": snap_id,
546 "manifest": manifest,
547 "created_at": "2026-01-01T00:00:00+00:00",
548 }
549 return MPackBundle(
550 commits=[],
551 snapshots=[snap_dict],
552 objects=[],
553 tags=[],
554 branch_heads={},
555 )
556
557 def test_traversal_key_in_manifest_rejected(self, tmp_path: pathlib.Path) -> None:
558 """apply_mpack must reject manifests with ../../ traversal keys."""
559 _init_repo(tmp_path)
560 bundle = self._minimal_bundle({"../../etc/cron.d/evil": "a" * 64})
561 result = apply_mpack(tmp_path, bundle)
562 # The snapshot must be skipped — not written.
563 assert result["snapshots_written"] == 0
564
565 def test_absolute_key_in_manifest_rejected(self, tmp_path: pathlib.Path) -> None:
566 """Manifest keys starting with / must be rejected."""
567 _init_repo(tmp_path)
568 bundle = self._minimal_bundle({"/etc/passwd": "a" * 64})
569 result = apply_mpack(tmp_path, bundle)
570 assert result["snapshots_written"] == 0
571
572 def test_null_byte_key_in_manifest_rejected(self, tmp_path: pathlib.Path) -> None:
573 _init_repo(tmp_path)
574 bundle = self._minimal_bundle({"tracks/\x00evil": "a" * 64})
575 result = apply_mpack(tmp_path, bundle)
576 assert result["snapshots_written"] == 0
577
578 def test_invalid_object_id_in_manifest_rejected(self, tmp_path: pathlib.Path) -> None:
579 """Manifest values must be valid 64-hex object IDs."""
580 _init_repo(tmp_path)
581 # Bypass compute_snapshot_id — it now validates IDs; craft bundle manually.
582 invalid_manifest: Manifest = {"file.py": "not-a-valid-oid"} # type: ignore[dict-item]
583 snap_dict: SnapshotDict = {
584 "snapshot_id": fake_id("invalid-snap"),
585 "manifest": invalid_manifest,
586 "created_at": "2026-01-01T00:00:00+00:00",
587 }
588 bundle = MPackBundle(
589 commits=[],
590 snapshots=[snap_dict],
591 objects=[],
592 tags=[],
593 branch_heads={},
594 )
595 result = apply_mpack(tmp_path, bundle)
596 assert result["snapshots_written"] == 0
597
598 def test_clean_manifest_written(self, tmp_path: pathlib.Path) -> None:
599 """A manifest with valid keys and IDs must be written successfully."""
600 _init_repo(tmp_path)
601 bundle = self._minimal_bundle({"src/main.py": long_id("b" * 64)})
602 result = apply_mpack(tmp_path, bundle)
603 assert result["snapshots_written"] == 1
604
605 def test_100_malicious_keys_all_skipped(self, tmp_path: pathlib.Path) -> None:
606 """Stress: 100 bundles each with a traversal key — all must be rejected."""
607 _init_repo(tmp_path)
608 skipped = 0
609 for i in range(100):
610 manifest: Manifest = {f"../../evil_{i}": "a" * 64}
611 bundle = self._minimal_bundle(manifest)
612 result = apply_mpack(tmp_path, bundle)
613 skipped += result["snapshots_written"]
614 assert skipped == 0
615
616 def test_malicious_object_id_in_pack_rejected(self, tmp_path: pathlib.Path) -> None:
617 """An object payload with a non-hex 'object_id' must be rejected by write_object."""
618 _init_repo(tmp_path)
619 from muse.core.pack import ObjectPayload
620 bad_obj = ObjectPayload(object_id="../../etc/evil", content=b"payload")
621 bundle = MPackBundle(
622 commits=[], snapshots=[], objects=[bad_obj], tags=[], branch_heads={}
623 )
624 result = apply_mpack(tmp_path, bundle)
625 # The object must be skipped (write_object raises ValueError on bad ID).
626 assert result["objects_written"] == 0
627
628 def test_unpack_objects_core_with_traversal_manifest(
629 self, tmp_path: pathlib.Path
630 ) -> None:
631 """Core apply_mpack rejects traversal manifest keys from any bundle source."""
632 _init_repo(tmp_path)
633 bundle = self._minimal_bundle({"../../evil": "a" * 64})
634 result = apply_mpack(tmp_path, bundle)
635 # The traversal manifest key must cause the snapshot to be skipped.
636 assert result["snapshots_written"] == 0
637
638
639 # ===========================================================================
640 # 9. Integration: full pipeline with adversarial inputs
641 # ===========================================================================
642
643
644 class TestEndToEndAdversarial:
645 def test_hash_object_write_then_ls_files(self, tmp_path: pathlib.Path) -> None:
646 """Write a real object, commit it, list it — no traversal at any step."""
647 _init_repo(tmp_path)
648 f = tmp_path / "song.mid"
649 f.write_bytes(b"\x00" * 128) # synthetic MIDI-like content
650 result = runner.invoke(
651 cli, ["hash-object", "--write", str(f)],
652 env=_env(tmp_path),
653 )
654 assert result.exit_code == 0
655
656 def test_check_attr_stdin_mixed_good_and_bad(self, tmp_path: pathlib.Path) -> None:
657 """Mixed stdin with one traversal path must reject the whole batch cleanly."""
658 _init_repo(tmp_path)
659 (tmp_path / ".museattributes").write_text(
660 '[rules]\n[[rules.entries]]\npath_pattern = "*"\ndimension = "*"\n'
661 'strategy = "auto"\ncomment = ""\n',
662 encoding="utf-8",
663 )
664 mixed_stdin = "tracks/good.mid\n../../../etc/passwd\ntracks/also_good.mid\n"
665 result = runner.invoke(
666 cli, ["check-attr", "--stdin"],
667 input=mixed_stdin,
668 env=_env(tmp_path),
669 )
670 # The batch must be rejected as soon as the bad path is encountered.
671 assert result.exit_code != 0
672
673 def test_no_sensitive_data_in_error_output(self, tmp_path: pathlib.Path) -> None:
674 """Error messages for traversal attempts must not echo the full attack string."""
675 _init_repo(tmp_path)
676 (tmp_path / ".museattributes").write_text(
677 '[rules]\n[[rules.entries]]\npath_pattern = "*"\ndimension = "*"\n'
678 'strategy = "auto"\ncomment = ""\n',
679 encoding="utf-8",
680 )
681 attack = "../../../etc/passwd"
682 result = runner.invoke(
683 cli, ["check-attr", attack],
684 env=_env(tmp_path),
685 )
686 assert result.exit_code != 0
687 # The error output must not contain raw control sequences or leak
688 # system paths (the path is echoed, but sanitize_display must strip it).
689 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 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago