From 221b7dd16ed17fc5cab1817813b7cb6aa3182b32 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:49:08 +0000 Subject: [PATCH 1/4] Stage the selected public CA within the Compose workspace --- .../action.yml | 2 +- qualification/README.md | 23 +++++ scripts/composer_container_trust.py | 93 ++++++++++++++++++- tests/test_composer_container_trust.py | 79 ++++++++++++++++ 4 files changed, 195 insertions(+), 2 deletions(-) diff --git a/.github/actions/verify-composer-container-trust/action.yml b/.github/actions/verify-composer-container-trust/action.yml index ab3d6c2..3a1952c 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. diff --git a/qualification/README.md b/qualification/README.md index b98ca4a..628f7cb 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -187,6 +187,29 @@ 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. + +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 "$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. + 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/scripts/composer_container_trust.py b/scripts/composer_container_trust.py index 0ef3082..1262a99 100644 --- a/scripts/composer_container_trust.py +++ b/scripts/composer_container_trust.py @@ -14,7 +14,10 @@ import os from pathlib import Path, PurePosixPath import re +import stat import subprocess +import sys +import tempfile from typing import Any from urllib.parse import urlsplit @@ -148,13 +151,90 @@ 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) + candidate = Path(bundle_name) + if candidate.is_symlink(): + raise ContainerTrustRefused("The selected CA file must not be a symbolic link.") + bundle = candidate.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.") return bundle, expected +def regular_ca_bytes(path: Path, expected: str) -> bytes: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 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: + raise ContainerTrustRefused("The selected CA object or authenticated bytes changed.") + return contents + finally: + os.close(descriptor) + + +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 = directory.resolve(strict=True) + if 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"]) + state = Path(tempfile.mkdtemp(prefix=".dw-composer-ca-", dir=directory)) + selected = state / "ca.pem" + try: + descriptor = os.open(selected, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) + with os.fdopen(descriptor, "wb") as stream: + stream.write(contents) + if regular_ca_bytes(selected, outputs["ca-sha256"]) != contents: + raise ContainerTrustRefused("The staged CA differs from its authenticated source.") + info = selected.stat(follow_symlinks=False) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o400: + raise ContainerTrustRefused("The staged CA must remain an owned read-only file.") + outputs["ca-file"] = str(selected) + return {"sha256": outputs["ca-sha256"], "size": len(contents), "mode": 0o400} + except BaseException: + selected.unlink(missing_ok=True) + state.rmdir() + raise + + +def cleanup_compose_ca(selected: Path, directory: Path, expected: str) -> None: + directory = directory.resolve(strict=True) + state = selected.parent + if (not SHA256.fullmatch(expected) or not selected.is_absolute() or selected.name != "ca.pem" + or state.parent != directory or not re.fullmatch(r"\.dw-composer-ca-[a-z0-9_]+", state.name) + or state.is_symlink() or selected.is_symlink()): + raise ContainerTrustRefused("Only the exact owned staged CA can be removed.") + info = state.stat() + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700 or set(state.iterdir()) != {selected}: + raise ContainerTrustRefused("The staged CA directory identity or contents changed.") + regular_ca_bytes(selected, expected) + selected.unlink() + state.rmdir() + + +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 +356,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 +365,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 +389,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/tests/test_composer_container_trust.py b/tests/test_composer_container_trust.py index 45348dc..e0d39e0 100644 --- a/tests/test_composer_container_trust.py +++ b/tests/test_composer_container_trust.py @@ -207,6 +207,76 @@ 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): + with self.subTest(directory=directory), self.assertRaises(ContainerTrustRefused): + container_trust.stage_compose_ca(outputs.copy(), 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) + with self.assertRaises(ContainerTrustRefused): + container_trust.stage_compose_ca(outputs.copy(), 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"]) + def test_actual_context_rejects_tracked_staged_untracked_and_ignored_bytes(self): runtime = self.source / "runtime.php" runtime.write_text(" Date: Fri, 13 Nov 2026 12:06:20 +0000 Subject: [PATCH 2/4] Confine staged CA operations and isolate Python entry points --- .../action.yml | 2 +- qualification/README.md | 7 +- scripts/composer_container_trust.py | 156 +++++++++++++----- tests/test_composer_container_trust.py | 117 ++++++++++++- 4 files changed, 236 insertions(+), 46 deletions(-) diff --git a/.github/actions/verify-composer-container-trust/action.yml b/.github/actions/verify-composer-container-trust/action.yml index 3a1952c..19e87d6 100644 --- a/.github/actions/verify-composer-container-trust/action.yml +++ b/.github/actions/verify-composer-container-trust/action.yml @@ -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 628f7cb..7e21920 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -193,13 +193,18 @@ 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 "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca \ + python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca \ --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . fi ``` diff --git a/scripts/composer_container_trust.py b/scripts/composer_container_trust.py index 1262a99..9242df7 100644 --- a/scripts/composer_container_trust.py +++ b/scripts/composer_container_trust.py @@ -9,15 +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 -import tempfile from typing import Any from urllib.parse import urlsplit @@ -151,74 +152,149 @@ 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.") - candidate = Path(bundle_name) - if candidate.is_symlink(): - raise ContainerTrustRefused("The selected CA file must not be a symbolic link.") - bundle = candidate.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 regular_ca_bytes(path: Path, expected: str) -> bytes: - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) +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 not 0 < before.st_size <= 8 * 1024 * 1024: + 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: + 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.") - return contents + 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: + with 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 = directory.resolve(strict=True) - if not directory.is_dir() or directory.stat().st_uid != os.getuid(): + 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"]) - state = Path(tempfile.mkdtemp(prefix=".dw-composer-ca-", dir=directory)) - selected = state / "ca.pem" - try: - descriptor = os.open(selected, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400) - with os.fdopen(descriptor, "wb") as stream: - stream.write(contents) - if regular_ca_bytes(selected, outputs["ca-sha256"]) != contents: - raise ContainerTrustRefused("The staged CA differs from its authenticated source.") - info = selected.stat(follow_symlinks=False) - if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o400: - raise ContainerTrustRefused("The staged CA must remain an owned read-only file.") - outputs["ca-file"] = str(selected) - return {"sha256": outputs["ca-sha256"], "size": len(contents), "mode": 0o400} - except BaseException: - selected.unlink(missing_ok=True) - state.rmdir() - raise + 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("ca.pem", 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, "ca.pem", 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 / "ca.pem") + return {"sha256": outputs["ca-sha256"], "size": len(contents), "mode": 0o400} + except BaseException: + if "ca.pem" in os.listdir(state): + os.unlink("ca.pem", 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: - directory = directory.resolve(strict=True) + 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 if (not SHA256.fullmatch(expected) or not selected.is_absolute() or selected.name != "ca.pem" or state.parent != directory or not re.fullmatch(r"\.dw-composer-ca-[a-z0-9_]+", state.name) or state.is_symlink() or selected.is_symlink()): raise ContainerTrustRefused("Only the exact owned staged CA can be removed.") - info = state.stat() - if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700 or set(state.iterdir()) != {selected}: - raise ContainerTrustRefused("The staged CA directory identity or contents changed.") - regular_ca_bytes(selected, expected) - selected.unlink() - state.rmdir() + with confined_directory(directory, workspace) as compose: + with confined_directory(state, directory) as held_state: + info = os.fstat(held_state) + if stat.S_IMODE(info.st_mode) != 0o700 or set(os.listdir(held_state)) != {"ca.pem"}: + raise ContainerTrustRefused("The staged CA directory identity or contents changed.") + with verified_ca(held_state, "ca.pem", 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(state.name, dir_fd=compose, follow_symlinks=False))): + raise ContainerTrustRefused("The staged cleanup parent or file identity changed.") + os.unlink("ca.pem", dir_fd=held_state) + if not same_object(info, os.stat(state.name, dir_fd=compose, follow_symlinks=False)): + raise ContainerTrustRefused("The staged cleanup directory changed before removal.") + os.rmdir(state.name, dir_fd=compose) def cleanup_main(arguments: list[str]) -> int: diff --git a/tests/test_composer_container_trust.py b/tests/test_composer_container_trust.py index e0d39e0..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): @@ -263,19 +267,124 @@ def test_staged_ca_cleanup_refuses_other_files_and_changed_state(self): 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(outputs.copy(), directory, self.source) + 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(outputs.copy(), linked, self.source) + 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"]) + 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" From 06efa32de898cff5766ef9f3a9db6a368fced981 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:11:48 +0000 Subject: [PATCH 3/4] Use a closed staged-directory name for descriptor cleanup --- scripts/composer_container_trust.py | 51 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/scripts/composer_container_trust.py b/scripts/composer_container_trust.py index 9242df7..6e3be41 100644 --- a/scripts/composer_container_trust.py +++ b/scripts/composer_container_trust.py @@ -27,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" ) @@ -226,9 +227,9 @@ def verified_ca(directory: int, name: str, expected: str): 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: - with verified_ca(directory, selected.name, expected) as (contents, _): - return contents + 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]: @@ -251,19 +252,19 @@ def stage_compose_ca(outputs: dict[str, str], directory: Path, source: Path) -> 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("ca.pem", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=state) + 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, "ca.pem", outputs["ca-sha256"]) as (copied, info): + 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 / "ca.pem") + outputs["ca-file"] = str(directory / name / STAGED_CA_FILE) return {"sha256": outputs["ca-sha256"], "size": len(contents), "mode": 0o400} except BaseException: - if "ca.pem" in os.listdir(state): - os.unlink("ca.pem", dir_fd=state) + 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 @@ -277,24 +278,26 @@ def cleanup_compose_ca(selected: Path, directory: Path, expected: str) -> None: if not directory.is_relative_to(workspace): raise ContainerTrustRefused("Staged cleanup must remain inside the trusted runner workspace.") state = selected.parent - if (not SHA256.fullmatch(expected) or not selected.is_absolute() or selected.name != "ca.pem" - or state.parent != directory or not re.fullmatch(r"\.dw-composer-ca-[a-z0-9_]+", state.name) + 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.") - with confined_directory(directory, workspace) as compose: - with confined_directory(state, directory) as held_state: - info = os.fstat(held_state) - if stat.S_IMODE(info.st_mode) != 0o700 or set(os.listdir(held_state)) != {"ca.pem"}: - raise ContainerTrustRefused("The staged CA directory identity or contents changed.") - with verified_ca(held_state, "ca.pem", 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(state.name, dir_fd=compose, follow_symlinks=False))): - raise ContainerTrustRefused("The staged cleanup parent or file identity changed.") - os.unlink("ca.pem", dir_fd=held_state) - if not same_object(info, os.stat(state.name, dir_fd=compose, follow_symlinks=False)): - raise ContainerTrustRefused("The staged cleanup directory changed before removal.") - os.rmdir(state.name, dir_fd=compose) + 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: From 259081547a1028d73e7e8aa59b9fd7b5a301d544 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:34:56 +0000 Subject: [PATCH 4/4] Admit isolated container verifier and retire legacy container paths --- qualification/README.md | 7 +++++++ qualification/policy.json | 2 +- scripts/qualification_policy.py | 5 ++++- tests/test_composer_trust_contract.py | 14 ++++++++++++++ tests/test_qualification_policy.py | 24 ++++++++++++++++++++++-- 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index 7e21920..3037ff7 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -215,6 +215,13 @@ 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/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_trust_contract.py b/tests/test_composer_trust_contract.py index 2798528..acf570f 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -62,6 +62,20 @@ def verify(): with self.assertRaisesRegex(PolicyError, "reviewed unconditional clean bootstrap"): verify() + def test_clean_trust_role_cannot_authorize_a_container_verifier(self): + policy = {"targets": {"fixture": {"composer_trust_required": True}}, "action_runtime": { + "allowed_releases": {"durable-workflow/.github": {REVISION: "clean-trust", "b" * 40: "composer-container-trust"}}}} + + def verify(): + source = yaml.safe_dump({"jobs": {"connected": {"steps": [self.bootstrap, self.guard, self.build]}}}) + with mock.patch("scripts.qualification_policy._composer_container_catalog", return_value=self.catalog): + return verify_composer_network_sinks(policy, "fixture", {"ci.yml": source}, self.root) + + with self.assertRaisesRegex(PolicyError, "preceding reviewed container verifier"): + verify() + self.guard["uses"] = PREFIX + "verify-composer-container-trust@" + "b" * 40 + self.assertEqual(["ci.yml:connected:container-fixture"], verify()) + def test_missing_unreviewed_or_intervening_guard_refuses(self): with self.assertRaises(PolicyError): self.check([self.build]) diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index 83cc601..7a81c93 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -699,8 +699,8 @@ def test_accepted_private_pip_predecessor_has_only_its_literal_catalog_revision( with self.subTest(ref=ref), self.assertRaises(PolicyError): self.scan(candidate) - def test_server38_container_verifier_release_is_path_limited(self) -> 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"