diff --git a/.github/actions/verify-composer-container-trust/action.yml b/.github/actions/verify-composer-container-trust/action.yml index ab3d6c2..19e87d6 100644 --- a/.github/actions/verify-composer-container-trust/action.yml +++ b/.github/actions/verify-composer-container-trust/action.yml @@ -27,7 +27,7 @@ outputs: description: Reviewed verifier directory for the dwf_composer_trust named context. value: ${{ steps.bind.outputs.context-directory }} ca-file: - description: Authenticated CA file for the dwf_ci_ca read-only build secret. + description: Owned read-only copy of the authenticated CA inside the Compose tree, for the dwf_ci_ca build secret. value: ${{ steps.bind.outputs.ca-file }} ca-sha256: description: Required DW_CI_CA_SHA256 build argument. @@ -56,7 +56,7 @@ runs: COMPOSER_CAFILE: ${{ inputs.ca-file }} DW_CI_CA_SHA256: ${{ inputs.ca-sha256 }} run: >- - python3 "$GITHUB_ACTION_PATH/../../../scripts/composer_container_trust.py" + python3 -I -S -B "$GITHUB_ACTION_PATH/../../../scripts/composer_container_trust.py" --request "$TRUST_REQUEST" --source-directory "$TRUST_SOURCE_DIRECTORY" --source-commit "$TRUST_SOURCE_COMMIT" diff --git a/qualification/README.md b/qualification/README.md index b98ca4a..3037ff7 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -187,6 +187,41 @@ unconditional reviewed `bootstrap-patched-pip` step. Ambient or step-local CA values cannot supply that identity. Additional Docker, Compose or Buildx build commands in the registered job also refuse, including calls in shell helpers. +The guard stages only that authenticated public CA into a new owned mode-0700 +directory inside the Compose tree. It verifies a single regular, non-symlink +source object and the original SHA-256, writes a mode-0400 copy, and binds the +rendered secret to that copy. Staging stays outside the Server and verifier +image contexts. This satisfies Bake's workspace filesystem boundary without +granting extra filesystem access or changing which store is trusted. +The authenticated original must remain under the owned `RUNNER_TEMP`; staging +and cleanup stay under the owned `GITHUB_WORKSPACE`. Both Python entry points +ignore workspace imports and site configuration. Directory descriptors hold +the selected parents while reading or removing the copy; aliases and changed +parent, state or file identities refuse before removal. + +After the registered stack is stopped, an always-run caller cleanup step +uses the same guard's source and outputs, when present, to remove only the copy: + +```sh +if [[ -n "$STAGED_CA" ]]; then + python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca \ + --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . +fi +``` + +Here `VERIFIER_CONTEXT`, `STAGED_CA` and `CA_SHA256` are the guard's +`context-directory`, `ca-file` and `ca-sha256` outputs. Cleanup refuses another +path, modified bytes, symlinks, hard links or unrelated directory contents. +The bootstrap's original selected bundle remains intact. A guard failure +cleans its staged copy before publishing any outputs. + +Container verification uses only its dedicated `composer-container-trust` +release. The isolated verifier at `06efa32de898cff5766ef9f3a9db6a368fced981` +replaces the retired `d12a1e2` container release. Older `clean-trust` releases, +including `699d09d`, retain their other approved action paths but cannot supply +the container verifier. Callers must adopt the corrected verifier and complete +their actual build qualification. + The `sdk-php-cooperative-server` entry in `composer-container-trust.json` applies only to its registered Compose request and reviewed Server recipes. It supplies no trust for other build or runtime paths. diff --git a/qualification/policy.json b/qualification/policy.json index 441a1c2..f6b8a08 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -82,6 +82,7 @@ "4d407b29186a635f0cc27475ef0bc0ae605a8866": "1.86.0" }, "durable-workflow/.github": { + "06efa32de898cff5766ef9f3a9db6a368fced981": "composer-container-trust", "18783be6bef737cc778e4c7282a8797e39daa61a": "fixed-apt", "242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5": "clean-trust", "411a14ac07d32073eed6ab4977f2825eba4739a7": "bound-linux-php", @@ -90,7 +91,6 @@ "699d09d602fc0353160bdecaa2b23f07ef4c7c21": "clean-trust", "75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust", "76f333804e68725059039fd54df241d40d855cd3": "clean-trust", - "d12a1e247ea28b89912cc87a8c0b596686d8c323": "composer-container-trust", "ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10": "patched-pip" }, "helm/chart-testing-action": { diff --git a/scripts/composer_container_trust.py b/scripts/composer_container_trust.py index 0ef3082..6e3be41 100644 --- a/scripts/composer_container_trust.py +++ b/scripts/composer_container_trust.py @@ -9,12 +9,16 @@ from __future__ import annotations import argparse +from contextlib import contextmanager import hashlib import json import os from pathlib import Path, PurePosixPath import re +import secrets +import stat import subprocess +import sys from typing import Any from urllib.parse import urlsplit @@ -23,6 +27,7 @@ SCHEMA = "durable-workflow.composer-container-trust.v1" SHA256 = re.compile(r"[0-9a-f]{64}\Z") COMMIT = re.compile(r"[0-9a-f]{40}\Z") +STAGED_CA_FILE = "ca.pem" COMPOSER_IMAGE = re.compile( r"(?:docker\.io/library/)?composer(?::2\.10\.3)?@sha256:[0-9a-f]{64}\Z" ) @@ -148,13 +153,167 @@ def authenticated_bundle(environment: dict[str, str]) -> tuple[Path, str]: bundle_name = environment.get("COMPOSER_CAFILE", "") if not bundle_name or any(character in bundle_name for character in "\r\n\0"): raise ContainerTrustRefused("The clean bootstrap must select a CA file.") - bundle = Path(bundle_name).resolve(strict=True) - if (not bundle.is_file() or bundle.stat().st_size > 8 * 1024 * 1024 - or sha256(bundle.read_bytes()) != expected): - raise ContainerTrustRefused("The selected CA file differs from the authenticated bootstrap.") + bundle = no_alias_path(Path(bundle_name)) + regular_ca_bytes(bundle, expected, runner_directory("RUNNER_TEMP")) return bundle, expected +def no_alias_path(path: Path) -> Path: + absolute = path.absolute() + if ".." in absolute.parts or any(character in str(path) for character in "\r\n\0"): + raise ContainerTrustRefused("Trust paths must not contain lexical aliases or line breaks.") + if any(part.is_symlink() for part in (absolute, *absolute.parents)): + raise ContainerTrustRefused("Trust paths must not contain symbolic links.") + return absolute.resolve(strict=True) + + +def runner_directory(name: str) -> Path: + value = os.environ.get(name, "") + if not value or not Path(value).is_absolute() or any(character in value for character in "\r\n\0"): + raise ContainerTrustRefused("An absolute trusted runner directory is required.") + directory = no_alias_path(Path(value)) + if not directory.is_dir() or directory.stat().st_uid != os.getuid(): + raise ContainerTrustRefused("Trust paths need an owned runner directory.") + return directory + + +def same_object(first: os.stat_result, second: os.stat_result) -> bool: + return (first.st_dev, first.st_ino) == (second.st_dev, second.st_ino) + + +@contextmanager +def confined_directory(path: Path, allowed_root: Path): + selected = no_alias_path(path) + if not selected.is_relative_to(allowed_root): + raise ContainerTrustRefused("The selected directory is outside its trusted runner directory.") + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + identity = allowed_root.stat(follow_symlinks=False) + descriptor = os.open(allowed_root, flags) + try: + if not same_object(identity, os.fstat(descriptor)): + raise ContainerTrustRefused("The trusted runner directory changed.") + for part in selected.relative_to(allowed_root).parts: + child = os.open(part, flags, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + info = os.fstat(descriptor) + if info.st_uid != os.getuid() or not same_object(info, selected.stat(follow_symlinks=False)): + raise ContainerTrustRefused("The owned directory identity changed.") + yield descriptor + finally: + os.close(descriptor) + + +@contextmanager +def verified_ca(directory: int, name: str, expected: str): + if Path(name).name != name or name in {"", ".", ".."}: + raise ContainerTrustRefused("The CA name must be a single file name.") + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, dir_fd=directory) + try: + before = os.fstat(descriptor) + if (not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_uid != os.getuid() + or not 0 < before.st_size <= 8 * 1024 * 1024): + raise ContainerTrustRefused("The selected CA must be a single regular bounded file.") + contents = os.pread(descriptor, 8 * 1024 * 1024 + 1, 0) + after = os.fstat(descriptor) + fields = ("st_dev", "st_ino", "st_mode", "st_uid", "st_nlink", "st_size", "st_mtime_ns", "st_ctime_ns") + if (any(getattr(before, key) != getattr(after, key) for key in fields) or sha256(contents) != expected + or not same_object(after, os.stat(name, dir_fd=directory, follow_symlinks=False))): + raise ContainerTrustRefused("The selected CA object or authenticated bytes changed.") + yield contents, after + finally: + os.close(descriptor) + + +def regular_ca_bytes(path: Path, expected: str, allowed_root: Path) -> bytes: + selected = no_alias_path(path) + with (confined_directory(selected.parent, allowed_root) as directory, + verified_ca(directory, selected.name, expected) as (contents, _)): + return contents + + +def stage_compose_ca(outputs: dict[str, str], directory: Path, source: Path) -> dict[str, Any]: + """Copy only the authenticated public bundle inside Bake's allowed tree.""" + if directory.is_symlink() or any(character in str(directory) for character in "\r\n\0"): + raise ContainerTrustRefused("The Compose directory must not be a symbolic link.") + directory = no_alias_path(directory) + workspace = runner_directory("GITHUB_WORKSPACE") + if not directory.is_relative_to(workspace) or not directory.is_dir() or directory.stat().st_uid != os.getuid(): + raise ContainerTrustRefused("The staged CA needs an owned Compose directory.") + if directory.is_relative_to(source.resolve(strict=True)) or directory.is_relative_to(Path(outputs["context-directory"])): + raise ContainerTrustRefused("The staged CA must stay outside all image build contexts.") + contents = regular_ca_bytes(Path(outputs["ca-file"]), outputs["ca-sha256"], runner_directory("RUNNER_TEMP")) + with confined_directory(directory, workspace) as compose: + name = ".dw-composer-ca-" + secrets.token_hex(12) + os.mkdir(name, 0o700, dir_fd=compose) + identity = os.stat(name, dir_fd=compose, follow_symlinks=False) + state = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=compose) + try: + if (not same_object(identity, os.fstat(state)) or identity.st_uid != os.getuid() + or stat.S_IMODE(identity.st_mode) != 0o700): + raise ContainerTrustRefused("The newly created private CA directory changed.") + descriptor = os.open(STAGED_CA_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=state) + with os.fdopen(descriptor, "wb") as stream: + stream.write(contents) + with verified_ca(state, STAGED_CA_FILE, outputs["ca-sha256"]) as (copied, info): + if (copied != contents or stat.S_IMODE(info.st_mode) != 0o400 + or not same_object(os.fstat(compose), directory.stat(follow_symlinks=False)) + or not same_object(identity, os.stat(name, dir_fd=compose, follow_symlinks=False))): + raise ContainerTrustRefused("The staged CA must remain an exact read-only copy.") + outputs["ca-file"] = str(directory / name / STAGED_CA_FILE) + return {"sha256": outputs["ca-sha256"], "size": len(contents), "mode": 0o400} + except BaseException: + if STAGED_CA_FILE in os.listdir(state): + os.unlink(STAGED_CA_FILE, dir_fd=state) + if same_object(os.fstat(state), os.stat(name, dir_fd=compose, follow_symlinks=False)): + os.rmdir(name, dir_fd=compose) + raise + finally: + os.close(state) + + +def cleanup_compose_ca(selected: Path, directory: Path, expected: str) -> None: + workspace = runner_directory("GITHUB_WORKSPACE") + directory = no_alias_path(directory) + if not directory.is_relative_to(workspace): + raise ContainerTrustRefused("Staged cleanup must remain inside the trusted runner workspace.") + state = selected.parent + state_name = re.fullmatch(r"\.dw-composer-ca-[0-9a-f]{24}", state.name) + if (not SHA256.fullmatch(expected) or not selected.is_absolute() or selected.name != STAGED_CA_FILE + or state.parent != directory or state_name is None + or state.is_symlink() or selected.is_symlink()): + raise ContainerTrustRefused("Only the exact owned staged CA can be removed.") + name = state_name.group(0) + with (confined_directory(directory, workspace) as compose, + confined_directory(directory / name, directory) as held_state): + info = os.fstat(held_state) + if stat.S_IMODE(info.st_mode) != 0o700 or set(os.listdir(held_state)) != {STAGED_CA_FILE}: + raise ContainerTrustRefused("The staged CA directory identity or contents changed.") + with verified_ca(held_state, STAGED_CA_FILE, expected) as (_, file_info): + if (stat.S_IMODE(file_info.st_mode) != 0o400 + or not same_object(os.fstat(compose), directory.stat(follow_symlinks=False)) + or not same_object(info, os.stat(name, dir_fd=compose, follow_symlinks=False))): + raise ContainerTrustRefused("The staged cleanup parent or file identity changed.") + os.unlink(STAGED_CA_FILE, dir_fd=held_state) + if not same_object(info, os.stat(name, dir_fd=compose, follow_symlinks=False)): + raise ContainerTrustRefused("The staged cleanup directory changed before removal.") + os.rmdir(name, dir_fd=compose) + + +def cleanup_main(arguments: list[str]) -> int: + parser = argparse.ArgumentParser(description="Remove only a verified staged public CA copy.") + parser.add_argument("--ca-file", type=Path, required=True) + parser.add_argument("--compose-directory", type=Path, required=True) + parser.add_argument("--ca-sha256", required=True) + args = parser.parse_args(arguments) + try: + cleanup_compose_ca(args.ca_file, args.compose_directory, args.ca_sha256) + return 0 + except (ContainerTrustRefused, OSError, ValueError) as error: + print(f"Staged Composer CA cleanup refused: {type(error).__name__}.") + return 1 + + def prepare( source: Path, commit: str, request_id: str, catalog: dict[str, Any], environment: dict[str, str], verifier_directory: Path, @@ -276,6 +435,8 @@ def inspect_compose(directory: Path, source: Path, request: dict[str, Any], outp def main() -> int: + if sys.argv[1:2] == ["cleanup-ca"]: + return cleanup_main(sys.argv[2:]) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source-directory", type=Path, required=True) parser.add_argument("--source-commit", required=True) @@ -283,12 +444,16 @@ def main() -> int: parser.add_argument("--compose-directory", type=Path, required=True) parser.add_argument("--provenance", type=Path, required=True) args = parser.parse_args() + staged: Path | None = None + outputs: dict[str, str] = {} try: catalog = json.loads((ROOT / "qualification/composer-container-trust.json").read_text()) outputs, evidence = prepare( args.source_directory, args.source_commit, args.request, catalog, dict(os.environ), ROOT / ".github/actions/verify-composer-trust", ) + evidence["staged_ca"] = stage_compose_ca(outputs, args.compose_directory, args.source_directory) + staged = Path(outputs["ca-file"]) evidence["compose_services"] = inspect_compose( args.compose_directory, args.source_directory, catalog["requests"][args.request], outputs, ) @@ -303,6 +468,11 @@ def main() -> int: print(f"Verified container Composer recipe for {evidence['source_repository']} at {args.source_commit}.") return 0 except (ContainerTrustRefused, OSError, ValueError, KeyError, TypeError) as error: + if staged is not None: + try: + cleanup_compose_ca(staged, args.compose_directory, outputs["ca-sha256"]) + except (ContainerTrustRefused, OSError, ValueError): + print("Staged Composer CA changed; automatic cleanup refused.") message = str(error) if isinstance(error, ContainerTrustRefused) else "Container trust input inspection failed." print(f"Container Composer trust refused: {message}") return 1 diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 7c94970..d40c593 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -2420,6 +2420,9 @@ def _inspect_action_release( def _verify_action_release_path(repository: str, directory: str, version: str) -> None: + if (repository == CENTRAL_ACTION_REPOSITORY and directory == ".github/actions/verify-composer-container-trust" + and version != "composer-container-trust"): + raise PolicyError("The Composer container verifier requires its dedicated reviewed release") if (repository == CENTRAL_ACTION_REPOSITORY and version == "bound-linux-php" and directory != ".github/actions/setup-php-linux"): raise PolicyError("The bound Linux PHP release approves only its setup-php-linux action path") @@ -2777,7 +2780,7 @@ def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: return [] releases = policy["action_runtime"]["allowed_releases"][CENTRAL_ACTION_REPOSITORY] approved = {sha for sha, label in releases.items() if label == "clean-trust"} - container_approved = approved | {sha for sha, label in releases.items() if label == "composer-container-trust"} + container_approved = {sha for sha, label in releases.items() if label == "composer-container-trust"} bound_setups = {sha for sha, label in releases.items() if label == "bound-linux-php"} documents = {filename: _parse_yaml(source, filename) for filename, source in sources.items()} selected = _composer_scoped_jobs(scopes, documents, bound_setups, target == "github-control-plane") diff --git a/tests/test_composer_container_trust.py b/tests/test_composer_container_trust.py index 45348dc..71d901d 100644 --- a/tests/test_composer_container_trust.py +++ b/tests/test_composer_container_trust.py @@ -2,6 +2,7 @@ import json import os from pathlib import Path +import shlex import subprocess import sys import tempfile @@ -42,6 +43,9 @@ def setUp(self): "DW_CI_CA_SHA256": sha256(self.bundle.read_bytes()), "COMPOSER_CAFILE": str(self.bundle), } + authority = patch.dict(os.environ, {"GITHUB_WORKSPACE": str(self.root), "RUNNER_TEMP": str(self.root)}) + authority.start() + self.addCleanup(authority.stop) self.catalog = { "schema": "durable-workflow.composer-container-trust.v1", "requests": {"fixture": { @@ -186,7 +190,7 @@ def test_bundle_selection_and_authenticated_digest_are_both_required(self): with self.subTest(environment=environment), self.assertRaises(ContainerTrustRefused): self.check(environment=environment) self.bundle.write_bytes(b"changed CA bytes") - with self.assertRaisesRegex(ContainerTrustRefused, "authenticated bootstrap"): + with self.assertRaisesRegex(ContainerTrustRefused, "authenticated bytes"): self.check() def test_symlinked_recipe_and_missing_verifier_are_refused(self): @@ -207,6 +211,181 @@ def test_output_paths_cannot_inject_github_output_fields(self): with self.assertRaises(ContainerTrustRefused): self.check(environment={**self.environment, "COMPOSER_CAFILE": str(bundle)}) + def test_selected_ca_symlink_cannot_supply_staged_bytes(self): + linked = self.root / "linked-ca.pem" + linked.symlink_to(self.bundle) + with self.assertRaisesRegex(ContainerTrustRefused, "symbolic link"): + self.check(environment=self.environment | {"COMPOSER_CAFILE": str(linked)}) + + def test_ca_staging_preserves_exact_authority_in_private_compose_state(self): + outputs, _ = self.check() + summary = container_trust.stage_compose_ca(outputs, self.root, self.source) + selected = Path(outputs["ca-file"]) + self.assertEqual(self.bundle.read_bytes(), selected.read_bytes()) + self.assertEqual(self.root, selected.parent.parent) + self.assertEqual(0o700, selected.parent.stat().st_mode & 0o777) + self.assertEqual(0o400, selected.stat().st_mode & 0o777) + self.assertEqual({"sha256": self.environment["DW_CI_CA_SHA256"], "size": self.bundle.stat().st_size, + "mode": 0o400}, summary) + container_trust.cleanup_compose_ca(selected, self.root, outputs["ca-sha256"]) + self.assertFalse(selected.parent.exists()) + self.assertTrue(self.bundle.exists(), "The bootstrap's selected original is never removed.") + + def test_ca_changed_or_hardlinked_before_staging_refuses_without_copy(self): + for mutation in ("changed", "hardlinked"): + outputs, _ = self.check() + if mutation == "changed": + original = self.bundle.read_bytes() + self.bundle.write_bytes(b"changed bytes") + else: + os.link(self.bundle, self.root / "ca-alias") + with self.subTest(mutation=mutation), self.assertRaises(ContainerTrustRefused): + container_trust.stage_compose_ca(outputs, self.root, self.source) + self.assertEqual([], list(self.root.glob(".dw-composer-ca-*"))) + if mutation == "changed": + self.bundle.write_bytes(original) + + def test_staged_ca_cleanup_refuses_other_files_and_changed_state(self): + outputs, _ = self.check() + with self.assertRaises(ContainerTrustRefused): + container_trust.cleanup_compose_ca(self.bundle, self.root, outputs["ca-sha256"]) + container_trust.stage_compose_ca(outputs, self.root, self.source) + selected = Path(outputs["ca-file"]) + selected.chmod(0o600) + selected.write_bytes(b"changed copy") + with self.assertRaises(ContainerTrustRefused): + container_trust.cleanup_compose_ca(selected, self.root, outputs["ca-sha256"]) + self.assertTrue(selected.exists()) + selected.write_bytes(self.bundle.read_bytes()) + selected.chmod(0o400) + extra = selected.parent / "unrelated" + extra.write_text("preserve this file") + with self.assertRaises(ContainerTrustRefused): + container_trust.cleanup_compose_ca(selected, self.root, outputs["ca-sha256"]) + self.assertTrue(extra.exists()) + + def test_ca_copy_cannot_enter_source_or_verifier_context_or_follow_a_directory_link(self): + outputs, _ = self.check() + for directory in (self.source, self.verifier): + candidate = outputs.copy() + with self.subTest(directory=directory), self.assertRaises(ContainerTrustRefused): + container_trust.stage_compose_ca(candidate, directory, self.source) + self.assertEqual([], list(directory.glob(".dw-composer-ca-*"))) + linked = self.root / "linked-compose" + linked.symlink_to(self.root, target_is_directory=True) + candidate = outputs.copy() + with self.assertRaises(ContainerTrustRefused): + container_trust.stage_compose_ca(candidate, linked, self.source) + + def test_nonregular_ca_source_refuses_without_blocking(self): + fifo = self.root / "ca-fifo" + os.mkfifo(fifo) + with self.assertRaises(ContainerTrustRefused): + container_trust.regular_ca_bytes(fifo, self.environment["DW_CI_CA_SHA256"], self.root) + + def test_guard_and_cleanup_commands_ignore_workspace_and_pythonpath_imports(self): + repository = Path(container_trust.__file__).resolve().parents[1] + sources = (repository / ".github/actions/verify-composer-container-trust/action.yml", + repository / "qualification/README.md") + marker = self.root / "import-executed" + shadow = self.root / "imports" + shadow.mkdir() + (shadow / "json.py").write_text(f"from pathlib import Path\nPath({str(marker)!r}).touch()\nraise RuntimeError('shadow')\n") + environment = dict(os.environ, PYTHONPATH=str(shadow)) + script = str(Path(container_trust.__file__).resolve()) + # The predecessor command proves this fixture can intercept a real import. + predecessor = subprocess.run([sys.executable, script, "--help"], cwd=shadow, env=environment, + capture_output=True) + self.assertNotEqual(0, predecessor.returncode) + self.assertTrue(marker.exists()) + marker.unlink() + for source, arguments in zip(sources, (["--help"], ["cleanup-ca", "--help"])): + invocation = next(line.strip() for line in source.read_text().splitlines() + if line.strip().startswith("python3 ") and "composer_container_trust.py" in line) + command = shlex.split(invocation.split(' "$', 1)[0]) + with self.subTest(entrypoint=source): + result = subprocess.run([*command, script, *arguments], cwd=shadow, env=environment, + capture_output=True) + self.assertEqual(0, result.returncode, result.stderr.decode()) + self.assertFalse(marker.exists()) + + def test_authenticated_digest_cannot_authorize_a_ca_outside_runner_temp(self): + with tempfile.TemporaryDirectory() as outside: + selected = Path(outside) / "ca.pem" + selected.write_bytes(self.bundle.read_bytes()) + with self.assertRaisesRegex(ContainerTrustRefused, "outside"): + self.check(environment=self.environment | {"COMPOSER_CAFILE": str(selected)}) + + def test_staging_and_cleanup_refuse_compose_aliases_and_outside_paths(self): + compose = self.root / "compose" + compose.mkdir() + outputs, _ = self.check() + container_trust.stage_compose_ca(outputs, compose, self.source) + selected = Path(outputs["ca-file"]) + linked = self.root / "compose-link" + linked.symlink_to(compose, target_is_directory=True) + parent_link = self.root / "parent-link" + parent_link.symlink_to(self.root, target_is_directory=True) + aliases = (linked, parent_link / "compose", compose / ".." / "compose", + self.root / "compose\n", self.bundle) + for directory in aliases: + with self.subTest(directory=directory), self.assertRaises((ContainerTrustRefused, OSError)): + container_trust.cleanup_compose_ca(selected, directory, outputs["ca-sha256"]) + self.assertTrue(selected.exists()) + with tempfile.TemporaryDirectory() as outside: + candidate = outputs.copy() + with self.assertRaisesRegex(ContainerTrustRefused, "owned Compose"): + container_trust.stage_compose_ca(candidate, Path(outside), self.source) + with self.assertRaisesRegex(ContainerTrustRefused, "trusted runner workspace"): + container_trust.cleanup_compose_ca(selected, Path(outside), outputs["ca-sha256"]) + container_trust.cleanup_compose_ca(selected, compose, outputs["ca-sha256"]) + + def test_cleanup_holds_parent_and_state_identity_before_removal(self): + original_pread = os.pread + for replaced in ("compose", "state"): + compose = self.root / ("compose-" + replaced) + compose.mkdir() + outputs, _ = self.check() + container_trust.stage_compose_ca(outputs, compose, self.source) + selected = Path(outputs["ca-file"]) + target = compose if replaced == "compose" else selected.parent + preserved = target.with_name(target.name + "-original") + + def replace_parent(*arguments): + contents = original_pread(*arguments) + target.rename(preserved) + target.mkdir(mode=0o700) + unrelated = target / "ca.pem" + unrelated.write_text("unrelated replacement contents") + return contents + + with patch.object(os, "pread", side_effect=replace_parent): + with self.subTest(replaced=replaced), self.assertRaises(ContainerTrustRefused): + container_trust.cleanup_compose_ca(selected, compose, outputs["ca-sha256"]) + self.assertEqual("unrelated replacement contents", (target / "ca.pem").read_text()) + original = preserved / selected.relative_to(target) + self.assertEqual(self.bundle.read_bytes(), original.read_bytes()) + + def test_ca_file_replacement_during_read_cannot_supply_bytes_or_be_removed(self): + outputs, _ = self.check() + container_trust.stage_compose_ca(outputs, self.root, self.source) + selected = Path(outputs["ca-file"]) + original = selected.with_name("original.pem") + original_pread = os.pread + + def replace_file(*arguments): + contents = original_pread(*arguments) + selected.rename(original) + selected.write_bytes(self.bundle.read_bytes()) + selected.chmod(0o400) + return contents + + with patch.object(os, "pread", side_effect=replace_file): + with self.assertRaisesRegex(ContainerTrustRefused, "object or authenticated bytes"): + container_trust.cleanup_compose_ca(selected, self.root, outputs["ca-sha256"]) + self.assertTrue(selected.exists()) + self.assertTrue(original.exists()) + def test_actual_context_rejects_tracked_staged_untracked_and_ignored_bytes(self): runtime = self.source / "runtime.php" runtime.write_text(" None: - commit = "d12a1e247ea28b89912cc87a8c0b596686d8c323" + def test_isolated_container_verifier_release_is_path_limited(self) -> None: + commit = "06efa32de898cff5766ef9f3a9db6a368fced981" action = "durable-workflow/.github/.github/actions/verify-composer-container-trust" source = workflow(f"- uses: {action}@{commit} # composer-container-trust") predecessor = copy.deepcopy(self.policy) @@ -716,6 +716,26 @@ def test_server38_container_verifier_release_is_path_limited(self) -> None: with self.subTest(audit_path=path), self.assertRaisesRegex(PolicyError, "only its verify-composer-container-trust"): audit_action_releases(self.policy, None, {"test.yml": changed}, {}) + def test_legacy_container_verifiers_are_retired_without_removing_other_clean_trust_paths(self) -> None: + prefix = "durable-workflow/.github/.github/actions/" + retired = "d12a1e247ea28b89912cc87a8c0b596686d8c323" + legacy = "699d09d602fc0353160bdecaa2b23f07ef4c7c21" + for commit, label, reason in ((retired, "composer-container-trust", "not centrally approved"), + (legacy, "clean-trust", "dedicated reviewed release")): + source = workflow(f"- uses: {prefix}verify-composer-container-trust@{commit} # {label}") + with self.subTest(scan=commit), self.assertRaisesRegex(PolicyError, reason): + self.scan(source) + with self.subTest(audit=commit), self.assertRaisesRegex(PolicyError, reason): + audit_action_releases(self.policy, None, {"test.yml": source}, {}) + for path in ("bootstrap-patched-pip", "verify-composer-trust"): + specification = f"{prefix}{path}@{legacy}" + source = workflow(f"- uses: {specification} # clean-trust") + with self.subTest(path=path): + self.assertEqual([specification], self.scan(source)[".github/workflows/test.yml"]["external_actions"]) + cache = {specification: {"runtime": "composite", "commit": legacy, "action": prefix + path, + "reference": legacy, "repository": "durable-workflow/.github"}} + self.assertEqual(1, len(audit_action_releases(self.policy, None, {"test.yml": source}, cache))) + def test_bound_php_setup_pin_is_approved_only_for_its_exact_action_path(self) -> None: commit = "411a14ac07d32073eed6ab4977f2825eba4739a7" action = "durable-workflow/.github/.github/actions/setup-php-linux"