From 1681bd4f96e6aa73e5ee3c343e9e5009643f95f7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:42:46 +0000 Subject: [PATCH] Admit only the reviewed CLI PHAR smoke runtime source --- qualification/composer-container-trust.json | 118 ++++++++++++++++++++ tests/fixtures/cli-php-phar-smoke.yml | 38 +++++++ tests/test_cli_phar_runtime.py | 93 +++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 tests/fixtures/cli-php-phar-smoke.yml create mode 100644 tests/test_cli_phar_runtime.py diff --git a/qualification/composer-container-trust.json b/qualification/composer-container-trust.json index 7c61713..59fc56e 100644 --- a/qualification/composer-container-trust.json +++ b/qualification/composer-container-trust.json @@ -686,6 +686,124 @@ "mode": 420 } } + }, + "cli-php-phar-smoke": { + "target": "cli", + "workflows": { + "build.yml": [ + "build-phar" + ] + }, + "commands": [ + "bash \"$GITHUB_WORKSPACE/scripts/ci/setup-php-phar-container.sh\"" + ], + "environment": [ + "CLI_PHP_CI_IMAGE", + "CLI_PHP_CI_ROOT", + "CLI_PHP_CI_STATE" + ], + "absent_files": [ + "docker/php-ci/Dockerfile.dockerignore", + "docker/php-ci/cli.Dockerfile.dockerignore" + ], + "files": { + ".dockerignore": { + "sha256": "19f259d2473129b500ed107780da5b5bc4a7dea47fde4500539bff4b919d4aee", + "mode": 420 + }, + "docker/php-ci/Dockerfile": { + "sha256": "81d23fbbee7e4ee97ca14df9d17982aa664335b94499c5019662259fe0387eaa", + "mode": 420 + }, + "docker/php-ci/cli.Dockerfile": { + "sha256": "c2b64bd82040083fbd812a470b266861f04303ea800ba58908f1e4601fe4179f", + "mode": 420 + }, + "docker/php-ci/verify-php-trust.php": { + "sha256": "529c282e7422202bf25565431a6391de388f86dfe5bfd6c75cf21daa56ffa75e", + "mode": 420 + }, + "docker/brotli/install-vendor.sh": { + "sha256": "d7eb8b2a3e0af1c3072a56bcb81632b22f061f211d8fe5e57ee901c8bf94c92f", + "mode": 420 + }, + "docker/brotli/verify-runtime.php": { + "sha256": "580cc465e349f3872eca592355294e7320fe7f1a4560ff5d4c83d5900921c00e", + "mode": 420 + }, + "docker/brotli/verify-system-brotli.c": { + "sha256": "3353927d206aaf5e63157e46be715c6cbee46bac777e29f8f9e25c4c1f656667", + "mode": 420 + }, + "docker/brotli/version": { + "sha256": "e064953e075ddb99fb7b48ad29e215f2dfa075812c127eb1d6ab680e9920fba2", + "mode": 420 + }, + "scripts/ci/php-container-images.json": { + "sha256": "85c87a2dff21225109581876befc00177c62824e44c044765dc921879ca41a4d", + "mode": 420 + }, + "scripts/ci/setup-php-container.sh": { + "sha256": "a7007c0fbef886b7e29b62d20b76404839746620361a9dd9e6024f1ff546d49b", + "mode": 493 + }, + "scripts/ci/run-php-container.sh": { + "sha256": "baef1e07ac53ce31fed01eac37ced2f98e363fa677e129952f6e15e47213db0e", + "mode": 493 + }, + "scripts/ci/probe-php-test-runtime.php": { + "sha256": "a0bbef217128b8ffe81de5c126dfafbfd93ebed28244c2a69204a8f4991be264", + "mode": 420 + }, + "scripts/ci/test-php-container-tls.py": { + "sha256": "da11950ff46810ba425fea6bb6a453869995204388570d1f764a160154f3e7b0", + "mode": 420 + }, + "scripts/ci/test-php-container-boundary.py": { + "sha256": "8d4db79b20598e5e9f1fc64f6a730b574d6f2161cc42a01442ac0095dc8684e9", + "mode": 420 + }, + "scripts/ci/ensure-box.sh": { + "sha256": "fd25fab5e1b1bd5d15ed37cd9585e901831fc7662a27e0f968340e06167da07e", + "mode": 493 + }, + "docker/php-ci/phar.ini": { + "sha256": "ec099701f20a226c574c65279f7fc98368c740f764db3a505c4efcd1ea21700b", + "mode": 420 + }, + "scripts/ci/exec-box.php": { + "sha256": "d3566ee7430eeba8843036d30415091619cc37e5d326fc8a31b2a97008e728c9", + "mode": 420 + }, + "scripts/ci/prepare-php-phar-container.sh": { + "sha256": "dd8833e2d3b9ef21fc14ccbe27a4491d91819b4ba3dc16ca3d23e2a6b5238027", + "mode": 493 + }, + "scripts/ci/probe-php-phar-runtime.php": { + "sha256": "28deb9870754643ad6a89a6c1b107446f11fef18d0dd9f8f1c09170b7fec1ecf", + "mode": 420 + }, + "scripts/ci/run-php-phar-container.sh": { + "sha256": "2c273c5867c0c270829d13210fc83b28b2d211acaf9d1692f0b55de0953a950a", + "mode": 493 + }, + "scripts/ci/setup-php-phar-container.sh": { + "sha256": "8b2179e704d8207ecde82e631799b38a609773d081bc700d38ca1f1295e81c5a", + "mode": 493 + }, + "scripts/ci/test_php_phar_container.py": { + "sha256": "56e49fc95402bb48bda28f6d17d0aa69803a742526958d0a0309d078f28cf384", + "mode": 420 + }, + "rust/parity/test_php_phar_container.py": { + "sha256": "2cc387159b9c239c8757871283e17cd673733f16d05a43ac42d187395beeec13", + "mode": 420 + }, + "tests/fixtures/php-phar-smoke-workflow.yml": { + "sha256": "e9d0ecc505ac2b4cb4d2c636665385f806b9eec056301ceb6d000c63321e2545", + "mode": 420 + } + } } } } diff --git a/tests/fixtures/cli-php-phar-smoke.yml b/tests/fixtures/cli-php-phar-smoke.yml new file mode 100644 index 0000000..7e038fc --- /dev/null +++ b/tests/fixtures/cli-php-phar-smoke.yml @@ -0,0 +1,38 @@ +# Proposed caller only. Source/recipe review and accepted central admission +# precede copying this job into the live workflow. +jobs: + build-phar: + name: Build PHAR (smoke test) + needs: [action-policy] + if: ${{ github.server_url == 'https://github.com' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.13' + - name: Bootstrap verified dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,curl,git + - 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 + - name: Check out the accepted PHP container build source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Build and prepare the pinned PHAR caller profile + run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-phar-container.sh" + - run: php scripts/generate-build-info.php + - name: Verify actual Composer dependency trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + - run: composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader + - run: box compile --no-parallel + - run: box info build/dw.phar + - run: php build/dw.phar list diff --git a/tests/test_cli_phar_runtime.py b/tests/test_cli_phar_runtime.py new file mode 100644 index 0000000..7d9a860 --- /dev/null +++ b/tests/test_cli_phar_runtime.py @@ -0,0 +1,93 @@ +"""The PHAR recipe admits one caller without weakening whole-target trust.""" +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import yaml + +from scripts.qualification_policy import PolicyError, verify_composer_network_sinks + + +class CliPharRuntimeTests(unittest.TestCase): + def setUp(self): + repository = Path(__file__).resolve().parents[1] + self.policy = json.loads((repository/'qualification/policy.json').read_text()) + self.catalog = json.loads((repository/'qualification/composer-container-trust.json').read_text()) + self.document = yaml.safe_load((repository/'tests/fixtures/cli-php-phar-smoke.yml').read_text()) + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.recipe = self.catalog['runtime_setups']['cli-php-phar-smoke'] + # Inert bodies test the actual catalog selectors/refusals. Independent + # source review binds the public consumer's real blobs and Git modes. + for name, expected in self.recipe['files'].items(): + path = self.root/name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('# inert reviewed input\n') + path.chmod(expected['mode']) + expected['sha256'] = hashlib.sha256(path.read_bytes()).hexdigest() + + def check(self, document=None, filename='build.yml'): + sources = {filename:yaml.safe_dump(self.document if document is None else document)} + with mock.patch('scripts.qualification_policy._composer_container_catalog', return_value=self.catalog): + return verify_composer_network_sinks(self.policy, 'cli', sources, self.root) + + def test_exact_proposed_caller_uses_the_registered_setup_and_actual_guard(self): + self.assertEqual('ubuntu-24.04', self.document['jobs']['build-phar']['runs-on']) + self.assertEqual(1, len(self.check())) + + def test_wrong_job_workflow_or_unguarded_extra_job_cannot_borrow_admission(self): + moved = deepcopy(self.document) + moved['jobs']['other'] = moved['jobs'].pop('build-phar') + with self.assertRaises(PolicyError): + self.check(moved) + with self.assertRaises(PolicyError): + self.check(filename='other.yml') + extra = deepcopy(self.document) + extra['jobs']['unguarded'] = {'steps':[{'run':'composer install --no-interaction'}]} + with self.assertRaises(PolicyError): + self.check(extra) + + def test_every_bound_file_and_absence_is_required(self): + for name in self.recipe['files']: + with self.subTest(file=name): + path = self.root/name + before = path.read_bytes() + path.write_bytes(before+b'# changed\n') + with self.assertRaises(PolicyError): + self.check() + path.write_bytes(before) + for name in self.recipe['absent_files']: + with self.subTest(absence=name): + path = self.root/name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + with self.assertRaises(PolicyError): + self.check() + path.unlink() + + def test_setup_overrides_or_all_verifier_deletion_fail_closed(self): + for field, value in (('if','false'), ('continue-on-error',True), ('env',{'CLI_PHP_CI_IMAGE':'unreviewed'})): + altered = deepcopy(self.document) + setup = next(step for step in altered['jobs']['build-phar']['steps'] + if step.get('run') == self.recipe['commands'][0]) + setup[field] = value + with self.subTest(field=field), self.assertRaises(PolicyError): + self.check(altered) + altered = deepcopy(self.document) + steps = altered['jobs']['build-phar']['steps'] + steps[:] = [step for step in steps if 'verify-composer-trust@' not in step.get('uses','')] + with self.assertRaises(PolicyError): + self.check(altered) + altered = deepcopy(self.document) + altered['jobs']['build-phar']['env'] = {'CLI_PHP_CI_STATE':'unreviewed'} + with self.assertRaises(PolicyError): + self.check(altered) + + +if __name__ == '__main__': + unittest.main()