"""Phase 5 — Security hardening tests for ``muse bridge``. Verifies: - SHA-1 validation (_validate_git_sha) - Branch name validation (_validate_git_branch_name) - _CatFile rejects non-hex input - No shell=True in bridge.py source - Commit message template uses .format() (not f-string with raw user input) - AttributionMapper strips control characters from handles """ from __future__ import annotations import pathlib import re import pytest from muse.core.paths import init_repo_dirs from muse.core.types import long_id # =========================================================================== # _validate_git_sha # =========================================================================== class TestValidateGitSha: """Unit tests for _validate_git_sha().""" def _fn(self, sha: str) -> bool: from muse.cli.commands.bridge import _validate_git_sha return _validate_git_sha(sha) def test_valid_40_char_hex_returns_true(self) -> None: assert self._fn("a" * 40) is True def test_valid_mixed_hex_returns_true(self) -> None: assert self._fn(f"{'0123456789abcdef' * 2}{'0' * 8}") is True def test_short_sha_returns_false(self) -> None: assert self._fn("abc") is False def test_empty_string_returns_false(self) -> None: assert self._fn("") is False def test_41_chars_returns_false(self) -> None: assert self._fn("a" * 41) is False def test_39_chars_returns_false(self) -> None: assert self._fn("a" * 39) is False def test_uppercase_hex_returns_false(self) -> None: # git SHA-1 must be lowercase assert self._fn("A" * 40) is False def test_sha_with_prefix_returns_false(self) -> None: assert self._fn(long_id("a" * 64)) is False def test_sha_with_semicolon_returns_false(self) -> None: assert self._fn(f"{'a' * 39};") is False def test_sha_with_space_returns_false(self) -> None: assert self._fn(f"{'a' * 39} ") is False def test_sha_with_newline_returns_false(self) -> None: assert self._fn(f"{'a' * 39}\n") is False # =========================================================================== # _validate_git_branch_name # =========================================================================== class TestValidateGitBranchName: """Unit tests for _validate_git_branch_name().""" def _fn(self, branch: str) -> bool: from muse.cli.commands.bridge import _validate_git_branch_name return _validate_git_branch_name(branch) def test_simple_branch_returns_true(self) -> None: assert self._fn("main") is True def test_feature_branch_returns_true(self) -> None: assert self._fn("feat/my-feature") is True def test_empty_string_returns_false(self) -> None: assert self._fn("") is False def test_branch_name_rejects_semicolon(self) -> None: """Semicolon is a shell command separator — must be rejected.""" assert self._fn("main;evil") is False def test_branch_name_rejects_ampersand(self) -> None: assert self._fn("main&evil") is False def test_branch_name_rejects_pipe(self) -> None: assert self._fn("main|evil") is False def test_branch_name_rejects_backtick(self) -> None: assert self._fn("main`cmd`") is False def test_branch_name_rejects_dollar(self) -> None: """$(...) command substitution must be rejected.""" assert self._fn("$(cmd)") is False def test_branch_name_rejects_dollar_sign_alone(self) -> None: assert self._fn("main$evil") is False def test_branch_name_rejects_space(self) -> None: assert self._fn("main evil") is False def test_branch_name_rejects_null_byte(self) -> None: assert self._fn("main\x00evil") is False def test_branch_name_rejects_control_char(self) -> None: assert self._fn("main\x1bevil") is False def test_branch_name_rejects_open_paren(self) -> None: assert self._fn("main(evil") is False def test_branch_name_rejects_close_paren(self) -> None: assert self._fn("main)evil") is False # =========================================================================== # _CatFile SHA validation # =========================================================================== class TestCatFileRejectsInvalidSha: """_CatFile.read() raises ValueError on non-hex input.""" def test_catfile_rejects_invalid_sha(self, tmp_path: pathlib.Path) -> None: """_CatFile raises ValueError on non-hex input without spawning a process.""" from unittest.mock import MagicMock, patch from muse.cli.commands.bridge import _CatFile # Patch Popen so we don't need a real git repo mock_proc = MagicMock() with patch("subprocess.Popen", return_value=mock_proc): cat = _CatFile(tmp_path) with pytest.raises(ValueError, match="Invalid git SHA-1"): cat.read("not-a-sha") def test_catfile_rejects_short_sha(self, tmp_path: pathlib.Path) -> None: from unittest.mock import MagicMock, patch from muse.cli.commands.bridge import _CatFile mock_proc = MagicMock() with patch("subprocess.Popen", return_value=mock_proc): cat = _CatFile(tmp_path) with pytest.raises(ValueError, match="Invalid git SHA-1"): cat.read("abc123") def test_catfile_rejects_sha_with_injection(self, tmp_path: pathlib.Path) -> None: """Shell injection attempt is caught by SHA validation.""" from unittest.mock import MagicMock, patch from muse.cli.commands.bridge import _CatFile mock_proc = MagicMock() with patch("subprocess.Popen", return_value=mock_proc): cat = _CatFile(tmp_path) with pytest.raises(ValueError, match="Invalid git SHA-1"): cat.read(f"{'a' * 39};rm -rf /") def test_catfile_accepts_valid_sha(self, tmp_path: pathlib.Path) -> None: """_CatFile.read() passes SHA validation for a well-formed 40-char hex.""" from unittest.mock import MagicMock, patch from muse.cli.commands.bridge import _CatFile mock_proc = MagicMock() # Simulate a "missing" response so read() returns early mock_proc.stdin = MagicMock() mock_proc.stdout = MagicMock() mock_proc.stdout.readline.return_value = b"aaa missing\n" with patch("subprocess.Popen", return_value=mock_proc): cat = _CatFile(tmp_path) # Should NOT raise — valid SHA clears validation result = cat.read("a" * 40) assert result == b"" # =========================================================================== # No shell=True in bridge.py # =========================================================================== class TestNoShellTrueInSubprocess: """Verify that bridge.py never uses shell=True in subprocess calls.""" def test_no_shell_true_in_subprocess(self) -> None: """Inspect bridge.py source: 'shell=True' must not appear.""" bridge_path = pathlib.Path(__file__).parent.parent / "muse" / "cli" / "commands" / "bridge.py" source = bridge_path.read_text(encoding="utf-8") assert "shell=True" not in source, ( "bridge.py contains 'shell=True' — this is a security vulnerability. " "All subprocess calls must use argument lists." ) # =========================================================================== # Commit message template uses .format() # =========================================================================== class TestCommitMessageTemplateUsesDotFormat: """Verify commit message uses .format() for safe user-value substitution.""" def test_commit_message_template_uses_format(self) -> None: """GitExporter.git_commit() uses template.format() not f-string with user input.""" bridge_path = pathlib.Path(__file__).parent.parent / "muse" / "cli" / "commands" / "bridge.py" source = bridge_path.read_text(encoding="utf-8") # The template call must be `message_template.format(` not an f-string # that directly embeds user-controlled values. assert "message_template.format(" in source, ( "GitExporter.git_commit() must use message_template.format() " "for safe substitution of user-controlled values." ) def test_commit_message_format_call_is_present(self) -> None: """The .format() call in git_commit accepts commit_id, branch, etc.""" # Import the class and inspect the method from muse.cli.commands.bridge import GitExporter import inspect src = inspect.getsource(GitExporter.git_commit) assert ".format(" in src # =========================================================================== # AttributionMapper — control character rejection # =========================================================================== class TestAttributionMapControlCharsRejected: """AttributionMapper strips control characters from handles.""" def test_attribution_map_control_chars_stripped(self, tmp_path: pathlib.Path) -> None: """Handle with embedded \\x00 has control chars removed, not passed through.""" from muse.cli.commands.bridge import AttributionMapper map_file = tmp_path / "attr.json" # Write a map file where the handle contains a null byte import json map_file.write_text( json.dumps({"evil@example.com": "handle\x00injected"}), encoding="utf-8", ) mapper = AttributionMapper(map_file) handle = mapper.get_handle("evil@example.com") # Null byte and other control chars must be stripped assert "\x00" not in handle assert "handle" in handle def test_attribution_map_control_chars_rejected(self, tmp_path: pathlib.Path) -> None: """Handle with \\x1b (ESC) escape code has the control char removed.""" from muse.cli.commands.bridge import AttributionMapper import json map_file = tmp_path / "attr.json" map_file.write_text( json.dumps({"esc@example.com": "handle\x1bevil"}), encoding="utf-8", ) mapper = AttributionMapper(map_file) handle = mapper.get_handle("esc@example.com") assert "\x1b" not in handle def test_attribution_map_missing_email_gives_synthetic_handle( self, tmp_path: pathlib.Path ) -> None: """Email not in the map yields a synthetic git-import/ handle.""" from muse.cli.commands.bridge import AttributionMapper mapper = AttributionMapper(None) handle = mapper.get_handle("nobody@example.com") assert handle.startswith("git-import/")