From 7de317bd986b6242f8560cbb387054e4f7306c0a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:12:47 +0000 Subject: [PATCH 1/7] Prepare pinned PHP matrix runtime and guarded launcher --- .dockerignore | 11 +++ .gitignore | 1 + docker/brotli/install-vendor.sh | 11 +++ docker/brotli/verify-runtime.php | 113 ++++++++++++++++++++++++ docker/brotli/verify-system-brotli.c | 43 +++++++++ docker/brotli/version | 1 + docker/php-ci/Dockerfile | 24 +++++ docker/php-ci/README.md | 26 ++++++ docker/php-ci/verify-php-trust.php | 34 +++++++ scripts/ci/lint-php.php | 20 +++++ scripts/ci/php-container-images.json | 4 + scripts/ci/run-php-container.sh | 68 ++++++++++++++ scripts/ci/setup-php-container.sh | 52 +++++++++++ tests/ci/test_php_container_launcher.py | 81 +++++++++++++++++ tests/ci/test_php_container_tls.py | 97 ++++++++++++++++++++ 15 files changed, 586 insertions(+) create mode 100644 .dockerignore create mode 100644 docker/brotli/install-vendor.sh create mode 100644 docker/brotli/verify-runtime.php create mode 100644 docker/brotli/verify-system-brotli.c create mode 100644 docker/brotli/version create mode 100644 docker/php-ci/Dockerfile create mode 100644 docker/php-ci/README.md create mode 100644 docker/php-ci/verify-php-trust.php create mode 100644 scripts/ci/lint-php.php create mode 100644 scripts/ci/php-container-images.json create mode 100755 scripts/ci/run-php-container.sh create mode 100755 scripts/ci/setup-php-container.sh create mode 100644 tests/ci/test_php_container_launcher.py create mode 100644 tests/ci/test_php_container_tls.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..aa219576 --- /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/.gitignore b/.gitignore index b91e237b..821d0169 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.phpunit.result.cache /coverage.xml /composer.lock +/.central-apt-source/ /polyglot-evidence/ /polyglot-build.json /polyglot-package-provenance.txt diff --git a/docker/brotli/install-vendor.sh b/docker/brotli/install-vendor.sh new file mode 100644 index 00000000..a60803ef --- /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 00000000..b708ab0c --- /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 00000000..59356043 --- /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 00000000..92b42278 --- /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 00000000..b87480b7 --- /dev/null +++ b/docker/php-ci/Dockerfile @@ -0,0 +1,24 @@ +ARG PHP_IMAGE +FROM composer:2.10.3@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 00000000..c1309610 --- /dev/null +++ b/docker/php-ci/README.md @@ -0,0 +1,26 @@ +# PHP CI runtime + +This is the SDK adaptation of Sample App's reviewed PHP/Composer container +pattern from main `c680ef92d578ee837d2d960c4235d5919d890134` (PR #341). +The literal official PHP 8.2 and 8.4 image indexes are in +`scripts/ci/php-container-images.json`. The fixed Bookworm APT prerequisite +must come from central source `18783be6bef737cc778e4c7282a8797e39daa61a` at +`.central-apt-source`; setup verifies its commit and helper hash before building. + +The common runtime compiles bcmath, intl, mbstring, pcntl, sockets and zip with +two build jobs. It retains the official image's cURL, POSIX, XML and SQLite +modules and probes the SDK/PHPUnit requirements, production INI and absent +coverage drivers. It adds no MySQL, PostgreSQL, Redis or GD profile. The +existing vendor Brotli verification and Composer 2.10.3 trust inspection run +during the image build. + +The launcher binds the inspected image config ID and selected CA paths. Each +setup runs the actual Composer TLS control: ambient synthetic trust reaches +the local repository; the selected clean bundle refuses before HTTP. Normal +dependency requests still require the reviewed live Composer guard. Runtime +and trust files require SDK-specific recipe review before CI caller admission; +Sample App's policy entry does not authorize this target. + +PHP 8.1 remains a separate migration constraint: its last official Bookworm +image carries APT 2.6.1. The supported PHP matrix stays unchanged while the +fixed-parent approach is reviewed under shared #297. diff --git a/docker/php-ci/verify-php-trust.php b/docker/php-ci/verify-php-trust.php new file mode 100644 index 00000000..36da8d26 --- /dev/null +++ b/docker/php-ci/verify-php-trust.php @@ -0,0 +1,34 @@ +getExtension() !== 'php') { + continue; + } + $process = proc_open([PHP_BINARY, '-l', $file->getPathname()], [STDIN, STDOUT, STDERR], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Cannot start PHP lint.'); + } + $failed = proc_close($process) !== 0 || $failed; + } +} +exit($failed ? 1 : 0); diff --git a/scripts/ci/php-container-images.json b/scripts/ci/php-container-images.json new file mode 100644 index 00000000..fd4d6705 --- /dev/null +++ b/scripts/ci/php-container-images.json @@ -0,0 +1,4 @@ +{ + "8.2": "php:8.2.35-cli-bookworm@sha256:ce09ca1d4b532edf9b0347b44a1968a80d9175addb581c450ba0c9a43fa0c5ea", + "8.4": "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe" +} diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh new file mode 100755 index 00000000..28181a29 --- /dev/null +++ b/scripts/ci/run-php-container.sh @@ -0,0 +1,68 @@ +#!/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="${SDK_PHP_CI_ROOT:?PHP CI repository root is required}" +state="${SDK_PHP_CI_STATE:?PHP CI state directory is required}" +image="${SDK_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 "sdk-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" + --mount "type=bind,source=$root,target=$root" + --mount "type=bind,source=$state,target=$state") + +# Composer's guard is a composite action outside the repository checkout. +if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then + options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly") +fi +# A local worktree has its Git metadata outside the source mount. +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 + +# Pass only SDK test configuration and synthetic runtime roles. +while IFS= read -r variable; do + case "$variable" in + APP_*|DB_*|SHARED_DB_*|REDIS_*|CACHE_*|QUEUE_*|SESSION_*|MAIL_*|BCRYPT_ROUNDS|\ + COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\ + SDK_PATH|SOURCE_MODE|WORKFLOW_REQUIREMENT|LARAVEL_REQUIREMENT|\ + FRAMEWORK_BOOTSTRAP|FRAMEWORK_PACKAGE|FRAMEWORK_EXERCISED_PACKAGE|FRAMEWORK_CONSOLE_PACKAGE|COMPAT_SCRIPT|\ + DURABLE_WORKFLOW_RUNTIME_URL|DURABLE_WORKFLOW_SERVER_URL|DURABLE_WORKFLOW_NAMESPACE|\ + DURABLE_WORKFLOW_TASK_QUEUE|DURABLE_WORKFLOW_WORKER_ID|\ + DURABLE_WORKFLOW_WORKER_TOKEN|DURABLE_WORKFLOW_CLIENT_TOKEN|\ + DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ) + options+=(--env "$variable") ;; + esac +done < <(compgen -e) + +# The host verifier discovers the launcher, but must inspect the real 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 00000000..2cf301dd --- /dev/null +++ b/scripts/ci/setup-php-container.sh @@ -0,0 +1,52 @@ +#!/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}" +image="$(python3 - "$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\.[24]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) +print(image) +PY +)" +test -f "$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" +test "$(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="sdk-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" +docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" +runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" +docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ + --cpus 1 --memory 512m --pids-limit 64 --network none "$runtime_id" \ + php -r ' + if (PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION !== $argv[1]) { throw new RuntimeException("The PHP matrix minor does not match the image."); } + foreach (["mbstring", "intl", "bcmath", "sockets", "zip", "curl", "pcntl", "posix", "dom", "xml", "xmlwriter", "pdo_sqlite", "sqlite3"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing extension: ".$extension); } + } + foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { + if (!function_exists($function)) { throw new RuntimeException("Missing function: ".$function); } + } + if (extension_loaded("xdebug") || extension_loaded("pcov")) { throw new RuntimeException("Coverage must remain none."); } + if (php_ini_loaded_file() !== "/usr/local/etc/php/php.ini") { throw new RuntimeException("Production INI must be loaded."); } + echo json_encode(["php" => PHP_VERSION, "extensions" => get_loaded_extensions(), "ini" => php_ini_loaded_file(), "coverage" => "none"]), PHP_EOL; + ' "$minor" +printf 'php_ci_image=%s\n' "$runtime_id" + +state="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/sdk-php-ci.XXXXXX")" +mkdir -p "$state/bin" "$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" +SDK_PHP_CI_TEST_IMAGE="$runtime_id" SDK_PHP_CI_ROOT="$root" SDK_PHP_CI_STATE="$state" \ + python3 -m unittest discover -s "$root/tests/ci" -p test_php_container_tls.py -v +{ + echo "SDK_PHP_CI_IMAGE=$runtime_id" + echo "SDK_PHP_CI_ROOT=$root" + echo "SDK_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/tests/ci/test_php_container_launcher.py b/tests/ci/test_php_container_launcher.py new file mode 100644 index 00000000..2af27ccb --- /dev/null +++ b/tests/ci/test_php_container_launcher.py @@ -0,0 +1,81 @@ +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-container.sh" + + +class PhpContainerLauncherTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="sdk-php-launcher-") + self.addCleanup(self.temporary.cleanup) + self.state = Path(self.temporary.name) + self.binary = self.state / "bin" + self.binary.mkdir() + self.log = self.state / "docker-arguments.json" + docker = self.binary / "docker" + docker.write_text("#!/usr/bin/env python3\nimport json,os,sys\n" + "open(os.environ['SDK_PHP_CI_TEST_LOG'],'w').write(json.dumps(sys.argv[1:]))\n") + docker.chmod(0o755) + for name in ("php", "composer"): + (self.binary / name).symlink_to(LAUNCHER) + self.environment = os.environ.copy() + self.environment.update({ + "PATH": str(self.binary) + os.pathsep + self.environment["PATH"], + "SDK_PHP_CI_ROOT": str(ROOT), "SDK_PHP_CI_STATE": str(self.state), + "SDK_PHP_CI_IMAGE": "sha256:" + "a" * 64, + "SDK_PHP_CI_TEST_LOG": str(self.log), + }) + + def invoke(self, command, *arguments): + return subprocess.run([str(self.binary / command), *arguments], cwd=ROOT, + env=self.environment, capture_output=True, text=True, timeout=10) + + def arguments(self): + return json.loads(self.log.read_text()) + + def test_config_id_and_caps_are_bound_without_passing_host_credentials(self): + for name in ("GH_TOKEN", "GITHUB_TOKEN", "COMPOSER_AUTH"): + self.environment[name] = "synthetic-unforwarded-canary" + result = self.invoke("php", "-r", "echo 1;") + self.assertEqual(result.returncode, 0, result.stderr) + arguments = self.arguments() + self.assertIn("sha256:" + "a" * 64, arguments) + self.assertIn("--init", arguments) + for option, value in (("--cpus", "2"), ("--memory", "2g"), + ("--pids-limit", "512"), ("--cap-drop", "ALL"), + ("--security-opt", "no-new-privileges")): + self.assertEqual(arguments[arguments.index(option) + 1], value) + for name in ("GH_TOKEN", "GITHUB_TOKEN", "COMPOSER_AUTH"): + self.assertNotIn(name, arguments) + self.assertNotIn("synthetic-unforwarded-canary", str(arguments)) + + def test_mutable_image_selector_refuses_before_docker(self): + self.environment["SDK_PHP_CI_IMAGE"] = "php:8.4" + result = self.invoke("php", "--version") + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + + def test_selected_ca_mounts_are_read_only_and_composer_guard_uses_real_phar(self): + bundle = self.state / "selected.pem" + bundle.write_text("public certificate fixture\n") + roots = self.state / "empty-roots" + roots.mkdir() + self.environment.update({"SSL_CERT_FILE": str(bundle), "COMPOSER_CAFILE": str(bundle), + "SSL_CERT_DIR": str(roots), "COMPOSER_CAPATH": str(roots)}) + result = self.invoke("php", "/reviewed/verify.php", str(self.binary / "composer")) + self.assertEqual(result.returncode, 0, result.stderr) + arguments = self.arguments() + for selected in (bundle, roots): + mount = f"type=bind,source={selected},target={selected},readonly" + self.assertEqual(arguments.count(mount), 1) + self.assertEqual(arguments[-3:], ["php", "/reviewed/verify.php", "/usr/local/bin/composer"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci/test_php_container_tls.py b/tests/ci/test_php_container_tls.py new file mode 100644 index 00000000..aa422786 --- /dev/null +++ b/tests/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("SDK_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["SDK_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": {"sdk-php/tls-control": {"1.0.0": { + "name": "sdk-php/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": "sdk-php/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({"SDK_PHP_CI_IMAGE": os.environ["SDK_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", "sdk-php/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() From 4d37a42d1c95020fa2c75dcd0cc7585dd788f71c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:16:41 +0000 Subject: [PATCH 2/7] Qualify launcher controls and satisfy CI asset checks --- .github/workflows/ci.yml | 2 ++ docker/php-ci/Dockerfile | 2 +- docker/php-ci/verify-php-trust.php | 12 +++++++----- scripts/ci/lint-php.php | 4 +++- scripts/ci/run-php-container.sh | 1 + scripts/ci/setup-php-container.sh | 4 ++-- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb83eda..41b1fef8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,8 @@ jobs: run: python scripts/ci/test-regression-corpus-policy.py - name: Require APT retries off before networked APT (CVE-2026-109518) run: python -I scripts/ci/test-apt-no-retries.py + - name: Verify PHP container launcher boundaries + run: python -B -m unittest discover -s tests/ci -p test_php_container_launcher.py test: name: PHP ${{ matrix.php }} diff --git a/docker/php-ci/Dockerfile b/docker/php-ci/Dockerfile index b87480b7..8e69dba4 100644 --- a/docker/php-ci/Dockerfile +++ b/docker/php-ci/Dockerfile @@ -1,5 +1,5 @@ ARG PHP_IMAGE -FROM composer:2.10.3@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer +FROM composer@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer FROM ${PHP_IMAGE} diff --git a/docker/php-ci/verify-php-trust.php b/docker/php-ci/verify-php-trust.php index 36da8d26..79f2e8cc 100644 --- a/docker/php-ci/verify-php-trust.php +++ b/docker/php-ci/verify-php-trust.php @@ -2,12 +2,14 @@ declare(strict_types=1); +final class PhpComposerTrustRefused extends RuntimeException {} + Phar::loadPhar($argv[1]); -require 'phar://composer.phar/vendor/autoload.php'; +require_once 'phar://composer.phar/vendor/autoload.php'; $version = Composer\Composer::VERSION; if (!str_starts_with($version, '2.') || version_compare($version, '2.10.3', '<')) { - throw new RuntimeException('Composer 2.10.3 or newer on the 2 line is required.'); + throw new PhpComposerTrustRefused('Composer 2.10.3 or newer on the 2 line is required.'); } $paths = [ @@ -18,16 +20,16 @@ foreach (array_unique($paths) as $path) { $bundle = file_get_contents($path); if ($bundle === false || !preg_match_all('/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s', $bundle, $certificates)) { - throw new RuntimeException('Cannot inspect effective CA bundle: '.$path); + throw new PhpComposerTrustRefused('Cannot inspect effective CA bundle: '.$path); } foreach ($certificates[0] as $certificate) { $parsed = openssl_x509_parse($certificate); if ($parsed === false) { - throw new RuntimeException('Invalid certificate in effective CA bundle: '.$path); + throw new PhpComposerTrustRefused('Invalid certificate in effective CA bundle: '.$path); } $subject = $parsed['subject']['CN'] ?? ''; if (in_array($subject, ['AAA Certificate Services', 'emSign ECC Root CA - G3'], true)) { - throw new RuntimeException('Blocked certificate in effective CA bundle: '.$path); + throw new PhpComposerTrustRefused('Blocked certificate in effective CA bundle: '.$path); } } echo 'Verified PHP/Composer CA bundle: '.$path.PHP_EOL; diff --git a/scripts/ci/lint-php.php b/scripts/ci/lint-php.php index b0ef5415..f9612758 100644 --- a/scripts/ci/lint-php.php +++ b/scripts/ci/lint-php.php @@ -2,6 +2,8 @@ declare(strict_types=1); +final class PhpLintUnavailable extends RuntimeException {} + // One container invocation; each file is linted by this runtime's PHP binary. $failed = false; foreach (['src', 'tests', 'examples'] as $directory) { @@ -12,7 +14,7 @@ } $process = proc_open([PHP_BINARY, '-l', $file->getPathname()], [STDIN, STDOUT, STDERR], $pipes); if (!is_resource($process)) { - throw new RuntimeException('Cannot start PHP lint.'); + throw new PhpLintUnavailable('Cannot start PHP lint.'); } $failed = proc_close($process) !== 0 || $failed; } diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh index 28181a29..76ea8710 100755 --- a/scripts/ci/run-php-container.sh +++ b/scripts/ci/run-php-container.sh @@ -54,6 +54,7 @@ while IFS= read -r variable; do DURABLE_WORKFLOW_WORKER_TOKEN|DURABLE_WORKFLOW_CLIENT_TOKEN|\ DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ) options+=(--env "$variable") ;; + *) ;; # Host credentials and unrelated configuration stay outside. esac done < <(compgen -e) diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index 2cf301dd..30992907 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -12,8 +12,8 @@ assert re.fullmatch(r'php:8\.[24]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) print(image) PY )" -test -f "$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" -test "$(git -C "$root/.central-apt-source" rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a +[[ -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="sdk-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" From 14727d1c9c5292e2b54dfed49c7061c27816ff37 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:52:18 +0000 Subject: [PATCH 3/7] Isolate host Python imports before PHP container setup --- .github/workflows/ci.yml | 2 +- scripts/ci/setup-php-container.sh | 4 ++-- tests/ci/test_php_container_launcher.py | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41b1fef8..ac1ae4a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: - name: Require APT retries off before networked APT (CVE-2026-109518) run: python -I scripts/ci/test-apt-no-retries.py - name: Verify PHP container launcher boundaries - run: python -B -m unittest discover -s tests/ci -p test_php_container_launcher.py + run: python -I -S -B tests/ci/test_php_container_launcher.py test: name: PHP ${{ matrix.php }} diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index 30992907..3d306f64 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -4,7 +4,7 @@ umask 077 root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" minor="${1:?Usage: setup-php-container.sh 8.2|8.4}" -image="$(python3 - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' +image="$(python3 -I -S - "$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]] @@ -40,7 +40,7 @@ mkdir -p "$state/bin" "$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" SDK_PHP_CI_TEST_IMAGE="$runtime_id" SDK_PHP_CI_ROOT="$root" SDK_PHP_CI_STATE="$state" \ - python3 -m unittest discover -s "$root/tests/ci" -p test_php_container_tls.py -v + python3 -I -S -B "$root/tests/ci/test_php_container_tls.py" -v { echo "SDK_PHP_CI_IMAGE=$runtime_id" echo "SDK_PHP_CI_ROOT=$root" diff --git a/tests/ci/test_php_container_launcher.py b/tests/ci/test_php_container_launcher.py index 2af27ccb..c034200a 100644 --- a/tests/ci/test_php_container_launcher.py +++ b/tests/ci/test_php_container_launcher.py @@ -4,6 +4,7 @@ import subprocess import tempfile import unittest +import shutil ROOT = Path(__file__).resolve().parents[2] @@ -76,6 +77,25 @@ def test_selected_ca_mounts_are_read_only_and_composer_guard_uses_real_phar(self self.assertEqual(arguments.count(mount), 1) self.assertEqual(arguments[-3:], ["php", "/reviewed/verify.php", "/usr/local/bin/composer"]) + def test_host_selector_ignores_workspace_and_pythonpath_modules_before_apt(self): + for location in ("workspace", "pythonpath"): + with self.subTest(location=location): + root = self.state / location + scripts = root / "scripts/ci" + scripts.mkdir(parents=True) + for name in ("setup-php-container.sh", "php-container-images.json"): + shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) + marker = root / "untrusted-import-ran" + modules = root if location == "workspace" else root / "ambient" + modules.mkdir(exist_ok=True) + (modules / "json.py").write_text(f"open({str(marker)!r},'w').write('marker only')\n") + environment = self.environment | {"PYTHONPATH": str(modules)} + result = subprocess.run(["bash", str(scripts / "setup-php-container.sh"), "8.2"], + cwd=root, env=environment, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0, "The deliberately absent APT helper must stop before Docker.") + self.assertFalse(marker.exists(), "Host setup must never import workspace/PYTHONPATH code.") + self.assertFalse(self.log.exists()) + if __name__ == "__main__": unittest.main() From bcc2f773c79f2e49938043a21a2f0d6c2289c6fa Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:12:52 +0000 Subject: [PATCH 4/7] Prepare PHP 8.1 on the fixed Bookworm parent --- .dockerignore | 1 + docker/php-ci/Dockerfile.81 | 44 ++++++++++++++++++++++++++++ docker/php-ci/README.md | 11 ++++++- scripts/ci/php-container-images.json | 1 + scripts/ci/setup-php-container.sh | 10 +++++-- 5 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 docker/php-ci/Dockerfile.81 diff --git a/.dockerignore b/.dockerignore index aa219576..c011ecb2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ !docker/ !docker/php-ci/ !docker/php-ci/Dockerfile +!docker/php-ci/Dockerfile.81 !docker/php-ci/verify-php-trust.php !docker/brotli/ !docker/brotli/* diff --git a/docker/php-ci/Dockerfile.81 b/docker/php-ci/Dockerfile.81 new file mode 100644 index 00000000..d6638d42 --- /dev/null +++ b/docker/php-ci/Dockerfile.81 @@ -0,0 +1,44 @@ +ARG PHP_IMAGE +FROM ${PHP_IMAGE} AS php81 +FROM composer@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer + +# Same fixed linux/amd64 Bookworm parent bound by the official 8.2/8.4 images. +# The old PHP image supplies only PHP files; none of its APT code is executed. +FROM debian@sha256:c67e92c8099ba22853ab4cfda7cfb494d253d6012ca82dee98ba8976772af018 + +ENV PHP_INI_DIR=/usr/local/etc/php \ + PHPIZE_DEPS="autoconf dpkg-dev file g++ gcc libc6-dev make pkg-config re2c" \ + PHP_CFLAGS="-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64" \ + PHP_CPPFLAGS="-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64" \ + PHP_LDFLAGS="-Wl,-O1 -pie" \ + PHP_VERSION=8.1.34 \ + PHP_SHA256=ffa9e0982e82eeaea848f57687b425ed173aa278fe563001310ae2638db5c251 + +COPY --from=php81 /usr/local /usr/local +COPY --from=php81 /usr/src/php.tar.xz /usr/src/php.tar.xz +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 test "$(dpkg --print-architecture)" = amd64 \ + && /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 \ + && printf 'Package: php*\nPin: release *\nPin-Priority: -1\n' > /etc/apt/preferences.d/no-debian-php \ + && echo "$PHP_SHA256 /usr/src/php.tar.xz" | sha256sum --check --strict \ + && sh /usr/local/share/durable-workflow/php-ci/brotli/install-vendor.sh /usr/local/share/durable-workflow/php-ci/brotli \ + autoconf dpkg-dev file g++ gcc libc6-dev make pkg-config re2c \ + ca-certificates curl xz-utils git unzip python3 libicu-dev libonig-dev libzip-dev \ + libargon2-1 libcurl4 libreadline8 libsqlite3-0 libsodium23 libssl3 libxml2 zlib1g \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && 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 +ENTRYPOINT ["docker-php-entrypoint"] +CMD ["php", "-a"] diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md index c1309610..f9eac002 100644 --- a/docker/php-ci/README.md +++ b/docker/php-ci/README.md @@ -2,11 +2,20 @@ This is the SDK adaptation of Sample App's reviewed PHP/Composer container pattern from main `c680ef92d578ee837d2d960c4235d5919d890134` (PR #341). -The literal official PHP 8.2 and 8.4 image indexes are in +The literal official PHP 8.1, 8.2 and 8.4 image indexes are in `scripts/ci/php-container-images.json`. The fixed Bookworm APT prerequisite must come from central source `18783be6bef737cc778e4c7282a8797e39daa61a` at `.central-apt-source`; setup verifies its commit and helper hash before building. +PHP 8.1 uses the separate `Dockerfile.81`. Its last official image contains +APT 2.6.1, so the build copies only `/usr/local` and the hashed PHP source +archive from that immutable image. It runs on the fixed linux/amd64 Bookworm +parent already bound by the official 8.2/8.4 images, checks the APT prerequisite +before any APT request, then installs the PHP ABI/runtime and build libraries. +It never starts the old image or runs its APT. This source proposal preserves +the existing PHP 8.1 and Carbon 2 jobs; the actual build, modules, Composer TLS +and original consumers must qualify independently before caller activation. + The common runtime compiles bcmath, intl, mbstring, pcntl, sockets and zip with two build jobs. It retains the official image's cURL, POSIX, XML and SQLite modules and probes the SDK/PHPUnit requirements, production INI and absent diff --git a/scripts/ci/php-container-images.json b/scripts/ci/php-container-images.json index fd4d6705..d3c8bf5f 100644 --- a/scripts/ci/php-container-images.json +++ b/scripts/ci/php-container-images.json @@ -1,4 +1,5 @@ { + "8.1": "php:8.1.34-cli-bookworm@sha256:482717722f08d9a6c2b4f98c40bfee6d7935bd1a17fef928881ecd5884dfd76e", "8.2": "php:8.2.35-cli-bookworm@sha256:ce09ca1d4b532edf9b0347b44a1968a80d9175addb581c450ba0c9a43fa0c5ea", "8.4": "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe" } diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index 3d306f64..b05ac303 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -3,12 +3,12 @@ set -euo pipefail umask 077 root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -minor="${1:?Usage: setup-php-container.sh 8.2|8.4}" +minor="${1:?Usage: setup-php-container.sh 8.1|8.2|8.4}" image="$(python3 -I -S - "$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\.[24]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) +assert re.fullmatch(r'php:8\.[124]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) print(image) PY )" @@ -17,7 +17,11 @@ PY echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 '"$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" | sha256sum --check --strict runtime="sdk-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" -docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" +dockerfile="$root/docker/php-ci/Dockerfile" +if [[ "$minor" = 8.1 ]]; then + dockerfile="$root/docker/php-ci/Dockerfile.81" +fi +docker build --file "$dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ --cpus 1 --memory 512m --pids-limit 64 --network none "$runtime_id" \ From 85ac10c1a6c122834b87866cb7765d7a402e3832 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:21:08 +0000 Subject: [PATCH 5/7] Default the PHP 8.1 runtime to an unprivileged user --- docker/php-ci/Dockerfile.81 | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/php-ci/Dockerfile.81 b/docker/php-ci/Dockerfile.81 index d6638d42..481a55a4 100644 --- a/docker/php-ci/Dockerfile.81 +++ b/docker/php-ci/Dockerfile.81 @@ -40,5 +40,6 @@ RUN test "$(dpkg --print-architecture)" = amd64 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app +USER www-data ENTRYPOINT ["docker-php-entrypoint"] CMD ["php", "-a"] From 20047958581febd597de05dff062b0896f36f383 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:32:28 +0000 Subject: [PATCH 6/7] Preserve the existing PHP CI defaults in the runtime launcher --- docker/php-ci/README.md | 14 +++++++++++--- docker/php-ci/ci-defaults.ini | 6 ++++++ scripts/ci/run-php-container.sh | 3 ++- scripts/ci/setup-php-container.sh | 9 +++++++-- tests/ci/test_php_container_launcher.py | 6 ++++++ 5 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 docker/php-ci/ci-defaults.ini diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md index f9eac002..33aea9ea 100644 --- a/docker/php-ci/README.md +++ b/docker/php-ci/README.md @@ -23,6 +23,15 @@ coverage drivers. It adds no MySQL, PostgreSQL, Redis or GD profile. The existing vendor Brotli verification and Composer 2.10.3 trust inspection run during the image build. +Each probe and launcher invocation also mounts the checked-in CI defaults +read-only over the production INI. These preserve the original setup-php +f3e473d1 `php.ini`/`jit.ini` values: UTC, `memory_limit=-1`, enabled OPcache, +256M JIT buffer and mode1235. CLI OPcache is not enabled by this file. The +container memory cap still bounds the process. A plain production128M limit +fails the PHP8.1 suite; the original hosted caller uses more than128MiB and +passes with its existing unlimited PHP limit. Runtime files remain unchanged, +so this SDK-specific test profile needs no shared-base rebuild. + The launcher binds the inspected image config ID and selected CA paths. Each setup runs the actual Composer TLS control: ambient synthetic trust reaches the local repository; the selected clean bundle refuses before HTTP. Normal @@ -30,6 +39,5 @@ dependency requests still require the reviewed live Composer guard. Runtime and trust files require SDK-specific recipe review before CI caller admission; Sample App's policy entry does not authorize this target. -PHP 8.1 remains a separate migration constraint: its last official Bookworm -image carries APT 2.6.1. The supported PHP matrix stays unchanged while the -fixed-parent approach is reviewed under shared #297. +The supported PHP matrix stays unchanged while caller migration and its exact +runtime/CI-defaults recipe are reviewed under shared #297. diff --git a/docker/php-ci/ci-defaults.ini b/docker/php-ci/ci-defaults.ini new file mode 100644 index 00000000..7962f647 --- /dev/null +++ b/docker/php-ci/ci-defaults.ini @@ -0,0 +1,6 @@ +; Existing setup-php f3e473d1 defaults from src/configs/ini/{php,jit}.ini. +date.timezone=UTC +memory_limit=-1 +opcache.enable=1 +opcache.jit_buffer_size=256M +opcache.jit=1235 diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh index 76ea8710..347f20e4 100755 --- a/scripts/ci/run-php-container.sh +++ b/scripts/ci/run-php-container.sh @@ -16,7 +16,8 @@ options=(--rm --init --interactive --name "sdk-php-ci-${command}-${BASHPID}" --cap-drop ALL --security-opt no-new-privileges --user "$(id -u):$(id -g)" --workdir "$PWD" --mount "type=bind,source=$root,target=$root" - --mount "type=bind,source=$state,target=$state") + --mount "type=bind,source=$state,target=$state" + --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-sdk-php-ci.ini,readonly") # Composer's guard is a composite action outside the repository checkout. if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index b05ac303..10649e2e 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -24,7 +24,8 @@ fi docker build --file "$dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ - --cpus 1 --memory 512m --pids-limit 64 --network none "$runtime_id" \ + --cpus 1 --memory 512m --pids-limit 64 --network none \ + --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-sdk-php-ci.ini,readonly" "$runtime_id" \ php -r ' if (PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION !== $argv[1]) { throw new RuntimeException("The PHP matrix minor does not match the image."); } foreach (["mbstring", "intl", "bcmath", "sockets", "zip", "curl", "pcntl", "posix", "dom", "xml", "xmlwriter", "pdo_sqlite", "sqlite3"] as $extension) { @@ -35,7 +36,11 @@ docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local} } if (extension_loaded("xdebug") || extension_loaded("pcov")) { throw new RuntimeException("Coverage must remain none."); } if (php_ini_loaded_file() !== "/usr/local/etc/php/php.ini") { throw new RuntimeException("Production INI must be loaded."); } - echo json_encode(["php" => PHP_VERSION, "extensions" => get_loaded_extensions(), "ini" => php_ini_loaded_file(), "coverage" => "none"]), PHP_EOL; + $defaults = ["date.timezone" => "UTC", "memory_limit" => "-1", "opcache.enable" => "1", "opcache.jit_buffer_size" => "256M", "opcache.jit" => "1235"]; + foreach ($defaults as $setting => $expected) { + if (ini_get($setting) !== $expected) { throw new RuntimeException("Existing CI default changed: ".$setting); } + } + echo json_encode(["php" => PHP_VERSION, "extensions" => get_loaded_extensions(), "ini" => php_ini_loaded_file(), "ci_defaults" => $defaults, "coverage" => "none"]), PHP_EOL; ' "$minor" printf 'php_ci_image=%s\n' "$runtime_id" diff --git a/tests/ci/test_php_container_launcher.py b/tests/ci/test_php_container_launcher.py index c034200a..a0595fc2 100644 --- a/tests/ci/test_php_container_launcher.py +++ b/tests/ci/test_php_container_launcher.py @@ -62,6 +62,12 @@ def test_mutable_image_selector_refuses_before_docker(self): self.assertNotEqual(result.returncode, 0) self.assertFalse(self.log.exists()) + def test_existing_ci_ini_defaults_are_mounted_read_only(self): + result = self.invoke("php", "-r", "echo ini_get('memory_limit');") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"type=bind,source={ROOT}/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-sdk-php-ci.ini,readonly", + self.arguments()) + def test_selected_ca_mounts_are_read_only_and_composer_guard_uses_real_phar(self): bundle = self.state / "selected.pem" bundle.write_text("public certificate fixture\n") From e6494b86f930c0c196b36a5e94d3b7f5f46c437e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:36:13 +0000 Subject: [PATCH 7/7] Run the mounted-profile probe as the caller UID --- scripts/ci/setup-php-container.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index 10649e2e..ef6ab9ca 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -24,7 +24,7 @@ fi docker build --file "$dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" docker run --rm --init --name "sdk-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ - --cpus 1 --memory 512m --pids-limit 64 --network none \ + --cpus 1 --memory 512m --pids-limit 64 --network none --user "$(id -u):$(id -g)" \ --mount "type=bind,source=$root/docker/php-ci/ci-defaults.ini,target=/usr/local/etc/php/conf.d/zz-sdk-php-ci.ini,readonly" "$runtime_id" \ php -r ' if (PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION !== $argv[1]) { throw new RuntimeException("The PHP matrix minor does not match the image."); }