diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa21957 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +** +!docker/ +!docker/php-ci/ +!docker/php-ci/Dockerfile +!docker/php-ci/verify-php-trust.php +!docker/brotli/ +!docker/brotli/* +!.central-apt-source/ +!.central-apt-source/docker/ +!.central-apt-source/docker/apt/ +!.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8666e1e..4e6f678 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: test: name: Test (PHP ${{ matrix.php }}) if: ${{ github.server_url == 'https://github.com' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: fail-fast: false @@ -67,12 +67,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: ${{ matrix.php }} - extensions: pcntl, posix - tools: composer:2.10.3 - coverage: none + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Build the pinned PHP runtime and install launchers + run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh" "${{ matrix.php }}" - name: Verify actual Composer dependency trust uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - run: composer install --prefer-dist --no-interaction @@ -88,6 +91,7 @@ jobs: node --check scripts/ci/verify-standalone-dependencies.js node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php + php -l scripts/ci/probe-php-test-runtime.php bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -101,6 +105,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py + python -I -S -B scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js @@ -180,6 +185,7 @@ jobs: node --check scripts/ci/verify-standalone-dependencies.js node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php + php -l scripts/ci/probe-php-test-runtime.php bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -193,6 +199,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py + python -I -S -B scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js diff --git a/.gitignore b/.gitignore index b71e3c7..db8ab90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /src/GeneratedBuildInfo.php .claude/settings.local.json **/.claude/settings.local.json + +/.central-apt-source diff --git a/docker/brotli/install-vendor.sh b/docker/brotli/install-vendor.sh new file mode 100644 index 0000000..a60803e --- /dev/null +++ b/docker/brotli/install-vendor.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +inputs="${1:?Brotli verification input directory is required.}" +shift +version="$(cat "$inputs/version")" +test -n "$version" + +apt-get update +apt-get install -y --no-install-recommends \ + "libbrotli1=$version" "libbrotli-dev=$version" "$@" diff --git a/docker/brotli/verify-runtime.php b/docker/brotli/verify-runtime.php new file mode 100644 index 0000000..b708ab0 --- /dev/null +++ b/docker/brotli/verify-runtime.php @@ -0,0 +1,113 @@ + ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new BrotliRuntimeRefused('Cannot inspect the Brotli runtime.'); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new BrotliRuntimeRefused(implode(' ', $command).' failed: '.trim($error)); + } + + return $output; +} + +function brotliElfMentionsDecoder(string $path): bool +{ + $input = fopen($path, 'rb'); + if ($input === false) { + throw new BrotliRuntimeRefused('Cannot inspect '.$path); + } + try { + if (fread($input, 4) !== "\x7fELF") { + return false; + } + $tail = ''; + while (!feof($input)) { + $chunk = $tail.fread($input, 1024 * 1024); + if (str_contains($chunk, "libbrotlidec.so.1\0")) { + return true; + } + $tail = substr($chunk, -32); + } + + return false; + } finally { + fclose($input); + } +} + +function brotliInspectFile(SplFileInfo $file, array $owned): int +{ + $path = $file->getPathname(); + if (str_starts_with($file->getFilename(), 'libbrotlidec.so') && !$file->isDir() + && !isset($owned[realpath($path) ?: $path])) { + throw new BrotliRuntimeRefused('A decoder is outside the patched package: '.$path); + } + if (!$file->isFile() || $file->isLink() || !brotliElfMentionsDecoder($path)) { + return 0; + } + $links = brotliCommand(['ldd', $path]); + // A library also contains its own SONAME. Only ldd's dependency mapping + // identifies a consumer; an unresolved mapping still fails. + if (!preg_match('~libbrotlidec\.so\.1\s+=>\s+(\S+)~', $links, $match)) { + return 0; + } + if (!isset($owned[realpath($match[1]) ?: $match[1]])) { + throw new BrotliRuntimeRefused('An ELF consumer does not load the patched decoder: '.$path); + } + + return 1; +} + +function verifyBrotliRuntime(): void +{ + $expected = trim((string) file_get_contents(__DIR__.'/version')); + $actual = trim(brotliCommand(['dpkg-query', '-W', '-f=${Version}', 'libbrotli1'])); + if ($actual !== $expected) { + throw new BrotliRuntimeRefused('libbrotli1 is '.$actual.'; expected the reviewed vendor package '.$expected.'.'); + } + $owned = []; + foreach (explode("\n", brotliCommand(['dpkg-query', '-L', 'libbrotli1'])) as $path) { + if (str_contains(basename($path), 'libbrotlidec.so')) { + $resolved = realpath($path); + if ($resolved !== false) { + $owned[$resolved] = true; + } + } + } + if ($owned === []) { + throw new BrotliRuntimeRefused('The patched package owns no decoder.'); + } + + $consumers = 0; + // These are the image's tool, dependency, application and browser roots. + // Symlinked directories are not followed; Debian /lib and /bin resolve into /usr. + foreach (['/usr', '/opt', '/app', '/var/www', '/ms-playwright'] as $root) { + if (!is_dir($root)) { + continue; + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $consumers += brotliInspectFile($file, $owned); + } + } + if ($consumers === 0) { + throw new BrotliRuntimeRefused('No system Brotli consumer was inspected.'); + } + echo brotliCommand([__DIR__.'/verify-system-brotli']); + echo json_encode(['package' => $actual, 'elf_consumers' => $consumers], JSON_THROW_ON_ERROR).PHP_EOL; +} + +if (realpath($argv[0] ?? '') === __FILE__) { + verifyBrotliRuntime(); +} diff --git a/docker/brotli/verify-system-brotli.c b/docker/brotli/verify-system-brotli.c new file mode 100644 index 0000000..5935604 --- /dev/null +++ b/docker/brotli/verify-system-brotli.c @@ -0,0 +1,43 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +int main(void) { + const char *marker = BrotliDecoderErrorString((BrotliDecoderErrorCode)-17); + Dl_info origin = {0}; + const void *symbol = dlsym(RTLD_DEFAULT, "BrotliDecoderErrorString"); + unsigned version = BrotliDecoderVersion(); + if (!symbol || !dladdr(symbol, &origin) || !origin.dli_fname) { + fputs("Cannot resolve the loaded Brotli decoder.\n", stderr); + return 2; + } + printf("decoder=%u.%u.%u marker=%s library=%s\n", + version >> 24, (version >> 12) & 4095, version & 4095, + marker ? marker : "missing", origin.dli_fname); + /* Brotli 1.0 reports the error name without the newer branch's prefix. */ + if (!marker || strcmp(marker, "BLOCK_SWITCH") != 0) { + fputs("The loaded decoder lacks the CVE-2026-111497 fix.\n", stderr); + return 1; + } + + /* Only valid bytes produced here are decoded; no malformed corpus is used. */ + const unsigned char input[] = "Durable Workflow ordinary Brotli response"; + unsigned char encoded[1024]; + unsigned char decoded[sizeof(input)]; + size_t encoded_size = sizeof(encoded); + size_t decoded_size = sizeof(decoded); + if (!BrotliEncoderCompress(4, 22, BROTLI_MODE_GENERIC, sizeof(input) - 1, + input, &encoded_size, encoded) || + BrotliDecoderDecompress(encoded_size, encoded, &decoded_size, decoded) != + BROTLI_DECODER_RESULT_SUCCESS || + decoded_size != sizeof(input) - 1 || + memcmp(decoded, input, sizeof(input) - 1) != 0) { + fputs("Ordinary Brotli data did not round trip.\n", stderr); + return 2; + } + puts("Valid Brotli data round trips through the corrected system decoder."); + return 0; +} diff --git a/docker/brotli/version b/docker/brotli/version new file mode 100644 index 0000000..92b4227 --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.0.9-2+deb12u1 diff --git a/docker/php-ci/Dockerfile b/docker/php-ci/Dockerfile new file mode 100644 index 0000000..8e69dba --- /dev/null +++ b/docker/php-ci/Dockerfile @@ -0,0 +1,24 @@ +ARG PHP_IMAGE +FROM composer@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer + +FROM ${PHP_IMAGE} + +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY docker/php-ci/verify-php-trust.php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php +COPY docker/brotli /usr/local/share/durable-workflow/php-ci/brotli + +RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \ + && printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && sh /usr/local/share/durable-workflow/php-ci/brotli/install-vendor.sh /usr/local/share/durable-workflow/php-ci/brotli \ + git unzip python3 gcc libc6-dev libicu-dev libonig-dev libzip-dev \ + && docker-php-ext-install -j2 bcmath intl mbstring pcntl sockets zip \ + && cc -O2 /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl \ + -o /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli \ + && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && php /usr/local/share/durable-workflow/php-ci/brotli/verify-runtime.php \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md new file mode 100644 index 0000000..7530d3e --- /dev/null +++ b/docker/php-ci/README.md @@ -0,0 +1,25 @@ +# PHP test runtime + +The no-database Dockerfile, Composer CA verifier and Brotli inputs are reused +byte-for-byte from SDK PHP source +[`4d37a42d`](https://github.com/durable-workflow/sdk-php/tree/4d37a42d1c95020fa2c75dcd0cc7585dd788f71c/docker/php-ci), itself adapted +from the accepted Sample App container pattern. Keep these common build inputs +aligned before starting another base build. CLI owns its minor selection, +runtime probe, restricted launcher environment and actual test results. +`cli.Dockerfile` extends that base with the pinned Node binary and procps, +which the existing release-contract and installer-shell fixtures require. + +`scripts/ci/php-container-images.json` selects immutable official PHP images +for 8.2, 8.4 and 8.5. Composer is pinned to the reviewed 2.10.3 image index. +The build requires the APT helper from central source +`18783be6bef737cc778e4c7282a8797e39daa61a`; setup checks its commit and file +digest before building. The probe requires production INI, no coverage, +PHPUnit's modules and the process-control functions exercised by CLI tests. + +The setup script runs with authenticated CA environment from the dependency +trust bootstrap and private runner state. It inspects the built image ID, +runs the actual Composer TLS control, then installs `php` and `composer` +launchers. Those launchers forward the selected CA files as read-only mounts +and keep unrelated host credentials outside the runtime. The caller's normal +Composer verifier still checks the actual container PHAR before dependency +requests and again before project scripts. diff --git a/docker/php-ci/cli.Dockerfile b/docker/php-ci/cli.Dockerfile new file mode 100644 index 0000000..3940e13 --- /dev/null +++ b/docker/php-ci/cli.Dockerfile @@ -0,0 +1,11 @@ +ARG PHP_RUNTIME_IMAGE +FROM node@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS node + +FROM ${PHP_RUNTIME_IMAGE} + +COPY --from=node /usr/local/bin/node /usr/local/bin/node + +RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \ + && apt-get update \ + && apt-get install -y --no-install-recommends procps \ + && rm -rf /var/lib/apt/lists/* diff --git a/docker/php-ci/verify-php-trust.php b/docker/php-ci/verify-php-trust.php new file mode 100644 index 0000000..79f2e8c --- /dev/null +++ b/docker/php-ci/verify-php-trust.php @@ -0,0 +1,36 @@ + ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); +if (! is_resource($process)) { + throw new PhpTestRuntimeRefused('Release-contract tests require Node.'); +} +$nodeVersion = trim(stream_get_contents($pipes[1])); +fclose($pipes[1]); +fclose($pipes[2]); +if (proc_close($process) !== 0 || $nodeVersion !== 'v22.23.4') { + throw new PhpTestRuntimeRefused('Release-contract tests require the pinned Node runtime.'); +} +echo json_encode([ + 'php' => PHP_VERSION, + 'architecture' => php_uname('m'), + 'extensions' => get_loaded_extensions(), + 'ini' => php_ini_loaded_file(), + 'coverage' => 'none', + 'node' => $nodeVersion, +], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh new file mode 100755 index 0000000..f09cc5b --- /dev/null +++ b/scripts/ci/run-php-container.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +command="${0##*/}" +case "$command" in php|composer) ;; *) echo 'Invoke this launcher as php or composer.' >&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" + --mount "type=bind,source=$state,target=$state") + +# 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 + +# Ordinary tests need no host credentials. The optional smoke fixture has +# explicit, separate role inputs; do not forward arbitrary DW_* or token env. +while IFS= read -r variable; do + case "$variable" in + COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\ + DURABLE_WORKFLOW_CLI_SMOKE|DURABLE_WORKFLOW_CLI_SMOKE_SERVER_URL|\ + DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN|DURABLE_WORKFLOW_CLI_SMOKE_OPERATOR_TOKEN|\ + DURABLE_WORKFLOW_CLI_SMOKE_WORKER_TOKEN|\ + DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ) + options+=(--env "$variable") ;; + *) ;; # Host credentials and unrelated configuration stay outside. + 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 +exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}" diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh new file mode 100755 index 0000000..38fb99c --- /dev/null +++ b/scripts/ci/setup-php-container.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +minor="${1:?Usage: setup-php-container.sh 8.2|8.4|8.5}" +image="$(python3 -I -S -B - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' +import json,re,sys +images=json.load(open(sys.argv[1])) +image=images[sys.argv[2]] +assert re.fullmatch(r'php:8\.[245]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) +print(image) +PY +)" +[[ -f "$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" ]] +[[ "$(git -C "$root/.central-apt-source" rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a ]] +echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 '"$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" | sha256sum --check --strict + +runtime="cli-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" +base="$runtime-base" +docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$base" "$root" +base_id="$(docker image inspect "$base" --format '{{.Id}}')" +docker build --file "$root/docker/php-ci/cli.Dockerfile" --build-arg "PHP_RUNTIME_IMAGE=$base" --tag "$runtime" "$root" +[[ "$(docker image inspect "$base" --format '{{.Id}}')" = "$base_id" ]] +runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" +docker image inspect "$base_id" "$runtime_id" | python3 -I -S -B -c ' +import json,sys +base,runtime=json.load(sys.stdin) +layers=base["RootFS"]["Layers"] +assert runtime["RootFS"]["Layers"][:len(layers)] == layers, "CLI overlay must extend the inspected shared base" +print("php_ci_base="+base["Id"]) +' +docker run --rm --init --name "cli-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ + --cpus 1 --memory 512m --pids-limit 64 --network none \ + --user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$root/scripts/ci/probe-php-test-runtime.php,target=/probe.php,readonly" \ + "$runtime_id" php /probe.php "$minor" +printf 'php_ci_image=%s\n' "$runtime_id" + +state="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/cli-php-ci.XXXXXX")" +mkdir -p "$state/bin" "$state/home" "$state/tmp" "$state/composer-home/cache" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/php" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/composer" +CLI_PHP_CI_TEST_IMAGE="$runtime_id" CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_STATE="$state" \ + python3 -I -S -B "$root/scripts/ci/test-php-container-tls.py" -v +{ + echo "CLI_PHP_CI_IMAGE=$runtime_id" + echo "CLI_PHP_CI_ROOT=$root" + echo "CLI_PHP_CI_STATE=$state" + echo "COMPOSER_HOME=$state/composer-home" + echo "COMPOSER_CACHE_DIR=$state/composer-home/cache" + echo "TMPDIR=$state/tmp" +} >> "${GITHUB_ENV:?GITHUB_ENV is required}" +echo "$state/bin" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/scripts/ci/test-php-container-launcher.py b/scripts/ci/test-php-container-launcher.py new file mode 100644 index 0000000..5a7b62c --- /dev/null +++ b/scripts/ci/test-php-container-launcher.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Exercise the actual launcher without a Docker daemon or credentials.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / 'scripts/ci/run-php-container.sh' +SETUP = ROOT / 'scripts/ci/setup-php-container.sh' + + +class PhpContainerSetupIsolationTest(unittest.TestCase): + def test_workspace_python_imports_do_not_run_before_apt_source_guard(self): + for origin in ['working-directory', 'PYTHONPATH']: + with self.subTest(origin=origin), tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + project = temporary / 'project' + scripts = project / 'scripts/ci' + scripts.mkdir(parents=True) + setup = scripts / SETUP.name + setup.write_bytes(SETUP.read_bytes()) + (scripts / 'php-container-images.json').write_bytes( + (ROOT / 'scripts/ci/php-container-images.json').read_bytes()) + marker = temporary / 'workspace-imported' + poison = project if origin == 'working-directory' else temporary / 'python-path' + poison.mkdir(exist_ok=True) + (poison / 'json.py').write_text( + 'from pathlib import Path\n' + f'Path({str(marker)!r}).write_text("executed")\n' + 'raise RuntimeError("Workspace Python import executed")\n') + binaries = temporary / 'bin' + binaries.mkdir() + (binaries / 'python3').symlink_to(sys.executable) + docker = binaries / 'docker' + docker.write_text('#!/bin/sh\nexit 99\n') + docker.chmod(0o755) + environment = {'PATH': str(binaries) + os.pathsep + os.defpath} + if origin == 'PYTHONPATH': + environment['PYTHONPATH'] = str(poison) + positive = subprocess.run([sys.executable, '-c', 'import json'], cwd=project, + env=environment, capture_output=True) + self.assertNotEqual(0, positive.returncode) + self.assertTrue(marker.exists(), 'The unisolated positive control must execute the marker') + marker.unlink() + + result = subprocess.run(['bash', str(setup), '8.2'], cwd=project, + env=environment, capture_output=True) + self.assertEqual(1, result.returncode, 'The missing APT source must stop setup before Docker') + self.assertFalse(marker.exists(), 'The real setup must not execute workspace/PYTHONPATH code') + + +class PhpContainerLauncherTest(unittest.TestCase): + def invoke(self, command='php', arguments=(), **overrides): + with tempfile.TemporaryDirectory() as directory: + state = Path(directory) + (state / 'bin').mkdir() + (state / 'bin' / command).symlink_to(LAUNCHER) + docker = state / 'bin/docker' + docker.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') + docker.chmod(0o755) + bundle = state / 'ca.pem' + bundle.write_text('synthetic mount only') + 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, + 'SSL_CERT_FILE': str(bundle), + 'COMPOSER_CAFILE': str(bundle), + 'COMPOSER_HOME': str(state / 'composer-home'), + 'DW_CI_CA_SHA256': '1' * 64, + 'GITHUB_TOKEN': 'synthetic-must-not-forward', + 'COMPOSER_AUTH': 'synthetic-must-not-forward', + 'DW_TOKEN': 'synthetic-must-not-forward', + 'PHP_INI_SCAN_DIR': '/synthetic-must-not-forward', + 'DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN': 'synthetic-smoke', + **overrides, + } + arguments = [str(state / 'bin/composer') if x == '{composer}' else x for x in arguments] + result = subprocess.run([str(state / 'bin' / command), *arguments], cwd=ROOT, + env=environment, capture_output=True, text=True) + return result, str(bundle), str(state) + + def test_credentials_and_php_overrides_do_not_enter_runtime(self): + result, bundle, state = self.invoke(arguments=('-r', 'echo PHP_VERSION;')) + self.assertEqual(0, result.returncode, result.stderr) + invocation = json.loads(result.stdout) + passed = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == '--env'] + for name in ['COMPOSER_HOME', 'DW_CI_CA_SHA256', 'DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN']: + self.assertIn(name, passed) + for name in ['GITHUB_TOKEN', 'COMPOSER_AUTH', 'DW_TOKEN', 'PHP_INI_SCAN_DIR']: + self.assertNotIn(name, passed) + self.assertIn('HOME=' + state + '/home', passed) + mounts = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == '--mount'] + self.assertEqual(1, mounts.count(f'type=bind,source={bundle},target={bundle},readonly')) + self.assertFalse(any('docker.sock' in mount for mount in mounts)) + self.assertIn('--cap-drop', invocation) + self.assertIn('no-new-privileges', invocation) + self.assertEqual(['php', '-r', 'echo PHP_VERSION;'], invocation[-3:]) + + def test_composer_verifier_reads_container_phar(self): + result, _, _ = self.invoke(arguments=('/guard/verify.php', '{composer}', str(ROOT)), + GITHUB_ACTION_PATH='/guard') + self.assertEqual(0, result.returncode, result.stderr) + invocation = json.loads(result.stdout) + self.assertEqual(['php', '/guard/verify.php', '/usr/local/bin/composer', str(ROOT)], invocation[-4:]) + self.assertIn('type=bind,source=/guard,target=/guard,readonly', invocation) + + def test_composer_uses_container_executable(self): + result, _, _ = self.invoke(command='composer', arguments=('test',)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['composer', 'test'], json.loads(result.stdout)[-2:]) + + def test_mutable_image_refuses_before_docker(self): + result, _, _ = self.invoke(CLI_PHP_CI_IMAGE='cli-php-ci:mutable') + self.assertNotEqual(0, result.returncode) + self.assertIn('inspected immutable image config ID', result.stderr) + self.assertEqual('', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/ci/test-php-container-tls.py b/scripts/ci/test-php-container-tls.py new file mode 100644 index 0000000..7095592 --- /dev/null +++ b/scripts/ci/test-php-container-tls.py @@ -0,0 +1,97 @@ +import http.server +import json +import os +import pathlib +import ssl +import subprocess +import tempfile +import threading +import unittest + + +@unittest.skipUnless(os.environ.get("CLI_PHP_CI_TEST_IMAGE"), "requires the built PHP CI image") +class PhpContainerTlsTests(unittest.TestCase): + def test_selected_clean_ca_overrides_ambient_trust_before_http(self): + state = pathlib.Path(os.environ["CLI_PHP_CI_STATE"]) + clean_bundle = pathlib.Path(os.environ["SSL_CERT_FILE"]) + requests = [] + + class Repository(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + payload = json.dumps({"packages": {"cli/tls-control": {"1.0.0": { + "name": "cli/tls-control", "version": "1.0.0", "type": "metapackage", + }}}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_arguments): + pass + + with tempfile.TemporaryDirectory(dir=state) as directory: + project = pathlib.Path(directory) + + def openssl(*arguments): + subprocess.run(["openssl", *arguments], cwd=project, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=30) + + openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=AAA Certificate Services", "-keyout", "root.key", "-out", "root.pem") + openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=127.0.0.1", + "-keyout", "server.key", "-out", "server.csr") + (project / "extensions.cnf").write_text("subjectAltName=IP:127.0.0.1\n") + openssl("x509", "-req", "-in", "server.csr", "-CA", "root.pem", "-CAkey", "root.key", + "-CAcreateserial", "-days", "1", "-extfile", "extensions.cnf", "-out", "server.pem") + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(project / "server.pem", project / "server.key") + # The Docker source/state mounts must never expose the synthetic private keys. + (project / "root.key").unlink() + (project / "server.key").unlink() + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Repository) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + (project / "composer.json").write_text(json.dumps({ + "name": "cli/tls-project", + "repositories": [{"type": "composer", "url": f"https://127.0.0.1:{server.server_port}"}, + {"packagist.org": False}], + })) + home = project / "home" + home.mkdir() + (home / "config.json").write_text(json.dumps({"config": {"cafile": str(project / "root.pem")}})) + environment = os.environ.copy() + for name in list(environment): + if name.endswith(("_CA_BUNDLE", "_CAINFO", "_CAPATH")) or name in { + "SSL_CERT_FILE", "SSL_CERT_DIR", "COMPOSER_CAFILE", "COMPOSER_CAPATH", + "PIP_CERT", "CARGO_HTTP_PROXY_CAINFO", "DW_CI_CA_SHA256", "COMPOSER", + }: + environment.pop(name) + environment.update({"CLI_PHP_CI_IMAGE": os.environ["CLI_PHP_CI_TEST_IMAGE"], + "COMPOSER_HOME": str(home), "COMPOSER_CACHE_DIR": str(home / "cache")}) + command = [str(state / "bin/composer"), "--no-plugins", "--no-scripts", "--no-cache", + "show", "--all", "cli/tls-control"] + ambient = subprocess.run(command, cwd=project, env=environment, text=True, + capture_output=True, timeout=45) + self.assertEqual(ambient.returncode, 0, ambient.stderr) + self.assertGreater(len(requests), 0, "Ambient trust must reach the HTTPS repository") + requests.clear() + environment.update({"SSL_CERT_FILE": str(clean_bundle), "CURL_CA_BUNDLE": str(clean_bundle), + "GIT_SSL_CAINFO": str(clean_bundle), "COMPOSER_CAFILE": str(clean_bundle)}) + selected = subprocess.run(command, cwd=project, env=environment, text=True, + capture_output=True, timeout=45) + self.assertNotEqual(selected.returncode, 0) + self.assertIn("curl error 60", selected.stderr, selected.stderr) + self.assertEqual(requests, [], "Selected clean trust must refuse before any HTTP request") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +if __name__ == "__main__": + unittest.main()