diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afa916c..93f62d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,8 @@ jobs: --health-retries=10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: @@ -57,13 +59,15 @@ jobs: - 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: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + - name: Check out the accepted PHP container build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - php-version: ${{ matrix.php }} - extensions: mbstring, intl, pdo_mysql, redis, bcmath, gd, zip - coverage: none - tools: composer:2.10.3 + 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 trust before dependency requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2 # clean-trust @@ -157,6 +161,8 @@ jobs: --health-retries=20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: @@ -169,13 +175,15 @@ jobs: - 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: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + - name: Check out the accepted PHP container build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - php-version: ${{ matrix.php }} - extensions: mbstring, intl, pdo_mysql, bcmath, gd, zip - coverage: none - tools: composer:2.10.3 + 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 trust before dependency requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2 # clean-trust diff --git a/.github/workflows/composer-lock-audit.yml b/.github/workflows/composer-lock-audit.yml index 7acd9df..51f5bd0 100644 --- a/.github/workflows/composer-lock-audit.yml +++ b/.github/workflows/composer-lock-audit.yml @@ -51,12 +51,15 @@ jobs: - 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: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + - name: Check out the accepted PHP container build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - php-version: '8.4' - coverage: none - tools: composer:2.10.3 + 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" "8.4" - name: Verify actual Composer trust before advisory requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2 # clean-trust diff --git a/.github/workflows/playground-journeys.yml b/.github/workflows/playground-journeys.yml index 70fe348..c9da739 100644 --- a/.github/workflows/playground-journeys.yml +++ b/.github/workflows/playground-journeys.yml @@ -37,13 +37,15 @@ jobs: - 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: Set up PHP - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + - name: Check out the accepted PHP container build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - php-version: '8.4' - extensions: mbstring, intl, pdo_mysql, redis, bcmath, gd, zip, curl, pcntl - coverage: none - tools: composer:2.10.3 + 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" "8.4" - name: Verify actual Composer trust before dependency requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2 # clean-trust @@ -76,7 +78,7 @@ jobs: - name: Run each SDK journey against the published native Server env: - PLAYGROUND_SOURCE_ROOT: ${{ runner.temp }}/playground-source + PLAYGROUND_SOURCE_ROOT: ${{ env.SAMPLE_APP_PHP_CI_STATE }}/source run: | mkdir -p "$RUNNER_TEMP/playground-evidence" for language in php python rust; do diff --git a/docker/php-ci/Dockerfile b/docker/php-ci/Dockerfile new file mode 100644 index 0000000..68753e3 --- /dev/null +++ b/docker/php-ci/Dockerfile @@ -0,0 +1,29 @@ +ARG PHP_IMAGE +FROM node:22.23.4-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS node +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/sample-app/require-fixed-bookworm-apt.sh +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY .devcontainer/docker/verify-php-trust.php /usr/local/share/sample-app/verify-php-trust.php +COPY docker/brotli /usr/local/share/sample-app/brotli +COPY docker/install-phpredis.sh /usr/local/bin/install-phpredis + +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && php /usr/local/share/sample-app/verify-php-trust.php /usr/local/bin/composer \ + && sh /usr/local/share/sample-app/brotli/install-vendor.sh /usr/local/share/sample-app/brotli \ + git unzip python3 gcc libc6-dev libicu-dev libonig-dev libzip-dev libpng-dev libjpeg62-turbo-dev libfreetype6-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j2 bcmath gd intl mbstring pcntl pdo_mysql zip \ + && sh /usr/local/bin/install-phpredis \ + && cc -O2 /usr/local/share/sample-app/brotli/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl \ + -o /usr/local/share/sample-app/brotli/verify-system-brotli \ + && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && php /usr/local/share/sample-app/verify-php-trust.php /usr/local/bin/composer \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app diff --git a/scripts/ci/php-container-images.json b/scripts/ci/php-container-images.json new file mode 100644 index 0000000..e38692a --- /dev/null +++ b/scripts/ci/php-container-images.json @@ -0,0 +1,4 @@ +{ + "8.4": "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe", + "8.5": "php:8.5.12-cli-bookworm@sha256:050713ce008e2360ea9dc96e1f9a075690132388d917fc3552806f7a7be6f2a0" +} diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh new file mode 100755 index 0000000..b8d84f6 --- /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="${SAMPLE_APP_PHP_CI_ROOT:?PHP CI repository root is required}" +state="${SAMPLE_APP_PHP_CI_STATE:?PHP CI state directory is required}" +image="${SAMPLE_APP_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 "sample-app-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 the CI configuration and the playground's separate synthetic role credentials. +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|\ + SAMPLE_APP_ROOT|SAMPLE_APP_PLAYGROUND_SCENARIO|SAMPLE_APP_PLAYGROUND_WORKFLOW_ID_PREFIX|\ + 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 0000000..515d5fe --- /dev/null +++ b/scripts/ci/setup-php-container.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +minor="${1:?Usage: setup-php-container.sh 8.4|8.5}" +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\.[45]\.\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="sample-app-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 "sample-app-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", "pdo_mysql", "redis", "bcmath", "gd", "zip", "curl", "pcntl"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing extension: ".$extension); } + } + 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="${RUNNER_TEMP:?RUNNER_TEMP is required}/sample-app-php-ci" +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" +SAMPLE_APP_PHP_CI_TEST_IMAGE="$runtime_id" SAMPLE_APP_PHP_CI_ROOT="$root" SAMPLE_APP_PHP_CI_STATE="$state" \ + python3 -m unittest discover -s "$root/tests/ci" -p test_php_container_tls.py -v +{ + echo "SAMPLE_APP_PHP_CI_IMAGE=$runtime_id" + echo "SAMPLE_APP_PHP_CI_ROOT=$root" + echo "SAMPLE_APP_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/Unit/DevcontainerImageContractTest.php b/tests/Unit/DevcontainerImageContractTest.php index 3e5321e..d7441bf 100644 --- a/tests/Unit/DevcontainerImageContractTest.php +++ b/tests/Unit/DevcontainerImageContractTest.php @@ -128,7 +128,7 @@ public function test_composer_jobs_check_trust_before_dependency_resolution(): v $this->assertSame('52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2', explode('@', $step['uses'])[1]); $this->assertStringContainsString('composer,curl,git', $step['with']['clients']); } - if (str_starts_with($step['uses'] ?? '', 'shivammathur/setup-php@')) { + if (str_contains($step['run'] ?? '', 'scripts/ci/setup-php-container.sh')) { $setup = $index; break; } @@ -136,7 +136,12 @@ public function test_composer_jobs_check_trust_before_dependency_resolution(): v $this->assertNotNull($bootstrap); $this->assertNotNull($setup); $this->assertLessThan($setup, $bootstrap); - $this->assertSame('composer:2.10.3', $steps[$setup]['with']['tools']); + $this->assertStringContainsString('"${{ matrix.php }}"', $steps[$setup]['run']); + $images = json_decode((string) file_get_contents($this->path('scripts/ci/php-container-images.json')), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame(['8.4', '8.5'], array_keys($images)); + foreach ($images as $minor => $image) { + $this->assertMatchesRegularExpression('/^php:'.preg_quote($minor, '/').'\.[0-9]+-cli-bookworm@sha256:[0-9a-f]{64}$/', $image); + } $this->assertSame( 'durable-workflow/.github/.github/actions/verify-composer-trust@52283e2e8ea9a2cf7e3865ee30f2a72a38b098a2', $steps[$setup + 1]['uses'], diff --git a/tests/ci/test_php_container_launcher.py b/tests/ci/test_php_container_launcher.py new file mode 100644 index 0000000..09a41bd --- /dev/null +++ b/tests/ci/test_php_container_launcher.py @@ -0,0 +1,88 @@ +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / "scripts/ci/run-php-container.sh" + + +class PhpContainerLauncherTests(unittest.TestCase): + def invoke(self, command="php", arguments=(), **overrides): + with tempfile.TemporaryDirectory() as directory: + state = pathlib.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 CA mount, never executed") + environment = { + "PATH": str(state / "bin") + os.pathsep + os.environ["PATH"], + "SAMPLE_APP_PHP_CI_ROOT": str(ROOT), + "SAMPLE_APP_PHP_CI_STATE": str(state), + "SAMPLE_APP_PHP_CI_IMAGE": "sha256:" + "f" * 64, + "SSL_CERT_FILE": str(bundle), + "COMPOSER_CAFILE": str(bundle), + "DW_CI_CA_SHA256": "1" * 64, + "DB_PORT": "12345", + "GITHUB_TOKEN": "must-not-enter-container", + "COMPOSER_AUTH": "must-not-enter-container", + "SAMPLE_APP_PLAYGROUND_SCENARIO": '{"input":["sample"]}', + "SAMPLE_APP_ROOT": str(ROOT), + "DURABLE_WORKFLOW_RUNTIME_URL": "http://127.0.0.1:8080", + "DURABLE_WORKFLOW_WORKER_TOKEN": "synthetic-worker", + **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, text=True, capture_output=True, check=True) + return json.loads(result.stdout), str(bundle) + + def test_only_selected_application_and_trust_environment_reaches_runtime(self): + invocation, bundle = self.invoke(arguments=("-r", "echo PHP_VERSION;")) + passed = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == "--env"] + self.assertIn("DB_PORT", passed) + self.assertIn("DW_CI_CA_SHA256", passed) + self.assertNotIn("GITHUB_TOKEN", passed) + self.assertNotIn("COMPOSER_AUTH", passed) + self.assertIn("SAMPLE_APP_PLAYGROUND_SCENARIO", passed) + self.assertIn("SAMPLE_APP_ROOT", passed) + self.assertIn("DURABLE_WORKFLOW_RUNTIME_URL", passed) + self.assertIn("DURABLE_WORKFLOW_WORKER_TOKEN", passed) + mounts = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == "--mount"] + self.assertEqual(mounts.count(f"type=bind,source={bundle},target={bundle},readonly"), 1) + self.assertIn("host", invocation) + self.assertEqual(invocation[-3:], ["php", "-r", "echo PHP_VERSION;"]) + + def test_trust_verifier_inspects_real_composer_phar(self): + invocation, _ = self.invoke(arguments=("/guard/verify.php", "{composer}", str(ROOT)), + GITHUB_ACTION_PATH="/guard") + self.assertEqual(invocation[-4:], ["php", "/guard/verify.php", "/usr/local/bin/composer", str(ROOT)]) + self.assertIn("type=bind,source=/guard,target=/guard,readonly", invocation) + + def test_composer_uses_the_container_executable(self): + invocation, _ = self.invoke(command="composer", arguments=("audit", "--locked")) + self.assertEqual(invocation[-3:], ["composer", "audit", "--locked"]) + + def test_mutable_image_tag_refuses_before_docker(self): + with self.assertRaises(subprocess.CalledProcessError) as failure: + self.invoke(SAMPLE_APP_PHP_CI_IMAGE="sample-app-php-ci:mutable") + self.assertIn("inspected immutable image config ID", failure.exception.stderr) + + def test_missing_runtime_identity_refuses_before_docker(self): + with tempfile.TemporaryDirectory() as directory: + command = pathlib.Path(directory) / "php" + command.symlink_to(LAUNCHER) + result = subprocess.run([str(command), "--version"], env={"PATH": os.environ["PATH"]}, + text=True, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("PHP CI repository root is required", result.stderr) + + +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 0000000..7b0d13f --- /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("SAMPLE_APP_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["SAMPLE_APP_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": {"sample-app/tls-control": {"1.0.0": { + "name": "sample-app/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": "sample-app/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({"SAMPLE_APP_PHP_CI_IMAGE": os.environ["SAMPLE_APP_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", "sample-app/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()