diff --git a/.devcontainer/docker/Dockerfile b/.devcontainer/docker/Dockerfile index 4a7c4b1..6760ffb 100644 --- a/.devcontainer/docker/Dockerfile +++ b/.devcontainer/docker/Dockerfile @@ -2,6 +2,14 @@ FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10 FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer +FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 AS brotli-build + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY .devcontainer/docker/verify-php-trust.php /usr/local/share/sample-app/verify-php-trust.php +COPY docker/brotli /usr/src/brotli-inputs +RUN php /usr/local/share/sample-app/verify-php-trust.php /usr/local/bin/composer \ + && sh /usr/src/brotli-inputs/build.sh + FROM docker:27.5.1-cli@sha256:851f91d241214e7c6db86513b270d58776379aacc5eb9c4a87e5b47115e3065c AS docker-cli FROM rust:1.86.0-slim-bookworm@sha256:57d415bbd61ce11e2d5f73de068103c7bd9f3188dc132c97cef4a8f62989e944 AS rust @@ -86,6 +94,12 @@ RUN set -eux; \ FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 +COPY --from=brotli-build /out /usr/local/share/sample-app/brotli +RUN dpkg -i /usr/local/share/sample-app/brotli/libbrotli1_*.deb \ + /usr/local/share/sample-app/brotli/libbrotli-dev_*.deb \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php \ + && rm /usr/local/share/sample-app/brotli/*.deb + ARG VCS_REF=unknown ARG DURABLE_WORKFLOW_CLI_VERSION @@ -205,6 +219,7 @@ RUN set -eux; \ fi; \ done; \ apt-get install -y --no-install-recommends build-essential pkg-config; \ + php /usr/local/share/sample-app/brotli/verify-runtime.php; \ groupadd --gid 1000 laravel; \ useradd --create-home --gid laravel --shell /bin/bash --uid 1000 laravel; \ passwd --delete laravel; \ @@ -368,7 +383,8 @@ RUN chmod +x /usr/local/bin/seed-mysql-volume /usr/local/bin/start-container \ /usr/local/bin/wait-for-devcontainer-identity \ && DEVCONTAINER_DEPENDENCY_SCOPE=baked \ DEVCONTAINER_SSH_HOST_KEY_STATE=absent \ - gosu laravel verify-devcontainer-image + gosu laravel verify-devcontainer-image \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php EXPOSE 22 80 5173 diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 11f3990..e0cfeb4 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -46,6 +46,7 @@ jobs: docker compose exec -T app node docker/verify-node-trust.mjs docker compose exec -T app node --test tests/Node/ProductionNodeTrustTest.mjs docker compose exec -T app php /usr/local/share/sample-app/verify-runtime-trust.php /usr/bin/composer + docker compose exec -T app php /usr/local/share/sample-app/brotli/verify-runtime.php docker compose exec -T app node docker/playwright-smoke.js - name: Run embedded workflow samples run: scripts/compose-smoke.sh diff --git a/Dockerfile b/Dockerfile index 2685d26..840a85f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,21 @@ FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer +FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 AS brotli-build + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY docker/verify-runtime-trust.php /usr/local/share/sample-app/verify-runtime-trust.php +COPY docker/brotli /usr/src/brotli-inputs +RUN php /usr/local/share/sample-app/verify-runtime-trust.php /usr/local/bin/composer \ + && sh /usr/src/brotli-inputs/build.sh + FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 AS base +COPY --from=brotli-build /out /usr/local/share/sample-app/brotli +RUN dpkg -i /usr/local/share/sample-app/brotli/libbrotli1_*.deb \ + /usr/local/share/sample-app/brotli/libbrotli-dev_*.deb \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php \ + && rm /usr/local/share/sample-app/brotli/*.deb + COPY --from=composer /usr/bin/composer /usr/bin/composer COPY docker/install-phpredis.sh /usr/local/bin/install-phpredis COPY docker/verify-runtime-trust.php docker/verify-node-trust.mjs /usr/local/share/sample-app/ @@ -11,6 +25,7 @@ RUN php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v compo curl ffmpeg git libnspr4 libnss3 libpq-dev libzip-dev unzip \ && docker-php-ext-install pdo pdo_mysql pcntl zip bcmath \ && sh /usr/local/bin/install-phpredis \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php \ && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ && rm -rf /var/lib/apt/lists/* @@ -67,6 +82,7 @@ RUN node /usr/local/share/sample-app/verify-node-trust.mjs \ && npx playwright install --with-deps chromium \ && node docker/playwright-smoke.js \ && node /usr/local/share/sample-app/verify-node-trust.mjs \ + && php /usr/local/share/sample-app/brotli/verify-runtime.php \ && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ && (cp .env.example .env 2>/dev/null || echo "APP_KEY=" > .env) \ && php artisan key:generate --force \ diff --git a/docker/brotli/CVE-2026-111497.patch b/docker/brotli/CVE-2026-111497.patch new file mode 100644 index 0000000..3df59b8 --- /dev/null +++ b/docker/brotli/CVE-2026-111497.patch @@ -0,0 +1,233 @@ +From 50a9b70a4d5e1443fc40927952276b68fffa83a0 Mon Sep 17 00:00:00 2001 +From: Evgenii Kliuchnikov +Date: Wed, 21 Oct 2026 11:25:30 +0000 +Subject: [PATCH] Detect and report invalid stream earlier. + +PiperOrigin-RevId: 876198329 +(cherry picked from commit 0ed4268a4401abcb4472096340477cf4af7d1a2b +--- + c/dec/decode.c | 87 +++++++++++++++++++++++++-------------- + c/include/brotli/decode.h | 3 +- + 2 files changed, 59 insertions(+), 31 deletions(-) + +diff --git a/c/dec/decode.c b/c/dec/decode.c +index ae5a3d3fa..b23d9fb22 100644 +--- a/c/dec/decode.c ++++ b/c/dec/decode.c +@@ -1117,7 +1117,7 @@ static BrotliDecoderErrorCode DecodeContextMap(uint32_t context_map_size, + + /* Decodes a command or literal and updates block type ring-buffer. + Reads 3..54 bits. */ +-static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( ++static BROTLI_INLINE BrotliDecoderErrorCode DecodeBlockTypeAndLength( + int safe, BrotliDecoderState* s, int tree_type) { + uint32_t max_block_type = s->num_block_types[tree_type]; + const HuffmanCode* type_tree = &s->block_type_trees[ +@@ -1128,7 +1128,7 @@ static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( + uint32_t* ringbuffer = &s->block_type_rb[tree_type * 2]; + uint32_t block_type; + if (max_block_type <= 1) { +- return BROTLI_FALSE; ++ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH; + } + + /* Read 0..15 + 3..39 bits. */ +@@ -1138,11 +1138,13 @@ static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( + } else { + BrotliBitReaderState memento; + BrotliBitReaderSaveState(br, &memento); +- if (!SafeReadSymbol(type_tree, br, &block_type)) return BROTLI_FALSE; ++ if (!SafeReadSymbol(type_tree, br, &block_type)) { ++ return BROTLI_DECODER_NEEDS_MORE_INPUT; ++ } + if (!SafeReadBlockLength(s, &s->block_length[tree_type], len_tree, br)) { + s->substate_read_block_length = BROTLI_STATE_READ_BLOCK_LENGTH_NONE; + BrotliBitReaderRestoreState(br, &memento); +- return BROTLI_FALSE; ++ return BROTLI_DECODER_NEEDS_MORE_INPUT; + } + } + +@@ -1158,7 +1160,7 @@ static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( + } + ringbuffer[0] = ringbuffer[1]; + ringbuffer[1] = block_type; +- return BROTLI_TRUE; ++ return BROTLI_DECODER_SUCCESS; + } + + static BROTLI_INLINE void DetectTrivialLiteralBlockTypes( +@@ -1194,62 +1196,68 @@ static BROTLI_INLINE void PrepareLiteralDecoding(BrotliDecoderState* s) { + + /* Decodes the block type and updates the state for literal context. + Reads 3..54 bits. */ +-static BROTLI_INLINE BROTLI_BOOL DecodeLiteralBlockSwitchInternal( ++static BROTLI_INLINE BrotliDecoderErrorCode DecodeLiteralBlockSwitchInternal( + int safe, BrotliDecoderState* s) { +- if (!DecodeBlockTypeAndLength(safe, s, 0)) { +- return BROTLI_FALSE; ++ BrotliDecoderErrorCode result = DecodeBlockTypeAndLength(safe, s, 0); ++ if (result != BROTLI_DECODER_SUCCESS) { ++ return result; + } + PrepareLiteralDecoding(s); +- return BROTLI_TRUE; ++ return BROTLI_DECODER_SUCCESS; + } + +-static void BROTLI_NOINLINE DecodeLiteralBlockSwitch(BrotliDecoderState* s) { +- DecodeLiteralBlockSwitchInternal(0, s); ++static BROTLI_NOINLINE BrotliDecoderErrorCode ++DecodeLiteralBlockSwitch(BrotliDecoderState* s) { ++ return DecodeLiteralBlockSwitchInternal(0, s); + } + +-static BROTLI_BOOL BROTLI_NOINLINE SafeDecodeLiteralBlockSwitch( ++static BROTLI_NOINLINE BrotliDecoderErrorCode SafeDecodeLiteralBlockSwitch( + BrotliDecoderState* s) { + return DecodeLiteralBlockSwitchInternal(1, s); + } + + /* Block switch for insert/copy length. + Reads 3..54 bits. */ +-static BROTLI_INLINE BROTLI_BOOL DecodeCommandBlockSwitchInternal( ++static BROTLI_INLINE BrotliDecoderErrorCode DecodeCommandBlockSwitchInternal( + int safe, BrotliDecoderState* s) { +- if (!DecodeBlockTypeAndLength(safe, s, 1)) { +- return BROTLI_FALSE; ++ BrotliDecoderErrorCode result = DecodeBlockTypeAndLength(safe, s, 1); ++ if (result != BROTLI_DECODER_SUCCESS) { ++ return result; + } + s->htree_command = s->insert_copy_hgroup.htrees[s->block_type_rb[3]]; +- return BROTLI_TRUE; ++ return BROTLI_DECODER_SUCCESS; + } + +-static void BROTLI_NOINLINE DecodeCommandBlockSwitch(BrotliDecoderState* s) { +- DecodeCommandBlockSwitchInternal(0, s); ++static BROTLI_NOINLINE BrotliDecoderErrorCode ++DecodeCommandBlockSwitch(BrotliDecoderState* s) { ++ return DecodeCommandBlockSwitchInternal(0, s); + } + +-static BROTLI_BOOL BROTLI_NOINLINE SafeDecodeCommandBlockSwitch( ++static BROTLI_NOINLINE BrotliDecoderErrorCode SafeDecodeCommandBlockSwitch( + BrotliDecoderState* s) { + return DecodeCommandBlockSwitchInternal(1, s); + } + + /* Block switch for distance codes. + Reads 3..54 bits. */ +-static BROTLI_INLINE BROTLI_BOOL DecodeDistanceBlockSwitchInternal( ++static BROTLI_INLINE BrotliDecoderErrorCode DecodeDistanceBlockSwitchInternal( + int safe, BrotliDecoderState* s) { +- if (!DecodeBlockTypeAndLength(safe, s, 2)) { +- return BROTLI_FALSE; ++ BrotliDecoderErrorCode result = DecodeBlockTypeAndLength(safe, s, 2); ++ if (result != BROTLI_DECODER_SUCCESS) { ++ return result; + } + s->dist_context_map_slice = s->dist_context_map + + (s->block_type_rb[5] << BROTLI_DISTANCE_CONTEXT_BITS); + s->dist_htree_index = s->dist_context_map_slice[s->distance_context]; +- return BROTLI_TRUE; ++ return BROTLI_DECODER_SUCCESS; + } + +-static void BROTLI_NOINLINE DecodeDistanceBlockSwitch(BrotliDecoderState* s) { +- DecodeDistanceBlockSwitchInternal(0, s); ++static BROTLI_NOINLINE BrotliDecoderErrorCode ++DecodeDistanceBlockSwitch(BrotliDecoderState* s) { ++ return DecodeDistanceBlockSwitchInternal(0, s); + } + +-static BROTLI_BOOL BROTLI_NOINLINE SafeDecodeDistanceBlockSwitch( ++static BROTLI_NOINLINE BrotliDecoderErrorCode SafeDecodeDistanceBlockSwitch( + BrotliDecoderState* s) { + return DecodeDistanceBlockSwitchInternal(1, s); + } +@@ -1719,6 +1727,9 @@ static BROTLI_INLINE BROTLI_BOOL CheckInputAmount( + return BrotliCheckInputAmount(br, num); + } + ++/* NB: METHOD should return BROTLI_FALSE only in case there is not enough input; ++ in case of "unsafe" execution, when input is guaranteed to be sufficient, ++ result is ignored. */ + #define BROTLI_SAFE(METHOD) \ + { \ + if (safe) { \ +@@ -1731,6 +1742,22 @@ static BROTLI_INLINE BROTLI_BOOL CheckInputAmount( + } \ + } + ++/* NB: METHOD should return BROTLI_DECODER_SUCCESS, BROTLI_DECODER_ERROR_*, or ++ BROTLI_DECODER_NEEDS_MORE_INPUT; the later two break the processing. */ ++#define BROTLI_SAFE_WITH_STATUS(METHOD) \ ++ { \ ++ BrotliDecoderErrorCode status; \ ++ if (safe) { \ ++ status = Safe##METHOD; \ ++ } else { \ ++ status = METHOD; \ ++ } \ ++ if (status != BROTLI_DECODER_SUCCESS) { \ ++ result = status; \ ++ goto saveStateAndReturn; \ ++ } \ ++ } ++ + static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + int safe, BrotliDecoderState* s) { + int pos = s->pos; +@@ -1769,7 +1796,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + goto saveStateAndReturn; + } + if (BROTLI_PREDICT_FALSE(s->block_length[1] == 0)) { +- BROTLI_SAFE(DecodeCommandBlockSwitch(s)); ++ BROTLI_SAFE_WITH_STATUS(DecodeCommandBlockSwitch(s)); + goto CommandBegin; + } + /* Read the insert/copy length in the command. */ +@@ -1797,7 +1824,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + goto saveStateAndReturn; + } + if (BROTLI_PREDICT_FALSE(s->block_length[0] == 0)) { +- BROTLI_SAFE(DecodeLiteralBlockSwitch(s)); ++ BROTLI_SAFE_WITH_STATUS(DecodeLiteralBlockSwitch(s)); + PreloadSymbol(safe, s->literal_htree, br, &bits, &value); + if (!s->trivial_literal_context) goto CommandInner; + } +@@ -1833,7 +1860,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + goto saveStateAndReturn; + } + if (BROTLI_PREDICT_FALSE(s->block_length[0] == 0)) { +- BROTLI_SAFE(DecodeLiteralBlockSwitch(s)); ++ BROTLI_SAFE_WITH_STATUS(DecodeLiteralBlockSwitch(s)); + if (s->trivial_literal_context) goto CommandInner; + } + context = BROTLI_CONTEXT(p1, p2, s->context_lookup); +@@ -1880,7 +1907,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + } else { + /* Read distance code in the command, unless it was implicitly zero. */ + if (BROTLI_PREDICT_FALSE(s->block_length[2] == 0)) { +- BROTLI_SAFE(DecodeDistanceBlockSwitch(s)); ++ BROTLI_SAFE_WITH_STATUS(DecodeDistanceBlockSwitch(s)); + } + BROTLI_SAFE(ReadDistance(s, br)); + } +diff --git a/c/include/brotli/decode.h b/c/include/brotli/decode.h +index 0f5c8f9d1..00e20c407 100644 +--- a/c/include/brotli/decode.h ++++ b/c/include/brotli/decode.h +@@ -84,8 +84,9 @@ typedef enum { + BROTLI_ERROR_CODE(_ERROR_FORMAT_, PADDING_1, -14) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_FORMAT_, PADDING_2, -15) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_FORMAT_, DISTANCE, -16) SEPARATOR \ ++ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR \ + \ +- /* -17..-18 codes are reserved */ \ ++ /* Code -18 is reserved */ \ + \ + BROTLI_ERROR_CODE(_ERROR_, DICTIONARY_NOT_SET, -19) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_, INVALID_ARGUMENTS, -20) SEPARATOR \ diff --git a/docker/brotli/README.md b/docker/brotli/README.md new file mode 100644 index 0000000..fec3c5d --- /dev/null +++ b/docker/brotli/README.md @@ -0,0 +1,25 @@ +# Bookworm Brotli correction + +The pinned PHP base uses Debian's Brotli 1.0.9 package. This directory builds a +same-base Debian backport of Google's +[CVE-2026-111497 fix](https://github.com/google/brotli/commit/50a9b70a4d5e1443fc40927952276b68fffa83a0). +The patched decoder and header must match upstream +[1.0.10](https://github.com/google/brotli/releases/tag/v1.0.10) exactly. + +APT authenticates the source index. `source.sha256` binds its three source +artifacts, and the script verifies the vendored patch and both resulting files. +The runtime and development packages keep the honest upstream version 1.0.9 +and use revision `1.0.9-2+b6+dw1`. It sorts below the next Debian binary rebuild +and security revision. When Debian publishes its correction, review and replace +this backport with that vendor package. + +The build deliberately does not execute upstream test corpora. Verification +checks the added error-name marker and encodes/decodes ordinary valid data. +The final PHP guard checks the exact package, refuses stray decoder copies, and +inspects dynamic consumers in the image's tool, application and browser roots. +Existing Node checks separately verify its embedded decoder's fixed release. +Both final image recipes and their native qualification run these guards. + +A scanner that checks only the upstream `1.0.9` string may still report the CVE. +Keep that result alongside the patch hashes, package revision and actual runtime +checks. Do not claim an upstream version upgrade or suppress it without review. diff --git a/docker/brotli/build.sh b/docker/brotli/build.sh new file mode 100644 index 0000000..dfb111c --- /dev/null +++ b/docker/brotli/build.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Same-base Bookworm backport of Google Brotli fix 50a9b70a4d5e1443fc40927952276b68fffa83a0. +# The patched decoder/header match upstream v1.0.10; upstream version metadata +# remains 1.0.9. The local revision is superseded by future Debian updates. +set -eu + +brotli_inputs=$(dirname "$(readlink -f "$0")") +brotli_version=$(cat "$brotli_inputs/version") +brotli_work=/usr/src/sample-brotli-build +brotli_arch=$(dpkg --print-architecture) +brotli_multiarch=$(dpkg-architecture -qDEB_HOST_MULTIARCH) + +dpkg --compare-versions "$brotli_version" gt 1.0.9-2+b6 +dpkg --compare-versions "$brotli_version" lt 1.0.9-2+b7 +dpkg --compare-versions "$brotli_version" lt 1.0.9-2+deb12u1 +sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/debian.sources +apt-get update +apt-get install -y --no-install-recommends dpkg-dev +mkdir -p "$brotli_work" +cd "$brotli_work" +apt-get source --download-only brotli=1.0.9-2 +sha256sum -c "$brotli_inputs/source.sha256" +dpkg-source -x brotli_1.0.9-2.dsc brotli-1.0.9 +cd brotli-1.0.9 +apt-get build-dep -y --no-install-recommends -P nopython . + +printf '%s %s\n' \ + 45a46c6b5df8f7b103f343d515c43e8856d9eb057f707e8667a559f1d1e04817 \ + "$brotli_inputs/CVE-2026-111497.patch" | sha256sum -c - +cp "$brotli_inputs/CVE-2026-111497.patch" debian/patches/ +printf '\nCVE-2026-111497.patch\n' >> debian/patches/series +dpkg-source --before-build . +sha256sum -c "$brotli_inputs/patched-files.sha256" + +{ + printf 'brotli (%s) bookworm-security; urgency=high\n\n' "$brotli_version" + printf ' * Durable Workflow backport of upstream 50a9b70 (v1.0.10),\n' + printf ' correcting CVE-2026-111497 without masking a vendor update.\n\n' + printf ' -- Durable Workflow Thu, 22 Oct 2026 00:00:00 +0000\n\n' + cat debian/changelog +} > debian/changelog.new +mv debian/changelog.new debian/changelog +cat >> debian/rules <<'RULES' + +override_dh_auto_configure: + dh_auto_configure -- -DBROTLI_DISABLE_TESTS=ON + +override_dh_auto_test: + @printf '%s\n' 'Upstream corpora are not executed; marker and valid-data checks run separately.' +RULES + +mkdir -p /out +cc -O2 -I c/include "$brotli_inputs/verify-system-brotli.c" \ + "/usr/lib/$brotli_multiarch/libbrotlidec.so.1" \ + "/usr/lib/$brotli_multiarch/libbrotlienc.so.1" -ldl -o /out/verify-system-brotli +set +e +/out/verify-system-brotli +brotli_baseline_exit=$? +set -e +test "$brotli_baseline_exit" -eq 1 + +DEB_BUILD_PROFILES=nopython DEB_BUILD_OPTIONS='nocheck noddebs parallel=1' \ + dpkg-buildpackage -B -uc -us -Pnopython +cp "$brotli_work/libbrotli1_${brotli_version}_${brotli_arch}.deb" /out/ +cp "$brotli_work/libbrotli-dev_${brotli_version}_${brotli_arch}.deb" /out/ +cp "$brotli_inputs/version" /out/version +cp "$brotli_inputs/verify-runtime.php" /out/verify-runtime.php +dpkg-deb --field "/out/libbrotli1_${brotli_version}_${brotli_arch}.deb" Package Version Architecture diff --git a/docker/brotli/patched-files.sha256 b/docker/brotli/patched-files.sha256 new file mode 100644 index 0000000..34a59e9 --- /dev/null +++ b/docker/brotli/patched-files.sha256 @@ -0,0 +1,2 @@ +44a4c1973553725c693dc38136cd32a5ec97a471bba8bf5c25bd23fa5b486542 c/dec/decode.c +ca83a7094a0990d79b327eabc75fc07a90fe13156531994ee712f2d49fa3f049 c/include/brotli/decode.h diff --git a/docker/brotli/source.sha256 b/docker/brotli/source.sha256 new file mode 100644 index 0000000..b5a63e0 --- /dev/null +++ b/docker/brotli/source.sha256 @@ -0,0 +1,3 @@ +8c4c86748ec9770e08b60233d658593650444b04a452dc5b607ed5b5537b683e brotli_1.0.9-2.dsc +f9e8d81d0405ba66d181529af42a3354f838c939095ff99930da6aa9cdf6fe46 brotli_1.0.9.orig.tar.gz +ab81b1db852c8d01e0fa5b0b650bb486f32a232b35336828423af50af6fecca0 brotli_1.0.9-2.debian.tar.xz diff --git a/docker/brotli/verify-runtime.php b/docker/brotli/verify-runtime.php new file mode 100644 index 0000000..fc7196e --- /dev/null +++ b/docker/brotli/verify-runtime.php @@ -0,0 +1,113 @@ + ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new BrotliRuntimeRefused('Cannot inspect the Brotli runtime.'); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new BrotliRuntimeRefused(implode(' ', $command).' failed: '.trim($error)); + } + + return $output; +} + +function brotliElfMentionsDecoder(string $path): bool +{ + $input = fopen($path, 'rb'); + if ($input === false) { + throw new BrotliRuntimeRefused('Cannot inspect '.$path); + } + try { + if (fread($input, 4) !== "\x7fELF") { + return false; + } + $tail = ''; + while (!feof($input)) { + $chunk = $tail.fread($input, 1024 * 1024); + if (str_contains($chunk, "libbrotlidec.so.1\0")) { + return true; + } + $tail = substr($chunk, -32); + } + + return false; + } finally { + fclose($input); + } +} + +function brotliInspectFile(SplFileInfo $file, array $owned): int +{ + $path = $file->getPathname(); + if (str_starts_with($file->getFilename(), 'libbrotlidec.so') && !$file->isDir() + && !isset($owned[realpath($path) ?: $path])) { + throw new BrotliRuntimeRefused('A decoder is outside the patched package: '.$path); + } + if (!$file->isFile() || $file->isLink() || !brotliElfMentionsDecoder($path)) { + return 0; + } + $links = brotliCommand(['ldd', $path]); + // A library also contains its own SONAME. Only ldd's dependency mapping + // identifies a consumer; an unresolved mapping still fails. + if (!preg_match('~libbrotlidec\.so\.1\s+=>\s+(\S+)~', $links, $match)) { + return 0; + } + if (!isset($owned[realpath($match[1]) ?: $match[1]])) { + throw new BrotliRuntimeRefused('An ELF consumer does not load the patched decoder: '.$path); + } + + return 1; +} + +function verifyBrotliRuntime(): void +{ + $expected = trim((string) file_get_contents(__DIR__.'/version')); + $actual = trim(brotliCommand(['dpkg-query', '-W', '-f=${Version}', 'libbrotli1'])); + if ($actual !== $expected) { + throw new BrotliRuntimeRefused('libbrotli1 is '.$actual.'; expected the reviewed backport '.$expected.'.'); + } + $owned = []; + foreach (explode("\n", brotliCommand(['dpkg-query', '-L', 'libbrotli1'])) as $path) { + if (str_contains(basename($path), 'libbrotlidec.so')) { + $resolved = realpath($path); + if ($resolved !== false) { + $owned[$resolved] = true; + } + } + } + if ($owned === []) { + throw new BrotliRuntimeRefused('The patched package owns no decoder.'); + } + + $consumers = 0; + // These are the image's tool, dependency, application and browser roots. + // Symlinked directories are not followed; Debian /lib and /bin resolve into /usr. + foreach (['/usr', '/opt', '/app', '/var/www', '/ms-playwright'] as $root) { + if (!is_dir($root)) { + continue; + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $consumers += brotliInspectFile($file, $owned); + } + } + if ($consumers === 0) { + throw new BrotliRuntimeRefused('No system Brotli consumer was inspected.'); + } + echo brotliCommand([__DIR__.'/verify-system-brotli']); + echo json_encode(['package' => $actual, 'elf_consumers' => $consumers], JSON_THROW_ON_ERROR).PHP_EOL; +} + +if (realpath($argv[0] ?? '') === __FILE__) { + verifyBrotliRuntime(); +} diff --git a/docker/brotli/verify-system-brotli.c b/docker/brotli/verify-system-brotli.c new file mode 100644 index 0000000..5935604 --- /dev/null +++ b/docker/brotli/verify-system-brotli.c @@ -0,0 +1,43 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +int main(void) { + const char *marker = BrotliDecoderErrorString((BrotliDecoderErrorCode)-17); + Dl_info origin = {0}; + const void *symbol = dlsym(RTLD_DEFAULT, "BrotliDecoderErrorString"); + unsigned version = BrotliDecoderVersion(); + if (!symbol || !dladdr(symbol, &origin) || !origin.dli_fname) { + fputs("Cannot resolve the loaded Brotli decoder.\n", stderr); + return 2; + } + printf("decoder=%u.%u.%u marker=%s library=%s\n", + version >> 24, (version >> 12) & 4095, version & 4095, + marker ? marker : "missing", origin.dli_fname); + /* Brotli 1.0 reports the error name without the newer branch's prefix. */ + if (!marker || strcmp(marker, "BLOCK_SWITCH") != 0) { + fputs("The loaded decoder lacks the CVE-2026-111497 fix.\n", stderr); + return 1; + } + + /* Only valid bytes produced here are decoded; no malformed corpus is used. */ + const unsigned char input[] = "Durable Workflow ordinary Brotli response"; + unsigned char encoded[1024]; + unsigned char decoded[sizeof(input)]; + size_t encoded_size = sizeof(encoded); + size_t decoded_size = sizeof(decoded); + if (!BrotliEncoderCompress(4, 22, BROTLI_MODE_GENERIC, sizeof(input) - 1, + input, &encoded_size, encoded) || + BrotliDecoderDecompress(encoded_size, encoded, &decoded_size, decoded) != + BROTLI_DECODER_RESULT_SUCCESS || + decoded_size != sizeof(input) - 1 || + memcmp(decoded, input, sizeof(input) - 1) != 0) { + fputs("Ordinary Brotli data did not round trip.\n", stderr); + return 2; + } + puts("Valid Brotli data round trips through the corrected system decoder."); + return 0; +} diff --git a/docker/brotli/version b/docker/brotli/version new file mode 100644 index 0000000..a9c9d73 --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.0.9-2+b6+dw1 diff --git a/scripts/ci/qualify-devcontainer-image.sh b/scripts/ci/qualify-devcontainer-image.sh index 4b588e5..49dc731 100755 --- a/scripts/ci/qualify-devcontainer-image.sh +++ b/scripts/ci/qualify-devcontainer-image.sh @@ -121,6 +121,9 @@ trap cleanup EXIT run_in_ready_devcontainer laravel .devcontainer/post-create.sh "${compose[@]}" up --detach --no-build --wait +# The complete image inventory includes root-owned build sources and tools. +"${compose[@]}" exec -T --user root laravel php /usr/local/share/sample-app/brotli/verify-runtime.php + run_in_ready_devcontainer laravel bash -euc ' for command in php composer python3 rustc cargo dw rg node npm docker; do command -v "$command" >/dev/null diff --git a/tests/Unit/BrotliImageContractTest.php b/tests/Unit/BrotliImageContractTest.php new file mode 100644 index 0000000..242c5ee --- /dev/null +++ b/tests/Unit/BrotliImageContractTest.php @@ -0,0 +1,92 @@ +assertSame( + '45a46c6b5df8f7b103f343d515c43e8856d9eb057f707e8667a559f1d1e04817', + hash_file('sha256', $root.'/docker/brotli/CVE-2026-111497.patch'), + ); + $patch = file_get_contents($root.'/docker/brotli/CVE-2026-111497.patch'); + preg_match_all('~^diff --git a/(\S+) b/(\S+)$~m', $patch, $files); + $this->assertSame(['c/dec/decode.c', 'c/include/brotli/decode.h'], $files[1]); + } + + public function test_local_revision_yields_to_the_next_vendor_update(): void + { + $version = trim(file_get_contents(dirname(__DIR__, 2).'/docker/brotli/version')); + foreach ([['gt', '1.0.9-2+b6'], ['lt', '1.0.9-2+b7'], ['lt', '1.0.9-2+deb12u1']] as [$comparison, $vendor]) { + $process = proc_open(['dpkg', '--compare-versions', $version, $comparison, $vendor], [], $pipes); + $this->assertIsResource($process); + $this->assertSame(0, proc_close($process), $version.' '.$comparison.' '.$vendor); + } + } + + public function test_both_images_install_same_base_packages_before_network_clients(): void + { + $root = dirname(__DIR__, 2); + foreach (['Dockerfile', '.devcontainer/docker/Dockerfile'] as $path) { + $source = file_get_contents($root.'/'.$path); + preg_match('~^FROM (\S+) AS brotli-build$~m', $source, $builder); + preg_match_all('~^FROM (php:\S+)~m', $source, $runtimes); + $this->assertCount(2, $runtimes[1]); + $this->assertSame($builder[1], $runtimes[1][1]); + $runtime = substr($source, strrpos($source, "\nFROM php:")); + $install = strpos($runtime, 'RUN dpkg -i '); + $check = strpos($runtime, 'php /usr/local/share/sample-app/brotli/verify-runtime.php'); + $download = strpos($runtime, 'apt-get update'); + foreach ([$install, $check, $download] as $position) { + $this->assertIsInt($position); + } + $this->assertLessThan($check, $install); + $this->assertLessThan($download, $check); + $this->assertGreaterThan(strrpos($runtime, 'apt-get install'), strrpos($runtime, 'php /usr/local/share/sample-app/brotli/verify-runtime.php')); + } + } + + public function test_native_image_qualification_inspects_the_actual_decoder(): void + { + $root = dirname(__DIR__, 2); + foreach (['.github/workflows/smoke.yml', 'scripts/ci/qualify-devcontainer-image.sh'] as $path) { + $this->assertStringContainsString('php /usr/local/share/sample-app/brotli/verify-runtime.php', file_get_contents($root.'/'.$path)); + } + } + + public function test_package_builder_supplies_the_composer_archive_required_by_its_trust_guard(): void + { + $root = dirname(__DIR__, 2); + foreach (['Dockerfile', '.devcontainer/docker/Dockerfile'] as $path) { + $source = file_get_contents($root.'/'.$path); + $start = strpos($source, ' AS brotli-build'); + $builder = substr($source, $start, strpos($source, "\nFROM ", $start) - $start); + $this->assertStringContainsString('COPY --from=composer /usr/bin/composer /usr/local/bin/composer', $builder); + $this->assertMatchesRegularExpression('~php /usr/local/share/sample-app/verify-(?:runtime|php)-trust\.php /usr/local/bin/composer~', $builder); + } + } + + public function test_full_inventory_uses_the_image_verification_role(): void + { + $root = dirname(__DIR__, 2); + $qualifier = file_get_contents($root.'/scripts/ci/qualify-devcontainer-image.sh'); + $this->assertStringContainsString( + '"${compose[@]}" exec -T --user root laravel php /usr/local/share/sample-app/brotli/verify-runtime.php', + $qualifier, + ); + $this->assertStringNotContainsString( + '/brotli/verify-runtime.php', + file_get_contents($root.'/.devcontainer/docker/verify-image.sh'), + ); + $this->assertStringContainsString( + '&& php /usr/local/share/sample-app/brotli/verify-runtime.php', + file_get_contents($root.'/.devcontainer/docker/Dockerfile'), + ); + } +} diff --git a/tests/Unit/ProductionImageTrustContractTest.php b/tests/Unit/ProductionImageTrustContractTest.php index da390cb..7ef1b78 100644 --- a/tests/Unit/ProductionImageTrustContractTest.php +++ b/tests/Unit/ProductionImageTrustContractTest.php @@ -14,7 +14,7 @@ public function test_external_runtime_and_composer_inputs_are_immutable(): void preg_match_all('/^FROM (\S+)/mi', $source, $inputs); $external = array_values(array_filter($inputs[1], static fn (string $input): bool => $input !== 'base')); - $this->assertCount(3, $external); + $this->assertCount(4, $external); foreach ($external as $input) { $this->assertMatchesRegularExpression('/^[^@]+:[^@]+@sha256:[a-f0-9]{64}$/D', $input); }