diff --git a/.github/workflows/build-publish-dev-images.yml b/.github/workflows/build-publish-dev-images.yml index e805043..efaff3c 100644 --- a/.github/workflows/build-publish-dev-images.yml +++ b/.github/workflows/build-publish-dev-images.yml @@ -1,4 +1,4 @@ -name: Build and publish development images +name: Build and verify development images on: push: @@ -11,6 +11,14 @@ on: - 'src/**' - 'templates/**' - '.github/workflows/build-publish-dev-images.yml' + pull_request: + paths: + - 'composer.json' + - 'composer.lock' + - 'runtime/**' + - 'src/**' + - 'templates/**' + - '.github/workflows/build-publish-dev-images.yml' permissions: id-token: write @@ -21,7 +29,7 @@ env: jobs: publish: - name: Build and publish PHP ${{ matrix.php }} (${{ matrix.arch }}) + name: Build and verify PHP ${{ matrix.php }} (${{ matrix.arch }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -30,6 +38,12 @@ jobs: arch: [x86_64, arm64] steps: + - name: Require a trusted branch for image verification + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::Image verification requires a trusted branch in this repository; fork pull requests are not verified." + exit 1 + - name: Set up QEMU to run ARM images uses: docker/setup-qemu-action@v4 @@ -40,6 +54,7 @@ jobs: uses: actions/checkout@v5 - name: Login to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} @@ -48,17 +63,19 @@ jobs: - name: Determine Repository and Platform id: meta run: | - VERSION_CLEAN=$(echo ${{ matrix.php }} | sed 's/\.//g') - if [ "${{ matrix.arch }}" = "x86_64" ]; then - echo "repo=ymirapp/php-runtime-dev" >> $GITHUB_OUTPUT - echo "platform=linux/amd64" >> $GITHUB_OUTPUT - else - echo "repo=ymirapp/arm-php-runtime-dev" >> $GITHUB_OUTPUT - echo "platform=linux/arm64" >> $GITHUB_OUTPUT - fi - echo "tag=php-$VERSION_CLEAN" >> $GITHUB_OUTPUT - echo "compat_tag=php-${{ matrix.php }}" >> $GITHUB_OUTPUT - echo "dir=php-$VERSION_CLEAN" >> $GITHUB_OUTPUT + VERSION_CLEAN=$(echo '${{ matrix.php }}' | sed 's/\.//g') + { + if [ "${{ matrix.arch }}" = "x86_64" ]; then + echo "repo=ymirapp/php-runtime-dev" + echo "platform=linux/amd64" + else + echo "repo=ymirapp/arm-php-runtime-dev" + echo "platform=linux/arm64" + fi + echo "tag=php-$VERSION_CLEAN" + echo "compat_tag=php-${{ matrix.php }}" + echo "dir=php-$VERSION_CLEAN" + } >> "$GITHUB_OUTPUT" - name: Build and Load Image uses: depot/build-push-action@v1 @@ -68,6 +85,7 @@ jobs: file: runtime/${{ steps.meta.outputs.dir }}/Dockerfile platforms: ${{ steps.meta.outputs.platform }} load: true + push: false tags: | ${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.tag }} ${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.compat_tag }} @@ -79,6 +97,7 @@ jobs: run: ./runtime/test-image.sh ${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.tag }} ${{ steps.meta.outputs.platform }} - name: Build and Push Image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: depot/build-push-action@v1 with: project: ${{ secrets.DEPOT_PROJECT_ID }} @@ -94,6 +113,7 @@ jobs: DOCKER_PLATFORM=${{ steps.meta.outputs.platform }} - name: Verify Tag Aliases + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | CANONICAL_IMAGE="${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.tag }}" COMPAT_IMAGE="${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.compat_tag }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 776b626..82fd7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,23 @@ on: push: paths: - '.github/workflows/ci.yml' + - 'runtime/**' - 'src/**' - 'tests/**' - 'composer.json' - 'composer.lock' + pull_request: + paths: + - '.github/workflows/ci.yml' + - '.github/workflows/build-publish-dev-images.yml' + - 'runtime/**' + - 'src/**' + - 'tests/**' + - 'composer.json' + - 'composer.lock' + +permissions: + contents: read jobs: code-quality: @@ -23,11 +36,15 @@ jobs: - name: Install dependencies run: composer install --no-ansi --no-interaction --no-scripts --no-security-blocking --no-suggest --prefer-dist - name: Run PHP CS Fixer - run: composer php-cs-fixer + run: | + composer php-cs-fixer + composer php-cs-fixer -- --path-mode=override runtime/security - name: Run PHPStan run: composer phpstan - name: Run Rector - run: composer rector + run: | + composer rector + composer rector -- process runtime/security php-tests: name: Run tests on ${{ matrix.php-versions }} runs-on: ubuntu-latest diff --git a/runtime/php-72/Dockerfile b/runtime/php-72/Dockerfile index 96d779b..3a8f888 100644 --- a/runtime/php-72/Dockerfile +++ b/runtime/php-72/Dockerfile @@ -9,6 +9,24 @@ ARG CPU_ARCHITECTURE ARG DOCKER_PLATFORM +# Sury PHP 7.2 source package +# Vendor-maintained security backports. dpkg-source applies the complete vendor patch series. +# Sury's pool does not guarantee retention; if a pinned file disappears, review a pinned vendor replacement and never loosen the digest guards. +FROM --platform=${DOCKER_PLATFORM} debian:trixie-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad as php-source +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl dpkg-dev xz-utils +COPY < + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (!is_file('/.dockerenv')) { + throw new RuntimeException('Security fixtures must run inside a disposable test container'); +} + +$action = 'cli' === PHP_SAPI ? ($argv[1] ?? '') : ($_SERVER['YMIR_TEST_ACTION'] ?? ''); + +if (in_array($action, ['cleanup', 'oom'], true)) { + $marker = '/tmp/ymir-security/'.PHP_SAPI.'-'.$action; + register_shutdown_function(function () use ($marker): void { + file_put_contents($marker.'-shutdown', 'called'); + }); + $probe = new class() { + /** + * Path prefix for cleanup markers. + * + * @var string + */ + public $marker; + + public function __destruct() + { + file_put_contents($this->marker.'-destructor', 'called'); + } + }; + $probe->marker = $marker; + file_put_contents($marker.'-ready', (string) getmypid()); + + if ('oom' === $action) { + $allocation = str_repeat('x', 64 * 1024 * 1024); + + throw new RuntimeException('OOM allocation unexpectedly succeeded'); + } +} + +if ('cli' === PHP_SAPI) { + exit(0); +} + +$previousErrorHandler = set_error_handler(function (): bool { return false; }); +restore_error_handler(); +$previousExceptionHandler = set_exception_handler(function (Throwable $exception): void {}); +restore_exception_handler(); + +if ('state' === $action) { + set_error_handler(function (): bool { return true; }); + set_exception_handler(function (Throwable $exception): void {}); +} + +session_start(); +if ('seed' === $action) { + $_SESSION['sentinel'] = 'preserved'; +} + +$session = $_SESSION; +session_write_close(); + +header('Content-Type: application/json'); +echo json_encode([ + 'pid' => getmypid(), + 'post' => $_POST, + 'get' => $_GET, + 'files' => $_FILES, + 'upload' => isset($_FILES['document']['tmp_name']) && is_uploaded_file($_FILES['document']['tmp_name']) ? file_get_contents($_FILES['document']['tmp_name']) : null, + 'nested_upload' => isset($_FILES['uploads']['tmp_name']['document']) && is_uploaded_file($_FILES['uploads']['tmp_name']['document']) ? file_get_contents($_FILES['uploads']['tmp_name']['document']) : null, + 'session' => $session, + 'save_path' => ini_get('session.save_path'), + 'progress_enabled' => ini_get('session.upload_progress.enabled'), + 'progress_cleanup' => ini_get('session.upload_progress.cleanup'), + 'multipart_limit' => ini_get('max_multipart_body_parts'), + 'auth_user' => $_SERVER['PHP_AUTH_USER'] ?? null, + 'previous_error_handler' => null !== $previousErrorHandler, + 'previous_exception_handler' => null !== $previousExceptionHandler, +]); diff --git a/runtime/security/verify.php b/runtime/security/verify.php new file mode 100644 index 0000000..d6aa95c --- /dev/null +++ b/runtime/security/verify.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use hollodotme\FastCGI\Interfaces\ProvidesResponseData; +use hollodotme\FastCGI\RequestContents\MultipartFormData; +use hollodotme\FastCGI\Requests\PostRequest; +use Symfony\Component\Process\Process; +use Ymir\Runtime\Exception\PhpFpm\PhpFpmProcessException; +use Ymir\Runtime\FastCgi\FastCgiServerClient; +use Ymir\Runtime\FastCgi\PhpFpmProcess; +use Ymir\Runtime\Logger; + +// These are legacy mitigation/lifecycle regressions, not an exploit or completeness proof. +if (!is_file('/.dockerenv') || PHP_VERSION_ID < 70200 || PHP_VERSION_ID >= 80200) { + throw new RuntimeException('Run legacy security verification only in a disposable PHP 7.2–8.1 image'); +} + +require '/opt/vendor/autoload.php'; + +/** + * Assert even when production PHP disables native assertions. + */ +function check(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +/** + * Send a real request through the runtime's FastCGI/process wrappers. + */ +function request(PhpFpmProcess $fpm, string $action = '', array $fields = [], array $files = [], array $vars = []): ProvidesResponseData +{ + $request = PostRequest::newWithRequestContent(__DIR__.'/request.php', new MultipartFormData($fields, $files)); + $request->addCustomVars(array_merge([ + 'YMIR_TEST_ACTION' => $action, + 'HTTP_COOKIE' => 'PHPSESSID=ymir-security', + ], $vars)); + + return $fpm->handle($request, 5000); +} + +/** + * Decode a successful fixture response without accepting empty/crashed responses. + */ +function data(ProvidesResponseData $response): array +{ + check('' === $response->getHeaderLine('Status') || '200 OK' === $response->getHeaderLine('Status'), 'Fixture returned an HTTP error'); + $data = json_decode($response->getBody(), true); + check(is_array($data) && isset($data['pid']), 'Fixture did not return valid JSON'); + + return $data; +} + +/** + * Prove the fixture ran and whether PHP invoked its shutdown hooks. + */ +function cleanupMarkers(string $prefix, bool $expected): void +{ + check(is_file($prefix.'-ready'), 'Fixture never reached the allocation/cleanup probe: '.$prefix); + check($expected === is_file($prefix.'-shutdown'), 'Unexpected shutdown callback behavior: '.$prefix); + check($expected === is_file($prefix.'-destructor'), 'Unexpected destructor behavior: '.$prefix); +} + +mkdir('/tmp/ymir-security'); +file_put_contents('/tmp/ymir-security/upload.txt', "security upload\n"); +$config = file_get_contents('/opt/ymir/etc/php-fpm.d/php-fpm.conf').<<<'FPM' + +user = root +group = root +php_admin_value[memory_limit] = 32M +php_admin_flag[log_errors] = on +php_admin_flag[display_errors] = off +php_admin_flag[session.upload_progress.enabled] = on +php_admin_flag[session.upload_progress.cleanup] = off +php_admin_value[session.upload_progress.freq] = 1 +php_admin_value[session.upload_progress.min_freq] = 0 +FPM; +file_put_contents('/tmp/ymir-security/fpm.conf', $config); +$process = new Process(['php-fpm', '--nodaemonize', '--force-stderr', '--allow-to-run-as-root', '--fpm-config', '/tmp/ymir-security/fpm.conf', '-d', 'opcache.file_cache_only=0']); +$fpm = new PhpFpmProcess(FastCgiServerClient::createFromSocketPath('/tmp/.ymir/php-fpm.sock', 1000, 5000), new Logger(Logger::INFO), $process); +$failed = false; + +try { + $fpm->start(); + $initial = data(request($fpm, 'seed')); + $workerPid = $initial['pid']; + $masterPid = $process->getPid(); + + $tests = [ + 'native default session storage persists data' => function () use ($initial): void { + check('' === ini_get('session.save_path') && '' === $initial['save_path'], 'CLI/FPM default session.save_path changed from the system temp directory'); + $file = sys_get_temp_dir().'/sess_ymir-security'; + check(is_file($file) && str_contains((string) file_get_contents($file), 'preserved'), 'Session data was not persisted in the default save path'); + }, + 'normal and nested multipart uploads' => function () use ($fpm, $workerPid): void { + $result = data(request($fpm, '', ['title' => 'normal', 'nested[a][b]' => 'deep', 'nested[a][c]' => 'sibling', 'items[]' => 'item'], ['document' => '/tmp/ymir-security/upload.txt', 'uploads[document]' => '/tmp/ymir-security/upload.txt'])); + check(['title' => 'normal', 'nested' => ['a' => ['b' => 'deep', 'c' => 'sibling']], 'items' => ['item']] === $result['post'], 'Multipart fields/nesting changed'); + check("security upload\n" === $result['upload'] && $result['upload'] === $result['nested_upload'], 'Flat/nested uploaded bytes were not preserved'); + check(UPLOAD_ERR_OK === $result['files']['document']['error'] && UPLOAD_ERR_OK === $result['files']['uploads']['error']['document'], 'Upload returned an error'); + check('upload.txt' === $result['files']['document']['name'] && 16 === $result['files']['document']['size'], 'Upload filename/size changed'); + check($workerPid === $result['pid'], 'Normal request unexpectedly replaced the warm worker'); + }, + 'upload progress is hard-disabled with enabled=1 and cleanup=0' => function () use ($fpm): void { + $result = data(request($fpm, '', ['PHP_SESSION_UPLOAD_PROGRESS' => 'probe'], ['document' => '/tmp/ymir-security/upload.txt'])); + check('1' === $result['progress_enabled'] && in_array($result['progress_cleanup'], ['', '0'], true), 'Upload-progress test INI settings were not applied'); + check("security upload\n" === $result['upload'], 'Progress probe did not upload a file'); + check(['sentinel' => 'preserved'] === $result['session'], 'Upload progress wrote session state despite the hard-disable'); + }, + 'explicit and derived multipart body-part limits' => function () use ($fpm, $initial): void { + check('-1' === $initial['multipart_limit'], 'Default multipart limit is missing or changed'); + foreach ([4 => 'max_multipart_body_parts=4', 5 => "max_multipart_body_parts=-1\nmax_input_vars=3\nmax_file_uploads=2"] as $limit => $ini) { + $fields = ['one' => '1', 'two' => '2', 'three' => '3']; + $files = ['document' => '/tmp/ymir-security/upload.txt']; + if (5 === $limit) { + $files['second'] = '/tmp/ymir-security/upload.txt'; + } + + $response = request($fpm, '', $fields, $files, ['PHP_VALUE' => $ini]); + $result = data($response); + check($fields === $result['post'] && count($files) === count($result['files']), 'Multipart request at the limit was truncated'); + check('' === $response->getError(), 'Multipart request at the limit emitted a warning'); + check((4 === $limit ? '4' : '-1') === $result['multipart_limit'], 'Multipart limit INI entry is missing or ignored'); + $files['rejected'] = '/tmp/ymir-security/upload.txt'; + $response = request($fpm, '', $fields, $files, ['PHP_VALUE' => $ini]); + $result = data($response); + check(str_contains($response->getError(), 'Multipart body parts limit exceeded '.$limit), 'Missing multipart-limit rejection diagnostic'); + check(!isset($result['files']['rejected']) && count($files) - 1 === count($result['files']) && $fields === $result['post'], 'Multipart parser did not stop at the body-part limit'); + } + }, + 'request state resets on the same warm worker' => function () use ($fpm, $workerPid): void { + $state = data(request($fpm, 'state', ['old' => 'value'], [], ['PHP_AUTH_USER' => 'probe', 'PHP_AUTH_PW' => 'synthetic', 'QUERY_STRING' => 'old=value'])); + check('probe' === $state['auth_user'] && ['old' => 'value'] === $state['get'], 'State probe was not populated'); + $clean = data(request($fpm)); + check($workerPid === $state['pid'] && $workerPid === $clean['pid'], 'State test did not reuse the original worker'); + check([] === $clean['post'] && [] === $clean['get'] && [] === $clean['files'] && null === $clean['auth_user'], 'Request data/auth leaked to the next request'); + check(!$clean['previous_error_handler'] && !$clean['previous_exception_handler'], 'Request-owned handlers leaked to the next request'); + }, + 'FPM OOM bypasses cleanup, replaces child, and permits warm reuse' => function () use ($fpm, $process, $masterPid, $workerPid): void { + data(request($fpm, 'cleanup')); + cleanupMarkers('/tmp/ymir-security/fpm-fcgi-cleanup', true); + $crashed = false; + + try { + request($fpm, 'oom'); + } catch (PhpFpmProcessException $exception) { + $crashed = 'PHP-FPM process crashed unexpectedly' === $exception->getMessage(); + } + + check($crashed, 'FPM OOM did not propagate the expected runtime crash exception'); + cleanupMarkers('/tmp/ymir-security/fpm-fcgi-oom', false); + check($workerPid === (int) file_get_contents('/tmp/ymir-security/fpm-fcgi-oom-ready'), 'OOM did not run on the original warm worker'); + $next = data(request($fpm)); + $warm = data(request($fpm)); + check($next['pid'] !== $workerPid && $next['pid'] === $warm['pid'], 'FPM did not replace the child and reuse its replacement'); + check($masterPid === $process->getPid(), 'Recovery restarted the FPM master instead of replacing the child'); + check([] === $next['post'] && [] === $next['files'] && null === $next['auth_user'], 'Replacement worker inherited request state'); + check(['sentinel' => 'preserved'] === $next['session'], 'OOM recovery changed the persisted session'); + check(str_contains($process->getErrorOutput(), 'Allowed memory size'), 'FPM OOM diagnostic was lost'); + check(1 === preg_match('/child '.$workerPid.' exited with code 1/', $process->getErrorOutput()), 'FPM child did not hard-exit with code 1'); + }, + 'CLI OOM hard-exits without cleanup and ordinary failures stay nonzero' => function (): void { + // PHP 7.2 can reuse PHP_SAPI-specialized FPM bytecode from the shared file cache. + $control = new Process([PHP_BINARY, '-d', 'opcache.enable_cli=0', '-d', 'memory_limit=32M', __DIR__.'/request.php', 'cleanup']); + $control->setTimeout(10); + check(0 === $control->run(), 'CLI cleanup control failed'); + cleanupMarkers('/tmp/ymir-security/cli-cleanup', true); + $oom = new Process([PHP_BINARY, '-d', 'opcache.enable_cli=0', '-d', 'memory_limit=32M', __DIR__.'/request.php', 'oom']); + $oom->setTimeout(10); + $oom->run(); + cleanupMarkers('/tmp/ymir-security/cli-oom', false); + check(1 === $oom->getExitCode(), 'CLI OOM did not hard-exit with code 1'); + check(str_contains($oom->getErrorOutput(), 'Allowed memory size'), 'CLI OOM diagnostic was lost'); + $failure = new Process([PHP_BINARY, '-r', 'exit(7);']); + $failure->setTimeout(10); + check(7 === $failure->run(), 'Ordinary CLI failure exit code changed'); + }, + ]; + + foreach ($tests as $name => $test) { + try { + $test(); + echo ' [OK] '.$name."\n"; + } catch (Throwable $exception) { + $failed = true; + echo ' [FAIL] '.$name.': '.$exception->getMessage()."\n"; + } + } +} finally { + $fpm->stop(); +} + +exit($failed ? 1 : 0); diff --git a/runtime/shared/sury-defaults.ini b/runtime/shared/sury-defaults.ini new file mode 100644 index 0000000..ebdc290 --- /dev/null +++ b/runtime/shared/sury-defaults.ini @@ -0,0 +1,5 @@ +; The Sury source packages compile Debian defaults into PHP 7.2 and 7.3. +; Restore the upstream defaults that the previous runtimes shipped with. +; An empty session.save_path makes file sessions use the system temporary directory (/tmp on Lambda). +include_path = ".:/opt/ymir/lib/php" +session.save_path = "" diff --git a/runtime/sources/README.md b/runtime/sources/README.md new file mode 100644 index 0000000..a2d089b --- /dev/null +++ b/runtime/sources/README.md @@ -0,0 +1,119 @@ +# Vendor-maintained legacy PHP sources + +Ymir consumes complete upstream/vendor source inputs for CVE-2026-109618 +and CVE-2026-109619. **Do not maintain a Ymir patch set, cherry-pick a subset +of distro patches, or modify vendor C/header contents to make a build pass.** +If a vendor source cannot meet the Lambda contracts through normal build +and runtime configuration, stop for a platform/support decision. + +## PHP-team source snapshots: 7.4, 8.0, 8.1 + +The [PHP security announcement](https://www.php.net/archive/2026.php) links +these exceptional source-backport branches. Dockerfiles consume full-commit +archives and verify their pinned SHA-256 before extraction: + +| PHP family | Official source commit | Compiled version | +| --- | --- | --- | +| 7.4 | [`712e60f9edfd03f0960fc4fc3c2c375f42b90aa2`](https://github.com/php/php-src/commit/712e60f9edfd03f0960fc4fc3c2c375f42b90aa2) | `7.4.34-dev` | +| 8.0 | [`82f8f31b16a035c1b67cc253e79f7cc288f4cc20`](https://github.com/php/php-src/commit/82f8f31b16a035c1b67cc253e79f7cc288f4cc20) | `8.0.30` | +| 8.1 | [`1d7dec178a094e6e75b229ce22754045f8c6070a`](https://github.com/php/php-src/commit/1d7dec178a094e6e75b229ce22754045f8c6070a) | `8.1.34` | + +Archive URL: `https://github.com/php/php-src/archive/.tar.gz`. +These are complete vendor trees, not release tarballs with locally applied +patches. Their missing parsers/lexers are generated by the upstream build +using build-only Bison/re2c. Preserve the vendor's real version metadata; +7.4's development banner must not be relabeled as 7.4.33. + +During preparation, each downloaded snapshot was compared with `git archive` +of the exact commit, and all three commit signatures verified against +Ilija Tovilo's key, fingerprint `259299AD4C7E1F40DE1302FF115CEA7A713E12E9`. +The key came from GitHub HTTPS, not an independent web-of-trust check. +The archive checksums enforced by Docker are observed pins, not signed +php.net release checksums. GitHub may regenerate compression bytes; a +checksum mismatch must fail closed and be investigated, never auto-accepted. + +## PEAR bootstrap for the Git snapshots + +The PHP Git snapshots omit the release-bundled PEAR installer. For 7.4, +8.0, and 8.1, the build fetches the unchanged upstream +[`install-pear-nozlib.phar`](https://raw.githubusercontent.com/pear/pearweb_phars/72aa5f771f56aaf826641b21159453f993e44450/install-pear-nozlib.phar) +from PEAR commit `72aa5f771f56aaf826641b21159453f993e44450`, with SHA-256 +`a71349da29f1a6e3f0792695b167e582375a93c70c0e9d35e7c4675697c5f039`. +The checksum must pass before the file is placed in the vendor installer's +expected path or executed by normal `make install`. No floating `master` +bootstrap or PHAR copied from an unrelated PHP release is used. + +## Complete Sury source packages: 7.2, 7.3 + +The [Sury PHP repository](https://packages.sury.org/php/) publishes Debian +`3.0 (quilt)` source packages. Its `orig` archives and packaging Git +checkouts are **not already-backported trees**. A build-only Debian stage +uses the format's native `dpkg-source -x` preparation to apply the vendor's +**complete** ordered series, unchanged: + +| PHP family | Pinned package revision | Vendor packaging commit | Series | +| --- | --- | --- | ---: | +| 7.2 | `7.2.34-66+0~20261021.122+debian13~1.gbpe0bcd4` | [`e0bcd488d9f8da024d9e1a254113539a846cac6d`](https://salsa.debian.org/php-team/php/-/tree/e0bcd488d9f8da024d9e1a254113539a846cac6d) | 159 | +| 7.3 | `7.3.33-35+0~20261021.144+debian13~1.gbpecf76f` | [`ecf76f6726bee03657a2c608240685732312fe54`](https://salsa.debian.org/php-team/php/-/tree/ecf76f6726bee03657a2c608240685732312fe54) | 139 | + +The Dockerfiles pin and verify the complete source file set, including the +`.dsc`, original archive, Debian archive, and the 7.3 upstream signature +file. They do not select patches or substitute pristine-source files. +Generated build preparation follows the vendor's own recipes. Compilation +and final Lambda images remain Amazon Linux 2023; Debian supplies source +preparation tools, not customer runtime binaries. + +The package inputs were authenticated on 2026-10-22 through: + +1. Sury's `InRelease`, signed by key + `15058500A0235D97F5D10063B188E2B695BD4743`. +2. Its SHA-256 entry for `main/source/Sources.xz`. +3. The source index's hashes of each package descriptor and archive. + +The historical key, signed release, and source index are retained under +`provenance/`. Both `.dsc` files are unsigned; do not claim descriptor +signature verification. The trust anchor came from Sury HTTPS, not an +independent identity check. Retained metadata proves pinned bytes, not +current repository freshness or a vendor support guarantee. + +```sh +gpgv --keyring "$PWD/runtime/sources/provenance/sury-keyring.gpg" \ + runtime/sources/provenance/sury-trixie-InRelease +``` + +Sury's complete source includes Debian defaults for sessions, include/PEAR +paths, and extension/header layout. Preserve Ymir's existing Lambda behavior +with supported build/make settings and runtime INI configuration, not by +removing distro patches. The build stage supplies the vendor's own `shtool` +and maps its aclocal/libtool helper paths to native AL2023 files. The pinned +vendor `phpize` probes missing Debian `dpkg` tools only to choose an aux +pathname; its fallback is mapped to AL2023's actual libtool build-aux +location. The missing-tool diagnostics are not suppressed, no commands are +stubbed, and no vendor script is edited. If future sources require other +`dpkg` behavior, reevaluate the integration rather than faking package data. +None of these build-only helper files enters the final layer. + +Optional Sury telemetry is not enabled by the current build flags: do not +define `TELEMETRY_HOST` or import distro binary build flags without a +separate privacy/startup review. + +## Verification and release boundary + +`runtime/test-image.sh ` checks the actual final image, +including bounded legacy mitigation/lifecycle regressions. Both amd64 and +arm64 must pass; source compilation or unchanged API identifiers alone do +not establish extension or Lambda compatibility. These checks do not prove +comprehensive exploit prevention or coverage of every historical CVE. + +The legacy mitigations terminate PHP immediately on OOM (exit 1, bypassing +shutdown callbacks/destructors), disable session upload-progress tracking, +and change some multipart handling. Do not introduce automatic application +retries that could repeat side effects. These remain EOL PHP versions; +backport availability is not resumed upstream support or an SLA. + +This work does not authorize merging, releasing, publishing images/layers, +or updating the platform's layer catalog. A later approved rollout needs +zip redeployment or a rebuilt/redeployed container base (including pinned +digests). Existing deployments are not patched retroactively. Preserve +recovery artifacts, but recognize that reverting to unpatched sources or +artifacts restores exposure; prefer a forward fix. diff --git a/runtime/sources/provenance/sury-keyring.gpg b/runtime/sources/provenance/sury-keyring.gpg new file mode 100644 index 0000000..010fd72 Binary files /dev/null and b/runtime/sources/provenance/sury-keyring.gpg differ diff --git a/runtime/sources/provenance/sury-trixie-InRelease b/runtime/sources/provenance/sury-trixie-InRelease new file mode 100644 index 0000000..37abc18 --- /dev/null +++ b/runtime/sources/provenance/sury-trixie-InRelease @@ -0,0 +1,93 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Origin: deb.sury.org +Suite: trixie +Codename: trixie +Date: Thu, 22 Oct 2026 12:01:18 UTC +Architectures: amd64 arm64 armhf +Components: main +Signed-By: 15058500A0235D97F5D10063B188E2B695BD4743 +MD5Sum: + b9103887fe74126b7d09641bdf48f900 2279894 main/binary-amd64/Packages + b7b9df7a3641a5776c196c0ca9911b07 515468 main/binary-amd64/Packages.gz + 922978904e473060f01d8944b32b31af 286632 main/binary-amd64/Packages.xz + 9d4004f80593c5a9eb2bdaa8716bd1ad 73 main/binary-amd64/Release + 7f42da00196de0153deadda285cc93b4 2259693 main/binary-arm64/Packages + 0a1652cf7ade9f42cdfb10ec4c334e66 508186 main/binary-arm64/Packages.gz + 5a708f17269e15f12fa84044cddb012b 283992 main/binary-arm64/Packages.xz + f92f251832da0cc176a7aa39270e58cf 73 main/binary-arm64/Release + 773b8020f984dafacdd2db0fc6cb8f44 2237917 main/binary-armhf/Packages + d60ceba45b51e02cd1d74ae85dce0b41 501681 main/binary-armhf/Packages.gz + b2b4b0768f7b52d75d45c4d9a5f86cc0 281664 main/binary-armhf/Packages.xz + 2ca1c27b6ce83d311aa5f3aacfb53c6d 73 main/binary-armhf/Release + ff30f930633c62bd898df58455e0d75c 320520 main/source/Sources + 1a925f4a390604fec3e51ac8a7ccdac4 62748 main/source/Sources.gz + 85e2972ef81917b8231ccdf2fca562ef 51348 main/source/Sources.xz + 07a819b8f5a91d997831ce45ad138e01 74 main/source/Release + be832b6275599af7d06b76faf59484b6 1078641 main/Contents-amd64 + 5d4e30850f9d5af4684f4d285136e70f 130374 main/Contents-amd64.gz + ab8d789476974dd55f1827a131e7a06e 1073294 main/Contents-arm64 + 41d8fc004cf5001f1fedbe214d56c3f8 129613 main/Contents-arm64.gz + 24304ad333b2a1e555b8968c79b0b8db 993271 main/Contents-armhf + 799de7ba3c5cb673cf16c5a079839967 118343 main/Contents-armhf.gz +SHA1: + ec5d4639ad64dea1e36579b95d62017d36790b18 2279894 main/binary-amd64/Packages + 9e5283f838ebce6e25737004bb5b3a0d5bde22e4 515468 main/binary-amd64/Packages.gz + 7c84b4cf36a516fb573c276bc5ee93fef7eef91e 286632 main/binary-amd64/Packages.xz + 9f7266eea2bf59a92a05d717fccdb570b78aaae8 73 main/binary-amd64/Release + 97369b68665cee589b5cbdcf0e760cd8f1a4dab5 2259693 main/binary-arm64/Packages + caa635bc1f26c43367b30436f6dbfcb61ded19fc 508186 main/binary-arm64/Packages.gz + bdedd9f753235447c8effb557d8faf1f94da2b2a 283992 main/binary-arm64/Packages.xz + e547659ee8f835036bcf088e7402df4c52291084 73 main/binary-arm64/Release + ed6c5eb9c6d6015ab6083ad4280e0e945ea450ff 2237917 main/binary-armhf/Packages + 1a70123c079f2695bf968ea9548b5cf6ba135e6f 501681 main/binary-armhf/Packages.gz + bc3904bcf94d08c38d370dc083729af9ffcb2bce 281664 main/binary-armhf/Packages.xz + e8a8e19aa76d7ec0bbcafa09a85088bd9fea331d 73 main/binary-armhf/Release + 4c233bbbb037a2562368e3e11e80e268ef278d6e 320520 main/source/Sources + 70931280c8babfe7ae92ef05200088916a7de4c8 62748 main/source/Sources.gz + cc39d451b9e17dca5b8c6c3ab6f57a62e3aeb648 51348 main/source/Sources.xz + 91351af59050113beeda8514206a8adb127e7624 74 main/source/Release + 4aaeec5efac9e0cac5ba2973f03d99f27c820a74 1078641 main/Contents-amd64 + f29681e41da2f9ba95e2f7802a745f7e95d4ae28 130374 main/Contents-amd64.gz + ae5eaabd7f9d03b350556f06d31201ef93308009 1073294 main/Contents-arm64 + 3616578856daf1bee181fd469251652f4f5a9d2a 129613 main/Contents-arm64.gz + 705dc74566b2800a1dc0c2410dedc4979775f0d7 993271 main/Contents-armhf + 9ed20a051f6192d062baf5b1089adcc7e27e0120 118343 main/Contents-armhf.gz +SHA256: + 69d6fb348a0ab21dd08444de4576b609b58d501eb750ad63f7c12c0288ac11d6 2279894 main/binary-amd64/Packages + 4de25eff258483a865fdb7621ca1e707c50ac4c330ab21ac041fd6d8638df027 515468 main/binary-amd64/Packages.gz + 2bad1b0406d03842697027936c0118aee624b5bd186eb4155cf018050b25a175 286632 main/binary-amd64/Packages.xz + d7d0ddcb2c705123de542d7e90c1c308de2701a3da17f44114383d4f78b36a19 73 main/binary-amd64/Release + 583dfbec1bc7689e3db788e298626f0795b1e72b4c04391df010da6e9de401f8 2259693 main/binary-arm64/Packages + 83d8bfa2078ad7e78266b21ba64978e1ff385dbd59105e3136d6b6250ce84106 508186 main/binary-arm64/Packages.gz + 8295158db9a2d8fab1036f897bf1d5109308442640b517523083099355ede945 283992 main/binary-arm64/Packages.xz + 0a684424a27719d6464154daf35515f4bd8ee54d46bea5766094a0934076f520 73 main/binary-arm64/Release + 4ea6d04effaaf83453995b4e4429cd6014d2cbd33c2f80fad747684132a49680 2237917 main/binary-armhf/Packages + b4735f51a728f6862bd657795ef6a9341f4eaeb46c8f7a47e8512e44df0c841a 501681 main/binary-armhf/Packages.gz + bb17d5ec52d13d16db9e5c634072d2d535c8d01e96a9214a4812778686e1a0ea 281664 main/binary-armhf/Packages.xz + 042250b7af51173809a63236cf3cf64aa36c76e3ebb71ca1554c3c9b1730592a 73 main/binary-armhf/Release + d90452068e3754eadcc38389867e0fb63b8ec3dacadb46fb71bf91691b0123da 320520 main/source/Sources + 151f9710fcd33ed0bdbd021c1dbc2608e169a2220812edfc795ba2107673b97c 62748 main/source/Sources.gz + bce36b31bf6b87c32bc8a8f28112502f78e26bf4f2c247b5a4a2d07e20d34c09 51348 main/source/Sources.xz + 5434aa1c901732b61185d9e7e640c03e38c5f9a2f566798c68063aa30b101a38 74 main/source/Release + f1b488695fdb46f3d548f5b3259908c0e698be8749244529112a696071696f52 1078641 main/Contents-amd64 + 7b572d1c5b6aaf5b67835fca713ab2973a0a7b40d147e26b21b675d216b014c4 130374 main/Contents-amd64.gz + a22569ef3f9dc9768559214b06173abc8b78ba423067f9a31a4f54e4c52d5561 1073294 main/Contents-arm64 + efc9c42392f0af8faeff12eb1beff22849e3cd29eac634ed8218447338166a5d 129613 main/Contents-arm64.gz + 29171dd4f10215cc9caf4e09786712a2a0af1123cfbee0b39ab743b822de5727 993271 main/Contents-armhf + f3c41b063462a00e45efc4396fc9142e62e3d4fdf8e064e712cda04c9e5bd334 118343 main/Contents-armhf.gz +-----BEGIN PGP SIGNATURE----- + +iQGzBAEBCgAdFiEEFQWFAKAjXZf10QBjsYjitpW9R0MFAmrZ+w4ACgkQsYjitpW9 +R0P80wv/T0B+3n4P+s4R9sYiJvPEAtVfB22myh/GzLEh4HS8WDt+pe/i16RZuzwV +GbKNCRpmf14imO0SAxSKBR/xc7qfr2k5gjKNwHH/FaO4jIV1Y2c2J9ZYrJr7RWzc +ED1P/AdhYWn79gRwFXC0dBiKSr9keP13m6ktop/fKB3mVE8ZhgkjG4+LNPxkmmgx +agG/OzTQQMiVLhgi/+uSdjNKJ9LTgO8+OTuRsS2fFAcMvD5EMeL6ucEjfMAPBFOM +Gofkc3x3Ah9/W9QUG0x6Ul0eXJl7q9KGze/ncJeSIENyfyesmC0OpLCmY5A99Nds +mmnBEo2nbuGU71lFDRoe9IBW78ERMcWypBBpam59VVxZBZUZcr/stPflFSnYM19A +93YFim7UV+IwaRbIj+H2MDVjt1I9jKHMDliMklhDPhlurUnn1BUgipGoAtH6kBZw +HqbhWVAQWHYO6OqMQsUFGRPGg8I/tMtbydElGlGPA8Rv8qXP/ZmUZxf1+GIqJes9 +ya+6ogIE +=AOSj +-----END PGP SIGNATURE----- diff --git a/runtime/sources/provenance/sury-trixie-Sources.xz b/runtime/sources/provenance/sury-trixie-Sources.xz new file mode 100644 index 0000000..2ba88c0 Binary files /dev/null and b/runtime/sources/provenance/sury-trixie-Sources.xz differ diff --git a/runtime/test-image.sh b/runtime/test-image.sh index dd317b9..ba91f5e 100755 --- a/runtime/test-image.sh +++ b/runtime/test-image.sh @@ -10,9 +10,20 @@ if [ -z "$PLATFORM" ]; then PLATFORM="linux/amd64"; fi echo "Testing $IMAGE on $PLATFORM..." FAILED=0 +CID="" +SECURITY_CID="" +cleanup() { + if [ -n "$CID" ]; then docker rm -f "$CID" > /dev/null 2>&1 || true; fi + if [ -n "$SECURITY_CID" ]; then docker rm -f "$SECURITY_CID" > /dev/null 2>&1 || true; fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # 1. Size -OPT_SIZE_MB=$(( $(docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "du -sk /opt | cut -f1") / 1024 )) +OPT_SIZE_KB=$(docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "du -sk /opt | cut -f1") || exit 1 +if ! [[ "$OPT_SIZE_KB" =~ ^[0-9]+$ ]]; then echo " [FAIL] Could not determine image size"; exit 1; fi +OPT_SIZE_MB=$(( OPT_SIZE_KB / 1024 )) if [ "$OPT_SIZE_MB" -gt 250 ]; then echo " [FAIL] Size: ${OPT_SIZE_MB}MB (exceeds 250MB Lambda limit)" @@ -26,7 +37,7 @@ fi # 2. PHP binary & Version echo "--------------------------------------------------------------------------------" -docker run --rm --platform "$PLATFORM" --entrypoint /opt/ymir/bin/php "$IMAGE" -v +docker run --rm --platform "$PLATFORM" --entrypoint /opt/ymir/bin/php "$IMAGE" -v || FAILED=1 echo "--------------------------------------------------------------------------------" # 3. Filesystem layout @@ -73,7 +84,8 @@ if [ -n "$STARTUP_ERRORS" ]; then FAILED=1 fi -PHP_VER=$(docker run --rm --platform "$PLATFORM" -e LD_LIBRARY_PATH="$LAMBDA_LD_PATH" --entrypoint /opt/ymir/bin/php "$IMAGE" -r "echo PHP_VERSION_ID;" 2>/dev/null) +PHP_VER=$(docker run --rm --platform "$PLATFORM" -e LD_LIBRARY_PATH="$LAMBDA_LD_PATH" --entrypoint /opt/ymir/bin/php "$IMAGE" -r "echo PHP_VERSION_ID;" 2>/dev/null) || exit 1 +if ! [[ "$PHP_VER" =~ ^[0-9]+$ ]]; then echo " [FAIL] Could not determine PHP version"; exit 1; fi REQUIRED=("apcu" "igbinary" "zstd" "imagick" "intl" "pdo_mysql" "pdo_pgsql" "pgsql") if [ -n "$PHP_VER" ] && [ "$PHP_VER" -ge 70400 ]; then @@ -99,9 +111,17 @@ if [ -z "$EXT_DIR" ]; then echo " [FAIL] Could not determine extension_dir" FAILED=1 else + if ! [[ "$EXT_DIR" =~ ^/opt/ymir/lib/php/extensions/no-debug-non-zts-[0-9]{8}$ ]]; then + echo " [FAIL] extension_dir changed: $EXT_DIR" + FAILED=1 + fi if docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "[ -d $EXT_DIR ]"; then echo " [OK] Extension directory exists: $EXT_DIR" - for ext in "pdo_mysql.so" "pdo_pgsql.so" "pgsql.so"; do + INI_CONTENT=$(docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "cat /opt/ymir/etc/php/php.ini /opt/ymir/etc/php/conf.d/*.ini 2>/dev/null") || exit 1 + # shortcut: shipped INIs use relative .so names; normalize paths if that format changes. + INI_EXTENSIONS=$(printf '%s\n' "$INI_CONTENT" | sed -nE 's/^[[:space:]]*(zend_)?extension[[:space:]]*=[[:space:]]*"?([^";[:space:]]+)"?.*/\2/p') + if [ -z "$INI_EXTENSIONS" ]; then echo " [FAIL] No extension entries found in shipped INI files"; exit 1; fi + for ext in "pdo_mysql.so" "pdo_pgsql.so" "pgsql.so" $INI_EXTENSIONS; do if docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "[ -f $EXT_DIR/$ext ]"; then echo " [OK] Extension file exists: $ext" else @@ -122,6 +142,15 @@ else fi fi +# 5.6 Default paths (empty session.save_path means the system temp directory) +DEFAULT_PATHS=$(docker run --rm --platform "$PLATFORM" -e LD_LIBRARY_PATH="$LAMBDA_LD_PATH" --entrypoint /opt/ymir/bin/php "$IMAGE" -r "echo ini_get('session.save_path'), '|', ini_get('include_path');" 2>/dev/null) +if [ "$DEFAULT_PATHS" = "|.:/opt/ymir/lib/php" ]; then + echo " [OK] Default session.save_path and include_path" +else + echo " [FAIL] Default session.save_path|include_path changed: '$DEFAULT_PATHS'" + FAILED=1 +fi + # 6. Relay Configuration if [ -n "$PHP_VER" ] && [ "$PHP_VER" -ge 70400 ]; then EVICTION_POLICY=$(docker run --rm --platform "$PLATFORM" -e LD_LIBRARY_PATH="$LAMBDA_LD_PATH" --entrypoint /opt/ymir/bin/php "$IMAGE" -r "echo ini_get('relay.eviction_policy');" 2>/dev/null) @@ -171,23 +200,41 @@ FPM_BIN="/opt/ymir/bin/php-fpm" FPM_CONF="/opt/ymir/etc/php-fpm.d/php-fpm.conf" if docker run --rm --platform "$PLATFORM" --entrypoint $FPM_BIN "$IMAGE" -t --fpm-config $FPM_CONF --allow-to-run-as-root; then - CID=$(docker run -d --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "mkdir -p /tmp/.ymir && exec $FPM_BIN -F --fpm-config $FPM_CONF --allow-to-run-as-root") + CID=$(docker run -d --platform "$PLATFORM" --entrypoint /bin/sh "$IMAGE" -c "mkdir -p /tmp/.ymir && exec $FPM_BIN -F --fpm-config $FPM_CONF --allow-to-run-as-root") || exit 1 sleep 2 - if docker ps -q --no-trunc | grep -q "$CID"; then + if [ "$(docker inspect --format '{{.State.Running}}' "$CID")" = "true" ]; then echo " [OK] PHP-FPM started" - docker stop "$CID" > /dev/null - docker rm "$CID" > /dev/null + docker stop "$CID" > /dev/null || exit 1 + docker rm "$CID" > /dev/null || exit 1 else echo " [FAIL] PHP-FPM failed to start" docker logs "$CID" - docker rm "$CID" > /dev/null + docker rm "$CID" > /dev/null || exit 1 FAILED=1 fi + CID="" else echo " [FAIL] PHP-FPM configuration test failed" FAILED=1 fi +# 10. Focused EOL mitigation regressions (supported branches have different OOM semantics). +if [ "$PHP_VER" -ge 70200 ] && [ "$PHP_VER" -lt 80200 ]; then + SECURITY_DIR="$(cd "$(dirname "$0")/security" && pwd)" || exit 1 + SECURITY_CID=$(docker create --platform "$PLATFORM" --network none --memory 256m --pids-limit 64 \ + --mount "type=bind,source=$SECURITY_DIR,target=/security,readonly" \ + -e LD_LIBRARY_PATH="$LAMBDA_LD_PATH" --entrypoint /usr/bin/timeout "$IMAGE" \ + 90 /opt/ymir/bin/php /security/verify.php) || exit 1 + docker start --attach "$SECURITY_CID" || exit 1 + SECURITY_STATUS=$(docker wait "$SECURITY_CID") || exit 1 + if [ "$SECURITY_STATUS" != "0" ]; then + echo " [FAIL] Legacy security regressions (container exit $SECURITY_STATUS)" + FAILED=1 + fi + docker rm "$SECURITY_CID" > /dev/null || exit 1 + SECURITY_CID="" +fi + if [ $FAILED -eq 1 ]; then echo "Verification FAILED!" exit 1