From 3b859fe114d353a1c5d843fdd0f2aa367254c55d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:03:18 +0000 Subject: [PATCH 1/3] Qualify the reviewed Sample App PHP container launcher --- qualification/README.md | 28 +++- qualification/composer-container-trust.json | 82 +++++++++++ scripts/qualification_policy.py | 111 ++++++++++++-- tests/test_composer_trust_contract.py | 155 ++++++++++++++++++++ 4 files changed, 358 insertions(+), 18 deletions(-) diff --git a/qualification/README.md b/qualification/README.md index 8939f55..a18ba21 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -187,11 +187,9 @@ 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 initial `sdk-php-cooperative-server` entry in -`composer-container-trust.json` has no approved recipes. It therefore refuses -every build until the Server hook and caller pass combined container controls. -This preparation action does not make SDK-PHP's Composer migration mandatory or -authorize its new audit job. Those changes follow combined 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. The container control runs the actual digest-pinned Composer image against a local synthetic TLS registry. The ambient store accepts its synthetic blocked @@ -200,6 +198,26 @@ digests, extra roots and a missing verifier also refuse. This exercises the container client; BuildKit mount lifetime and final-image inspection remain part of the Server hook's combined qualification. +### Composer through a reviewed PHP launcher + +The same catalog has a separate `runtime_setups` entry for Sample App's PHP CI +launcher. It binds the exact setup command, target, workflow/jobs, literal PHP +matrix, build inputs, launcher and TLS control by file hash and mode. An +unreviewed Dockerfile exclusion file refuses. The setup probes and exports an +immutable local image config ID; subsequent PHP and Composer invocations use +that ID. The actual Composer TLS control runs in both PHP profiles: ambient +trust reaches a synthetic HTTPS repository, while the selected clean bundle +refuses before an HTTP request. + +This recognizes the PHP setup only. The reviewed clean bootstrap must still +precede it, and the reviewed live Composer verifier must run in the container +immediately before every dependency-request step. The launcher mounts the +selected CA file and empty additional-root directory read-only and inspects the +real Composer PHAR. Runtime selector overrides, changed source bytes or modes, +conditional/ignored setup, another target/job, and missing/intervening guards +refuse. This entry does not approve arbitrary Docker commands or another +repository's launcher. + ### Composer on the runner For the initial Ubuntu 24.04 or 26.04 x64 PHP 8.3 production profile, the policy diff --git a/qualification/composer-container-trust.json b/qualification/composer-container-trust.json index 68bc267..ff49f1a 100644 --- a/qualification/composer-container-trust.json +++ b/qualification/composer-container-trust.json @@ -31,5 +31,87 @@ } ] } + }, + "runtime_setups": { + "sample-app-php-ci": { + "target": "sample-app", + "matrix": { + "php": ["8.4", "8.5"] + }, + "workflows": { + "ci.yml": [ + "test", + "microservice-test" + ], + "composer-lock-audit.yml": [ + "audit" + ], + "playground-journeys.yml": [ + "journeys" + ] + }, + "commands": [ + "bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh\" \"${{ matrix.php }}\"", + "bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh\" \"8.4\"" + ], + "environment": [ + "SAMPLE_APP_PHP_CI_IMAGE", + "SAMPLE_APP_PHP_CI_ROOT", + "SAMPLE_APP_PHP_CI_STATE" + ], + "absent_files": [ + "docker/php-ci/Dockerfile.dockerignore" + ], + "files": { + ".dockerignore": { + "sha256": "3d3b99f9bbf9d4ce35b7dc8462c4da1c87983b2b65a3a829d8ec9d80f43551cc", + "mode": 384 + }, + "docker/php-ci/Dockerfile": { + "sha256": "dcdd1e2f99db98825f9cf6b89ab2b0191252d2816001af09f391b6fb1a8b035a", + "mode": 384 + }, + "scripts/ci/php-container-images.json": { + "sha256": "fc4d410589d1fdb322107e404bfcf9f4593e6ac7e3fcce729b1d78fafd9be1c4", + "mode": 384 + }, + "scripts/ci/run-php-container.sh": { + "sha256": "56aef0c6ca67f277dce114eac3d5de396f5b751697df6c01e8f760cb18cc9f49", + "mode": 448 + }, + "scripts/ci/setup-php-container.sh": { + "sha256": "104481886027a954f0af0d614933f6b8d934302c9ce55d1dc1f71e50130445ab", + "mode": 448 + }, + "tests/ci/test_php_container_tls.py": { + "sha256": "936a6584fbe2b3523a8835e4f18df272b9bf384b9d95419b6ff3368e986052e4", + "mode": 384 + }, + ".devcontainer/docker/verify-php-trust.php": { + "sha256": "bd8682984a757eb28887e996f77c9cafed32634cc7e19155b9885da1bb5b48c9", + "mode": 384 + }, + "docker/install-phpredis.sh": { + "sha256": "17b21ea9724199f502605abeeff6856f8af721027691e1de74d12cc6ccb7d33b", + "mode": 384 + }, + "docker/brotli/install-vendor.sh": { + "sha256": "d7eb8b2a3e0af1c3072a56bcb81632b22f061f211d8fe5e57ee901c8bf94c92f", + "mode": 384 + }, + "docker/brotli/verify-runtime.php": { + "sha256": "580cc465e349f3872eca592355294e7320fe7f1a4560ff5d4c83d5900921c00e", + "mode": 384 + }, + "docker/brotli/verify-system-brotli.c": { + "sha256": "3353927d206aaf5e63157e46be715c6cbee46bac777e29f8f9e25c4c1f656667", + "mode": 384 + }, + "docker/brotli/version": { + "sha256": "e064953e075ddb99fb7b48ad29e215f2dfa075812c127eb1d6ab680e9920fba2", + "mode": 384 + } + } + } } } diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index e85471a..cf5bef5 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -13,6 +13,7 @@ import os import re import shlex +import stat import sys import time import urllib.error @@ -2536,8 +2537,8 @@ def _composer_step_source(source: str, root: Path, visited: set[Path] | None = N return "\n".join(contents) -def _composer_trust_modified(source: str) -> bool: - fields = "|".join(sorted(COMPOSER_TRUST_FIELDS)) +def _composer_trust_modified(source: str, runtime_fields: set[str] | None = None) -> bool: + fields = "|".join(sorted(COMPOSER_TRUST_FIELDS | (runtime_fields or set()))) assignment = r"\b(?:" + fields + r")\b[\"']?\s*(?::?=)|\bunset\s+[^\n]*(?:" + fields + ")" configuration = r"\bcomposer\b[^;\n]*\bconfig\b[^;\n]*\b(?:cafile|capath|disable-tls|secure-http)\b" return re.search(assignment, source) is not None or re.search(configuration, source) is not None @@ -2584,30 +2585,106 @@ def _advance_composer_trust(step: dict[str, Any], state: tuple[bool, bool, bool, return clean, checked, setup_safe, python_clean -def _require_composer_request_trust(step: dict[str, Any], state: tuple[bool, bool, bool, bool], label: str, source: str) -> None: +def _require_composer_request_trust(step: dict[str, Any], state: tuple[bool, bool, bool, bool], label: str, source: str, + runtime_fields: set[str] | None = None) -> None: clean, checked, _, _ = state if not clean or not checked: raise PolicyError(f"{label} Composer requests lack the reviewed clean bootstrap and live verifier") if re.search(r"(?:always|failure|cancelled)\s*\(", step.get("if", "")): raise PolicyError(f"{label} Composer requests may run after trust refusal") - overrides = set(step.get("env") or {}) & COMPOSER_TRUST_FIELDS + overrides = set(step.get("env") or {}) & (COMPOSER_TRUST_FIELDS | (runtime_fields or set())) if overrides: raise PolicyError(f"{label} overrides Composer trust after verification") - if _composer_trust_modified(source): + if _composer_trust_modified(source, runtime_fields): raise PolicyError(f"{label} changes Composer trust inside its request step or helper") -def _composer_job_sinks(job: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, label: str) -> list[str]: - if set(job.get("env") or {}) & COMPOSER_TRUST_FIELDS: +def _composer_runtime_setups(catalog: dict[str, Any], target: str, filename: str, job: str) -> list[dict[str, Any]]: + return [recipe for recipe in catalog.get("runtime_setups", {}).values() + if recipe["target"] == target and job in recipe["workflows"].get(Path(filename).name, [])] + + +def _require_composer_runtime_matrix(step: dict[str, Any], job: dict[str, Any], recipe: dict[str, Any]) -> None: + for dimension, allowed in recipe.get("matrix", {}).items(): + if "${{ matrix." + dimension + " }}" not in step["run"]: + continue + matrix = (job.get("strategy") or {}).get("matrix") + if (not isinstance(matrix, dict) or "include" in matrix or "exclude" in matrix + or not isinstance(matrix.get(dimension), list) or matrix[dimension] != allowed): + raise PolicyError("Composer runtime setup requires its reviewed literal PHP matrix") + + +def _composer_runtime_path(root: Path, name: str) -> Path: + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + raise PolicyError("Composer runtime recipe path must stay inside the repository") + path = root / relative + if (not path.resolve().is_relative_to(root.resolve()) + or any(parent.is_symlink() for parent in path.parents if parent != root and root in parent.parents)): + raise PolicyError("Composer runtime recipe path must not traverse symlinks") + return path + + +def _require_composer_runtime_file(root: Path, name: str, expected: dict[str, Any]) -> None: + path = _composer_runtime_path(root, name) + try: + metadata = path.lstat() + if (not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != expected["mode"] + or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]): + raise PolicyError(f"Reviewed Composer runtime source differs: {name}") + except OSError as error: + raise PolicyError(f"Reviewed Composer runtime source is unavailable: {name}") from error + + +def _require_composer_runtime_sources(root: Path, recipe: dict[str, Any]) -> None: + if not recipe["files"]: + raise PolicyError("Composer runtime setup has no reviewed source files") + for name in recipe.get("absent_files", []): + path = _composer_runtime_path(root, name) + if path.exists() or path.is_symlink(): + raise PolicyError(f"Unreviewed Composer runtime input is present: {name}") + for name, expected in recipe["files"].items(): + _require_composer_runtime_file(root, name, expected) + + +def _reviewed_composer_runtime(step: dict[str, Any], root: Path, recipes: list[dict[str, Any]], job: dict[str, Any]) -> bool: + """Recognize setup only; the next request still requires its live verifier.""" + matching = [recipe for recipe in recipes if step.get("run", "").strip() in recipe["commands"]] + if not matching: + return False + if len(matching) != 1: + raise PolicyError("Composer runtime setup has ambiguous reviewed recipes") + if (step.get("uses") or step.get("if") or step.get("env") or step.get("working-directory") + or step.get("shell", "bash") != "bash" + or str(step.get("continue-on-error", "false")) != "false"): + raise PolicyError("Composer runtime setup must run unconditionally without overrides") + _require_composer_runtime_matrix(step, job, matching[0]) + _require_composer_runtime_sources(root, matching[0]) + return True + + +def _composer_job_sinks(job: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, label: str, + runtime_setups: list[dict[str, Any]] | None = None) -> list[str]: + recipes = runtime_setups or [] + runtime_fields = {name for recipe in recipes for name in recipe["environment"]} + if set(job.get("env") or {}) & (COMPOSER_TRUST_FIELDS | runtime_fields): raise PolicyError(f"{label} declares unverified job-level Composer trust overrides") evidence = [] state = (False, False, False, False) for index, step in enumerate(job.get("steps") or []): step_label = f"{label}:step-{index + 1}" + if _reviewed_composer_runtime(step, root, recipes, job): + clean, _, _, python_clean = state + state = (clean, False, clean and python_clean, python_clean) + continue expanded = _composer_step_source(step.get("run", ""), root) if _composer_network_commands(expanded): - _require_composer_request_trust(step, state, step_label, expanded) + _require_composer_request_trust(step, state, step_label, expanded, runtime_fields) evidence.append(step_label) + if (set(step.get("env") or {}) & runtime_fields + or runtime_fields and _composer_trust_modified(expanded, runtime_fields)): + clean, _, _, python_clean = state + state = (clean, False, False, python_clean) state = _advance_composer_trust(step, state, approved, bound_setups, local) return evidence @@ -2659,23 +2736,31 @@ def _unscoped_composer_guard(documents: dict[str, Any], selected: set[tuple[str, return False -def _composer_document_sinks(documents: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, selected: set[tuple[str, str]] | None) -> list[str]: +def _composer_document_sinks(documents: dict[str, Any], root: Path, approved: set[str], bound_setups: set[str], local: bool, + selected: set[tuple[str, str]] | None, catalog: dict[str, Any], target: str) -> list[str]: evidence = [] for filename, document in documents.items(): jobs = {name: job for name, job in (document.get("jobs") or {}).items() if selected is None or (filename, name) in selected} if not jobs: continue - if set(document.get("env") or {}) & COMPOSER_TRUST_FIELDS: + runtime_fields = {field for name in jobs for recipe in _composer_runtime_setups(catalog, target, filename, name) + for field in recipe["environment"]} + if set(document.get("env") or {}) & (COMPOSER_TRUST_FIELDS | runtime_fields): raise PolicyError(f"{filename} declares unverified workflow-level Composer trust overrides") for name, job in jobs.items(): - requests = _composer_job_sinks(job, root, approved, bound_setups, local, f"{filename}:{name}") + recipes = _composer_runtime_setups(catalog, target, filename, name) + requests = _composer_job_sinks(job, root, approved, bound_setups, local, f"{filename}:{name}", recipes) if selected is not None and not requests: raise PolicyError("A scoped Composer job has no enumerated dependency request") evidence.extend(requests) return evidence +def _composer_container_catalog() -> dict[str, Any]: + return json.loads((Path(__file__).resolve().parents[1] / "qualification/composer-container-trust.json").read_text()) + + def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: dict[str, str], root: Path) -> list[str]: configuration = policy["targets"][target] scopes = configuration.get("composer_trust_scopes", []) @@ -2688,9 +2773,9 @@ def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: documents = {filename: _parse_yaml(source, filename) for filename, source in sources.items()} selected = _composer_scoped_jobs(scopes, documents, bound_setups, target == "github-control-plane") full = whole or not scopes or _unscoped_composer_guard(documents, selected) + catalog = _composer_container_catalog() evidence = _composer_document_sinks(documents, root, approved, bound_setups, target == "github-control-plane", - None if full else selected) - catalog = json.loads((Path(__file__).resolve().parents[1] / "qualification/composer-container-trust.json").read_text()) + None if full else selected, catalog, target) evidence.extend(verify_composer_container_callers(target, sources, approved, catalog, root)) return evidence diff --git a/tests/test_composer_trust_contract.py b/tests/test_composer_trust_contract.py index 8b5760f..891daaa 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -1,7 +1,9 @@ from copy import deepcopy +import hashlib from pathlib import Path import tempfile import unittest +from unittest import mock import yaml @@ -327,6 +329,159 @@ def test_an_unregistered_setup_or_another_action_at_that_pin_refuses(self): self.check(steps) +class ComposerRuntimeSetupContractTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.policy = { + "targets": {"fixture": {"composer_trust_required": True}}, + "action_runtime": {"allowed_releases": {"durable-workflow/.github": {REVISION: "clean-trust"}}}, + } + files = {} + for name, contents, mode in ( + ("scripts/setup.sh", "#!/bin/bash\nln -sf scripts/launch.sh runtime/composer\n", 0o755), + ("scripts/launch.sh", "#!/bin/bash\nexec docker run sha256:fixture composer \"$@\"\n", 0o755), + ("docker/php/Dockerfile", "FROM php@sha256:fixture\n", 0o644), + ): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + path.chmod(mode) + files[name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "mode": mode} + self.command = 'bash "$GITHUB_WORKSPACE/scripts/setup.sh" "8.4"' + self.recipe = {"target": "fixture", "workflows": {"ci.yml": ["test"]}, "commands": [self.command], + "environment": ["FIXTURE_PHP_IMAGE"], "files": files} + catalog = {"requests": {}, "runtime_setups": {"fixture-php": self.recipe}} + patched = mock.patch("scripts.qualification_policy._composer_container_catalog", return_value=catalog) + patched.start() + self.addCleanup(patched.stop) + self.steps = [ + {"uses": PREFIX + "bootstrap-patched-pip@" + REVISION, "with": {"clients": "python,composer,curl,git"}}, + {"run": self.command}, + {"uses": PREFIX + "verify-composer-trust@" + REVISION}, + {"run": "composer install --prefer-dist"}, + ] + + def check(self, steps=None, *, target="fixture", filename="ci.yml", job="test", env=None, job_env=None, matrix=None): + document = {"jobs": {job: {"steps": self.steps if steps is None else steps}}} + if env is not None: + document["env"] = env + if job_env is not None: + document["jobs"][job]["env"] = job_env + if matrix is not None: + document["jobs"][job]["strategy"] = {"matrix": matrix} + policy = deepcopy(self.policy) + policy["targets"][target] = policy["targets"]["fixture"] + return verify_composer_network_sinks(policy, target, {filename: yaml.safe_dump(document)}, self.root) + + def test_exact_runtime_setup_still_needs_a_fresh_live_verifier(self): + self.assertEqual(["ci.yml:test:step-4"], self.check()) + with self.assertRaises(PolicyError): + self.check([*self.steps[:2], self.steps[-1]]) + with self.assertRaises(PolicyError): + self.check([*self.steps[:3], {"run": "pwd"}, self.steps[-1]]) + + def test_setup_does_not_supply_bootstrap_or_request_authority(self): + for removed in (0, 1, 2): + steps = deepcopy(self.steps) + steps.pop(removed) + with self.subTest(removed=removed), self.assertRaises(PolicyError): + self.check(steps) + for index in (0, 2): + steps = deepcopy(self.steps) + steps[index]["uses"] = steps[index]["uses"].replace(REVISION, "b" * 40) + with self.subTest(index=index), self.assertRaises(PolicyError): + self.check(steps) + + def test_recipe_is_confined_to_the_registered_target_workflow_and_job(self): + for change in ({"target": "another"}, {"filename": "release.yml"}, {"job": "publish"}): + with self.subTest(change=change), self.assertRaises(PolicyError): + self.check(**change) + + def test_changed_missing_nonregular_and_mode_changed_inputs_refuse(self): + for name in self.recipe["files"]: + path = self.root / name + contents, mode = path.read_bytes(), path.stat().st_mode & 0o777 + for change in ("bytes", "missing", "symlink", "mode"): + if change == "bytes": + path.write_bytes(contents + b"\n# changed\n") + elif change == "missing": + path.unlink() + elif change == "symlink": + path.unlink() + path.symlink_to(self.root / "absent") + else: + path.chmod(0o600) + with self.subTest(name=name, change=change), self.assertRaises(PolicyError): + self.check() + path.unlink(missing_ok=True) + path.write_bytes(contents) + path.chmod(mode) + + def test_conditional_ignored_or_overridden_runtime_setups_refuse(self): + for field, value in (("if", "always()"), ("continue-on-error", True), ("env", {"ANY": "value"}), + ("working-directory", "another"), ("shell", "sh")): + steps = deepcopy(self.steps) + steps[1][field] = value + with self.subTest(field=field), self.assertRaises(PolicyError): + self.check(steps) + steps = deepcopy(self.steps) + steps[1]["run"] += "\ncomposer install" + with self.assertRaises(PolicyError): + self.check(steps) + + def test_runtime_selector_overrides_and_mutations_cannot_follow_setup(self): + for index in (2, 3): + steps = deepcopy(self.steps) + steps[index]["env"] = {"FIXTURE_PHP_IMAGE": "unreviewed"} + with self.subTest(index=index), self.assertRaises(PolicyError): + self.check(steps) + for command in ("export FIXTURE_PHP_IMAGE=unreviewed", "unset FIXTURE_PHP_IMAGE"): + with self.subTest(command=command), self.assertRaises(PolicyError): + self.check([*self.steps[:2], {"run": command}, *self.steps[2:]]) + for kwargs in ({"env": {"FIXTURE_PHP_IMAGE": "unreviewed"}}, + {"job_env": {"FIXTURE_PHP_IMAGE": "unreviewed"}}): + with self.subTest(kwargs=kwargs), self.assertRaises(PolicyError): + self.check(**kwargs) + + def test_runtime_setup_requires_the_python_and_composer_trust_clients(self): + for clients in ("composer,curl,git", "python,curl,git", "python,composer"): + steps = deepcopy(self.steps) + steps[0]["with"]["clients"] = clients + with self.subTest(clients=clients), self.assertRaises(PolicyError): + self.check(steps) + + def test_empty_or_escaping_source_recipes_refuse(self): + original = self.recipe["files"] + for files in ({}, {"../outside": next(iter(original.values()))}, + {"/absolute": next(iter(original.values()))}): + self.recipe["files"] = files + with self.subTest(files=files), self.assertRaises(PolicyError): + self.check() + self.recipe["files"] = original + + def test_an_unreviewed_dockerfile_exclusion_file_refuses(self): + self.recipe["absent_files"] = ["docker/php/Dockerfile.dockerignore"] + self.assertEqual(["ci.yml:test:step-4"], self.check()) + (self.root / "docker/php/Dockerfile.dockerignore").write_text("*\n") + with self.assertRaises(PolicyError): + self.check() + + def test_interpolated_matrix_values_are_closed_to_the_reviewed_minors(self): + command = self.command.replace('"8.4"', '"${{ matrix.php }}"') + self.recipe["commands"] = [command] + self.recipe["matrix"] = {"php": ["8.4", "8.5"]} + self.steps[1]["run"] = command + self.assertEqual(["ci.yml:test:step-4"], self.check(matrix={"php": ["8.4", "8.5"]})) + for matrix in ({}, {"php": ["8.4"]}, {"php": ["8.4", "8.6"]}, + {"php": ["8.4", '8.5"; composer install; #']}, + {"php": ["8.4", "8.5"], "include": [{"php": "8.6"}]}, + {"php": ["8.4", "8.5"], "exclude": [{"php": "8.5"}]}): + with self.subTest(matrix=matrix), self.assertRaises(PolicyError): + self.check(matrix=matrix) + + class ScopedComposerMigrationTest(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() From e51bc3945f9f5050bc98cbd8a17ddeba4fa6c27f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:06:45 +0000 Subject: [PATCH 2/3] Cover ambiguous recipes and symlinked build inputs --- tests/test_composer_trust_contract.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/test_composer_trust_contract.py b/tests/test_composer_trust_contract.py index 891daaa..1fe56f2 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -352,8 +352,8 @@ def setUp(self): self.command = 'bash "$GITHUB_WORKSPACE/scripts/setup.sh" "8.4"' self.recipe = {"target": "fixture", "workflows": {"ci.yml": ["test"]}, "commands": [self.command], "environment": ["FIXTURE_PHP_IMAGE"], "files": files} - catalog = {"requests": {}, "runtime_setups": {"fixture-php": self.recipe}} - patched = mock.patch("scripts.qualification_policy._composer_container_catalog", return_value=catalog) + self.catalog = {"requests": {}, "runtime_setups": {"fixture-php": self.recipe}} + patched = mock.patch("scripts.qualification_policy._composer_container_catalog", return_value=self.catalog) patched.start() self.addCleanup(patched.stop) self.steps = [ @@ -481,6 +481,19 @@ def test_interpolated_matrix_values_are_closed_to_the_reviewed_minors(self): with self.subTest(matrix=matrix), self.assertRaises(PolicyError): self.check(matrix=matrix) + def test_ambiguous_recipes_do_not_authorize_a_runtime(self): + self.catalog["runtime_setups"]["duplicate"] = deepcopy(self.recipe) + with self.assertRaisesRegex(PolicyError, "ambiguous"): + self.check() + + def test_a_symlinked_build_input_directory_refuses_even_when_bytes_match(self): + original = self.root / "docker/php" + moved = self.root / "actual-build-input" + original.rename(moved) + original.symlink_to(moved, target_is_directory=True) + with self.assertRaisesRegex(PolicyError, "symlinks"): + self.check() + class ScopedComposerMigrationTest(unittest.TestCase): def setUp(self): From 2cf3caf265f433d5406bc7c26fe6a28093fe078e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:17:59 +0000 Subject: [PATCH 3/3] Enforce Sample App trust and portable Git executable modes --- qualification/composer-container-trust.json | 24 ++++++++++----------- qualification/policy.json | 1 + scripts/qualification_policy.py | 7 +++++- tests/test_composer_trust_contract.py | 22 ++++++++++++++++++- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/qualification/composer-container-trust.json b/qualification/composer-container-trust.json index ff49f1a..90a9c3e 100644 --- a/qualification/composer-container-trust.json +++ b/qualification/composer-container-trust.json @@ -65,51 +65,51 @@ "files": { ".dockerignore": { "sha256": "3d3b99f9bbf9d4ce35b7dc8462c4da1c87983b2b65a3a829d8ec9d80f43551cc", - "mode": 384 + "mode": 420 }, "docker/php-ci/Dockerfile": { "sha256": "dcdd1e2f99db98825f9cf6b89ab2b0191252d2816001af09f391b6fb1a8b035a", - "mode": 384 + "mode": 420 }, "scripts/ci/php-container-images.json": { "sha256": "fc4d410589d1fdb322107e404bfcf9f4593e6ac7e3fcce729b1d78fafd9be1c4", - "mode": 384 + "mode": 420 }, "scripts/ci/run-php-container.sh": { "sha256": "56aef0c6ca67f277dce114eac3d5de396f5b751697df6c01e8f760cb18cc9f49", - "mode": 448 + "mode": 493 }, "scripts/ci/setup-php-container.sh": { "sha256": "104481886027a954f0af0d614933f6b8d934302c9ce55d1dc1f71e50130445ab", - "mode": 448 + "mode": 493 }, "tests/ci/test_php_container_tls.py": { "sha256": "936a6584fbe2b3523a8835e4f18df272b9bf384b9d95419b6ff3368e986052e4", - "mode": 384 + "mode": 420 }, ".devcontainer/docker/verify-php-trust.php": { "sha256": "bd8682984a757eb28887e996f77c9cafed32634cc7e19155b9885da1bb5b48c9", - "mode": 384 + "mode": 420 }, "docker/install-phpredis.sh": { "sha256": "17b21ea9724199f502605abeeff6856f8af721027691e1de74d12cc6ccb7d33b", - "mode": 384 + "mode": 420 }, "docker/brotli/install-vendor.sh": { "sha256": "d7eb8b2a3e0af1c3072a56bcb81632b22f061f211d8fe5e57ee901c8bf94c92f", - "mode": 384 + "mode": 420 }, "docker/brotli/verify-runtime.php": { "sha256": "580cc465e349f3872eca592355294e7320fe7f1a4560ff5d4c83d5900921c00e", - "mode": 384 + "mode": 420 }, "docker/brotli/verify-system-brotli.c": { "sha256": "3353927d206aaf5e63157e46be715c6cbee46bac777e29f8f9e25c4c1f656667", - "mode": 384 + "mode": 420 }, "docker/brotli/version": { "sha256": "e064953e075ddb99fb7b48ad29e215f2dfa075812c127eb1d6ab680e9920fba2", - "mode": 384 + "mode": 420 } } } diff --git a/qualification/policy.json b/qualification/policy.json index 66eb861..f85e735 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -264,6 +264,7 @@ "sample-app": { "branch": "main", "repository": "sample-app", + "composer_trust_required": true, "workflows": [ { "action_policy_preflight": true, diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index cf5bef5..5ee33d4 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -2629,7 +2629,12 @@ def _require_composer_runtime_file(root: Path, name: str, expected: dict[str, An path = _composer_runtime_path(root, name) try: metadata = path.lstat() - if (not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != expected["mode"] + mode = stat.S_IMODE(metadata.st_mode) + # Git binds executability, while the checkout umask may remove read + # permissions. Keep private 0600/0700 and canonical 0644/0755 valid, + # without admitting extra execute, group/other write, or special bits. + if (not stat.S_ISREG(metadata.st_mode) or mode & ~expected["mode"] + or bool(mode & stat.S_IXUSR) != bool(expected["mode"] & stat.S_IXUSR) or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]): raise PolicyError(f"Reviewed Composer runtime source differs: {name}") except OSError as error: diff --git a/tests/test_composer_trust_contract.py b/tests/test_composer_trust_contract.py index 1fe56f2..7f4c790 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -1,5 +1,6 @@ from copy import deepcopy import hashlib +import json from pathlib import Path import tempfile import unittest @@ -153,6 +154,12 @@ def check(self, steps=None, jobs=None): def test_reviewed_order_is_enumerated(self): self.assertEqual(["ci.yml:test:step-4"], self.check()) + def test_adopted_sample_app_cannot_remove_every_composer_guard(self): + policy = json.loads((Path(__file__).resolve().parents[1] / "qualification/policy.json").read_text()) + source = yaml.safe_dump({"jobs": {"test": {"steps": [{"run": "composer install"}]}}}) + with self.assertRaisesRegex(PolicyError, "clean bootstrap and live verifier"): + verify_composer_network_sinks(policy, "sample-app", {"ci.yml": source}, self.root) + def test_an_omitted_job_is_not_hidden_by_a_safe_job(self): with self.assertRaisesRegex(PolicyError, "forgotten"): self.check(jobs={"safe": {"steps": self.steps}, "forgotten": {"steps": [{"run": "composer audit --locked"}]}}) @@ -412,7 +419,7 @@ def test_changed_missing_nonregular_and_mode_changed_inputs_refuse(self): path.unlink() path.symlink_to(self.root / "absent") else: - path.chmod(0o600) + path.chmod(mode ^ 0o100) with self.subTest(name=name, change=change), self.assertRaises(PolicyError): self.check() path.unlink(missing_ok=True) @@ -494,6 +501,19 @@ def test_a_symlinked_build_input_directory_refuses_even_when_bytes_match(self): with self.assertRaisesRegex(PolicyError, "symlinks"): self.check() + def test_git_executability_is_portable_across_private_checkout_umasks(self): + self.assertEqual(["ci.yml:test:step-4"], self.check()) + for name, expected in self.recipe["files"].items(): + (self.root / name).chmod(expected["mode"] & 0o700) + self.assertEqual(["ci.yml:test:step-4"], self.check()) + + def test_writable_and_special_permission_bits_refuse(self): + path = self.root / "scripts/launch.sh" + for mode in (0o777, 0o775, 0o4755, 0o2755, 0o1755): + path.chmod(mode) + with self.subTest(mode=mode), self.assertRaises(PolicyError): + self.check() + class ScopedComposerMigrationTest(unittest.TestCase): def setUp(self):