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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
19 changes: 13 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
test:
name: Test (PHP ${{ matrix.php }})
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
Expand All @@ -67,12 +67,15 @@ jobs:
clients: pip,python,composer,curl,git
- name: Disable APT download retries (CVE-2026-109518)
run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null
- uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
- name: Check out the accepted PHP container build source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
php-version: ${{ matrix.php }}
extensions: pcntl, posix
tools: composer:2.10.3
coverage: none
repository: durable-workflow/.github
ref: 18783be6bef737cc778e4c7282a8797e39daa61a
path: .central-apt-source
persist-credentials: false
- name: Build the pinned PHP runtime and install launchers
run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh" "${{ matrix.php }}"
- name: Verify actual Composer dependency trust
uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust
- run: composer install --prefer-dist --no-interaction
Expand All @@ -88,6 +91,7 @@ jobs:
node --check scripts/ci/verify-standalone-dependencies.js
node --check scripts/ci/rust-dependency-inventory.js
php -l scripts/ci/probe-standalone-runtime.php
php -l scripts/ci/probe-php-test-runtime.php
bash -n scripts/ci/verify-release-tag-source.sh
bash -n scripts/ci/establish-native-prerequisites.sh
bash -n scripts/ci/reconcile-release-metadata.sh
Expand All @@ -101,6 +105,7 @@ jobs:
python scripts/ci/test-release-rust-workflow.py
python scripts/ci/test-apt-no-retries.py
python scripts/ci/test-build-workflow.py
python -I -S -B scripts/ci/test-php-container-launcher.py
node --test scripts/ci/test-rust-dependency-inventory.js
python scripts/ci/test-native-prerequisites.py
node --test scripts/ci/test-release-workflow-metadata.js
Expand Down Expand Up @@ -180,6 +185,7 @@ jobs:
node --check scripts/ci/verify-standalone-dependencies.js
node --check scripts/ci/rust-dependency-inventory.js
php -l scripts/ci/probe-standalone-runtime.php
php -l scripts/ci/probe-php-test-runtime.php
bash -n scripts/ci/verify-release-tag-source.sh
bash -n scripts/ci/establish-native-prerequisites.sh
bash -n scripts/ci/reconcile-release-metadata.sh
Expand All @@ -193,6 +199,7 @@ jobs:
python scripts/ci/test-release-rust-workflow.py
python scripts/ci/test-apt-no-retries.py
python scripts/ci/test-build-workflow.py
python -I -S -B scripts/ci/test-php-container-launcher.py
node --test scripts/ci/test-rust-dependency-inventory.js
python scripts/ci/test-native-prerequisites.py
node --test scripts/ci/test-release-workflow-metadata.js
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@
/src/GeneratedBuildInfo.php
.claude/settings.local.json
**/.claude/settings.local.json

/.central-apt-source
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
25 changes: 25 additions & 0 deletions docker/php-ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# PHP test runtime

The no-database Dockerfile, Composer CA verifier and Brotli inputs are reused
byte-for-byte from SDK PHP source
[`4d37a42d`](https://github.com/durable-workflow/sdk-php/tree/4d37a42d1c95020fa2c75dcd0cc7585dd788f71c/docker/php-ci), itself adapted
from the accepted Sample App container pattern. Keep these common build inputs
aligned before starting another base build. CLI owns its minor selection,
runtime probe, restricted launcher environment and actual test results.
`cli.Dockerfile` extends that base with the pinned Node binary and procps,
which the existing release-contract and installer-shell fixtures require.

`scripts/ci/php-container-images.json` selects immutable official PHP images
for 8.2, 8.4 and 8.5. Composer is pinned to the reviewed 2.10.3 image index.
The build requires the APT helper from central source
`18783be6bef737cc778e4c7282a8797e39daa61a`; setup checks its commit and file
digest before building. The probe requires production INI, no coverage,
PHPUnit's modules and the process-control functions exercised by CLI tests.

The setup script runs with authenticated CA environment from the dependency
trust bootstrap and private runner state. It inspects the built image ID,
runs the actual Composer TLS control, then installs `php` and `composer`
launchers. Those launchers forward the selected CA files as read-only mounts
and keep unrelated host credentials outside the runtime. The caller's normal
Composer verifier still checks the actual container PHAR before dependency
requests and again before project scripts.
11 changes: 11 additions & 0 deletions docker/php-ci/cli.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ARG PHP_RUNTIME_IMAGE
FROM node@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS node

FROM ${PHP_RUNTIME_IMAGE}

COPY --from=node /usr/local/bin/node /usr/local/bin/node

RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \
&& apt-get update \
&& apt-get install -y --no-install-recommends procps \
&& rm -rf /var/lib/apt/lists/*
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;
}
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.2": "php:8.2.35-cli-bookworm@sha256:ce09ca1d4b532edf9b0347b44a1968a80d9175addb581c450ba0c9a43fa0c5ea",
"8.4": "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe",
"8.5": "php:8.5.12-cli-bookworm@sha256:050713ce008e2360ea9dc96e1f9a075690132388d917fc3552806f7a7be6f2a0"
}
Loading
Loading