Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**
!docker/
!docker/php-ci/
!docker/php-ci/Dockerfile
!docker/php-ci/Dockerfile.81
!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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -I -S -B tests/ci/test_php_container_launcher.py

test:
name: PHP ${{ matrix.php }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/.phpunit.result.cache
/coverage.xml
/composer.lock
/.central-apt-source/
/polyglot-evidence/
/polyglot-build.json
/polyglot-package-provenance.txt
Expand Down
11 changes: 11 additions & 0 deletions docker/brotli/install-vendor.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
113 changes: 113 additions & 0 deletions docker/brotli/verify-runtime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

final class BrotliRuntimeRefused extends RuntimeException {}

function brotliCommand(array $command): string
{
$process = proc_open($command, [0 => ['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();
}
43 changes: 43 additions & 0 deletions docker/brotli/verify-system-brotli.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#define _GNU_SOURCE
#include <brotli/decode.h>
#include <brotli/encode.h>
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

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;
}
1 change: 1 addition & 0 deletions docker/brotli/version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.9-2+deb12u1
24 changes: 24 additions & 0 deletions docker/php-ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions docker/php-ci/Dockerfile.81
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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
USER www-data
ENTRYPOINT ["docker-php-entrypoint"]
CMD ["php", "-a"]
43 changes: 43 additions & 0 deletions docker/php-ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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.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
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
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.

The supported PHP matrix stays unchanged while caller migration and its exact
runtime/CI-defaults recipe are reviewed under shared #297.
6 changes: 6 additions & 0 deletions docker/php-ci/ci-defaults.ini
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions docker/php-ci/verify-php-trust.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

final class PhpComposerTrustRefused extends RuntimeException {}

Phar::loadPhar($argv[1]);
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 PhpComposerTrustRefused('Composer 2.10.3 or newer on the 2 line is required.');
}

$paths = [
'/etc/ssl/certs/ca-certificates.crt',
getenv('SSL_CERT_FILE') ?: ini_get('openssl.cafile') ?: openssl_get_cert_locations()['default_cert_file'],
getenv('COMPOSER_CAFILE') ?: Composer\CaBundle\CaBundle::getSystemCaRootBundlePath(),
];
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 PhpComposerTrustRefused('Cannot inspect effective CA bundle: '.$path);
}
foreach ($certificates[0] as $certificate) {
$parsed = openssl_x509_parse($certificate);
if ($parsed === false) {
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 PhpComposerTrustRefused('Blocked certificate in effective CA bundle: '.$path);
}
}
echo 'Verified PHP/Composer CA bundle: '.$path.PHP_EOL;
}
22 changes: 22 additions & 0 deletions scripts/ci/lint-php.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

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) {
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS));
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$process = proc_open([PHP_BINARY, '-l', $file->getPathname()], [STDIN, STDOUT, STDERR], $pipes);
if (!is_resource($process)) {
throw new PhpLintUnavailable('Cannot start PHP lint.');
}
$failed = proc_close($process) !== 0 || $failed;
}
}
exit($failed ? 1 : 0);
5 changes: 5 additions & 0 deletions scripts/ci/php-container-images.json
Original file line number Diff line number Diff line change
@@ -0,0 +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"
}
Loading
Loading