Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions qualification/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions qualification/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"699d09d602fc0353160bdecaa2b23f07ef4c7c21": "clean-trust",
"75587fc33ff40c34f32ef777293dad56bb6f48c7": "clean-trust",
"76f333804e68725059039fd54df241d40d855cd3": "clean-trust",
"d12a1e247ea28b89912cc87a8c0b596686d8c323": "composer-container-trust",
"ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10": "patched-pip"
},
"helm/chart-testing-action": {
Expand Down
6 changes: 6 additions & 0 deletions scripts/protected_policy_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 12 additions & 5 deletions scripts/qualification_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -2774,14 +2777,16 @@ 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")
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, 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


Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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

Expand Down
122 changes: 122 additions & 0 deletions tests/fixtures/ci-bound-php-source.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions tests/test_composer_trust_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
35 changes: 35 additions & 0 deletions tests/test_protected_policy_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down
17 changes: 17 additions & 0 deletions tests/test_qualification_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading