From 675c7d5114e6e875a1668b3ae0f9a27e0c9995d7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:52:52 -0600 Subject: [PATCH 1/3] Composer trust hook for cooperative CI builds (.github#237) The production stage's Composer request can now be bound to the central verifier. When DW_CI_CA_SHA256 is set, the same RUN, after composer.json takes its final form, requires the dwf_composer_trust build context, the dwf_ci_ca secret and well-formed DW_SERVER_SOURCE_COMMIT and DW_SERVER_DOCKERFILE_SHA256, selects the authenticated bundle and an empty additional-root directory for PHP, Composer, curl and Git, runs verify.php, and only then runs the unchanged composer update. A missing input refuses before any request. The verifier, secret and root directory are mounts, so none reaches the image. Ordinary builds pass none of these and keep their behavior through an empty default dwf_composer_trust stage. The Composer sink inventory and the release-image order test move with the RUN's new shape. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 44 ++++++++++++++++--- ...eleaseImagePublishWorkflowContractTest.php | 2 +- tests/Unit/Support/composer-sinks.json | 6 +-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9ae79b249..d7ead4e1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,13 @@ FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS node +# Composer trust hook (central .github#237, SDK-PHP #223). A cooperative CI +# build passes the reviewed central verifier as build context +# dwf_composer_trust, the authenticated CA bundle as secret dwf_ci_ca, and +# DW_CI_CA_SHA256 plus its source bindings as build arguments. This empty +# default stage keeps ordinary builds, which pass none of them, unchanged. +FROM scratch AS dwf_composer_trust + FROM composer AS phpredis-source ARG PHPREDIS_VERSION=6.3.0 @@ -118,11 +125,38 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* \ && server-verify-brotli -RUN php scripts/ci/prepare-release-workflow-composer-metadata.php \ - && composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress \ - && cp composer.json /tmp/release-composer.json \ - && cp composer.lock /tmp/release-composer.lock \ - && test -f /workflow/.package-provenance +# With DW_CI_CA_SHA256 set, the Composer request below must run against the +# authenticated bundle and an empty additional-root directory, verified by +# the central verifier in this same RUN, after composer.json takes its final +# form. A missing hook input refuses the build before any request. The +# secret, verifier and empty root directory are mounts, so none of them +# reaches the image. +ARG DW_CI_CA_SHA256= +ARG DW_SERVER_SOURCE_COMMIT= +ARG DW_SERVER_DOCKERFILE_SHA256= +RUN --mount=type=bind,from=dwf_composer_trust,target=/dwf-composer-trust,readonly \ + --mount=type=secret,id=dwf_ci_ca,required=false \ + --mount=type=tmpfs,target=/dwf-composer-roots \ + set -eu; \ + php scripts/ci/prepare-release-workflow-composer-metadata.php; \ + if [ -n "${DW_CI_CA_SHA256}" ]; then \ + refuse() { echo "Composer trust hook: $1" >&2; exit 1; }; \ + [ -s /run/secrets/dwf_ci_ca ] || refuse 'secret dwf_ci_ca is required'; \ + [ -f /dwf-composer-trust/verify.php ] || refuse 'build context dwf_composer_trust is required'; \ + printf '%s' "${DW_SERVER_SOURCE_COMMIT}" | grep -Eqx '[0-9a-f]{40}' || refuse 'DW_SERVER_SOURCE_COMMIT must be a full commit'; \ + printf '%s' "${DW_SERVER_DOCKERFILE_SHA256}" | grep -Eqx '[0-9a-f]{64}' || refuse 'DW_SERVER_DOCKERFILE_SHA256 must be a sha256'; \ + [ -z "$(ls -A /dwf-composer-roots)" ] || refuse 'the additional-root directory must be empty'; \ + export SSL_CERT_FILE=/run/secrets/dwf_ci_ca CURL_CA_BUNDLE=/run/secrets/dwf_ci_ca \ + GIT_SSL_CAINFO=/run/secrets/dwf_ci_ca COMPOSER_CAFILE=/run/secrets/dwf_ci_ca \ + SSL_CERT_DIR=/dwf-composer-roots GIT_SSL_CAPATH=/dwf-composer-roots COMPOSER_CAPATH=/dwf-composer-roots \ + DW_CI_CA_SHA256; \ + echo "Composer trust hook: source ${DW_SERVER_SOURCE_COMMIT} dockerfile ${DW_SERVER_DOCKERFILE_SHA256}"; \ + php /dwf-composer-trust/verify.php /usr/bin/composer "$PWD"; \ + fi; \ + composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress; \ + cp composer.json /tmp/release-composer.json; \ + cp composer.lock /tmp/release-composer.lock; \ + test -f /workflow/.package-provenance COPY . . RUN cp /tmp/release-composer.json composer.json \ diff --git a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php index a16ec81f6..e716fcbe5 100644 --- a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php +++ b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php @@ -257,7 +257,7 @@ public function test_dockerfile_cannot_replace_workflow_checkout_with_forged_pro $verifyOffset = strpos($dockerfile, 'git -C /workflow rev-parse HEAD'); $cleanOffset = strpos($dockerfile, 'git -C /workflow diff --quiet HEAD --'); $provenanceOffset = strpos($dockerfile, '> /workflow/.package-provenance'); - $metadataOffset = strpos($dockerfile, 'RUN php scripts/ci/prepare-release-workflow-composer-metadata.php'); + $metadataOffset = strpos($dockerfile, " php scripts/ci/prepare-release-workflow-composer-metadata.php; \\\n"); $composerUpdateOffset = strpos($dockerfile, 'composer update durable-workflow/workflow'); $copyApplicationOffset = strpos($dockerfile, "COPY . .\n"); $compareInstalledProvenanceOffset = strpos( diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index c9cb11807..5adc738e7 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -187,14 +187,14 @@ }, { "path": "Dockerfile", - "line": "&& composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress \\", + "line": "composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress; \\", "count": 1, - "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store.", + "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store. With DW_CI_CA_SHA256 set, the same RUN first runs the central verifier against the authenticated bundle (.github#237).", "guards": [ { "type": "server-verify", "line": "RUN server-verify-trust final \\", - "distance": 50 + "distance": 77 } ] }, From 5c31a5ba580cf42e55c72cafc393687cbeb11f6a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:56:30 -0600 Subject: [PATCH 2/3] Apply the Composer trust hook to dump-autoload too General's review of #943: composer dump-autoload --optimize after COPY . . can run Composer scripts and plugins, and the first RUN's trust settings have expired by then. The second RUN now restores the final composer.json and lock, applies the same mounts and required-mode checks, runs the verifier, and only then dumps the autoloader. Its behavior is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7ead4e1e..ddd8ff001 100644 --- a/Dockerfile +++ b/Dockerfile @@ -159,13 +159,34 @@ RUN --mount=type=bind,from=dwf_composer_trust,target=/dwf-composer-trust,readonl test -f /workflow/.package-provenance COPY . . -RUN cp /tmp/release-composer.json composer.json \ - && cp /tmp/release-composer.lock composer.lock \ - && rm -f /tmp/release-composer.json /tmp/release-composer.lock \ - && install -m 0664 /dev/null database/database.sqlite \ - && composer dump-autoload --optimize \ - && cmp /workflow/.package-provenance vendor/durable-workflow/workflow/.package-provenance \ - && cp vendor/durable-workflow/workflow/.package-provenance /app/.package-provenance +# dump-autoload can run Composer scripts and plugins, and the previous RUN's +# trust settings have expired, so the hook is applied again here, after the +# final composer.json and composer.lock are restored. +RUN --mount=type=bind,from=dwf_composer_trust,target=/dwf-composer-trust,readonly \ + --mount=type=secret,id=dwf_ci_ca,required=false \ + --mount=type=tmpfs,target=/dwf-composer-roots \ + set -eu; \ + cp /tmp/release-composer.json composer.json; \ + cp /tmp/release-composer.lock composer.lock; \ + rm -f /tmp/release-composer.json /tmp/release-composer.lock; \ + install -m 0664 /dev/null database/database.sqlite; \ + if [ -n "${DW_CI_CA_SHA256}" ]; then \ + refuse() { echo "Composer trust hook: $1" >&2; exit 1; }; \ + [ -s /run/secrets/dwf_ci_ca ] || refuse 'secret dwf_ci_ca is required'; \ + [ -f /dwf-composer-trust/verify.php ] || refuse 'build context dwf_composer_trust is required'; \ + printf '%s' "${DW_SERVER_SOURCE_COMMIT}" | grep -Eqx '[0-9a-f]{40}' || refuse 'DW_SERVER_SOURCE_COMMIT must be a full commit'; \ + printf '%s' "${DW_SERVER_DOCKERFILE_SHA256}" | grep -Eqx '[0-9a-f]{64}' || refuse 'DW_SERVER_DOCKERFILE_SHA256 must be a sha256'; \ + [ -z "$(ls -A /dwf-composer-roots)" ] || refuse 'the additional-root directory must be empty'; \ + export SSL_CERT_FILE=/run/secrets/dwf_ci_ca CURL_CA_BUNDLE=/run/secrets/dwf_ci_ca \ + GIT_SSL_CAINFO=/run/secrets/dwf_ci_ca COMPOSER_CAFILE=/run/secrets/dwf_ci_ca \ + SSL_CERT_DIR=/dwf-composer-roots GIT_SSL_CAPATH=/dwf-composer-roots COMPOSER_CAPATH=/dwf-composer-roots \ + DW_CI_CA_SHA256; \ + echo "Composer trust hook: source ${DW_SERVER_SOURCE_COMMIT} dockerfile ${DW_SERVER_DOCKERFILE_SHA256}"; \ + php /dwf-composer-trust/verify.php /usr/bin/composer "$PWD"; \ + fi; \ + composer dump-autoload --optimize; \ + cmp /workflow/.package-provenance vendor/durable-workflow/workflow/.package-provenance; \ + cp vendor/durable-workflow/workflow/.package-provenance /app/.package-provenance # ── Production image ───────────────────────────────────────────────── COPY docker/bootstrap.sh /usr/local/bin/server-bootstrap From c4fc6c105f26a4496e9c92dad900c6abbc7a9700 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:55:41 -0600 Subject: [PATCH 3/3] Keep the Composer request lines byte-identical to main Sonar flagged docker:S8546 on the composer update line, a pre-existing and intentional single-package update, because #943's restructuring touched it. The hook's contract is to run the verifier immediately before the unchanged Composer command, so the command and its following lines now keep main's exact text, chained after the hook's if-block. Sonar attributes them to their original commit again, and the sink inventory entry returns to its original line. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 10 +++++----- tests/Unit/Support/composer-sinks.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ddd8ff001..2151e7c11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -152,11 +152,11 @@ RUN --mount=type=bind,from=dwf_composer_trust,target=/dwf-composer-trust,readonl DW_CI_CA_SHA256; \ echo "Composer trust hook: source ${DW_SERVER_SOURCE_COMMIT} dockerfile ${DW_SERVER_DOCKERFILE_SHA256}"; \ php /dwf-composer-trust/verify.php /usr/bin/composer "$PWD"; \ - fi; \ - composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress; \ - cp composer.json /tmp/release-composer.json; \ - cp composer.lock /tmp/release-composer.lock; \ - test -f /workflow/.package-provenance + fi \ + && composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress \ + && cp composer.json /tmp/release-composer.json \ + && cp composer.lock /tmp/release-composer.lock \ + && test -f /workflow/.package-provenance COPY . . # dump-autoload can run Composer scripts and plugins, and the previous RUN's diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 5adc738e7..a6d5226cd 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -187,7 +187,7 @@ }, { "path": "Dockerfile", - "line": "composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress; \\", + "line": "&& composer update durable-workflow/workflow --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress \\", "count": 1, "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store. With DW_CI_CA_SHA256 set, the same RUN first runs the central verifier against the authenticated bundle (.github#237).", "guards": [