From eee16665bd056b5b199ee43c90c2bc463865db5d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:12:56 +0000 Subject: [PATCH 1/5] Prepare an isolated PHP profile for the PHAR smoke caller --- README.md | 8 ++ docker/php-ci/phar.ini | 4 + rust/parity/test_php_phar_container.py | 11 ++ scripts/ci/exec-box.php | 15 +++ scripts/ci/prepare-php-phar-container.sh | 31 +++++ scripts/ci/probe-php-phar-runtime.php | 11 ++ scripts/ci/run-php-phar-container.sh | 84 ++++++++++++++ scripts/ci/setup-php-phar-container.sh | 32 +++++ scripts/ci/test-php-phar-container.py | 142 +++++++++++++++++++++++ 9 files changed, 338 insertions(+) create mode 100644 docker/php-ci/phar.ini create mode 100644 rust/parity/test_php_phar_container.py create mode 100644 scripts/ci/exec-box.php create mode 100755 scripts/ci/prepare-php-phar-container.sh create mode 100644 scripts/ci/probe-php-phar-runtime.php create mode 100755 scripts/ci/run-php-phar-container.sh create mode 100755 scripts/ci/setup-php-phar-container.sh create mode 100644 scripts/ci/test-php-phar-container.py diff --git a/README.md b/README.md index 3debd47..67f3f41 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,14 @@ unaffected. This binds the SPC component only. PHP, downloaded libraries, build caches and each host platform still require their applicable toolchain qualification. +The PHAR smoke caller has a separate [runtime profile](docker/php-ci/phar.ini) +and [launcher](scripts/ci/run-php-phar-container.sh). Its host controls and Box +artifact stay read-only. Only dependencies, build output, generated build +metadata and private runtime data are writable. The setup reuses the accepted +PHP image build source, then independently probes the PHAR settings and runs +the TLS and replacement controls. Source preparation alone does not qualify +the hosted caller or the separate reproducibility and legacy release paths. + Standalone release builds use the reviewed [dependency source policy](scripts/ci/standalone-dependencies.json). The workflow verifies the OpenSSL archive before compilation and checks the produced runtime after both cold builds and cache restores. Each release includes diff --git a/docker/php-ci/phar.ini b/docker/php-ci/phar.ini new file mode 100644 index 0000000..4f91c51 --- /dev/null +++ b/docker/php-ci/phar.ini @@ -0,0 +1,4 @@ +date.timezone=UTC +memory_limit=-1 +phar.readonly=0 +opcache.enable_cli=0 diff --git a/rust/parity/test_php_phar_container.py b/rust/parity/test_php_phar_container.py new file mode 100644 index 0000000..49f1b45 --- /dev/null +++ b/rust/parity/test_php_phar_container.py @@ -0,0 +1,11 @@ +"""Discover PHAR launcher controls in the existing host-Python test lane.""" +import importlib.util +from pathlib import Path + + +def load_tests(loader, _tests, _pattern): + source = Path(__file__).resolve().parents[2]/'scripts/ci/test-php-phar-container.py' + spec = importlib.util.spec_from_file_location('php_phar_controls', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return loader.loadTestsFromModule(module) diff --git a/scripts/ci/exec-box.php b/scripts/ci/exec-box.php new file mode 100644 index 0000000..1f2ae65 --- /dev/null +++ b/scripts/ci/exec-box.php @@ -0,0 +1,15 @@ + "$generated"); fi +[[ -f "$generated" && -O "$generated" && -n "$(find "$generated" -prune -type f -links 1)" ]] +bash "$root/scripts/ci/ensure-box.sh" 4.6.6 "$state/artifacts/box.phar" +for command in php composer box; do + ln -sf "$root/scripts/ci/run-php-phar-container.sh" "$state/bin/$command" +done +export CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_IMAGE="$image" CLI_PHP_CI_STATE="$state" +"$state/bin/php" "$root/scripts/ci/probe-php-phar-runtime.php" 8.4 +"$state/bin/box" --version +CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test-php-container-tls.py" -v +CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test-php-phar-container.py" -v diff --git a/scripts/ci/probe-php-phar-runtime.php b/scripts/ci/probe-php-phar-runtime.php new file mode 100644 index 0000000..773738b --- /dev/null +++ b/scripts/ci/probe-php-phar-runtime.php @@ -0,0 +1,11 @@ + ini_get('phar.readonly'), 'memory_limit' => ini_get('memory_limit')], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/run-php-phar-container.sh b/scripts/ci/run-php-phar-container.sh new file mode 100755 index 0000000..ea98671 --- /dev/null +++ b/scripts/ci/run-php-phar-container.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +command="${0##*/}" +case "$command" in php|composer|box) ;; *) echo 'Invoke this PHAR launcher as php, composer or box.' >&2; exit 2 ;; esac +root="${CLI_PHP_CI_ROOT:?PHP CI repository root is required}" +state="${CLI_PHP_CI_STATE:?PHP CI state directory is required}" +image="${CLI_PHP_CI_IMAGE:?PHP CI image is required}" +if [[ ! "$image" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo 'PHP CI must use the inspected immutable image config ID.' >&2 + exit 1 +fi + +options=(--rm --init --interactive --name "cli-php-ci-${command}-${BASHPID}" + --cpus 2 --memory 2g --pids-limit 512 --network host + --cap-drop ALL --security-opt no-new-privileges + --user "$(id -u):$(id -g)" --workdir "$PWD" + --env "HOME=$state/home" + --mount "type=bind,source=$root,target=$root,readonly" + --mount "type=bind,source=$state,target=$state,readonly") + +# Keep host-executed launchers and controls read-only. Only dependency data, +# test output and private runtime caches can be changed by application code. +for directory in "$root/vendor" "$root/build" "$state/home" "$state/tmp" "$state/composer-home"; do + if [[ ! -d "$directory" || -L "$directory" || "$(realpath "$directory")" != "$directory" ]]; then + echo 'PHP CI writable directories must be existing real paths.' >&2 + exit 1 + fi + options+=(--mount "type=bind,source=$directory,target=$directory") +done +generated="$root/src/GeneratedBuildInfo.php" +[[ -f "$generated" && ! -L "$generated" && -O "$generated" && "$(realpath "$generated")" = "$generated" ]] || { + echo 'The generated build-info output must be a real owned file.' >&2; exit 1; +} +[[ -n "$(find "$generated" -prune -type f -links 1)" ]] || exit 1 +options+=(--mount "type=bind,source=$generated,target=$generated" + --mount "type=bind,source=$root/docker/php-ci/phar.ini,target=/usr/local/etc/php/conf.d/zz-cli-phar.ini,readonly") + +# The Composer verifier is a composite action outside the checkout. +if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then + options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly") +fi +git_directory="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)" +if [[ "$git_directory" != "$root/"* ]]; then + options+=(--mount "type=bind,source=$git_directory,target=$git_directory,readonly") +fi + +declare -A mounted=() +for variable in SSL_CERT_FILE CURL_CA_BUNDLE GIT_SSL_CAINFO COMPOSER_CAFILE \ + REQUESTS_CA_BUNDLE PIP_CERT NPM_CONFIG_CAFILE CARGO_HTTP_CAINFO CARGO_HTTP_PROXY_CAINFO \ + SSL_CERT_DIR GIT_SSL_CAPATH COMPOSER_CAPATH; do + if [[ -n "${!variable:-}" ]]; then + selected="${!variable}" + if [[ -z "${mounted[$selected]:-}" ]]; then + options+=(--mount "type=bind,source=$selected,target=$selected,readonly") + mounted[$selected]=1 + fi + options+=(--env "$variable") + fi +done + +# PHAR builds need no role credentials or PHP startup overrides. +while IFS= read -r variable; do + case "$variable" in + COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\ + DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ|SOURCE_DATE_EPOCH|\ + DW_CLI_VERSION|DW_CLI_COMMIT|DW_CLI_BUILD_DATE|GITHUB_SHA|GITHUB_REF_TYPE|GITHUB_REF_NAME) + options+=(--env "$variable") ;; + *) ;; + esac +done < <(compgen -e) + +# The host verifier must inspect the actual container Composer PHAR. +arguments=() +for argument in "$@"; do + if [[ "$argument" == "$state/bin/composer" ]]; then + argument=/usr/local/bin/composer + fi + arguments+=("$argument") +done +if [[ "$command" = box ]]; then + exec docker run "${options[@]}" "$image" php "$root/scripts/ci/exec-box.php" "$state/artifacts/box.phar" "${arguments[@]}" +fi +exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}" diff --git a/scripts/ci/setup-php-phar-container.sh b/scripts/ci/setup-php-phar-container.sh new file mode 100755 index 0000000..b96bfbe --- /dev/null +++ b/scripts/ci/setup-php-phar-container.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +metadata="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/cli-phar-setup.XXXXXX")" +trap 'rm -rf -- "$metadata"' EXIT +: > "$metadata/environment" +: > "$metadata/path" +# Reuse the accepted build/ancestry source without changing the admitted test +# profile. Its private outputs are parsed as data, never sourced as shell code. +GITHUB_ENV="$metadata/environment" GITHUB_PATH="$metadata/path" \ + bash "$root/scripts/ci/setup-php-container.sh" 8.4 +bindings="$(python3 -I -S -B - "$metadata/environment" "$metadata/path" "$root" <<'PY' +from pathlib import Path +import re,sys +values = dict(line.split('=', 1) for line in Path(sys.argv[1]).read_text().splitlines()) +assert set(values) == {'CLI_PHP_CI_IMAGE','CLI_PHP_CI_ROOT','CLI_PHP_CI_STATE','COMPOSER_HOME','COMPOSER_CACHE_DIR','TMPDIR'} +assert values['CLI_PHP_CI_ROOT'] == sys.argv[3] +assert re.fullmatch(r'sha256:[0-9a-f]{64}', values['CLI_PHP_CI_IMAGE']) +state = values['CLI_PHP_CI_STATE'] +assert values['COMPOSER_HOME'] == state+'/composer-home' +assert values['COMPOSER_CACHE_DIR'] == state+'/composer-home/cache' +assert values['TMPDIR'] == state+'/tmp' +assert Path(sys.argv[2]).read_text() == state+'/bin\n' +print(values['CLI_PHP_CI_IMAGE']) +print(state) +PY +)" +mapfile -t fields <<< "$bindings" +bash "$root/scripts/ci/prepare-php-phar-container.sh" "${fields[0]}" "${fields[1]}" +cat "$metadata/environment" >> "${GITHUB_ENV:?GITHUB_ENV is required}" +cat "$metadata/path" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/scripts/ci/test-php-phar-container.py b/scripts/ci/test-php-phar-container.py new file mode 100644 index 0000000..be653d5 --- /dev/null +++ b/scripts/ci/test-php-phar-container.py @@ -0,0 +1,142 @@ +"""PHAR caller mount, environment and real control-replacement regressions.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / 'scripts/ci/run-php-phar-container.sh' + + +def fixture(parent, predecessor=False): + root, state = parent/'source', parent/'state' + for name in ('vendor', 'build', 'src', 'scripts/ci', 'docker/php-ci'): + (root/name).mkdir(parents=True, exist_ok=True) + for name in ('bin', 'home', 'tmp', 'composer-home/cache', 'artifacts'): + (state/name).mkdir(parents=True, exist_ok=True) + (root/'src/GeneratedBuildInfo.php').touch() + (root/'docker/php-ci/phar.ini').write_bytes((ROOT/'docker/php-ci/phar.ini').read_bytes()) + (state/'artifacts/box.phar').write_text('synthetic immutable artifact') + launcher = root/'scripts/ci/run-php-phar-container.sh' + source = LAUNCHER.read_text() + if predecessor: + source = source.replace('target=$root,readonly', 'target=$root').replace('target=$state,readonly', 'target=$state') + launcher.write_text(source) + launcher.chmod(0o755) + for name in ('php', 'composer', 'box'): + (state/'bin'/name).symlink_to(launcher) + subprocess.run(['git', 'init', '--quiet', str(root)], check=True) + return root, state, launcher + + +class PharLauncherTests(unittest.TestCase): + def invoke(self, command='php', arguments=(), mutate=None): + with tempfile.TemporaryDirectory() as directory: + root, state, _ = fixture(Path(directory)) + docker = state/'bin/docker' + docker.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') + docker.chmod(0o755) + environment = {'PATH':str(state/'bin')+os.pathsep+os.defpath, 'CLI_PHP_CI_ROOT':str(root), + 'CLI_PHP_CI_STATE':str(state), 'CLI_PHP_CI_IMAGE':'sha256:'+'f'*64, + 'COMPOSER_AUTH':'synthetic-secret', 'GITHUB_TOKEN':'synthetic-secret', + 'PHP_INI_SCAN_DIR':'untrusted', 'DW_TOKEN':'synthetic-secret', + 'GITHUB_SHA':'a'*40, 'SOURCE_DATE_EPOCH':'123'} + if mutate: + mutate(root, state, environment) + args = [str(state/'bin/composer') if x == '{composer}' else x for x in arguments] + result = subprocess.run([str(state/'bin'/command), *args], cwd=root, env=environment, + capture_output=True, text=True, timeout=10) + return result, str(root), str(state) + + def test_only_data_outputs_are_writable_and_credentials_are_excluded(self): + result, root, state = self.invoke() + self.assertEqual(0, result.returncode, result.stderr) + options = json.loads(result.stdout) + mounts = [options[i+1] for i, item in enumerate(options[:-1]) if item == '--mount'] + self.assertIn(f'type=bind,source={root},target={root},readonly', mounts) + self.assertIn(f'type=bind,source={state},target={state},readonly', mounts) + for path in (root+'/build', root+'/vendor', root+'/src/GeneratedBuildInfo.php', state+'/home', state+'/tmp', state+'/composer-home'): + self.assertIn(f'type=bind,source={path},target={path}', mounts) + self.assertIn(f'type=bind,source={root}/docker/php-ci/phar.ini,target=/usr/local/etc/php/conf.d/zz-cli-phar.ini,readonly', mounts) + environment = [options[i+1] for i, item in enumerate(options[:-1]) if item == '--env'] + for name in ('COMPOSER_AUTH', 'GITHUB_TOKEN', 'PHP_INI_SCAN_DIR', 'DW_TOKEN'): + self.assertNotIn(name, environment) + self.assertIn('GITHUB_SHA', environment) + self.assertIn('SOURCE_DATE_EPOCH', environment) + self.assertIn('no-new-privileges', options) + self.assertIn('--cap-drop', options) + + def test_box_runs_the_readonly_artifact_guard_and_composer_guard_sees_real_phar(self): + result, root, state = self.invoke('box', ('compile', '--no-parallel')) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['php', root+'/scripts/ci/exec-box.php', state+'/artifacts/box.phar', 'compile', '--no-parallel'], json.loads(result.stdout)[-5:]) + result, _, _ = self.invoke('php', ('/guard.php', '{composer}')) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['php', '/guard.php', '/usr/local/bin/composer'], json.loads(result.stdout)[-3:]) + + def test_mutable_image_and_linked_output_refuse_before_docker(self): + def image(_root, _state, environment): + environment['CLI_PHP_CI_IMAGE'] = 'mutable:tag' + def linked(root, _state, _environment): + (root/'build').rmdir() + (root/'build').symlink_to(root/'scripts') + for mutation in (image, linked): + result, _, _ = self.invoke(mutate=mutation) + self.assertNotEqual(0, result.returncode) + self.assertEqual('', result.stdout) + + +@unittest.skipUnless(os.environ.get('CLI_PHP_CI_TEST_IMAGE'), 'requires inspected PHP image') +class PharRuntimeBoundaryTests(unittest.TestCase): + def test_predecessor_replaces_controls_but_candidate_preserves_controls_and_writes(self): + for predecessor in (True, False): + for attack in ('launcher', 'wrapper', 'artifact'): + with self.subTest(predecessor=predecessor, attack=attack), tempfile.TemporaryDirectory( + dir=Path(os.environ['CLI_PHP_CI_STATE'])/'tmp') as directory: + root, state, launcher = fixture(Path(directory), predecessor) + wrapper = state/'bin/php' + digest = hashlib.sha256(launcher.read_bytes()).hexdigest() + environment = {'PATH':os.defpath, 'CLI_PHP_CI_ROOT':str(root), 'CLI_PHP_CI_STATE':str(state), + 'CLI_PHP_CI_IMAGE':os.environ['CLI_PHP_CI_TEST_IMAGE'], 'TMPDIR':str(state/'tmp')} + code = r''' +[$unused, $attack, $launcher, $wrapper, $root, $state] = $argv; +$payload = "#!/bin/sh\necho CLI_PHAR_CONTROL_REPLACED\n"; +if ($attack === 'launcher') { + $changed = @file_put_contents($launcher, $payload) !== false; +} elseif ($attack === 'wrapper') { + $changed = @unlink($wrapper) && @file_put_contents($wrapper, $payload) !== false; + if ($changed) { chmod($wrapper, 0755); } +} else { + $changed = @file_put_contents($state.'/artifacts/box.phar', 'replaced') !== false; +} +$writable = true; +foreach ([$root.'/vendor/output', $root.'/build/output', $root.'/src/GeneratedBuildInfo.php', + $state.'/home/output', $state.'/tmp/output', $state.'/composer-home/output'] as $path) { + $writable = $writable && file_put_contents($path, 'synthetic output') !== false; +} +echo json_encode(['changed'=>$changed, 'writable'=>$writable]); +''' + result = subprocess.run([str(wrapper), '-r', code, '--', attack, str(launcher), str(wrapper), str(root), str(state)], + cwd=root, env=environment, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + observation = json.loads(result.stdout) + self.assertEqual(predecessor, observation['changed']) + self.assertTrue(observation['writable']) + following = subprocess.run([str(wrapper), '-r', 'echo "CLI_REAL_CONTAINER_PHP";'], cwd=root, + env=environment, capture_output=True, text=True, timeout=30) + self.assertEqual(0, following.returncode, following.stderr) + if predecessor and attack != 'artifact': + self.assertEqual('CLI_PHAR_CONTROL_REPLACED', following.stdout.strip()) + else: + self.assertEqual('CLI_REAL_CONTAINER_PHP', following.stdout) + if not predecessor: + self.assertEqual(digest, hashlib.sha256(launcher.read_bytes()).hexdigest()) + self.assertTrue(wrapper.is_symlink()) + self.assertEqual('synthetic immutable artifact', (state/'artifacts/box.phar').read_text()) + + +if __name__ == '__main__': + unittest.main() From 71094edd8b266c018454ec23ef77c6dc5514aed7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:22:30 +0000 Subject: [PATCH 2/5] Preserve Box child reentry and bind the proposed PHAR caller --- rust/parity/test_php_phar_container.py | 2 +- scripts/ci/exec-box.php | 14 ++++++- scripts/ci/prepare-php-phar-container.sh | 9 +++-- scripts/ci/probe-php-phar-runtime.php | 6 ++- scripts/ci/run-php-phar-container.sh | 5 +++ scripts/ci/setup-php-phar-container.sh | 4 +- ...ontainer.py => test_php_phar_container.py} | 19 +++++++++- tests/fixtures/php-phar-smoke-workflow.yml | 38 +++++++++++++++++++ 8 files changed, 87 insertions(+), 10 deletions(-) rename scripts/ci/{test-php-phar-container.py => test_php_phar_container.py} (89%) create mode 100644 tests/fixtures/php-phar-smoke-workflow.yml diff --git a/rust/parity/test_php_phar_container.py b/rust/parity/test_php_phar_container.py index 49f1b45..a5604b7 100644 --- a/rust/parity/test_php_phar_container.py +++ b/rust/parity/test_php_phar_container.py @@ -4,7 +4,7 @@ def load_tests(loader, _tests, _pattern): - source = Path(__file__).resolve().parents[2]/'scripts/ci/test-php-phar-container.py' + source = Path(__file__).resolve().parents[2]/'scripts/ci/test_php_phar_container.py' spec = importlib.util.spec_from_file_location('php_phar_controls', source) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) diff --git a/scripts/ci/exec-box.php b/scripts/ci/exec-box.php index 1f2ae65..31a71eb 100644 --- a/scripts/ci/exec-box.php +++ b/scripts/ci/exec-box.php @@ -2,14 +2,24 @@ declare(strict_types=1); +final class BoxArtifactRefused extends RuntimeException {} + $box = $argv[1] ?? ''; +$metadata = @lstat($box); if (! is_file($box) || is_link($box) + || $metadata === false || $metadata['uid'] !== posix_getuid() + || $metadata['nlink'] !== 1 || ($metadata['mode'] & 07777) !== 0400 || filesize($box) !== 2947031 || hash_file('sha256', $box) !== 'aa0966319f709e74bf2bf1d58ddb987903ae4f6d0a9d335ec2261813c189f7fc') { - throw new RuntimeException('The read-only Box artifact differs from the reviewed release.'); + throw new BoxArtifactRefused('The read-only Box artifact differs from the reviewed release.'); } array_splice($argv, 0, 2, [$box]); $argc = count($argv); $_SERVER['argv'] = $argv; $_SERVER['argc'] = $argc; -require $box; +// Box uses this entrypoint for its own child extract/info commands. Those +// children must reopen the verified read-only PHAR, not this guard script. +$_SERVER['SCRIPT_FILENAME'] = $box; +$_SERVER['SCRIPT_NAME'] = $box; +$_SERVER['PHP_SELF'] = $box; +require_once $box; diff --git a/scripts/ci/prepare-php-phar-container.sh b/scripts/ci/prepare-php-phar-container.sh index 9404fa9..f385f03 100755 --- a/scripts/ci/prepare-php-phar-container.sh +++ b/scripts/ci/prepare-php-phar-container.sh @@ -8,8 +8,8 @@ image="${1:?Usage: prepare-php-phar-container.sh CONFIG_ID PRIVATE_STATE}" state="${2:?Usage: prepare-php-phar-container.sh CONFIG_ID PRIVATE_STATE}" [[ "$image" =~ ^sha256:[0-9a-f]{64}$ ]] [[ -d "$state" && ! -L "$state" && -O "$state" && "$(realpath "$state")" = "$state" ]] -case "$state/" in "$root/"*) exit 1 ;; esac -case "$root/" in "$state/"*) exit 1 ;; esac +case "$state/" in "$root/"*) exit 1 ;; *) ;; esac +case "$root/" in "$state/"*) exit 1 ;; *) ;; esac for directory in "$root/vendor" "$root/build" "$state/bin" "$state/artifacts" \ "$state/home" "$state/tmp" "$state/composer-home/cache"; do [[ ! -L "$directory" ]] @@ -21,6 +21,9 @@ generated="$root/src/GeneratedBuildInfo.php" if [[ ! -e "$generated" ]]; then (set -o noclobber; : > "$generated"); fi [[ -f "$generated" && -O "$generated" && -n "$(find "$generated" -prune -type f -links 1)" ]] bash "$root/scripts/ci/ensure-box.sh" 4.6.6 "$state/artifacts/box.phar" +box="$state/artifacts/box.phar" +[[ -f "$box" && ! -L "$box" && -O "$box" && -n "$(find "$box" -prune -type f -links 1)" ]] +chmod 0400 "$box" for command in php composer box; do ln -sf "$root/scripts/ci/run-php-phar-container.sh" "$state/bin/$command" done @@ -28,4 +31,4 @@ export CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_IMAGE="$image" CLI_PHP_CI_STATE="$stat "$state/bin/php" "$root/scripts/ci/probe-php-phar-runtime.php" 8.4 "$state/bin/box" --version CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test-php-container-tls.py" -v -CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test-php-phar-container.py" -v +CLI_PHP_CI_TEST_IMAGE="$image" python3 -I -S -B "$root/scripts/ci/test_php_phar_container.py" -v diff --git a/scripts/ci/probe-php-phar-runtime.php b/scripts/ci/probe-php-phar-runtime.php index 773738b..4f3faae 100644 --- a/scripts/ci/probe-php-phar-runtime.php +++ b/scripts/ci/probe-php-phar-runtime.php @@ -2,10 +2,12 @@ declare(strict_types=1); +final class PharRuntimeRefused extends RuntimeException {} + if (PHP_VERSION !== '8.4.27' || ini_get('phar.readonly') !== '0' || ini_get('memory_limit') !== '-1' || date_default_timezone_get() !== 'UTC' || ! in_array('/usr/local/etc/php/conf.d/zz-cli-phar.ini', explode(",\n", trim((string) php_ini_scanned_files())), true)) { - throw new RuntimeException('The exact PHP PHAR profile must be active.'); + throw new PharRuntimeRefused('The exact PHP PHAR profile must be active.'); } -require __DIR__.'/probe-php-test-runtime.php'; +require_once __DIR__.'/probe-php-test-runtime.php'; echo json_encode(['phar_readonly' => ini_get('phar.readonly'), 'memory_limit' => ini_get('memory_limit')], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/run-php-phar-container.sh b/scripts/ci/run-php-phar-container.sh index ea98671..b98f901 100755 --- a/scripts/ci/run-php-phar-container.sh +++ b/scripts/ci/run-php-phar-container.sh @@ -79,6 +79,11 @@ for argument in "$@"; do arguments+=("$argument") done if [[ "$command" = box ]]; then + artifact="$state/artifacts/box.phar" + [[ -f "$artifact" && ! -L "$artifact" && -O "$artifact" \ + && -n "$(find "$artifact" -prune -type f -links 1 -perm 0400)" ]] || { + echo 'Box must be a single-link owned read-only artifact.' >&2; exit 1; + } exec docker run "${options[@]}" "$image" php "$root/scripts/ci/exec-box.php" "$state/artifacts/box.phar" "${arguments[@]}" fi exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}" diff --git a/scripts/ci/setup-php-phar-container.sh b/scripts/ci/setup-php-phar-container.sh index b96bfbe..af097dd 100755 --- a/scripts/ci/setup-php-phar-container.sh +++ b/scripts/ci/setup-php-phar-container.sh @@ -13,7 +13,9 @@ GITHUB_ENV="$metadata/environment" GITHUB_PATH="$metadata/path" \ bindings="$(python3 -I -S -B - "$metadata/environment" "$metadata/path" "$root" <<'PY' from pathlib import Path import re,sys -values = dict(line.split('=', 1) for line in Path(sys.argv[1]).read_text().splitlines()) +lines = Path(sys.argv[1]).read_text().splitlines() +assert len(lines) == 6 +values = dict(line.split('=', 1) for line in lines) assert set(values) == {'CLI_PHP_CI_IMAGE','CLI_PHP_CI_ROOT','CLI_PHP_CI_STATE','COMPOSER_HOME','COMPOSER_CACHE_DIR','TMPDIR'} assert values['CLI_PHP_CI_ROOT'] == sys.argv[3] assert re.fullmatch(r'sha256:[0-9a-f]{64}', values['CLI_PHP_CI_IMAGE']) diff --git a/scripts/ci/test-php-phar-container.py b/scripts/ci/test_php_phar_container.py similarity index 89% rename from scripts/ci/test-php-phar-container.py rename to scripts/ci/test_php_phar_container.py index be653d5..976b473 100644 --- a/scripts/ci/test-php-phar-container.py +++ b/scripts/ci/test_php_phar_container.py @@ -20,6 +20,7 @@ def fixture(parent, predecessor=False): (root/'src/GeneratedBuildInfo.php').touch() (root/'docker/php-ci/phar.ini').write_bytes((ROOT/'docker/php-ci/phar.ini').read_bytes()) (state/'artifacts/box.phar').write_text('synthetic immutable artifact') + (state/'artifacts/box.phar').chmod(0o400) launcher = root/'scripts/ci/run-php-phar-container.sh' source = LAUNCHER.read_text() if predecessor: @@ -83,11 +84,26 @@ def image(_root, _state, environment): def linked(root, _state, _environment): (root/'build').rmdir() (root/'build').symlink_to(root/'scripts') - for mutation in (image, linked): + def generated_link(root, _state, _environment): + generated = root/'src/GeneratedBuildInfo.php' + generated.unlink() + generated.symlink_to(root/'scripts/ci/run-php-phar-container.sh') + def generated_hardlink(root, _state, _environment): + generated = root/'src/GeneratedBuildInfo.php' + generated.unlink() + os.link(root/'scripts/ci/run-php-phar-container.sh', generated) + for mutation in (image, linked, generated_link, generated_hardlink): result, _, _ = self.invoke(mutate=mutation) self.assertNotEqual(0, result.returncode) self.assertEqual('', result.stdout) + def test_linked_box_artifact_refuses_before_docker(self): + def hardlinked(root, state, _environment): + os.link(state/'artifacts/box.phar', root/'vendor/alias') + result, _, _ = self.invoke('box', mutate=hardlinked) + self.assertNotEqual(0, result.returncode) + self.assertEqual('', result.stdout) + @unittest.skipUnless(os.environ.get('CLI_PHP_CI_TEST_IMAGE'), 'requires inspected PHP image') class PharRuntimeBoundaryTests(unittest.TestCase): @@ -110,6 +126,7 @@ def test_predecessor_replaces_controls_but_candidate_preserves_controls_and_writ $changed = @unlink($wrapper) && @file_put_contents($wrapper, $payload) !== false; if ($changed) { chmod($wrapper, 0755); } } else { + @chmod($state.'/artifacts/box.phar', 0600); $changed = @file_put_contents($state.'/artifacts/box.phar', 'replaced') !== false; } $writable = true; diff --git a/tests/fixtures/php-phar-smoke-workflow.yml b/tests/fixtures/php-phar-smoke-workflow.yml new file mode 100644 index 0000000..655884e --- /dev/null +++ b/tests/fixtures/php-phar-smoke-workflow.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 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.13' + - name: Bootstrap verified dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 + 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 + 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 + - 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 From 72692adf5592929daa253cf774faa65748b6c5f7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:25:36 +0000 Subject: [PATCH 3/5] Assert every fixed PHAR runtime setting --- scripts/ci/probe-php-phar-runtime.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ci/probe-php-phar-runtime.php b/scripts/ci/probe-php-phar-runtime.php index 4f3faae..4c7e672 100644 --- a/scripts/ci/probe-php-phar-runtime.php +++ b/scripts/ci/probe-php-phar-runtime.php @@ -5,9 +5,10 @@ final class PharRuntimeRefused extends RuntimeException {} if (PHP_VERSION !== '8.4.27' || ini_get('phar.readonly') !== '0' - || ini_get('memory_limit') !== '-1' || date_default_timezone_get() !== 'UTC' + || ini_get('memory_limit') !== '-1' || ini_get('opcache.enable_cli') !== '0' || date_default_timezone_get() !== 'UTC' || ! in_array('/usr/local/etc/php/conf.d/zz-cli-phar.ini', explode(",\n", trim((string) php_ini_scanned_files())), true)) { throw new PharRuntimeRefused('The exact PHP PHAR profile must be active.'); } require_once __DIR__.'/probe-php-test-runtime.php'; -echo json_encode(['phar_readonly' => ini_get('phar.readonly'), 'memory_limit' => ini_get('memory_limit')], JSON_THROW_ON_ERROR).PHP_EOL; +echo json_encode(['phar_readonly' => ini_get('phar.readonly'), 'memory_limit' => ini_get('memory_limit'), + 'opcache_enable_cli' => ini_get('opcache.enable_cli'), 'timezone' => date_default_timezone_get()], JSON_THROW_ON_ERROR).PHP_EOL; From a6b0aa974fe9418c67b8116f86a83dd5f5c2d6b2 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:36:01 +0000 Subject: [PATCH 4/5] Bind proposed PHAR caller action version comments --- tests/fixtures/php-phar-smoke-workflow.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/php-phar-smoke-workflow.yml b/tests/fixtures/php-phar-smoke-workflow.yml index 655884e..7e038fc 100644 --- a/tests/fixtures/php-phar-smoke-workflow.yml +++ b/tests/fixtures/php-phar-smoke-workflow.yml @@ -8,20 +8,20 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + - 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 + 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: repository: durable-workflow/.github ref: 18783be6bef737cc778e4c7282a8797e39daa61a @@ -31,7 +31,7 @@ jobs: 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 + 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 From c6a80eff620d57859d81b50677365b2b21635d46 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:55:43 +0000 Subject: [PATCH 5/5] Use the admitted profile for the actual PHAR smoke caller --- .github/workflows/build.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfd48b1..689f6ed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -216,8 +216,9 @@ jobs: build-phar: name: Build PHAR (smoke test) + needs: [action-policy] if: ${{ github.server_url == 'https://github.com' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -232,12 +233,15 @@ jobs: 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 - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + - name: Check out the accepted PHP container build source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - php-version: '8.4' - tools: composer:2.10.3, box:4.6.6 - coverage: none - ini-values: phar.readonly=0 + 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