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 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..a5604b7 --- /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..31a71eb --- /dev/null +++ b/scripts/ci/exec-box.php @@ -0,0 +1,25 @@ + "$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 +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..4c7e672 --- /dev/null +++ b/scripts/ci/probe-php-phar-runtime.php @@ -0,0 +1,14 @@ + 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; diff --git a/scripts/ci/run-php-phar-container.sh b/scripts/ci/run-php-phar-container.sh new file mode 100755 index 0000000..b98f901 --- /dev/null +++ b/scripts/ci/run-php-phar-container.sh @@ -0,0 +1,89 @@ +#!/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 + 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 new file mode 100755 index 0000000..af097dd --- /dev/null +++ b/scripts/ci/setup-php-phar-container.sh @@ -0,0 +1,34 @@ +#!/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 +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']) +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..976b473 --- /dev/null +++ b/scripts/ci/test_php_phar_container.py @@ -0,0 +1,159 @@ +"""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') + (state/'artifacts/box.phar').chmod(0o400) + 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') + 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): + 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 { + @chmod($state.'/artifacts/box.phar', 0600); + $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() diff --git a/tests/fixtures/php-phar-smoke-workflow.yml b/tests/fixtures/php-phar-smoke-workflow.yml new file mode 100644 index 0000000..7e038fc --- /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 # 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