diff --git a/qualification/README.md b/qualification/README.md index 9b192ec..b98ca4a 100644 --- a/qualification/README.md +++ b/qualification/README.md @@ -199,6 +199,11 @@ The older recipe remains available. Source matching alone does not qualify the caller: its clean predecessor, live in-container verifier and actual registered Compose request remain required. +The `composer-container-trust` action-release role approves only the container +verifier path. It cannot supply the clean bootstrap or host Composer verifier. +The container request still binds its CA file and digest to an earlier, +independently approved clean bootstrap. + The container control runs the actual digest-pinned Composer image against a local synthetic TLS registry. The ambient store accepts its synthetic blocked root. The verified clean store refuses before an HTTP request. Wrong bundle diff --git a/qualification/policy.json b/qualification/policy.json index f85e735..441a1c2 100644 --- a/qualification/policy.json +++ b/qualification/policy.json @@ -90,6 +90,7 @@ "699d09d602fc0353160bdecaa2b23f07ef4c7c21": "clean-trust", "75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust", "76f333804e68725059039fd54df241d40d855cd3": "clean-trust", + "d12a1e247ea28b89912cc87a8c0b596686d8c323": "composer-container-trust", "ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10": "patched-pip" }, "helm/chart-testing-action": { diff --git a/scripts/protected_policy_context.py b/scripts/protected_policy_context.py index c4367ec..ca673f6 100644 --- a/scripts/protected_policy_context.py +++ b/scripts/protected_policy_context.py @@ -63,6 +63,11 @@ CI_WORKFLOW: "760de748e04a5c652d0174b8bb68b689aed98c17aed10a06b628af6b931719fc", SONAR_WORKFLOW: "f7fad8367df4a4e9565d97e13aa120d55038ed9c749fdfcd86e1312bfca3cced", } +# Inert successor for the existing contracts job's qualified Ubuntu24 PHP8.3 +# setup. Live workflow adoption and the protected runtime pin follow review. +REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS = { + CI_WORKFLOW: "363540f9e81e4842099f0cad394ce3fc2a83835ce1355ffef5b16722547699b4", +} # The successor remains inert until its separate runtime pin and manual-only # workflow integration. Keep the consumed predecessor's identity unchanged. REVIEWED_INITIAL_PROFILE_SUCCESSORS = { @@ -110,6 +115,7 @@ def reviewed_context_hashes(policy_target: str) -> dict[str, set[str]]: registries = (REVIEWED_COMPLEX_WORKFLOWS, REVIEWED_SOURCE_CI_SUCCESSORS, REVIEWED_PHP_CONFIGURATION_SOURCE_CI_SUCCESSORS, REVIEWED_APT_NO_RETRIES_SOURCE_CI_SUCCESSORS, + REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS, REVIEWED_INITIAL_PROFILE_SUCCESSORS, REVIEWED_NORMALIZED_PROFILE_SUCCESSORS, REVIEWED_ALTERNATIVES_PROFILE_SUCCESSORS, REVIEWED_SELECTED_ALTERNATIVES_PROFILE_SUCCESSORS, diff --git a/scripts/qualification_policy.py b/scripts/qualification_policy.py index 5ee33d4..7c94970 100644 --- a/scripts/qualification_policy.py +++ b/scripts/qualification_policy.py @@ -2423,6 +2423,9 @@ def _verify_action_release_path(repository: str, directory: str, version: str) - 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") + if (repository == CENTRAL_ACTION_REPOSITORY and version == "composer-container-trust" + and directory != ".github/actions/verify-composer-container-trust"): + raise PolicyError("The Composer container release approves only its verify-composer-container-trust action path") def audit_action_releases( @@ -2774,6 +2777,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"} 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") @@ -2781,7 +2785,8 @@ def verify_composer_network_sinks(policy: dict[str, Any], target: str, sources: catalog = _composer_container_catalog() evidence = _composer_document_sinks(documents, root, approved, bound_setups, target == "github-control-plane", None if full else selected, catalog, target) - evidence.extend(verify_composer_container_callers(target, sources, approved, catalog, root)) + evidence.extend(verify_composer_container_callers(target, sources, approved, catalog, root, + verifier_approved=container_approved)) return evidence @@ -2829,14 +2834,14 @@ def _require_only_registered_build(steps: list[dict[str, Any]], selected: int, r raise PolicyError(f"{request_id} contains an additional unguarded Docker build") -def _require_container_caller(steps: list[dict[str, Any]], request_id: str, request: dict[str, Any], approved: set[str], local: bool, root: Path) -> None: +def _require_container_caller(steps: list[dict[str, Any]], request_id: str, request: dict[str, Any], approved: set[str], local: bool, root: Path, verifier_approved: set[str]) -> None: matches = [index for index, step in enumerate(steps) if step.get("run", "").strip() == request["command"]] if len(matches) != 1 or matches[0] == 0: raise PolicyError(f"{request_id} must retain its single registered Compose build command") build, guard = steps[matches[0]], steps[matches[0] - 1] _require_bootstrap_binding(steps[:matches[0] - 1], guard, approved, local, request_id) _require_only_registered_build(steps, matches[0], root, request_id) - if not _reviewed_composer_action(guard.get("uses", ""), "verify-composer-container-trust", approved, local): + if not _reviewed_composer_action(guard.get("uses", ""), "verify-composer-container-trust", verifier_approved, local): raise PolicyError(f"{request_id} lacks the immediately preceding reviewed container verifier") if any(step.get("if") or str(step.get("continue-on-error", "false")) != "false" for step in (guard, build)): raise PolicyError(f"{request_id} cannot skip or ignore the verifier or build refusal") @@ -2856,7 +2861,8 @@ def _require_container_caller(steps: list[dict[str, Any]], request_id: str, requ raise PolicyError(f"{request_id} has additional unverified trust overrides") -def verify_composer_container_callers(target: str, sources: dict[str, str], approved: set[str], catalog: dict[str, Any], root: Path) -> list[str]: +def verify_composer_container_callers(target: str, sources: dict[str, str], approved: set[str], catalog: dict[str, Any], root: Path, *, verifier_approved: set[str] | None = None) -> list[str]: + verifier_approved = approved if verifier_approved is None else verifier_approved evidence = [] for request_id, request in catalog["requests"].items(): if request.get("target") != target: @@ -2867,7 +2873,8 @@ def verify_composer_container_callers(target: str, sources: dict[str, str], appr job = _parse_yaml(candidates[0], request["workflow"]).get("jobs", {}).get(request["job"]) if not isinstance(job, dict): raise PolicyError(f"{request_id} is missing its registered build job") - _require_container_caller(job.get("steps", []), request_id, request, approved, target == "github-control-plane", root) + _require_container_caller(job.get("steps", []), request_id, request, approved, + target == "github-control-plane", root, verifier_approved) evidence.append(f"{request['workflow']}:{request['job']}:container-{request_id}") return evidence diff --git a/tests/fixtures/ci-bound-php-source.yml b/tests/fixtures/ci-bound-php-source.yml new file mode 100644 index 0000000..7f875dc --- /dev/null +++ b/tests/fixtures/ci-bound-php-source.yml @@ -0,0 +1,122 @@ +name: Repository checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: repository-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + contracts: + name: Shared contract tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,curl,git,npm,cargo + + - name: Disable APT download retries (CVE-2026-109518) + run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null + + - uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + with: + php-version: '8.3' + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 + coverage: none + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.22.1 + cache: npm + + - uses: dtolnay/rust-toolchain@4d407b29186a635f0cc27475ef0bc0ae605a8866 # 1.86.0 + with: + toolchain: 1.86.0 + + - uses: ./.github/actions/verify-composer-trust # local + + - name: Install Python test dependencies + run: >- + python -m pip install --only-binary :all: jsonschema==4.23.0 PyYAML==6.0.2 + + - name: Test retained shared contracts + run: >- + python -m unittest + tests.test_qualification_policy + tests.test_protected_policy_context + tests.test_protected_policy_check + tests.test_protected_policy_inputs + tests.test_protected_policy_issue + tests.test_native_policy_check + tests.test_native_policy_workflow + tests.test_regression_corpus + tests.test_regression_evidence_schema + tests.test_server_codec_counterfactual_schema + tests.test_visual_evidence + tests.test_temporal_runtime + tests.test_cancellation_workflow_policy + tests.test_sdk_metrics_contract + tests.test_ci_trust + tests.test_pip_effective_trust + tests.test_cli_effective_trust + tests.test_composer_trust_contract + tests.test_composer_container_trust + tests.test_composer_container_effective_trust + tests.test_php_linux_bootstrap + tests.test_php_linux_archive + tests.test_php_linux_normalize + tests.test_php_linux_recipe + tests.test_php_linux_tools + tests.test_php_linux_alternatives + tests.test_setup_php_linux + tests.test_php_linux_qualification + tests.test_php_linux_qualification_workflow + tests.test_php_linux_configuration + + - name: Test actual Composer trust selection + run: COMPOSER_BINARY="$(command -v composer)" phpunit --configuration phpunit-composer-trust.xml --do-not-cache-result + + - name: Install Node dependencies + run: npm ci --ignore-scripts + + - name: Test browser evidence boundary + run: npm run test:visual-capture + + protected-parser-install: + name: Protected parser installer controls + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Create a disposable installer runtime + run: | + python -I -m venv "$RUNNER_TEMP/protected-parser-install" + printf '%s\n' "$RUNNER_TEMP/protected-parser-install/bin" >> "$GITHUB_PATH" + - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python + - name: Exercise the actual isolated hash-locked installer + run: python -I tests/test_policy_dependency_install.py diff --git a/tests/test_composer_trust_contract.py b/tests/test_composer_trust_contract.py index 7f4c790..2798528 100644 --- a/tests/test_composer_trust_contract.py +++ b/tests/test_composer_trust_contract.py @@ -43,6 +43,25 @@ def check(self, steps=None): def test_build_uses_every_output_from_its_fresh_registered_guard(self): self.assertEqual(["ci.yml:connected:container-fixture"], self.check()) + def test_container_only_release_cannot_supply_the_clean_bootstrap(self): + revision = "b" * 40 + policy = {"targets": {"fixture": {"composer_trust_required": True}}, "action_runtime": { + "allowed_releases": {"durable-workflow/.github": {REVISION: "clean-trust"}}}} + self.guard["uses"] = PREFIX + "verify-composer-container-trust@" + revision + + 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() + policy["action_runtime"]["allowed_releases"]["durable-workflow/.github"][revision] = "composer-container-trust" + self.assertEqual(["ci.yml:connected:container-fixture"], verify()) + self.bootstrap["uses"] = PREFIX + "bootstrap-patched-pip@" + revision + with self.assertRaisesRegex(PolicyError, "reviewed unconditional clean bootstrap"): + verify() + def test_missing_unreviewed_or_intervening_guard_refuses(self): with self.assertRaises(PolicyError): self.check([self.build]) diff --git a/tests/test_protected_policy_context.py b/tests/test_protected_policy_context.py index 9ec2f5f..2ab3a55 100644 --- a/tests/test_protected_policy_context.py +++ b/tests/test_protected_policy_context.py @@ -3,6 +3,8 @@ import unittest from unittest.mock import patch +import yaml + from scripts import protected_policy_context as context from scripts.protected_policy_inputs import InputRefused @@ -29,6 +31,39 @@ def test_unknown_context_target_refuses_without_interpreting_a_workflow(self): with self.assertRaisesRegex(InputRefused, "context target"): context.validate_context({}, policy_target="elsewhere") + def test_bound_php_source_ci_successor_preserves_checks_and_requires_exact_review(self): + path = context.CI_WORKFLOW + raw = (ROOT / "tests/fixtures/ci-bound-php-source.yml").read_bytes() + previous = yaml.safe_load((ROOT / "tests/fixtures/ci-php-configuration-source.yml").read_text()) + job = previous["jobs"]["contracts"] + job["runs-on"] = "ubuntu-24.04" + job["steps"][0]["with"] = {"persist-credentials": False} + setup = next(step for step in job["steps"] if step.get("uses", "").startswith("shivammathur/setup-php@")) + index = job["steps"].index(setup) + job["steps"].insert(index, { + "name": "Disable APT download retries (CVE-2026-109518)", + "run": "echo 'Acquire::Retries \"0\";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null", + }) + setup["uses"] = "durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7" + setup["with"]["ini-file"] = "production" + setup["with"]["tools"] = "composer:2.10.3,phpunit:11.5.57" + self.assertEqual(previous, yaml.safe_load(raw)) + self.assertEqual(context.REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS[path], hashlib.sha256(raw).hexdigest()) + with patch.object(context, "REVIEWED_BOUND_PHP_SOURCE_CI_SUCCESSORS", {}), self.assertRaises(InputRefused): + context.validate_context({path: raw}) + context.validate_context({path: raw}) + for changed in (raw + b"\n# unreviewed\n", + raw.replace(b"411a14ac07d32073eed6ab4977f2825eba4739a7", b"a" * 40), + raw.replace(b"coverage: none", b"coverage: xdebug"), + raw.replace(b"tests.test_composer_trust_contract", b"tests.unreviewed")): + self.assertNotEqual(raw, changed) + with self.assertRaises(InputRefused): + context.validate_context({path: changed}) + with self.assertRaises(InputRefused): + context.validate_context({WORKFLOW: raw}) + with self.assertRaises(InputRefused): + context.validate_context({path: raw}, policy_target="ai") + def test_initial_qualification_is_only_the_exact_reviewed_future_workflow(self): path=".github/workflows/qualify-linux-php-initial.yml" raw=(ROOT/"tests/fixtures/qualify-linux-php-initial.yml").read_bytes() diff --git a/tests/test_qualification_policy.py b/tests/test_qualification_policy.py index ef9ba9c..83cc601 100644 --- a/tests/test_qualification_policy.py +++ b/tests/test_qualification_policy.py @@ -699,6 +699,23 @@ 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" + action = "durable-workflow/.github/.github/actions/verify-composer-container-trust" + source = workflow(f"- uses: {action}@{commit} # composer-container-trust") + predecessor = copy.deepcopy(self.policy) + del predecessor["action_runtime"]["allowed_releases"]["durable-workflow/.github"][commit] + with self.assertRaisesRegex(PolicyError, "not centrally approved"): + scan_workflow_sources(predecessor, "github-control-plane", {"test.yml": source}) + self.assertEqual([f"{action}@{commit}"], self.scan(source)[".github/workflows/test.yml"]["external_actions"]) + for path in ("bootstrap-patched-pip", "setup-php-linux", "verify-composer-trust", + "verify-composer-container-trust/other"): + changed = workflow(f"- uses: durable-workflow/.github/.github/actions/{path}@{commit} # composer-container-trust") + with self.subTest(path=path), self.assertRaisesRegex(PolicyError, "only its verify-composer-container-trust"): + self.scan(changed) + 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_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"