From b00d4e55eed70026bf6d96c019e246347d018a94 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 21:48:48 +0200 Subject: [PATCH] Harden isolated release builds in Flatpak --- tests/test_release_execution.py | 112 +++++++++++++- .../govoplan_release/release_execution.py | 145 ++++++++++++++++-- 2 files changed, 242 insertions(+), 15 deletions(-) diff --git a/tests/test_release_execution.py b/tests/test_release_execution.py index 8ad0f34..573b89c 100644 --- a/tests/test_release_execution.py +++ b/tests/test_release_execution.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import os import subprocess import sys import tempfile @@ -30,7 +31,12 @@ from govoplan_release.release_execution import ( # noqa: E402 verify_repository_preflight_binding, _clone_catalog_sources, _checkout_frozen_source, + _flatpak_proxy_environment, + _run_isolated_wheel_builder, + _trusted_flatpak_builder_proxy, + _trusted_host_bubblewrap, _verify_exact_publication_delta, + isolated_builder_launcher, ) from govoplan_release.selective_catalog import ( # noqa: E402 enforce_frozen_selected_source_provenance, @@ -39,6 +45,91 @@ from govoplan_release.selective_catalog import ( # noqa: E402 class ReleaseExecutionTests(unittest.TestCase): + def test_flatpak_proxy_environment_drops_unrelated_values(self) -> None: + with patch.dict( + os.environ, + { + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/attacker/bus", + "XDG_RUNTIME_DIR": "/run/user/1000", + "PYTHONPATH": "/attacker", + "GIT_DIR": "/attacker/repository", + }, + clear=True, + ): + environment = _flatpak_proxy_environment() + + self.assertEqual("/usr/bin:/bin", environment["PATH"]) + self.assertEqual( + "unix:path=/run/flatpak/bus", + environment["DBUS_SESSION_BUS_ADDRESS"], + ) + self.assertNotIn("XDG_RUNTIME_DIR", environment) + self.assertNotIn("PYTHONPATH", environment) + self.assertNotIn("GIT_DIR", environment) + + def test_host_bubblewrap_preflight_requires_root_owned_safe_binary(self) -> None: + with patch( + "govoplan_release.release_execution.subprocess.run", + return_value=SimpleNamespace( + returncode=0, + stdout="81ed|0\n", + ), + ): + self.assertTrue(_trusted_host_bubblewrap()) + + with patch( + "govoplan_release.release_execution.subprocess.run", + return_value=SimpleNamespace( + returncode=0, + stdout="81ff|1000\n", + ), + ): + self.assertFalse(_trusted_host_bubblewrap()) + + def test_builder_launcher_uses_only_preflighted_flatpak_proxy(self) -> None: + with patch( + "govoplan_release.release_execution._trusted_flatpak_builder_proxy", + return_value=True, + ): + self.assertEqual( + ("/usr/bin/flatpak-spawn", "--host", "/usr/bin/bwrap"), + isolated_builder_launcher(), + ) + + def test_flatpak_proxy_requires_recognized_container(self) -> None: + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(_trusted_flatpak_builder_proxy()) + + def test_flatpak_builder_launch_uses_sanitized_environment(self) -> None: + process = SimpleNamespace(wait=lambda timeout: 0) + command = ( + "/usr/bin/flatpak-spawn", + "--host", + "/usr/bin/bwrap", + ) + with ( + patch.dict( + os.environ, + { + "DBUS_SESSION_BUS_ADDRESS": "unix:path=/attacker/bus", + "PYTHONPATH": "/attacker", + }, + clear=True, + ), + patch( + "govoplan_release.release_execution.subprocess.Popen", + return_value=process, + ) as popen, + ): + self.assertEqual(0, _run_isolated_wheel_builder(command)) + + environment = popen.call_args.kwargs["env"] + self.assertEqual( + "unix:path=/run/flatpak/bus", + environment["DBUS_SESSION_BUS_ADDRESS"], + ) + self.assertNotIn("PYTHONPATH", environment) + def test_git_environment_ignores_inherited_redirection_and_commands(self) -> None: with patch.dict( "os.environ", @@ -510,6 +601,11 @@ class ReleaseExecutionTests(unittest.TestCase): '[project]\nname = "govoplan-files"\nversion = "1.2.3"\n', encoding="utf-8", ) + _git(workspace, "init", "-b", "main", str(python_repo)) + _git(python_repo, "config", "user.name", "Release Test") + _git(python_repo, "config", "user.email", "release@example.test") + _git(python_repo, "add", "pyproject.toml") + _git(python_repo, "commit", "-m", "Source") specs = ( RepositorySpec( name="govoplan-files", @@ -530,6 +626,12 @@ class ReleaseExecutionTests(unittest.TestCase): def build(command: tuple[str, ...]) -> int: wheel_dir = Path(command[command.index("--bind") + 1]) + second_bind = command.index("--bind", command.index("--bind") + 1) + source_dir = Path(command[second_bind + 1]) + (source_dir / "build-mutated.txt").write_text( + "isolated copy", + encoding="utf-8", + ) ( wheel_dir / "govoplan_files-1.2.3-py3-none-any.whl" ).write_bytes(b"wheel") @@ -581,10 +683,18 @@ class ReleaseExecutionTests(unittest.TestCase): }, ) + self.assertFalse((python_repo / "build-mutated.txt").exists()) self.assertEqual({"govoplan-files"}, set(artifacts)) self.assertEqual(1, runner.call_count) command = runner.call_args.args[0] - self.assertIn(str(python_repo), command) + self.assertNotIn(str(python_repo), command) + self.assertTrue( + any( + value.startswith(str(candidate / "artifacts")) + and value.endswith("/source") + for value in command + ) + ) self.assertIn("--unshare-all", command) self.assertIn("PIP_NO_INDEX", command) self.assertNotIn(str(Path.home()), command) diff --git a/tools/release/govoplan_release/release_execution.py b/tools/release/govoplan_release/release_execution.py index a611eec..39ee8de 100644 --- a/tools/release/govoplan_release/release_execution.py +++ b/tools/release/govoplan_release/release_execution.py @@ -73,6 +73,9 @@ MAX_CATALOG_SOURCE_REPOSITORIES = 128 MAX_TRUSTED_RUNTIME_ENTRIES = 10_000 MAX_TRUSTED_GIT_ENTRIES = 500_000 DURABLE_REMOTE = "origin" +FLATPAK_BUILDER_PROXY = Path("/usr/bin/flatpak-spawn") +HOST_BUBBLEWRAP = Path("/usr/bin/bwrap") +FLATPAK_HOST_BUS = Path("/run/flatpak/bus") class ReleaseExecutionError(RuntimeError): @@ -1165,16 +1168,34 @@ def _build_selected_wheels_from_sources( tempfile.mkdtemp(prefix=f".{repo}-build-", dir=artifacts) ) os.chmod(staging, 0o700, follow_symlinks=False) + source_staging = staging / "source" + wheel_staging = staging / "out" + source_staging.mkdir(mode=0o700) + staged = git( + frozen_path, + "checkout-index", + "--all", + "--force", + f"--prefix={source_staging.absolute()}/", + timeout=120, + ) + if staged.returncode != 0: + shutil.rmtree(staging) + raise ReleaseExecutionBlocked( + f"Could not stage the verified tracked source tree for {repo}." + ) + wheel_staging.mkdir(mode=0o700) command = isolated_wheel_builder_command( - source=frozen_path, - output=staging, + source=source_staging, + output=wheel_staging, ) if _run_isolated_wheel_builder(command) != 0: + shutil.rmtree(staging) raise ReleaseExecutionBlocked( f"Server-owned wheel staging failed for {repo}: " "the isolated no-network worker did not complete successfully" ) - created = _single_isolated_wheel(staging) + created = _single_isolated_wheel(wheel_staging) destination = artifacts / created.name if destination.exists() or destination.is_symlink(): raise ReleaseExecutionBlocked( @@ -1191,8 +1212,7 @@ def _build_selected_wheels_from_sources( f"Isolated wheel identity does not match selected source {repo}." ) os.chmod(destination, 0o600, follow_symlinks=False) - created.unlink() - staging.rmdir() + shutil.rmtree(staging) result[repo] = destination return result @@ -1228,12 +1248,12 @@ def isolated_wheel_builder_command(*, source: Path, output: Path) -> tuple[str, "/tmp", "--dir", "/tmp/home", - "--ro-bind", - str(source.absolute()), - "/src", "--bind", str(output.absolute()), "/out", + "--bind", + str(source.absolute()), + "/src", "--chdir", "/tmp", "--clearenv", @@ -1288,12 +1308,10 @@ def isolated_wheel_builder_command(*, source: Path, output: Path) -> tuple[str, def isolated_builder_launcher() -> tuple[str, ...]: - flatpak_spawn = Path("/usr/bin/flatpak-spawn") - direct_bwrap = Path("/usr/bin/bwrap") - if _trusted_system_executable(flatpak_spawn): - return (str(flatpak_spawn), "--host", "/usr/bin/bwrap") - if _trusted_system_executable(direct_bwrap): - return (str(direct_bwrap),) + if _trusted_flatpak_builder_proxy(): + return (str(FLATPAK_BUILDER_PROXY), "--host", str(HOST_BUBBLEWRAP)) + if _trusted_system_executable(HOST_BUBBLEWRAP): + return (str(HOST_BUBBLEWRAP),) raise ReleaseExecutionBlocked( "Catalog generation requires a trusted bubblewrap worker; no supported " "isolated builder is available." @@ -1313,10 +1331,109 @@ def _trusted_system_executable(path: Path) -> bool: ) +def _trusted_flatpak_builder_proxy() -> bool: + """Trust only Flatpak's immutable SDK proxy and a root-owned host bwrap.""" + + if os.environ.get("container") != "flatpak": + return False + try: + flatpak_info = Path("/.flatpak-info").lstat() + flatpak_runtime = FLATPAK_HOST_BUS.parent.lstat() + host_bus = FLATPAK_HOST_BUS.lstat() + proxy = FLATPAK_BUILDER_PROXY.lstat() + ancestors = (Path("/usr"), Path("/usr/bin")) + ancestor_metadata = tuple(path.lstat() for path in ancestors) + read_only = all( + os.statvfs(path).f_flag & os.ST_RDONLY + for path in (*ancestors, FLATPAK_BUILDER_PROXY) + ) + except (AttributeError, OSError): + return False + if not ( + stat.S_ISREG(flatpak_info.st_mode) + and flatpak_info.st_uid == os.getuid() + and flatpak_info.st_mode & 0o077 == 0 + and stat.S_ISDIR(flatpak_runtime.st_mode) + and flatpak_runtime.st_uid == os.getuid() + and flatpak_runtime.st_mode & 0o077 == 0 + and stat.S_ISSOCK(host_bus.st_mode) + and host_bus.st_uid == os.getuid() + and host_bus.st_mode & 0o022 == 0 + and stat.S_ISREG(proxy.st_mode) + and proxy.st_mode & 0o022 == 0 + and proxy.st_mode & 0o111 != 0 + and all( + stat.S_ISDIR(metadata.st_mode) + and metadata.st_mode & 0o022 == 0 + for metadata in ancestor_metadata + ) + and read_only + ): + return False + return _trusted_host_bubblewrap() + + +def _trusted_host_bubblewrap() -> bool: + try: + check = subprocess.run( # noqa: S603 - immutable allowlisted proxy. + ( + str(FLATPAK_BUILDER_PROXY), + "--host", + "/usr/bin/stat", + "-c", + "%f|%u", + str(HOST_BUBBLEWRAP), + ), + cwd=Path("/"), + env=_flatpak_proxy_environment(), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=5, + check=False, + close_fds=True, + ) + except (OSError, subprocess.SubprocessError): + return False + output = check.stdout.strip() + if check.returncode != 0 or len(output) > 64: + return False + try: + mode_text, uid_text = output.split("|", maxsplit=1) + mode = int(mode_text, 16) + uid = int(uid_text, 10) + except ValueError: + return False + return ( + stat.S_ISREG(mode) + and uid == 0 + and mode & 0o022 == 0 + and mode & 0o111 != 0 + ) + + +def _flatpak_proxy_environment() -> dict[str, str]: + return { + "PATH": "/usr/bin:/bin", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "DBUS_SESSION_BUS_ADDRESS": f"unix:path={FLATPAK_HOST_BUS}", + } + + def _run_isolated_wheel_builder(command: tuple[str, ...]) -> int: + environment = { + "PATH": "/usr/bin:/bin", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } + if command[:1] == (str(FLATPAK_BUILDER_PROXY),): + environment = _flatpak_proxy_environment() process = subprocess.Popen( # noqa: S603 - absolute trusted launcher only. command, cwd=Path("/"), + env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,