Harden staging image push authority on current main

This commit is contained in:
twentyOne2x 2026-07-21 21:44:54 +02:00
parent 658083e330
commit 5b103285ee
3 changed files with 585 additions and 46 deletions

View file

@ -231,8 +231,16 @@ explicit inputs; inherited `DOCKER_*`, `BUILDKIT_*`, and `BUILDX_*` overrides
are removed and Docker stdin is closed. The pull does not are removed and Docker stdin is closed. The pull does not
start a container, install a unit, restart a service, or contact Cloud SQL. start a container, install a unit, restart a service, or contact Cloud SQL.
Registry authentication and this pull require a separate live-read approval. Registry authentication and this pull require a separate live-read approval.
While holding an advisory lock on the explicit Docker config directory, the The package opens every component of the explicit Docker config path without
command pulls and records the dynamically observed config ID. It intentionally following links and retains that full descriptor chain while Docker runs. Every
component must be root- or current-user-owned and non-writable by group/other;
a root-owned sticky temporary directory is accepted as a boundary only when the
operator directory below it is private. Docker starts in the held final
directory and receives `--config .`, so replacing and restoring the named path
mid-command cannot redirect credential reads. The package also revalidates the
named chain before and after each call while holding an advisory lock on the
final directory. The command pulls and records the dynamically observed config
ID. It intentionally
retains the exact repository-digest reference: Docker's image cache belongs to retains the exact repository-digest reference: Docker's image cache belongs to
the daemon, so a config-directory lock cannot prove that another client did not the daemon, so a config-directory lock cannot prove that another client did not
start relying on the same immutable reference after the precheck. Repeated runs start relying on the same immutable reference after the precheck. Repeated runs

View file

@ -1916,6 +1916,100 @@ def _docker_environment() -> dict[str, str]:
} }
def _close_descriptors(descriptors: tuple[int, ...] | list[int]) -> None:
for descriptor in reversed(descriptors):
os.close(descriptor)
def _open_directory_chain_descriptors(
directory: Path,
label: str,
) -> tuple[tuple[int, ...], tuple[tuple[int, int], ...]]:
"""Retain every directory component without following links."""
_require(directory.is_absolute() and ".." not in directory.parts, f"{label} must be an absolute direct path")
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptors: list[int] = []
identities: list[tuple[int, int]] = []
try:
descriptors.append(os.open("/", flags))
observed = os.fstat(descriptors[-1])
_require(stat.S_ISDIR(observed.st_mode), f"{label} root is unsafe")
identities.append((observed.st_dev, observed.st_ino))
for component in directory.parts[1:]:
_require(component not in {"", ".", ".."}, f"{label} path is unsafe")
descriptors.append(os.open(component, flags, dir_fd=descriptors[-1]))
observed = os.fstat(descriptors[-1])
_require(stat.S_ISDIR(observed.st_mode), f"{label} path is unsafe")
identities.append((observed.st_dev, observed.st_ino))
named = os.stat(directory, follow_symlinks=False)
_require(
stat.S_ISDIR(named.st_mode) and identities[-1] == (named.st_dev, named.st_ino),
f"{label} path identity drifted",
)
except (OSError, PackageError) as exc:
_close_descriptors(descriptors)
if isinstance(exc, PackageError):
raise
raise PackageError(f"{label} is missing or contains a symbolic link") from exc
return tuple(descriptors), tuple(identities)
def _open_directory_chain_nofollow(
directory: Path,
label: str,
) -> tuple[int, tuple[tuple[int, int], ...]]:
"""Open an absolute directory one component at a time without following links."""
descriptors, identities = _open_directory_chain_descriptors(directory, label)
try:
descriptor = os.dup(descriptors[-1])
except OSError as exc:
raise PackageError(f"{label} descriptor cannot be retained") from exc
finally:
_close_descriptors(descriptors)
return descriptor, identities
def _validate_docker_config_chain_posture(descriptors: tuple[int, ...]) -> None:
_require(bool(descriptors), "Docker config directory chain is empty")
for index, descriptor in enumerate(descriptors):
observed = os.fstat(descriptor)
mode = stat.S_IMODE(observed.st_mode)
final = index == len(descriptors) - 1
if final:
trusted = observed.st_uid == os.geteuid() and mode & 0o022 == 0
else:
trusted_owner = observed.st_uid in {0, os.geteuid()}
nonwritable = mode & 0o022 == 0
root_sticky_boundary = observed.st_uid == 0 and bool(mode & stat.S_ISVTX)
trusted = trusted_owner and (nonwritable or root_sticky_boundary)
_require(
stat.S_ISDIR(observed.st_mode) and trusted,
"Docker config directory must have only root/current-user trusted ancestors; "
"a writable ancestor is allowed only when it is a root-owned sticky boundary",
)
def _open_trusted_docker_config_chain(
docker_config: Path,
) -> tuple[tuple[int, ...], tuple[tuple[int, int], ...]]:
descriptors, identities = _open_directory_chain_descriptors(
docker_config,
"Docker config directory",
)
try:
_validate_docker_config_chain_posture(descriptors)
except (OSError, PackageError):
_close_descriptors(descriptors)
raise
return descriptors, identities
def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_config: Path) -> None: def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_config: Path) -> None:
_require(docker_binary.is_absolute(), "Docker binary must be absolute") _require(docker_binary.is_absolute(), "Docker binary must be absolute")
_require("\x00" not in str(docker_binary) and "\n" not in str(docker_binary), "Docker binary is invalid") _require("\x00" not in str(docker_binary) and "\n" not in str(docker_binary), "Docker binary is invalid")
@ -1928,31 +2022,57 @@ def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_con
and "\n" not in docker_host, and "\n" not in docker_host,
"Docker host must be a local Unix endpoint", "Docker host must be a local Unix endpoint",
) )
_require( descriptors, _identities = _open_trusted_docker_config_chain(docker_config)
docker_config.is_absolute() and docker_config.is_dir() and not docker_config.is_symlink(), _close_descriptors(descriptors)
"Docker config directory is missing or unsafe",
)
class _DockerCacheLock: class _DockerCacheLock:
def __init__(self, docker_config: Path) -> None: def __init__(self, docker_config: Path) -> None:
self._docker_config = docker_config self._docker_config = docker_config
self._descriptor: int | None = None self._descriptor: int | None = None
self._descriptors: tuple[int, ...] = ()
self._identities: tuple[tuple[int, int], ...] = ()
def __enter__(self) -> None: def __enter__(self) -> _DockerCacheLock:
flags = os.O_RDONLY _require(self._descriptor is None, "Docker cache authority lock is already active")
if hasattr(os, "O_DIRECTORY"): descriptors: tuple[int, ...] = ()
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try: try:
descriptor = os.open(self._docker_config, flags) descriptors, identities = _open_trusted_docker_config_chain(self._docker_config)
descriptor = descriptors[-1]
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc: except (OSError, PackageError) as exc:
if "descriptor" in locals(): _close_descriptors(descriptors)
os.close(descriptor)
raise PackageError("Docker cache authority is already in use or unsafe") from exc raise PackageError("Docker cache authority is already in use or unsafe") from exc
self._descriptors = descriptors
self._identities = identities
self._descriptor = descriptor self._descriptor = descriptor
return self
def revalidate(self, docker_config: Path) -> None:
_require(
self._descriptor is not None
and bool(self._descriptors)
and bool(self._identities)
and docker_config == self._docker_config,
"Docker cache authority is not active for this config",
)
_validate_docker_config_chain_posture(self._descriptors)
named_descriptors, named_identities = _open_trusted_docker_config_chain(docker_config)
try:
_require(
named_identities == self._identities,
"Docker config directory ancestor identity or posture drifted",
)
finally:
_close_descriptors(named_descriptors)
def enter_subprocess_config_directory(self) -> None:
_require(self._descriptor is not None, "Docker cache authority is not active")
os.fchdir(self._descriptor)
def subprocess_config_authority(self) -> tuple[str, tuple[int, ...]]:
_require(self._descriptor is not None, "Docker cache authority is not active")
return ".", (self._descriptor,)
def __exit__(self, _kind: object, _value: object, _traceback: object) -> None: def __exit__(self, _kind: object, _value: object, _traceback: object) -> None:
if self._descriptor is None: if self._descriptor is None:
@ -1960,8 +2080,10 @@ class _DockerCacheLock:
try: try:
fcntl.flock(self._descriptor, fcntl.LOCK_UN) fcntl.flock(self._descriptor, fcntl.LOCK_UN)
finally: finally:
os.close(self._descriptor) _close_descriptors(self._descriptors)
self._descriptor = None self._descriptor = None
self._descriptors = ()
self._identities = ()
def _run_docker( def _run_docker(
@ -1972,16 +2094,36 @@ def _run_docker(
*, *,
check: bool, check: bool,
timeout: int, timeout: int,
authority: _DockerCacheLock | None = None,
) -> subprocess.CompletedProcess[bytes]: ) -> subprocess.CompletedProcess[bytes]:
_validate_docker_authority(docker_binary, docker_host, docker_config) _validate_docker_authority(docker_binary, docker_host, docker_config)
_require("--config" not in arguments, "Docker arguments cannot override the retained config authority")
if authority is None:
with _DockerCacheLock(docker_config) as owned_authority:
return _run_docker(
docker_binary,
docker_host,
docker_config,
arguments,
check=check,
timeout=timeout,
authority=owned_authority,
)
authority.revalidate(docker_config)
subprocess_config, pass_fds = authority.subprocess_config_authority()
try:
return subprocess.run( return subprocess.run(
[str(docker_binary), "--host", docker_host, "--config", str(docker_config), *arguments], [str(docker_binary), "--host", docker_host, "--config", subprocess_config, *arguments],
check=check, check=check,
capture_output=True, capture_output=True,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
timeout=timeout, timeout=timeout,
env=_docker_environment(), env=_docker_environment(),
pass_fds=pass_fds,
preexec_fn=authority.enter_subprocess_config_directory,
) )
finally:
authority.revalidate(docker_config)
def _bounded_docker_output(result: subprocess.CompletedProcess[bytes], label: str) -> None: def _bounded_docker_output(result: subprocess.CompletedProcess[bytes], label: str) -> None:
@ -1996,6 +2138,8 @@ def _inspect_local_image(
docker_host: str, docker_host: str,
docker_config: Path, docker_config: Path,
image_reference: str, image_reference: str,
*,
authority: _DockerCacheLock | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
result = _run_docker( result = _run_docker(
docker_binary, docker_binary,
@ -2004,6 +2148,7 @@ def _inspect_local_image(
["image", "inspect", image_reference], ["image", "inspect", image_reference],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
_bounded_docker_output(result, "local image inspection") _bounded_docker_output(result, "local image inspection")
inspected = _load_docker_inspection(result.stdout) inspected = _load_docker_inspection(result.stdout)
@ -2019,6 +2164,7 @@ def _local_tag_config_id(
*, *,
repository: str, repository: str,
tag: str, tag: str,
authority: _DockerCacheLock | None = None,
) -> str | None: ) -> str | None:
_require( _require(
repository in {LOCAL_BUILD_REPOSITORY, IMAGE_REPOSITORY} repository in {LOCAL_BUILD_REPOSITORY, IMAGE_REPOSITORY}
@ -2034,6 +2180,7 @@ def _local_tag_config_id(
["image", "ls", "--no-trunc", "--format", "{{json .}}", repository], ["image", "ls", "--no-trunc", "--format", "{{json .}}", repository],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
_bounded_docker_output(inventory, "local image inventory") _bounded_docker_output(inventory, "local image inventory")
matching_ids: set[str] = set() matching_ids: set[str] = set()
@ -2059,7 +2206,13 @@ def _local_tag_config_id(
if not matching_ids: if not matching_ids:
return None return None
inventory_config_id = next(iter(matching_ids)) inventory_config_id = next(iter(matching_ids))
inspected = _inspect_local_image(docker_binary, docker_host, docker_config, reference) inspected = _inspect_local_image(
docker_binary,
docker_host,
docker_config,
reference,
authority=authority,
)
config_id = inspected.get("Id") config_id = inspected.get("Id")
_require(config_id == inventory_config_id, "local image inventory and inspection disagree") _require(config_id == inventory_config_id, "local image inventory and inspection disagree")
return inventory_config_id return inventory_config_id
@ -2084,6 +2237,44 @@ def _target_repository_digests(inspected: dict[str, Any]) -> list[str]:
return matching return matching
def _validate_receipt_context_separation(
context: Path,
output_receipt: Path,
*,
output_parent_descriptor: int | None = None,
) -> None:
_require(
context.is_absolute() and output_receipt.is_absolute() and output_receipt.name not in {"", ".", ".."},
"build/push context and receipt paths must be absolute",
)
context_descriptor, _context_chain = _open_directory_chain_nofollow(
context,
"prepared image context",
)
output_descriptor: int | None = None
try:
output_descriptor, output_chain = _open_directory_chain_nofollow(
output_receipt.parent,
"build/push receipt parent",
)
context_stat = os.fstat(context_descriptor)
context_identity = (context_stat.st_dev, context_stat.st_ino)
if output_parent_descriptor is not None:
held_parent = os.fstat(output_parent_descriptor)
_require(
stat.S_ISDIR(held_parent.st_mode) and (held_parent.st_dev, held_parent.st_ino) == output_chain[-1],
"build/push receipt parent identity drifted",
)
_require(
context_identity not in output_chain,
"build/push receipt must be outside the image context",
)
finally:
if output_descriptor is not None:
os.close(output_descriptor)
os.close(context_descriptor)
def build_and_push_staging_image( def build_and_push_staging_image(
repo_root: Path, repo_root: Path,
context: Path, context: Path,
@ -2101,15 +2292,12 @@ def build_and_push_staging_image(
validate_reviewed_context_source(repo_root, context, image_input) validate_reviewed_context_source(repo_root, context, image_input)
_validate_docker_authority(docker_binary, docker_host, docker_config) _validate_docker_authority(docker_binary, docker_host, docker_config)
_validate_receipt_output_path(output_receipt) _validate_receipt_output_path(output_receipt)
_require( _validate_receipt_context_separation(context, output_receipt)
context != output_receipt and context not in output_receipt.parents,
"build/push receipt must be outside the image context",
)
build_tag = f"input-{image_input['input_sha256']}" build_tag = f"input-{image_input['input_sha256']}"
build_reference = f"{LOCAL_BUILD_REPOSITORY}:{build_tag}" build_reference = f"{LOCAL_BUILD_REPOSITORY}:{build_tag}"
try: try:
with _DockerCacheLock(docker_config): with _DockerCacheLock(docker_config) as authority:
_require( _require(
_local_tag_config_id( _local_tag_config_id(
docker_binary, docker_binary,
@ -2117,6 +2305,7 @@ def build_and_push_staging_image(
docker_config, docker_config,
repository=LOCAL_BUILD_REPOSITORY, repository=LOCAL_BUILD_REPOSITORY,
tag=build_tag, tag=build_tag,
authority=authority,
) )
is None, is None,
"local build tag must be absent before build", "local build tag must be absent before build",
@ -2142,9 +2331,16 @@ def build_and_push_staging_image(
command, command,
check=True, check=True,
timeout=1800, timeout=1800,
authority=authority,
) )
_bounded_docker_output(built, "staging image build") _bounded_docker_output(built, "staging image build")
built_image = _inspect_local_image(docker_binary, docker_host, docker_config, build_reference) built_image = _inspect_local_image(
docker_binary,
docker_host,
docker_config,
build_reference,
authority=authority,
)
observed = validate_image_configuration(image_input, built_image) observed = validate_image_configuration(image_input, built_image)
build_config_id = observed["config_digest"] build_config_id = observed["config_digest"]
_require( _require(
@ -2154,6 +2350,7 @@ def build_and_push_staging_image(
docker_config, docker_config,
repository=LOCAL_BUILD_REPOSITORY, repository=LOCAL_BUILD_REPOSITORY,
tag=build_tag, tag=build_tag,
authority=authority,
) )
== build_config_id, == build_config_id,
"local build tag differs from the inspected image", "local build tag differs from the inspected image",
@ -2178,6 +2375,7 @@ def build_and_push_staging_image(
docker_config, docker_config,
repository=IMAGE_REPOSITORY, repository=IMAGE_REPOSITORY,
tag=candidate_tag, tag=candidate_tag,
authority=authority,
) )
is None, is None,
"content-bound staging candidate tag already exists locally", "content-bound staging candidate tag already exists locally",
@ -2189,6 +2387,7 @@ def build_and_push_staging_image(
["image", "tag", build_reference, candidate_reference], ["image", "tag", build_reference, candidate_reference],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
_bounded_docker_output(tagged, "staging candidate tag") _bounded_docker_output(tagged, "staging candidate tag")
_require( _require(
@ -2198,6 +2397,7 @@ def build_and_push_staging_image(
docker_config, docker_config,
repository=IMAGE_REPOSITORY, repository=IMAGE_REPOSITORY,
tag=candidate_tag, tag=candidate_tag,
authority=authority,
) )
== build_config_id, == build_config_id,
"staging candidate tag differs from the built image", "staging candidate tag differs from the built image",
@ -2207,6 +2407,7 @@ def build_and_push_staging_image(
docker_host, docker_host,
docker_config, docker_config,
candidate_reference, candidate_reference,
authority=authority,
) )
_require( _require(
validate_image_configuration(image_input, pre_push_image) == observed, validate_image_configuration(image_input, pre_push_image) == observed,
@ -2223,6 +2424,7 @@ def build_and_push_staging_image(
["image", "push", "--quiet", candidate_reference], ["image", "push", "--quiet", candidate_reference],
check=True, check=True,
timeout=1800, timeout=1800,
authority=authority,
) )
_bounded_docker_output(pushed, "staging image push") _bounded_docker_output(pushed, "staging image push")
post_push_image = _inspect_local_image( post_push_image = _inspect_local_image(
@ -2230,6 +2432,7 @@ def build_and_push_staging_image(
docker_host, docker_host,
docker_config, docker_config,
candidate_reference, candidate_reference,
authority=authority,
) )
_require( _require(
validate_image_configuration(image_input, post_push_image) == observed, validate_image_configuration(image_input, post_push_image) == observed,
@ -2243,7 +2446,12 @@ def build_and_push_staging_image(
receipt = build_build_push_receipt(image_input, post_push_image=post_push_image) receipt = build_build_push_receipt(image_input, post_push_image=post_push_image)
except (OSError, subprocess.SubprocessError, PackageError) as exc: except (OSError, subprocess.SubprocessError, PackageError) as exc:
raise PackageError("staging image build/push failed") from exc raise PackageError("staging image build/push failed") from exc
publish_build_push_receipt(output_receipt, receipt, image_input=image_input) publish_build_push_receipt(
output_receipt,
receipt,
image_input=image_input,
outside_context=context,
)
return receipt return receipt
@ -2252,6 +2460,8 @@ def _local_image_reference_config_id(
docker_host: str, docker_host: str,
docker_config: Path, docker_config: Path,
image_reference: str, image_reference: str,
*,
authority: _DockerCacheLock | None = None,
) -> str | None: ) -> str | None:
inventory = _run_docker( inventory = _run_docker(
docker_binary, docker_binary,
@ -2260,6 +2470,7 @@ def _local_image_reference_config_id(
["image", "ls", "--digests", "--no-trunc", "--format", "{{json .}}", IMAGE_REPOSITORY], ["image", "ls", "--digests", "--no-trunc", "--format", "{{json .}}", IMAGE_REPOSITORY],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
_require(len(inventory.stdout) <= MAX_DOCKER_INSPECTION_BYTES, "local image inventory output is too large") _require(len(inventory.stdout) <= MAX_DOCKER_INSPECTION_BYTES, "local image inventory output is too large")
match = IMAGE_REFERENCE.fullmatch(image_reference) match = IMAGE_REFERENCE.fullmatch(image_reference)
@ -2295,6 +2506,7 @@ def _local_image_reference_config_id(
["image", "inspect", image_reference], ["image", "inspect", image_reference],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
inspected = _load_docker_inspection(result.stdout) inspected = _load_docker_inspection(result.stdout)
_require(isinstance(inspected, list) and len(inspected) == 1, "local image reference inspection is not exact") _require(isinstance(inspected, list) and len(inspected) == 1, "local image reference inspection is not exact")
@ -2329,7 +2541,7 @@ def inspect_registry_image(
_validate_docker_authority(docker_binary, docker_host, docker_config) _validate_docker_authority(docker_binary, docker_host, docker_config)
inspection: dict[str, Any] | None = None inspection: dict[str, Any] | None = None
try: try:
with _DockerCacheLock(docker_config): with _DockerCacheLock(docker_config) as authority:
_run_docker( _run_docker(
docker_binary, docker_binary,
docker_host, docker_host,
@ -2337,12 +2549,14 @@ def inspect_registry_image(
["pull", "--quiet", "--platform", PLATFORM, image_reference], ["pull", "--quiet", "--platform", PLATFORM, image_reference],
check=True, check=True,
timeout=600, timeout=600,
authority=authority,
) )
pulled_config_id = _local_image_reference_config_id( pulled_config_id = _local_image_reference_config_id(
docker_binary, docker_binary,
docker_host, docker_host,
docker_config, docker_config,
image_reference, image_reference,
authority=authority,
) )
_require(pulled_config_id is not None, "pulled registry image reference is missing") _require(pulled_config_id is not None, "pulled registry image reference is missing")
result = _run_docker( result = _run_docker(
@ -2352,6 +2566,7 @@ def inspect_registry_image(
["image", "inspect", image_reference], ["image", "inspect", image_reference],
check=True, check=True,
timeout=60, timeout=60,
authority=authority,
) )
inspected = _load_docker_inspection(result.stdout) inspected = _load_docker_inspection(result.stdout)
_require(isinstance(inspected, list) and len(inspected) == 1, "registry image inspection is not exact") _require(isinstance(inspected, list) and len(inspected) == 1, "registry image inspection is not exact")
@ -2625,16 +2840,7 @@ def _rename_noreplace(source: Path, destination: Path) -> None:
def _open_private_operator_directory(directory: Path, label: str) -> int: def _open_private_operator_directory(directory: Path, label: str) -> int:
_require(directory.is_absolute(), f"{label} must be absolute") descriptor, _identities = _open_directory_chain_nofollow(directory, label)
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(directory, flags)
except OSError as exc:
raise PackageError(f"{label} is missing or unsafe") from exc
try: try:
observed = os.fstat(descriptor) observed = os.fstat(descriptor)
named = os.stat(directory, follow_symlinks=False) named = os.stat(directory, follow_symlinks=False)
@ -2767,11 +2973,14 @@ def publish_build_push_receipt(
receipt: dict[str, Any], receipt: dict[str, Any],
*, *,
image_input: dict[str, Any], image_input: dict[str, Any],
outside_context: Path | None = None,
) -> None: ) -> None:
"""Atomically publish one validated, self-hashed build/push receipt.""" """Atomically publish one validated, self-hashed build/push receipt."""
validate_build_push_receipt(receipt, image_input=image_input) validate_build_push_receipt(receipt, image_input=image_input)
_validate_receipt_output_path(output_receipt) _validate_receipt_output_path(output_receipt)
if outside_context is not None:
_validate_receipt_context_separation(outside_context, output_receipt)
try: try:
payload = (json.dumps(receipt, allow_nan=False, indent=2, sort_keys=True) + "\n").encode("utf-8") payload = (json.dumps(receipt, allow_nan=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
@ -2784,6 +2993,12 @@ def publish_build_push_receipt(
descriptor: int | None = None descriptor: int | None = None
identity: tuple[int, int] | None = None identity: tuple[int, int] | None = None
try: try:
if outside_context is not None:
_validate_receipt_context_separation(
outside_context,
output_receipt,
output_parent_descriptor=parent_descriptor,
)
_require( _require(
_directory_entry_stat(parent_descriptor, output_receipt.name) is None, _directory_entry_stat(parent_descriptor, output_receipt.name) is None,
"build/push receipt output already exists", "build/push receipt output already exists",
@ -2834,6 +3049,12 @@ def publish_build_push_receipt(
label="retained build/push receipt link", label="retained build/push receipt link",
) )
_fsync_directory_descriptor(parent_descriptor) _fsync_directory_descriptor(parent_descriptor)
if outside_context is not None:
_validate_receipt_context_separation(
outside_context,
output_receipt,
output_parent_descriptor=parent_descriptor,
)
except (OSError, PackageError) as exc: except (OSError, PackageError) as exc:
if isinstance(exc, PackageError): if isinstance(exc, PackageError):
raise raise

View file

@ -9,6 +9,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -1138,6 +1139,277 @@ def test_build_push_requires_explicit_execution_flag_before_validation(tmp_path:
) )
def test_build_push_rejects_writable_docker_config_before_docker(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_input = image_input_fixture(tmp_path)
build_args = {
"ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"],
"IDENTITY_SHA256": image_input["identity"]["bundle_sha256"],
"INPUT_SHA256": image_input["input_sha256"],
"TELEO_REVISION": image_input["runtime"]["teleo_git_head"],
}
context = tmp_path / "context"
context.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
output = tmp_path / "writable-config-build-push-receipt.json"
docker_binary, docker_host, docker_config = docker_authority(tmp_path)
docker_config.chmod(0o777)
docker_calls: list[list[str]] = []
monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args))
monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None)
monkeypatch.setattr(
package.subprocess,
"run",
lambda arguments, **_kwargs: docker_calls.append(arguments),
)
with pytest.raises(package.PackageError, match="Docker config directory"):
package.build_and_push_staging_image(
repo_root,
context,
docker_binary=docker_binary,
docker_host=docker_host,
docker_config=docker_config,
output_receipt=output,
execute_staging_push=True,
)
assert docker_calls == []
assert not output.exists()
def test_docker_authority_rejects_nonsticky_writable_ancestor(tmp_path: Path) -> None:
docker_binary, docker_host, _docker_config = docker_authority(tmp_path)
shared = tmp_path / "shared"
shared.mkdir(mode=0o777)
shared.chmod(0o777)
operator = shared / "operator"
operator.mkdir(mode=0o700)
docker_config = operator / "docker-config"
docker_config.mkdir(mode=0o700)
with pytest.raises(package.PackageError, match="trusted ancestors"):
package._validate_docker_authority(docker_binary, docker_host, docker_config)
def test_docker_authority_accepts_sticky_root_with_private_operator_directory() -> None:
sticky_root = Path("/private/tmp") if sys.platform == "darwin" else Path("/tmp")
operator = Path(tempfile.mkdtemp(prefix="teleo-docker-authority-", dir=sticky_root))
operator.chmod(0o700)
try:
docker_binary = operator / "docker"
docker_binary.write_text("", encoding="utf-8")
docker_binary.chmod(0o755)
docker_config = operator / "docker-config"
docker_config.mkdir(mode=0o700)
package._validate_docker_authority(
docker_binary,
"unix:///tmp/livingip-docker.sock",
docker_config,
)
with package._DockerCacheLock(docker_config) as authority:
authority.revalidate(docker_config)
subprocess_config, pass_fds = authority.subprocess_config_authority()
assert subprocess_config == "."
assert len(pass_fds) == 1
finally:
shutil.rmtree(operator)
def test_run_docker_uses_retained_config_during_mid_subprocess_swap_restore(
tmp_path: Path,
) -> None:
docker_binary = tmp_path / "bin" / "docker"
docker_binary.parent.mkdir()
docker_binary.write_text(
"""#!/usr/bin/env python3
import sys
from pathlib import Path
config = Path(sys.argv[sys.argv.index("--config") + 1])
original = Path(sys.argv[-2])
retained = Path(sys.argv[-1])
original.rename(retained)
original.mkdir(mode=0o700)
(original / "marker").write_text("attacker", encoding="utf-8")
try:
print((config / "marker").read_text(encoding="utf-8"))
finally:
(original / "marker").unlink()
original.rmdir()
retained.rename(original)
""",
encoding="utf-8",
)
docker_binary.chmod(0o755)
docker_config = tmp_path / "docker-config"
docker_config.mkdir(mode=0o700)
(docker_config / "marker").write_text("trusted", encoding="utf-8")
retained_config = tmp_path / "retained-docker-config"
result = package._run_docker(
docker_binary,
"unix:///tmp/livingip-docker.sock",
docker_config,
["probe", str(docker_config), str(retained_config)],
check=True,
timeout=30,
)
assert result.stdout == b"trusted\n"
assert (docker_config / "marker").read_text(encoding="utf-8") == "trusted"
assert not retained_config.exists()
def test_build_push_rejects_replaced_docker_config_before_mutation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_input = image_input_fixture(tmp_path)
build_args = {
"ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"],
"IDENTITY_SHA256": image_input["identity"]["bundle_sha256"],
"INPUT_SHA256": image_input["input_sha256"],
"TELEO_REVISION": image_input["runtime"]["teleo_git_head"],
}
context = tmp_path / "context"
context.mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
output = tmp_path / "replaced-config-build-push-receipt.json"
docker_binary, docker_host, docker_config = docker_authority(tmp_path)
retained_config = tmp_path / "retained-docker-config"
docker_calls: list[list[str]] = []
monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args))
monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None)
def replace_config_before_build(*_args: object, **_kwargs: object) -> None:
docker_config.rename(retained_config)
docker_config.mkdir(mode=0o700)
return None
monkeypatch.setattr(package, "_local_tag_config_id", replace_config_before_build)
monkeypatch.setattr(
package.subprocess,
"run",
lambda arguments, **_kwargs: docker_calls.append(arguments),
)
with pytest.raises(package.PackageError, match="staging image build/push failed") as raised:
package.build_and_push_staging_image(
repo_root,
context,
docker_binary=docker_binary,
docker_host=docker_host,
docker_config=docker_config,
output_receipt=output,
execute_staging_push=True,
)
assert "identity or posture drifted" in str(raised.value.__cause__)
assert docker_calls == []
assert not output.exists()
def test_build_push_rejects_receipt_parent_symlink_alias_before_docker(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_input = image_input_fixture(tmp_path)
build_args = {
"ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"],
"IDENTITY_SHA256": image_input["identity"]["bundle_sha256"],
"INPUT_SHA256": image_input["input_sha256"],
"TELEO_REVISION": image_input["runtime"]["teleo_git_head"],
}
context = tmp_path / "context"
context.mkdir()
(context / "receipts").mkdir()
alias = tmp_path / "context-alias"
alias.symlink_to(context, target_is_directory=True)
output = alias / "receipts" / "receipt.json"
repo_root = tmp_path / "repo"
repo_root.mkdir()
docker_binary, docker_host, docker_config = docker_authority(tmp_path)
docker_calls: list[list[str]] = []
monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args))
monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None)
monkeypatch.setattr(
package.subprocess,
"run",
lambda arguments, **_kwargs: docker_calls.append(arguments),
)
with pytest.raises(package.PackageError, match="symbolic link"):
package.build_and_push_staging_image(
repo_root,
context,
docker_binary=docker_binary,
docker_host=docker_host,
docker_config=docker_config,
output_receipt=output,
execute_staging_push=True,
)
assert docker_calls == []
assert not output.exists()
def test_build_push_rechecks_receipt_context_separation_at_publication(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_input = image_input_fixture(tmp_path)
build_args = {
"ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"],
"IDENTITY_SHA256": image_input["identity"]["bundle_sha256"],
"INPUT_SHA256": image_input["input_sha256"],
"TELEO_REVISION": image_input["runtime"]["teleo_git_head"],
}
context = tmp_path / "context"
context.mkdir()
(context / "receipts").mkdir()
repo_root = tmp_path / "repo"
repo_root.mkdir()
output_parent = tmp_path / "receipts"
output_parent.mkdir()
retained_parent = tmp_path / "retained-receipts"
output = output_parent / "receipt.json"
docker_binary, docker_host, docker_config = docker_authority(tmp_path)
fake_run, state, calls = mocked_build_push_docker(image_input)
original_publish = package.publish_build_push_receipt
monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args))
monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None)
monkeypatch.setattr(package.subprocess, "run", fake_run)
def alias_parent_before_publish(*args: object, **kwargs: object) -> None:
output_parent.rename(retained_parent)
output_parent.symlink_to(context / "receipts", target_is_directory=True)
original_publish(*args, **kwargs)
monkeypatch.setattr(package, "publish_build_push_receipt", alias_parent_before_publish)
with pytest.raises(package.PackageError, match="symbolic link"):
package.build_and_push_staging_image(
repo_root,
context,
docker_binary=docker_binary,
docker_host=docker_host,
docker_config=docker_config,
output_receipt=output,
execute_staging_push=True,
)
assert state == {"build": True, "candidate": True, "digests": True, "pushes": 1}
assert calls
assert not output.exists()
assert not (context / "receipts" / "receipt.json").exists()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"references", "references",
[ [
@ -1437,6 +1709,41 @@ def test_build_push_receipt_publication_rolls_back_failed_parent_fsync(
assert retained[0].stat().st_ino == output.stat().st_ino assert retained[0].stat().st_ino == output.stat().st_ino
def test_build_push_receipt_detects_parent_replacement_after_publication(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
image_input = image_input_fixture(tmp_path)
receipt = build_push_receipt_fixture(image_input)
context = tmp_path / "prepared-context"
context.mkdir()
output_parent = tmp_path / "operator-receipts"
output_parent.mkdir()
retained_parent = tmp_path / "retained-operator-receipts"
output = output_parent / "receipt.json"
original_fsync = package._fsync_directory_descriptor
def replace_parent_after_fsync(descriptor: int) -> None:
original_fsync(descriptor)
output_parent.rename(retained_parent)
output_parent.mkdir(mode=0o700)
monkeypatch.setattr(package, "_fsync_directory_descriptor", replace_parent_after_fsync)
with pytest.raises(package.PackageError, match="parent identity drifted"):
package.publish_build_push_receipt(
output,
receipt,
image_input=image_input,
outside_context=context,
)
assert not output.exists()
retained_output = retained_parent / output.name
assert retained_output.exists()
assert retained_output.stat().st_nlink == 2
def test_release_cross_binds_receipt_registry_and_independent_inspection(tmp_path: Path) -> None: def test_release_cross_binds_receipt_registry_and_independent_inspection(tmp_path: Path) -> None:
image_input = image_input_fixture(tmp_path) image_input = image_input_fixture(tmp_path)
reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}"
@ -1818,7 +2125,7 @@ def test_registry_inspection_absent_pull_dependent_retains_new_reference(
image_input = image_input_fixture(tmp_path) image_input = image_input_fixture(tmp_path)
reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}"
docker_binary, docker_host, docker_config = docker_authority(tmp_path) docker_binary, docker_host, docker_config = docker_authority(tmp_path)
prefix = [str(docker_binary), "--host", docker_host, "--config", str(docker_config)] prefix = [str(docker_binary), "--host", docker_host, "--config"]
calls: list[list[str]] = [] calls: list[list[str]] = []
environments: list[dict[str, str]] = [] environments: list[dict[str, str]] = []
state = {"present": False, "dependent": False, "inspection_calls": 0} state = {"present": False, "dependent": False, "inspection_calls": 0}
@ -1829,7 +2136,10 @@ def test_registry_inspection_absent_pull_dependent_retains_new_reference(
def fake_run(arguments: list[str], **kwargs): def fake_run(arguments: list[str], **kwargs):
calls.append(arguments) calls.append(arguments)
environments.append(kwargs["env"]) environments.append(kwargs["env"])
assert arguments[:5] == prefix assert arguments[:4] == prefix
assert arguments[4] == "."
assert len(kwargs["pass_fds"]) == 1
assert kwargs["preexec_fn"] is not None
operation = arguments[5:] operation = arguments[5:]
if operation[:2] == ["image", "ls"]: if operation[:2] == ["image", "ls"]:
return subprocess.CompletedProcess( return subprocess.CompletedProcess(