diff --git a/tests/test_release_catalog_publication.py b/tests/test_release_catalog_publication.py index e84e387..3e1f7ef 100644 --- a/tests/test_release_catalog_publication.py +++ b/tests/test_release_catalog_publication.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import os from pathlib import Path @@ -26,12 +27,65 @@ from govoplan_release.publisher import ( # noqa: E402 verify_committed_publication, verify_remote_branch_head, verify_remote_publication, + _git_bytes, _sanitized_git_environment, + _seal_git_metadata_file, _trusted_npm_command, ) class ReleaseCatalogPublicationTests(unittest.TestCase): + def test_publication_git_writes_ignore_permissive_operator_umask(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repository = Path(temp_dir) / "website" + repository.mkdir() + self._git(repository, "init", "--quiet") + payload_number = 0 + while True: + payload = f"private publication object {payload_number}\n".encode() + header = f"blob {len(payload)}\0".encode() + expected_object = hashlib.sha1( + header + payload, + usedforsecurity=False, + ).hexdigest() + object_parent = repository / ".git" / "objects" / expected_object[:2] + if not object_parent.exists(): + break + payload_number += 1 + + previous_umask = os.umask(0o002) + try: + object_id = ( + _git_bytes( + repository, + "hash-object", + "-w", + "--stdin", + input_bytes=payload, + ) + .decode("ascii") + .strip() + ) + finally: + os.umask(previous_umask) + + object_path = object_parent / expected_object[2:] + self.assertEqual(expected_object, object_id) + self.assertEqual(os.geteuid(), object_parent.stat().st_uid) + self.assertEqual(0o700, object_parent.stat().st_mode & 0o777) + self.assertEqual(os.geteuid(), object_path.stat().st_uid) + self.assertEqual(0o400, object_path.stat().st_mode & 0o777) + + def test_publication_git_metadata_is_sealed_after_git_writes(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + metadata = Path(temp_dir) / "index" + metadata.write_bytes(b"index") + metadata.chmod(0o664) + + _seal_git_metadata_file(metadata, label="test index") + + self.assertEqual(0o600, metadata.stat().st_mode & 0o777) + def test_publication_git_identity_is_fixed_and_non_personal(self) -> None: with patch.dict( os.environ, diff --git a/tools/release/govoplan_release/publisher.py b/tools/release/govoplan_release/publisher.py index b0f5947..3b5c34a 100644 --- a/tools/release/govoplan_release/publisher.py +++ b/tools/release/govoplan_release/publisher.py @@ -614,6 +614,14 @@ def publish_catalog_candidate( effective_tag_name, publication_commit, ) + _seal_git_metadata_file( + resolved_web_root + / ".git" + / "refs" + / "tags" + / effective_tag_name, + label="website publication tag reference", + ) publication_tag_object = git_text( resolved_web_root, "rev-parse", @@ -1445,9 +1453,38 @@ def commit_publication_tree( frozen_head, ) _git_checked(web_root, "read-tree", "--reset", commit_sha) + _seal_git_metadata_file( + web_root / ".git" / "refs" / "heads" / branch, + label="website branch reference", + ) + _seal_git_metadata_file( + web_root / ".git" / "index", + label="website Git index", + ) return commit_sha +def _seal_git_metadata_file(path: Path, *, label: str) -> None: + try: + metadata = path.lstat() + if ( + stat.S_ISLNK(metadata.st_mode) + or not stat.S_ISREG(metadata.st_mode) + or metadata.st_uid != os.geteuid() + ): + raise RuntimeError(f"{label} is not an operator-owned regular file") + os.chmod(path, 0o600, follow_symlinks=False) + sealed = path.lstat() + except OSError as exc: + raise RuntimeError(f"{label} cannot be sealed") from exc + if ( + not stat.S_ISREG(sealed.st_mode) + or sealed.st_uid != os.geteuid() + or stat.S_IMODE(sealed.st_mode) != 0o600 + ): + raise RuntimeError(f"{label} was not sealed to operator-only access") + + def verify_committed_publication( *, web_root: Path, @@ -1948,6 +1985,8 @@ def _git_bytes( "-C", str(path), "-c", + "core.sharedRepository=0600", + "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null",