Harden isolated release builds in Flatpak
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -30,7 +31,12 @@ from govoplan_release.release_execution import ( # noqa: E402
|
|||||||
verify_repository_preflight_binding,
|
verify_repository_preflight_binding,
|
||||||
_clone_catalog_sources,
|
_clone_catalog_sources,
|
||||||
_checkout_frozen_source,
|
_checkout_frozen_source,
|
||||||
|
_flatpak_proxy_environment,
|
||||||
|
_run_isolated_wheel_builder,
|
||||||
|
_trusted_flatpak_builder_proxy,
|
||||||
|
_trusted_host_bubblewrap,
|
||||||
_verify_exact_publication_delta,
|
_verify_exact_publication_delta,
|
||||||
|
isolated_builder_launcher,
|
||||||
)
|
)
|
||||||
from govoplan_release.selective_catalog import ( # noqa: E402
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
enforce_frozen_selected_source_provenance,
|
enforce_frozen_selected_source_provenance,
|
||||||
@@ -39,6 +45,91 @@ from govoplan_release.selective_catalog import ( # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
class ReleaseExecutionTests(unittest.TestCase):
|
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:
|
def test_git_environment_ignores_inherited_redirection_and_commands(self) -> None:
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
@@ -510,6 +601,11 @@ class ReleaseExecutionTests(unittest.TestCase):
|
|||||||
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
'[project]\nname = "govoplan-files"\nversion = "1.2.3"\n',
|
||||||
encoding="utf-8",
|
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 = (
|
specs = (
|
||||||
RepositorySpec(
|
RepositorySpec(
|
||||||
name="govoplan-files",
|
name="govoplan-files",
|
||||||
@@ -530,6 +626,12 @@ class ReleaseExecutionTests(unittest.TestCase):
|
|||||||
|
|
||||||
def build(command: tuple[str, ...]) -> int:
|
def build(command: tuple[str, ...]) -> int:
|
||||||
wheel_dir = Path(command[command.index("--bind") + 1])
|
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"
|
wheel_dir / "govoplan_files-1.2.3-py3-none-any.whl"
|
||||||
).write_bytes(b"wheel")
|
).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({"govoplan-files"}, set(artifacts))
|
||||||
self.assertEqual(1, runner.call_count)
|
self.assertEqual(1, runner.call_count)
|
||||||
command = runner.call_args.args[0]
|
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("--unshare-all", command)
|
||||||
self.assertIn("PIP_NO_INDEX", command)
|
self.assertIn("PIP_NO_INDEX", command)
|
||||||
self.assertNotIn(str(Path.home()), command)
|
self.assertNotIn(str(Path.home()), command)
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ MAX_CATALOG_SOURCE_REPOSITORIES = 128
|
|||||||
MAX_TRUSTED_RUNTIME_ENTRIES = 10_000
|
MAX_TRUSTED_RUNTIME_ENTRIES = 10_000
|
||||||
MAX_TRUSTED_GIT_ENTRIES = 500_000
|
MAX_TRUSTED_GIT_ENTRIES = 500_000
|
||||||
DURABLE_REMOTE = "origin"
|
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):
|
class ReleaseExecutionError(RuntimeError):
|
||||||
@@ -1165,16 +1168,34 @@ def _build_selected_wheels_from_sources(
|
|||||||
tempfile.mkdtemp(prefix=f".{repo}-build-", dir=artifacts)
|
tempfile.mkdtemp(prefix=f".{repo}-build-", dir=artifacts)
|
||||||
)
|
)
|
||||||
os.chmod(staging, 0o700, follow_symlinks=False)
|
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(
|
command = isolated_wheel_builder_command(
|
||||||
source=frozen_path,
|
source=source_staging,
|
||||||
output=staging,
|
output=wheel_staging,
|
||||||
)
|
)
|
||||||
if _run_isolated_wheel_builder(command) != 0:
|
if _run_isolated_wheel_builder(command) != 0:
|
||||||
|
shutil.rmtree(staging)
|
||||||
raise ReleaseExecutionBlocked(
|
raise ReleaseExecutionBlocked(
|
||||||
f"Server-owned wheel staging failed for {repo}: "
|
f"Server-owned wheel staging failed for {repo}: "
|
||||||
"the isolated no-network worker did not complete successfully"
|
"the isolated no-network worker did not complete successfully"
|
||||||
)
|
)
|
||||||
created = _single_isolated_wheel(staging)
|
created = _single_isolated_wheel(wheel_staging)
|
||||||
destination = artifacts / created.name
|
destination = artifacts / created.name
|
||||||
if destination.exists() or destination.is_symlink():
|
if destination.exists() or destination.is_symlink():
|
||||||
raise ReleaseExecutionBlocked(
|
raise ReleaseExecutionBlocked(
|
||||||
@@ -1191,8 +1212,7 @@ def _build_selected_wheels_from_sources(
|
|||||||
f"Isolated wheel identity does not match selected source {repo}."
|
f"Isolated wheel identity does not match selected source {repo}."
|
||||||
)
|
)
|
||||||
os.chmod(destination, 0o600, follow_symlinks=False)
|
os.chmod(destination, 0o600, follow_symlinks=False)
|
||||||
created.unlink()
|
shutil.rmtree(staging)
|
||||||
staging.rmdir()
|
|
||||||
result[repo] = destination
|
result[repo] = destination
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1228,12 +1248,12 @@ def isolated_wheel_builder_command(*, source: Path, output: Path) -> tuple[str,
|
|||||||
"/tmp",
|
"/tmp",
|
||||||
"--dir",
|
"--dir",
|
||||||
"/tmp/home",
|
"/tmp/home",
|
||||||
"--ro-bind",
|
|
||||||
str(source.absolute()),
|
|
||||||
"/src",
|
|
||||||
"--bind",
|
"--bind",
|
||||||
str(output.absolute()),
|
str(output.absolute()),
|
||||||
"/out",
|
"/out",
|
||||||
|
"--bind",
|
||||||
|
str(source.absolute()),
|
||||||
|
"/src",
|
||||||
"--chdir",
|
"--chdir",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"--clearenv",
|
"--clearenv",
|
||||||
@@ -1288,12 +1308,10 @@ def isolated_wheel_builder_command(*, source: Path, output: Path) -> tuple[str,
|
|||||||
|
|
||||||
|
|
||||||
def isolated_builder_launcher() -> tuple[str, ...]:
|
def isolated_builder_launcher() -> tuple[str, ...]:
|
||||||
flatpak_spawn = Path("/usr/bin/flatpak-spawn")
|
if _trusted_flatpak_builder_proxy():
|
||||||
direct_bwrap = Path("/usr/bin/bwrap")
|
return (str(FLATPAK_BUILDER_PROXY), "--host", str(HOST_BUBBLEWRAP))
|
||||||
if _trusted_system_executable(flatpak_spawn):
|
if _trusted_system_executable(HOST_BUBBLEWRAP):
|
||||||
return (str(flatpak_spawn), "--host", "/usr/bin/bwrap")
|
return (str(HOST_BUBBLEWRAP),)
|
||||||
if _trusted_system_executable(direct_bwrap):
|
|
||||||
return (str(direct_bwrap),)
|
|
||||||
raise ReleaseExecutionBlocked(
|
raise ReleaseExecutionBlocked(
|
||||||
"Catalog generation requires a trusted bubblewrap worker; no supported "
|
"Catalog generation requires a trusted bubblewrap worker; no supported "
|
||||||
"isolated builder is available."
|
"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:
|
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.
|
process = subprocess.Popen( # noqa: S603 - absolute trusted launcher only.
|
||||||
command,
|
command,
|
||||||
cwd=Path("/"),
|
cwd=Path("/"),
|
||||||
|
env=environment,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user