From 7f048bda80d85f73329f16005a1a7ad5abf86297 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 16:38:43 +0000 Subject: [PATCH 01/21] Start every Server pip install from the patched, bundle-only pip GHSA-6q48-6c7w-9gw2: networked pip installs in Server CI and the active conformance scripts ran with whatever pip and trust store the runner or image had, and several python images still trust both withdrawn roots. scripts/ci/bootstrap-patched-pip.sh installs the hash-pinned pip 26.2.2 into a venv and refuses a bundle with either root. pip also trusts the operating system store through truststore, so the venv's pip.conf selects legacy-certs, which trusts only that checked bundle. patched-pip.sh runs pip from such a venv for containers, and blocked_roots.py counts the roots. - Runner jobs use the central bootstrap action; the Helm and perf jobs then pin PIP_CERT and PIP_USE_DEPRECATED to the checked bundle. - Conformance venvs take the bootstrap right after creation and drop their old-pip "pip install --upgrade pip" calls. - Hash-locked adapter installs run through patched-pip.sh. - The worker-versioning image pins Composer and Node, refreshes the OS CA store through apt and asserts it before any TLS download. --- .github/workflows/helm-chart-checks.yml | 33 ++++++++++++- .github/workflows/phpunit-feature.yml | 2 + .github/workflows/server-parity.yml | 2 +- .github/workflows/server-perf.yml | 17 +++++++ scripts/ci/blocked_roots.py | 48 +++++++++++++++++++ scripts/ci/bootstrap-patched-pip.sh | 36 ++++++++++++++ scripts/ci/patched-pip.sh | 13 +++++ .../activities-published-artifacts.sh | 1 + .../child-workflows-published-artifacts.sh | 1 + .../namespaces-published-artifacts.sh | 4 +- .../conformance/nexus-published-artifacts.sh | 9 +++- .../conformance/python-published-artifacts.sh | 2 +- .../conformance/replay-published-artifacts.sh | 2 +- .../conformance/sagas-published-artifacts.sh | 2 +- .../corrected-marker-adapters.sh | 4 +- .../server-parity/legacy-marker-adapters.sh | 2 +- .../server-parity/query-authority-adapters.sh | 2 +- .../server-parity/sdk-direction-adapters.sh | 2 +- .../conformance/skew-published-artifacts.sh | 2 +- .../worker-versioning-mixed.Dockerfile | 18 +++++-- ...ersioning-rust-host-published-artifacts.sh | 1 + .../workflow-lifecycle-published-artifacts.sh | 3 +- .../workflow-updates-published-artifacts.sh | 7 +++ scripts/perf/run-namespace-isolation.sh | 1 + .../UpdateRequestKeyConsumer/compose.yml | 2 + 25 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 scripts/ci/blocked_roots.py create mode 100755 scripts/ci/bootstrap-patched-pip.sh create mode 100755 scripts/ci/patched-pip.sh diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 444321009..9d757e76d 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -61,10 +61,27 @@ jobs: echo "::endgroup::" done + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + + # pip also trusts the runner's own store (truststore). Confine every + # later pip in this job to the bootstrapped pip's bundle once it is + # proven clean. + - name: Confine pip to its verified CA bundle + run: | + bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" + python scripts/ci/blocked_roots.py "$bundle" + { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + - name: Check database CA mounts and bootstrap configuration run: | - python3 -m pip install --only-binary :all: PyYAML==6.0.2 - python3 scripts/ci/test_database_tls_chart.py + python -m pip install --only-binary :all: PyYAML==6.0.2 + python scripts/ci/test_database_tls_chart.py - name: Render every CI fixture run: | @@ -136,6 +153,18 @@ jobs: with: python-version: ${{ env.PYTHON_VERSION }} + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + + # pip also trusts the runner's own store (truststore). Confine every + # later pip in this job, including ones that actions run in their own + # venvs, to the bootstrapped pip's bundle once it is proven clean. + - name: Confine pip to its verified CA bundle + run: | + bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" + python scripts/ci/blocked_roots.py "$bundle" + { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 with: diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1ce0da38c..e19fc28eb 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -41,6 +41,8 @@ jobs: uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 - name: Install policy dependencies run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 1c6e559c1..6c71fa15e 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -129,7 +129,7 @@ jobs: docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/published-sdk-adapters:/adapters" \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /adapters/python-packages --report /adapters/python-install-report.json \ -r /source/scripts/conformance/server-parity/published-sdk-patch/requirements.txt \ > published-sdk-adapters/python-install.txt 2>&1 diff --git a/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 42e0048ec..0d15c0765 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -172,6 +172,23 @@ jobs: > workflow-package/.package-provenance rm -rf workflow-package/.git + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + + # pip also trusts the runner's own store (truststore). Confine every + # later pip in this job to the bootstrapped pip's bundle once it is + # proven clean. + - name: Confine pip to its verified CA bundle + run: | + bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" + python scripts/ci/blocked_roots.py "$bundle" + { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + - name: Run perf harness result-gate tests run: | python3 -m py_compile scripts/perf/server_soak.py scripts/benchmark/capacity_suite.py scripts/benchmark/capacity_schema_publication.py scripts/benchmark/capacity_matrix.py tests/Unit/Support/server_soak_test.py tests/Unit/Support/capacity_benchmark_test.py benchmarks/capacity/v1/bindings/python/capacity_adapter.py benchmarks/capacity/v1/collectors/local-docker/local_docker_collector.py diff --git a/scripts/ci/blocked_roots.py b/scripts/ci/blocked_roots.py new file mode 100644 index 000000000..724a49ce8 --- /dev/null +++ b/scripts/ci/blocked_roots.py @@ -0,0 +1,48 @@ +"""Refuse a CA bundle that still trusts a root withdrawn under GHSA-6q48-6c7w-9gw2. + +Usage: python blocked_roots.py BUNDLE [BUNDLE ...] + +Prints each bundle's root count and blocked subjects, and exits non-zero +when a bundle is empty or trusts a withdrawn root. Standard library only, so +it runs in any CI or container Python. +""" +import os +import re +import ssl +import sys +import tempfile + +BLOCKED = ("AAA Certificate Services", "emSign ECC Root CA - G3") + + +def blocked_roots(path): + with open(path, encoding="ascii", errors="replace") as bundle: + certificates = re.findall( + r"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----", bundle.read(), re.S + ) + counts = dict.fromkeys(BLOCKED, 0) + for certificate in certificates: + with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as handle: + handle.write(certificate) + try: + subject = ssl._ssl._test_decode_cert(handle.name)["subject"] + finally: + os.unlink(handle.name) + for name in (value for rdn in subject for key, value in rdn if key == "commonName"): + if name in counts: + counts[name] += 1 + return len(certificates), counts + + +def main(paths): + status = 0 + for path in paths: + total, counts = blocked_roots(path) + print(f"{path}: {total} roots, blocked {counts}") + if total == 0 or any(counts.values()): + status = 1 + return status + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]) if len(sys.argv) > 1 else "Usage: blocked_roots.py BUNDLE [BUNDLE ...]") diff --git a/scripts/ci/bootstrap-patched-pip.sh b/scripts/ci/bootstrap-patched-pip.sh new file mode 100755 index 000000000..bc1f6a960 --- /dev/null +++ b/scripts/ci/bootstrap-patched-pip.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Installs the hash-pinned pip 26.2.2 into the given venv before its first +# networked pip install, refuses a pip whose CA bundle still trusts a root +# withdrawn under GHSA-6q48-6c7w-9gw2, and confines the venv's later pip +# runs to that verified bundle. The wheel's SHA-256 pin keeps its own +# download safe over any trust path. This is the container counterpart of the +# central bootstrap-patched-pip action. +# +# pip normally trusts the operating system store as well as its bundle +# (truststore), and several images still ship an affected store. The venv's +# pip.conf therefore selects pip's legacy-certs mode, which trusts only the +# verified bundle. +# +# Usage: bootstrap-patched-pip.sh VENV_PYTHON (for example "$venv/bin/python") +set -eu + +python=${1:?Usage: bootstrap-patched-pip.sh VENV_PYTHON} +here=$(dirname "$0") + +"$python" -I -m pip --isolated --disable-pip-version-check install --no-deps --upgrade --quiet --no-cache-dir \ + "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" + +version=$("$python" -I -c 'import pip; print(pip.__version__)') +if [ "$version" != 26.2.2 ]; then + echo "pip 26.2.2 is required; found $version" >&2 + exit 1 +fi +bundle=$("$python" -I -c 'from pip._vendor import certifi; print(certifi.where())') +"$python" -I "$here/blocked_roots.py" "$bundle" + +prefix=$("$python" -I -c 'import sys; print(sys.prefix if sys.prefix != sys.base_prefix else "")') +if [ -z "$prefix" ]; then + echo "$python is not a venv; its pip would still trust the operating system store." >&2 + exit 1 +fi +printf '[global]\nuse-deprecated = legacy-certs\n' > "$prefix/pip.conf" diff --git a/scripts/ci/patched-pip.sh b/scripts/ci/patched-pip.sh new file mode 100755 index 000000000..ba9b45098 --- /dev/null +++ b/scripts/ci/patched-pip.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Runs pip from a fresh venv that first takes the hash-pinned pip 26.2.2 +# (GHSA-6q48-6c7w-9gw2), for containers whose own pip is unpatched or not +# writable by the build user. Arguments go to pip unchanged. +# +# Usage: patched-pip.sh install ARGS... +set -eu + +here=$(dirname "$0") +venv="$(mktemp -d)/pip" +python3 -m venv "$venv" +sh "$here/bootstrap-patched-pip.sh" "$venv/bin/python" >&2 +exec "$venv/bin/python" -m pip "$@" diff --git a/scripts/conformance/activities-published-artifacts.sh b/scripts/conformance/activities-published-artifacts.sh index 5e95a351a..07325e7b3 100755 --- a/scripts/conformance/activities-published-artifacts.sh +++ b/scripts/conformance/activities-published-artifacts.sh @@ -231,6 +231,7 @@ prepare_focused_python_sdk() { local distribution mkdir -p "$distribution_dir" if python3 -m venv "$venv" >/dev/null 2>"$install_log" \ + && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ && "$venv/bin/python" -m pip download --disable-pip-version-check --no-deps \ --dest "$distribution_dir" "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1 \ && python3 "$script_dir/distribution_identities.py" record-unique \ diff --git a/scripts/conformance/child-workflows-published-artifacts.sh b/scripts/conformance/child-workflows-published-artifacts.sh index 944a7feeb..5730a76cf 100755 --- a/scripts/conformance/child-workflows-published-artifacts.sh +++ b/scripts/conformance/child-workflows-published-artifacts.sh @@ -244,6 +244,7 @@ prepare_focused_python_sdk() { local venv="$run_root/sdk-python-typed-child-failure-venv" local install_log="$result_dir/sdk-python-typed-child-failure-install.log" if python3 -m venv "$venv" >"$install_log" 2>&1 \ + && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ && "$venv/bin/python" -m pip install --disable-pip-version-check --no-input "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1; then export DW_CHILD_WORKFLOWS_PYTHON_BIN="$venv/bin/python" return 0 diff --git a/scripts/conformance/namespaces-published-artifacts.sh b/scripts/conformance/namespaces-published-artifacts.sh index ea30df993..42fc43c1e 100755 --- a/scripts/conformance/namespaces-published-artifacts.sh +++ b/scripts/conformance/namespaces-published-artifacts.sh @@ -511,8 +511,8 @@ if ! python3 -m venv "$run_root/.venv" > "$result_dir/python-venv.log" 2>&1; the exit 1 fi # durable-workflow== is intentionally visible for public runner scanners. -if ! "$run_root/.venv/bin/python" -m pip install --upgrade pip > "$result_dir/python-pip-upgrade.log" 2>&1; then - blocked_result "pip upgrade failed; see python-pip-upgrade.log" "$started_at" +if ! sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" > "$result_dir/python-pip-upgrade.log" 2>&1; then + blocked_result "patched pip bootstrap failed; see python-pip-upgrade.log" "$started_at" exit 1 fi if ! "$run_root/.venv/bin/python" -m pip install "durable-workflow==$python_sdk_version" > "$result_dir/python-sdk-install.log" 2>&1; then diff --git a/scripts/conformance/nexus-published-artifacts.sh b/scripts/conformance/nexus-published-artifacts.sh index 4e88eedae..e843c31d5 100755 --- a/scripts/conformance/nexus-published-artifacts.sh +++ b/scripts/conformance/nexus-published-artifacts.sh @@ -2256,6 +2256,13 @@ async function startPythonSdkService(compose, versions) { const scriptPath = path.join(compose.runRoot, 'nexus-python-sdk-service.py'); const containerName = `${compose.project}-python-sdk-service`; fs.writeFileSync(scriptPath, pythonPublishedServiceScript()); + // The service venv takes the hash-pinned pip and trusts only its verified + // bundle before installing the SDK (GHSA-6q48-6c7w-9gw2). + const pipHelpers = path.join(compose.runRoot, 'dw-ci'); + fs.mkdirSync(pipHelpers, {recursive: true}); + for (const helper of ['bootstrap-patched-pip.sh', 'blocked_roots.py']) { + fs.copyFileSync(path.join(path.dirname(process.argv[5]), '..', 'ci', helper), path.join(pipHelpers, helper)); + } dockerRunDetached([ '--name', containerName, '--network', dockerComposeNetwork(compose.project), @@ -2266,7 +2273,7 @@ async function startPythonSdkService(compose, versions) { 'python:3.12-slim', 'sh', '-lc', - `python -m venv /tmp/dw-python && . /tmp/dw-python/bin/activate && pip install --no-cache-dir durable-workflow==${JSON.stringify(versions['sdk-python']).slice(1, -1)} >/tmp/dw-python-pip.log 2>&1 && python /work/nexus-python-sdk-service.py`, + `python -m venv /tmp/dw-python && sh /work/dw-ci/bootstrap-patched-pip.sh /tmp/dw-python/bin/python >/tmp/dw-python-pip.log 2>&1 && . /tmp/dw-python/bin/activate && pip install --no-cache-dir durable-workflow==${JSON.stringify(versions['sdk-python']).slice(1, -1)} >/tmp/dw-python-pip.log 2>&1 && python /work/nexus-python-sdk-service.py`, ], path.join(resultDir, 'nexus-python-sdk-service-start.log')); const health = await waitForJson(`http://127.0.0.1:${port}/health`, 120000); diff --git a/scripts/conformance/python-published-artifacts.sh b/scripts/conformance/python-published-artifacts.sh index a2c7c53bb..caa435ef1 100755 --- a/scripts/conformance/python-published-artifacts.sh +++ b/scripts/conformance/python-published-artifacts.sh @@ -474,7 +474,7 @@ fi python3 -m venv "$run_root/.venv" # shellcheck disable=SC1091 . "$run_root/.venv/bin/activate" -python -m pip install --upgrade pip > "$result_dir/pip-upgrade.log" 2>&1 +sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" > "$result_dir/pip-upgrade.log" 2>&1 mkdir -p "$run_root/python-distributions" python -m pip download --no-deps --dest "$run_root/python-distributions" \ "durable-workflow==$python_version" > "$result_dir/python-sdk-download.log" 2>&1 \ diff --git a/scripts/conformance/replay-published-artifacts.sh b/scripts/conformance/replay-published-artifacts.sh index bea69c424..93a95c60a 100755 --- a/scripts/conformance/replay-published-artifacts.sh +++ b/scripts/conformance/replay-published-artifacts.sh @@ -1651,7 +1651,7 @@ fi # shellcheck disable=SC1091 . "$run_root/python-venv/bin/activate" set +e -python -m pip install --upgrade pip > "$result_dir/python-pip-upgrade.log" 2>&1 +sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/python-venv/bin/python" > "$result_dir/python-pip-upgrade.log" 2>&1 python_pip_status=$? mkdir -p "$run_root/python-distributions" python -m pip download --no-deps --dest "$run_root/python-distributions" \ diff --git a/scripts/conformance/sagas-published-artifacts.sh b/scripts/conformance/sagas-published-artifacts.sh index 2524f8586..3135c8909 100755 --- a/scripts/conformance/sagas-published-artifacts.sh +++ b/scripts/conformance/sagas-published-artifacts.sh @@ -1102,7 +1102,7 @@ cp "$result_dir/server-image-digest.txt" "$run_root/server-image-digest.txt" python3 -m venv "$run_root/.venv" # shellcheck disable=SC1091 . "$run_root/.venv/bin/activate" -python -m pip install --upgrade pip +sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" python -m pip install "durable-workflow==$python_version" httpx mkdir -p "$run_root/php-worker" "$run_root/cli/bin" "$run_root/waterline-app" "$run_root/logs" diff --git a/scripts/conformance/server-parity/corrected-marker-adapters.sh b/scripts/conformance/server-parity/corrected-marker-adapters.sh index 677e49f88..005bd0d78 100644 --- a/scripts/conformance/server-parity/corrected-marker-adapters.sh +++ b/scripts/conformance/server-parity/corrected-marker-adapters.sh @@ -31,13 +31,13 @@ docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap= docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/$output/python250:/packages" -v "$PWD/$output:/adapters" \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /packages --report /adapters/python250-install-report.json \ -r "/source/$adapter/python250-requirements.txt" > "$output/python250-install.txt" 2>&1 docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/$output/python-current:/packages" -v "$PWD/$output:/adapters" \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /packages --report /adapters/python-current-install-report.json \ -r "/source/$adapter/python-current-requirements.txt" > "$output/python-current-install.txt" 2>&1 docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=1536m --memory-swap=1536m \ diff --git a/scripts/conformance/server-parity/legacy-marker-adapters.sh b/scripts/conformance/server-parity/legacy-marker-adapters.sh index b9e0f0fc4..547ab86e5 100644 --- a/scripts/conformance/server-parity/legacy-marker-adapters.sh +++ b/scripts/conformance/server-parity/legacy-marker-adapters.sh @@ -16,7 +16,7 @@ cp "$adapter/rust/Cargo.lock" legacy-marker-adapters/Cargo.lock docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/legacy-marker-adapters:/adapters" \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /adapters/python-packages --report /adapters/python-install-report.json \ -r "/source/$adapter/requirements.txt" > legacy-marker-adapters/python-install.txt 2>&1 image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' "$profile/artifacts.json") diff --git a/scripts/conformance/server-parity/query-authority-adapters.sh b/scripts/conformance/server-parity/query-authority-adapters.sh index 4d777cc5b..52a300cd9 100755 --- a/scripts/conformance/server-parity/query-authority-adapters.sh +++ b/scripts/conformance/server-parity/query-authority-adapters.sh @@ -19,7 +19,7 @@ cp "$cargo_target/debug/server-parity-query-authority-client" "$output/rust-clie cp scripts/conformance/server-parity/query-authority/rust/Cargo.lock "$output/Cargo.lock" docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/$output:/adapters" "$python_image" \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /adapters/python --report /adapters/python-install.json \ -r /source/scripts/conformance/server-parity/query-authority/requirements.txt >"$output/python-install.txt" 2>&1 docker run --rm -i --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ diff --git a/scripts/conformance/server-parity/sdk-direction-adapters.sh b/scripts/conformance/server-parity/sdk-direction-adapters.sh index c83efc54b..dc695a820 100644 --- a/scripts/conformance/server-parity/sdk-direction-adapters.sh +++ b/scripts/conformance/server-parity/sdk-direction-adapters.sh @@ -21,7 +21,7 @@ cp "${vcs_sources[0]}" sdk-direction-adapters/rust-sdk-vcs.json docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD:/source:ro" -v "$PWD/sdk-direction-adapters:/adapters" \ mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ --target /adapters/python-packages --report /adapters/python-install-report.json \ -r "/source/$adapter/requirements.txt" > sdk-direction-adapters/python-install.txt 2>&1 image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' "$profile/artifacts.json") diff --git a/scripts/conformance/skew-published-artifacts.sh b/scripts/conformance/skew-published-artifacts.sh index e4f766c06..ffaaa4e57 100755 --- a/scripts/conformance/skew-published-artifacts.sh +++ b/scripts/conformance/skew-published-artifacts.sh @@ -473,7 +473,7 @@ python_source="not_installed" if [[ -n "${DW_PYTHON_SDK_VERSION:-}" ]]; then if require_command python3; then if python3 -m venv "$run_root/.venv" >"$result_dir/python-install.log" 2>&1 \ - && "$run_root/.venv/bin/python" -m pip install --upgrade pip >>"$result_dir/python-install.log" 2>&1 \ + && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" >>"$result_dir/python-install.log" 2>&1 \ && "$run_root/.venv/bin/python" -m pip install "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$result_dir/python-install.log" 2>&1; then python_status="available" python_reason="" diff --git a/scripts/conformance/worker-versioning-mixed.Dockerfile b/scripts/conformance/worker-versioning-mixed.Dockerfile index 0cd59e52e..4ba2745b1 100644 --- a/scripts/conformance/worker-versioning-mixed.Dockerfile +++ b/scripts/conformance/worker-versioning-mixed.Dockerfile @@ -1,15 +1,25 @@ -FROM composer:2 AS composer -FROM node:22-slim AS node +# Fixed TLS toolchains (GHSA-6q48-6c7w-9gw2), pinned by version and digest. +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer +FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS node FROM python:3.13-slim ARG PHP_SDK_VERSION ARG PYTHON_SDK_VERSION -RUN apt-get update && apt-get install -y --no-install-recommends php-cli php-curl unzip \ - && rm -r /var/lib/apt/lists/* +COPY bootstrap-patched-pip.sh blocked_roots.py /opt/dw-ci/ +# apt checks package signatures rather than TLS trust. It refreshes the OS CA +# store, which must then exclude the withdrawn roots before any TLS download. +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates php-cli php-curl unzip \ + && rm -r /var/lib/apt/lists/* \ + && python3 /opt/dw-ci/blocked_roots.py /etc/ssl/certs/ca-certificates.crt COPY --from=composer /usr/bin/composer /usr/bin/composer COPY --from=node /usr/local/bin/node /usr/local/bin/node +# The Python SDK lives in a venv whose pip is the hash-pinned release and +# trusts only its verified bundle; python3 resolves to that venv. +ENV PATH=/opt/python/venv/bin:$PATH RUN mkdir -p /opt/php /opt/python && cd /opt/php \ && composer require --update-no-dev --no-scripts --no-plugins --no-interaction --no-progress "durable-workflow/sdk:${PHP_SDK_VERSION}" \ + && /usr/local/bin/python3 -m venv /opt/python/venv \ + && sh /opt/dw-ci/bootstrap-patched-pip.sh /opt/python/venv/bin/python \ && pip install --no-cache-dir --report /opt/python/install-report.json "durable-workflow==${PYTHON_SDK_VERSION}" COPY worker-versioning-rust-probe /fixtures/worker-versioning-rust-probe COPY worker-versioning-mixed-* /fixtures/ diff --git a/scripts/conformance/worker-versioning-rust-host-published-artifacts.sh b/scripts/conformance/worker-versioning-rust-host-published-artifacts.sh index cbd38c07e..a80d51084 100755 --- a/scripts/conformance/worker-versioning-rust-host-published-artifacts.sh +++ b/scripts/conformance/worker-versioning-rust-host-published-artifacts.sh @@ -89,6 +89,7 @@ if [[ "${DW_WV_MIXED_COHORTS:-0}" == 1 ]]; then rm -r "$result_dir/target" "$result_dir/cargo" "$result_dir/run" cp "$repo_root"/scripts/conformance/worker-versioning-mixed-* "$context/" cp "$repo_root/scripts/conformance/worker-versioning-mixed.Dockerfile" "$context/Dockerfile" + cp "$repo_root/scripts/ci/bootstrap-patched-pip.sh" "$repo_root/scripts/ci/blocked_roots.py" "$context/" docker build --tag "$resource-mixed:qualification" \ --build-arg PHP_SDK_VERSION="$DW_PHP_SDK_VERSION" \ --build-arg PYTHON_SDK_VERSION="$DW_PYTHON_SDK_VERSION" "$context" \ diff --git a/scripts/conformance/workflow-lifecycle-published-artifacts.sh b/scripts/conformance/workflow-lifecycle-published-artifacts.sh index 1ce4669b9..4834d30a1 100755 --- a/scripts/conformance/workflow-lifecycle-published-artifacts.sh +++ b/scripts/conformance/workflow-lifecycle-published-artifacts.sh @@ -925,7 +925,8 @@ PY python_bin="$venv/bin/python" executor="venv_pypi_install" - if ! "$python_bin" -m pip install --disable-pip-version-check --no-input "durable-workflow==${sdk_version}" >"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1; then + if ! sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$python_bin" >"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1 \ + || ! "$python_bin" -m pip install --disable-pip-version-check --no-input "durable-workflow==${sdk_version}" >>"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1; then local install_error install_error="$(python_sdk_last_log_line "$result_dir/python-sdk-lifecycle-pip-install.log" 'pip exited without diagnostic output.')" write_python_sdk_runner_blocked \ diff --git a/scripts/conformance/workflow-updates-published-artifacts.sh b/scripts/conformance/workflow-updates-published-artifacts.sh index 123d987f2..ace7fb9ef 100755 --- a/scripts/conformance/workflow-updates-published-artifacts.sh +++ b/scripts/conformance/workflow-updates-published-artifacts.sh @@ -3027,8 +3027,15 @@ run_python_sdk_shard() { write_python_sdk_shard_status runner_blocked "pip is required inside the disposable Python SDK shard environment; see python-sdk-pip-version.log." pip_unavailable true return 0 fi + if ! sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv_python" >> "$result_dir/python-sdk-pip-version.log" 2>&1; then + write_python_sdk_shard_status runner_blocked "The Python SDK shard could not install the patched pip; see python-sdk-pip-version.log." pip_unavailable true + return 0 + fi + # --isolated skips the venv's pip.conf, so confine trust to pip's verified + # bundle here too. if ! PIP_CONFIG_FILE=/dev/null "$venv_python" -m pip --isolated install \ + --use-deprecated=legacy-certs \ --disable-pip-version-check \ --no-input \ --index-url https://pypi.org/simple \ diff --git a/scripts/perf/run-namespace-isolation.sh b/scripts/perf/run-namespace-isolation.sh index ee82071fa..b0470be0e 100755 --- a/scripts/perf/run-namespace-isolation.sh +++ b/scripts/perf/run-namespace-isolation.sh @@ -152,6 +152,7 @@ if ! curl --fail --silent --max-time 2 "$BASE_URL/api/ready" >/dev/null; then fi python3 -m venv "$VENV" +sh "$ROOT_DIR/scripts/ci/bootstrap-patched-pip.sh" "$VENV/bin/python" "$VENV/bin/pip" install --quiet --disable-pip-version-check "durable-workflow==$SDK_VERSION" "$VENV/bin/python" "$ROOT_DIR/scripts/perf/namespace_isolation.py" \ --base-url "$BASE_URL" \ diff --git a/tests/Fixtures/UpdateRequestKeyConsumer/compose.yml b/tests/Fixtures/UpdateRequestKeyConsumer/compose.yml index d780bf0f9..bc81b5475 100644 --- a/tests/Fixtures/UpdateRequestKeyConsumer/compose.yml +++ b/tests/Fixtures/UpdateRequestKeyConsumer/compose.yml @@ -67,6 +67,7 @@ services: command: - | python -m venv /opt/venv + sh /dw-ci/bootstrap-patched-pip.sh /opt/venv/bin/python /opt/venv/bin/pip install --index-url https://pypi.org/simple --no-cache-dir --quiet durable-workflow==2.6.1 exec sleep infinity tmpfs: @@ -81,6 +82,7 @@ services: - ./consumer.py:/fixture/consumer.py:ro - ./definition.py:/fixture/definition.py:ro - ./intake_limits.py:/fixture/intake_limits.py:ro + - ../../../scripts/ci:/dw-ci:ro depends_on: api: condition: service_healthy From 4954fe8aa30be1a0592ef717d9b130066ae7573f Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 17:09:52 +0000 Subject: [PATCH 02/21] Cover the remaining pip paths and pass the workflow policies - The capacity benchmark's Python worker installs through a bootstrapped venv first on PATH instead of the image's pip 24.0. - The StorageAdmission Rust stage resolves its unlocked crates through the Server image's checked CA store, not rust:1.86's. - The published SDK adapter build moves into a script, like its sibling adapter builds, so the Docker isolation policy can see the helper call. - Central action pins carry their '# patched-pip' comment, and job environment writes use one echo per line. - The helm confine steps no longer run a repository script; the central action already checks that bundle. - New stdlib tests prove the root counter and bootstrap refuse withdrawn roots, other pip versions and non-venv interpreters, and that every pip install in CI, scripts and fixtures goes through the helpers. - The staged replay runner test stages the bootstrap and asserts the runner hands it the run venv. --- .github/workflows/helm-chart-checks.yml | 17 +- .github/workflows/phpunit-feature.yml | 5 +- .github/workflows/server-parity.yml | 31 +-- .github/workflows/server-perf.yml | 9 +- benchmarks/capacity/v1/README.md | 7 +- .../v1/topologies/local-docker/compose.json | 5 +- .../published-sdk-patch-adapters.sh | 31 +++ .../StorageAdmission/sdk/Dockerfile | 6 + .../PhpunitFeatureWorkflowContractTest.php | 1 + .../ReplayConformanceRunnerContractTest.php | 15 ++ tests/Unit/Support/patched_pip_test.py | 189 ++++++++++++++++++ 11 files changed, 268 insertions(+), 48 deletions(-) create mode 100644 scripts/conformance/server-parity/published-sdk-patch-adapters.sh create mode 100644 tests/Unit/Support/patched_pip_test.py diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 9d757e76d..050216418 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -67,16 +67,15 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip # pip also trusts the runner's own store (truststore). Confine every - # later pip in this job to the bootstrapped pip's bundle once it is - # proven clean. + # later pip in this job to the bundle the bootstrap action checked. - name: Confine pip to its verified CA bundle run: | bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" - python scripts/ci/blocked_roots.py "$bundle" - { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + echo "PIP_CERT=$bundle" >> "$GITHUB_ENV" + echo "PIP_USE_DEPRECATED=legacy-certs" >> "$GITHUB_ENV" - name: Check database CA mounts and bootstrap configuration run: | @@ -154,16 +153,16 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip # pip also trusts the runner's own store (truststore). Confine every # later pip in this job, including ones that actions run in their own - # venvs, to the bootstrapped pip's bundle once it is proven clean. + # venvs, to the bundle the bootstrap action checked. - name: Confine pip to its verified CA bundle run: | bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" - python scripts/ci/blocked_roots.py "$bundle" - { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + echo "PIP_CERT=$bundle" >> "$GITHUB_ENV" + echo "PIP_USE_DEPRECATED=legacy-certs" >> "$GITHUB_ENV" - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index e19fc28eb..442a13381 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -42,7 +42,7 @@ jobs: with: python-version: "3.13" - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip - name: Install policy dependencies run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits @@ -108,6 +108,9 @@ jobs: - name: Prove corpus policy guards cannot be weakened run: python scripts/ci/test-regression-corpus-policy.py + - name: Prove pip starts from the patched, root-checked bootstrap + run: python3 -m unittest tests/Unit/Support/patched_pip_test.py + feature: name: PHPUnit feature suite needs: action-policy diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 6c71fa15e..842c85ef8 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -114,36 +114,7 @@ jobs: steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Build the locked published SDK adapter once - run: | - set -euo pipefail - mkdir -p sdk-cargo sdk-target published-sdk-adapters/python-packages - docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=2g --memory-swap=2g \ - -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ - -e CARGO_INCREMENTAL=0 -e CARGO_PROFILE_DEV_DEBUG=0 \ - -v "$PWD:/source:ro" -v "$PWD/sdk-cargo:/cargo" -v "$PWD/sdk-target:/target" \ - -w /source/scripts/conformance/server-parity/published-sdk-patch/rust \ - mirror.gcr.io/library/rust@sha256:300ec56abce8cc9448ddea2172747d048ed902a3090e6b57babb2bf19f754081 \ - cargo build --locked > published-sdk-adapters/rust-sdk-build.txt 2>&1 - cp sdk-target/debug/server-parity-published-rust-patch published-sdk-adapters/rust-worker - cp scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock published-sdk-adapters/Cargo.lock - docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ - -v "$PWD:/source:ro" -v "$PWD/published-sdk-adapters:/adapters" \ - mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ - sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ - --target /adapters/python-packages --report /adapters/python-install-report.json \ - -r /source/scripts/conformance/server-parity/published-sdk-patch/requirements.txt \ - > published-sdk-adapters/python-install.txt 2>&1 - image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityProfiles/published-sdk-patch-replay/artifacts.json) - docker run --rm --user="$(id -u):$(id -g)" --network=none --entrypoint node \ - -v "$PWD:/source:ro" -v "$PWD/published-sdk-adapters:/adapters" -w /source "$image" \ - scripts/conformance/server-parity/published-sdk-patch/identity.mjs /adapters \ - tests/Fixtures/ServerParityProfiles/published-sdk-patch-replay/artifacts.json - printf '%s\n' "$GITHUB_SHA" > published-sdk-adapters/runner-revision.txt - sha256sum scripts/conformance/server-parity/published-sdk-patch/rust/{Cargo.toml,Cargo.lock,src/main.rs} \ - scripts/conformance/server-parity/published-sdk-patch/{python.py,requirements.txt} > published-sdk-adapters/source-hashes.sha256 - cd published-sdk-adapters - find . -type f ! -path ./SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS - sha256sum -c SHA256SUMS >/dev/null + run: bash scripts/conformance/server-parity/published-sdk-patch-adapters.sh - name: Build genuine legacy-marker adapters with the existing cache run: bash scripts/conformance/server-parity/legacy-marker-adapters.sh - name: Build fresh published SDK direction adapters with the existing cache diff --git a/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 0d15c0765..1c3d8f986 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -178,16 +178,15 @@ jobs: python-version: "3.13" - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip # pip also trusts the runner's own store (truststore). Confine every - # later pip in this job to the bootstrapped pip's bundle once it is - # proven clean. + # later pip in this job to the bundle the bootstrap action checked. - name: Confine pip to its verified CA bundle run: | bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" - python scripts/ci/blocked_roots.py "$bundle" - { echo "PIP_CERT=$bundle"; echo "PIP_USE_DEPRECATED=legacy-certs"; } >> "$GITHUB_ENV" + echo "PIP_CERT=$bundle" >> "$GITHUB_ENV" + echo "PIP_USE_DEPRECATED=legacy-certs" >> "$GITHUB_ENV" - name: Run perf harness result-gate tests run: | diff --git a/benchmarks/capacity/v1/README.md b/benchmarks/capacity/v1/README.md index 713d018d4..c88458e2d 100644 --- a/benchmarks/capacity/v1/README.md +++ b/benchmarks/capacity/v1/README.md @@ -62,9 +62,14 @@ cell coverage together with the suite: Install each adapter from its own immutable package manifest before a run: (cd benchmarks/capacity/v1/bindings/php && composer install --no-dev) - (cd benchmarks/capacity/v1/bindings/python && python3 -m pip install -r requirements.lock) + python3 -m venv /tmp/capacity-venv + sh scripts/ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python + /tmp/capacity-venv/bin/python -m pip install -r benchmarks/capacity/v1/bindings/python/requirements.lock (cd benchmarks/capacity/v1/bindings/rust && cargo build --release --locked) +Activate `/tmp/capacity-venv` before running the Python adapter. The bootstrap +installs the patched pip and limits it to its checked CA bundle. + Every adapter entrypoint implements four modes. `describe` emits its adapter contract without credentials. `conformance` evaluates the checked-in payload fixtures without contacting a runtime. `worker` registers every declared workflow, diff --git a/benchmarks/capacity/v1/topologies/local-docker/compose.json b/benchmarks/capacity/v1/topologies/local-docker/compose.json index 7bfab2985..b9628ca33 100644 --- a/benchmarks/capacity/v1/topologies/local-docker/compose.json +++ b/benchmarks/capacity/v1/topologies/local-docker/compose.json @@ -285,9 +285,10 @@ "sdk-python-worker": { "image": "docker.io/library/python:3.12.4-slim-bookworm", "platform": "linux/amd64", - "command": ["sh", "-ec", "python3 -m pip install --disable-pip-version-check --no-cache-dir --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], + "command": ["sh", "-ec", "/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], "working_dir": "/capacity", - "volumes": ["${CAPACITY_REPOSITORY_ROOT}/benchmarks/capacity/v1/bindings/python:/capacity:ro"], + "environment": {"PATH": "/tmp/capacity-venv/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"}, + "volumes": ["${CAPACITY_REPOSITORY_ROOT}/benchmarks/capacity/v1/bindings/python:/capacity:ro", "${CAPACITY_REPOSITORY_ROOT}/scripts/ci:/dw-ci:ro"], "networks": ["capacity"], "cpus": 1, "mem_limit": 1073741824, diff --git a/scripts/conformance/server-parity/published-sdk-patch-adapters.sh b/scripts/conformance/server-parity/published-sdk-patch-adapters.sh new file mode 100644 index 000000000..f3fcbc569 --- /dev/null +++ b/scripts/conformance/server-parity/published-sdk-patch-adapters.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build the locked published SDK adapter once for the server-parity job. +set -euo pipefail +profile=tests/Fixtures/ServerParityProfiles/published-sdk-patch-replay +adapter=scripts/conformance/server-parity/published-sdk-patch +mkdir -p sdk-cargo sdk-target published-sdk-adapters/python-packages +docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=2g --memory-swap=2g \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_INCREMENTAL=0 -e CARGO_PROFILE_DEV_DEBUG=0 \ + -v "$PWD:/source:ro" -v "$PWD/sdk-cargo:/cargo" -v "$PWD/sdk-target:/target" \ + -w "/source/$adapter/rust" \ + mirror.gcr.io/library/rust@sha256:300ec56abce8cc9448ddea2172747d048ed902a3090e6b57babb2bf19f754081 \ + cargo build --locked > published-sdk-adapters/rust-sdk-build.txt 2>&1 +cp sdk-target/debug/server-parity-published-rust-patch published-sdk-adapters/rust-worker +cp "$adapter/rust/Cargo.lock" published-sdk-adapters/Cargo.lock +docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD:/source:ro" -v "$PWD/published-sdk-adapters:/adapters" \ + mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c \ + sh /source/scripts/ci/patched-pip.sh install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + --target /adapters/python-packages --report /adapters/python-install-report.json \ + -r "/source/$adapter/requirements.txt" > published-sdk-adapters/python-install.txt 2>&1 +image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' "$profile/artifacts.json") +docker run --rm --user="$(id -u):$(id -g)" --network=none --entrypoint node \ + -v "$PWD:/source:ro" -v "$PWD/published-sdk-adapters:/adapters" -w /source "$image" \ + scripts/conformance/server-parity/published-sdk-patch/identity.mjs /adapters "$profile/artifacts.json" +git rev-parse HEAD > published-sdk-adapters/runner-revision.txt +sha256sum "$adapter"/rust/{Cargo.toml,Cargo.lock,src/main.rs} "$adapter"/{python.py,requirements.txt} \ + > published-sdk-adapters/source-hashes.sha256 +cd published-sdk-adapters +find . -type f ! -path ./SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS +sha256sum -c SHA256SUMS >/dev/null diff --git a/tests/Integration/StorageAdmission/sdk/Dockerfile b/tests/Integration/StorageAdmission/sdk/Dockerfile index f82d6d71b..3eb3c4c5c 100644 --- a/tests/Integration/StorageAdmission/sdk/Dockerfile +++ b/tests/Integration/StorageAdmission/sdk/Dockerfile @@ -1,6 +1,12 @@ ARG SERVER_IMAGE +FROM ${SERVER_IMAGE} AS server-trust + FROM rust:1.86 AS rust-worker ARG RUST_SDK_VERSION +# This image's CA store still holds a root withdrawn under GHSA-6q48-6c7w-9gw2. +# cargo resolves the unlocked crates through the Server image's store instead. +COPY --from=server-trust /etc/ssl/certs/ca-certificates.crt /etc/server-trust/ca-certificates.crt +ENV CARGO_HTTP_CAINFO=/etc/server-trust/ca-certificates.crt WORKDIR /build RUN cargo init --bin --name storage-sdk-worker . \ && cargo add "durable-workflow@=${RUST_SDK_VERSION}" serde_json sha2@0.10 \ diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index 14c48570a..d6fcd2cfd 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -161,6 +161,7 @@ public function test_preflight_validates_release_metadata_and_corpus_tools(): vo 'timeout-minutes: 5', 'node scripts/ci/sync-source-release.mjs --check', 'python scripts/ci/test-regression-corpus-policy.py', + 'python3 -m unittest tests/Unit/Support/patched_pip_test.py', ] as $needle) { $this->assertStringContainsString($needle, $this->workflow); } diff --git a/tests/Unit/ReplayConformanceRunnerContractTest.php b/tests/Unit/ReplayConformanceRunnerContractTest.php index 9db137392..6f5f85224 100644 --- a/tests/Unit/ReplayConformanceRunnerContractTest.php +++ b/tests/Unit/ReplayConformanceRunnerContractTest.php @@ -644,6 +644,16 @@ public function test_staged_runner_rewrites_pass_record_when_cleanup_fails_after ); copy($repoRoot.'/docker-compose.published.yml', $serverCheckout.'/docker-compose.published.yml'); + // The staged runner hands its venv to the patched-pip bootstrap; + // record that hand-off instead of downloading pip. + $pipBootstrapLog = $workspace.'/pip-bootstrap.log'; + $runVenvPython = realpath($runRoot).'/python-venv/bin/python'; + $this->mkdirp($workspace.'/scripts/ci'); + $this->writeExecutable( + $workspace.'/scripts/ci/bootstrap-patched-pip.sh', + "#!/bin/sh\nprintf '%s\\n' \"\$1\" >> ".escapeshellarg($pipBootstrapLog)."\n", + ); + $realPython = trim((string) shell_exec('command -v python3 2>/dev/null')); if ($realPython === '') { $this->markTestSkipped('python3 is required for the replay runner contract.'); @@ -1321,6 +1331,11 @@ class Handler(BaseHTTPRequestHandler): ], array_column($installArtifacts, 'status', 'artifact'), ); + $this->assertSame( + [$runVenvPython], + file($pipBootstrapLog, FILE_IGNORE_NEW_LINES), + 'the Python SDK install must start from the patched pip in the run venv', + ); $sdkPhpInstall = array_values(array_filter( $installArtifacts, static fn (array $artifact): bool => $artifact['artifact'] === 'sdk-php', diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py new file mode 100644 index 000000000..a28d0a147 --- /dev/null +++ b/tests/Unit/Support/patched_pip_test.py @@ -0,0 +1,189 @@ +"""Contracts for the patched-pip helpers (GHSA-6q48-6c7w-9gw2). + +Run with: python3 -m unittest tests/Unit/Support/patched_pip_test.py +Needs the openssl command line tool to make throwaway test roots. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +CI = ROOT / "scripts" / "ci" +PIP_WHEEL = ( + "pip-26.2.2-py3-none-any.whl#sha256=" + "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" +) +WITHDRAWN = ("AAA Certificate Services", "emSign ECC Root CA - G3") + +# A stand-in venv interpreter. It records each call, answers the bootstrap's +# questions from the environment and runs the real root counter. +FAKE_PYTHON = """#!/bin/sh +printf '%s\\n' "$*" >> "$FAKE_LOG" +case "$*" in + *"-m pip "*) exit "${FAKE_INSTALL_STATUS:-0}" ;; + *"import pip;"*) printf '%s\\n' "$FAKE_PIP_VERSION" ;; + *"import certifi"*) printf '%s\\n' "$FAKE_BUNDLE" ;; + *"sys.prefix"*) printf '%s\\n' "$FAKE_PREFIX" ;; + *) exec "$REAL_PYTHON" "$@" ;; +esac +""" + +# Files that mention a pip install without running one through the helpers. +NOT_PATCHED_PIP_INSTALLS = { + # Assertion text about pip's install report, not a command. + "scripts/conformance/server-parity/published-sdk-patch/identity.mjs", + # Descriptive evidence strings; never executed. + "scripts/conformance/worker-versioning-published-workers.mjs", + # Debian's pip in the operator-chosen Server image uses that image's OS + # store, which the Server image build checks for the withdrawn roots. + "tests/Integration/StorageAdmission/sdk/Dockerfile", +} +SCANNED = (".github/workflows", "scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") +PIP_INSTALL = re.compile(r"\bpip3?\s+install\b|-m\s+pip\s+(?:--isolated\s+)?(?:install|download)\b") + + +def make_root(directory: Path, common_name: str) -> str: + stem = directory / re.sub(r"\W+", "-", common_name) + subprocess.run( + [ + "openssl", "req", "-x509", "-nodes", "-days", "1", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", f"/CN={common_name}", + "-keyout", f"{stem}.key", "-out", f"{stem}.pem", + ], + check=True, + capture_output=True, + ) + return Path(f"{stem}.pem").read_text() + + +@unittest.skipUnless(shutil.which("openssl"), "openssl is required to make test roots") +class PatchedPipTest(unittest.TestCase): + def setUp(self) -> None: + self.work = Path(tempfile.mkdtemp(prefix="dw-patched-pip-")) + self.addCleanup(shutil.rmtree, self.work, ignore_errors=True) + self.clean = make_root(self.work, "Durable Workflow Test Root") + self.withdrawn = {name: make_root(self.work, name) for name in WITHDRAWN} + + def bundle(self, *certificates: str) -> Path: + path = Path(tempfile.mkstemp(suffix=".pem", dir=self.work)[1]) + path.write_text("".join(certificates)) + return path + + def count(self, bundle: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-I", str(CI / "blocked_roots.py"), str(bundle)], + capture_output=True, + text=True, + ) + + def bootstrap(self, **overrides: str) -> tuple[subprocess.CompletedProcess[str], Path]: + venv = self.work / "venv" + venv.mkdir(exist_ok=True) + python = self.work / "python" + python.write_text(FAKE_PYTHON) + python.chmod(0o755) + log = self.work / "calls.log" + environment = { + **os.environ, + "FAKE_LOG": str(log), + "FAKE_PIP_VERSION": "26.2.2", + "FAKE_BUNDLE": str(self.bundle(self.clean)), + "FAKE_PREFIX": str(venv), + "REAL_PYTHON": sys.executable, + **overrides, + } + result = subprocess.run( + ["sh", str(CI / "bootstrap-patched-pip.sh"), str(python)], + capture_output=True, + text=True, + env=environment, + ) + return result, venv + + def test_root_counter_accepts_a_bundle_without_withdrawn_roots(self) -> None: + result = self.count(self.bundle(self.clean, self.clean)) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("2 roots", result.stdout) + + def test_root_counter_refuses_each_withdrawn_root(self) -> None: + for name, certificate in self.withdrawn.items(): + with self.subTest(name): + result = self.count(self.bundle(self.clean, certificate)) + + self.assertEqual(1, result.returncode, result.stdout) + self.assertIn(f"'{name}': 1", result.stdout) + + def test_root_counter_refuses_an_empty_bundle(self) -> None: + self.assertEqual(1, self.count(self.bundle()).returncode) + + def test_bootstrap_installs_the_pinned_pip_and_confines_the_venv_to_its_bundle(self) -> None: + result, venv = self.bootstrap() + + self.assertEqual(0, result.returncode, result.stderr) + install = (self.work / "calls.log").read_text().splitlines()[0] + self.assertIn("-I -m pip --isolated", install) + self.assertIn("--no-deps", install) + self.assertIn(f"pip @ https://files.pythonhosted.org/", install) + self.assertTrue(install.endswith(PIP_WHEEL), install) + self.assertEqual("[global]\nuse-deprecated = legacy-certs\n", (venv / "pip.conf").read_text()) + + def test_bootstrap_refuses_another_pip_version(self) -> None: + result, venv = self.bootstrap(FAKE_PIP_VERSION="26.2.1") + + self.assertNotEqual(0, result.returncode) + self.assertIn("pip 26.2.2 is required; found 26.2.1", result.stderr) + self.assertFalse((venv / "pip.conf").exists()) + + def test_bootstrap_refuses_a_bundle_holding_a_withdrawn_root(self) -> None: + for name, certificate in self.withdrawn.items(): + with self.subTest(name): + result, venv = self.bootstrap(FAKE_BUNDLE=str(self.bundle(self.clean, certificate))) + + self.assertNotEqual(0, result.returncode) + self.assertFalse((venv / "pip.conf").exists()) + + def test_bootstrap_refuses_an_interpreter_outside_a_venv(self) -> None: + result, venv = self.bootstrap(FAKE_PREFIX="") + + self.assertNotEqual(0, result.returncode) + self.assertIn("is not a venv", result.stderr) + self.assertFalse((venv / "pip.conf").exists()) + + def test_bootstrap_stops_when_the_pinned_pip_does_not_install(self) -> None: + result, venv = self.bootstrap(FAKE_INSTALL_STATUS="1") + + self.assertNotEqual(0, result.returncode) + self.assertEqual(1, len((self.work / "calls.log").read_text().splitlines())) + self.assertFalse((venv / "pip.conf").exists()) + + +class PatchedPipInventoryTest(unittest.TestCase): + def test_every_pip_download_starts_from_the_patched_pip(self) -> None: + unpatched = [] + for directory in SCANNED: + for path in sorted((ROOT / directory).rglob("*")): + relative = path.relative_to(ROOT).as_posix() + if not path.is_file() or relative in NOT_PATCHED_PIP_INSTALLS: + continue + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + if PIP_INSTALL.search(text) and not re.search(r"bootstrap-patched-pip|patched-pip\.sh", text): + unpatched.append(relative) + + self.assertEqual([], unpatched, "run these pip installs through scripts/ci/bootstrap-patched-pip.sh") + + +if __name__ == "__main__": + unittest.main() From 3c73aa178981efc739fb78427fc28610d28a06a5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 17:23:27 +0000 Subject: [PATCH 03/21] Close the remaining pip paths and prove the confinement with real pip Security review of the previous head found executable pip paths the file-level inventory missed, and a leaked temporary venv. - worker-versioning, heartbeats, schedules, signals-queries, principal-attribution and the child-workflows install probe now start their venvs (or container pip) from the patched bootstrap. The worker-versioning PHP worker uses Composer 2.10.3 pinned by digest. - The StorageAdmission SDK image takes the helpers as a named build context and bootstraps its venv before the SDK install. - patched-pip.sh keeps pip's exit status and removes its temporary venv. - The inventory test now checks each pip command (shell, Python list and JS array forms) and each workflow job, names the one job the central policy still owns, and requires legacy-certs on --isolated commands. - A real-pip control serves HTTPS from a synthetic withdrawn root that only the OS store trusts. Unconfined pip 26.2.2 reaches it; the bootstrapped venv and the job-level PIP_CERT plus legacy-certs refuse with CERTIFICATE_VERIFY_FAILED. --- scripts/ci/patched-pip.sh | 18 +- .../child-workflows-install-probe.py | 7 +- .../heartbeats-published-artifacts.mjs | 11 +- ...incipal-attribution-published-artifacts.sh | 8 +- .../schedules-published-artifacts.mjs | 6 +- .../signals-queries-published-artifacts.sh | 6 +- .../worker-versioning-published-workers.mjs | 11 +- .../StorageAdmission/sdk/Dockerfile | 2 + .../StorageAdmission/sdk/compose.yml | 3 + tests/Unit/Support/patched_pip_test.py | 278 ++++++++++++++++-- 10 files changed, 302 insertions(+), 48 deletions(-) diff --git a/scripts/ci/patched-pip.sh b/scripts/ci/patched-pip.sh index ba9b45098..e76fe5a41 100755 --- a/scripts/ci/patched-pip.sh +++ b/scripts/ci/patched-pip.sh @@ -1,13 +1,21 @@ #!/bin/sh # Runs pip from a fresh venv that first takes the hash-pinned pip 26.2.2 # (GHSA-6q48-6c7w-9gw2), for containers whose own pip is unpatched or not -# writable by the build user. Arguments go to pip unchanged. +# writable by the build user. Arguments go to pip unchanged, pip's exit +# status is kept, and the temporary venv is removed afterwards. # # Usage: patched-pip.sh install ARGS... set -eu here=$(dirname "$0") -venv="$(mktemp -d)/pip" -python3 -m venv "$venv" -sh "$here/bootstrap-patched-pip.sh" "$venv/bin/python" >&2 -exec "$venv/bin/python" -m pip "$@" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +python3 -m venv "$work/pip" +sh "$here/bootstrap-patched-pip.sh" "$work/pip/bin/python" >&2 +status=0 +"$work/pip/bin/python" -m pip "$@" || status=$? +exit "$status" diff --git a/scripts/conformance/child-workflows-install-probe.py b/scripts/conformance/child-workflows-install-probe.py index 45fa9a115..af2d75253 100755 --- a/scripts/conformance/child-workflows-install-probe.py +++ b/scripts/conformance/child-workflows-install-probe.py @@ -27,6 +27,7 @@ RESULT_DIR = Path(sys.argv[1]) RUN_ROOT = Path(sys.argv[2]) REPO_ROOT = Path(sys.argv[3]) +PATCHED_PIP_BOOTSTRAP = Path(__file__).resolve().parent.parent / "ci" / "bootstrap-patched-pip.sh" def now() -> str: @@ -150,8 +151,11 @@ def require_version(name: str) -> str: try: create_code, create_output = run([sys.executable, "-m", "venv", str(python_venv)]) python_bin = python_venv / "bin" / "python" + # Start from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2). + bootstrap_command = ["sh", str(PATCHED_PIP_BOOTSTRAP), str(python_bin)] + bootstrap_code, bootstrap_output = run(bootstrap_command) if create_code == 0 else (1, create_output) install_command = [str(python_bin), "-m", "pip", "install", "--disable-pip-version-check", "--no-input", f"durable-workflow=={python_version}"] - install_code, install_output = run(install_command) if create_code == 0 else (1, create_output) + install_code, install_output = run(install_command) if bootstrap_code == 0 else (1, bootstrap_output) probe_command = [str(python_bin), "-c", "import importlib.metadata as m; print(m.version('durable-workflow'))"] probe_code, probe_output = run(probe_command) if install_code == 0 else (1, install_output) status = "pass" if probe_code == 0 and same_python_release(python_version, probe_output.strip()) else "fail" @@ -161,6 +165,7 @@ def require_version(name: str) -> str: f"https://pypi.org/project/durable-workflow/{python_version}/", [ {"argv": [sys.executable, "-m", "venv", str(python_venv)], "exit_code": create_code, "output": create_output[-1000:]}, + {"argv": bootstrap_command, "exit_code": bootstrap_code, "output": bootstrap_output[-1000:]}, {"argv": install_command, "exit_code": install_code, "output": install_output[-3000:]}, {"argv": probe_command, "exit_code": probe_code, "output": probe_output[-1000:]}, ], diff --git a/scripts/conformance/heartbeats-published-artifacts.mjs b/scripts/conformance/heartbeats-published-artifacts.mjs index 3da0c40b5..7e1007aee 100755 --- a/scripts/conformance/heartbeats-published-artifacts.mjs +++ b/scripts/conformance/heartbeats-published-artifacts.mjs @@ -1404,6 +1404,11 @@ function pythonRuntimeArgs() { ]; } +// pip runs from the patched, bundle-only bootstrap (GHSA-6q48-6c7w-9gw2). +function patchedPipArgs() { + return ['-v', `${path.join(REPO_ROOT, 'scripts', 'ci')}:/dw-ci:ro`]; +} + function installPythonPackage() { if (!commandExists('docker')) throw new Error('docker is required to install the pinned public Python package'); writePythonProject(); @@ -1415,8 +1420,9 @@ function installPythonPackage() { run('docker', [ 'run', '--rm', ...pythonRuntimeArgs(), + ...patchedPipArgs(), PYTHON_IMAGE, - 'python', '-m', 'pip', 'download', '--disable-pip-version-check', '--no-deps', + 'sh', '/dw-ci/patched-pip.sh', 'download', '--disable-pip-version-check', '--no-deps', '--dest', '/app/distributions', `durable-workflow==${SDK_PYTHON_VERSION}`, ], { timeout: 600_000 }); @@ -1429,8 +1435,9 @@ function installPythonPackage() { run('docker', [ 'run', '--rm', ...pythonRuntimeArgs(), + ...patchedPipArgs(), PYTHON_IMAGE, - 'python', '-m', 'pip', 'install', '--disable-pip-version-check', '--no-cache-dir', + 'sh', '/dw-ci/patched-pip.sh', 'install', '--disable-pip-version-check', '--no-cache-dir', '--target', '/app/site-packages', `/app/distributions/${distribution}`, ], { timeout: 600_000 }); diff --git a/scripts/conformance/principal-attribution-published-artifacts.sh b/scripts/conformance/principal-attribution-published-artifacts.sh index e9d3dbf25..bca2c3ef1 100755 --- a/scripts/conformance/principal-attribution-published-artifacts.sh +++ b/scripts/conformance/principal-attribution-published-artifacts.sh @@ -837,6 +837,12 @@ def smoke_python(root: Path, version: str, source: str) -> dict[str, Any]: return item("sdk-python", version, source, "runner_blocked", create_command, "python venv creation failed", output) python = venv_dir / "bin" / "python" + # Start from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2). + bootstrap_command = ["sh", os.environ["DW_PATCHED_PIP_BOOTSTRAP"], str(python)] + code, output = run(bootstrap_command, timeout=180) + if code != 0: + return item("sdk-python", version, source, "runner_blocked", bootstrap_command, "patched pip bootstrap failed", output) + install_command = [ str(python), "-m", @@ -1017,7 +1023,7 @@ if __name__ == "__main__": raise SystemExit(main()) PY -if ! python3 "$run_root/artifact-smoke.py" "$run_root/pins.json" "$result_dir/artifact-install-evidence.json" "$run_root/artifacts" "$run_root/cli/bin/dw" > "$result_dir/artifact-smoke.log" 2>&1; then +if ! DW_PATCHED_PIP_BOOTSTRAP="$repo_root/scripts/ci/bootstrap-patched-pip.sh" python3 "$run_root/artifact-smoke.py" "$run_root/pins.json" "$result_dir/artifact-install-evidence.json" "$run_root/artifacts" "$run_root/cli/bin/dw" > "$result_dir/artifact-smoke.log" 2>&1; then blocked_result "published artifact install smoke failed before producing evidence; see artifact-smoke.log" "$started_at" exit 1 fi diff --git a/scripts/conformance/schedules-published-artifacts.mjs b/scripts/conformance/schedules-published-artifacts.mjs index 1dd2f7c1e..ebb32b31f 100755 --- a/scripts/conformance/schedules-published-artifacts.mjs +++ b/scripts/conformance/schedules-published-artifacts.mjs @@ -51,6 +51,8 @@ class CadenceObservationInfrastructureError extends PublishedStackInfrastructure } const modulePath = fileURLToPath(import.meta.url); +// Python venvs start from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2). +const PATCHED_PIP_BOOTSTRAP = path.join(path.dirname(modulePath), '..', 'ci', 'bootstrap-patched-pip.sh'); const repoRoot = process.env.DW_SCHEDULES_REPO_ROOT ?? path.resolve(path.dirname(modulePath), '../..'); const resultDir = process.env.DW_SCHEDULES_RESULT_DIR @@ -2053,7 +2055,7 @@ async function installSchedulesPythonLifecycleArtifact(shardRoot, artifactVersio await execLogged('python3', ['-m', 'venv', venv], path.join(resultDir, 'schedules-python-lifecycle-venv.log')); const pythonBin = path.join(venv, 'bin', 'python'); - await execLogged(pythonBin, ['-m', 'pip', 'install', '--upgrade', 'pip'], path.join(resultDir, 'schedules-python-lifecycle-pip-upgrade.log')); + await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-python-lifecycle-pip-upgrade.log')); await execLogged( pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], @@ -6694,7 +6696,7 @@ async function installSchedulesPythonArtifact(shardRoot, artifactVersions, artif await execLogged('python3', ['-m', 'venv', venv], path.join(resultDir, 'schedules-cross-language-python-venv.log')); const pythonBin = path.join(venv, 'bin', 'python'); - await execLogged(pythonBin, ['-m', 'pip', 'install', '--upgrade', 'pip'], path.join(resultDir, 'schedules-cross-language-python-pip-upgrade.log')); + await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-cross-language-python-pip-upgrade.log')); await execLogged( pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], diff --git a/scripts/conformance/signals-queries-published-artifacts.sh b/scripts/conformance/signals-queries-published-artifacts.sh index de3a26b99..7ddbe412d 100755 --- a/scripts/conformance/signals-queries-published-artifacts.sh +++ b/scripts/conformance/signals-queries-published-artifacts.sh @@ -1892,9 +1892,11 @@ def ensure_python_sdk(run_root: Path, log_file: Path) -> tuple[str, dict[str, An raise RuntimeError("could not create Python SDK virtual environment") python_bin = venv_dir / "bin" / "python" - pip = run_command([str(python_bin), "-m", "pip", "install", "--upgrade", "pip"], log_file=log_file, timeout=180) + # Start from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2). + bootstrap = Path(os.environ["REPO_ROOT"]) / "scripts" / "ci" / "bootstrap-patched-pip.sh" + pip = run_command(["sh", str(bootstrap), str(python_bin)], log_file=log_file, timeout=180) if pip.returncode != 0: - raise RuntimeError("could not upgrade pip in Python SDK virtual environment") + raise RuntimeError("could not install the patched pip in the Python SDK virtual environment") distribution_dir = venv_dir / "distributions" distribution_dir.mkdir(parents=True, exist_ok=True) diff --git a/scripts/conformance/worker-versioning-published-workers.mjs b/scripts/conformance/worker-versioning-published-workers.mjs index f26962957..2bc7f32d1 100644 --- a/scripts/conformance/worker-versioning-published-workers.mjs +++ b/scripts/conformance/worker-versioning-published-workers.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const SHARD_SCHEMA = 'durable-workflow.v2.worker-versioning-runtime.published-worker-execution-evidence'; const REGISTRATION_SCENARIO = 'worker_registration_build_ids'; @@ -11,6 +12,10 @@ const CACHE_EVICTION_SCENARIO = 'replay_across_cache_eviction'; const NO_COMPATIBLE_SCENARIO = 'no_compatible_worker_behavior'; const CROSS_LANGUAGE_SCENARIO = 'cross_language_php_python_pinning'; const ADVERSARIAL_SCENARIO = 'adversarial_no_version_bump'; +// Fixed TLS toolchains (GHSA-6q48-6c7w-9gw2): Composer pinned by digest, and +// every venv starts from the patched, bundle-only pip. +const COMPOSER_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee'; +const PATCHED_PIP_BOOTSTRAP = fileURLToPath(new URL('../ci/bootstrap-patched-pip.sh', import.meta.url)); const resultDir = process.env.DW_WV_RESULT_DIR ?? process.cwd(); const runRoot = process.env.DW_WV_RUN_ROOT ?? resultDir; @@ -1123,7 +1128,7 @@ async function installPythonWorker(shardRoot) { runRequired('python3', ['-m', 'venv', venv], { logPath: installLog }); const pythonBin = path.join(venv, 'bin', 'python'); - runRequired(pythonBin, ['-m', 'pip', 'install', '--upgrade', 'pip'], { logPath: installLog, append: true }); + runRequired('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], { logPath: installLog, append: true }); runRequired(pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], { logPath: installLog, append: true, @@ -1148,7 +1153,7 @@ function installPhpWorker(shardRoot) { `${phpRoot}:/app`, '-w', '/app', - 'composer:2', + COMPOSER_IMAGE, 'require', '--no-interaction', '--no-progress', @@ -1186,7 +1191,7 @@ function runPhpWorker(php, input) { '/app/php', '--entrypoint', 'php', - 'composer:2', + COMPOSER_IMAGE, '/app/php/published_worker.php', containerInput, containerOutput, diff --git a/tests/Integration/StorageAdmission/sdk/Dockerfile b/tests/Integration/StorageAdmission/sdk/Dockerfile index 3eb3c4c5c..b6326e233 100644 --- a/tests/Integration/StorageAdmission/sdk/Dockerfile +++ b/tests/Integration/StorageAdmission/sdk/Dockerfile @@ -21,8 +21,10 @@ USER root RUN install -d -o 1000 -g 1000 /sdk USER 1000:1000 WORKDIR /sdk +COPY --from=ci bootstrap-patched-pip.sh blocked_roots.py /sdk/ci/ RUN composer require "durable-workflow/sdk:${PHP_SDK_VERSION}" --no-interaction --no-progress --prefer-dist \ && python3 -m venv /sdk/venv \ + && sh /sdk/ci/bootstrap-patched-pip.sh /sdk/venv/bin/python \ && /sdk/venv/bin/pip install --no-cache-dir "durable-workflow==${PYTHON_SDK_VERSION}" COPY --from=rust-worker /build/target/release/storage-sdk-worker /sdk/rust-worker COPY --chown=1000:1000 worker.php worker.py /sdk/ diff --git a/tests/Integration/StorageAdmission/sdk/compose.yml b/tests/Integration/StorageAdmission/sdk/compose.yml index b74b4cc7c..f0b52c7e4 100644 --- a/tests/Integration/StorageAdmission/sdk/compose.yml +++ b/tests/Integration/StorageAdmission/sdk/compose.yml @@ -2,6 +2,9 @@ x-sdk: &sdk image: ${COMPOSE_PROJECT_NAME}-sdk build: context: ./sdk + # The patched-pip bootstrap (GHSA-6q48-6c7w-9gw2). + additional_contexts: + ci: ../../../scripts/ci args: SERVER_IMAGE: ${SERVER_IMAGE:?Set the exact published Server image digest} PHP_SDK_VERSION: ${PHP_SDK_VERSION:?Load versions.env} diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py index a28d0a147..5d5dda6f1 100644 --- a/tests/Unit/Support/patched_pip_test.py +++ b/tests/Unit/Support/patched_pip_test.py @@ -1,17 +1,21 @@ """Contracts for the patched-pip helpers (GHSA-6q48-6c7w-9gw2). Run with: python3 -m unittest tests/Unit/Support/patched_pip_test.py -Needs the openssl command line tool to make throwaway test roots. +Needs the openssl command line tool, and network access to PyPI for the +real-pip controls, which bootstrap the hash-pinned pip 26.2.2. """ from __future__ import annotations +import http.server import os import re import shutil +import ssl import subprocess import sys import tempfile +import threading import unittest from pathlib import Path @@ -36,38 +40,46 @@ esac """ -# Files that mention a pip install without running one through the helpers. -NOT_PATCHED_PIP_INSTALLS = { - # Assertion text about pip's install report, not a command. - "scripts/conformance/server-parity/published-sdk-patch/identity.mjs", - # Descriptive evidence strings; never executed. - "scripts/conformance/worker-versioning-published-workers.mjs", - # Debian's pip in the operator-chosen Server image uses that image's OS - # store, which the Server image build checks for the withdrawn roots. - "tests/Integration/StorageAdmission/sdk/Dockerfile", +# pip installs, downloads and upgrades, in shell, YAML, Python or JS argument +# lists: "pip install", "-m pip --isolated install", "'-m', 'pip', 'install'". +PIP_COMMAND = re.compile( + r"""\bpip3?(?:['"]?\s*,?\s*['"]?--[\w-]+(?:=\S+)?)*['"]?\s*,?\s*['"]?(?:install|download)\b""" +) +PATCHED = re.compile(r"bootstrap-patched-pip|patched-pip\.sh|PATCHED_PIP_BOOTSTRAP") +SCANNED = ("scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") +# Mentions that are not executed pip commands. +NOT_EXECUTED = { + "scripts/conformance/server-parity/published-sdk-patch/identity.mjs": "assertion text about pip's install report", +} +# Workflow jobs whose pip use the repository cannot confine yet. +UNCONFINED_JOBS = { + # The central policy fixes this job's exact steps and forbids job env. + # It is confined when the central bootstrap action itself confines pip. + ("phpunit-feature.yml", "action-policy"), } -SCANNED = (".github/workflows", "scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") -PIP_INSTALL = re.compile(r"\bpip3?\s+install\b|-m\s+pip\s+(?:--isolated\s+)?(?:install|download)\b") def make_root(directory: Path, common_name: str) -> str: stem = directory / re.sub(r"\W+", "-", common_name) - subprocess.run( - [ - "openssl", "req", "-x509", "-nodes", "-days", "1", - "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", - "-subj", f"/CN={common_name}", - "-keyout", f"{stem}.key", "-out", f"{stem}.pem", - ], - check=True, - capture_output=True, + openssl( + "req", "-x509", "-nodes", "-days", "1", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", f"/CN={common_name}", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-keyout", f"{stem}.key", "-out", f"{stem}.pem", ) return Path(f"{stem}.pem").read_text() -@unittest.skipUnless(shutil.which("openssl"), "openssl is required to make test roots") -class PatchedPipTest(unittest.TestCase): +def openssl(*arguments: str) -> None: + subprocess.run(["openssl", *arguments], check=True, capture_output=True) + + +class PatchedPipHelperTest(unittest.TestCase): def setUp(self) -> None: + if not shutil.which("openssl"): + self.skipTest("openssl is required to make test roots") self.work = Path(tempfile.mkdtemp(prefix="dw-patched-pip-")) self.addCleanup(shutil.rmtree, self.work, ignore_errors=True) self.clean = make_root(self.work, "Durable Workflow Test Root") @@ -91,10 +103,9 @@ def bootstrap(self, **overrides: str) -> tuple[subprocess.CompletedProcess[str], python = self.work / "python" python.write_text(FAKE_PYTHON) python.chmod(0o755) - log = self.work / "calls.log" environment = { **os.environ, - "FAKE_LOG": str(log), + "FAKE_LOG": str(self.work / "calls.log"), "FAKE_PIP_VERSION": "26.2.2", "FAKE_BUNDLE": str(self.bundle(self.clean)), "FAKE_PREFIX": str(venv), @@ -133,7 +144,7 @@ def test_bootstrap_installs_the_pinned_pip_and_confines_the_venv_to_its_bundle(s install = (self.work / "calls.log").read_text().splitlines()[0] self.assertIn("-I -m pip --isolated", install) self.assertIn("--no-deps", install) - self.assertIn(f"pip @ https://files.pythonhosted.org/", install) + self.assertIn("pip @ https://files.pythonhosted.org/", install) self.assertTrue(install.endswith(PIP_WHEEL), install) self.assertEqual("[global]\nuse-deprecated = legacy-certs\n", (venv / "pip.conf").read_text()) @@ -167,22 +178,225 @@ def test_bootstrap_stops_when_the_pinned_pip_does_not_install(self) -> None: self.assertFalse((venv / "pip.conf").exists()) +class _RecordingHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - http.server naming + self.server.paths.append(self.path) # type: ignore[attr-defined] + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + +class RealPipTrustTest(unittest.TestCase): + """Real pip 26.2.2 against a root trusted only through the OS store. + + The "affected OS store" is SSL_CERT_FILE holding a synthetic root named + like a withdrawn one (1/1); pip's own bundle stays clean (0/0). A local + HTTPS server signed by that root records each request that gets through. + """ + + @classmethod + def setUpClass(cls) -> None: + if not shutil.which("openssl"): + raise unittest.SkipTest("openssl is required to make test roots") + cls.work = Path(tempfile.mkdtemp(prefix="dw-real-pip-")) + root = cls.work / "root" + openssl( + "req", "-x509", "-nodes", "-days", "1", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", f"/CN={WITHDRAWN[0]}", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-keyout", f"{root}.key", "-out", f"{root}.pem", + ) + leaf = cls.work / "leaf" + openssl( + "req", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", + "-keyout", f"{leaf}.key", "-out", f"{leaf}.csr", + ) + openssl( + "x509", "-req", "-days", "1", "-in", f"{leaf}.csr", + "-CA", f"{root}.pem", "-CAkey", f"{root}.key", "-CAcreateserial", + "-copy_extensions", "copy", "-out", f"{leaf}.pem", + ) + cls.affected_store = Path(f"{root}.pem") + cls.empty_store_dir = cls.work / "no-store" + cls.empty_store_dir.mkdir() + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(f"{leaf}.pem", f"{leaf}.key") + cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler) + cls.server.paths = [] # type: ignore[attr-defined] + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + cls.venv = cls.work / "venv" + subprocess.run([sys.executable, "-m", "venv", str(cls.venv)], check=True, capture_output=True) + cls.python = str(cls.venv / "bin" / "python") + bootstrap = subprocess.run( + ["sh", str(CI / "bootstrap-patched-pip.sh"), cls.python], + capture_output=True, + text=True, + env=cls.base_environment(), + ) + if bootstrap.returncode != 0: + cls.tearDownClass() + raise AssertionError(f"bootstrap failed:\n{bootstrap.stdout}{bootstrap.stderr}") + cls.pip_bundle = subprocess.run( + [cls.python, "-I", "-c", "from pip._vendor import certifi; print(certifi.where())"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + shutil.rmtree(cls.work, ignore_errors=True) + + @staticmethod + def base_environment() -> dict[str, str]: + return { + name: value + for name, value in os.environ.items() + if not name.startswith(("PIP_", "SSL_CERT_", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE")) + } + + def fetch(self, *pip_options: str, **environment: str) -> tuple[subprocess.CompletedProcess[str], list[str]]: + self.server.paths.clear() # type: ignore[attr-defined] + result = subprocess.run( + [ + self.python, "-m", "pip", *pip_options, "download", + "--disable-pip-version-check", "--no-input", "--no-deps", + "--retries", "0", "--timeout", "10", + "--dest", str(self.work / "downloads"), + "--index-url", f"https://127.0.0.1:{self.server.server_address[1]}/simple/", + "dw-trust-control", + ], + capture_output=True, + text=True, + env={ + **self.base_environment(), + "SSL_CERT_FILE": str(self.affected_store), + "SSL_CERT_DIR": str(self.empty_store_dir), + **environment, + }, + ) + return result, list(self.server.paths) # type: ignore[attr-defined] + + def assertRefused(self, result: subprocess.CompletedProcess[str], paths: list[str]) -> None: + self.assertEqual([], paths, "pip reached the server through the affected OS store") + self.assertNotEqual(0, result.returncode) + self.assertIn("CERTIFICATE_VERIFY_FAILED", result.stdout + result.stderr) + + def test_the_affected_store_is_trusted_by_unconfined_pip(self) -> None: + # Without the venv's pip.conf, pip 26.2.2 adds the OS store through + # truststore, so the synthetic withdrawn root is trusted. This proves + # the refusals below come from the confinement, not the setup. + result, paths = self.fetch(PIP_CONFIG_FILE=os.devnull) + + self.assertEqual(["/simple/dw-trust-control/"], paths, result.stdout + result.stderr) + self.assertNotIn("CERTIFICATE_VERIFY_FAILED", result.stdout + result.stderr) + + def test_the_bootstrapped_venv_refuses_the_affected_store(self) -> None: + self.assertRefused(*self.fetch()) + + def test_job_environment_confinement_refuses_the_affected_store(self) -> None: + self.assertRefused(*self.fetch( + PIP_CONFIG_FILE=os.devnull, + PIP_CERT=self.pip_bundle, + PIP_USE_DEPRECATED="legacy-certs", + )) + + def test_patched_pip_wrapper_keeps_pip_status_and_removes_its_venv(self) -> None: + temporary = self.work / "wrapper-tmp" + for arguments, expected in ((["--version"], 0), (["install", "--no-index", "dw-trust-missing"], 1)): + with self.subTest(arguments): + temporary.mkdir() + result = subprocess.run( + ["sh", str(CI / "patched-pip.sh"), *arguments], + capture_output=True, + text=True, + env={**self.base_environment(), "TMPDIR": str(temporary)}, + ) + + self.assertEqual(expected, result.returncode, result.stdout + result.stderr) + self.assertEqual([], list(temporary.iterdir())) + temporary.rmdir() + + class PatchedPipInventoryTest(unittest.TestCase): - def test_every_pip_download_starts_from_the_patched_pip(self) -> None: + def test_every_script_pip_command_follows_the_patched_bootstrap(self) -> None: unpatched = [] for directory in SCANNED: for path in sorted((ROOT / directory).rglob("*")): relative = path.relative_to(ROOT).as_posix() - if not path.is_file() or relative in NOT_PATCHED_PIP_INSTALLS: + if not path.is_file() or relative in NOT_EXECUTED or path.parent == CI: continue try: text = path.read_text() except (UnicodeDecodeError, OSError): continue - if PIP_INSTALL.search(text) and not re.search(r"bootstrap-patched-pip|patched-pip\.sh", text): - unpatched.append(relative) - - self.assertEqual([], unpatched, "run these pip installs through scripts/ci/bootstrap-patched-pip.sh") + first_bootstrap = PATCHED.search(text) + for command in PIP_COMMAND.finditer(text): + line = text[text.rfind("\n", 0, command.start()) + 1:text.find("\n", command.end())] + if "patched-pip.sh" in line: + continue + if first_bootstrap is None or first_bootstrap.start() > command.start(): + number = text.count("\n", 0, command.start()) + 1 + unpatched.append(f"{relative}:{number}: {line.strip()}") + + self.assertEqual([], unpatched, "start these pip commands from scripts/ci/bootstrap-patched-pip.sh") + + def test_isolated_pip_commands_keep_legacy_certs(self) -> None: + # --isolated ignores the venv's pip.conf, so the command itself must + # keep pip off the operating system store. + loose = [] + for directory in SCANNED: + for path in sorted((ROOT / directory).rglob("*")): + if not path.is_file() or path.parent == CI: + continue + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + for command in re.finditer(r"pip['\"]?\s*,?\s*['\"]?--isolated\b", text): + following = text[command.start():command.start() + 400] + if "legacy-certs" not in following: + loose.append(f"{path.relative_to(ROOT).as_posix()}:{text.count(chr(10), 0, command.start()) + 1}") + + self.assertEqual([], loose) + + def test_every_workflow_job_with_pip_confines_it(self) -> None: + unconfined = [] + for workflow in sorted((ROOT / ".github" / "workflows").glob("*.yml")): + text = workflow.read_text() + jobs = text.split("\njobs:\n", 1)[1] if "\njobs:\n" in text else "" + for job in re.split(r"\n(?= [\w-]+:\s*\n)", "\n" + jobs): + name = re.match(r"\s*([\w-]+):", job) + if not name or not PIP_COMMAND.search(job): + continue + key = (workflow.name, name.group(1)) + if key in UNCONFINED_JOBS: + continue + confined = "patched-pip.sh" in job or ( + "bootstrap-patched-pip@" in job and "PIP_USE_DEPRECATED=legacy-certs" in job + ) + if not confined: + unconfined.append(f"{workflow.name} job {name.group(1)}") + + self.assertEqual([], unconfined, "bootstrap pip and confine it to its checked bundle in these jobs") + + def test_known_unconfined_jobs_still_exist(self) -> None: + for workflow, job in UNCONFINED_JOBS: + text = (ROOT / ".github" / "workflows" / workflow).read_text() + self.assertRegex(text, rf"\n {re.escape(job)}:\s*\n", f"remove the stale exception for {workflow} {job}") if __name__ == "__main__": From 87818ac5db52b069acd2e763d9187dc168c38ea2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 17:50:39 +0000 Subject: [PATCH 04/21] Confine the helm jobs' pip through step env; tighten the test helpers CodeQL flags a step that runs python and writes its output to GITHUB_ENV after a dispatch-supplied checkout. The helm jobs now set PIP_USE_DEPRECATED (and, for chart-testing-action's own venv, PIP_CERT to the bootstrapped pip's checked bundle) as static step env instead. The test's pip-command pattern can no longer backtrack, and its local TLS server requires TLS 1.2 or later. --- .github/workflows/helm-chart-checks.yml | 26 +++++++++---------------- tests/Unit/Support/patched_pip_test.py | 8 +++++--- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 050216418..eeed55a4a 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -69,15 +69,11 @@ jobs: - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip - # pip also trusts the runner's own store (truststore). Confine every - # later pip in this job to the bundle the bootstrap action checked. - - name: Confine pip to its verified CA bundle - run: | - bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" - echo "PIP_CERT=$bundle" >> "$GITHUB_ENV" - echo "PIP_USE_DEPRECATED=legacy-certs" >> "$GITHUB_ENV" - - name: Check database CA mounts and bootstrap configuration + # pip also trusts the runner's own store (truststore). legacy-certs + # keeps it to the bundle the bootstrap action checked. + env: + PIP_USE_DEPRECATED: legacy-certs run: | python -m pip install --only-binary :all: PyYAML==6.0.2 python scripts/ci/test_database_tls_chart.py @@ -155,15 +151,6 @@ jobs: - name: Bootstrap patched pip trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip - # pip also trusts the runner's own store (truststore). Confine every - # later pip in this job, including ones that actions run in their own - # venvs, to the bundle the bootstrap action checked. - - name: Confine pip to its verified CA bundle - run: | - bundle="$(python -I -c 'from pip._vendor import certifi; print(certifi.where())')" - echo "PIP_CERT=$bundle" >> "$GITHUB_ENV" - echo "PIP_USE_DEPRECATED=legacy-certs" >> "$GITHUB_ENV" - - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 with: @@ -171,6 +158,11 @@ jobs: - name: Set up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 + # The action installs its tools with its own venv's pip. Point that pip + # at the bundle the bootstrap action checked, and only that bundle. + env: + PIP_CERT: ${{ env.pythonLocation }}/lib/python3.12/site-packages/pip/_vendor/certifi/cacert.pem + PIP_USE_DEPRECATED: legacy-certs with: version: ${{ env.CHART_TESTING_VERSION }} yamllint_version: ${{ env.YAMLLINT_VERSION }} diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py index 5d5dda6f1..91b9418f9 100644 --- a/tests/Unit/Support/patched_pip_test.py +++ b/tests/Unit/Support/patched_pip_test.py @@ -42,8 +42,9 @@ # pip installs, downloads and upgrades, in shell, YAML, Python or JS argument # lists: "pip install", "-m pip --isolated install", "'-m', 'pip', 'install'". +SEPARATOR = r"""[\s'",]+""" PIP_COMMAND = re.compile( - r"""\bpip3?(?:['"]?\s*,?\s*['"]?--[\w-]+(?:=\S+)?)*['"]?\s*,?\s*['"]?(?:install|download)\b""" + rf"""\bpip3?(?:{SEPARATOR}--[\w-]+(?:=[^\s'",]+)?)*{SEPARATOR}(?:install|download)\b""" ) PATCHED = re.compile(r"bootstrap-patched-pip|patched-pip\.sh|PATCHED_PIP_BOOTSTRAP") SCANNED = ("scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") @@ -229,6 +230,7 @@ def setUpClass(cls) -> None: cls.empty_store_dir.mkdir() context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(f"{leaf}.pem", f"{leaf}.key") cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler) cls.server.paths = [] # type: ignore[attr-defined] @@ -366,7 +368,7 @@ def test_isolated_pip_commands_keep_legacy_certs(self) -> None: text = path.read_text() except (UnicodeDecodeError, OSError): continue - for command in re.finditer(r"pip['\"]?\s*,?\s*['\"]?--isolated\b", text): + for command in re.finditer(rf"\bpip{SEPARATOR}--isolated\b", text): following = text[command.start():command.start() + 400] if "legacy-certs" not in following: loose.append(f"{path.relative_to(ROOT).as_posix()}:{text.count(chr(10), 0, command.start()) + 1}") @@ -386,7 +388,7 @@ def test_every_workflow_job_with_pip_confines_it(self) -> None: if key in UNCONFINED_JOBS: continue confined = "patched-pip.sh" in job or ( - "bootstrap-patched-pip@" in job and "PIP_USE_DEPRECATED=legacy-certs" in job + "bootstrap-patched-pip@" in job and re.search(r"PIP_USE_DEPRECATED[=:]\s*legacy-certs", job) ) if not confined: unconfined.append(f"{workflow.name} job {name.group(1)}") From 0caac16771e2cea02ec07e6d1a519543c8b77de5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 18:54:33 +0000 Subject: [PATCH 05/21] Pin Composer and confine Cargo and pip trust to checked stores Composer: every executable image is composer:2.10.3 at its reviewed digest, with no environment or build-argument override that can swap it. Where Composer runs in a published Server image whose store is not checked (Nexus, the signals/queries Waterline observer, the activities handoff, the server-parity profiles), it gets COMPOSER_CAFILE from the pinned image's checked store and an empty COMPOSER_CAPATH before the first package request. Runner-side and in-repo installs run composer-trust-guard.sh first. The audits run in the pinned image. composer_trust_test.py inventories every Composer sink against composer-sinks.json, fails on unpinned images and unreviewed commands, and proves with real Composer that the CA file and empty directory refuse a synthetic withdrawn root the system store trusts. Cargo: CARGO_HTTP_CAINFO adds to OpenSSL's hashed directory, so the StorageAdmission Rust stage pairs it with an empty SSL_CERT_DIR and checks the file first. cargo_trust_test.py proves both halves with real cargo. pip: the sink inventory is a reviewed manifest that fails closed on new install, download, wheel and index commands, with negative cases. The bootstrap failures stop the Python and replay runners. GHSA-6q48-6c7w-9gw2 --- .github/workflows/phpunit-feature.yml | 26 +- .github/workflows/release.yml | 4 +- .github/workflows/server-perf.yml | 9 +- .../v1/topologies/local-docker/compose.json | 2 +- .../v1/topologies/local-docker/topology.json | 2 +- scripts/ci/checked-ca.sh | 21 + scripts/ci/composer-trust-guard.sh | 31 + scripts/ci/qualify-database-tls.sh | 3 +- .../activities-published-artifacts.sh | 23 + .../heartbeats-published-artifacts.mjs | 3 +- .../heartbeats-published-artifacts.sh | 1 - .../namespaces-published-artifacts.sh | 10 +- .../conformance/nexus-published-artifacts.sh | 12 +- ...incipal-attribution-published-artifacts.sh | 16 +- .../conformance/python-published-artifacts.sh | 7 +- .../conformance/replay-published-artifacts.sh | 16 +- .../conformance/sagas-published-artifacts.sh | 12 +- .../schedules-published-artifacts.mjs | 10 +- .../corrected-marker-embedded-install.sh | 1 + .../corrected-marker-embedded-profile.sh | 3 + .../server-parity/legacy-marker-profile.sh | 3 + .../server-parity/repeated-patch-install.sh | 1 + .../server-parity/repeated-patch-profile.sh | 4 + .../signals-queries-published-artifacts.sh | 41 +- .../conformance/skew-published-artifacts.mjs | 6 +- .../conformance/skew-published-artifacts.sh | 18 +- .../worker-versioning-published-artifacts.sh | 2 +- .../workflow-updates-published-artifacts.sh | 4 + .../Dockerfile.octane-frankenphp-experiment | 7 +- .../Dockerfile.octane-openswoole-experiment | 7 + .../perf/Dockerfile.octane-swoole-experiment | 7 + scripts/perf/Dockerfile.sdk | 4 +- .../perf/Dockerfile.sdk-current-experiment | 2 +- scripts/perf/soak-fixed-envelope.compose.yml | 1 - .../StorageAdmission/sdk/Dockerfile | 19 +- .../ActivityConformanceRunnerContractTest.php | 9 +- ...NamespaceConformanceRunnerContractTest.php | 4 +- .../PhpunitFeatureWorkflowContractTest.php | 2 + ...eleaseImagePublishWorkflowContractTest.php | 4 +- .../ReplayConformanceRunnerContractTest.php | 4 +- tests/Unit/SkewRefusalMatrixContractTest.php | 2 +- tests/Unit/Support/cargo_trust_test.py | 161 +++++ tests/Unit/Support/composer-sinks.json | 590 ++++++++++++++++++ tests/Unit/Support/composer_trust_test.py | 246 ++++++++ tests/Unit/Support/patched_pip_test.py | 144 +++-- tests/Unit/Support/pip-sinks.json | 235 +++++++ ...ersioningConformanceRunnerContractTest.php | 6 +- 47 files changed, 1599 insertions(+), 146 deletions(-) create mode 100755 scripts/ci/checked-ca.sh create mode 100755 scripts/ci/composer-trust-guard.sh create mode 100644 tests/Unit/Support/cargo_trust_test.py create mode 100644 tests/Unit/Support/composer-sinks.json create mode 100644 tests/Unit/Support/composer_trust_test.py create mode 100644 tests/Unit/Support/pip-sinks.json diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 388721fc4..ce2e0b641 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -77,13 +77,18 @@ jobs: run: php scripts/ci/test-workflow-package-authority.php - name: Install worker OpenAPI validation dependencies + # In the pinned Composer image, whose CA store is checked, not with the + # runner's own store (GHSA-6q48-6c7w-9gw2). run: >- - composer install + docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer + -v "$PWD:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee + install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist + --ignore-platform-reqs - name: Require versioned worker OpenAPI evolution env: @@ -91,7 +96,9 @@ jobs: run: php scripts/ci/check-worker-openapi-evolution.php "$OPENAPI_BASE_REF" - name: Audit locked PHP dependencies - run: composer audit --locked --no-interaction + # In the pinned Composer image, whose CA store is checked (GHSA-6q48-6c7w-9gw2). + run: >- + docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-interaction - name: Require durable codec evidence env: @@ -111,6 +118,16 @@ jobs: - name: Prove pip starts from the patched, root-checked bootstrap run: python3 -m unittest tests/Unit/Support/patched_pip_test.py + - name: Prove cargo trusts only a checked CA file and an empty directory + env: + DW_REQUIRE_CARGO_TRUST_TEST: '1' + run: python3 -m unittest tests/Unit/Support/cargo_trust_test.py + + - name: Prove Composer runs pinned or confined to a checked CA file + env: + DW_REQUIRE_COMPOSER_TRUST_TEST: '1' + run: python3 -m unittest tests/Unit/Support/composer_trust_test.py + feature: name: PHPUnit feature suite needs: action-policy @@ -132,8 +149,6 @@ jobs: - name: Cache public mirrored build inputs under their canonical image names run: | - docker pull mirror.gcr.io/library/composer:2 - docker tag mirror.gcr.io/library/composer:2 composer:2 docker pull mirror.gcr.io/library/php:8.3-apache docker tag mirror.gcr.io/library/php:8.3-apache php:8.3-apache @@ -187,7 +202,8 @@ jobs: FROM durable-workflow-server-phpunit-base RUN apt-get update \ && apt-get install -y --no-install-recommends git \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && server-verify-trust final DOCKERFILE docker network create \ "dw-server-feature-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c635e430..6011bef91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,9 @@ jobs: printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - name: Audit immutable PHP release dependencies - run: composer audit --working-dir=release-source --locked --no-dev --no-interaction + # In the pinned Composer image, whose CA store is checked (GHSA-6q48-6c7w-9gw2). + run: >- + docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction - name: Validate release publish context and credentials id: release_publish diff --git a/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 1c3d8f986..3b31d758c 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -197,9 +197,10 @@ jobs: - name: Compile exact first-party capacity adapters run: | php -l benchmarks/capacity/v1/bindings/php/capacity_adapter.php - composer install \ - --working-dir=benchmarks/capacity/v1/bindings/php \ - --no-dev --no-interaction --no-progress --prefer-dist + # The pinned Composer image's checked CA store, not the runner's. + docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD:/app" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ + install --no-dev --no-interaction --no-progress --prefer-dist --ignore-platform-reqs DURABLE_WORKFLOW_RUNTIME_URL=http://127.0.0.1 \ DURABLE_WORKFLOW_TASK_QUEUE=capacity-check \ php benchmarks/capacity/v1/bindings/php/capacity_adapter.php check @@ -231,7 +232,7 @@ jobs: run: | # Stream sources into the test container so containerized runners avoid host bind-path assumptions. tar --exclude=.git --exclude=vendor --exclude=build -cf - . \ - | docker run --rm -i -w /app composer:2 \ + | docker run --rm -i -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ sh -lc 'tar -xf - -C /app && cp -a /app/workflow-package /workflow && php scripts/ci/prepare-release-workflow-composer-metadata.php && composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist && composer install --no-interaction --no-progress --prefer-dist && vendor/bin/phpunit tests/Unit/BoundedGrowthPolicyTest.php tests/Unit/ServerPerfHarnessContractTest.php --colors=never' - name: Clean up capacity contract Docker resources diff --git a/benchmarks/capacity/v1/topologies/local-docker/compose.json b/benchmarks/capacity/v1/topologies/local-docker/compose.json index b9628ca33..736d434f4 100644 --- a/benchmarks/capacity/v1/topologies/local-docker/compose.json +++ b/benchmarks/capacity/v1/topologies/local-docker/compose.json @@ -10,7 +10,7 @@ "restart": "no" }, "composer-installer": { - "image": "docker.io/library/composer:2.10.2", + "image": "docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "platform": "linux/amd64", "network_mode": "bridge", "working_dir": "/capacity", diff --git a/benchmarks/capacity/v1/topologies/local-docker/topology.json b/benchmarks/capacity/v1/topologies/local-docker/topology.json index 87d48f6a7..657ce1af0 100644 --- a/benchmarks/capacity/v1/topologies/local-docker/topology.json +++ b/benchmarks/capacity/v1/topologies/local-docker/topology.json @@ -23,7 +23,7 @@ "rust": "sdk-rust-worker" }, "installation_images": { - "composer": "docker.io/library/composer:2.10.2", + "composer": "docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "docker_cli": "docker.io/library/docker:27.5.1-cli" }, "cleanup": { diff --git a/scripts/ci/checked-ca.sh b/scripts/ci/checked-ca.sh new file mode 100755 index 000000000..42b7fe17a --- /dev/null +++ b/scripts/ci/checked-ca.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Writes a checked CA file and an empty CA directory for tools that run in +# an image whose own store is not checked (GHSA-6q48-6c7w-9gw2). The file is +# the CA store of the pinned Composer image, refused if it holds a withdrawn +# root. Point Composer at both with COMPOSER_CAFILE=DIR/ca.pem and +# COMPOSER_CAPATH=DIR/no-ca-directory; a CA file alone adds to curl's +# compiled CA directory instead of replacing it. +# +# Usage: checked-ca.sh DIR +set -eu + +here=$(dirname "$0") +directory=${1:?Usage: checked-ca.sh DIR} +image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee + +mkdir -p "$directory/no-ca-directory" +docker run --rm --network none --entrypoint cat "$image" /etc/ssl/certs/ca-certificates.crt > "$directory/ca.pem.tmp" +python3 -I "$here/blocked_roots.py" "$directory/ca.pem.tmp" >&2 +mv "$directory/ca.pem.tmp" "$directory/ca.pem" +chmod 0644 "$directory/ca.pem" +chmod 0555 "$directory/no-ca-directory" diff --git a/scripts/ci/composer-trust-guard.sh b/scripts/ci/composer-trust-guard.sh new file mode 100755 index 000000000..582544ce2 --- /dev/null +++ b/scripts/ci/composer-trust-guard.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Refuses unless the CA trust Composer is about to use excludes the roots +# withdrawn under GHSA-6q48-6c7w-9gw2. Run it before a Composer request in +# an image whose store is not otherwise checked. +# +# With COMPOSER_CAFILE and COMPOSER_CAPATH (see checked-ca.sh), Composer +# trusts only that file, so the directory must be empty and the file clean. +# Without them, PHP's curl reads the system bundle and also the hashed +# system directory, so both are checked. +# +# Usage: composer-trust-guard.sh +set -eu + +here=$(dirname "$0") +if [ -n "${COMPOSER_CAFILE:-}" ] || [ -n "${COMPOSER_CAPATH:-}" ]; then + if [ -z "${COMPOSER_CAFILE:-}" ] || [ -z "${COMPOSER_CAPATH:-}" ]; then + echo "Set COMPOSER_CAFILE and COMPOSER_CAPATH together; a CA file alone adds to curl's CA directory." >&2 + exit 1 + fi + if [ -n "$(ls -A "$COMPOSER_CAPATH")" ]; then + echo "COMPOSER_CAPATH must be an empty directory." >&2 + exit 1 + fi + exec python3 -I "$here/blocked_roots.py" "$COMPOSER_CAFILE" +fi + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +find -L /etc/ssl/certs -maxdepth 1 -type f \( -name '*.pem' -o -name '*.crt' -o -name '*.[0-9]' \) \ + -exec cat {} + > "$work/directory.pem" +python3 -I "$here/blocked_roots.py" /etc/ssl/certs/ca-certificates.crt "$work/directory.pem" diff --git a/scripts/ci/qualify-database-tls.sh b/scripts/ci/qualify-database-tls.sh index 16741714f..a878f3a03 100644 --- a/scripts/ci/qualify-database-tls.sh +++ b/scripts/ci/qualify-database-tls.sh @@ -88,7 +88,8 @@ docker run --rm --name "$TLS_TEST_PROJECT-certificates" \ openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile server.ext -out server.pem >/dev/null 2>&1 ' if [[ "$TLS_TEST_MODE" = source ]]; then - compose run --rm --no-deps php composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist + # Refuses before any package request unless the image's CA store is checked (GHSA-6q48-6c7w-9gw2). + compose run --rm --no-deps php sh -c 'sh scripts/ci/composer-trust-guard.sh && composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist' fi compose up --wait --wait-timeout 240 tls-pg tls-mysql tls-maria redis diff --git a/scripts/conformance/activities-published-artifacts.sh b/scripts/conformance/activities-published-artifacts.sh index 07325e7b3..782877816 100755 --- a/scripts/conformance/activities-published-artifacts.sh +++ b/scripts/conformance/activities-published-artifacts.sh @@ -154,10 +154,32 @@ run_in_published_server_container() { runner_args+=(--keep-run-root) fi + # Composer inside the published image trusts only the pinned Composer + # image's CA store and an empty directory (GHSA-6q48-6c7w-9gw2), whichever + # copy of this runner the image holds. The digest fixes that store's + # content; composer_trust_test.py checks it, and so does blocked_roots.py + # when this runner sits in a checkout. + local trust_dir="$result_dir/.composer-trust" + mkdir -p "$trust_dir/no-ca-directory" + docker run --rm --network none --entrypoint cat \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ + /etc/ssl/certs/ca-certificates.crt > "$trust_dir/ca.pem" + if [[ ! -s "$trust_dir/ca.pem" ]]; then + printf '%s\n' 'could not read the pinned Composer CA store' >&2 + return 2 + fi + if [[ -f "$script_dir/../ci/blocked_roots.py" ]]; then + python3 -I "$script_dir/../ci/blocked_roots.py" "$trust_dir/ca.pem" >&2 || return 2 + fi + chmod 0555 "$trust_dir/no-ca-directory" + docker run --rm \ --user "$host_user" \ --entrypoint bash \ -v "$result_dir:/result" \ + -v "$result_dir/.composer-trust:/composer-trust:ro" \ + -e COMPOSER_CAFILE=/composer-trust/ca.pem \ + -e COMPOSER_CAPATH=/composer-trust/no-ca-directory \ "${docker_env[@]}" \ "$server_image" \ /app/scripts/conformance/activities-published-artifacts.sh \ @@ -365,6 +387,7 @@ prepare_published_php_activity_artifacts() { if ! ( cd "$project_dir" + sh "$script_dir/../ci/composer-trust-guard.sh" && COMPOSER_HOME="$run_root/composer-home" \ COMPOSER_CACHE_DIR="$composer_cache" \ composer install --no-interaction --no-progress --prefer-dist --no-scripts diff --git a/scripts/conformance/heartbeats-published-artifacts.mjs b/scripts/conformance/heartbeats-published-artifacts.mjs index 7e1007aee..49505f849 100755 --- a/scripts/conformance/heartbeats-published-artifacts.mjs +++ b/scripts/conformance/heartbeats-published-artifacts.mjs @@ -60,7 +60,8 @@ const STALE_WORKER_ID = `heartbeat-${CELL}-stale-${SUFFIX}`; const FRESH_WORKER_ID = `heartbeat-${CELL}-fresh-${SUFFIX}`; const WORKFLOW_TYPE = `conformance.heartbeat.${CELL}`; const TOKEN = env('DW_HEARTBEATS_AUTH_TOKEN') || 'dev-token'; -const PHP_IMAGE = env('DW_HEARTBEATS_PHP_IMAGE') || 'composer:2'; +// The pinned image's CA store is checked (GHSA-6q48-6c7w-9gw2). +const PHP_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee'; const PYTHON_IMAGE = env('DW_HEARTBEATS_PYTHON_IMAGE') || 'python:3.12-slim'; const RUST_IMAGE = env('DW_HEARTBEATS_RUST_IMAGE') || 'rust:1.86.0-slim-bookworm'; const SERVER_VERSION = env('DW_SERVER_VERSION'); diff --git a/scripts/conformance/heartbeats-published-artifacts.sh b/scripts/conformance/heartbeats-published-artifacts.sh index 6e4124fdd..9b015a6ed 100755 --- a/scripts/conformance/heartbeats-published-artifacts.sh +++ b/scripts/conformance/heartbeats-published-artifacts.sh @@ -24,7 +24,6 @@ Optional overrides: DW_HEARTBEATS_NAMESPACE Defaults to heartbeats-conformance. DW_HEARTBEATS_SHARED_SERVER_STATE Reuse a verified wave bootstrap receipt; requires its prescribed PHP namespace. - DW_HEARTBEATS_PHP_IMAGE Defaults to composer:2. DW_HEARTBEATS_SERVER_HOST Host-side server address; defaults to 127.0.0.1. DW_HEARTBEATS_HEARTBEAT_SECONDS Self-started server cadence; defaults to 2. DW_HEARTBEATS_STALE_AFTER_SECONDS Self-started server stale window; defaults to 7. diff --git a/scripts/conformance/namespaces-published-artifacts.sh b/scripts/conformance/namespaces-published-artifacts.sh index 42fc43c1e..a860d3925 100755 --- a/scripts/conformance/namespaces-published-artifacts.sh +++ b/scripts/conformance/namespaces-published-artifacts.sh @@ -968,7 +968,7 @@ PY set +e docker run --rm --user "$host_uid_gid" -e HOME=/tmp -e COMPOSER_HOME=/tmp/composer \ - -v "$waterline_app:/app" -w /app composer:2 \ + -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer create-project laravel/laravel . --no-interaction --no-progress \ > "$result_dir/waterline-create-project.log" 2>&1 waterline_create_status=$? @@ -984,7 +984,7 @@ PY set +e docker run --rm --user "$host_uid_gid" -e HOME=/tmp -e COMPOSER_HOME=/tmp/composer \ - -v "$waterline_app:/app" -w /app composer:2 \ + -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress \ "durable-workflow/waterline:${waterline_version}@beta" \ "durable-workflow/workflow:${workflow_php_version}@beta" \ @@ -1004,7 +1004,7 @@ PY -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan key:generate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \ > "$result_dir/waterline-key-generate.log" 2>&1 waterline_key_status=$? set -e @@ -1020,7 +1020,7 @@ PY -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan migrate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \ > "$result_dir/waterline-migrate.log" 2>&1 waterline_migrate_status=$? set -e @@ -1037,7 +1037,7 @@ PY -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan waterline:namespace-conformance \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:namespace-conformance \ --run-id "published-namespaces-${RUN_ID:-waterline}" \ "${waterline_artifact_args[@]}" \ --output /result/waterline-namespace-result.json \ diff --git a/scripts/conformance/nexus-published-artifacts.sh b/scripts/conformance/nexus-published-artifacts.sh index e843c31d5..8dc331ab9 100755 --- a/scripts/conformance/nexus-published-artifacts.sh +++ b/scripts/conformance/nexus-published-artifacts.sh @@ -1730,7 +1730,7 @@ function publishedCrossLanguageWorkerExecution(versions, sources, verification, role: 'sdk_php_remote_client', sdk_language: 'sdk-php', package_version: runtimeEvidence.php?.sdk_package_version || versions['sdk-php'], - container_image: runtimeEvidence.php?.container_image || 'composer:2', + container_image: runtimeEvidence.php?.container_image || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', service_started: phpServiceHealth.health_succeeded === true, public_service_call_surface: reflectedPublicServiceCallSurface(runtimeEvidence.php), caller_workflow_invocation: callerWorkflowInvocationEvidence('sdk-php', runtimeEvidence.php), @@ -1739,7 +1739,7 @@ function publishedCrossLanguageWorkerExecution(versions, sources, verification, role: 'workflow_php_runtime_service', sdk_language: 'workflow-php', package_version: runtimeEvidence.php?.package_version || versions.workflow, - container_image: runtimeEvidence.php?.container_image || 'composer:2', + container_image: runtimeEvidence.php?.container_image || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', service_started: phpServiceHealth.health_succeeded === true, service_health_succeeded: phpServiceHealth.health_succeeded === true, service_health: phpServiceHealth, @@ -2285,6 +2285,12 @@ async function startPhpWorkflowService(compose, versions) { const scriptPath = path.join(compose.runRoot, 'nexus-php-workflow-service.php'); const containerName = `${compose.project}-php-workflow-service`; fs.writeFileSync(scriptPath, phpPublishedServiceScript()); + // Composer in the published Server image trusts only a checked CA file and + // an empty directory, whatever that image's store holds (GHSA-6q48-6c7w-9gw2). + runLogged('sh', [ + path.join(path.dirname(process.argv[5]), '..', 'ci', 'checked-ca.sh'), + path.join(compose.runRoot, 'composer-trust'), + ], path.join(resultDir, 'nexus-php-composer-trust.log')); dockerRunDetached([ '--name', containerName, '--network', dockerComposeNetwork(compose.project), @@ -2292,6 +2298,8 @@ async function startPhpWorkflowService(compose, versions) { '--user', '1000:1000', '--entrypoint', 'sh', '-e', 'COMPOSER_HOME=/tmp/dw-composer', + '-e', 'COMPOSER_CAFILE=/work/composer-trust/ca.pem', + '-e', 'COMPOSER_CAPATH=/work/composer-trust/no-ca-directory', '-p', `127.0.0.1:${port}:8092`, '-v', `${compose.runRoot}:/work:ro`, serverImage(), diff --git a/scripts/conformance/principal-attribution-published-artifacts.sh b/scripts/conformance/principal-attribution-published-artifacts.sh index bca2c3ef1..b4a278675 100755 --- a/scripts/conformance/principal-attribution-published-artifacts.sh +++ b/scripts/conformance/principal-attribution-published-artifacts.sh @@ -895,7 +895,7 @@ def run_in_composer_container(root: Path, workdir: Path, command: list[str], *, "COMPOSER_HOME=/work/composer-home", "-e", "COMPOSER_CACHE_DIR=/work/composer-cache", - "composer:2", + "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", *command, ] @@ -1411,7 +1411,7 @@ waterline_key_status=not_invoked waterline_migrate_status=not_invoked waterline_command_status=not_invoked waterline_report_validation_status=not_invoked -if docker run --rm --user "$(id -u):$(id -g)" -v "$waterline_app:/app" -w /app composer:2 \ +if docker run --rm --user "$(id -u):$(id -g)" -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer create-project laravel/laravel . --no-interaction --no-progress \ > "$result_dir/waterline-create-project.log" 2>&1; then waterline_create_status=0 @@ -1423,7 +1423,7 @@ if [[ "$waterline_create_status" == 0 ]]; then mkdir -p "$waterline_app/database" : > "$waterline_app/database/database.sqlite" - if docker run --rm --user "$(id -u):$(id -g)" -v "$waterline_app:/app" -w /app composer:2 \ + if docker run --rm --user "$(id -u):$(id -g)" -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress \ "durable-workflow/waterline:${waterline_version}@beta" \ "durable-workflow/workflow:${workflow_php_version}@beta" \ @@ -1444,7 +1444,7 @@ if [[ "$waterline_require_status" == 0 ]]; then -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan key:generate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \ > "$result_dir/waterline-key-generate.log" 2>&1; then waterline_key_status=0 else @@ -1461,7 +1461,7 @@ if [[ "$waterline_key_status" == 0 ]]; then -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan migrate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \ > "$result_dir/waterline-migrate.log" 2>&1; then waterline_migrate_status=0 else @@ -1479,7 +1479,7 @@ if [[ "$waterline_migrate_status" == 0 ]]; then -e DB_DATABASE=/app/database/database.sqlite \ -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ - composer:2 php artisan waterline:principal-attribution-conformance \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:principal-attribution-conformance \ --run-id "published-principal-${RUN_ID:-waterline}" \ "${waterline_artifact_args[@]}" \ --output /result/waterline-principal-attribution-result.json \ @@ -2135,7 +2135,7 @@ def run_php_code(code: str, env: dict[str, str], *, timeout: int = 45) -> subpro return subprocess.CompletedProcess( [PHP_BIN, "-r", ""], 127, - stdout=f"PHP binary missing ({PHP_BIN}) and docker is unavailable for composer:2 fallback", + stdout=f"PHP binary missing ({PHP_BIN}) and docker is unavailable for composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee fallback", ) app_dir = PHP_SDK_AUTOLOAD.parent.parent @@ -2162,7 +2162,7 @@ def run_php_code(code: str, env: dict[str, str], *, timeout: int = 45) -> subpro ] for name, value in container_env.items(): docker_command.extend(["-e", f"{name}={value}"]) - docker_command.extend(["composer:2", "php", "-r", code]) + docker_command.extend(["composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "php", "-r", code]) return subprocess.run( docker_command, diff --git a/scripts/conformance/python-published-artifacts.sh b/scripts/conformance/python-published-artifacts.sh index caa435ef1..19be790f8 100755 --- a/scripts/conformance/python-published-artifacts.sh +++ b/scripts/conformance/python-published-artifacts.sh @@ -474,7 +474,8 @@ fi python3 -m venv "$run_root/.venv" # shellcheck disable=SC1091 . "$run_root/.venv/bin/activate" -sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" > "$result_dir/pip-upgrade.log" 2>&1 +sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" > "$result_dir/pip-upgrade.log" 2>&1 \ + || fail_blocked "the patched pip bootstrap failed; see pip-upgrade.log" mkdir -p "$run_root/python-distributions" python -m pip download --no-deps --dest "$run_root/python-distributions" \ "durable-workflow==$python_version" > "$result_dir/python-sdk-download.log" 2>&1 \ @@ -523,7 +524,7 @@ write_prerelease_composer_manifest "$run_root/artifacts/workflow" "python-confor docker run --rm --user "$(id -u):$(id -g)" \ -e COMPOSER_HOME=/tmp/composer-home \ -e COMPOSER_CACHE_DIR=/app/.composer-cache \ - -v "$run_root/artifacts/workflow:/app" composer:2 \ + -v "$run_root/artifacts/workflow:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress --prefer-dist --no-scripts \ "durable-workflow/workflow:$workflow_version" > "$result_dir/workflow-artifact-install.log" 2>&1 \ || fail_blocked "published workflow artifact install failed for durable-workflow/workflow:$workflow_version" @@ -536,7 +537,7 @@ write_prerelease_composer_manifest "$run_root/artifacts/waterline" "python-confo docker run --rm --user "$(id -u):$(id -g)" \ -e COMPOSER_HOME=/tmp/composer-home \ -e COMPOSER_CACHE_DIR=/app/.composer-cache \ - -v "$run_root/artifacts/waterline:/app" composer:2 \ + -v "$run_root/artifacts/waterline:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress --prefer-dist --no-scripts \ "durable-workflow/workflow:$workflow_version" \ "durable-workflow/waterline:$waterline_version" > "$result_dir/waterline-artifact-install.log" 2>&1 \ diff --git a/scripts/conformance/replay-published-artifacts.sh b/scripts/conformance/replay-published-artifacts.sh index 93a95c60a..83af648c9 100755 --- a/scripts/conformance/replay-published-artifacts.sh +++ b/scripts/conformance/replay-published-artifacts.sh @@ -1654,9 +1654,15 @@ set +e sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/python-venv/bin/python" > "$result_dir/python-pip-upgrade.log" 2>&1 python_pip_status=$? mkdir -p "$run_root/python-distributions" -python -m pip download --no-deps --dest "$run_root/python-distributions" \ - "durable-workflow==${python_sdk_version}" > "$result_dir/python-download.log" 2>&1 -python_download_status=$? +# Without the patched pip, no Python SDK request is made at all. +if [[ "$python_pip_status" -eq 0 ]]; then + python -m pip download --no-deps --dest "$run_root/python-distributions" \ + "durable-workflow==${python_sdk_version}" > "$result_dir/python-download.log" 2>&1 + python_download_status=$? +else + printf '%s\n' 'The patched pip bootstrap failed; see python-pip-upgrade.log.' > "$result_dir/python-download.log" + python_download_status=1 +fi if [[ "$python_download_status" -eq 0 ]]; then python3 "$script_dir/distribution_identities.py" record-unique \ "$distribution_identity_file" sdk-python "$python_sdk_version" \ @@ -1928,7 +1934,7 @@ fi waterline_app="$run_root/waterline-app" mkdir -p "$waterline_app" set +e -docker run --rm "${composer_env_args[@]}" -v "$waterline_app:/app" -w /app composer:2 \ +docker run --rm "${composer_env_args[@]}" -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress \ "durable-workflow/waterline:${waterline_version}@beta" \ "durable-workflow/workflow:${workflow_php_version}@beta" \ @@ -1975,7 +1981,7 @@ if [[ "$waterline_install_status" -eq 0 ]]; then -v "$waterline_app:/app" \ -v "$run_root/waterline-probe.php:/probe.php:ro" \ -w /app \ - composer:2 php /probe.php \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /probe.php \ > "$result_dir/waterline-probe.json" 2> "$result_dir/waterline-probe.log" waterline_probe_status=$? set -e diff --git a/scripts/conformance/sagas-published-artifacts.sh b/scripts/conformance/sagas-published-artifacts.sh index 3135c8909..c42167f52 100755 --- a/scripts/conformance/sagas-published-artifacts.sh +++ b/scripts/conformance/sagas-published-artifacts.sh @@ -1106,7 +1106,7 @@ sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" python -m pip install "durable-workflow==$python_version" httpx mkdir -p "$run_root/php-worker" "$run_root/cli/bin" "$run_root/waterline-app" "$run_root/logs" -docker run --rm -v "$run_root/php-worker:/app" composer:2 \ +docker run --rm -v "$run_root/php-worker:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress \ "durable-workflow/workflow:${workflow_version}@beta" \ "durable-workflow/sdk:${php_sdk_version}@beta" @@ -1126,7 +1126,7 @@ if [[ ! -x "$run_root/cli/bin/dw" ]]; then blocked_result "official CLI installer did not create an executable dw binary for release $cli_version" "$started_at" exit 1 fi -if ! docker run --rm -v "$run_root/waterline-app:/app" composer:2 sh -lc " +if ! docker run --rm -v "$run_root/waterline-app:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc " composer create-project --no-interaction --no-progress laravel/laravel . && composer require --no-interaction --no-progress \ 'durable-workflow/waterline:${waterline_version}@beta' \ @@ -1214,7 +1214,7 @@ services: condition: service_healthy waterline: - image: composer:2 + image: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee working_dir: /app command: ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8090"] environment: @@ -3380,7 +3380,7 @@ def restart_php_worker(reason: str = "restart_requested") -> datetime: f"{RUN_ROOT / 'php-worker'}:/work", "-w", "/work", - "composer:2", + "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "php", "worker.php", ], @@ -3976,7 +3976,7 @@ async def main() -> None: "topology": { "server": f"published Docker image exposed at {metadata.get('server_url')}", "server_queue_worker": "same published Docker image running php artisan queue:work against the shared database queue", - "php_worker": "composer:2 container with durable-workflow/workflow package", + "php_worker": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee container with durable-workflow/workflow package", "python_worker": "venv with durable-workflow PyPI package", "cli": "official GitHub release installer and standalone dw binary", "waterline": f"generated Laravel host app with durable-workflow/waterline package exposed at {metadata.get('waterline_url')} against the shared saga run database", @@ -4092,7 +4092,7 @@ docker run -d --name "$php_worker_container" --network host \ -v "$run_root:/run-root" \ -v "$run_root/php-worker:/work" \ -w /work \ - composer:2 php worker.php + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php worker.php # shellcheck disable=SC1091 . "$run_root/.venv/bin/activate" diff --git a/scripts/conformance/schedules-published-artifacts.mjs b/scripts/conformance/schedules-published-artifacts.mjs index ebb32b31f..9011d8227 100755 --- a/scripts/conformance/schedules-published-artifacts.mjs +++ b/scripts/conformance/schedules-published-artifacts.mjs @@ -1350,7 +1350,7 @@ async function resolveLatestPublishedPhpSdkVersion() { const command = [ 'run', '--rm', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', 'show', '--all', '--format=json', @@ -6729,7 +6729,7 @@ async function installSchedulesPhpArtifact( `${phpRoot}:/app`, '-w', '/app', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', 'require', '--no-interaction', '--no-progress', @@ -6750,7 +6750,7 @@ async function installSchedulesPhpArtifact( '/app', '--entrypoint', 'php', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', '-r', "require 'vendor/autoload.php'; echo Composer\\InstalledVersions::getPrettyVersion('durable-workflow/sdk') ?: '';", ], @@ -6888,7 +6888,7 @@ async function runSchedulesPhpWorkerCapture(php, input) { '/app', '--entrypoint', 'php', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', '/app/schedules_worker.php', containerInput, containerOutput, @@ -7891,7 +7891,7 @@ async function runSchedulesPhpSurfaceProbe(php, input) { '/app', '--entrypoint', 'php', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', '/app/schedules_php_surface.php', containerInput, containerOutput, diff --git a/scripts/conformance/server-parity/corrected-marker-embedded-install.sh b/scripts/conformance/server-parity/corrected-marker-embedded-install.sh index 7aee56e87..685c6c50d 100644 --- a/scripts/conformance/server-parity/corrected-marker-embedded-install.sh +++ b/scripts/conformance/server-parity/corrected-marker-embedded-install.sh @@ -13,6 +13,7 @@ mkdir -p bootstrap/cache storage/logs storage/framework/cache/data \ storage/framework/sessions storage/framework/views cp "$DW_PARITY_EMBEDDED_PROFILE/composer.json" "$DW_PARITY_EMBEDDED_PROFILE/composer.lock" . printf '%s composer.lock\n' "$DW_PARITY_EMBEDDED_LOCK_SHA256" | sha256sum -c - +sh "$(dirname "$0")/../../ci/composer-trust-guard.sh" >&2 composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader if test -n "${DW_PARITY_EMBEDDED_RUNTIME_HASHES:-}"; then (cd vendor/durable-workflow/workflow && sha256sum -c "$DW_PARITY_EMBEDDED_RUNTIME_HASHES") diff --git a/scripts/conformance/server-parity/corrected-marker-embedded-profile.sh b/scripts/conformance/server-parity/corrected-marker-embedded-profile.sh index 12f04e518..bb4e4d59f 100644 --- a/scripts/conformance/server-parity/corrected-marker-embedded-profile.sh +++ b/scripts/conformance/server-parity/corrected-marker-embedded-profile.sh @@ -72,9 +72,12 @@ for role in "${roles[@]}"; do package_checks=(-e DW_PARITY_EMBEDDED_RUNTIME_HASHES="$DW_PARITY_EMBEDDED_RUNTIME_HASHES") fi fi + # Composer in these frozen images trusts only a checked CA file (GHSA-6q48-6c7w-9gw2). + sh scripts/ci/checked-ca.sh parity-composer-trust docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/$state/$role:/runtime" "${engine[@]}" "${package_checks[@]}" \ -w /runtime -e COMPOSER_HOME=/tmp/composer -e DW_PARITY_EMBEDDED_PROFILE="/source/$inputs" \ + -v "$PWD/parity-composer-trust:/composer-trust:ro" -e COMPOSER_CAFILE=/composer-trust/ca.pem -e COMPOSER_CAPATH=/composer-trust/no-ca-directory \ -e DW_PARITY_EMBEDDED_LOCK_SHA256="$lock" "$image" \ /source/scripts/conformance/server-parity/corrected-marker-embedded-install.sh \ > "$evidence/embedded-$role-install.txt" 2>&1 diff --git a/scripts/conformance/server-parity/legacy-marker-profile.sh b/scripts/conformance/server-parity/legacy-marker-profile.sh index 0987dbfed..41cf90f82 100644 --- a/scripts/conformance/server-parity/legacy-marker-profile.sh +++ b/scripts/conformance/server-parity/legacy-marker-profile.sh @@ -61,9 +61,12 @@ for role in original replacement; do else app=embedded-app; inputs=embedded; lock=$(jq -r '.embedded_composer_lock_sha256' "$profile/artifacts.json") fi + # Composer in these frozen images trusts only a checked CA file (GHSA-6q48-6c7w-9gw2). + sh scripts/ci/checked-ca.sh parity-composer-trust docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m --entrypoint sh \ -v "$PWD:/source:ro" -v "$PWD/parity-legacy-state/$app:/runtime" "${engine[@]}" \ -w /runtime -e COMPOSER_HOME=/tmp/composer -e DW_PARITY_EMBEDDED_PROFILE="/source/$profile/$inputs" \ + -v "$PWD/parity-composer-trust:/composer-trust:ro" -e COMPOSER_CAFILE=/composer-trust/ca.pem -e COMPOSER_CAPATH=/composer-trust/no-ca-directory \ -e DW_PARITY_EMBEDDED_LOCK_SHA256="$lock" "$image" \ /source/scripts/conformance/server-parity/repeated-patch-install.sh \ > "parity-evidence/legacy-marker/embedded-$role-install.txt" 2>&1 diff --git a/scripts/conformance/server-parity/repeated-patch-install.sh b/scripts/conformance/server-parity/repeated-patch-install.sh index b9f453c92..8265ecde6 100644 --- a/scripts/conformance/server-parity/repeated-patch-install.sh +++ b/scripts/conformance/server-parity/repeated-patch-install.sh @@ -9,6 +9,7 @@ if ! test -e composer.json; then fi cp "$DW_PARITY_EMBEDDED_PROFILE/composer.json" "$DW_PARITY_EMBEDDED_PROFILE/composer.lock" . printf '%s composer.lock\n' "$DW_PARITY_EMBEDDED_LOCK_SHA256" | sha256sum -c - +sh "$(dirname "$0")/../../ci/composer-trust-guard.sh" >&2 composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader rm -f bootstrap/cache/packages.php bootstrap/cache/services.php bootstrap/cache/routes-v7.php php artisan package:discover --ansi diff --git a/scripts/conformance/server-parity/repeated-patch-profile.sh b/scripts/conformance/server-parity/repeated-patch-profile.sh index 86e7aada8..09a007110 100644 --- a/scripts/conformance/server-parity/repeated-patch-profile.sh +++ b/scripts/conformance/server-parity/repeated-patch-profile.sh @@ -46,8 +46,12 @@ esac runtime=(-v "$PWD/parity-repeated-state/embedded-app:/runtime" -e DW_MODE=embedded -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e LOG_CHANNEL=stderr -e DW_SERVER_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=) +# Composer in these frozen published images trusts only a checked CA file +# (GHSA-6q48-6c7w-9gw2); their own stores hold a withdrawn root. +sh scripts/ci/checked-ca.sh parity-composer-trust docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m --entrypoint sh \ -v "$PWD:/source:ro" "${runtime[@]}" -w /runtime -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD/parity-composer-trust:/composer-trust:ro" -e COMPOSER_CAFILE=/composer-trust/ca.pem -e COMPOSER_CAPATH=/composer-trust/no-ca-directory \ -e DW_PARITY_EMBEDDED_PROFILE="/source/$profile/embedded" -e DW_PARITY_EMBEDDED_LOCK_SHA256="$lock" \ "$image" /source/scripts/conformance/server-parity/repeated-patch-install.sh \ > parity-evidence/repeated-patch/embedded-install.txt 2>&1 diff --git a/scripts/conformance/signals-queries-published-artifacts.sh b/scripts/conformance/signals-queries-published-artifacts.sh index 7ddbe412d..807a3b33e 100755 --- a/scripts/conformance/signals-queries-published-artifacts.sh +++ b/scripts/conformance/signals-queries-published-artifacts.sh @@ -75,12 +75,11 @@ Environment overrides: DW_SIGNALS_QUERIES_NAMESPACE Namespace for the adversarial shard. Defaults to default. DW_SIGNALS_QUERIES_CLI_BIN Optional configured dw binary path; does not prove published install. DW_SIGNALS_QUERIES_PYTHON Optional configured Python executable; does not prove published install. - DW_SIGNALS_QUERIES_PHP_DOCKER_IMAGE Docker image used to install and run the published PHP package. - Defaults to composer:2. DW_SIGNALS_QUERIES_WATERLINE_PHP_DOCKER_IMAGE Docker image used to install and run the published Waterline observer. Defaults to durableworkflow/server: so pdo_mysql - is available when observing a MySQL-backed server. + is available when observing a MySQL-backed server. Composer in it + trusts only the checked CA file in /.composer-trust. DW_SIGNALS_QUERIES_KEEP_RUN_ROOT Set to 1 to keep the adversarial shard scratch directory. DW_SIGNALS_QUERIES_CLEANUP_TERMINATION_READY_FILE Regression-only checkpoint written after every bind-mounted @@ -1975,7 +1974,8 @@ except PackageNotFoundError: def sdk_php_docker_image() -> str: - return env_text("DW_SIGNALS_QUERIES_PHP_DOCKER_IMAGE") or "composer:2" + # The pinned image's CA store is checked (GHSA-6q48-6c7w-9gw2). + return "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" class PhpSdkArtifactError(RuntimeError): @@ -13781,6 +13781,7 @@ def docker_run_for_project( ]) for key, value in sorted((extra_env or {}).items()): docker_command.extend(["-e", f"{key}={value}"]) + docker_command.extend(composer_trust_options()) docker_command.extend([ image or sdk_php_docker_image(), @@ -13789,6 +13790,24 @@ def docker_run_for_project( return docker_command +def composer_trust_dir() -> Path: + return Path(os.environ.get("RESULT_DIR") or ".") / ".composer-trust" + + +def composer_trust_options() -> list[str]: + # Composer in a published Server image trusts only the CA file the wrapper + # checked and an empty directory, whatever that image's store holds + # (GHSA-6q48-6c7w-9gw2). A missing file makes Composer refuse. + return [ + "-v", + f"{composer_trust_dir()}:/composer-trust:ro", + "-e", + "COMPOSER_CAFILE=/composer-trust/ca.pem", + "-e", + "COMPOSER_CAPATH=/composer-trust/no-ca-directory", + ] + + def waterline_create_project_command( project_dir: Path, composer_cache_dir: Path, @@ -13915,6 +13934,20 @@ def _run_waterline_observer_probe( waterline_root.mkdir(parents=True, exist_ok=True) composer_cache_dir.mkdir(parents=True, exist_ok=True) log_file = result_dir / "waterline-signals-queries-observer.log" + trust_command = [ + "sh", + str(Path(os.environ["REPO_ROOT"]) / "scripts" / "ci" / "checked-ca.sh"), + str(composer_trust_dir()), + ] + trust = run_command(trust_command, log_file=log_file, timeout=300) + if trust.returncode != 0: + return waterline_observer_setup_result( + status="runner_blocked", + reason="The checked Composer CA file could not be prepared (GHSA-6q48-6c7w-9gw2).", + blocker_kind="composer_trust", + failed_command=trust_command, + command_result=trust, + ), {"error": "composer_trust", "log_file": log_file.name} create_command = waterline_create_project_command( waterline_root, composer_cache_dir, diff --git a/scripts/conformance/skew-published-artifacts.mjs b/scripts/conformance/skew-published-artifacts.mjs index 64d1e8394..5e55ab03c 100755 --- a/scripts/conformance/skew-published-artifacts.mjs +++ b/scripts/conformance/skew-published-artifacts.mjs @@ -1329,7 +1329,7 @@ function invocationAvailability(surfaceName) { const appDir = stringValue(record.app_dir) || envValue('DW_SKEW_PHP_SDK_APP_DIR'); if (appDir && fs.existsSync(path.join(appDir, 'vendor/autoload.php'))) { if (executableOnPath('docker')) { - return { available: true, appDir, phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2' }; + return { available: true, appDir, phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee' }; } return { @@ -1374,7 +1374,7 @@ function invocationAvailability(surfaceName) { available: true, appDir, surfaceUrl, - phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2', + phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', }; } @@ -2574,7 +2574,7 @@ function phpDockerInvocation(availability, script, payload, strategy = { kind: ' `${script}:/tmp/dw-skew-probe.php:ro`, '-w', '/app', - availability.phpImage || 'composer:2', + availability.phpImage || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', 'php', '/tmp/dw-skew-probe.php', JSON.stringify(payload), diff --git a/scripts/conformance/skew-published-artifacts.sh b/scripts/conformance/skew-published-artifacts.sh index ffaaa4e57..0991b7b33 100755 --- a/scripts/conformance/skew-published-artifacts.sh +++ b/scripts/conformance/skew-published-artifacts.sh @@ -501,7 +501,7 @@ if [[ -n "$php_sdk_version" ]]; then php_sdk_status="runner_blocked" php_sdk_reason="PHP SDK install requires an exact durable-workflow/sdk version; got ${php_sdk_version}" elif require_command docker; then - if docker run --rm -v "$run_root/php-sdk-worker:/app" composer:2 \ + if docker run --rm -v "$run_root/php-sdk-worker:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress "durable-workflow/sdk:${php_sdk_version}" >"$result_dir/php-sdk-composer-install.log" 2>&1; then php_sdk_status="available" php_sdk_reason="" @@ -513,7 +513,7 @@ if [[ -n "$php_sdk_version" ]]; then fi else php_sdk_status="runner_blocked" - php_sdk_reason="docker is required to install the PHP SDK artifact through composer:2" + php_sdk_reason="docker is required to install the PHP SDK artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" fi fi @@ -549,7 +549,7 @@ if [[ -n "${DW_WATERLINE_VERSION:-}" ]]; then waterline_serve_status=0 if [[ -z "$waterline_surface_url" ]]; then - if docker run --rm -v "$run_root/waterline:/app" -w /app composer:2 \ + if docker run --rm -v "$run_root/waterline:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer create-project laravel/laravel . --no-interaction --no-progress \ >"$result_dir/waterline-create-project.log" 2>&1; then waterline_create_status=0 @@ -562,7 +562,7 @@ if [[ -n "${DW_WATERLINE_VERSION:-}" ]]; then mkdir -p "$run_root/waterline/database" : > "$run_root/waterline/database/database.sqlite" - if docker run --rm -v "$run_root/waterline:/app" -w /app composer:2 \ + if docker run --rm -v "$run_root/waterline:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ composer require --no-interaction --no-progress \ "durable-workflow/waterline:${DW_WATERLINE_VERSION}@beta" \ "durable-workflow/workflow:${workflow_version}@beta" \ @@ -583,7 +583,7 @@ if [[ -n "${DW_WATERLINE_VERSION:-}" ]]; then -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ -e WATERLINE_NAMESPACE="${DW_SKEW_NAMESPACE:-default}" \ - composer:2 php artisan key:generate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \ >"$result_dir/waterline-key-generate.log" 2>&1; then waterline_key_status=0 else @@ -601,7 +601,7 @@ if [[ -n "${DW_WATERLINE_VERSION:-}" ]]; then -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ -e WATERLINE_NAMESPACE="${DW_SKEW_NAMESPACE:-default}" \ - composer:2 php artisan migrate --force \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \ >"$result_dir/waterline-migrate.log" 2>&1; then waterline_migrate_status=0 else @@ -719,7 +719,7 @@ PHP -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ -e WATERLINE_NAMESPACE="${DW_SKEW_NAMESPACE:-default}" \ -e DW_SKEW_WATERLINE_FIXTURE_RUN_ID="$waterline_fixture_run_id" \ - composer:2 php /workspace/waterline-seed.php \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /workspace/waterline-seed.php \ >"$result_dir/waterline-seed-fixture.log" 2>&1; then waterline_fixture_status=0 else @@ -744,7 +744,7 @@ PHP -e WATERLINE_ENGINE_SOURCE=v2 \ -e WATERLINE_ALLOW_UNAUTHENTICATED=true \ -e WATERLINE_NAMESPACE="${DW_SKEW_NAMESPACE:-default}" \ - composer:2 php artisan serve --host=0.0.0.0 --port "$waterline_port" \ + composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan serve --host=0.0.0.0 --port "$waterline_port" \ >"$result_dir/waterline-serve-container.id" 2>"$result_dir/waterline-serve-start.log"; then if wait_for_waterline "$waterline_surface_url" >"$result_dir/waterline-ready.log" 2>&1; then waterline_serve_status=0 @@ -788,7 +788,7 @@ PHP fi else waterline_status="runner_blocked" - waterline_reason="docker is required to install the Waterline artifact through composer:2" + waterline_reason="docker is required to install the Waterline artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" fi fi diff --git a/scripts/conformance/worker-versioning-published-artifacts.sh b/scripts/conformance/worker-versioning-published-artifacts.sh index f81b57793..cbcfef8c4 100755 --- a/scripts/conformance/worker-versioning-published-artifacts.sh +++ b/scripts/conformance/worker-versioning-published-artifacts.sh @@ -1293,7 +1293,7 @@ DOCKERFILE fi mkdir -p "$run_root/waterline-app" - if ! docker run --rm -v "$run_root/waterline-app:/app" composer:2 sh -lc " + if ! docker run --rm -v "$run_root/waterline-app:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc " composer create-project --no-interaction --no-progress laravel/laravel . && composer require --no-interaction --no-progress \ 'durable-workflow/waterline:${DW_WATERLINE_VERSION}@beta' \ diff --git a/scripts/conformance/workflow-updates-published-artifacts.sh b/scripts/conformance/workflow-updates-published-artifacts.sh index ace7fb9ef..3586814df 100755 --- a/scripts/conformance/workflow-updates-published-artifacts.sh +++ b/scripts/conformance/workflow-updates-published-artifacts.sh @@ -4261,6 +4261,10 @@ run_operator_diagnostics_shard() { write_operator_diagnostics_shard_status runner_blocked "Composer is required to install the pinned Packagist durable-workflow/waterline package." composer_unavailable true return 0 fi + if ! sh "$script_dir/../ci/composer-trust-guard.sh" > "$result_dir/operator-composer-trust.log" 2>&1; then + write_operator_diagnostics_shard_status runner_blocked "Composer's CA trust includes a root withdrawn under GHSA-6q48-6c7w-9gw2; see operator-composer-trust.log." composer_trust true + return 0 + fi if [[ -z "$operator_port" ]]; then operator_port="$(choose_tcp_port)" diff --git a/scripts/perf/Dockerfile.octane-frankenphp-experiment b/scripts/perf/Dockerfile.octane-frankenphp-experiment index 9f8d1e6ba..22ff52967 100644 --- a/scripts/perf/Dockerfile.octane-frankenphp-experiment +++ b/scripts/perf/Dockerfile.octane-frankenphp-experiment @@ -1,6 +1,6 @@ ARG SERVER_IMAGE=durableworkflow/server@sha256:998b9dffe1f8effb159af9660ac190ccbc9d0c954900e68e9946baa6bb4c6c42 -FROM composer:2 AS phpredis-source +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS phpredis-source ARG PHPREDIS_VERSION=6.3.0 ARG PHPREDIS_COMMIT=df4fab2de7fc327c54c94a13af2b9542e4fbd720 @@ -29,6 +29,11 @@ COPY --from=published /app/ /app/ WORKDIR /app +# Composer trusts only the pinned Composer image's checked CA store here +# (GHSA-6q48-6c7w-9gw2); this base image's own store may hold a withdrawn root. +COPY --from=phpredis-source /etc/ssl/certs/ca-certificates.crt /etc/composer-trust/ca.pem +RUN install -d -m 0555 /etc/composer-trust/no-ca-directory +ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \ 'symfony/psr-http-message-bridge:7.4.8' \ --update-no-dev --no-interaction --no-progress --no-scripts \ diff --git a/scripts/perf/Dockerfile.octane-openswoole-experiment b/scripts/perf/Dockerfile.octane-openswoole-experiment index 86d870ae6..de56e02d2 100644 --- a/scripts/perf/Dockerfile.octane-openswoole-experiment +++ b/scripts/perf/Dockerfile.octane-openswoole-experiment @@ -1,4 +1,6 @@ ARG SERVER_IMAGE=durableworkflow/server@sha256:5d435d14fdbe3acc991ae591a155864f132cefbfbf1b53b724a8e7b006a91612 +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca + FROM ${SERVER_IMAGE} RUN apt-get update \ @@ -7,6 +9,11 @@ RUN apt-get update \ && docker-php-ext-enable openswoole \ && rm -rf /var/lib/apt/lists/* +# Composer trusts only the pinned Composer image's checked CA store here +# (GHSA-6q48-6c7w-9gw2); this base image's own store may hold a withdrawn root. +COPY --from=checked-ca /etc/ssl/certs/ca-certificates.crt /etc/composer-trust/ca.pem +RUN install -d -m 0555 /etc/composer-trust/no-ca-directory +ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \ 'symfony/psr-http-message-bridge:7.4.8' \ --update-no-dev --no-interaction --no-progress --no-scripts \ diff --git a/scripts/perf/Dockerfile.octane-swoole-experiment b/scripts/perf/Dockerfile.octane-swoole-experiment index 50cd5ce20..77e861720 100644 --- a/scripts/perf/Dockerfile.octane-swoole-experiment +++ b/scripts/perf/Dockerfile.octane-swoole-experiment @@ -1,4 +1,6 @@ ARG SERVER_IMAGE=durableworkflow/server@sha256:4ee8a95df43fbd7022b4d1b583046063b9d3e6e2d1fd4408e58a6b8f486cff56 +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca + FROM ${SERVER_IMAGE} RUN apt-get update \ @@ -7,6 +9,11 @@ RUN apt-get update \ && docker-php-ext-enable swoole \ && rm -rf /var/lib/apt/lists/* +# Composer trusts only the pinned Composer image's checked CA store here +# (GHSA-6q48-6c7w-9gw2); this base image's own store may hold a withdrawn root. +COPY --from=checked-ca /etc/ssl/certs/ca-certificates.crt /etc/composer-trust/ca.pem +RUN install -d -m 0555 /etc/composer-trust/no-ca-directory +ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \ 'symfony/psr-http-message-bridge:7.4.8' \ --update-no-dev --no-interaction --no-progress --no-scripts \ diff --git a/scripts/perf/Dockerfile.sdk b/scripts/perf/Dockerfile.sdk index 41eca2540..8679ef471 100644 --- a/scripts/perf/Dockerfile.sdk +++ b/scripts/perf/Dockerfile.sdk @@ -1,6 +1,6 @@ -ARG COMPOSER_IMAGE=composer:2 ARG PHP_IMAGE=php:8.3-cli -FROM ${COMPOSER_IMAGE} AS dependencies +# The pinned image's CA store is checked (GHSA-6q48-6c7w-9gw2). +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies WORKDIR /sdk COPY scripts/perf/sdk-php/composer.json scripts/perf/sdk-php/composer.lock ./ RUN composer install --no-dev --no-interaction --no-progress --prefer-dist diff --git a/scripts/perf/Dockerfile.sdk-current-experiment b/scripts/perf/Dockerfile.sdk-current-experiment index 27c61cf6d..612918029 100644 --- a/scripts/perf/Dockerfile.sdk-current-experiment +++ b/scripts/perf/Dockerfile.sdk-current-experiment @@ -1,4 +1,4 @@ -FROM composer:2 AS dependencies +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies WORKDIR /sdk COPY benchmarks/capacity/v1/bindings/php/composer.json benchmarks/capacity/v1/bindings/php/composer.lock ./ RUN composer require 'durable-workflow/sdk:2.1.1' \ diff --git a/scripts/perf/soak-fixed-envelope.compose.yml b/scripts/perf/soak-fixed-envelope.compose.yml index fcded9b9b..53c837591 100644 --- a/scripts/perf/soak-fixed-envelope.compose.yml +++ b/scripts/perf/soak-fixed-envelope.compose.yml @@ -37,7 +37,6 @@ services: soak-sdk: build: args: - COMPOSER_IMAGE: composer:2.8@sha256:5248900ab8b5f7f880c2d62180e40960cd87f60149ec9a1abfd62ac72a02577c PHP_IMAGE: php:8.3-cli@sha256:9bfe0302473ae40466205b3050b8dd9528502e9a9741a96a3911bcaec26bc7cd cpus: 0.5 mem_limit: 512m diff --git a/tests/Integration/StorageAdmission/sdk/Dockerfile b/tests/Integration/StorageAdmission/sdk/Dockerfile index b6326e233..b583aafb5 100644 --- a/tests/Integration/StorageAdmission/sdk/Dockerfile +++ b/tests/Integration/StorageAdmission/sdk/Dockerfile @@ -1,12 +1,22 @@ ARG SERVER_IMAGE FROM ${SERVER_IMAGE} AS server-trust +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca + FROM rust:1.86 AS rust-worker ARG RUST_SDK_VERSION # This image's CA store still holds a root withdrawn under GHSA-6q48-6c7w-9gw2. -# cargo resolves the unlocked crates through the Server image's store instead. +# cargo resolves the unlocked crates through the Server image's checked file +# only: CARGO_HTTP_CAINFO adds to the OpenSSL CA directory rather than +# replacing it, so that directory is pointed at an empty one. COPY --from=server-trust /etc/ssl/certs/ca-certificates.crt /etc/server-trust/ca-certificates.crt -ENV CARGO_HTTP_CAINFO=/etc/server-trust/ca-certificates.crt +RUN install -d -m 0555 /etc/server-trust/no-ca-directory +ENV CARGO_HTTP_CAINFO=/etc/server-trust/ca-certificates.crt \ + SSL_CERT_FILE=/etc/server-trust/ca-certificates.crt \ + SSL_CERT_DIR=/etc/server-trust/no-ca-directory +COPY --from=ci blocked_roots.py /etc/server-trust/blocked_roots.py +RUN python3 /etc/server-trust/blocked_roots.py "$CARGO_HTTP_CAINFO" \ + && test -z "$(ls -A "$SSL_CERT_DIR")" WORKDIR /build RUN cargo init --bin --name storage-sdk-worker . \ && cargo add "durable-workflow@=${RUST_SDK_VERSION}" serde_json sha2@0.10 \ @@ -19,6 +29,11 @@ ARG PHP_SDK_VERSION ARG PYTHON_SDK_VERSION USER root RUN install -d -o 1000 -g 1000 /sdk +# Composer trusts only the pinned Composer image's checked CA store +# (GHSA-6q48-6c7w-9gw2), whichever Server image this builds on. +COPY --from=checked-ca /etc/ssl/certs/ca-certificates.crt /etc/composer-trust/ca.pem +RUN install -d -m 0555 /etc/composer-trust/no-ca-directory +ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory USER 1000:1000 WORKDIR /sdk COPY --from=ci bootstrap-patched-pip.sh blocked_roots.py /sdk/ci/ diff --git a/tests/Unit/ActivityConformanceRunnerContractTest.php b/tests/Unit/ActivityConformanceRunnerContractTest.php index 9b14e1ba6..7c48e52e2 100644 --- a/tests/Unit/ActivityConformanceRunnerContractTest.php +++ b/tests/Unit/ActivityConformanceRunnerContractTest.php @@ -47,7 +47,9 @@ public function test_extracted_runner_hands_execution_back_to_the_exact_publishe copy($repoRoot.'/scripts/conformance/activities-published-artifacts.sh', $runnerDir.'/activities-published-artifacts.sh'); file_put_contents( $binDir.'/docker', - "#!/usr/bin/env bash\nprintf '%s\\n' \"\$@\" > \"\$FAKE_DOCKER_LOG\"\n", + "#!/usr/bin/env bash\n" + ."if [[ \" \$* \" == *' --entrypoint cat '* ]]; then printf '%s\\n' '-----BEGIN CERTIFICATE-----'; exit 0; fi\n" + ."printf '%s\\n' \"\$@\" > \"\$FAKE_DOCKER_LOG\"\n", ); chmod($binDir.'/docker', 0755); @@ -83,6 +85,11 @@ public function test_extracted_runner_hands_execution_back_to_the_exact_publishe $arguments, ); $this->assertContains($resultDir.':/result', $arguments); + $this->assertContains($resultDir.'/.composer-trust:/composer-trust:ro', $arguments); + $this->assertContains('COMPOSER_CAFILE=/composer-trust/ca.pem', $arguments); + $this->assertContains('COMPOSER_CAPATH=/composer-trust/no-ca-directory', $arguments); + $this->assertFileExists($resultDir.'/.composer-trust/ca.pem'); + $this->assertSame([], array_values(array_diff(scandir($resultDir.'/.composer-trust/no-ca-directory'), ['.', '..']))); $this->assertContains('DW_ACTIVITIES_CONTAINER_HANDOFF=1', $arguments); $this->assertContains('DW_ACTIVITIES_CONTAINER_REMOVED_ON_EXIT=1', $arguments); $this->assertContains('DW_ACTIVITIES_RUNNER_SOURCE='.$serverImage, $arguments); diff --git a/tests/Unit/NamespaceConformanceRunnerContractTest.php b/tests/Unit/NamespaceConformanceRunnerContractTest.php index 2edc02068..a54ec6222 100644 --- a/tests/Unit/NamespaceConformanceRunnerContractTest.php +++ b/tests/Unit/NamespaceConformanceRunnerContractTest.php @@ -142,8 +142,8 @@ public function test_runner_executes_published_waterline_namespace_shard_when_no $this->assertStringContainsString('"durable-workflow/waterline:${waterline_version}@beta"', $source); $this->assertStringContainsString('"durable-workflow/workflow:${workflow_php_version}@beta"', $source); $this->assertStringContainsString('"durable-workflow/sdk:${sdk_php_version}@beta"', $source); - $this->assertStringContainsString('composer:2 php artisan migrate --force', $source); - $this->assertStringContainsString('composer:2 php artisan waterline:namespace-conformance', $source); + $this->assertStringContainsString('composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force', $source); + $this->assertStringContainsString('composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:namespace-conformance', $source); $this->assertStringContainsString('--output /result/waterline-namespace-result.json', $source); $this->assertStringContainsString('DW_NAMESPACES_WATERLINE_RESULT="$waterline_result_path"', $source); $this->assertStringContainsString('"required": True', $source); diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index d6fcd2cfd..d5ec16236 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -162,6 +162,8 @@ public function test_preflight_validates_release_metadata_and_corpus_tools(): vo 'node scripts/ci/sync-source-release.mjs --check', 'python scripts/ci/test-regression-corpus-policy.py', 'python3 -m unittest tests/Unit/Support/patched_pip_test.py', + 'python3 -m unittest tests/Unit/Support/cargo_trust_test.py', + 'python3 -m unittest tests/Unit/Support/composer_trust_test.py', ] as $needle) { $this->assertStringContainsString($needle, $this->workflow); } diff --git a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php index 6854d5c27..0e8d80d79 100644 --- a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php +++ b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php @@ -113,7 +113,7 @@ public function test_php_security_audits_fail_before_release_publication(): void $this->assertIsInt($loginIndex); $this->assertLessThan($loginIndex, $auditIndex); $this->assertSame( - 'composer audit --working-dir=release-source --locked --no-dev --no-interaction', + 'docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction', $steps[$auditIndex]['run'], ); $this->assertArrayNotHasKey('continue-on-error', $steps[$auditIndex]); @@ -125,7 +125,7 @@ public function test_php_security_audits_fail_before_release_publication(): void static fn (array $step): bool => ($step['name'] ?? null) === 'Audit locked PHP dependencies', )); $this->assertCount(1, $audits); - $this->assertSame('composer audit --locked --no-interaction', $audits[0]['run']); + $this->assertSame('docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-interaction', $audits[0]['run']); $this->assertArrayNotHasKey('continue-on-error', $audits[0]); $this->assertArrayNotHasKey('if', $audits[0]); } diff --git a/tests/Unit/ReplayConformanceRunnerContractTest.php b/tests/Unit/ReplayConformanceRunnerContractTest.php index 6f5f85224..272de4cf1 100644 --- a/tests/Unit/ReplayConformanceRunnerContractTest.php +++ b/tests/Unit/ReplayConformanceRunnerContractTest.php @@ -245,8 +245,8 @@ public function test_runner_installs_and_probes_waterline_before_install_only_ca $this->assertStringContainsString('container_user="$(id -u):$(id -g)"', $source); $this->assertStringContainsString('COMPOSER_HOME=/tmp/composer', $source); $this->assertStringContainsString('COMPOSER_CACHE_DIR=/tmp/composer-cache', $source); - $this->assertStringContainsString('docker run --rm "${composer_env_args[@]}" -v "$waterline_app:/app" -w /app composer:2', $source); - $this->assertStringNotContainsString('docker run --rm -v "$waterline_app:/app" -w /app composer:2', $source); + $this->assertStringContainsString('docker run --rm "${composer_env_args[@]}" -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', $source); + $this->assertStringNotContainsString('docker run --rm -v "$waterline_app:/app" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', $source); $this->assertStringContainsString('"durable-workflow/waterline:${waterline_version}@beta"', $source); $this->assertStringContainsString('"durable-workflow/workflow:${workflow_php_version}@beta"', $source); $this->assertStringContainsString('"durable-workflow/sdk:${php_sdk_version}@beta"', $source); diff --git a/tests/Unit/SkewRefusalMatrixContractTest.php b/tests/Unit/SkewRefusalMatrixContractTest.php index 21d884af0..7a741b62d 100644 --- a/tests/Unit/SkewRefusalMatrixContractTest.php +++ b/tests/Unit/SkewRefusalMatrixContractTest.php @@ -568,7 +568,7 @@ public function test_published_artifact_runner_handoff_covers_full_matrix_output 'when no Waterline URL is supplied, the skew runner must create a disposable Laravel app for the published Waterline package', ); $this->assertStringContainsString( - 'composer:2 php artisan serve --host=0.0.0.0 --port "$waterline_port"', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan serve --host=0.0.0.0 --port "$waterline_port"', $shell, 'the skew runner must boot the disposable Waterline app before reporting Waterline render evidence', ); diff --git a/tests/Unit/Support/cargo_trust_test.py b/tests/Unit/Support/cargo_trust_test.py new file mode 100644 index 000000000..74cd832b3 --- /dev/null +++ b/tests/Unit/Support/cargo_trust_test.py @@ -0,0 +1,161 @@ +"""Real-cargo trust control for GHSA-6q48-6c7w-9gw2. + +Cargo's CARGO_HTTP_CAINFO adds a checked CA file, but TLS still trusts the +OpenSSL CA directory (the image's hashed system store, or SSL_CERT_DIR). +A local HTTPS sparse index is signed by a synthetic withdrawn root that only +that directory holds. Unconfined cargo, and cargo with only a clean +CARGO_HTTP_CAINFO, reach it; the clean file paired with an empty directory +refuses before any request. + +Run with: python3 -m unittest tests/Unit/Support/cargo_trust_test.py +Needs cargo and openssl; set DW_REQUIRE_CARGO_TRUST_TEST=1 to fail rather +than skip when either is missing. +""" + +from __future__ import annotations + +import http.server +import os +import shutil +import ssl +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path + + +def openssl(*arguments: str) -> str: + return subprocess.run(["openssl", *arguments], check=True, capture_output=True, text=True).stdout + + +def make_root(stem: Path, common_name: str) -> None: + openssl( + "req", "-x509", "-nodes", "-days", "1", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", f"/CN={common_name}", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-keyout", f"{stem}.key", "-out", f"{stem}.pem", + ) + + +class _RecordingHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - http.server naming + self.server.paths.append(self.path) # type: ignore[attr-defined] + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + +class _QuietServer(http.server.ThreadingHTTPServer): + # A refused handshake drops the connection; that is the expected outcome. + def handle_error(self, request: object, client_address: object) -> None: + pass + + +class CargoTrustTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + missing = [tool for tool in ("cargo", "openssl") if not shutil.which(tool)] + if missing: + if os.environ.get("DW_REQUIRE_CARGO_TRUST_TEST") == "1": + raise AssertionError(f"required for the cargo trust control: {missing}") + raise unittest.SkipTest(f"needs {missing}") + cls.work = Path(tempfile.mkdtemp(prefix="dw-cargo-trust-")) + withdrawn = cls.work / "withdrawn" + make_root(withdrawn, "AAA Certificate Services") + clean = cls.work / "clean" + make_root(clean, "Durable Workflow Clean Test Root") + leaf = cls.work / "leaf" + openssl( + "req", "-new", "-nodes", + "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1", + "-keyout", f"{leaf}.key", "-out", f"{leaf}.csr", + ) + openssl( + "x509", "-req", "-days", "1", "-in", f"{leaf}.csr", + "-CA", f"{withdrawn}.pem", "-CAkey", f"{withdrawn}.key", "-CAcreateserial", + "-copy_extensions", "copy", "-out", f"{leaf}.pem", + ) + # The affected "system" directory: the withdrawn root, hashed like + # c_rehash does. The clean file holds only an unrelated root. + cls.affected_dir = cls.work / "affected-certs" + cls.affected_dir.mkdir() + digest = openssl("x509", "-hash", "-noout", "-in", f"{withdrawn}.pem").strip() + shutil.copy(f"{withdrawn}.pem", cls.affected_dir / f"{digest}.0") + cls.empty_dir = cls.work / "no-ca-directory" + cls.empty_dir.mkdir() + cls.clean_file = Path(f"{clean}.pem") + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(f"{leaf}.pem", f"{leaf}.key") + cls.server = _QuietServer(("127.0.0.1", 0), _RecordingHandler) + cls.server.paths = [] # type: ignore[attr-defined] + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + cls.project = cls.work / "project" + (cls.project / "src").mkdir(parents=True) + (cls.project / "src" / "lib.rs").write_text("") + (cls.project / "Cargo.toml").write_text( + '[package]\nname = "dw-trust-control"\nversion = "0.0.0"\nedition = "2021"\n\n' + '[dependencies]\ndw-trust-missing = { version = "1", registry = "dwtrust" }\n' + ) + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + shutil.rmtree(cls.work, ignore_errors=True) + + def resolve(self, **environment: str) -> tuple[subprocess.CompletedProcess[str], list[str]]: + self.server.paths.clear() # type: ignore[attr-defined] + cargo_home = Path(tempfile.mkdtemp(dir=self.work)) + result = subprocess.run( + ["cargo", "generate-lockfile", "--manifest-path", str(self.project / "Cargo.toml")], + capture_output=True, + text=True, + timeout=120, + env={ + **{name: value for name, value in os.environ.items() + if not name.startswith(("SSL_CERT_", "CARGO_HTTP_", "CARGO_REGISTRIES_"))}, + "CARGO_HOME": str(cargo_home), + "CARGO_NET_RETRY": "0", + "CARGO_HTTP_TIMEOUT": "10", + "CARGO_REGISTRIES_DWTRUST_INDEX": f"sparse+https://127.0.0.1:{self.server.server_address[1]}/index/", + "SSL_CERT_FILE": str(self.clean_file), + **environment, + }, + ) + return result, list(self.server.paths) # type: ignore[attr-defined] + + def test_the_affected_directory_is_trusted_by_unconfined_cargo(self) -> None: + result, paths = self.resolve(SSL_CERT_DIR=str(self.affected_dir)) + + self.assertIn("/index/config.json", paths, result.stderr) + + def test_a_clean_cainfo_alone_still_trusts_the_affected_directory(self) -> None: + result, paths = self.resolve(SSL_CERT_DIR=str(self.affected_dir), CARGO_HTTP_CAINFO=str(self.clean_file)) + + self.assertIn("/index/config.json", paths, result.stderr) + + def test_a_clean_cainfo_with_an_empty_directory_refuses(self) -> None: + result, paths = self.resolve(SSL_CERT_DIR=str(self.empty_dir), CARGO_HTTP_CAINFO=str(self.clean_file)) + + self.assertEqual([], paths, "cargo reached the server through the withdrawn root") + self.assertNotEqual(0, result.returncode) + self.assertRegex(result.stderr, r"(?i)certificate|SSL") + + def test_the_storage_admission_rust_stage_pairs_the_file_with_an_empty_directory(self) -> None: + root = Path(__file__).resolve().parents[3] + dockerfile = (root / "tests/Integration/StorageAdmission/sdk/Dockerfile").read_text() + stage = dockerfile.split("AS rust-worker", 1)[1].split("\nFROM ", 1)[0] + cargo = stage.index("RUN cargo ") + for setting in ("CARGO_HTTP_CAINFO=", "SSL_CERT_DIR=/etc/server-trust/no-ca-directory", "SSL_CERT_FILE="): + self.assertIn(setting, stage[:cargo], setting) diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json new file mode 100644 index 000000000..5baf76fa9 --- /dev/null +++ b/tests/Unit/Support/composer-sinks.json @@ -0,0 +1,590 @@ +{ + "schema": "durable-workflow.server.composer-sinks/v1", + "advisory": "GHSA-6q48-6c7w-9gw2", + "pinned_image": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "review": "composer_trust_test.py fails on any Composer sink not listed here with the same count.", + "sinks": [ + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", + "count": 2, + "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. The corpus validator reruns it after adding git." + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "-v \"$PWD:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "composer install --no-interaction --no-progress --prefer-dist", + "count": 1, + "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. (dw-server-prepared-local-base is `docker build --target base`.)" + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-interaction", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": ".github/workflows/release.yml", + "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD/release-source:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": ".github/workflows/server-perf.yml", + "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": ".github/workflows/server-perf.yml", + "line": "sh -lc 'tar -xf - -C /app && cp -a /app/workflow-package /workflow && php scripts/ci/prepare-release-workflow-composer-metadata.php && composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist && composer install --no-interaction --no-progress --prefer-dist && vendor/bin/phpunit tests/Unit/BoundedGrowthPolicyTest.php tests/Unit/ServerPerfHarnessContractTest.php --colors=never'", + "count": 2, + "confinement": "Runs in the pinned Composer image of the preceding `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": ".github/workflows/server-perf.yml", + "line": "| docker run --rm -i -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "Dockerfile", + "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." + }, + { + "path": "Dockerfile", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "benchmarks/capacity/v1/README.md", + "line": "(cd benchmarks/capacity/v1/bindings/php && composer install --no-dev)", + "count": 1, + "confinement": "Documentation; not executed." + }, + { + "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", + "line": "\"command\": [\"composer\", \"install\", \"--no-dev\", \"--no-interaction\", \"--no-progress\", \"--prefer-dist\", \"--classmap-authoritative\"],", + "count": 1, + "confinement": "The service image is the pinned Composer image; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", + "line": "\"image\": \"docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "benchmarks/capacity/v1/topologies/local-docker/topology.json", + "line": "\"composer\": \"docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/ci/checked-ca.sh", + "line": "image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/ci/qualify-database-tls.sh", + "line": "compose run --rm --no-deps php sh -c 'sh scripts/ci/composer-trust-guard.sh && composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist'", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + }, + { + "path": "scripts/conformance/activities-published-artifacts.sh", + "line": "composer install --no-interaction --no-progress --prefer-dist --no-scripts", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The published-image delegate also sets COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/activities-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/heartbeats-published-artifacts.mjs", + "line": "const PHP_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee';", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/migration-published-artifacts.mjs", + "line": "rollback_steps: signals.rollback_procedure ? commands.filter((command) => /backup|restore|composer require|queue:restart|mysql|psql/i.test(command)) : [],", + "count": 1, + "confinement": "Evidence filter pattern; not executed." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "-v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 2, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:namespace-conformance \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/nexus-published-artifacts.sh", + "line": "`mkdir -p /tmp/dw-php && cd /tmp/dw-php && composer init --no-interaction --name=dw/nexus-conformance --require=durable-workflow/workflow:${JSON.stringify(versions.workflow).slice(1, -1)} --require=durable-workflow/sdk:${JSON.stringify(versions['sdk-php']).slice(1, -1)} >/tmp/dw-php-composer-init.log 2>&1 && composer update --no-interaction --prefer-dist --no-progress >/tmp/dw-php-composer-update.log 2>&1 && php -S 0.0.0.0:8092 /work/nexus-php-workflow-service.php`,", + "count": 2, + "confinement": "Runs in the published Server image; COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/nexus-published-artifacts.sh", + "line": "container_image: runtimeEvidence.php?.container_image || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", + "count": 2, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:principal-attribution-conformance \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "docker_command.extend([\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\", \"php\", \"-r\", code])", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "if docker run --rm --user \"$(id -u):$(id -g)\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 2, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "stdout=f\"PHP binary missing ({PHP_BIN}) and docker is unavailable for composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee fallback\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "line": "-v \"$run_root/artifacts/waterline:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "line": "-v \"$run_root/artifacts/workflow:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress --prefer-dist --no-scripts \\", + "count": 2, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/replay-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/replay-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /probe.php \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/replay-published-artifacts.sh", + "line": "docker run --rm \"${composer_env_args[@]}\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "\"php_worker\": \"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee container with durable-workflow/workflow package\",", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "composer create-project --no-interaction --no-progress laravel/laravel . &&", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 2, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php worker.php", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "docker run --rm -v \"$run_root/php-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "image: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/schedules-published-artifacts.mjs", + "line": "'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", + "count": 5, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/server-parity/README.md", + "line": "composer install --working-dir=scripts/conformance/server-parity --no-interaction", + "count": 1, + "confinement": "Documentation; not executed." + }, + { + "path": "scripts/conformance/server-parity/activity-result-inspection/README.md", + "line": "Install this directory's lock normally with `composer install --no-dev", + "count": 1, + "confinement": "Documentation; not executed." + }, + { + "path": "scripts/conformance/server-parity/corrected-marker-embedded-install.sh", + "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/server-parity/corrected-marker-embedded-install.sh", + "line": "composer show durable-workflow/workflow --format=json", + "count": 1, + "confinement": "Reads the installed package locally; no repository request." + }, + { + "path": "scripts/conformance/server-parity/repeated-patch-install.sh", + "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/server-parity/repeated-patch-install.sh", + "line": "composer show durable-workflow/workflow --format=json", + "count": 1, + "confinement": "Reads the installed package locally; no repository request." + }, + { + "path": "scripts/conformance/signals-queries-published-artifacts.sh", + "line": "\"composer\",", + "count": 1, + "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/signals-queries-published-artifacts.sh", + "line": "[\"composer\", \"create-project\", \"laravel/laravel\", \".\", \"--no-interaction\", \"--no-progress\", \"--prefer-dist\"],", + "count": 1, + "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/conformance/signals-queries-published-artifacts.sh", + "line": "return \"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.mjs", + "line": "availability.phpImage || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.mjs", + "line": "phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.mjs", + "line": "return { available: true, appDir, phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee' };", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \"durable-workflow/sdk:${php_sdk_version}\" >\"$result_dir/php-sdk-composer-install.log\" 2>&1; then", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /workspace/waterline-seed.php \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan serve --host=0.0.0.0 --port \"$waterline_port\" \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "if docker run --rm -v \"$run_root/php-sdk-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "if docker run --rm -v \"$run_root/waterline:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "count": 2, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "php_sdk_reason=\"docker is required to install the PHP SDK artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "waterline_reason=\"docker is required to install the Waterline artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", + "line": "&& composer require --update-no-dev --no-scripts --no-plugins --no-interaction --no-progress \"durable-workflow/sdk:${PHP_SDK_VERSION}\" \\", + "count": 1, + "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-published-artifacts.sh", + "line": "composer create-project --no-interaction --no-progress laravel/laravel . &&", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress \\", + "count": 1, + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-published-artifacts.sh", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/worker-versioning-published-workers.mjs", + "line": "command: `composer require durable-workflow/sdk:${sdkPhpVersion}`,", + "count": 2, + "confinement": "Evidence or report text; not executed." + }, + { + "path": "scripts/conformance/worker-versioning-published-workers.mjs", + "line": "const COMPOSER_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee';", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/conformance/workflow-updates-published-artifacts.sh", + "line": "composer create-project laravel/laravel . --no-interaction --no-progress --prefer-dist", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + }, + { + "path": "scripts/conformance/workflow-updates-published-artifacts.sh", + "line": "composer require --no-interaction --no-progress --prefer-dist \\", + "count": 1, + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + }, + { + "path": "scripts/perf/Dockerfile.octane-frankenphp-experiment", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS phpredis-source", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.octane-frankenphp-experiment", + "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", + "count": 1, + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/perf/Dockerfile.octane-openswoole-experiment", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.octane-openswoole-experiment", + "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", + "count": 1, + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/perf/Dockerfile.octane-swoole-experiment", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.octane-swoole-experiment", + "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", + "count": 1, + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + }, + { + "path": "scripts/perf/Dockerfile.sdk", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.sdk", + "line": "RUN composer install --no-dev --no-interaction --no-progress --prefer-dist", + "count": 1, + "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.sdk-current-experiment", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "scripts/perf/Dockerfile.sdk-current-experiment", + "line": "RUN composer require 'durable-workflow/sdk:2.1.1' \\", + "count": 1, + "confinement": "Runs in the pinned Composer 2.10.3 build stage; PinnedComposerImageTest checks its CA store." + }, + { + "path": "tests/Fixtures/PhpActivityLeaseConsumer/README.md", + "line": "Run `composer install` from this directory using the committed lock. It installs", + "count": 1, + "confinement": "Documentation; not executed." + }, + { + "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", + "count": 1, + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + }, + { + "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", + "line": "RUN composer require \"durable-workflow/sdk:${PHP_SDK_VERSION}\" --no-interaction --no-progress --prefer-dist \\", + "count": 1, + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + } + ] +} diff --git a/tests/Unit/Support/composer_trust_test.py b/tests/Unit/Support/composer_trust_test.py new file mode 100644 index 000000000..a75e710d0 --- /dev/null +++ b/tests/Unit/Support/composer_trust_test.py @@ -0,0 +1,246 @@ +"""Composer trust controls for GHSA-6q48-6c7w-9gw2. + +Every executable Composer image is the reviewed 2.10.3 digest, and that +image's own CA store is checked. Where Composer runs in an image whose store +is not checked (frozen published Server images), it gets COMPOSER_CAFILE (a +checked file) and COMPOSER_CAPATH (an empty directory). A real-Composer +control proves that pairing: a local HTTPS repository is signed by a +synthetic withdrawn root that only the container's system store trusts. + +Run with: python3 -m unittest tests/Unit/Support/composer_trust_test.py +The image and real-Composer tests need docker; set +DW_REQUIRE_COMPOSER_TRUST_TEST=1 to fail rather than skip without it. +""" + +from __future__ import annotations + +import http.server +import json +import os +import re +import shutil +import ssl +import subprocess +import tempfile +import threading +import unittest +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +CI = ROOT / "scripts" / "ci" +PINNED_COMPOSER = "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" +SEPARATOR = r"""[\s'",]+""" +# Composer image references, pinned or not, and Composer network commands. +COMPOSER_IMAGE = re.compile(r"""(? list[Path]: + files = [path for directory in SCANNED for path in sorted((root / directory).rglob("*")) + if path.is_file() and "vendor" not in path.relative_to(root).parts] + return files + [path for path in (root / "Dockerfile", *sorted(root.glob("docker-compose*.yml"))) if path.is_file()] + + +def composer_sinks(root: Path) -> Counter[tuple[str, str]]: + sinks: Counter[tuple[str, str]] = Counter() + for path in scanned_files(root): + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + for match in COMPOSER_SINK.finditer(text): + start = text.rfind("\n", 0, match.start()) + 1 + end = text.find("\n", match.start()) + sinks[(path.relative_to(root).as_posix(), text[start:end if end >= 0 else None].strip())] += 1 + return sinks + + +def unreviewed_sinks(root: Path, entries: list[dict[str, object]]) -> tuple[list[str], list[str]]: + reviewed = Counter({(str(entry["path"]), str(entry["line"])): int(entry["count"]) for entry in entries}) + found = composer_sinks(root) + describe = lambda sinks: [f"{path}: {line} (x{count})" for (path, line), count in sorted(sinks.items())] + return describe(found - reviewed), describe(reviewed - found) + + +def docker_or_skip(test: unittest.TestCase) -> None: + if shutil.which("docker") and subprocess.run(["docker", "info"], capture_output=True).returncode == 0: + return + if os.environ.get("DW_REQUIRE_COMPOSER_TRUST_TEST") == "1": + raise AssertionError("docker is required for the Composer trust controls") + test.skipTest("needs docker") + + +class ComposerInventoryTest(unittest.TestCase): + def setUp(self) -> None: + self.entries = json.loads(MANIFEST.read_text())["sinks"] + + def test_every_composer_image_is_the_pinned_digest(self) -> None: + floating = [] + for path in scanned_files(ROOT): + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + for match in COMPOSER_IMAGE.finditer(text): + if re.sub(r"^(?:docker\.io/)?(?:library/)?", "", match.group(0)) != PINNED_COMPOSER: + floating.append(f"{path.relative_to(ROOT).as_posix()}: {match.group(0)}") + + self.assertEqual([], floating, f"use {PINNED_COMPOSER}") + + def test_every_composer_sink_is_reviewed(self) -> None: + unreviewed, stale = unreviewed_sinks(ROOT, self.entries) + + self.assertEqual([], unreviewed, f"confine these Composer sinks, then record them in {MANIFEST.name}") + self.assertEqual([], stale, f"remove these entries from {MANIFEST.name}") + + def test_every_reviewed_sink_names_its_confinement(self) -> None: + for entry in self.entries: + with self.subTest(entry["path"]): + self.assertTrue(str(entry.get("confinement", "")).strip()) + + def test_unconfined_sinks_fail_the_review(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-composer-sinks-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + cases = { + "scripts/conformance/floating.sh": "docker run --rm -v \"$PWD:/app\" composer:2 install\n", + "scripts/conformance/runner.sh": "# runs in the pinned image\ncomposer require durable-workflow/sdk\n", + "scripts/conformance/self-update.sh": "composer self-update --2\n", + ".github/workflows/audit.yml": "jobs:\n audit:\n steps:\n - run: composer audit --locked\n", + } + for relative, text in cases.items(): + (work / relative).parent.mkdir(parents=True, exist_ok=True) + (work / relative).write_text(text) + + unreviewed, _ = unreviewed_sinks(work, self.entries) + + for relative in cases: + with self.subTest(relative): + self.assertTrue(any(item.startswith(f"{relative}: ") for item in unreviewed), unreviewed) + + +class PinnedComposerImageTest(unittest.TestCase): + def test_the_pinned_image_store_holds_no_withdrawn_root(self) -> None: + docker_or_skip(self) + work = Path(tempfile.mkdtemp(prefix="dw-composer-store-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + bundle = subprocess.run( + ["docker", "run", "--rm", "--network", "none", "--entrypoint", "cat", PINNED_COMPOSER, + "/etc/ssl/certs/ca-certificates.crt"], + check=True, capture_output=True, text=True, + ).stdout + (work / "ca.pem").write_text(bundle) + result = subprocess.run(["python3", "-I", str(CI / "blocked_roots.py"), str(work / "ca.pem")], + capture_output=True, text=True) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_the_checked_ca_helper_writes_the_pinned_store_and_an_empty_directory(self) -> None: + docker_or_skip(self) + work = Path(tempfile.mkdtemp(prefix="dw-checked-ca-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + result = subprocess.run(["sh", str(CI / "checked-ca.sh"), str(work / "trust")], capture_output=True, text=True) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertGreater((work / "trust" / "ca.pem").stat().st_size, 0) + self.assertEqual([], list((work / "trust" / "no-ca-directory").iterdir())) + + +class _RecordingHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - http.server naming + self.server.paths.append(self.path) # type: ignore[attr-defined] + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + +class _QuietServer(http.server.ThreadingHTTPServer): + # A refused handshake drops the connection; that is the expected outcome. + def handle_error(self, request: object, client_address: object) -> None: + pass + + +class RealComposerTrustTest(unittest.TestCase): + """Composer in a container whose system store trusts a synthetic withdrawn root.""" + + @classmethod + def setUpClass(cls) -> None: + if not shutil.which("openssl"): + raise unittest.SkipTest("needs openssl") + cls.work = Path(tempfile.mkdtemp(prefix="dw-composer-trust-")) + cls.work.chmod(0o755) + + def openssl(*arguments: str) -> None: + subprocess.run(["openssl", *arguments], check=True, capture_output=True) + + root = cls.work / "withdrawn" + openssl("req", "-x509", "-nodes", "-days", "1", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", "/CN=AAA Certificate Services", "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", "-keyout", f"{root}.key", "-out", f"{root}.pem") + leaf = cls.work / "leaf" + openssl("req", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1", + "-keyout", f"{leaf}.key", "-out", f"{leaf}.csr") + openssl("x509", "-req", "-days", "1", "-in", f"{leaf}.csr", "-CA", f"{root}.pem", "-CAkey", f"{root}.key", + "-CAcreateserial", "-copy_extensions", "copy", "-out", f"{leaf}.pem") + clean = cls.work / "clean" + openssl("req", "-x509", "-nodes", "-days", "1", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-subj", "/CN=Durable Workflow Clean Test Root", "-keyout", f"{clean}.key", "-out", f"{clean}.pem") + (cls.work / "no-ca-directory").mkdir() + for path in cls.work.iterdir(): + path.chmod(0o755 if path.is_dir() else 0o644) + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(f"{leaf}.pem", f"{leaf}.key") + cls.server = _QuietServer(("127.0.0.1", 0), _RecordingHandler) + cls.server.paths = [] # type: ignore[attr-defined] + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + + @classmethod + def tearDownClass(cls) -> None: + if hasattr(cls, "server"): + cls.server.shutdown() + cls.server.server_close() + shutil.rmtree(getattr(cls, "work", "/nonexistent"), ignore_errors=True) + + def request(self, *environment: str) -> tuple[subprocess.CompletedProcess[str], list[str]]: + docker_or_skip(self) + self.server.paths.clear() # type: ignore[attr-defined] + port = self.server.server_address[1] + # Trust the synthetic root through the container's own system store, + # then ask Composer for a package from the local repository only. + script = ( + "cp /trust/withdrawn.pem /usr/local/share/ca-certificates/withdrawn.crt && update-ca-certificates >/dev/null 2>&1" + f" && COMPOSER_HOME=/tmp/composer composer create-project --no-interaction --no-plugins --no-install" + f" --repository=https://127.0.0.1:{port} dw/trust-control /tmp/out" + ) + result = subprocess.run( + ["docker", "run", "--rm", "--network", "host", "-v", f"{self.work}:/trust:ro", *environment, + "--entrypoint", "sh", PINNED_COMPOSER, "-c", script], + capture_output=True, text=True, timeout=180, + ) + return result, list(self.server.paths) # type: ignore[attr-defined] + + def test_the_system_store_root_is_trusted_by_unconfined_composer(self) -> None: + result, paths = self.request() + + self.assertIn("/packages.json", paths, result.stdout + result.stderr) + + def test_a_checked_cafile_with_an_empty_capath_refuses(self) -> None: + result, paths = self.request( + "-e", "COMPOSER_CAFILE=/trust/clean.pem", "-e", "COMPOSER_CAPATH=/trust/no-ca-directory", + ) + + self.assertEqual([], paths, "Composer reached the server through the withdrawn root") + self.assertNotEqual(0, result.returncode) diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py index 91b9418f9..e637dd9b1 100644 --- a/tests/Unit/Support/patched_pip_test.py +++ b/tests/Unit/Support/patched_pip_test.py @@ -8,6 +8,7 @@ from __future__ import annotations import http.server +import json import os import re import shutil @@ -17,6 +18,7 @@ import tempfile import threading import unittest +from collections import Counter from pathlib import Path ROOT = Path(__file__).resolve().parents[3] @@ -40,24 +42,41 @@ esac """ -# pip installs, downloads and upgrades, in shell, YAML, Python or JS argument -# lists: "pip install", "-m pip --isolated install", "'-m', 'pip', 'install'". SEPARATOR = r"""[\s'",]+""" -PIP_COMMAND = re.compile( - rf"""\bpip3?(?:{SEPARATOR}--[\w-]+(?:=[^\s'",]+)?)*{SEPARATOR}(?:install|download)\b""" +# Every place pip can make a request: "pip install", "-m pip --isolated +# download", "'-m', 'pip', 'wheel'" and the like in shell, YAML, Python or JS +# argument lists; any "-m pip" whose arguments come from elsewhere; and the +# actions that run pip themselves. +PIP_SINK = re.compile( + rf"""-m{SEPARATOR}pip\b|\bpip3?(?:{SEPARATOR}--[\w-]+(?:=[^\s'",]+)?)*{SEPARATOR}(?:install|download|wheel|index)\b""" + r"""|uses:\s*(?:helm/chart-testing-action|durable-workflow/\.github/\.github/actions/bootstrap-patched-pip)@""" ) -PATCHED = re.compile(r"bootstrap-patched-pip|patched-pip\.sh|PATCHED_PIP_BOOTSTRAP") -SCANNED = ("scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") -# Mentions that are not executed pip commands. -NOT_EXECUTED = { - "scripts/conformance/server-parity/published-sdk-patch/identity.mjs": "assertion text about pip's install report", -} -# Workflow jobs whose pip use the repository cannot confine yet. -UNCONFINED_JOBS = { - # The central policy fixes this job's exact steps and forbids job env. - # It is confined when the central bootstrap action itself confines pip. - ("phpunit-feature.yml", "action-policy"), -} +SCANNED = (".github/workflows", "scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") +# The reviewed sinks: exact line, count per file, and how each is confined. +MANIFEST = ROOT / "tests" / "Unit" / "Support" / "pip-sinks.json" + + +def pip_sinks(root: Path) -> Counter[tuple[str, str]]: + files = [path for directory in SCANNED for path in sorted((root / directory).rglob("*")) if path.is_file()] + files += [path for path in (root / "Dockerfile", *sorted(root.glob("docker-compose*.yml"))) if path.is_file()] + sinks: Counter[tuple[str, str]] = Counter() + for path in files: + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + for match in PIP_SINK.finditer(text): + start = text.rfind("\n", 0, match.start()) + 1 + end = text.find("\n", match.start()) + sinks[(path.relative_to(root).as_posix(), text[start:end if end >= 0 else None].strip())] += 1 + return sinks + + +def unreviewed_sinks(root: Path, entries: list[dict[str, object]]) -> tuple[list[str], list[str]]: + reviewed = Counter({(str(entry["path"]), str(entry["line"])): int(entry["count"]) for entry in entries}) + found = pip_sinks(root) + describe = lambda sinks: [f"{path}: {line} (x{count})" for (path, line), count in sorted(sinks.items())] + return describe(found - reviewed), describe(reviewed - found) def make_root(directory: Path, common_name: str) -> str: @@ -190,6 +209,12 @@ def log_message(self, format: str, *args: object) -> None: # noqa: A002 pass +class _QuietServer(http.server.ThreadingHTTPServer): + # A refused handshake drops the connection; that is the expected outcome. + def handle_error(self, request: object, client_address: object) -> None: + pass + + class RealPipTrustTest(unittest.TestCase): """Real pip 26.2.2 against a root trusted only through the OS store. @@ -232,7 +257,7 @@ def setUpClass(cls) -> None: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(f"{leaf}.pem", f"{leaf}.key") - cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler) + cls.server = _QuietServer(("127.0.0.1", 0), _RecordingHandler) cls.server.paths = [] # type: ignore[attr-defined] cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) threading.Thread(target=cls.server.serve_forever, daemon=True).start() @@ -334,27 +359,47 @@ def test_patched_pip_wrapper_keeps_pip_status_and_removes_its_venv(self) -> None class PatchedPipInventoryTest(unittest.TestCase): - def test_every_script_pip_command_follows_the_patched_bootstrap(self) -> None: - unpatched = [] - for directory in SCANNED: - for path in sorted((ROOT / directory).rglob("*")): - relative = path.relative_to(ROOT).as_posix() - if not path.is_file() or relative in NOT_EXECUTED or path.parent == CI: - continue - try: - text = path.read_text() - except (UnicodeDecodeError, OSError): - continue - first_bootstrap = PATCHED.search(text) - for command in PIP_COMMAND.finditer(text): - line = text[text.rfind("\n", 0, command.start()) + 1:text.find("\n", command.end())] - if "patched-pip.sh" in line: - continue - if first_bootstrap is None or first_bootstrap.start() > command.start(): - number = text.count("\n", 0, command.start()) + 1 - unpatched.append(f"{relative}:{number}: {line.strip()}") + def setUp(self) -> None: + self.entries = json.loads(MANIFEST.read_text())["sinks"] + + def test_every_pip_sink_is_reviewed(self) -> None: + unreviewed, stale = unreviewed_sinks(ROOT, self.entries) + + self.assertEqual([], unreviewed, f"confine these pip sinks, then record them in {MANIFEST.name}") + self.assertEqual([], stale, f"remove these entries from {MANIFEST.name}") + + def test_every_reviewed_sink_names_its_confinement(self) -> None: + for entry in self.entries: + with self.subTest(entry["path"]): + self.assertTrue(str(entry.get("confinement", "")).strip()) + + def test_unconfined_sinks_fail_the_review(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-pip-sinks-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + cases = { + # Mentioning the bootstrap does not confine a later install. + "scripts/conformance/mention-only.sh": "# sh scripts/ci/bootstrap-patched-pip.sh is our policy\npython -m pip install durable-workflow\n", + "scripts/conformance/wheel.sh": "pip wheel --wheel-dir /tmp/w durable-workflow\n", + "scripts/conformance/index.sh": "python3 -m pip index versions durable-workflow\n", + "scripts/conformance/args.py": "subprocess.run([python, '-m', 'pip', *arguments])\n", + # Echoing the confinement strings does not confine the job. + ".github/workflows/echo.yml": "jobs:\n echo:\n steps:\n - run: |\n" + " echo bootstrap-patched-pip@ PIP_USE_DEPRECATED=legacy-certs\n" + " python -m pip install PyYAML\n", + } + for relative, text in cases.items(): + (work / relative).parent.mkdir(parents=True, exist_ok=True) + (work / relative).write_text(text) + # A second sink added to a reviewed file fails as well. + reviewed = "scripts/conformance/sagas-published-artifacts.sh" + (work / reviewed).parent.mkdir(parents=True, exist_ok=True) + (work / reviewed).write_text((ROOT / reviewed).read_text() + '\npython -m pip install "durable-workflow==$python_version" httpx\n') + + unreviewed, _ = unreviewed_sinks(work, self.entries) - self.assertEqual([], unpatched, "start these pip commands from scripts/ci/bootstrap-patched-pip.sh") + for relative in [*cases, reviewed]: + with self.subTest(relative): + self.assertTrue(any(item.startswith(f"{relative}: ") for item in unreviewed), unreviewed) def test_isolated_pip_commands_keep_legacy_certs(self) -> None: # --isolated ignores the venv's pip.conf, so the command itself must @@ -375,31 +420,6 @@ def test_isolated_pip_commands_keep_legacy_certs(self) -> None: self.assertEqual([], loose) - def test_every_workflow_job_with_pip_confines_it(self) -> None: - unconfined = [] - for workflow in sorted((ROOT / ".github" / "workflows").glob("*.yml")): - text = workflow.read_text() - jobs = text.split("\njobs:\n", 1)[1] if "\njobs:\n" in text else "" - for job in re.split(r"\n(?= [\w-]+:\s*\n)", "\n" + jobs): - name = re.match(r"\s*([\w-]+):", job) - if not name or not PIP_COMMAND.search(job): - continue - key = (workflow.name, name.group(1)) - if key in UNCONFINED_JOBS: - continue - confined = "patched-pip.sh" in job or ( - "bootstrap-patched-pip@" in job and re.search(r"PIP_USE_DEPRECATED[=:]\s*legacy-certs", job) - ) - if not confined: - unconfined.append(f"{workflow.name} job {name.group(1)}") - - self.assertEqual([], unconfined, "bootstrap pip and confine it to its checked bundle in these jobs") - - def test_known_unconfined_jobs_still_exist(self) -> None: - for workflow, job in UNCONFINED_JOBS: - text = (ROOT / ".github" / "workflows" / workflow).read_text() - self.assertRegex(text, rf"\n {re.escape(job)}:\s*\n", f"remove the stale exception for {workflow} {job}") - if __name__ == "__main__": unittest.main() diff --git a/tests/Unit/Support/pip-sinks.json b/tests/Unit/Support/pip-sinks.json new file mode 100644 index 000000000..e376302d2 --- /dev/null +++ b/tests/Unit/Support/pip-sinks.json @@ -0,0 +1,235 @@ +{ + "schema": "durable-workflow.server.pip-sinks/v1", + "advisory": "GHSA-6q48-6c7w-9gw2", + "rule": "Every place pip can make a request is listed here with how it is confined. A new or changed sink fails tests/Unit/Support/patched_pip_test.py until it is confined and recorded.", + "sinks": [ + { + "path": ".github/workflows/helm-chart-checks.yml", + "line": "python -m pip install --only-binary :all: PyYAML==6.0.2", + "count": 1, + "confinement": "Central bootstrap-patched-pip earlier in the job; this step's env sets PIP_USE_DEPRECATED=legacy-certs, so pip uses only the bundle the action checked." + }, + { + "path": ".github/workflows/helm-chart-checks.yml", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "count": 2, + "confinement": "The central bootstrap action: installs the hash-pinned pip 26.2.2 and checks its bundle." + }, + { + "path": ".github/workflows/helm-chart-checks.yml", + "line": "uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0", + "count": 1, + "confinement": "The action installs yamllint/yamale with its own venv pip; its step env sets PIP_CERT to the bootstrapped pip's checked bundle and PIP_USE_DEPRECATED=legacy-certs." + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "run: python -m pip install PyYAML==6.0.2", + "count": 1, + "confinement": "KNOWN EXCEPTION, owned by .github #239: the central policy fixes this preflight job's exact steps and forbids job env, so it is confined only when the pin moves to the accepted central action head." + }, + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "count": 1, + "confinement": "The central bootstrap action at the policy's reviewed step." + }, + { + "path": ".github/workflows/server-perf.yml", + "line": "python3 -m pip install \\", + "count": 1, + "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job." + }, + { + "path": ".github/workflows/server-perf.yml", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "count": 1, + "confinement": "The central bootstrap action." + }, + { + "path": "benchmarks/capacity/v1/README.md", + "line": "/tmp/capacity-venv/bin/python -m pip install -r benchmarks/capacity/v1/bindings/python/requirements.lock", + "count": 1, + "confinement": "Documentation; the instructions bootstrap /tmp/capacity-venv first." + }, + { + "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", + "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", + "count": 1, + "confinement": "sh -e: bootstrap-patched-pip.sh on /tmp/capacity-venv, then that venv (first on PATH) installs." + }, + { + "path": "scripts/ci/bootstrap-patched-pip.sh", + "line": "\"$python\" -I -m pip --isolated --disable-pip-version-check install --no-deps --upgrade --quiet --no-cache-dir \\", + "count": 1, + "confinement": "The bootstrap itself: installs the hash-pinned pip wheel, so the download is verified by hash." + }, + { + "path": "scripts/ci/bootstrap-patched-pip.sh", + "line": "# networked pip install, refuses a pip whose CA bundle still trusts a root", + "count": 1, + "confinement": "Comment." + }, + { + "path": "scripts/ci/patched-pip.sh", + "line": "\"$work/pip/bin/python\" -m pip \"$@\" || status=$?", + "count": 1, + "confinement": "The wrapper: runs pip from a venv it has just bootstrapped (set -e)." + }, + { + "path": "scripts/conformance/activities-published-artifacts.sh", + "line": "&& \"$venv/bin/python\" -m pip download --disable-pip-version-check --no-deps \\", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + }, + { + "path": "scripts/conformance/activities-published-artifacts.sh", + "line": "&& \"$venv/bin/python\" -m pip install --disable-pip-version-check --no-input \"$distribution\" >>\"$install_log\" 2>&1; then", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + }, + { + "path": "scripts/conformance/child-workflows-install-probe.py", + "line": "install_command = [str(python_bin), \"-m\", \"pip\", \"install\", \"--disable-pip-version-check\", \"--no-input\", f\"durable-workflow=={python_version}\"]", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (bootstrap_code == 0)." + }, + { + "path": "scripts/conformance/child-workflows-published-artifacts.sh", + "line": "&& \"$venv/bin/python\" -m pip install --disable-pip-version-check --no-input \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$install_log\" 2>&1; then", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + }, + { + "path": "scripts/conformance/namespaces-published-artifacts.sh", + "line": "if ! \"$run_root/.venv/bin/python\" -m pip install \"durable-workflow==$python_sdk_version\" > \"$result_dir/python-sdk-install.log\" 2>&1; then", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check, then blocked_result)." + }, + { + "path": "scripts/conformance/nexus-published-artifacts.sh", + "line": "`python -m venv /tmp/dw-python && sh /work/dw-ci/bootstrap-patched-pip.sh /tmp/dw-python/bin/python >/tmp/dw-python-pip.log 2>&1 && . /tmp/dw-python/bin/activate && pip install --no-cache-dir durable-workflow==${JSON.stringify(versions['sdk-python']).slice(1, -1)} >/tmp/dw-python-pip.log 2>&1 && python /work/nexus-python-sdk-service.py`,", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /tmp/dw-python in the Python service (&& chain)." + }, + { + "path": "scripts/conformance/principal-attribution-published-artifacts.sh", + "line": "\"-m\",", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runner_blocked otherwise)." + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "line": "python -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise)." + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "line": "python -m pip install \"$python_distribution\" > \"$result_dir/python-sdk-install.log\" 2>&1 \\", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise)." + }, + { + "path": "scripts/conformance/replay-published-artifacts.sh", + "line": "python -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed)." + }, + { + "path": "scripts/conformance/replay-published-artifacts.sh", + "line": "python -m pip install \"$python_distribution\" > \"$result_dir/python-install.log\" 2>&1", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed)." + }, + { + "path": "scripts/conformance/sagas-published-artifacts.sh", + "line": "python -m pip install \"durable-workflow==$python_version\" httpx", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; set -e)." + }, + { + "path": "scripts/conformance/schedules-published-artifacts.mjs", + "line": "['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`],", + "count": 2, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (execLogged throws otherwise)." + }, + { + "path": "scripts/conformance/server-parity/published-sdk-patch/identity.mjs", + "line": "assert.ok(installed, 'published Python SDK in the actual pip install report');", + "count": 1, + "confinement": "Assertion text about pip's install report; not a command." + }, + { + "path": "scripts/conformance/signals-queries-published-artifacts.sh", + "line": "\"-m\",", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise)." + }, + { + "path": "scripts/conformance/signals-queries-published-artifacts.sh", + "line": "[str(python_bin), \"-m\", \"pip\", \"install\", str(distribution)],", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise)." + }, + { + "path": "scripts/conformance/skew-published-artifacts.sh", + "line": "&& \"$run_root/.venv/bin/python\" -m pip install \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$result_dir/python-install.log\" 2>&1; then", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + }, + { + "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", + "line": "&& pip install --no-cache-dir --report /opt/python/install-report.json \"durable-workflow==${PYTHON_SDK_VERSION}\"", + "count": 1, + "confinement": "Same RUN && chain bootstraps /opt/python (first on PATH) before this install." + }, + { + "path": "scripts/conformance/worker-versioning-published-workers.mjs", + "line": "command: `python3 -m pip install durable-workflow==${pythonVersion}`,", + "count": 3, + "confinement": "Evidence receipt text; not executed." + }, + { + "path": "scripts/conformance/worker-versioning-published-workers.mjs", + "line": "runRequired(pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], {", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runRequired throws otherwise)." + }, + { + "path": "scripts/conformance/workflow-lifecycle-published-artifacts.sh", + "line": "|| ! \"$python_bin\" -m pip install --disable-pip-version-check --no-input \"durable-workflow==${sdk_version}\" >>\"$result_dir/python-sdk-lifecycle-pip-install.log\" 2>&1; then", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (short-circuits otherwise)." + }, + { + "path": "scripts/conformance/workflow-updates-published-artifacts.sh", + "line": "if ! \"$venv_python\" -m pip --version > \"$result_dir/python-sdk-pip-version.log\" 2>&1; then", + "count": 1, + "confinement": "Local version query; no request." + }, + { + "path": "scripts/conformance/workflow-updates-published-artifacts.sh", + "line": "if ! PIP_CONFIG_FILE=/dev/null \"$venv_python\" -m pip --isolated install \\", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check); --isolated, so it passes --use-deprecated=legacy-certs itself." + }, + { + "path": "scripts/perf/run-namespace-isolation.sh", + "line": "\"$VENV/bin/pip\" install --quiet --disable-pip-version-check \"durable-workflow==$SDK_VERSION\"", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter $VENV (set -e)." + }, + { + "path": "tests/Fixtures/UpdateRequestKeyConsumer/compose.yml", + "line": "/opt/venv/bin/pip install --index-url https://pypi.org/simple --no-cache-dir --quiet durable-workflow==2.6.1", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /opt/venv (sh -e)." + }, + { + "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", + "line": "&& /sdk/venv/bin/pip install --no-cache-dir \"durable-workflow==${PYTHON_SDK_VERSION}\"", + "count": 1, + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /sdk/venv (RUN && chain)." + } + ] +} diff --git a/tests/Unit/WorkerVersioningConformanceRunnerContractTest.php b/tests/Unit/WorkerVersioningConformanceRunnerContractTest.php index cdf32aac6..44644764a 100644 --- a/tests/Unit/WorkerVersioningConformanceRunnerContractTest.php +++ b/tests/Unit/WorkerVersioningConformanceRunnerContractTest.php @@ -1009,9 +1009,9 @@ public function test_worker_versioning_runner_records_published_waterline_visibi 'Waterline visibility must come from the published Waterline shard, not a placeholder.', ); $this->assertStringNotContainsString( - "image: composer:2\n working_dir: /app", + "image: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\n working_dir: /app", $shell, - 'the disposable Waterline service must not run under composer:2 because it lacks pdo_mysql', + 'the disposable Waterline service must not run under composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee because it lacks pdo_mysql', ); $this->assertStringNotContainsString( 'waterline_runtime_image="${DW_WV_WATERLINE_RUNTIME_IMAGE:-$server_image}"', @@ -1660,7 +1660,7 @@ public function test_shell_refreshes_server_url_after_waterline_compose_rebinds_ printf '%s\n%s\n' 'PDO' 'pdo_mysql' exit 0 ;; - *" composer:2 "*) + *" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee "*) exit 0 ;; esac From aaa9a7be218f2f389fbe6e4f5733f14bdf7a38cd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 19:01:02 +0000 Subject: [PATCH 06/21] Adopt the accepted central trust action and drop the checks ref input Every bootstrap-patched-pip use moves from ff93e15d to the Security-accepted 75587fc3 (clean-trust, .github #239). It exports the checked bundle with an empty SSL_CERT_DIR to later steps, so the action-policy preflight's PyYAML install is no longer an exception. helm-chart-checks.yml no longer takes a source_ref input: both callers passed github.sha, so checkout now uses the caller's commit and the dispatch-shaped ref dataflow CodeQL flagged is gone. Pip requests after a checkout run as python -I -m pip, so a checked-out pip package cannot shadow the installed client. GHSA-6q48-6c7w-9gw2 --- .github/workflows/helm-chart-checks.yml | 14 ++++-------- .github/workflows/helm-chart-release.yml | 2 -- .github/workflows/helm-chart-validation.yml | 2 -- .github/workflows/phpunit-feature.yml | 2 +- .github/workflows/server-perf.yml | 4 ++-- tests/Unit/Support/pip-sinks.json | 24 ++++++++++----------- 6 files changed, 19 insertions(+), 29 deletions(-) diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index eeed55a4a..52efe2a93 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -1,12 +1,8 @@ name: Reusable Helm Chart Checks on: + # Callers validate their own commit; checkout uses it by default. workflow_call: - inputs: - source_ref: - description: Exact Server source revision to validate - required: true - type: string permissions: contents: read @@ -35,7 +31,6 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ inputs.source_ref }} - name: Enforce immutable chart versioning env: @@ -67,7 +62,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Check database CA mounts and bootstrap configuration # pip also trusts the runner's own store (truststore). legacy-certs @@ -75,7 +70,7 @@ jobs: env: PIP_USE_DEPRECATED: legacy-certs run: | - python -m pip install --only-binary :all: PyYAML==6.0.2 + python -I -m pip install --only-binary :all: PyYAML==6.0.2 python scripts/ci/test_database_tls_chart.py - name: Render every CI fixture @@ -141,7 +136,6 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ inputs.source_ref }} - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -149,7 +143,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 diff --git a/.github/workflows/helm-chart-release.yml b/.github/workflows/helm-chart-release.yml index 6f1816c4e..e56b8e0a5 100644 --- a/.github/workflows/helm-chart-release.yml +++ b/.github/workflows/helm-chart-release.yml @@ -25,8 +25,6 @@ jobs: permissions: contents: read uses: ./.github/workflows/helm-chart-checks.yml # local - with: - source_ref: ${{ github.sha }} publish: name: Publish protected chart source diff --git a/.github/workflows/helm-chart-validation.yml b/.github/workflows/helm-chart-validation.yml index 439d676f7..667c364ba 100644 --- a/.github/workflows/helm-chart-validation.yml +++ b/.github/workflows/helm-chart-validation.yml @@ -26,5 +26,3 @@ jobs: permissions: contents: read uses: ./.github/workflows/helm-chart-checks.yml # local - with: - source_ref: ${{ github.sha }} diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index ce2e0b641..799362cd8 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -42,7 +42,7 @@ jobs: with: python-version: "3.13" - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Install policy dependencies run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits diff --git a/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 3b31d758c..38881063b 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -178,7 +178,7 @@ jobs: python-version: "3.13" - name: Bootstrap patched pip trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust # pip also trusts the runner's own store (truststore). Confine every # later pip in this job to the bundle the bootstrap action checked. @@ -206,7 +206,7 @@ jobs: php benchmarks/capacity/v1/bindings/php/capacity_adapter.php check rm -rf benchmarks/capacity/v1/bindings/php/vendor - python3 -m pip install \ + python3 -I -m pip install \ --requirement benchmarks/capacity/v1/bindings/python/requirements.lock \ --target build/capacity-python-adapter PYTHONPATH=build/capacity-python-adapter \ diff --git a/tests/Unit/Support/pip-sinks.json b/tests/Unit/Support/pip-sinks.json index e376302d2..e292e4358 100644 --- a/tests/Unit/Support/pip-sinks.json +++ b/tests/Unit/Support/pip-sinks.json @@ -5,15 +5,15 @@ "sinks": [ { "path": ".github/workflows/helm-chart-checks.yml", - "line": "python -m pip install --only-binary :all: PyYAML==6.0.2", + "line": "python -I -m pip install --only-binary :all: PyYAML==6.0.2", "count": 1, - "confinement": "Central bootstrap-patched-pip earlier in the job; this step's env sets PIP_USE_DEPRECATED=legacy-certs, so pip uses only the bundle the action checked." + "confinement": "Central bootstrap-patched-pip earlier in the job; this step's env sets PIP_USE_DEPRECATED=legacy-certs, so pip uses only the bundle the action checked. `-I` keeps a checked-out `pip` package from shadowing the installed client." }, { "path": ".github/workflows/helm-chart-checks.yml", - "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 2, - "confinement": "The central bootstrap action: installs the hash-pinned pip 26.2.2 and checks its bundle." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps." }, { "path": ".github/workflows/helm-chart-checks.yml", @@ -25,25 +25,25 @@ "path": ".github/workflows/phpunit-feature.yml", "line": "run: python -m pip install PyYAML==6.0.2", "count": 1, - "confinement": "KNOWN EXCEPTION, owned by .github #239: the central policy fixes this preflight job's exact steps and forbids job env, so it is confined only when the pin moves to the accepted central action head." + "confinement": "The clean-trust action at the preflight's reviewed step exports the checked bundle as PIP_CERT and SSL_CERT_FILE with an empty SSL_CERT_DIR, so pip and its truststore see only that bundle." }, { "path": ".github/workflows/phpunit-feature.yml", - "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 1, - "confinement": "The central bootstrap action at the policy's reviewed step." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps. At the policy's reviewed preflight step." }, { "path": ".github/workflows/server-perf.yml", - "line": "python3 -m pip install \\", + "line": "python3 -I -m pip install \\", "count": 1, - "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job." + "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job. `-I` keeps a checked-out `pip` package from shadowing the installed client." }, { "path": ".github/workflows/server-perf.yml", - "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@ff93e15dd3d3aa0b5ba8991fe5101a9e1be49c10 # patched-pip", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 1, - "confinement": "The central bootstrap action." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps." }, { "path": "benchmarks/capacity/v1/README.md", @@ -61,7 +61,7 @@ "path": "scripts/ci/bootstrap-patched-pip.sh", "line": "\"$python\" -I -m pip --isolated --disable-pip-version-check install --no-deps --upgrade --quiet --no-cache-dir \\", "count": 1, - "confinement": "The bootstrap itself: installs the hash-pinned pip wheel, so the download is verified by hash." + "confinement": "The bootstrap itself: installs the hash-pinned pip wheel, so the download is verified by hash. `-I` keeps a checked-out `pip` package from shadowing the installed client." }, { "path": "scripts/ci/bootstrap-patched-pip.sh", From 7d77558fb8fc5e73bf10edd670c88aeaab875fea Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 19:14:25 +0000 Subject: [PATCH 07/21] Hash the capacity Python lock and install only its locked wheels Sonar flagged the server-perf capacity adapter install (S8541, S8544): its lock pinned versions without hashes and pip could run build scripts. The lock now carries every PyPI file hash for each pin. The CI step, the local-docker worker and the README install with --require-hashes, --only-binary :all: and --no-deps, and the capacity suite refuses a lock line without sha256 hashes. --- .github/workflows/server-perf.yml | 2 +- benchmarks/capacity/v1/README.md | 3 +- .../v1/bindings/python/requirements.lock | 18 +++++------ .../v1/topologies/local-docker/compose.json | 2 +- scripts/benchmark/capacity_suite.py | 20 ++++++++---- tests/Unit/Support/capacity_benchmark_test.py | 31 +++++++++++++++++++ tests/Unit/Support/pip-sinks.json | 12 +++---- 7 files changed, 64 insertions(+), 24 deletions(-) diff --git a/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 38881063b..3ab8d40b6 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -206,7 +206,7 @@ jobs: php benchmarks/capacity/v1/bindings/php/capacity_adapter.php check rm -rf benchmarks/capacity/v1/bindings/php/vendor - python3 -I -m pip install \ + python3 -I -m pip install --require-hashes --only-binary :all: --no-deps \ --requirement benchmarks/capacity/v1/bindings/python/requirements.lock \ --target build/capacity-python-adapter PYTHONPATH=build/capacity-python-adapter \ diff --git a/benchmarks/capacity/v1/README.md b/benchmarks/capacity/v1/README.md index c88458e2d..1fba8618d 100644 --- a/benchmarks/capacity/v1/README.md +++ b/benchmarks/capacity/v1/README.md @@ -64,7 +64,8 @@ Install each adapter from its own immutable package manifest before a run: (cd benchmarks/capacity/v1/bindings/php && composer install --no-dev) python3 -m venv /tmp/capacity-venv sh scripts/ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python - /tmp/capacity-venv/bin/python -m pip install -r benchmarks/capacity/v1/bindings/python/requirements.lock + /tmp/capacity-venv/bin/python -m pip install --require-hashes --only-binary :all: --no-deps \ + -r benchmarks/capacity/v1/bindings/python/requirements.lock (cd benchmarks/capacity/v1/bindings/rust && cargo build --release --locked) Activate `/tmp/capacity-venv` before running the Python adapter. The bootstrap diff --git a/benchmarks/capacity/v1/bindings/python/requirements.lock b/benchmarks/capacity/v1/bindings/python/requirements.lock index cb9885bd1..5bd545b27 100644 --- a/benchmarks/capacity/v1/bindings/python/requirements.lock +++ b/benchmarks/capacity/v1/bindings/python/requirements.lock @@ -1,9 +1,9 @@ -anyio==4.14.2 -certifi==2026.10.19 -durable-workflow==2.0.1 -fastavro==1.12.2 -h11==0.16.0 -httpcore==1.0.9 -httpx==0.28.1 -idna==3.18 -typing-extensions==4.16.0 +anyio==4.14.2 --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f +certifi==2026.10.19 --hash=sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae --hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b +durable-workflow==2.0.1 --hash=sha256:4a099f496bd8fd04b69376d00e586bf0f4a2bc01177e5351f9a7539958175472 --hash=sha256:9dcef113c0b5dfed9c21f9d70a7c49d1c262268f07ed38f9cbb5c31a0f27be2d +fastavro==1.12.2 --hash=sha256:030f17eb4c7978538a31b55dea451ceace851a88dc9816b1923f8fb8a260db4c --hash=sha256:03614131093a32c90c8fd95ff356c316752b8850cb8a770bc96cef17a003e2fc --hash=sha256:044fafca0853e9ae14009de7763ac9e8e8f8b96f8a4e90bd58b695443266a370 --hash=sha256:06b6971d3dae10cb34353b857d16ad21ebd6f0ea394e86c96abdcad109005d6e --hash=sha256:070c6134604bd7b6fd44409406ac50445339682b2e872885db2e859f92d22e93 --hash=sha256:0e331896e8efffc72fa03e63b87ebfc37960113127da8e0f5152d91664ffed68 --hash=sha256:1924349c74666c89417bd5cc2749f598e2f15f1d56ee81428b2317ab02c88aae --hash=sha256:2328ec07925c04c89719e3971c9068a165c7fd474ea87675b1204de0440e71ff --hash=sha256:25ef6855935f67582740ffa6bb978e40ec51be876117a3555c36fa2488dcdf25 --hash=sha256:283dcd3129b632021894425974bedd0eb6db3bbf5994e448ccad10db4d803d31 --hash=sha256:2b73d50978d5e57416fa68461f9f3c8f39ea39e761cb1e12f919745adefe26a7 --hash=sha256:2d125e210d5a0a1f701f12c0ecad9a03f1b04b5eddbce6ca36a1fc217da977ef --hash=sha256:2d4d66afad78e8f47feaa307728a6b71fe3effc63ba2b9eeb109ee687c9bd397 --hash=sha256:3888ef7a51adc77cdf07251bc762566a1be36211e1cff689f13980f3776a2f36 --hash=sha256:3a007f95cc682f56e6d83f1d17c29c00bf719d6fe8e003282b535af3a1ba09c0 --hash=sha256:3c79502d56cf6b76210032e1c53494ddfbc73c140bccf2ef4092b3f0825323ab --hash=sha256:4c346cf449baf3b113e997c34151ad205e7135bc429469b005b180ade7e65e28 --hash=sha256:4c463a3701f293e30d3d62e71e1989f112028d07f87432baf4507eeb57ec3831 --hash=sha256:55dea7e74b834d4b70467fc19c5b9ccb5509fe39abc4d26891187c1b22176423 --hash=sha256:57594b72cf663bbd0f3ad8a319a999fc3d7c71065a6799b2c1d1a6a137894c5b --hash=sha256:57bb6b908cb2e05baab63b04c3a31be3b4545a10bfab9748b8763016b5256704 --hash=sha256:5e90460b0cd21f62be3cb26087e706e2cebb7b3fcef9e05b4473b61bb0415b5e --hash=sha256:653c4f90dd21d8a1e74309919e08934e420d9aef51d051d14bf5a1c0e8293c22 --hash=sha256:731aefe6c4bf2bafa0798ef83927676d06e44d1d18202cfb56d63b40422ab900 --hash=sha256:742d93f2ca835e4fa83a3ae9ed2bce8b28029ed62ac730f339a37685c23075cd --hash=sha256:74412132bbfb153cbf704517f2c89f7d3e170feb681b13bceace690f66f8d5fa --hash=sha256:7caeecf519eff50f007ca4bee16b6e0a8252e5fe682c94432192a20867239888 --hash=sha256:7ccd15966b8218d41b06ec3e7c2556be89a8a693026c771e6564d2e40bbaf8ea --hash=sha256:7f01ebaada59d74fdf6d28e5031a961a413b3752e9edb0c03866fa18480cf4c8 --hash=sha256:81e8da77d201916f6771fc357fda8267c2a256d7aa11923d43bc5f2fc155878b --hash=sha256:81f6108f3ac292fb6cd05758c9e531389d8fc5e94e8c949b9298f4fb0a239662 --hash=sha256:84a4f76a0aece0aa72b5ed8162ba2ff8c78908b8361b5a5d92ddd161977ccb74 --hash=sha256:8d37c87826ae7195cfbd20fcd448801f2f563bb38f2691ec6574e39cb9eca6c8 --hash=sha256:98dfcdfaf1498ae2f0e2fafe900a82e8320cc81d8ae5a95b8b8879eaa3298c39 --hash=sha256:a475418f71c5aed69899813ecccf392429c08c3a63df3030129db71760b0db8f --hash=sha256:ae60df21cc7059e2f3b1928ad2c0b75c6b26f9ada79d992f87b6fc3f50d3877e --hash=sha256:afede7324822800e4f90e96b9514188a237a60f35e8e7a10b2129c10c78f6e4d --hash=sha256:b5539711dfa1ec8f3eca57482b93a48a165af4a99e9d5f41e3af3fb913aadf92 --hash=sha256:bc44ba6289fb1f5ee318335958dde6ad6d742dcb4bb8930de843e9024c64b68c --hash=sha256:bfac2dada8ddc002e8b7d8289d6fad4f070bc1fec20371cec684a7d10d932e96 --hash=sha256:c01b0f0ce030a7b89263c0236ca77923eae352c5f35ecf214b04d3aaea8eb2c3 --hash=sha256:c57a9920400166398695d92580eca21fd7a79f3c67d691ac7e20a7d1b5300735 --hash=sha256:c7c6d26c731a0e1e8e7d4ae8f13ae524eb6ec0e90d99c8147a19fdbae14eb807 --hash=sha256:d48cd7094598a7e9d4297e8bf4bbe0dc9dc2ba4367d83dbb603e3b3c6aa35566 --hash=sha256:daec9f9655a1d4636613c47d6d3343f6e039150d66cdce62543e20ca36612a8a --hash=sha256:e235dfdabb51993bcd4a8f45c3a54f21a782f7f92b3def0648b0ace45a1b1ac7 --hash=sha256:e367a84c9133018e0a3bc822abe78d7f1f9a6092991a0ec409468cf4ef260282 --hash=sha256:ecd1b23ea7f9af09c865ac8503d07afd7e6bf782d76bb83cbbdba15b7a0db807 --hash=sha256:eec44256856fd59d29d1f1d0950ace18a58e4228e7d49de5d5e1b1875b227dde --hash=sha256:f089f24225a28ddafa5cfad7c41cfa84db1a55f2d473370769a95c0e3bac60c9 --hash=sha256:f604ba83498e209fff4c7ecc5063a39421dc538dace694bc592f9f338254f3dc +h11==0.16.0 --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +httpcore==1.0.9 --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 +httpx==0.28.1 --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +idna==3.18 --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 +typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 diff --git a/benchmarks/capacity/v1/topologies/local-docker/compose.json b/benchmarks/capacity/v1/topologies/local-docker/compose.json index 736d434f4..2d4abec81 100644 --- a/benchmarks/capacity/v1/topologies/local-docker/compose.json +++ b/benchmarks/capacity/v1/topologies/local-docker/compose.json @@ -285,7 +285,7 @@ "sdk-python-worker": { "image": "docker.io/library/python:3.12.4-slim-bookworm", "platform": "linux/amd64", - "command": ["sh", "-ec", "/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], + "command": ["sh", "-ec", "/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], "working_dir": "/capacity", "environment": {"PATH": "/tmp/capacity-venv/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"}, "volumes": ["${CAPACITY_REPOSITORY_ROOT}/benchmarks/capacity/v1/bindings/python:/capacity:ro", "${CAPACITY_REPOSITORY_ROOT}/scripts/ci:/dw-ci:ro"], diff --git a/scripts/benchmark/capacity_suite.py b/scripts/benchmark/capacity_suite.py index 8eb350e59..f7b5916b1 100755 --- a/scripts/benchmark/capacity_suite.py +++ b/scripts/benchmark/capacity_suite.py @@ -1195,17 +1195,25 @@ def _validate_adapter_dependency( raise ContractError( "Python adapter must require the suite's exact SDK artifact" ) - locked_requirements = ( - (adapter_root / "requirements.lock").read_text().splitlines() - ) + # Each line pins one package and lists the hashes pip must match. + locked_lines = [ + line.split() + for line in (adapter_root / "requirements.lock").read_text().splitlines() + if line.strip() + ] + locked_requirements = [words[0] for words in locked_lines] if f"durable-workflow=={expected_version}" not in locked_requirements or any( - "==" not in requirement - for requirement in locked_requirements - if requirement + "==" not in requirement for requirement in locked_requirements ): raise ContractError( "Python adapter lock must pin its complete dependency graph" ) + if any( + len(words) < 2 + or any(not word.startswith("--hash=sha256:") for word in words[1:]) + for words in locked_lines + ): + raise ContractError("Python adapter lock must hash every pinned package") return try: manifest = tomllib.loads((adapter_root / "Cargo.toml").read_text()) diff --git a/tests/Unit/Support/capacity_benchmark_test.py b/tests/Unit/Support/capacity_benchmark_test.py index b79bc29f9..bca340802 100644 --- a/tests/Unit/Support/capacity_benchmark_test.py +++ b/tests/Unit/Support/capacity_benchmark_test.py @@ -2508,5 +2508,36 @@ def test_reference_result_round_trips_as_machine_readable_json(self) -> None: self.assertEqual(capacity_suite.RESULT_SCHEMA, result["schema"]) +class PythonAdapterLockTest(unittest.TestCase): + """The Python adapter lock pins and hashes its complete graph.""" + + HASH = "--hash=sha256:" + "a" * 64 + + def check(self, lock: str) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "requirements.txt").write_text("durable-workflow==2.0.1\n") + (root / "requirements.lock").write_text(lock) + capacity_suite._validate_adapter_dependency("python", root, "2.0.1") + + def test_a_hashed_lock_passes(self) -> None: + self.check(f"anyio==4.14.2 {self.HASH}\ndurable-workflow==2.0.1 {self.HASH} {self.HASH}\n") + + def test_the_checked_in_lock_passes(self) -> None: + root = ROOT / "benchmarks/capacity/v1/bindings/python" + capacity_suite._validate_adapter_dependency("python", root, "2.0.1") + + def test_unhashed_or_unpinned_locks_are_refused(self) -> None: + for lock in ( + "anyio==4.14.2\ndurable-workflow==2.0.1 " + self.HASH + "\n", + "anyio==4.14.2 --hash=md5:00\ndurable-workflow==2.0.1 " + self.HASH + "\n", + "anyio>=4 " + self.HASH + "\ndurable-workflow==2.0.1 " + self.HASH + "\n", + "anyio==4.14.2 " + self.HASH + "\n", + ): + with self.subTest(lock=lock.splitlines()[0]): + with self.assertRaises(capacity_suite.ContractError): + self.check(lock) + + if __name__ == "__main__": unittest.main() diff --git a/tests/Unit/Support/pip-sinks.json b/tests/Unit/Support/pip-sinks.json index e292e4358..2b920bc39 100644 --- a/tests/Unit/Support/pip-sinks.json +++ b/tests/Unit/Support/pip-sinks.json @@ -35,9 +35,9 @@ }, { "path": ".github/workflows/server-perf.yml", - "line": "python3 -I -m pip install \\", + "line": "python3 -I -m pip install --require-hashes --only-binary :all: --no-deps \\", "count": 1, - "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job. `-I` keeps a checked-out `pip` package from shadowing the installed client." + "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job. `-I` keeps a checked-out `pip` package from shadowing the installed client. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." }, { "path": ".github/workflows/server-perf.yml", @@ -47,15 +47,15 @@ }, { "path": "benchmarks/capacity/v1/README.md", - "line": "/tmp/capacity-venv/bin/python -m pip install -r benchmarks/capacity/v1/bindings/python/requirements.lock", + "line": "/tmp/capacity-venv/bin/python -m pip install --require-hashes --only-binary :all: --no-deps \\", "count": 1, - "confinement": "Documentation; the instructions bootstrap /tmp/capacity-venv first." + "confinement": "Documentation; the instructions bootstrap /tmp/capacity-venv first. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." }, { "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", - "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", + "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", "count": 1, - "confinement": "sh -e: bootstrap-patched-pip.sh on /tmp/capacity-venv, then that venv (first on PATH) installs." + "confinement": "sh -e: bootstrap-patched-pip.sh on /tmp/capacity-venv, then that venv (first on PATH) installs. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." }, { "path": "scripts/ci/bootstrap-patched-pip.sh", From 0c622a250dfc82aa09a3ac4339297909586530ab Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 19:46:18 +0000 Subject: [PATCH 08/21] Isolate pip from checked-out source and check every sink's guard pip: every networked "-m pip" that can run from checked-out or mounted source now runs as "python -I -m pip", including patched-pip.sh, the capacity worker and the conformance runners. A real-pip control proves a working directory with a marker-writing pip.py and pip/ package shadows an unisolated "-m pip", and that patched-pip.sh and "-I" ignore it. The inventory rejects any "-m pip" without "-I" apart from structured, owned exceptions; the only one is the action-policy preflight, whose exact command the central policy fixes. Both sink manifests now record, for each occurrence, a guard type, the exact guard line and its distance, checked against the source within the same script or workflow job (sink_guards.py). Deleting the bootstrap before a sagas pip install, or the trust guard before a corrected-marker Composer install, fails although the sink lines are unchanged. Bootstrap guards match invocations only, not path definitions. The worker-versioning mixed image confines Composer to the pinned image's checked store. The Cargo test asserts the exact clean SSL_CERT_FILE path. PECL, install-php-extensions, curl and other downloads move to the separately owned server#677. GHSA-6q48-6c7w-9gw2 --- benchmarks/capacity/v1/README.md | 2 +- .../v1/topologies/local-docker/compose.json | 2 +- scripts/ci/patched-pip.sh | 2 +- .../activities-published-artifacts.sh | 4 +- .../child-workflows-install-probe.py | 2 +- .../child-workflows-published-artifacts.sh | 2 +- .../migration-published-artifacts.mjs | 2 +- .../namespaces-published-artifacts.sh | 2 +- ...incipal-attribution-published-artifacts.sh | 1 + .../conformance/python-published-artifacts.sh | 4 +- .../conformance/replay-published-artifacts.sh | 4 +- .../conformance/sagas-published-artifacts.sh | 2 +- .../schedules-published-artifacts.mjs | 4 +- .../signals-queries-published-artifacts.sh | 3 +- .../conformance/skew-published-artifacts.sh | 2 +- .../worker-versioning-mixed.Dockerfile | 5 + .../worker-versioning-published-workers.mjs | 8 +- .../workflow-lifecycle-published-artifacts.sh | 2 +- .../workflow-updates-published-artifacts.sh | 4 +- .../ReplayConformanceRunnerContractTest.php | 8 + tests/Unit/Support/cargo_trust_test.py | 11 +- tests/Unit/Support/composer-sinks.json | 942 ++++++++++++++++-- tests/Unit/Support/composer_trust_test.py | 71 +- tests/Unit/Support/patched_pip_test.py | 176 +++- tests/Unit/Support/pip-sinks.json | 414 ++++++-- tests/Unit/Support/sink_guards.py | 141 +++ .../WorkflowUpdateRuntimeContractTest.php | 2 +- 27 files changed, 1607 insertions(+), 215 deletions(-) create mode 100644 tests/Unit/Support/sink_guards.py diff --git a/benchmarks/capacity/v1/README.md b/benchmarks/capacity/v1/README.md index 1fba8618d..dc3042337 100644 --- a/benchmarks/capacity/v1/README.md +++ b/benchmarks/capacity/v1/README.md @@ -64,7 +64,7 @@ Install each adapter from its own immutable package manifest before a run: (cd benchmarks/capacity/v1/bindings/php && composer install --no-dev) python3 -m venv /tmp/capacity-venv sh scripts/ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python - /tmp/capacity-venv/bin/python -m pip install --require-hashes --only-binary :all: --no-deps \ + /tmp/capacity-venv/bin/python -I -m pip install --require-hashes --only-binary :all: --no-deps \ -r benchmarks/capacity/v1/bindings/python/requirements.lock (cd benchmarks/capacity/v1/bindings/rust && cargo build --release --locked) diff --git a/benchmarks/capacity/v1/topologies/local-docker/compose.json b/benchmarks/capacity/v1/topologies/local-docker/compose.json index 2d4abec81..104fceeb3 100644 --- a/benchmarks/capacity/v1/topologies/local-docker/compose.json +++ b/benchmarks/capacity/v1/topologies/local-docker/compose.json @@ -285,7 +285,7 @@ "sdk-python-worker": { "image": "docker.io/library/python:3.12.4-slim-bookworm", "platform": "linux/amd64", - "command": ["sh", "-ec", "/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], + "command": ["sh", "-ec", "/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -I -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\"durable-workflow\"))')\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null"], "working_dir": "/capacity", "environment": {"PATH": "/tmp/capacity-venv/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"}, "volumes": ["${CAPACITY_REPOSITORY_ROOT}/benchmarks/capacity/v1/bindings/python:/capacity:ro", "${CAPACITY_REPOSITORY_ROOT}/scripts/ci:/dw-ci:ro"], diff --git a/scripts/ci/patched-pip.sh b/scripts/ci/patched-pip.sh index e76fe5a41..cb6553f9d 100755 --- a/scripts/ci/patched-pip.sh +++ b/scripts/ci/patched-pip.sh @@ -17,5 +17,5 @@ trap 'exit 143' TERM python3 -m venv "$work/pip" sh "$here/bootstrap-patched-pip.sh" "$work/pip/bin/python" >&2 status=0 -"$work/pip/bin/python" -m pip "$@" || status=$? +"$work/pip/bin/python" -I -m pip "$@" || status=$? exit "$status" diff --git a/scripts/conformance/activities-published-artifacts.sh b/scripts/conformance/activities-published-artifacts.sh index 782877816..d4e80af87 100755 --- a/scripts/conformance/activities-published-artifacts.sh +++ b/scripts/conformance/activities-published-artifacts.sh @@ -254,13 +254,13 @@ prepare_focused_python_sdk() { mkdir -p "$distribution_dir" if python3 -m venv "$venv" >/dev/null 2>"$install_log" \ && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ - && "$venv/bin/python" -m pip download --disable-pip-version-check --no-deps \ + && "$venv/bin/python" -I -m pip download --disable-pip-version-check --no-deps \ --dest "$distribution_dir" "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1 \ && python3 "$script_dir/distribution_identities.py" record-unique \ "$distribution_identity_file" sdk-python "$DW_PYTHON_SDK_VERSION" "$distribution_dir" '*' \ >>"$install_log" 2>&1 \ && distribution="$(find "$distribution_dir" -maxdepth 1 -type f -print -quit)" \ - && "$venv/bin/python" -m pip install --disable-pip-version-check --no-input "$distribution" >>"$install_log" 2>&1; then + && "$venv/bin/python" -I -m pip install --disable-pip-version-check --no-input "$distribution" >>"$install_log" 2>&1; then export DW_ACTIVITIES_PYTHON_BIN="$venv/bin/python" return 0 fi diff --git a/scripts/conformance/child-workflows-install-probe.py b/scripts/conformance/child-workflows-install-probe.py index af2d75253..02a9ed9e4 100755 --- a/scripts/conformance/child-workflows-install-probe.py +++ b/scripts/conformance/child-workflows-install-probe.py @@ -154,7 +154,7 @@ def require_version(name: str) -> str: # Start from the patched, bundle-only pip (GHSA-6q48-6c7w-9gw2). bootstrap_command = ["sh", str(PATCHED_PIP_BOOTSTRAP), str(python_bin)] bootstrap_code, bootstrap_output = run(bootstrap_command) if create_code == 0 else (1, create_output) - install_command = [str(python_bin), "-m", "pip", "install", "--disable-pip-version-check", "--no-input", f"durable-workflow=={python_version}"] + install_command = [str(python_bin), "-I", "-m", "pip", "install", "--disable-pip-version-check", "--no-input", f"durable-workflow=={python_version}"] install_code, install_output = run(install_command) if bootstrap_code == 0 else (1, bootstrap_output) probe_command = [str(python_bin), "-c", "import importlib.metadata as m; print(m.version('durable-workflow'))"] probe_code, probe_output = run(probe_command) if install_code == 0 else (1, install_output) diff --git a/scripts/conformance/child-workflows-published-artifacts.sh b/scripts/conformance/child-workflows-published-artifacts.sh index 5730a76cf..ab6bff9b8 100755 --- a/scripts/conformance/child-workflows-published-artifacts.sh +++ b/scripts/conformance/child-workflows-published-artifacts.sh @@ -245,7 +245,7 @@ prepare_focused_python_sdk() { local install_log="$result_dir/sdk-python-typed-child-failure-install.log" if python3 -m venv "$venv" >"$install_log" 2>&1 \ && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ - && "$venv/bin/python" -m pip install --disable-pip-version-check --no-input "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1; then + && "$venv/bin/python" -I -m pip install --disable-pip-version-check --no-input "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1; then export DW_CHILD_WORKFLOWS_PYTHON_BIN="$venv/bin/python" return 0 fi diff --git a/scripts/conformance/migration-published-artifacts.mjs b/scripts/conformance/migration-published-artifacts.mjs index ab6736350..c5f7e3bac 100755 --- a/scripts/conformance/migration-published-artifacts.mjs +++ b/scripts/conformance/migration-published-artifacts.mjs @@ -2978,7 +2978,7 @@ function publicGuideAuditScenarioOutputs( case 'rollback_contract_verified': return { ...common, - rollback_steps: signals.rollback_procedure ? commands.filter((command) => /backup|restore|composer require|queue:restart|mysql|psql/i.test(command)) : [], + rollback_steps: signals.rollback_procedure ? commands.filter((command) => /backup|restore|composer\s+require|queue:restart|mysql|psql/i.test(command)) : [], rollback_supported_state: signals.rollback_procedure ? 'documented_but_not_executed' : 'not_documented_by_public_guide_audit', public_operator_signal: signals.rollback_procedure ? { diff --git a/scripts/conformance/namespaces-published-artifacts.sh b/scripts/conformance/namespaces-published-artifacts.sh index a860d3925..583ad15ac 100755 --- a/scripts/conformance/namespaces-published-artifacts.sh +++ b/scripts/conformance/namespaces-published-artifacts.sh @@ -515,7 +515,7 @@ if ! sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python blocked_result "patched pip bootstrap failed; see python-pip-upgrade.log" "$started_at" exit 1 fi -if ! "$run_root/.venv/bin/python" -m pip install "durable-workflow==$python_sdk_version" > "$result_dir/python-sdk-install.log" 2>&1; then +if ! "$run_root/.venv/bin/python" -I -m pip install "durable-workflow==$python_sdk_version" > "$result_dir/python-sdk-install.log" 2>&1; then blocked_result "durable-workflow==$python_sdk_version install failed; see python-sdk-install.log" "$started_at" exit 1 fi diff --git a/scripts/conformance/principal-attribution-published-artifacts.sh b/scripts/conformance/principal-attribution-published-artifacts.sh index b4a278675..e4fce558e 100755 --- a/scripts/conformance/principal-attribution-published-artifacts.sh +++ b/scripts/conformance/principal-attribution-published-artifacts.sh @@ -845,6 +845,7 @@ def smoke_python(root: Path, version: str, source: str) -> dict[str, Any]: install_command = [ str(python), + "-I", "-m", "pip", "install", diff --git a/scripts/conformance/python-published-artifacts.sh b/scripts/conformance/python-published-artifacts.sh index 19be790f8..3066dce16 100755 --- a/scripts/conformance/python-published-artifacts.sh +++ b/scripts/conformance/python-published-artifacts.sh @@ -477,7 +477,7 @@ python3 -m venv "$run_root/.venv" sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" > "$result_dir/pip-upgrade.log" 2>&1 \ || fail_blocked "the patched pip bootstrap failed; see pip-upgrade.log" mkdir -p "$run_root/python-distributions" -python -m pip download --no-deps --dest "$run_root/python-distributions" \ +python -I -m pip download --no-deps --dest "$run_root/python-distributions" \ "durable-workflow==$python_version" > "$result_dir/python-sdk-download.log" 2>&1 \ || fail_blocked "PyPI durable-workflow==$python_version download failed" python3 "$script_dir/distribution_identities.py" record-unique \ @@ -485,7 +485,7 @@ python3 "$script_dir/distribution_identities.py" record-unique \ "$run_root/python-distributions" '*' \ || fail_blocked "PyPI durable-workflow==$python_version did not retain one consumed distribution file" python_distribution="$(find "$run_root/python-distributions" -maxdepth 1 -type f -print -quit)" -python -m pip install "$python_distribution" > "$result_dir/python-sdk-install.log" 2>&1 \ +python -I -m pip install "$python_distribution" > "$result_dir/python-sdk-install.log" 2>&1 \ || fail_blocked "PyPI durable-workflow==$python_version install failed" if ! curl -fsSL --retry 3 -o "$run_root/cli/install.sh" "$cli_installer_url"; then diff --git a/scripts/conformance/replay-published-artifacts.sh b/scripts/conformance/replay-published-artifacts.sh index 83af648c9..10e59d133 100755 --- a/scripts/conformance/replay-published-artifacts.sh +++ b/scripts/conformance/replay-published-artifacts.sh @@ -1656,7 +1656,7 @@ python_pip_status=$? mkdir -p "$run_root/python-distributions" # Without the patched pip, no Python SDK request is made at all. if [[ "$python_pip_status" -eq 0 ]]; then - python -m pip download --no-deps --dest "$run_root/python-distributions" \ + python -I -m pip download --no-deps --dest "$run_root/python-distributions" \ "durable-workflow==${python_sdk_version}" > "$result_dir/python-download.log" 2>&1 python_download_status=$? else @@ -1670,7 +1670,7 @@ if [[ "$python_download_status" -eq 0 ]]; then fi python_distribution="$(find "$run_root/python-distributions" -maxdepth 1 -type f -print -quit)" if [[ "$python_download_status" -eq 0 && -n "$python_distribution" ]]; then - python -m pip install "$python_distribution" > "$result_dir/python-install.log" 2>&1 + python -I -m pip install "$python_distribution" > "$result_dir/python-install.log" 2>&1 python_install_status=$? else printf '%s\n' 'Python SDK distribution download or identity capture failed.' > "$result_dir/python-install.log" diff --git a/scripts/conformance/sagas-published-artifacts.sh b/scripts/conformance/sagas-published-artifacts.sh index c42167f52..2bfdc7d9e 100755 --- a/scripts/conformance/sagas-published-artifacts.sh +++ b/scripts/conformance/sagas-published-artifacts.sh @@ -1103,7 +1103,7 @@ python3 -m venv "$run_root/.venv" # shellcheck disable=SC1091 . "$run_root/.venv/bin/activate" sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" -python -m pip install "durable-workflow==$python_version" httpx +python -I -m pip install "durable-workflow==$python_version" httpx mkdir -p "$run_root/php-worker" "$run_root/cli/bin" "$run_root/waterline-app" "$run_root/logs" docker run --rm -v "$run_root/php-worker:/app" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \ diff --git a/scripts/conformance/schedules-published-artifacts.mjs b/scripts/conformance/schedules-published-artifacts.mjs index 9011d8227..36a5935ee 100755 --- a/scripts/conformance/schedules-published-artifacts.mjs +++ b/scripts/conformance/schedules-published-artifacts.mjs @@ -2058,7 +2058,7 @@ async function installSchedulesPythonLifecycleArtifact(shardRoot, artifactVersio await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-python-lifecycle-pip-upgrade.log')); await execLogged( pythonBin, - ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], + ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], path.join(resultDir, 'schedules-python-lifecycle-install.log'), ); markArtifactSource(artifactSources, 'sdk-python', pythonPackageArtifactSource(pythonVersion), artifactVersions); @@ -6699,7 +6699,7 @@ async function installSchedulesPythonArtifact(shardRoot, artifactVersions, artif await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-cross-language-python-pip-upgrade.log')); await execLogged( pythonBin, - ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], + ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], path.join(resultDir, 'schedules-cross-language-python-install.log'), ); markArtifactSource(artifactSources, 'sdk-python', pythonPackageArtifactSource(pythonVersion), artifactVersions); diff --git a/scripts/conformance/signals-queries-published-artifacts.sh b/scripts/conformance/signals-queries-published-artifacts.sh index 807a3b33e..ce8118834 100755 --- a/scripts/conformance/signals-queries-published-artifacts.sh +++ b/scripts/conformance/signals-queries-published-artifacts.sh @@ -1902,6 +1902,7 @@ def ensure_python_sdk(run_root: Path, log_file: Path) -> tuple[str, dict[str, An download = run_command( [ str(python_bin), + "-I", "-m", "pip", "download", @@ -1918,7 +1919,7 @@ def ensure_python_sdk(run_root: Path, log_file: Path) -> tuple[str, dict[str, An distribution = unique_distribution_file(distribution_dir, "*") record_distribution_file("sdk-python", sdk_version, distribution) install = run_command( - [str(python_bin), "-m", "pip", "install", str(distribution)], + [str(python_bin), "-I", "-m", "pip", "install", str(distribution)], log_file=log_file, timeout=240, ) diff --git a/scripts/conformance/skew-published-artifacts.sh b/scripts/conformance/skew-published-artifacts.sh index 0991b7b33..c293dde53 100755 --- a/scripts/conformance/skew-published-artifacts.sh +++ b/scripts/conformance/skew-published-artifacts.sh @@ -474,7 +474,7 @@ if [[ -n "${DW_PYTHON_SDK_VERSION:-}" ]]; then if require_command python3; then if python3 -m venv "$run_root/.venv" >"$result_dir/python-install.log" 2>&1 \ && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" >>"$result_dir/python-install.log" 2>&1 \ - && "$run_root/.venv/bin/python" -m pip install "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$result_dir/python-install.log" 2>&1; then + && "$run_root/.venv/bin/python" -I -m pip install "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$result_dir/python-install.log" 2>&1; then python_status="available" python_reason="" python_source="pypi" diff --git a/scripts/conformance/worker-versioning-mixed.Dockerfile b/scripts/conformance/worker-versioning-mixed.Dockerfile index 4ba2745b1..2d5550981 100644 --- a/scripts/conformance/worker-versioning-mixed.Dockerfile +++ b/scripts/conformance/worker-versioning-mixed.Dockerfile @@ -12,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -r /var/lib/apt/lists/* \ && python3 /opt/dw-ci/blocked_roots.py /etc/ssl/certs/ca-certificates.crt COPY --from=composer /usr/bin/composer /usr/bin/composer +# Composer trusts only the pinned image's checked store and an empty directory. +COPY --from=composer /etc/ssl/certs/ca-certificates.crt /etc/composer-trust/ca.pem +RUN install -d -m 0555 /etc/composer-trust/no-ca-directory \ + && python3 /opt/dw-ci/blocked_roots.py /etc/composer-trust/ca.pem +ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory COPY --from=node /usr/local/bin/node /usr/local/bin/node # The Python SDK lives in a venv whose pip is the hash-pinned release and # trusts only its verified bundle; python3 resolves to that venv. diff --git a/scripts/conformance/worker-versioning-published-workers.mjs b/scripts/conformance/worker-versioning-published-workers.mjs index 2bc7f32d1..4b7e8c568 100644 --- a/scripts/conformance/worker-versioning-published-workers.mjs +++ b/scripts/conformance/worker-versioning-published-workers.mjs @@ -195,7 +195,7 @@ async function main() { version: pythonVersion, source: 'pypi_release', status: 'pass', - command: `python3 -m pip install durable-workflow==${pythonVersion}`, + command: `python3 -I -m pip install durable-workflow==${pythonVersion}`, }, { artifact: 'sdk-php', @@ -381,7 +381,7 @@ async function main() { version: pythonVersion, source: 'pypi_release', status: 'pass', - command: `python3 -m pip install durable-workflow==${pythonVersion}`, + command: `python3 -I -m pip install durable-workflow==${pythonVersion}`, }, { artifact: 'sdk-php', @@ -1129,7 +1129,7 @@ async function installPythonWorker(shardRoot) { runRequired('python3', ['-m', 'venv', venv], { logPath: installLog }); const pythonBin = path.join(venv, 'bin', 'python'); runRequired('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], { logPath: installLog, append: true }); - runRequired(pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], { + runRequired(pythonBin, ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], { logPath: installLog, append: true, }); @@ -1539,7 +1539,7 @@ function publishedPythonWorkerExecution() { version: pythonVersion, source: 'pypi_release', status: 'pass', - command: `python3 -m pip install durable-workflow==${pythonVersion}`, + command: `python3 -I -m pip install durable-workflow==${pythonVersion}`, }, ], }; diff --git a/scripts/conformance/workflow-lifecycle-published-artifacts.sh b/scripts/conformance/workflow-lifecycle-published-artifacts.sh index 4834d30a1..5f4bdd11d 100755 --- a/scripts/conformance/workflow-lifecycle-published-artifacts.sh +++ b/scripts/conformance/workflow-lifecycle-published-artifacts.sh @@ -926,7 +926,7 @@ PY executor="venv_pypi_install" if ! sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$python_bin" >"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1 \ - || ! "$python_bin" -m pip install --disable-pip-version-check --no-input "durable-workflow==${sdk_version}" >>"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1; then + || ! "$python_bin" -I -m pip install --disable-pip-version-check --no-input "durable-workflow==${sdk_version}" >>"$result_dir/python-sdk-lifecycle-pip-install.log" 2>&1; then local install_error install_error="$(python_sdk_last_log_line "$result_dir/python-sdk-lifecycle-pip-install.log" 'pip exited without diagnostic output.')" write_python_sdk_runner_blocked \ diff --git a/scripts/conformance/workflow-updates-published-artifacts.sh b/scripts/conformance/workflow-updates-published-artifacts.sh index 3586814df..56449c77a 100755 --- a/scripts/conformance/workflow-updates-published-artifacts.sh +++ b/scripts/conformance/workflow-updates-published-artifacts.sh @@ -3023,7 +3023,7 @@ run_python_sdk_shard() { write_python_sdk_shard_status runner_blocked "The Python SDK shard virtual environment did not expose a Python executable." python_venv_python true return 0 fi - if ! "$venv_python" -m pip --version > "$result_dir/python-sdk-pip-version.log" 2>&1; then + if ! "$venv_python" -I -m pip --version > "$result_dir/python-sdk-pip-version.log" 2>&1; then write_python_sdk_shard_status runner_blocked "pip is required inside the disposable Python SDK shard environment; see python-sdk-pip-version.log." pip_unavailable true return 0 fi @@ -3034,7 +3034,7 @@ run_python_sdk_shard() { # --isolated skips the venv's pip.conf, so confine trust to pip's verified # bundle here too. - if ! PIP_CONFIG_FILE=/dev/null "$venv_python" -m pip --isolated install \ + if ! PIP_CONFIG_FILE=/dev/null "$venv_python" -I -m pip --isolated install \ --use-deprecated=legacy-certs \ --disable-pip-version-check \ --no-input \ diff --git a/tests/Unit/ReplayConformanceRunnerContractTest.php b/tests/Unit/ReplayConformanceRunnerContractTest.php index 272de4cf1..1ff884589 100644 --- a/tests/Unit/ReplayConformanceRunnerContractTest.php +++ b/tests/Unit/ReplayConformanceRunnerContractTest.php @@ -790,6 +790,14 @@ class Handler(BaseHTTPRequestHandler): #!/usr/bin/env bash set -euo pipefail real_python=$real_python +# The runner must isolate pip from its working directory. +if [[ "\${1:-}" == "-m" && "\${2:-}" == "pip" ]]; then + printf '%s\n' 'pip run without -I' >&2 + exit 97 +fi +if [[ "\${1:-}" == "-I" ]]; then + shift +fi if [[ "\${1:-}" == "-m" && "\${2:-}" == "pip" ]]; then if [[ "\${3:-}" == "download" ]]; then destination="" diff --git a/tests/Unit/Support/cargo_trust_test.py b/tests/Unit/Support/cargo_trust_test.py index 74cd832b3..3de615c92 100644 --- a/tests/Unit/Support/cargo_trust_test.py +++ b/tests/Unit/Support/cargo_trust_test.py @@ -16,6 +16,7 @@ import http.server import os +import re import shutil import ssl import subprocess @@ -157,5 +158,11 @@ def test_the_storage_admission_rust_stage_pairs_the_file_with_an_empty_directory dockerfile = (root / "tests/Integration/StorageAdmission/sdk/Dockerfile").read_text() stage = dockerfile.split("AS rust-worker", 1)[1].split("\nFROM ", 1)[0] cargo = stage.index("RUN cargo ") - for setting in ("CARGO_HTTP_CAINFO=", "SSL_CERT_DIR=/etc/server-trust/no-ca-directory", "SSL_CERT_FILE="): - self.assertIn(setting, stage[:cargo], setting) + before = stage[:cargo] + # SSL_CERT_FILE selects the same checked Server bundle; it is not cleared. + clean = "/etc/server-trust/ca-certificates.crt" + for setting in (f"CARGO_HTTP_CAINFO={clean}", f"SSL_CERT_FILE={clean}", + "SSL_CERT_DIR=/etc/server-trust/no-ca-directory"): + self.assertRegex(before, rf"(?m)^(?:ENV |\s+){re.escape(setting)}(?:\s|\\|$)", setting) + self.assertIn(f'RUN python3 /etc/server-trust/blocked_roots.py "$CARGO_HTTP_CAINFO"', before) + self.assertIn(f"COPY --from=server-trust /etc/ssl/certs/ca-certificates.crt {clean}", before) diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 5baf76fa9..2599ca5d2 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -1,5 +1,5 @@ { - "schema": "durable-workflow.server.composer-sinks/v1", + "schema": "durable-workflow.server.composer-sinks/v2", "advisory": "GHSA-6q48-6c7w-9gw2", "pinned_image": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "review": "composer_trust_test.py fails on any Composer sink not listed here with the same count.", @@ -8,583 +8,1319 @@ "path": ".github/workflows/phpunit-feature.yml", "line": "-lc 'set -eu; tar --no-same-owner -xf - -C /app; cp -a /app/workflow-package /workflow; php scripts/ci/prepare-release-workflow-composer-metadata.php; composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist; composer install --no-interaction --no-progress --prefer-dist; git --version; vendor/bin/phpunit --version; git rev-parse --verify \"$CORPUS_BASE_REF^{commit}\"; php scripts/ci/check-worker-openapi-evolution.php \"$CORPUS_BASE_REF\"; vendor/bin/phpunit tests/Feature tests/Unit/NexusContractTest.php tests/Unit/CodecRegressionCorpusTest.php tests/Unit/PhpunitFeatureWorkflowContractTest.php tests/Unit/OpenApiDocumentEvolutionTest.php tests/Unit/NamespaceConformanceRunnerContractTest.php tests/Unit/NamespaceRuntimeContractTest.php tests/Unit/SchedulesConformanceRunnerContractTest.php tests/Unit/SchedulesRuntimeContractTest.php tests/Unit/ServerParitySdkDirectionResponseSummaryTest.php tests/Unit/ServerParitySdkQueryTransportTest.php tests/Unit/ServerParitySdkUpdateTransportTest.php tests/Unit/ServerParityLockPressureRetryTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImageImmutabilityGuardTest.php --no-progress --colors=never; vendor/bin/phpunit tests/Unit/ReleaseImagePublishWorkflowContractTest.php --filter test_php_security_audits_fail_before_release_publication --no-progress --colors=never; python3 scripts/ci/validate-regression-corpus.py --base-ref \"$CORPUS_BASE_REF\" --verify-counterfactual'", "count": 2, - "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. The corpus validator reruns it after adding git." + "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. The corpus validator reruns it after adding git.", + "guards": [ + { + "type": "server-verify", + "line": "&& server-verify-trust final", + "distance": 75 + }, + { + "type": "server-verify", + "line": "&& server-verify-trust final", + "distance": 75 + } + ] }, { "path": ".github/workflows/phpunit-feature.yml", "line": "-v \"$PWD:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/phpunit-feature.yml", "line": "composer install --no-interaction --no-progress --prefer-dist", "count": 1, - "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. (dw-server-prepared-local-base is `docker build --target base`.)" + "confinement": "Runs in this repository's Server base image; `server-verify-trust final` checks its system bundle, hashed directory and Composer selection at build. (dw-server-prepared-local-base is `docker build --target base`.)", + "guards": [ + { + "type": "server-base-build", + "line": "docker build --target base -t dw-server-prepared-local-base .", + "distance": 22 + } + ] }, { "path": ".github/workflows/phpunit-feature.yml", "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-interaction", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/release.yml", "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD/release-source:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/server-perf.yml", "line": "sh -lc 'tar -xf - -C /app && cp -a /app/workflow-package /workflow && php scripts/ci/prepare-release-workflow-composer-metadata.php && composer update durable-workflow/workflow --with-dependencies --minimal-changes --no-install --no-scripts --no-autoloader --no-interaction --no-progress --prefer-dist && composer install --no-interaction --no-progress --prefer-dist && vendor/bin/phpunit tests/Unit/BoundedGrowthPolicyTest.php tests/Unit/ServerPerfHarnessContractTest.php --colors=never'", "count": 2, - "confinement": "Runs in the pinned Composer image of the preceding `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs in the pinned Composer image of the preceding `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "| docker run --rm -i -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + }, + { + "type": "pinned-image", + "line": "| docker run --rm -i -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": ".github/workflows/server-perf.yml", "line": "| docker run --rm -i -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "Dockerfile", "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.", + "guards": [ + { + "type": "server-verify", + "line": "RUN server-verify-trust final", + "distance": 48 + } + ] }, { "path": "Dockerfile", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "benchmarks/capacity/v1/README.md", "line": "(cd benchmarks/capacity/v1/bindings/php && composer install --no-dev)", "count": 1, - "confinement": "Documentation; not executed." + "confinement": "Documentation; not executed.", + "guards": [ + { + "type": "documentation", + "line": null, + "distance": null + } + ] }, { "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", "line": "\"command\": [\"composer\", \"install\", \"--no-dev\", \"--no-interaction\", \"--no-progress\", \"--prefer-dist\", \"--classmap-authoritative\"],", "count": 1, - "confinement": "The service image is the pinned Composer image; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "The service image is the pinned Composer image; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "\"image\": \"docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", + "distance": 4 + } + ] }, { "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", "line": "\"image\": \"docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "benchmarks/capacity/v1/topologies/local-docker/topology.json", "line": "\"composer\": \"docker.io/library/composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/ci/checked-ca.sh", "line": "image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/ci/qualify-database-tls.sh", "line": "compose run --rm --no-deps php sh -c 'sh scripts/ci/composer-trust-guard.sh && composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist'", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set.", + "guards": [ + { + "type": "trust-guard", + "line": "compose run --rm --no-deps php sh -c 'sh scripts/ci/composer-trust-guard.sh && composer install --no-dev --no-scripts --no-interaction --no-progress --prefer-dist'", + "distance": 0 + } + ] }, { "path": "scripts/conformance/activities-published-artifacts.sh", "line": "composer install --no-interaction --no-progress --prefer-dist --no-scripts", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The published-image delegate also sets COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The published-image delegate also sets COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$script_dir/../ci/composer-trust-guard.sh\" &&", + "distance": 3 + } + ] }, { "path": "scripts/conformance/activities-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/heartbeats-published-artifacts.mjs", "line": "const PHP_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee';", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." - }, - { - "path": "scripts/conformance/migration-published-artifacts.mjs", - "line": "rollback_steps: signals.rollback_procedure ? commands.filter((command) => /backup|restore|composer require|queue:restart|mysql|psql/i.test(command)) : [],", - "count": 1, - "confinement": "Evidence filter pattern; not executed." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "-v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 2, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "-v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "-v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:namespace-conformance \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/nexus-published-artifacts.sh", "line": "`mkdir -p /tmp/dw-php && cd /tmp/dw-php && composer init --no-interaction --name=dw/nexus-conformance --require=durable-workflow/workflow:${JSON.stringify(versions.workflow).slice(1, -1)} --require=durable-workflow/sdk:${JSON.stringify(versions['sdk-php']).slice(1, -1)} >/tmp/dw-php-composer-init.log 2>&1 && composer update --no-interaction --prefer-dist --no-progress >/tmp/dw-php-composer-update.log 2>&1 && php -S 0.0.0.0:8092 /work/nexus-php-workflow-service.php`,", "count": 2, - "confinement": "Runs in the published Server image; COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "Runs in the published Server image; COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "checked-ca", + "line": "'-e', 'COMPOSER_CAFILE=/work/composer-trust/ca.pem',", + "distance": 6 + }, + { + "type": "checked-ca", + "line": "'-e', 'COMPOSER_CAFILE=/work/composer-trust/ca.pem',", + "distance": 6 + } + ] }, { "path": "scripts/conformance/nexus-published-artifacts.sh", "line": "container_image: runtimeEvidence.php?.container_image || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", "count": 2, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if docker run --rm --user \"$(id -u):$(id -g)\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if docker run --rm --user \"$(id -u):$(id -g)\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan waterline:principal-attribution-conformance \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "docker_command.extend([\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\", \"php\", \"-r\", code])", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "if docker run --rm --user \"$(id -u):$(id -g)\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 2, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "stdout=f\"PHP binary missing ({PHP_BIN}) and docker is unavailable for composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee fallback\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/python-published-artifacts.sh", "line": "-v \"$run_root/artifacts/waterline:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/python-published-artifacts.sh", "line": "-v \"$run_root/artifacts/workflow:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/python-published-artifacts.sh", "line": "composer require --no-interaction --no-progress --prefer-dist --no-scripts \\", "count": 2, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "-v \"$run_root/artifacts/workflow:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + }, + { + "type": "pinned-image", + "line": "-v \"$run_root/artifacts/waterline:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/replay-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "docker run --rm \"${composer_env_args[@]}\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/replay-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /probe.php \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/replay-published-artifacts.sh", "line": "docker run --rm \"${composer_env_args[@]}\" -v \"$waterline_app:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "\"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "\"php_worker\": \"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee container with durable-workflow/workflow package\",", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "composer create-project --no-interaction --no-progress laravel/laravel . &&", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "distance": 1 + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 2, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "docker run --rm -v \"$run_root/php-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + }, + { + "type": "pinned-image", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "distance": 2 + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php worker.php", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "docker run --rm -v \"$run_root/php-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", "line": "image: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/schedules-published-artifacts.mjs", "line": "'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", "count": 5, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/server-parity/README.md", "line": "composer install --working-dir=scripts/conformance/server-parity --no-interaction", "count": 1, - "confinement": "Documentation; not executed." + "confinement": "Documentation; not executed.", + "guards": [ + { + "type": "documentation", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/server-parity/activity-result-inspection/README.md", "line": "Install this directory's lock normally with `composer install --no-dev", "count": 1, - "confinement": "Documentation; not executed." + "confinement": "Documentation; not executed.", + "guards": [ + { + "type": "documentation", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/server-parity/corrected-marker-embedded-install.sh", "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 1 + } + ] }, { "path": "scripts/conformance/server-parity/corrected-marker-embedded-install.sh", "line": "composer show durable-workflow/workflow --format=json", "count": 1, - "confinement": "Reads the installed package locally; no repository request." + "confinement": "Reads the installed package locally; no repository request.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 7 + } + ] }, { "path": "scripts/conformance/server-parity/repeated-patch-install.sh", "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set. The profiles also set COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 1 + } + ] }, { "path": "scripts/conformance/server-parity/repeated-patch-install.sh", "line": "composer show durable-workflow/workflow --format=json", "count": 1, - "confinement": "Reads the installed package locally; no repository request." + "confinement": "Reads the installed package locally; no repository request.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 4 + } + ] }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", "line": "\"composer\",", "count": 1, - "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "trusted-builder", + "line": "require_command = docker_run_for_project(", + "distance": 4 + } + ] }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", "line": "[\"composer\", \"create-project\", \"laravel/laravel\", \".\", \"--no-interaction\", \"--no-progress\", \"--prefer-dist\"],", "count": 1, - "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "Waterline observer in the published Server image (docker_run_for_project); COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "trusted-builder", + "line": "return docker_run_for_project(", + "distance": 3 + } + ] }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", "line": "return \"composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.mjs", "line": "availability.phpImage || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.mjs", "line": "phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee',", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.mjs", "line": "return { available: true, appDir, phpImage: envValue('DW_SKEW_PHP_IMAGE') || 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee' };", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer create-project laravel/laravel . --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if docker run --rm -v \"$run_root/waterline:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \"durable-workflow/sdk:${php_sdk_version}\" >\"$result_dir/php-sdk-composer-install.log\" 2>&1; then", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if docker run --rm -v \"$run_root/php-sdk-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if docker run --rm -v \"$run_root/waterline:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php /workspace/waterline-seed.php \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan key:generate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan migrate --force \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee php artisan serve --host=0.0.0.0 --port \"$waterline_port\" \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "if docker run --rm -v \"$run_root/php-sdk-worker:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "if docker run --rm -v \"$run_root/waterline:/app\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", "count": 2, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + }, + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "php_sdk_reason=\"docker is required to install the PHP SDK artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", "line": "waterline_reason=\"docker is required to install the Waterline artifact through composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee\"", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", "line": "&& composer require --update-no-dev --no-scripts --no-plugins --no-interaction --no-progress \"durable-workflow/sdk:${PHP_SDK_VERSION}\" \\", "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.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "distance": 6 + } + ] }, { "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/worker-versioning-published-artifacts.sh", "line": "composer create-project --no-interaction --no-progress laravel/laravel . &&", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "distance": 1 + } + ] }, { "path": "scripts/conformance/worker-versioning-published-artifacts.sh", "line": "composer require --no-interaction --no-progress \\", "count": 1, - "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Runs inside the pinned Composer image of the enclosing `docker run`; Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-image", + "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", + "distance": 2 + } + ] }, { "path": "scripts/conformance/worker-versioning-published-artifacts.sh", "line": "if ! docker run --rm -v \"$run_root/waterline-app:/app\" composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee sh -lc \"", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/worker-versioning-published-workers.mjs", "line": "command: `composer require durable-workflow/sdk:${sdkPhpVersion}`,", "count": 2, - "confinement": "Evidence or report text; not executed." + "confinement": "Evidence or report text; not executed.", + "guards": [ + { + "type": "evidence-record", + "line": null, + "distance": null + }, + { + "type": "evidence-record", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/worker-versioning-published-workers.mjs", "line": "const COMPOSER_IMAGE = 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee';", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/workflow-updates-published-artifacts.sh", "line": "composer create-project laravel/laravel . --no-interaction --no-progress --prefer-dist", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set.", + "guards": [ + { + "type": "trust-guard", + "line": "if ! sh \"$script_dir/../ci/composer-trust-guard.sh\" > \"$result_dir/operator-composer-trust.log\" 2>&1; then", + "distance": 171 + } + ] }, { "path": "scripts/conformance/workflow-updates-published-artifacts.sh", "line": "composer require --no-interaction --no-progress --prefer-dist \\", "count": 1, - "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set." + "confinement": "composer-trust-guard.sh runs first and refuses a withdrawn root in COMPOSER_CAFILE, or in the system bundle and hashed directory when no CA file is set.", + "guards": [ + { + "type": "trust-guard", + "line": "if ! sh \"$script_dir/../ci/composer-trust-guard.sh\" > \"$result_dir/operator-composer-trust.log\" 2>&1; then", + "distance": 180 + } + ] }, { "path": "scripts/perf/Dockerfile.octane-frankenphp-experiment", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS phpredis-source", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/perf/Dockerfile.octane-frankenphp-experiment", "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", "count": 1, - "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "distance": 1 + } + ] }, { "path": "scripts/perf/Dockerfile.octane-openswoole-experiment", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/perf/Dockerfile.octane-openswoole-experiment", "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", "count": 1, - "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "distance": 1 + } + ] }, { "path": "scripts/perf/Dockerfile.octane-swoole-experiment", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/perf/Dockerfile.octane-swoole-experiment", "line": "RUN composer require 'laravel/octane:2.20.0' 'laminas/laminas-diactoros:3.8.0' \\", "count": 1, - "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "distance": 1 + } + ] }, { "path": "scripts/perf/Dockerfile.sdk", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/perf/Dockerfile.sdk", "line": "RUN composer install --no-dev --no-interaction --no-progress --prefer-dist", "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.", + "guards": [ + { + "type": "pinned-stage", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", + "distance": 3 + } + ] }, { "path": "scripts/perf/Dockerfile.sdk-current-experiment", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "scripts/perf/Dockerfile.sdk-current-experiment", "line": "RUN composer require 'durable-workflow/sdk:2.1.1' \\", "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.", + "guards": [ + { + "type": "pinned-stage", + "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS dependencies", + "distance": 3 + } + ] }, { "path": "tests/Fixtures/PhpActivityLeaseConsumer/README.md", "line": "Run `composer install` from this directory using the committed lock. It installs", "count": 1, - "confinement": "Documentation; not executed." + "confinement": "Documentation; not executed.", + "guards": [ + { + "type": "documentation", + "line": null, + "distance": null + } + ] }, { "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", "line": "FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS checked-ca", "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store." + "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] }, { "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", "line": "RUN composer require \"durable-workflow/sdk:${PHP_SDK_VERSION}\" --no-interaction --no-progress --prefer-dist \\", "count": 1, - "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request." + "confinement": "COMPOSER_CAFILE is the pinned image's checked store and COMPOSER_CAPATH an empty directory, set before the first package request.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "distance": 4 + } + ] } ] } diff --git a/tests/Unit/Support/composer_trust_test.py b/tests/Unit/Support/composer_trust_test.py index a75e710d0..39ddd3be0 100644 --- a/tests/Unit/Support/composer_trust_test.py +++ b/tests/Unit/Support/composer_trust_test.py @@ -21,12 +21,16 @@ import shutil import ssl import subprocess +import sys import tempfile import threading import unittest from collections import Counter from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import sink_guards # noqa: E402 + ROOT = Path(__file__).resolve().parents[3] CI = ROOT / "scripts" / "ci" PINNED_COMPOSER = "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" @@ -40,6 +44,27 @@ ) SCANNED = (".github/workflows", "scripts", "tests/Fixtures", "tests/Integration", "benchmarks", "docker") MANIFEST = ROOT / "tests" / "Unit" / "Support" / "composer-sinks.json" +SELF_CHECKS = { + # A Composer image reference is confined by being the pinned digest. + "pinned-reference": lambda occurrence: re.sub(r"^(?:docker\.io/)?(?:library/)?", "", occurrence.text) == PINNED_COMPOSER, + "evidence-record": sink_guards.evidence_record(ROOT), +} +# How a reviewed sink is confined, checked against the source (sink_guards). +GUARDS = { + # The enclosing docker run or service uses the pinned image. + "pinned-image": sink_guards.GuardType(re.compile(re.escape(PINNED_COMPOSER)), -1), + # A Dockerfile stage built from the pinned image. + "pinned-stage": sink_guards.GuardType(re.compile(r"^\s*FROM\s"), -1, contains=PINNED_COMPOSER), + # COMPOSER_CAFILE (with an empty COMPOSER_CAPATH) set before the request. + "checked-ca": sink_guards.GuardType(re.compile(r"COMPOSER_CAFILE"), -1), + # composer-trust-guard.sh refuses an affected store first. + "trust-guard": sink_guards.GuardType(re.compile(r"composer-trust-guard\.sh"), -1), + # This repository's Server image, whose stores server-verify-trust checks. + "server-verify": sink_guards.GuardType(re.compile(r"server-verify-trust"), -1), + "server-base-build": sink_guards.GuardType(re.compile(r"docker build --target base "), -1), + # docker_run_for_project, which always adds the checked CA pair. + "trusted-builder": sink_guards.GuardType(re.compile(r"docker_run_for_project\("), -1), +} def scanned_files(root: Path) -> list[Path]: @@ -49,17 +74,7 @@ def scanned_files(root: Path) -> list[Path]: def composer_sinks(root: Path) -> Counter[tuple[str, str]]: - sinks: Counter[tuple[str, str]] = Counter() - for path in scanned_files(root): - try: - text = path.read_text() - except (UnicodeDecodeError, OSError): - continue - for match in COMPOSER_SINK.finditer(text): - start = text.rfind("\n", 0, match.start()) + 1 - end = text.find("\n", match.start()) - sinks[(path.relative_to(root).as_posix(), text[start:end if end >= 0 else None].strip())] += 1 - return sinks + return sink_guards.counts(sink_guards.occurrences(root, scanned_files(root), COMPOSER_SINK)) def unreviewed_sinks(root: Path, entries: list[dict[str, object]]) -> tuple[list[str], list[str]]: @@ -81,6 +96,40 @@ class ComposerInventoryTest(unittest.TestCase): def setUp(self) -> None: self.entries = json.loads(MANIFEST.read_text())["sinks"] + def guard_problems(self, root: Path, paths: list[str] | None = None) -> list[str]: + files = scanned_files(root) if paths is None else [root / path for path in paths] + found = sink_guards.occurrences(root, files, COMPOSER_SINK) + return sink_guards.guard_problems(root, found, self.entries, GUARDS, SELF_CHECKS) + + def test_every_sink_keeps_its_recorded_guard(self) -> None: + self.assertEqual([], self.guard_problems(ROOT), f"restore the guard or update {MANIFEST.name}") + + def test_removing_a_guard_fails_although_the_sink_is_unchanged(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-composer-guards-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + relative = "scripts/conformance/server-parity/corrected-marker-embedded-install.sh" + source = (ROOT / relative).read_text().split("\n") + guard = next(i for i, line in enumerate(source) if GUARDS["trust-guard"].pattern.search(line)) + (work / relative).parent.mkdir(parents=True) + (work / relative).write_text("\n".join(source)) + + self.assertEqual([], self.guard_problems(work, [relative])) + + (work / relative).write_text("\n".join(source[:guard] + source[guard + 1:])) + + self.assertEqual([], unreviewed_sinks(work, self.entries)[0]) + problems = self.guard_problems(work, [relative]) + self.assertTrue(any("composer install" in problem for problem in problems), problems) + + def test_a_pinned_stage_guard_must_name_the_pinned_image(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-composer-stage-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + relative = "scripts/perf/Dockerfile.sdk" + (work / relative).parent.mkdir(parents=True) + (work / relative).write_text((ROOT / relative).read_text().replace(PINNED_COMPOSER, "composer:2")) + + self.assertTrue(self.guard_problems(work, [relative])) + def test_every_composer_image_is_the_pinned_digest(self) -> None: floating = [] for path in scanned_files(ROOT): diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py index e637dd9b1..e2f70b01f 100644 --- a/tests/Unit/Support/patched_pip_test.py +++ b/tests/Unit/Support/patched_pip_test.py @@ -21,6 +21,9 @@ from collections import Counter from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import sink_guards # noqa: E402 + ROOT = Path(__file__).resolve().parents[3] CI = ROOT / "scripts" / "ci" PIP_WHEEL = ( @@ -56,20 +59,59 @@ MANIFEST = ROOT / "tests" / "Unit" / "Support" / "pip-sinks.json" -def pip_sinks(root: Path) -> Counter[tuple[str, str]]: +# How a reviewed sink is confined, checked against the source (sink_guards). +GUARDS = { + # A call that installs the patched, root-checked pip, earlier in the same + # script or job: bootstrap-patched-pip.sh run by sh, the central action, + # or the runners' own calls. Path definitions alone do not count. + "bootstrap": sink_guards.GuardType(re.compile( + r"""\bsh\s+["']?[^\s"']*bootstrap-patched-pip\.sh""" + r"""|uses:\s*durable-workflow/\.github/\.github/actions/bootstrap-patched-pip@""" + r"""|'sh',\s*\[PATCHED_PIP_BOOTSTRAP""" + r"""|\brun\w*\(\s*(?:bootstrap_command\b|\["sh",\s*str\(bootstrap\))""" + ), -1), + # The bootstrap's own install of the hash-pinned wheel, in the same command. + "pinned-wheel": sink_guards.GuardType(re.compile(r"#sha256=[0-9a-f]{64}"), 1), +} +CENTRAL_ACTION = "durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7" +SELF_CHECKS = { + # The Security-accepted central action is itself the bootstrap. + "accepted-action": lambda occurrence: occurrence.line == f"uses: {CENTRAL_ACTION} # clean-trust", + # "pip --version" makes no request. + "version-query": lambda occurrence: re.search(r"-m pip --version\b", occurrence.line) is not None, + "evidence-record": sink_guards.evidence_record(ROOT), +} +# A networked "-m pip" runs from checked-out or mounted source, so Python must +# not import a pip from the working directory: "-I" comes first. +MODULE_PIP = re.compile(r"""-m[\s'",\\]+pip\b""") + + +def scanned_files(root: Path) -> list[Path]: files = [path for directory in SCANNED for path in sorted((root / directory).rglob("*")) if path.is_file()] - files += [path for path in (root / "Dockerfile", *sorted(root.glob("docker-compose*.yml"))) if path.is_file()] - sinks: Counter[tuple[str, str]] = Counter() - for path in files: - try: - text = path.read_text() - except (UnicodeDecodeError, OSError): + return files + [path for path in (root / "Dockerfile", *sorted(root.glob("docker-compose*.yml"))) if path.is_file()] + + +def pip_sinks(root: Path) -> Counter[tuple[str, str]]: + return sink_guards.counts(sink_guards.occurrences(root, scanned_files(root), PIP_SINK)) + + +def unisolated_module_pips(root: Path, exceptions: list[dict[str, object]]) -> list[str]: + """Every "-m pip" without "-I" before it, apart from the recorded exceptions.""" + allowed = Counter((str(entry["path"]), str(entry["line"])) for entry in exceptions) + found = [] + for occurrence in sink_guards.occurrences(root, scanned_files(root), MODULE_PIP): + if sink_guards.COMMENT.match(occurrence.line): + continue + source = (root / occurrence.path).read_text().split("\n") + before = "\n".join(source[:occurrence.index] + [source[occurrence.index][:occurrence.column]]) + if re.search(r"""-I[\s'",\\]*$""", before): continue - for match in PIP_SINK.finditer(text): - start = text.rfind("\n", 0, match.start()) + 1 - end = text.find("\n", match.start()) - sinks[(path.relative_to(root).as_posix(), text[start:end if end >= 0 else None].strip())] += 1 - return sinks + key = (occurrence.path, occurrence.line) + if allowed[key] > 0: + allowed[key] -= 1 + continue + found.append(f"{occurrence.path}:{occurrence.index + 1}: {occurrence.line}") + return found def unreviewed_sinks(root: Path, entries: list[dict[str, object]]) -> tuple[list[str], list[str]]: @@ -358,9 +400,117 @@ def test_patched_pip_wrapper_keeps_pip_status_and_removes_its_venv(self) -> None temporary.rmdir() + def poisoned_directory(self) -> tuple[Path, Path]: + """A working directory whose pip.py and pip/ package write a marker.""" + directory = Path(tempfile.mkdtemp(prefix="dw-poisoned-cwd-", dir=self.work)) + marker = directory / "imported-checkout-pip" + payload = f"open({str(marker)!r}, 'a').write('checked-out pip ran\\n')\n" + (directory / "pip.py").write_text(payload) + (directory / "pip").mkdir() + (directory / "pip" / "__init__.py").write_text(payload) + (directory / "pip" / "__main__.py").write_text(payload) + return directory, marker + + def test_a_poisoned_working_directory_shadows_pip_without_isolation(self) -> None: + # The control: from this directory an unisolated "-m pip" runs the + # checked-out package instead of the bootstrapped client. + directory, marker = self.poisoned_directory() + subprocess.run([self.python, "-m", "pip", "--version"], cwd=directory, capture_output=True, + env=self.base_environment()) + + self.assertTrue(marker.exists()) + + def test_patched_pip_wrapper_ignores_a_poisoned_working_directory(self) -> None: + directory, marker = self.poisoned_directory() + result = subprocess.run(["sh", str(CI / "patched-pip.sh"), "--version"], cwd=directory, + capture_output=True, text=True, env=self.base_environment()) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("pip 26.2.2", result.stdout) + self.assertFalse(marker.exists(), "the wrapper imported pip from its working directory") + + def test_the_bootstrapped_venv_ignores_a_poisoned_working_directory_with_isolation(self) -> None: + directory, marker = self.poisoned_directory() + result = subprocess.run([self.python, "-I", "-m", "pip", "--version"], cwd=directory, + capture_output=True, text=True, env=self.base_environment()) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("pip 26.2.2", result.stdout) + self.assertFalse(marker.exists()) + + class PatchedPipInventoryTest(unittest.TestCase): def setUp(self) -> None: - self.entries = json.loads(MANIFEST.read_text())["sinks"] + self.manifest = json.loads(MANIFEST.read_text()) + self.entries = self.manifest["sinks"] + + def guard_problems(self, root: Path, paths: list[str] | None = None) -> list[str]: + files = scanned_files(root) if paths is None else [root / path for path in paths] + found = sink_guards.occurrences(root, files, PIP_SINK) + return sink_guards.guard_problems(root, found, self.entries, GUARDS, SELF_CHECKS) + + def test_every_sink_keeps_its_recorded_guard(self) -> None: + self.assertEqual([], self.guard_problems(ROOT), f"restore the guard or update {MANIFEST.name}") + + def test_removing_a_guard_fails_although_the_sink_is_unchanged(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-pip-guards-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + relative = "scripts/conformance/sagas-published-artifacts.sh" + sink = 'python -I -m pip install "durable-workflow==$python_version" httpx' + source = (ROOT / relative).read_text().split("\n") + index = next(i for i, line in enumerate(source) if line.strip() == sink) + guard = next(i for i in range(index - 1, -1, -1) if GUARDS["bootstrap"].pattern.search(source[i])) + (work / relative).parent.mkdir(parents=True) + (work / relative).write_text("\n".join(source)) + + self.assertEqual([], self.guard_problems(work, [relative])) + + (work / relative).write_text("\n".join(source[:guard] + source[guard + 1:])) + + self.assertEqual([], unreviewed_sinks(work, self.entries)[0]) + problems = self.guard_problems(work, [relative]) + self.assertTrue(any(sink[:40] in problem for problem in problems), problems) + + def test_a_guard_type_must_match_its_sink(self) -> None: + entries = [dict(entry, guards=[dict(guard, type="comment", line=None, distance=None) for guard in entry["guards"]]) + if entry["path"] == "scripts/conformance/sagas-published-artifacts.sh" else entry + for entry in self.entries] + found = sink_guards.occurrences(ROOT, [ROOT / "scripts/conformance/sagas-published-artifacts.sh"], PIP_SINK) + + self.assertTrue(sink_guards.guard_problems(ROOT, found, entries, GUARDS, SELF_CHECKS)) + + def test_every_networked_module_pip_is_isolated(self) -> None: + self.assertEqual([], unisolated_module_pips(ROOT, self.manifest["isolation_exceptions"])) + + def test_unisolated_module_pips_fail(self) -> None: + work = Path(tempfile.mkdtemp(prefix="dw-pip-isolation-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + cases = { + "scripts/shell.sh": ('"$venv/bin/python" -m pip install durable-workflow\n', 1), + "scripts/list.py": ('run([str(python), "-m", "pip", "install", "x"])\n', 1), + "scripts/split.py": ('command = [\n str(python),\n "-m",\n "pip",\n]\n', 1), + "scripts/array.mjs": ("run(python, ['-m', 'pip', 'install', 'x']);\n", 1), + "scripts/isolated.sh": ('"$venv/bin/python" -I -m pip install durable-workflow\n', 0), + "scripts/isolated.mjs": ("run(python, ['-I', '-m', 'pip', 'install', 'x']);\n", 0), + "scripts/comment.sh": ("# python -m pip install durable-workflow\n", 0), + } + for relative, (text, _) in cases.items(): + (work / relative).parent.mkdir(parents=True, exist_ok=True) + (work / relative).write_text(text) + # An exception covers only its exact line. + exceptions = [{"path": "scripts/shell.sh", "line": "python -m pip install other"}] + + found = unisolated_module_pips(work, exceptions) + + for relative, (_, expected) in cases.items(): + with self.subTest(relative): + self.assertEqual(expected, sum(item.startswith(f"{relative}:") for item in found), found) + + def test_every_isolation_exception_is_owned_and_still_needed(self) -> None: + for exception in self.manifest["isolation_exceptions"]: + with self.subTest(exception["path"]): + self.assertTrue(str(exception.get("owner", "")).strip() and str(exception.get("reason", "")).strip()) + self.assertIn(exception["line"], [line.strip() for line in (ROOT / exception["path"]).read_text().split("\n")]) def test_every_pip_sink_is_reviewed(self) -> None: unreviewed, stale = unreviewed_sinks(ROOT, self.entries) diff --git a/tests/Unit/Support/pip-sinks.json b/tests/Unit/Support/pip-sinks.json index 2b920bc39..aa2dc8d46 100644 --- a/tests/Unit/Support/pip-sinks.json +++ b/tests/Unit/Support/pip-sinks.json @@ -1,5 +1,5 @@ { - "schema": "durable-workflow.server.pip-sinks/v1", + "schema": "durable-workflow.server.pip-sinks/v2", "advisory": "GHSA-6q48-6c7w-9gw2", "rule": "Every place pip can make a request is listed here with how it is confined. A new or changed sink fails tests/Unit/Support/patched_pip_test.py until it is confined and recorded.", "sinks": [ @@ -7,229 +7,523 @@ "path": ".github/workflows/helm-chart-checks.yml", "line": "python -I -m pip install --only-binary :all: PyYAML==6.0.2", "count": 1, - "confinement": "Central bootstrap-patched-pip earlier in the job; this step's env sets PIP_USE_DEPRECATED=legacy-certs, so pip uses only the bundle the action checked. `-I` keeps a checked-out `pip` package from shadowing the installed client." + "confinement": "Central bootstrap-patched-pip earlier in the job; this step's env sets PIP_USE_DEPRECATED=legacy-certs, so pip uses only the bundle the action checked. `-I` keeps a checked-out `pip` package from shadowing the installed client.", + "guards": [ + { + "type": "bootstrap", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", + "distance": 8 + } + ] }, { "path": ".github/workflows/helm-chart-checks.yml", "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 2, - "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps.", + "guards": [ + { + "type": "accepted-action", + "line": null, + "distance": null + }, + { + "type": "accepted-action", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/helm-chart-checks.yml", "line": "uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0", "count": 1, - "confinement": "The action installs yamllint/yamale with its own venv pip; its step env sets PIP_CERT to the bootstrapped pip's checked bundle and PIP_USE_DEPRECATED=legacy-certs." + "confinement": "The action installs yamllint/yamale with its own venv pip; its step env sets PIP_CERT to the bootstrapped pip's checked bundle and PIP_USE_DEPRECATED=legacy-certs.", + "guards": [ + { + "type": "bootstrap", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", + "distance": 8 + } + ] }, { "path": ".github/workflows/phpunit-feature.yml", "line": "run: python -m pip install PyYAML==6.0.2", "count": 1, - "confinement": "The clean-trust action at the preflight's reviewed step exports the checked bundle as PIP_CERT and SSL_CERT_FILE with an empty SSL_CERT_DIR, so pip and its truststore see only that bundle." + "confinement": "The clean-trust action at the preflight's reviewed step exports the checked bundle as PIP_CERT and SSL_CERT_FILE with an empty SSL_CERT_DIR, so pip and its truststore see only that bundle.", + "guards": [ + { + "type": "bootstrap", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", + "distance": 2 + } + ] }, { "path": ".github/workflows/phpunit-feature.yml", "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 1, - "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps. At the policy's reviewed preflight step." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps. At the policy's reviewed preflight step.", + "guards": [ + { + "type": "accepted-action", + "line": null, + "distance": null + } + ] }, { "path": ".github/workflows/server-perf.yml", "line": "python3 -I -m pip install --require-hashes --only-binary :all: --no-deps \\", "count": 1, - "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job. `-I` keeps a checked-out `pip` package from shadowing the installed client. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." + "confinement": "Central bootstrap earlier in the job, then PIP_CERT (checked bundle) and PIP_USE_DEPRECATED=legacy-certs are set for the job. `-I` keeps a checked-out `pip` package from shadowing the installed client. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files.", + "guards": [ + { + "type": "bootstrap", + "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", + "distance": 28 + } + ] }, { "path": ".github/workflows/server-perf.yml", "line": "uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust", "count": 1, - "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps." + "confinement": "The central clean-trust action (Security-accepted .github #239): installs the hash-pinned pip 26.2.2, checks its bundle and exports it, with an empty SSL_CERT_DIR, as PIP_CERT and SSL_CERT_FILE to later steps.", + "guards": [ + { + "type": "accepted-action", + "line": null, + "distance": null + } + ] }, { "path": "benchmarks/capacity/v1/README.md", - "line": "/tmp/capacity-venv/bin/python -m pip install --require-hashes --only-binary :all: --no-deps \\", + "line": "/tmp/capacity-venv/bin/python -I -m pip install --require-hashes --only-binary :all: --no-deps \\", "count": 1, - "confinement": "Documentation; the instructions bootstrap /tmp/capacity-venv first. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." + "confinement": "Documentation; the instructions bootstrap /tmp/capacity-venv first. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files. Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh scripts/ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python", + "distance": 1 + } + ] }, { "path": "benchmarks/capacity/v1/topologies/local-docker/compose.json", - "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", + "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -I -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", "count": 1, - "confinement": "sh -e: bootstrap-patched-pip.sh on /tmp/capacity-venv, then that venv (first on PATH) installs. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files." + "confinement": "sh -e: bootstrap-patched-pip.sh on /tmp/capacity-venv, then that venv (first on PATH) installs. The lock hashes every pinned file; --require-hashes, --only-binary :all: and --no-deps install only those files. Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "\"command\": [\"sh\", \"-ec\", \"/usr/local/bin/python3 -m venv /tmp/capacity-venv; sh /dw-ci/bootstrap-patched-pip.sh /tmp/capacity-venv/bin/python; python3 -I -m pip install --disable-pip-version-check --no-cache-dir --require-hashes --only-binary :all: --no-deps -r requirements.lock; test \\\"$(python3 -c 'import importlib.metadata; print(importlib.metadata.version(\\\"durable-workflow\\\"))')\\\" = 2.0.1; touch /tmp/capacity-adapter-ready; exec tail -f /dev/null\"],", + "distance": 0 + } + ] }, { "path": "scripts/ci/bootstrap-patched-pip.sh", "line": "\"$python\" -I -m pip --isolated --disable-pip-version-check install --no-deps --upgrade --quiet --no-cache-dir \\", "count": 1, - "confinement": "The bootstrap itself: installs the hash-pinned pip wheel, so the download is verified by hash. `-I` keeps a checked-out `pip` package from shadowing the installed client." + "confinement": "The bootstrap itself: installs the hash-pinned pip wheel, so the download is verified by hash. `-I` keeps a checked-out `pip` package from shadowing the installed client.", + "guards": [ + { + "type": "pinned-wheel", + "line": "\"pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70\"", + "distance": -1 + } + ] }, { "path": "scripts/ci/bootstrap-patched-pip.sh", "line": "# networked pip install, refuses a pip whose CA bundle still trusts a root", "count": 1, - "confinement": "Comment." + "confinement": "Comment.", + "guards": [ + { + "type": "comment", + "line": null, + "distance": null + } + ] }, { "path": "scripts/ci/patched-pip.sh", - "line": "\"$work/pip/bin/python\" -m pip \"$@\" || status=$?", + "line": "\"$work/pip/bin/python\" -I -m pip \"$@\" || status=$?", "count": 1, - "confinement": "The wrapper: runs pip from a venv it has just bootstrapped (set -e)." + "confinement": "The wrapper: runs pip from a venv it has just bootstrapped (set -e). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$here/bootstrap-patched-pip.sh\" \"$work/pip/bin/python\" >&2", + "distance": 2 + } + ] }, { "path": "scripts/conformance/activities-published-artifacts.sh", - "line": "&& \"$venv/bin/python\" -m pip download --disable-pip-version-check --no-deps \\", + "line": "&& \"$venv/bin/python\" -I -m pip download --disable-pip-version-check --no-deps \\", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$venv/bin/python\" >>\"$install_log\" 2>&1 \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/activities-published-artifacts.sh", - "line": "&& \"$venv/bin/python\" -m pip install --disable-pip-version-check --no-input \"$distribution\" >>\"$install_log\" 2>&1; then", + "line": "&& \"$venv/bin/python\" -I -m pip install --disable-pip-version-check --no-input \"$distribution\" >>\"$install_log\" 2>&1; then", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$venv/bin/python\" >>\"$install_log\" 2>&1 \\", + "distance": 7 + } + ] }, { "path": "scripts/conformance/child-workflows-install-probe.py", - "line": "install_command = [str(python_bin), \"-m\", \"pip\", \"install\", \"--disable-pip-version-check\", \"--no-input\", f\"durable-workflow=={python_version}\"]", + "line": "install_command = [str(python_bin), \"-I\", \"-m\", \"pip\", \"install\", \"--disable-pip-version-check\", \"--no-input\", f\"durable-workflow=={python_version}\"]", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (bootstrap_code == 0)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (bootstrap_code == 0). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "bootstrap_code, bootstrap_output = run(bootstrap_command) if create_code == 0 else (1, create_output)", + "distance": 1 + } + ] }, { "path": "scripts/conformance/child-workflows-published-artifacts.sh", - "line": "&& \"$venv/bin/python\" -m pip install --disable-pip-version-check --no-input \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$install_log\" 2>&1; then", + "line": "&& \"$venv/bin/python\" -I -m pip install --disable-pip-version-check --no-input \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$install_log\" 2>&1; then", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$venv/bin/python\" >>\"$install_log\" 2>&1 \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/namespaces-published-artifacts.sh", - "line": "if ! \"$run_root/.venv/bin/python\" -m pip install \"durable-workflow==$python_sdk_version\" > \"$result_dir/python-sdk-install.log\" 2>&1; then", + "line": "if ! \"$run_root/.venv/bin/python\" -I -m pip install \"durable-workflow==$python_sdk_version\" > \"$result_dir/python-sdk-install.log\" 2>&1; then", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check, then blocked_result)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check, then blocked_result). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "if ! sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/.venv/bin/python\" > \"$result_dir/python-pip-upgrade.log\" 2>&1; then", + "distance": 4 + } + ] }, { "path": "scripts/conformance/nexus-published-artifacts.sh", "line": "`python -m venv /tmp/dw-python && sh /work/dw-ci/bootstrap-patched-pip.sh /tmp/dw-python/bin/python >/tmp/dw-python-pip.log 2>&1 && . /tmp/dw-python/bin/activate && pip install --no-cache-dir durable-workflow==${JSON.stringify(versions['sdk-python']).slice(1, -1)} >/tmp/dw-python-pip.log 2>&1 && python /work/nexus-python-sdk-service.py`,", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /tmp/dw-python in the Python service (&& chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /tmp/dw-python in the Python service (&& chain).", + "guards": [ + { + "type": "bootstrap", + "line": "`python -m venv /tmp/dw-python && sh /work/dw-ci/bootstrap-patched-pip.sh /tmp/dw-python/bin/python >/tmp/dw-python-pip.log 2>&1 && . /tmp/dw-python/bin/activate && pip install --no-cache-dir durable-workflow==${JSON.stringify(versions['sdk-python']).slice(1, -1)} >/tmp/dw-python-pip.log 2>&1 && python /work/nexus-python-sdk-service.py`,", + "distance": 0 + } + ] }, { "path": "scripts/conformance/principal-attribution-published-artifacts.sh", "line": "\"-m\",", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runner_blocked otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runner_blocked otherwise).", + "guards": [ + { + "type": "bootstrap", + "line": "code, output = run(bootstrap_command, timeout=180)", + "distance": 7 + } + ] }, { "path": "scripts/conformance/python-published-artifacts.sh", - "line": "python -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", + "line": "python -I -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/.venv/bin/python\" > \"$result_dir/pip-upgrade.log\" 2>&1 \\", + "distance": 3 + } + ] }, { "path": "scripts/conformance/python-published-artifacts.sh", - "line": "python -m pip install \"$python_distribution\" > \"$result_dir/python-sdk-install.log\" 2>&1 \\", + "line": "python -I -m pip install \"$python_distribution\" > \"$result_dir/python-sdk-install.log\" 2>&1 \\", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; fail_blocked otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/.venv/bin/python\" > \"$result_dir/pip-upgrade.log\" 2>&1 \\", + "distance": 11 + } + ] }, { "path": "scripts/conformance/replay-published-artifacts.sh", - "line": "python -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", + "line": "python -I -m pip download --no-deps --dest \"$run_root/python-distributions\" \\", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/python-venv/bin/python\" > \"$result_dir/python-pip-upgrade.log\" 2>&1", + "distance": 5 + } + ] }, { "path": "scripts/conformance/replay-published-artifacts.sh", - "line": "python -m pip install \"$python_distribution\" > \"$result_dir/python-install.log\" 2>&1", + "line": "python -I -m pip install \"$python_distribution\" > \"$result_dir/python-install.log\" 2>&1", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated python-venv; no request when it failed). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/python-venv/bin/python\" > \"$result_dir/python-pip-upgrade.log\" 2>&1", + "distance": 19 + } + ] }, { "path": "scripts/conformance/sagas-published-artifacts.sh", - "line": "python -m pip install \"durable-workflow==$python_version\" httpx", + "line": "python -I -m pip install \"durable-workflow==$python_version\" httpx", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; set -e)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (the activated .venv; set -e). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/.venv/bin/python\"", + "distance": 1 + } + ] }, { "path": "scripts/conformance/schedules-published-artifacts.mjs", - "line": "['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`],", + "line": "['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`],", "count": 2, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (execLogged throws otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (execLogged throws otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-python-lifecycle-pip-upgrade.log'));", + "distance": 3 + }, + { + "type": "bootstrap", + "line": "await execLogged('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], path.join(resultDir, 'schedules-cross-language-python-pip-upgrade.log'));", + "distance": 3 + } + ] }, { "path": "scripts/conformance/server-parity/published-sdk-patch/identity.mjs", "line": "assert.ok(installed, 'published Python SDK in the actual pip install report');", "count": 1, - "confinement": "Assertion text about pip's install report; not a command." + "confinement": "Assertion text about pip's install report; not a command.", + "guards": [ + { + "type": "assertion-message", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", "line": "\"-m\",", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise).", + "guards": [ + { + "type": "bootstrap", + "line": "pip = run_command([\"sh\", str(bootstrap), str(python_bin)], log_file=log_file, timeout=180)", + "distance": 10 + } + ] }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", - "line": "[str(python_bin), \"-m\", \"pip\", \"install\", str(distribution)],", + "line": "[str(python_bin), \"-I\", \"-m\", \"pip\", \"install\", str(distribution)],", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (raises otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "pip = run_command([\"sh\", str(bootstrap), str(python_bin)], log_file=log_file, timeout=180)", + "distance": 26 + } + ] }, { "path": "scripts/conformance/skew-published-artifacts.sh", - "line": "&& \"$run_root/.venv/bin/python\" -m pip install \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$result_dir/python-install.log\" 2>&1; then", + "line": "&& \"$run_root/.venv/bin/python\" -I -m pip install \"durable-workflow==${DW_PYTHON_SDK_VERSION}\" >>\"$result_dir/python-install.log\" 2>&1; then", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (&& chain). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$run_root/.venv/bin/python\" >>\"$result_dir/python-install.log\" 2>&1 \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/worker-versioning-mixed.Dockerfile", "line": "&& pip install --no-cache-dir --report /opt/python/install-report.json \"durable-workflow==${PYTHON_SDK_VERSION}\"", "count": 1, - "confinement": "Same RUN && chain bootstraps /opt/python (first on PATH) before this install." + "confinement": "Same RUN && chain bootstraps /opt/python (first on PATH) before this install.", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh /opt/dw-ci/bootstrap-patched-pip.sh /opt/python/venv/bin/python \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/worker-versioning-published-workers.mjs", - "line": "command: `python3 -m pip install durable-workflow==${pythonVersion}`,", + "line": "command: `python3 -I -m pip install durable-workflow==${pythonVersion}`,", "count": 3, - "confinement": "Evidence receipt text; not executed." + "confinement": "Evidence receipt text; not executed. Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "evidence-record", + "line": null, + "distance": null + }, + { + "type": "evidence-record", + "line": null, + "distance": null + }, + { + "type": "evidence-record", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/worker-versioning-published-workers.mjs", - "line": "runRequired(pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], {", + "line": "runRequired(pythonBin, ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], {", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runRequired throws otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (runRequired throws otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "runRequired('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], { logPath: installLog, append: true });", + "distance": 1 + } + ] }, { "path": "scripts/conformance/workflow-lifecycle-published-artifacts.sh", - "line": "|| ! \"$python_bin\" -m pip install --disable-pip-version-check --no-input \"durable-workflow==${sdk_version}\" >>\"$result_dir/python-sdk-lifecycle-pip-install.log\" 2>&1; then", + "line": "|| ! \"$python_bin\" -I -m pip install --disable-pip-version-check --no-input \"durable-workflow==${sdk_version}\" >>\"$result_dir/python-sdk-lifecycle-pip-install.log\" 2>&1; then", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (short-circuits otherwise)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (short-circuits otherwise). Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "if ! sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$python_bin\" >\"$result_dir/python-sdk-lifecycle-pip-install.log\" 2>&1 \\", + "distance": 1 + } + ] }, { "path": "scripts/conformance/workflow-updates-published-artifacts.sh", - "line": "if ! \"$venv_python\" -m pip --version > \"$result_dir/python-sdk-pip-version.log\" 2>&1; then", + "line": "if ! \"$venv_python\" -I -m pip --version > \"$result_dir/python-sdk-pip-version.log\" 2>&1; then", "count": 1, - "confinement": "Local version query; no request." + "confinement": "Local version query; no request. Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "version-query", + "line": null, + "distance": null + } + ] }, { "path": "scripts/conformance/workflow-updates-published-artifacts.sh", - "line": "if ! PIP_CONFIG_FILE=/dev/null \"$venv_python\" -m pip --isolated install \\", + "line": "if ! PIP_CONFIG_FILE=/dev/null \"$venv_python\" -I -m pip --isolated install \\", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check); --isolated, so it passes --use-deprecated=legacy-certs itself." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter (explicit check); --isolated, so it passes --use-deprecated=legacy-certs itself. Runs as python -I, so a checked-out pip cannot shadow the client.", + "guards": [ + { + "type": "bootstrap", + "line": "if ! sh \"$script_dir/../ci/bootstrap-patched-pip.sh\" \"$venv_python\" >> \"$result_dir/python-sdk-pip-version.log\" 2>&1; then", + "distance": 7 + } + ] }, { "path": "scripts/perf/run-namespace-isolation.sh", "line": "\"$VENV/bin/pip\" install --quiet --disable-pip-version-check \"durable-workflow==$SDK_VERSION\"", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter $VENV (set -e)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter $VENV (set -e).", + "guards": [ + { + "type": "bootstrap", + "line": "sh \"$ROOT_DIR/scripts/ci/bootstrap-patched-pip.sh\" \"$VENV/bin/python\"", + "distance": 1 + } + ] }, { "path": "tests/Fixtures/UpdateRequestKeyConsumer/compose.yml", "line": "/opt/venv/bin/pip install --index-url https://pypi.org/simple --no-cache-dir --quiet durable-workflow==2.6.1", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /opt/venv (sh -e)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /opt/venv (sh -e).", + "guards": [ + { + "type": "bootstrap", + "line": "sh /dw-ci/bootstrap-patched-pip.sh /opt/venv/bin/python", + "distance": 1 + } + ] }, { "path": "tests/Integration/StorageAdmission/sdk/Dockerfile", "line": "&& /sdk/venv/bin/pip install --no-cache-dir \"durable-workflow==${PYTHON_SDK_VERSION}\"", "count": 1, - "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /sdk/venv (RUN && chain)." + "confinement": "Runs only after scripts/ci/bootstrap-patched-pip.sh succeeded on the same venv interpreter /sdk/venv (RUN && chain).", + "guards": [ + { + "type": "bootstrap", + "line": "&& sh /sdk/ci/bootstrap-patched-pip.sh /sdk/venv/bin/python \\", + "distance": 1 + } + ] + } + ], + "isolation_exceptions": [ + { + "path": ".github/workflows/phpunit-feature.yml", + "line": "run: python -m pip install PyYAML==6.0.2", + "owner": "durable-workflow/.github central qualification policy", + "reason": "The central policy admits only this exact action-policy preflight install. General is asked (cloud#438 19131108) to admit python -I -m pip; adopt it here when it does." } ] } diff --git a/tests/Unit/Support/sink_guards.py b/tests/Unit/Support/sink_guards.py new file mode 100644 index 000000000..afeac40b9 --- /dev/null +++ b/tests/Unit/Support/sink_guards.py @@ -0,0 +1,141 @@ +"""Machine-checked guards for reviewed network sinks (GHSA-6q48-6c7w-9gw2). + +A sink manifest lists every line that can make a package request. For each +occurrence of that line it names the guard confining it: a guard type, the +exact guard line and the signed line distance from the sink (positive when +the guard comes first). The nearest line of that type, in the type's +direction, within the same file and, in a workflow, the same job, must be +that line at that distance. Deleting or moving a guard therefore fails the +inventory even when the sink line itself is unchanged. + +Types without a guard line hold only for sinks that cannot run: comments, +documentation and assertion messages. A pinned reference is its own guard. +""" + +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +COMMENT = re.compile(r"^\s*(?:#|//)") +WORKFLOW_JOB = re.compile(r"^ [A-Za-z0-9_-]+:\s*$") + + +@dataclass(frozen=True) +class GuardType: + pattern: re.Pattern[str] + direction: int # -1: the guard precedes the sink; +1: it follows in the same command + contains: str | None = None # text the guard line itself must hold + + +@dataclass(frozen=True) +class Occurrence: + path: str + line: str + index: int + column: int + text: str + + +def occurrences(root: Path, files: list[Path], sink: re.Pattern[str]) -> list[Occurrence]: + found = [] + for path in files: + try: + text = path.read_text() + except (UnicodeDecodeError, OSError): + continue + starts = [0] + [index + 1 for index, character in enumerate(text) if character == "\n"] + for match in sink.finditer(text): + line_index = text.count("\n", 0, match.start()) + start = starts[line_index] + end = text.find("\n", start) + line = text[start:end if end >= 0 else None] + found.append(Occurrence(path.relative_to(root).as_posix(), line.strip(), line_index, + match.start() - start, match.group(0))) + return found + + +def counts(found: list[Occurrence]) -> Counter[tuple[str, str]]: + return Counter((occurrence.path, occurrence.line) for occurrence in found) + + +def find_guard(lines: list[str], occurrence: Occurrence, guard: GuardType) -> tuple[str, int] | None: + workflow = occurrence.path.startswith(".github/workflows/") + line = lines[occurrence.index] + here = line[:occurrence.column] if guard.direction < 0 else line[occurrence.column + len(occurrence.text):] + if guard.pattern.search(here): + return line.strip(), 0 + step = guard.direction + index = occurrence.index + step + while 0 <= index < len(lines): + candidate = lines[index] + if workflow and WORKFLOW_JOB.match(candidate): + return None + if guard.direction > 0 and not lines[index - 1].rstrip().endswith("\\"): + return None # a following guard must belong to the same continued command + if not COMMENT.match(candidate) and guard.pattern.search(candidate): + return candidate.strip(), occurrence.index - index + index += step + return None + + +# Sinks that cannot run, checked from the sink itself. Inventories add their +# own self-confining kinds, such as a pinned reference. +SelfCheck = Callable[[Occurrence], bool] +SELF_CHECKS: dict[str, SelfCheck] = { + "comment": lambda occurrence: COMMENT.match(occurrence.line) is not None, + "documentation": lambda occurrence: occurrence.path.endswith(".md"), + "assertion-message": lambda occurrence: re.match(r"assert\.\w+\(", occurrence.line) is not None, +} + + +def evidence_record(root: Path) -> SelfCheck: + """A JS evidence field naming a command, in a file that never reads one back.""" + return lambda occurrence: ( + occurrence.path.endswith(".mjs") + and re.match(r"command: `", occurrence.line) is not None + and re.search(r"\.command\b", (root / occurrence.path).read_text()) is None + ) + + +def guard_problems(root: Path, found: list[Occurrence], entries: list[dict], types: dict[str, GuardType], + self_checks: dict[str, SelfCheck] | None = None) -> list[str]: + """Every occurrence's recorded guard, checked against the source.""" + checks = {**SELF_CHECKS, **(self_checks or {})} + reviewed = {(str(entry["path"]), str(entry["line"])): entry for entry in entries} + seen: Counter[tuple[str, str]] = Counter() + sources: dict[str, list[str]] = {} + problems = [] + for occurrence in found: + key = (occurrence.path, occurrence.line) + entry = reviewed.get(key) + if entry is None: + continue # reported by the inventory comparison + position = seen[key] + seen[key] += 1 + guards = entry.get("guards") or [] + label = f"{occurrence.path}:{occurrence.index + 1}: {occurrence.line[:80]}" + if position >= len(guards): + problems.append(f"{label}: no recorded guard for occurrence {position + 1}") + continue + recorded = guards[position] + kind = recorded.get("type") + if kind in checks: + if recorded.get("line") is not None or recorded.get("distance") is not None or not checks[kind](occurrence): + problems.append(f"{label}: not a {kind}") + continue + guard = types.get(kind) + if guard is None: + problems.append(f"{label}: unknown guard type {kind!r}") + continue + lines = sources.setdefault(occurrence.path, (root / occurrence.path).read_text().split("\n")) + actual = find_guard(lines, occurrence, guard) + expected = (recorded.get("line"), recorded.get("distance")) + if actual != expected: + problems.append(f"{label}: {kind} guard {expected!r} not found; nearest is {actual!r}") + elif guard.contains is not None and guard.contains not in actual[0]: + problems.append(f"{label}: {kind} guard does not hold {guard.contains!r}") + return problems diff --git a/tests/Unit/WorkflowUpdateRuntimeContractTest.php b/tests/Unit/WorkflowUpdateRuntimeContractTest.php index 8c4de23c6..22d97a0b7 100644 --- a/tests/Unit/WorkflowUpdateRuntimeContractTest.php +++ b/tests/Unit/WorkflowUpdateRuntimeContractTest.php @@ -1836,7 +1836,7 @@ public function test_python_sdk_shard_installs_pinned_pypi_artifact_and_runs_pac $source, ); $this->assertStringContainsString( - 'PIP_CONFIG_FILE=/dev/null "$venv_python" -m pip --isolated install', + 'PIP_CONFIG_FILE=/dev/null "$venv_python" -I -m pip --isolated install', $source, ); $this->assertStringContainsString( From 2d3dcc31a83de8ad78715837402c3c0cec4bf34d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 20:28:06 +0000 Subject: [PATCH 09/21] Check each adapter binding's dependency in its own function Sonar python:S3776 flagged the combined check after the hash-shape rule was added. Each language's checks are unchanged. --- scripts/benchmark/capacity_suite.py | 94 +++++++++++++++-------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/scripts/benchmark/capacity_suite.py b/scripts/benchmark/capacity_suite.py index f7b5916b1..17b9f6c48 100755 --- a/scripts/benchmark/capacity_suite.py +++ b/scripts/benchmark/capacity_suite.py @@ -1171,50 +1171,56 @@ def _adapter_definition_types(workload: dict[str, Any], kind: str) -> set[str]: def _validate_adapter_dependency( binding: str, adapter_root: Path, expected_version: str ) -> None: - if binding == "php": - manifest = load_json(adapter_root / "composer.json") - require = _object(manifest.get("require"), "php adapter composer.require") - if require.get("durable-workflow/sdk") != expected_version: - raise ContractError( - "PHP adapter must require the suite's exact SDK artifact" - ) - lock = load_json(adapter_root / "composer.lock") - locked_packages = { - str(package.get("name")): str(package.get("version")) - for package in _list( - lock.get("packages"), "php adapter composer.lock.packages" - ) - if isinstance(package, dict) - } - if locked_packages.get("durable-workflow/sdk") != expected_version: - raise ContractError("PHP adapter lock must retain the exact SDK artifact") - return - if binding == "python": - requirement = (adapter_root / "requirements.txt").read_text().strip() - if requirement != f"durable-workflow=={expected_version}": - raise ContractError( - "Python adapter must require the suite's exact SDK artifact" - ) - # Each line pins one package and lists the hashes pip must match. - locked_lines = [ - line.split() - for line in (adapter_root / "requirements.lock").read_text().splitlines() - if line.strip() - ] - locked_requirements = [words[0] for words in locked_lines] - if f"durable-workflow=={expected_version}" not in locked_requirements or any( - "==" not in requirement for requirement in locked_requirements - ): - raise ContractError( - "Python adapter lock must pin its complete dependency graph" - ) - if any( - len(words) < 2 - or any(not word.startswith("--hash=sha256:") for word in words[1:]) - for words in locked_lines - ): - raise ContractError("Python adapter lock must hash every pinned package") - return + validators = { + "php": _validate_php_adapter_dependency, + "python": _validate_python_adapter_dependency, + } + validators.get(binding, _validate_rust_adapter_dependency)( + adapter_root, expected_version + ) + + +def _validate_php_adapter_dependency(adapter_root: Path, expected_version: str) -> None: + manifest = load_json(adapter_root / "composer.json") + require = _object(manifest.get("require"), "php adapter composer.require") + if require.get("durable-workflow/sdk") != expected_version: + raise ContractError("PHP adapter must require the suite's exact SDK artifact") + lock = load_json(adapter_root / "composer.lock") + locked_packages = { + str(package.get("name")): str(package.get("version")) + for package in _list(lock.get("packages"), "php adapter composer.lock.packages") + if isinstance(package, dict) + } + if locked_packages.get("durable-workflow/sdk") != expected_version: + raise ContractError("PHP adapter lock must retain the exact SDK artifact") + + +def _validate_python_adapter_dependency( + adapter_root: Path, expected_version: str +) -> None: + requirement = (adapter_root / "requirements.txt").read_text().strip() + if requirement != f"durable-workflow=={expected_version}": + raise ContractError("Python adapter must require the suite's exact SDK artifact") + # Each line pins one package and lists the hashes pip must match. + locked_lines = [ + line.split() + for line in (adapter_root / "requirements.lock").read_text().splitlines() + if line.strip() + ] + locked_requirements = [words[0] for words in locked_lines] + if f"durable-workflow=={expected_version}" not in locked_requirements or any( + "==" not in requirement for requirement in locked_requirements + ): + raise ContractError("Python adapter lock must pin its complete dependency graph") + if not all(_hashes_every_file(words[1:]) for words in locked_lines): + raise ContractError("Python adapter lock must hash every pinned package") + + +def _hashes_every_file(options: list[str]) -> bool: + return bool(options) and all(word.startswith("--hash=sha256:") for word in options) + + +def _validate_rust_adapter_dependency(adapter_root: Path, expected_version: str) -> None: try: manifest = tomllib.loads((adapter_root / "Cargo.toml").read_text()) except (OSError, tomllib.TOMLDecodeError) as exc: From e9dee76028d76b74be0ac6fece8ac0894ea6fe04 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 21:21:36 +0000 Subject: [PATCH 10/21] Ship a Brotli decoder fixed for CVE-2026-111497 Debian 13 has no fixed libbrotli1 yet. A builder stage on the runtime's pinned base rebuilds Debian's own brotli 1.1.0-2 source, fetched through apt's signed index and checked against pinned hashes, with upstream's 1.1-line fix (google/brotli da98063, released as v1.1.1) as one quilt patch. The runtime installs only libbrotli1 1.1.0-2+b7+dw1, which any future Debian fix supersedes. server-verify-brotli runs in the base and after production's last package change. It requires that package version, that it holds the only libbrotlidec and that every linked object loads it, that Node is a fixed release (v22.23.4 carries the cherry-pick), that the decoder names the fix's new error code, and that a valid stream round trips. The Alpine stage that clones phpredis takes brotli-libs 1.2.1-r0 first. --- .github/workflows/phpunit-feature.yml | 3 + Dockerfile | 29 ++- docker/brotli/CVE-2026-111497.patch | 234 ++++++++++++++++++++++++ docker/brotli/brotli-check.py | 27 +++ docker/brotli/build.sh | 39 ++++ docker/brotli/source.sha256 | 3 + docker/brotli/verify-brotli.sh | 45 +++++ docker/brotli/version | 1 + tests/Unit/Support/brotli_trust_test.py | 155 ++++++++++++++++ tests/Unit/Support/composer-sinks.json | 4 +- 10 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 docker/brotli/CVE-2026-111497.patch create mode 100644 docker/brotli/brotli-check.py create mode 100755 docker/brotli/build.sh create mode 100644 docker/brotli/source.sha256 create mode 100755 docker/brotli/verify-brotli.sh create mode 100644 docker/brotli/version create mode 100644 tests/Unit/Support/brotli_trust_test.py diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 799362cd8..1d6137d12 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -128,6 +128,9 @@ jobs: DW_REQUIRE_COMPOSER_TRUST_TEST: '1' run: python3 -m unittest tests/Unit/Support/composer_trust_test.py + - name: Prove the image builds, installs and checks the fixed Brotli decoder + run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py + feature: name: PHPUnit feature suite needs: action-policy diff --git a/Dockerfile b/Dockerfile index bec97bd23..1ab37b5af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,11 @@ ARG PHPREDIS_COMMIT=df4fab2de7fc327c54c94a13af2b9542e4fbd720 COPY docker/trust/ /usr/local/lib/server-trust/ RUN sh /usr/local/lib/server-trust/verify-trust.sh system +# git clones through libcurl, whose Brotli decoder must carry the +# CVE-2026-111497 fix: Alpine's signed brotli-libs 1.2.1-r0 or later. +RUN apk add --no-cache --upgrade "brotli-libs>=1.2.1-r0" \ + && apk info -e "brotli-libs>=1.2.1-r0" + RUN git clone --depth 1 --branch "${PHPREDIS_VERSION}" https://github.com/phpredis/phpredis.git /phpredis \ && cd /phpredis \ && RESOLVED_COMMIT="$(git rev-parse HEAD)" \ @@ -19,6 +24,15 @@ RUN git clone --depth 1 --branch "${PHPREDIS_VERSION}" https://github.com/phpred exit 1; \ fi +# Debian 13 has no fixed libbrotli1 for CVE-2026-111497 yet: rebuild Debian's +# own source with upstream's fix, for each release architecture (see +# docker/brotli/build.sh). The runtime stage installs only that package. +FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS brotli +COPY docker/trust/ /usr/local/lib/server-trust/ +RUN sh /usr/local/lib/server-trust/verify-trust.sh system +COPY docker/brotli/ /usr/src/server-brotli/ +RUN sh /usr/src/server-brotli/build.sh + # The fixed official Apache runtime is pinned for both release architectures. FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS base @@ -31,6 +45,9 @@ COPY --from=phpredis-source /phpredis /usr/src/php/ext/redis # The image's trust checks; `server-verify-trust` reruns them on any image. COPY docker/trust/ /usr/local/lib/server-trust/ +# The fixed Brotli decoder and its check, `server-verify-brotli`. +COPY docker/brotli/ /usr/local/lib/server-brotli/ +COPY --from=brotli /out/ /usr/src/server-brotli-deb/ # Published conformance runners execute from the exact Server image. # Keep their Node and Python runtimes available alongside the service. Node @@ -47,6 +64,9 @@ RUN chmod 0755 /usr/local/lib/server-trust/verify-trust.sh \ python3 \ python3-venv \ unzip \ + && dpkg -i /usr/src/server-brotli-deb/libbrotli1_*.deb \ + && rm -rf /usr/src/server-brotli-deb \ + && ln -s /usr/local/lib/server-brotli/verify-brotli.sh /usr/local/bin/server-verify-brotli \ && docker-php-ext-install opcache redis pdo pdo_mysql pdo_pgsql pcntl zip bcmath \ && groupmod --gid 1000 www-data \ && usermod --uid 1000 --gid 1000 www-data \ @@ -55,8 +75,10 @@ RUN chmod 0755 /usr/local/lib/server-trust/verify-trust.sh \ COPY --from=composer /usr/bin/composer /usr/bin/composer COPY --from=node /usr/local/bin/node /usr/local/bin/node -# Every store the image's runtimes use, before the production stage downloads. -RUN server-verify-trust final +# Every store the image's runtimes use, and every Brotli decoder they load, +# before the production stage downloads. +RUN server-verify-trust final \ + && server-verify-brotli # Source qualification exercises the actual startup path from this base too. COPY --chmod=0755 docker/ensure-sqlite-database.sh /usr/local/bin/server-ensure-sqlite @@ -101,7 +123,8 @@ RUN apt-get update \ && rm -rf /workflow/.git \ && apt-get purge -y git \ && apt-get autoremove -y --purge \ - && rm -rf /var/lib/apt/lists/* + && 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 \ diff --git a/docker/brotli/CVE-2026-111497.patch b/docker/brotli/CVE-2026-111497.patch new file mode 100644 index 000000000..6aaa8f48a --- /dev/null +++ b/docker/brotli/CVE-2026-111497.patch @@ -0,0 +1,234 @@ +From da9806362874b9bd4b552a3af360b5d6347beb12 Mon Sep 17 00:00:00 2001 +From: Evgenii Kliuchnikov +Date: Fri, 27 Feb 2026 05:10:32 -0800 +Subject: [PATCH] Detect and report invalid stream earlier. + +PiperOrigin-RevId: 876198329 +(cherry picked from commit 0ed4268a4401abcb4472096340477cf4af7d1a2b) +--- + c/dec/decode.c | 89 ++++++++++++++++++++++++++------------- + c/include/brotli/decode.h | 4 +- + 2 files changed, 60 insertions(+), 33 deletions(-) + +diff --git a/c/dec/decode.c b/c/dec/decode.c +index 220c7e85c..2b4576556 100644 +--- a/c/dec/decode.c ++++ b/c/dec/decode.c +@@ -1124,7 +1124,7 @@ static BrotliDecoderErrorCode DecodeContextMap(brotli_reg_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) { + brotli_reg_t max_block_type = s->num_block_types[tree_type]; + const HuffmanCode* type_tree = &s->block_type_trees[ +@@ -1135,7 +1135,7 @@ static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( + brotli_reg_t* ringbuffer = &s->block_type_rb[tree_type * 2]; + brotli_reg_t block_type; + if (max_block_type <= 1) { +- return BROTLI_FALSE; ++ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH; + } + + /* Read 0..15 + 3..39 bits. */ +@@ -1145,11 +1145,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; + } + } + +@@ -1165,7 +1167,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( +@@ -1202,62 +1204,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( +- BrotliDecoderState* s) { ++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); + } +@@ -1887,6 +1895,9 @@ static BROTLI_INLINE BROTLI_BOOL CheckInputAmount( + return BrotliCheckInputAmount(br); + } + ++/* 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) { \ +@@ -1899,6 +1910,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; +@@ -1938,7 +1965,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. */ +@@ -2046,7 +2073,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)); + } +@@ -2240,7 +2267,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( + } + + NextLiteralBlock: +- BROTLI_SAFE(DecodeLiteralBlockSwitch(s)); ++ BROTLI_SAFE_WITH_STATUS(DecodeLiteralBlockSwitch(s)); + goto CommandInner; + + saveStateAndReturn: +@@ -2462,6 +2489,8 @@ BrotliDecoderResult BrotliDecoderDecompressStream( + if (result != BROTLI_DECODER_SUCCESS) { + break; + } ++ BROTLI_DCHECK(s->meta_block_remaining_len <= ++ (int)BROTLI_BLOCK_SIZE_CAP); + BROTLI_LOG_UINT(s->is_last_metablock); + BROTLI_LOG_UINT(s->meta_block_remaining_len); + BROTLI_LOG_UINT(s->is_metadata); +diff --git a/c/include/brotli/decode.h b/c/include/brotli/decode.h +index af1aa23f6..18ca1482d 100644 +--- a/c/include/brotli/decode.h ++++ b/c/include/brotli/decode.h +@@ -85,9 +85,7 @@ 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 \ +- \ +- /* -17 code is reserved */ \ +- \ ++ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_, COMPOUND_DICTIONARY, -18) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_, DICTIONARY_NOT_SET, -19) SEPARATOR \ + BROTLI_ERROR_CODE(_ERROR_, INVALID_ARGUMENTS, -20) SEPARATOR \ diff --git a/docker/brotli/brotli-check.py b/docker/brotli/brotli-check.py new file mode 100644 index 000000000..ad7ab0f90 --- /dev/null +++ b/docker/brotli/brotli-check.py @@ -0,0 +1,27 @@ +"""Checks a libbrotlidec for the CVE-2026-111497 fix without a malformed stream. + +The fix adds BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17), which an +unfixed decoder names INVALID. A valid stream must still decode. +Usage: brotli-check.py LIBRARY ENCODED_HEX EXPECTED_TEXT +""" +import ctypes +import sys + +library, encoded, expected = sys.argv[1], bytes.fromhex(sys.argv[2]), sys.argv[3].encode() +decoder = ctypes.CDLL(library) +decoder.BrotliDecoderErrorString.restype = ctypes.c_char_p +decoder.BrotliDecoderErrorString.argtypes = [ctypes.c_int] +name = decoder.BrotliDecoderErrorString(-17) +if name != b"_ERROR_FORMAT_BLOCK_SWITCH": + sys.exit(f"{library} reports error -17 as {name!r}: it lacks the CVE-2026-111497 fix.") + +decoder.BrotliDecoderDecompress.restype = ctypes.c_int +decoder.BrotliDecoderDecompress.argtypes = [ + ctypes.c_size_t, ctypes.c_char_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_char_p] +size = ctypes.c_size_t(len(expected) * 4 + 64) +output = ctypes.create_string_buffer(size.value) +if decoder.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output) != 1: + sys.exit(f"{library} cannot decode a valid stream.") +if output.raw[: size.value] != expected: + sys.exit(f"{library} decoded a valid stream incorrectly.") +print(f"{library}: CVE-2026-111497 fix present; valid stream round trips") diff --git a/docker/brotli/build.sh b/docker/brotli/build.sh new file mode 100755 index 000000000..d6ccf910b --- /dev/null +++ b/docker/brotli/build.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Debian 13 has no fixed libbrotli1 for CVE-2026-111497 yet. Rebuild +# Debian's own brotli 1.1.0-2 source with upstream's fix for the 1.1 line +# (google/brotli da9806362874b9bd4b552a3af360b5d6347beb12, released as +# v1.1.1) for the platform being built. The source comes through apt's signed +# Sources index and must also match the hashes in source.sha256. The package +# keeps Debian's file layout and names itself 1.1.0-2+b7+dw1: above the +# trixie build it replaces, below any future Debian fix (+deb13uN). +set -eu + +here=$(dirname "$(readlink -f "$0")") +version=$(cat "$here/version") +work=/usr/src/server-brotli-build + +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 "$work" && cd "$work" +apt-get source --download-only brotli=1.1.0-2 +sha256sum -c "$here/source.sha256" +dpkg-source -x brotli_1.1.0-2.dsc brotli-1.1.0 +cd brotli-1.1.0 +apt-get build-dep -y --no-install-recommends -P nopython . + +cp "$here/CVE-2026-111497.patch" debian/patches/ +echo CVE-2026-111497.patch >> debian/patches/series +{ + printf 'brotli (%s) trixie-security; urgency=high\n\n' "$version" + printf ' * Durable Workflow rebuild: apply upstream da98063 (v1.1.1),\n' + printf ' "Detect and report invalid stream earlier." (CVE-2026-111497).\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 + +DEB_BUILD_PROFILES=nopython DEB_BUILD_OPTIONS='nocheck noddebs' dpkg-buildpackage -B -uc -us -Pnopython +mkdir -p /out +cp "$work/libbrotli1_${version}_$(dpkg --print-architecture).deb" /out/ +dpkg-deb --field "/out/libbrotli1_${version}_$(dpkg --print-architecture).deb" Package Version diff --git a/docker/brotli/source.sha256 b/docker/brotli/source.sha256 new file mode 100644 index 000000000..fd414609b --- /dev/null +++ b/docker/brotli/source.sha256 @@ -0,0 +1,3 @@ +39b06802a852629132d549a7f7449dee7f435e801706714a4bc2ea2f15b28f36 brotli_1.1.0-2.dsc +10973f4b4199eafa1d5735ef661ddb2ec2f97319ee9fd1824d4aabe08cff5265 brotli_1.1.0.orig.tar.gz +3d913a3740bcad9a294007575a6beb1846beadbd62b44fb2bf9fdaeddea3236f brotli_1.1.0-2.debian.tar.xz diff --git a/docker/brotli/verify-brotli.sh b/docker/brotli/verify-brotli.sh new file mode 100755 index 000000000..12a385f6d --- /dev/null +++ b/docker/brotli/verify-brotli.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Refuses an image whose Brotli decoders lack the CVE-2026-111497 fix. The +# system decoder must be the rebuilt libbrotli1 (see build.sh), be the only +# libbrotlidec on the image and be the one every linked object loads. Node +# must be a release carrying its own fixed copy. No malformed stream is used: +# the fix's new error code identifies the fixed decoder, and a valid stream +# must still round trip. +set -eu + +here=$(dirname "$(readlink -f "$0")") +expected=$(cat "$here/version") +fail() { echo "$*" >&2; exit 1; } + +installed=$(dpkg-query -W -f '${Version}' libbrotli1) +[ "$installed" = "$expected" ] || fail "libbrotli1 $installed is not the fixed $expected." +library=$(readlink -f "$(dpkg -L libbrotli1 | grep '/libbrotlidec\.so\.1$')") + +# Every libbrotlidec file on the image belongs to that package. +for file in $(find / -xdev \( -path /proc -o -path /sys \) -prune -o -type f -name 'libbrotlidec*' -print); do + [ "$file" = "$library" ] || fail "$file is a libbrotlidec outside libbrotli1." +done + +# Every object that needs the decoder loads that file. +for object in $(grep -rlsF libbrotlidec.so.1 /usr/lib /usr/local/lib /usr/bin /usr/local/bin /usr/sbin /usr/local/sbin); do + [ "$(readlink -f "$object")" = "$library" ] && continue + case $(head -c 4 "$object" | od -An -c | tr -d ' ') in + 177ELF) ;; + *) continue ;; + esac + loaded=$(ldd "$object" | awk '$1 == "libbrotlidec.so.1" { print $3 }') + [ -z "$loaded" ] || [ "$(readlink -f "$loaded")" = "$library" ] \ + || fail "$object loads $loaded, not $library." +done + +# Node 22.23.4, 24.22.1 and 26.12.1 carry the fix in their own copy. +node -e ' +const [major, minor, patch] = process.versions.node.split(".").map(Number); +const fixed = {22: [23, 4], 24: [22, 1], 26: [12, 1]}[major]; +const ok = major > 26 || (fixed && (minor > fixed[0] || (minor === fixed[0] && patch >= fixed[1]))); +if (!ok) { console.error(`Node ${process.version} lacks the CVE-2026-111497 fix.`); process.exit(1); }' + +# The fixed decoder names its new error code; a valid stream round trips. +sample='Durable Workflow brotli round trip λ' +encoded=$(node -e 'process.stdout.write(require("zlib").brotliCompressSync(Buffer.from(process.argv[1])).toString("hex"))' "$sample") +python3 -I "$here/brotli-check.py" "$library" "$encoded" "$sample" diff --git a/docker/brotli/version b/docker/brotli/version new file mode 100644 index 000000000..f1380c9a6 --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.1.0-2+b7+dw1 diff --git a/tests/Unit/Support/brotli_trust_test.py b/tests/Unit/Support/brotli_trust_test.py new file mode 100644 index 000000000..8e4dd1efc --- /dev/null +++ b/tests/Unit/Support/brotli_trust_test.py @@ -0,0 +1,155 @@ +"""The Server image's Brotli decoders carry the CVE-2026-111497 fix. + +Debian 13 has no fixed libbrotli1 yet, so the image rebuilds Debian's own +brotli 1.1.0-2 with upstream's fix for the 1.1 line (docker/brotli). These +checks keep the vendored patch, the pinned sources, the package version and +the Dockerfile wiring as reviewed. The image-level check is +docker/brotli/verify-brotli.sh, which the build runs. + +Run with: python3 -m unittest tests/Unit/Support/brotli_trust_test.py +""" + +from __future__ import annotations + +import hashlib +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +BROTLI = ROOT / "docker" / "brotli" +PATCH_SHA256 = "3c1e4587947e4c61dd9c7c4619b1ae12d6bedbfd3c33c23981bf31269e18d52c" +UPSTREAM_FIX = "da9806362874b9bd4b552a3af360b5d6347beb12" +SOURCES = { + "brotli_1.1.0-2.dsc": "39b06802a852629132d549a7f7449dee7f435e801706714a4bc2ea2f15b28f36", + "brotli_1.1.0.orig.tar.gz": "10973f4b4199eafa1d5735ef661ddb2ec2f97319ee9fd1824d4aabe08cff5265", + "brotli_1.1.0-2.debian.tar.xz": "3d913a3740bcad9a294007575a6beb1846beadbd62b44fb2bf9fdaeddea3236f", +} +VERSION = "1.1.0-2+b7+dw1" + + +def debian_compare(left: str, right: str) -> int: + """dpkg's version ordering, for versions without epochs.""" + + def order(char: str) -> int: + if char == "~": + return -1 + if char.isalpha(): + return ord(char) + return ord(char) + 256 + + def part(a: str, b: str) -> int: + while a or b: + a_text = re.match(r"[^0-9]*", a).group() + b_text = re.match(r"[^0-9]*", b).group() + for index in range(max(len(a_text), len(b_text))): + x = order(a_text[index]) if index < len(a_text) else 0 + y = order(b_text[index]) if index < len(b_text) else 0 + if x != y: + return -1 if x < y else 1 + a, b = a[len(a_text):], b[len(b_text):] + a_digits = re.match(r"[0-9]*", a).group() + b_digits = re.match(r"[0-9]*", b).group() + if int(a_digits or 0) != int(b_digits or 0): + return -1 if int(a_digits or 0) < int(b_digits or 0) else 1 + a, b = a[len(a_digits):], b[len(b_digits):] + return 0 + + left_upstream, _, left_revision = left.rpartition("-") + right_upstream, _, right_revision = right.rpartition("-") + return part(left_upstream, right_upstream) or part(left_revision, right_revision) + + +def stages(dockerfile: str) -> dict[str, str]: + """Each named stage's text, from its FROM line to the next.""" + parts = re.split(r"(?m)^(?=FROM )", dockerfile) + named = {} + for text in parts: + match = re.match(r"FROM (\S+)(?: AS (\S+))?", text) + if match and match.group(2): + named[match.group(2)] = text + return named + + +def dockerfile_problems(dockerfile: str) -> list[str]: + problems = [] + named = stages(dockerfile) + builder, base = named.get("brotli", ""), named.get("base", "") + image = lambda text: (re.match(r"FROM (\S+)", text) or [None, None])[1] + if not builder: + return ["no brotli builder stage"] + if image(builder) != image(base) or "@sha256:" not in (image(builder) or ""): + problems.append("the builder must use the runtime's exact pinned base") + if builder.find("verify-trust.sh system") < 0 or builder.find("verify-trust.sh system") > builder.find("build.sh"): + problems.append("the builder must check its CA store before it downloads") + install = base.find("dpkg -i /usr/src/server-brotli-deb/libbrotli1_") + if "COPY --from=brotli /out/ /usr/src/server-brotli-deb/" not in base or install < 0: + problems.append("the runtime must install the rebuilt libbrotli1") + final = base.find("server-verify-trust final") + if final < 0 or base.find("server-verify-brotli", final) < 0: + problems.append("the runtime must verify its decoders before production downloads") + last_package_step = dockerfile.rfind("apt-get autoremove") + if last_package_step < 0 or dockerfile.find("server-verify-brotli", last_package_step) < 0: + problems.append("the image must verify its decoders after its last package change") + source = named.get("phpredis-source", "") + floor = source.find('apk add --no-cache --upgrade "brotli-libs>=1.2.1-r0"') + if floor < 0 or floor > source.find("RUN git clone"): + problems.append("the Alpine stage must take the fixed brotli-libs before it clones") + return problems + + +class BrotliTrustTest(unittest.TestCase): + def test_the_vendored_patch_is_upstreams_fix(self) -> None: + patch = (BROTLI / "CVE-2026-111497.patch").read_bytes() + self.assertEqual(hashlib.sha256(patch).hexdigest(), PATCH_SHA256) + text = patch.decode() + self.assertTrue(text.startswith(f"From {UPSTREAM_FIX} ")) + self.assertEqual(re.findall(r"(?m)^diff --git a/(\S+)", text), ["c/dec/decode.c", "c/include/brotli/decode.h"]) + self.assertIn("+ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR", text) + + def test_the_debian_sources_are_pinned(self) -> None: + lines = (BROTLI / "source.sha256").read_text().splitlines() + self.assertEqual({name: digest for digest, name in (line.split(" ") for line in lines)}, SOURCES) + build = (BROTLI / "build.sh").read_text() + self.assertLess(build.index('sha256sum -c "$here/source.sha256"'), build.index("dpkg-source -x")) + self.assertIn("echo CVE-2026-111497.patch >> debian/patches/series", build) + self.assertIn("apt-get source --download-only brotli=1.1.0-2", build) + + def test_the_version_replaces_trixies_and_yields_to_a_debian_fix(self) -> None: + self.assertEqual((BROTLI / "version").read_text().strip(), VERSION) + self.assertGreater(debian_compare(VERSION, "1.1.0-2+b7"), 0) + self.assertLess(debian_compare(VERSION, "1.1.0-2+deb13u1"), 0) + self.assertLess(debian_compare(VERSION, "1.1.1-1"), 0) + self.assertEqual(debian_compare("1.0~rc1-1", "1.0-1"), -1) + + def test_the_check_identifies_the_fix_without_a_malformed_stream(self) -> None: + check = (BROTLI / "brotli-check.py").read_text() + self.assertIn('BrotliDecoderErrorString(-17)', check) + self.assertIn('b"_ERROR_FORMAT_BLOCK_SWITCH"', check) + verify = (BROTLI / "verify-brotli.sh").read_text() + for required in ("dpkg-query -W -f '${Version}' libbrotli1", "-name 'libbrotlidec*'", "ldd ", + "{22: [23, 4], 24: [22, 1], 26: [12, 1]}", "brotli-check.py"): + self.assertIn(required, verify) + + def test_the_dockerfile_builds_installs_and_verifies_the_fix(self) -> None: + self.assertEqual(dockerfile_problems((ROOT / "Dockerfile").read_text()), []) + + def test_unwired_dockerfiles_fail(self) -> None: + dockerfile = (ROOT / "Dockerfile").read_text() + for label, mutated in [ + ("no install", dockerfile.replace(" && dpkg -i /usr/src/server-brotli-deb/libbrotli1_*.deb \\\n", "")), + ("no runtime check", dockerfile.replace("RUN server-verify-trust final \\\n && server-verify-brotli\n", + "RUN server-verify-trust final\n")), + ("no final check", dockerfile.replace(" && rm -rf /var/lib/apt/lists/* \\\n && server-verify-brotli\n", + " && rm -rf /var/lib/apt/lists/*\n")), + ("floor after clone", dockerfile.replace('RUN apk add --no-cache --upgrade "brotli-libs>=1.2.1-r0" \\\n' + ' && apk info -e "brotli-libs>=1.2.1-r0"\n\n', "")), + ("other base", re.sub(r"(?m)^(FROM php@sha256:)[0-9a-f]+( AS brotli)$", r"\g<1>" + "0" * 64 + r"\2", dockerfile)), + ]: + with self.subTest(label): + self.assertNotEqual(mutated, dockerfile, label) + self.assertNotEqual(dockerfile_problems(mutated), [], label) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 2599ca5d2..787c0f989 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -126,8 +126,8 @@ "guards": [ { "type": "server-verify", - "line": "RUN server-verify-trust final", - "distance": 48 + "line": "RUN server-verify-trust final \\", + "distance": 50 } ] }, From 83a45873dd3e0a249715b741b8c6403d48ac4ad6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 01:41:59 +0000 Subject: [PATCH 11/21] Refuse any libbrotlidec link that does not resolve to the reviewed decoder Security review of e9dee760: the verifier's inventory searched regular files only, so a libbrotlidec.so.1 symlink outside libbrotli1 that points to a differently named decoder passed unless a scanned ELF happened to load through it. docker/brotli/decoder-inventory.sh now lists matching files and links and refuses any path whose resolved target is not the reviewed package library: a link to another decoder, a dangling link or a separate copy. The verifier also checks that the resolved library is owned by libbrotli1. The unit test runs the real inventory on a constructed tree, including the counterfactual link to a differently named decoder, and fails if the search goes back to regular files only. --- docker/brotli/decoder-inventory.sh | 18 +++++++++++ docker/brotli/verify-brotli.sh | 10 +++--- tests/Unit/Support/brotli_trust_test.py | 43 ++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100755 docker/brotli/decoder-inventory.sh diff --git a/docker/brotli/decoder-inventory.sh b/docker/brotli/decoder-inventory.sh new file mode 100755 index 000000000..9ff5c8724 --- /dev/null +++ b/docker/brotli/decoder-inventory.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Refuses any libbrotlidec file or link under ROOT that does not resolve to +# LIBRARY, the reviewed libbrotli1 decoder: another copy, a dangling link, or +# a link (such as a stray libbrotlidec.so.1) to a differently named decoder. +# Usage: decoder-inventory.sh ROOT LIBRARY +set -eu +root=$1 +library=$2 +fail() { echo "$*" >&2; exit 1; } + +{ [ -f "$library" ] && [ ! -L "$library" ]; } || fail "$library is not a regular file." +base=${root%/} +find "$root" -xdev \( -path "$base/proc" -o -path "$base/sys" \) -prune -o \ + \( -type f -o -type l \) -name 'libbrotlidec*' -print | +while IFS= read -r path; do + target=$(readlink -f "$path") || fail "$path does not resolve." + [ "$target" = "$library" ] || fail "$path resolves to $target, not $library." +done diff --git a/docker/brotli/verify-brotli.sh b/docker/brotli/verify-brotli.sh index 12a385f6d..67f03f778 100755 --- a/docker/brotli/verify-brotli.sh +++ b/docker/brotli/verify-brotli.sh @@ -14,11 +14,13 @@ fail() { echo "$*" >&2; exit 1; } installed=$(dpkg-query -W -f '${Version}' libbrotli1) [ "$installed" = "$expected" ] || fail "libbrotli1 $installed is not the fixed $expected." library=$(readlink -f "$(dpkg -L libbrotli1 | grep '/libbrotlidec\.so\.1$')") +case $(dpkg -S "$library" 2>/dev/null) in + libbrotli1:*) ;; + *) fail "$library is not owned by libbrotli1." ;; +esac -# Every libbrotlidec file on the image belongs to that package. -for file in $(find / -xdev \( -path /proc -o -path /sys \) -prune -o -type f -name 'libbrotlidec*' -print); do - [ "$file" = "$library" ] || fail "$file is a libbrotlidec outside libbrotli1." -done +# Every libbrotlidec file or link on the image resolves to that library. +sh "$here/decoder-inventory.sh" / "$library" # Every object that needs the decoder loads that file. for object in $(grep -rlsF libbrotlidec.so.1 /usr/lib /usr/local/lib /usr/bin /usr/local/bin /usr/sbin /usr/local/sbin); do diff --git a/tests/Unit/Support/brotli_trust_test.py b/tests/Unit/Support/brotli_trust_test.py index 8e4dd1efc..917016096 100644 --- a/tests/Unit/Support/brotli_trust_test.py +++ b/tests/Unit/Support/brotli_trust_test.py @@ -12,7 +12,10 @@ from __future__ import annotations import hashlib +import os import re +import subprocess +import tempfile import unittest from pathlib import Path @@ -127,9 +130,47 @@ def test_the_check_identifies_the_fix_without_a_malformed_stream(self) -> None: self.assertIn('BrotliDecoderErrorString(-17)', check) self.assertIn('b"_ERROR_FORMAT_BLOCK_SWITCH"', check) verify = (BROTLI / "verify-brotli.sh").read_text() - for required in ("dpkg-query -W -f '${Version}' libbrotli1", "-name 'libbrotlidec*'", "ldd ", + for required in ("dpkg-query -W -f '${Version}' libbrotli1", "dpkg -S \"$library\"", "libbrotli1:*)", + 'sh "$here/decoder-inventory.sh" / "$library"', "ldd ", "{22: [23, 4], 24: [22, 1], 26: [12, 1]}", "brotli-check.py"): self.assertIn(required, verify) + inventory = (BROTLI / "decoder-inventory.sh").read_text() + for required in ("\\( -type f -o -type l \\) -name 'libbrotlidec*'", 'readlink -f "$path"'): + self.assertIn(required, inventory) + + def test_every_decoder_path_must_resolve_to_the_reviewed_library(self) -> None: + def inventory(root: Path, library: Path) -> subprocess.CompletedProcess: + return subprocess.run(["sh", str(BROTLI / "decoder-inventory.sh"), str(root), str(library)], + capture_output=True, text=True, check=False) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + lib = root / "usr" / "lib" + lib.mkdir(parents=True) + library = lib / "libbrotlidec.so.1.1.0" + library.write_bytes(b"reviewed decoder") + os.symlink("libbrotlidec.so.1.1.0", lib / "libbrotlidec.so.1") + self.assertEqual(inventory(root, library).returncode, 0, "the package's own file and link") + stray = root / "opt" / "app" / "libbrotlidec.so.1" + stray.parent.mkdir(parents=True) + os.symlink(library, stray) + self.assertEqual(inventory(root, library).returncode, 0, "another link to the reviewed library") + other = lib / "libdecoder-other.so.1" + other.write_bytes(b"another decoder") + for label, make in [ + ("a link to a differently named decoder", lambda: os.symlink(other, stray)), + ("a dangling link", lambda: os.symlink(root / "missing" / "libbrotlidec.so.1", stray)), + ("a separate copy", lambda: stray.write_bytes(b"reviewed decoder")), + ]: + with self.subTest(label): + stray.unlink() + make() + result = inventory(root, library) + self.assertNotEqual(result.returncode, 0, label) + self.assertIn(str(stray), result.stderr) + stray.unlink() + self.assertNotEqual(inventory(root, root / "usr" / "lib" / "libbrotlidec.so.1").returncode, 0, + "the expected library must be the resolved file, not a link") def test_the_dockerfile_builds_installs_and_verifies_the_fix(self) -> None: self.assertEqual(dockerfile_problems((ROOT / "Dockerfile").read_text()), []) From e93ce655e071859862637ed270b4919ccb7d6da9 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 14:36:25 +0000 Subject: [PATCH 12/21] fix: patch release audit decoder before package requests --- .github/workflows/phpunit-feature.yml | 2 +- .github/workflows/release.yml | 4 +- .../ci/audit-immutable-release-container.sh | 24 +++ scripts/ci/audit-immutable-release.sh | 22 +++ ...eleaseImagePublishWorkflowContractTest.php | 2 +- tests/Unit/Support/composer-sinks.json | 39 ++-- .../Support/release_composer_audit_test.py | 172 ++++++++++++++++++ 7 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 scripts/ci/audit-immutable-release-container.sh create mode 100644 scripts/ci/audit-immutable-release.sh create mode 100644 tests/Unit/Support/release_composer_audit_test.py diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 1d6137d12..54d4d8569 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -126,7 +126,7 @@ jobs: - name: Prove Composer runs pinned or confined to a checked CA file env: DW_REQUIRE_COMPOSER_TRUST_TEST: '1' - run: python3 -m unittest tests/Unit/Support/composer_trust_test.py + run: python3 -m unittest tests/Unit/Support/composer_trust_test.py tests/Unit/Support/release_composer_audit_test.py - name: Prove the image builds, installs and checks the fixed Brotli decoder run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6011bef91..daa771a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,9 +98,7 @@ jobs: printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - name: Audit immutable PHP release dependencies - # In the pinned Composer image, whose CA store is checked (GHSA-6q48-6c7w-9gw2). - run: >- - docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction + run: sh scripts/ci/audit-immutable-release.sh release-source - name: Validate release publish context and credentials id: release_publish diff --git a/scripts/ci/audit-immutable-release-container.sh b/scripts/ci/audit-immutable-release-container.sh new file mode 100644 index 000000000..b6f91b4e4 --- /dev/null +++ b/scripts/ci/audit-immutable-release-container.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Runs only in the pinned disposable Composer image. Establish and verify the +# decoder floor before Composer or curl can decode any network response. +set -eu + +identity=${1:?The nonprivileged audit UID:GID is required} +case "$identity" in + *[!0-9:]*|:*|*:|*:*:*) echo 'Audit identity must be a nonprivileged UID:GID.' >&2; exit 1 ;; + *:*) ;; + *) echo 'Audit identity must contain both UID and GID.' >&2; exit 1 ;; +esac +test "${identity%%:*}" -gt 0 +test "${identity#*:}" -gt 0 +: "${COMPOSER_CAFILE:?A checked CA file is required}" +: "${COMPOSER_CAPATH:?An empty CA directory is required}" +test -s "$COMPOSER_CAFILE" +test -d "$COMPOSER_CAPATH" +test -z "$(ls -A "$COMPOSER_CAPATH")" + +apk add --no-cache --upgrade 'brotli-libs>=1.2.1-r0' su-exec +apk info -e 'brotli-libs>=1.2.1-r0' >/dev/null + +export COMPOSER_HOME=/tmp/release-composer +exec su-exec "$identity" composer --no-plugins --no-scripts audit --locked --no-dev --no-interaction diff --git a/scripts/ci/audit-immutable-release.sh b/scripts/ci/audit-immutable-release.sh new file mode 100644 index 000000000..3a52287c4 --- /dev/null +++ b/scripts/ci/audit-immutable-release.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Audit an immutable source lock in a disposable, decoder-patched Composer +# image. Source stays read-only; the audit receives no publishing credentials. +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source=$(realpath -- "${1:?Usage: audit-immutable-release.sh SOURCE_DIRECTORY}") +image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee +trust=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-audit.XXXXXXXX") +trap 'rm -rf "$trust"' EXIT HUP INT TERM + +sh "$here/checked-ca.sh" "$trust" + +docker run --rm --user 0:0 --entrypoint /bin/sh \ + --mount "type=bind,src=$source,dst=/app,readonly" \ + --mount "type=bind,src=$here,dst=/release-tools,readonly" \ + --mount "type=bind,src=$trust,dst=/release-trust,readonly" \ + -e COMPOSER_CAFILE=/release-trust/ca.pem \ + -e COMPOSER_CAPATH=/release-trust/no-ca-directory \ + -e SSL_CERT_FILE=/release-trust/ca.pem \ + -e SSL_CERT_DIR=/release-trust/no-ca-directory \ + -w /app "$image" /release-tools/audit-immutable-release-container.sh "$(id -u):$(id -g)" diff --git a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php index 0e8d80d79..52d5b264c 100644 --- a/tests/Unit/ReleaseImagePublishWorkflowContractTest.php +++ b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php @@ -113,7 +113,7 @@ public function test_php_security_audits_fail_before_release_publication(): void $this->assertIsInt($loginIndex); $this->assertLessThan($loginIndex, $auditIndex); $this->assertSame( - 'docker run --rm --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD/release-source:/app:ro" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction', + 'sh scripts/ci/audit-immutable-release.sh release-source', $steps[$auditIndex]['run'], ); $this->assertArrayNotHasKey('continue-on-error', $steps[$auditIndex]); diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 787c0f989..be8fe726c 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -61,19 +61,6 @@ } ] }, - { - "path": ".github/workflows/release.yml", - "line": "docker run --rm --user \"$(id -u):$(id -g)\" -e COMPOSER_HOME=/tmp/composer -v \"$PWD/release-source:/app:ro\" -w /app composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee audit --locked --no-dev --no-interaction", - "count": 1, - "confinement": "Pinned Composer 2.10.3 image; PinnedComposerImageTest checks its CA store.", - "guards": [ - { - "type": "pinned-reference", - "line": null, - "distance": null - } - ] - }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", @@ -1321,6 +1308,32 @@ "distance": 4 } ] + }, + { + "path": "scripts/ci/audit-immutable-release.sh", + "line": "image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "Pinned Composer digest; checked-ca.sh provides the validated file and empty directory. Inner helper installs and verifies the decoder floor before requests.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, + { + "path": "scripts/ci/audit-immutable-release-container.sh", + "line": "exec su-exec \"$identity\" composer --no-plugins --no-scripts audit --locked --no-dev --no-interaction", + "count": 1, + "confinement": "Outer immutable-release helper binds the checked CA pair. The inner helper verifies brotli-libs>=1.2.1-r0 before the nonprivileged, plugins-disabled audit; release_composer_audit_test.py executes refusal and ordering controls.", + "guards": [ + { + "type": "checked-ca", + "line": "test -s \"$COMPOSER_CAFILE\"", + "distance": 8 + } + ] } ] } diff --git a/tests/Unit/Support/release_composer_audit_test.py b/tests/Unit/Support/release_composer_audit_test.py new file mode 100644 index 000000000..635b443ec --- /dev/null +++ b/tests/Unit/Support/release_composer_audit_test.py @@ -0,0 +1,172 @@ +"""The actual release-audit shell must refuse before making an unsafe request.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +AUDIT = ROOT / "scripts/ci/audit-immutable-release-container.sh" +WRAPPER = ROOT / "scripts/ci/audit-immutable-release.sh" + + +class ReleaseComposerAuditTest(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory(prefix="dw-release-audit-test-") + self.addCleanup(temporary.cleanup) + self.work = Path(temporary.name) + self.bin = self.work / "bin" + self.bin.mkdir() + self.log = self.work / "commands.jsonl" + self.ca = self.work / "ca.pem" + self.ca.write_text("constructed checked bundle\n") + self.capath = self.work / "empty-ca" + self.capath.mkdir() + self.env = { + **os.environ, + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "AUDIT_TEST_LOG": str(self.log), + "COMPOSER_CAFILE": str(self.ca), + "COMPOSER_CAPATH": str(self.capath), + } + self.executable("apk", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["apk", *sys.argv[1:]]) + "\\n") +raise SystemExit(int(os.environ.get("APK_ADD_STATUS" if sys.argv[1] == "add" else "APK_INFO_STATUS", "0"))) +''') + self.executable("su-exec", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["su-exec", *sys.argv[1:]]) + "\\n") +os.execvp(sys.argv[2], sys.argv[2:]) +''') + self.executable("composer", ''' +import json, os, sys +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["composer", *sys.argv[1:]]) + "\\n") +raise SystemExit(int(os.environ.get("COMPOSER_STATUS", "0"))) +''') + + def executable(self, name: str, body: str) -> None: + path = self.bin / name + path.write_text("#!/usr/bin/env python3\n" + body.lstrip()) + path.chmod(0o755) + + def run_audit(self, source: str | None = None, identity: str = "1000:1000") -> subprocess.CompletedProcess[str]: + script = AUDIT + if source is not None: + script = self.work / "mutated-audit.sh" + script.write_text(source) + return subprocess.run(["sh", str(script), identity], env=self.env, capture_output=True, text=True) + + def commands(self) -> list[list[str]]: + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def assert_safe_order(self, result: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(0, result.returncode, result.stderr) + commands = self.commands() + self.assertEqual(["apk", "apk", "su-exec", "composer"], [command[0] for command in commands]) + self.assertEqual(["apk", "add", "--no-cache", "--upgrade", "brotli-libs>=1.2.1-r0", "su-exec"], commands[0]) + self.assertEqual(["apk", "info", "-e", "brotli-libs>=1.2.1-r0"], commands[1]) + self.assertEqual("1000:1000", commands[2][1]) + self.assertEqual(["composer", "--no-plugins", "--no-scripts", "audit", "--locked", "--no-dev", "--no-interaction"], commands[3]) + + def test_installs_and_verifies_floor_before_nonprivileged_locked_audit(self) -> None: + self.assert_safe_order(self.run_audit()) + + def test_failed_upgrade_cannot_reach_composer(self) -> None: + self.env["APK_ADD_STATUS"] = "23" + self.assertEqual(23, self.run_audit().returncode) + self.assertEqual(["apk"], [command[0] for command in self.commands()]) + + def test_unmet_floor_cannot_reach_composer(self) -> None: + self.env["APK_INFO_STATUS"] = "24" + self.assertEqual(24, self.run_audit().returncode) + self.assertEqual(["apk", "apk"], [command[0] for command in self.commands()]) + + def test_audit_failure_keeps_its_exit_code(self) -> None: + self.env["COMPOSER_STATUS"] = "7" + self.assertEqual(7, self.run_audit().returncode) + self.assertEqual("composer", self.commands()[-1][0]) + + def test_root_or_malformed_identity_refuses_before_download(self) -> None: + for identity in ["0:0", "0:1000", "1000:0", "1000", "1000:1000:1", "1000:abc", "-1:1000"]: + with self.subTest(identity): + self.log.unlink(missing_ok=True) + self.assertNotEqual(0, self.run_audit(identity=identity).returncode) + self.assertEqual([], self.commands()) + + def test_missing_or_nonempty_ca_pair_refuses_before_package_download(self) -> None: + self.env.pop("COMPOSER_CAFILE") + self.assertNotEqual(0, self.run_audit().returncode) + self.assertEqual([], self.commands()) + self.env["COMPOSER_CAFILE"] = str(self.ca) + (self.capath / "extra.pem").write_text("unexpected root") + self.assertNotEqual(0, self.run_audit().returncode) + self.assertEqual([], self.commands()) + + def test_removing_or_moving_the_floor_is_caught(self) -> None: + source = AUDIT.read_text() + floor = "apk info -e 'brotli-libs>=1.2.1-r0' >/dev/null\n" + self.assertIn(floor, source) + for mutated in [source.replace(floor, ""), source.replace(floor, "") + floor]: + with self.subTest(source=mutated): + self.log.unlink(missing_ok=True) + result = self.run_audit(mutated) + with self.assertRaises(AssertionError): + self.assert_safe_order(result) + + def prepare_wrapper(self) -> None: + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", str(self.work / "test.key"), "-out", str(self.ca), + "-subj", "/CN=release-audit-constructed-ca", "-days", "1", + ], check=True, capture_output=True) + self.env["AUDIT_TEST_CA"] = str(self.ca) + self.env["RUNNER_TEMP"] = str(self.work) + self.executable("docker", ''' +import json, os, pathlib, sys +args = sys.argv[1:] +with open(os.environ["AUDIT_TEST_LOG"], "a") as output: + output.write(json.dumps(["docker", *args]) + "\\n") +if "cat" in args: + print(pathlib.Path(os.environ["AUDIT_TEST_CA"]).read_text(), end="") +raise SystemExit(0) +''') + + def test_wrapper_checks_bundle_and_binds_source_and_trust_read_only(self) -> None: + self.prepare_wrapper() + result = subprocess.run(["sh", str(WRAPPER), str(self.work)], env=self.env, capture_output=True, text=True) + self.assertEqual(0, result.returncode, result.stderr) + commands = self.commands() + self.assertEqual(2, len(commands)) + self.assertIn("none", commands[0]) + args = commands[1] + self.assertIn("COMPOSER_CAFILE=/release-trust/ca.pem", args) + self.assertIn("COMPOSER_CAPATH=/release-trust/no-ca-directory", args) + self.assertIn("SSL_CERT_FILE=/release-trust/ca.pem", args) + self.assertIn("SSL_CERT_DIR=/release-trust/no-ca-directory", args) + mounts = [args[index + 1] for index, value in enumerate(args) if value == "--mount"] + self.assertEqual(3, len(mounts)) + self.assertTrue(all(mount.endswith(",readonly") for mount in mounts)) + trust = next(mount for mount in mounts if ",dst=/release-trust," in mount) + directory = trust.split("src=", 1)[1].split(",", 1)[0] + self.assertFalse(Path(directory).exists(), "the disposable CA directory must be retired") + self.assertEqual([], list(self.work.glob("release-audit.*"))) + + def test_bad_bundle_refuses_before_starting_networked_audit(self) -> None: + self.prepare_wrapper() + self.ca.write_text("not a certificate bundle\n") + result = subprocess.run(["sh", str(WRAPPER), str(self.work)], env=self.env, capture_output=True, text=True) + self.assertNotEqual(0, result.returncode) + self.assertEqual(1, len(self.commands())) + self.assertEqual([], list(self.work.glob("release-audit.*"))) + + +if __name__ == "__main__": + unittest.main() From 5669e2e4a376a14c7accded90e2f9ecec5256d49 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 14:48:08 +0000 Subject: [PATCH 13/21] Run split-form Composer installs through a confined wrapper Security's review of f1e264a44 found three server-parity.yml installs outside composer-sinks.json. Each is a `docker run --entrypoint composer \` command whose subcommand sits on a later line. They ran Composer inside the published Server image under test, without the checked CA pair and with its Brotli decoder. The same form hid six more installs in five parity scripts, and a recipe in the v1 benchmark README. - The scanner counts a container whose entrypoint is Composer as a sink, wherever its subcommand sits, and reads a backslash-newline as a separator. Negative controls cover the exact split workflow form, path, `=` and phar entrypoints, continued commands and another runner script. - scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed brotli-libs (>=1.2.1-r0, brotli-safe-composer.sh, checked on every use). It passes the checked CA file and an empty CA directory after the caller's options and refuses options that name either, an entrypoint or an env file. - All nine installs and the README recipe use it. Their vendor directories still run in the published images under test. - The manifest records each call site as a wrapper invocation, and the wrapper's own Composer command with its Brotli-floor guard. --- .github/workflows/server-parity.yml | 12 +- benchmarks/server-port/v1/README.md | 3 +- scripts/ci/brotli-safe-composer.sh | 47 ++++++++ scripts/ci/run-composer.sh | 43 +++++++ .../corrected-marker-adapters.sh | 8 +- .../server-parity/query-authority-cohort.sh | 6 +- .../server-parity/range-repair-profile.sh | 2 +- .../server-parity/sdk-direction-profile.sh | 2 +- .../server-parity/worker-fencing-profile.sh | 8 +- tests/Unit/Support/composer-sinks.json | 113 ++++++++++++++++++ tests/Unit/Support/composer_trust_test.py | 108 ++++++++++++++++- 11 files changed, 330 insertions(+), 22 deletions(-) create mode 100755 scripts/ci/brotli-safe-composer.sh create mode 100644 scripts/ci/run-composer.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 657fada82..b94874ebd 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -454,8 +454,8 @@ jobs: } trap container_log_tails ERR image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + # Composer runs confined in the pinned image, never in the published image under test. + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-php" --network "$RESOURCE_SCOPE" --network-alias=parity-php \ --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m --tmpfs /state \ @@ -968,8 +968,8 @@ jobs: } trap container_log_tails ERR image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + # Composer runs confined in the pinned image, never in the published image under test. + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-php" --network "$RESOURCE_SCOPE" --network-alias=parity-php \ --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m --tmpfs /state \ @@ -1477,8 +1477,8 @@ jobs: chmod 0777 parity-state parity-evidence touch parity-state/php.sqlite parity-state/embedded.sqlite parity-state/php-layout.sqlite chmod 0666 parity-state/*.sqlite - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + # Composer runs confined in the pinned image, never in the published image under test. + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs > parity-evidence/comparator-tests.txt diff --git a/benchmarks/server-port/v1/README.md b/benchmarks/server-port/v1/README.md index 024b690c9..6876d5a62 100644 --- a/benchmarks/server-port/v1/README.md +++ b/benchmarks/server-port/v1/README.md @@ -26,8 +26,7 @@ export DW_PORT_SOURCE_ROOT="$PWD" port_project="dw-server-port-$(date -u +%Y%m%dT%H%M%S | tr '[:upper:]' '[:lower:]')" port_compose=scripts/perf/server-port-baseline.compose.yml port_image=$(jq -r .artifacts.server benchmarks/server-port/v1/php-baseline.json) -docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$port_image" \ +bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p benchmarks/capacity/v1/bindings/php/vendor docker compose -p "$port_project" -f "$port_compose" config --quiet diff --git a/scripts/ci/brotli-safe-composer.sh b/scripts/ci/brotli-safe-composer.sh new file mode 100755 index 000000000..dde36efba --- /dev/null +++ b/scripts/ci/brotli-safe-composer.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Prints a local image for running Composer with a fixed Brotli decoder +# (CVE-2026-111497). It is the pinned Composer image after Alpine's signed +# brotli-libs 1.2.1-r0 or later: the image's libcurl decodes Brotli responses +# with that library, and the pinned image ships 1.2.0-r1. The image is built +# once per Docker host and checked again on every use, so a stale or +# replaced local tag is refused rather than trusted. +# +# Use it through run-composer.sh, which also sets the checked CA pair +# (GHSA-6q48-6c7w-9gw2). +# +# Usage: image=$(sh scripts/ci/brotli-safe-composer.sh) +# sh scripts/ci/brotli-safe-composer.sh --check IMAGE +set -eu + +base=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee +image=dw-composer-brotli-floor:2.10.3-da624c2c705d +floor='brotli-libs>=1.2.1-r0' + +# Refuses IMAGE unless its installed brotli-libs meets the floor and every +# Brotli decoder its PHP links belongs to that package. +check() { + docker run --rm --network none --user 0 --entrypoint sh "$1" -c ' + set -eu + apk info -e "$1" > /dev/null || { echo "Composer image lacks $1" >&2; exit 1; } + for library in $(ldd "$(command -v php)" | awk "/libbrotlidec/ {print \$3}"); do + apk info --who-owns "$library" | grep -q "brotli-libs-" || { echo "Unowned decoder $library" >&2; exit 1; } + done + composer --version > /dev/null 2>&1 + ' sh "$floor" >&2 +} + +if test "${1:-}" = --check; then + check "${2:?Usage: brotli-safe-composer.sh --check IMAGE}" + exit 0 +fi + +if ! docker image inspect "$image" > /dev/null 2>&1; then + printf 'FROM %s\nLABEL dw.composer.base="%s"\nRUN apk add --no-cache --upgrade "%s" && apk info -e "%s"\n' \ + "$base" "$base" "$floor" "$floor" \ + | docker build --quiet --tag "$image" - > /dev/null +fi +# The derived image must still be the pinned one plus the fixed decoder. +parent=$(docker image inspect --format '{{index .Config.Labels "dw.composer.base"}}' "$image") +test "$parent" = "$base" || { echo "$image was not built from $base" >&2; exit 1; } +check "$image" +printf '%s\n' "$image" diff --git a/scripts/ci/run-composer.sh b/scripts/ci/run-composer.sh new file mode 100644 index 000000000..89eded9af --- /dev/null +++ b/scripts/ci/run-composer.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Runs Composer for CI and conformance scripts, never in an image under test: +# - in the pinned Composer image after Alpine's fixed Brotli decoder +# (brotli-safe-composer.sh, CVE-2026-111497); +# - trusting only the pinned image's checked CA file and an empty CA +# directory (checked-ca.sh, GHSA-6q48-6c7w-9gw2); +# - as the invoking user unless the caller passes its own --user. +# +# The caller's docker run options come first and cannot replace the CA pair, +# the image or the entrypoint, which this script sets after them. +# +# Usage: run-composer.sh [docker run options...] -- COMPOSER_ARGUMENTS... +# Example: run-composer.sh --rm -v "$PWD:/app" -w /app -- install --no-dev +set -euo pipefail + +here=$(dirname "$0") +options=() +while (($#)) && [[ $1 != -- ]]; do + case "$1" in + *COMPOSER_CAFILE* | *COMPOSER_CAPATH* | --entrypoint* | --env-file*) + echo "run-composer.sh sets the CA pair and the entrypoint itself; refusing $1" >&2 + exit 2 ;; + esac + options+=("$1") + shift +done +if (($# == 0)); then + echo 'Usage: run-composer.sh [docker run options...] -- COMPOSER_ARGUMENTS...' >&2 + exit 2 +fi +shift + +image=$(sh "$here/brotli-safe-composer.sh") +ca=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dw-composer-ca.XXXXXX") +trap 'rm -rf -- "$ca"' EXIT +sh "$here/checked-ca.sh" "$ca" +chmod 0755 "$ca" + +status=0 +docker run --user "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer "${options[@]}" \ + -e COMPOSER_CAFILE=/checked-ca/ca.pem -e COMPOSER_CAPATH=/checked-ca/no-ca-directory \ + -v "$ca:/checked-ca:ro" --entrypoint composer "$image" "$@" || status=$? +exit "$status" diff --git a/scripts/conformance/server-parity/corrected-marker-adapters.sh b/scripts/conformance/server-parity/corrected-marker-adapters.sh index 005bd0d78..02d2f3af3 100644 --- a/scripts/conformance/server-parity/corrected-marker-adapters.sh +++ b/scripts/conformance/server-parity/corrected-marker-adapters.sh @@ -10,8 +10,8 @@ cargo=${DW_PARITY_CORRECTED_CARGO:-$output/cargo} target=${DW_PARITY_CORRECTED_TARGET:-$output/target} mkdir -p "$output"/{php,php-range,python250,python-current,bin} "$cargo" "$target" cp "$adapter/php/composer.json" "$adapter/php/composer.lock" "$output/php/" -docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD/$output/php:/consumer" -w /consumer "$image" \ +bash scripts/ci/run-composer.sh --rm --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD/$output/php:/consumer" -w /consumer -- \ install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist \ > "$output/php-install.txt" 2>&1 docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -24,8 +24,8 @@ docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=128m --memory-swap= -v "$PWD:/source:ro" -w /source "$image" \ "$adapter/check-admission-receipt.py" > "$output/admission-receipt-controls.txt" 2>&1 cp tests/Fixtures/ServerParityProfiles/version-range-repair/sdk/{composer.json,composer.lock} "$output/php-range/" -docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD/$output/php-range:/consumer" -w /consumer "$image" \ +bash scripts/ci/run-composer.sh --rm --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD/$output/php-range:/consumer" -w /consumer -- \ install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist \ > "$output/php-range-install.txt" 2>&1 docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ diff --git a/scripts/conformance/server-parity/query-authority-cohort.sh b/scripts/conformance/server-parity/query-authority-cohort.sh index d8598ea66..dc78a1505 100755 --- a/scripts/conformance/server-parity/query-authority-cohort.sh +++ b/scripts/conformance/server-parity/query-authority-cohort.sh @@ -25,9 +25,9 @@ jq -n --arg scope "$RESOURCE_SCOPE" --arg backend "$backend" --arg revision "$(g > "$evidence/identity.json" # The workflow's always-step retires partial setup and every admitted original. trap 'printf "%s\n" "$?" > "$evidence/profile-status.txt"' EXIT -docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ - --entrypoint composer -e COMPOSER_HOME=/tmp/query-composer \ - -v "$PWD/$sdk:/sdk" -w /sdk "$image" install --no-dev --no-scripts --no-interaction --prefer-dist \ +bash scripts/ci/run-composer.sh --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + -e COMPOSER_HOME=/tmp/query-composer -v "$PWD/$sdk:/sdk" -w /sdk -- \ + install --no-dev --no-scripts --no-interaction --prefer-dist \ > "$evidence/sdk-install.txt" 2>&1 case "$backend" in mysql) db=(-e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password) ;; diff --git a/scripts/conformance/server-parity/range-repair-profile.sh b/scripts/conformance/server-parity/range-repair-profile.sh index 215367be6..068ebec60 100644 --- a/scripts/conformance/server-parity/range-repair-profile.sh +++ b/scripts/conformance/server-parity/range-repair-profile.sh @@ -51,7 +51,7 @@ case "$backend" in database=(-e DB_CONNECTION=sqlite -v "$state:/range-state") ;; *) echo 'Choose mysql, pgsql or sqlite' >&2; exit 2 ;; esac -docker run --name "$resource-sdk" "${limits[@]}" --entrypoint composer -e COMPOSER_HOME=/tmp/composer -v "$state/sdk:/sdk" -w /sdk "$image" \ +bash scripts/ci/run-composer.sh --name "$resource-sdk" "${limits[@]}" -v "$state/sdk:/sdk" -w /sdk -- \ install --no-interaction --no-progress --no-cache > "$evidence/sdk-install.txt" 2>&1 printf '%s %s\n' "$(jq -r '.sdk_php_composer_lock_sha256' "$manifest")" "$state/sdk/composer.lock" \ | sha256sum -c > "$evidence/sdk-lock.txt" diff --git a/scripts/conformance/server-parity/sdk-direction-profile.sh b/scripts/conformance/server-parity/sdk-direction-profile.sh index d70fd248d..a0a96e7ac 100644 --- a/scripts/conformance/server-parity/sdk-direction-profile.sh +++ b/scripts/conformance/server-parity/sdk-direction-profile.sh @@ -120,7 +120,7 @@ case "$backend" in database=(-e DB_CONNECTION=sqlite -v "$state:/range-state") ;; *) echo 'Choose mysql, pgsql or sqlite' >&2; exit 2 ;; esac -docker run --name "$resource-sdk" "${limits[@]}" --entrypoint composer -e COMPOSER_HOME=/tmp/composer -v "$state/sdk:/sdk" -w /sdk "$image" \ +bash scripts/ci/run-composer.sh --name "$resource-sdk" "${limits[@]}" -v "$state/sdk:/sdk" -w /sdk -- \ install --no-interaction --no-progress --no-cache > "$evidence/sdk-install.txt" 2>&1 printf '%s %s\n' "$(jq -r '.sdk_php_composer_lock_sha256' "$manifest")" "$state/sdk/composer.lock" \ | sha256sum -c > "$evidence/sdk-lock.txt" diff --git a/scripts/conformance/server-parity/worker-fencing-profile.sh b/scripts/conformance/server-parity/worker-fencing-profile.sh index 8095630c1..f4f167cfe 100644 --- a/scripts/conformance/server-parity/worker-fencing-profile.sh +++ b/scripts/conformance/server-parity/worker-fencing-profile.sh @@ -35,15 +35,15 @@ chmod a+w parity-fence-source "$sdk_path" image=$(jq -r '.server.build_platform_image | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' "$tuple") rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 if ! test -f parity-fence-source/vendor/autoload.php; then - docker run --rm --user=1000:1000 --entrypoint composer -e COMPOSER_HOME=/tmp/parity-composer \ - -v "$PWD/parity-fence-source:/app" -w /app "$image" install --no-dev --no-scripts --no-interaction --prefer-dist \ + bash scripts/ci/run-composer.sh --rm --user=1000:1000 -e COMPOSER_HOME=/tmp/parity-composer \ + -v "$PWD/parity-fence-source:/app" -w /app -- install --no-dev --no-scripts --no-interaction --prefer-dist \ > parity-evidence/${tag}-server-install.txt 2>&1 else printf '%s\n' 'Reusing the exact locked server dependencies from the preceding source cohort.' > "parity-evidence/${tag}-server-install.txt" fi if ! test -f "$sdk_path/vendor/autoload.php"; then - docker run --rm --user=1000:1000 --entrypoint composer -e COMPOSER_HOME=/tmp/parity-composer \ - -v "$PWD:/source" -w "/source/$sdk_path" "$image" install --no-dev --no-scripts --no-interaction --prefer-dist \ + bash scripts/ci/run-composer.sh --rm --user=1000:1000 -e COMPOSER_HOME=/tmp/parity-composer \ + -v "$PWD:/source" -w "/source/$sdk_path" -- install --no-dev --no-scripts --no-interaction --prefer-dist \ > parity-evidence/${tag}-sdk-install.txt 2>&1 else printf '%s\n' 'Reusing the exact locked sdk dependencies from the preceding source cohort.' > "parity-evidence/${tag}-sdk-install.txt" diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 787c0f989..e208343c3 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -74,6 +74,23 @@ } ] }, + { + "path": ".github/workflows/server-parity.yml", + "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "count": 3, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + } + ] + }, { "path": ".github/workflows/server-perf.yml", "line": "-v \"$PWD:/app\" -w /app/benchmarks/capacity/v1/bindings/php composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee \\", @@ -196,6 +213,28 @@ } ] }, + { + "path": "benchmarks/server-port/v1/README.md", + "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "count": 1, + "confinement": "Documentation of the run-composer.sh recipe.", + "guards": [ + { + "type": "documentation" + } + ] + }, + { + "path": "scripts/ci/brotli-safe-composer.sh", + "line": "base=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", + "count": 1, + "confinement": "The pinned digest the Brotli-floor image is built from.", + "guards": [ + { + "type": "pinned-reference" + } + ] + }, { "path": "scripts/ci/checked-ca.sh", "line": "image=composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee", @@ -222,6 +261,19 @@ } ] }, + { + "path": "scripts/ci/run-composer.sh", + "line": "-v \"$ca:/checked-ca:ro\" --entrypoint composer \"$image\" \"$@\" || status=$?", + "count": 1, + "confinement": "The image comes from brotli-safe-composer.sh, which refuses it unless brotli-libs is at least 1.2.1-r0 and owns every Brotli decoder PHP links. The same command sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "brotli-floor", + "line": "image=$(sh \"$here/brotli-safe-composer.sh\")", + "distance": 9 + } + ] + }, { "path": "scripts/conformance/activities-published-artifacts.sh", "line": "composer install --no-interaction --no-progress --prefer-dist --no-scripts", @@ -753,6 +805,20 @@ } ] }, + { + "path": "scripts/conformance/server-parity/corrected-marker-adapters.sh", + "line": "bash scripts/ci/run-composer.sh --rm --cpus=1 --memory=512m --memory-swap=512m \\", + "count": 2, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + } + ] + }, { "path": "scripts/conformance/server-parity/corrected-marker-embedded-install.sh", "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", @@ -779,6 +845,28 @@ } ] }, + { + "path": "scripts/conformance/server-parity/query-authority-cohort.sh", + "line": "bash scripts/ci/run-composer.sh --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \\", + "count": 1, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + } + ] + }, + { + "path": "scripts/conformance/server-parity/range-repair-profile.sh", + "line": "bash scripts/ci/run-composer.sh --name \"$resource-sdk\" \"${limits[@]}\" -v \"$state/sdk:/sdk\" -w /sdk -- \\", + "count": 1, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + } + ] + }, { "path": "scripts/conformance/server-parity/repeated-patch-install.sh", "line": "composer install --no-dev --no-scripts --no-cache --no-interaction --no-progress --prefer-dist --optimize-autoloader", @@ -805,6 +893,31 @@ } ] }, + { + "path": "scripts/conformance/server-parity/sdk-direction-profile.sh", + "line": "bash scripts/ci/run-composer.sh --name \"$resource-sdk\" \"${limits[@]}\" -v \"$state/sdk:/sdk\" -w /sdk -- \\", + "count": 1, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + } + ] + }, + { + "path": "scripts/conformance/server-parity/worker-fencing-profile.sh", + "line": "bash scripts/ci/run-composer.sh --rm --user=1000:1000 -e COMPOSER_HOME=/tmp/parity-composer \\", + "count": 2, + "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either or an entrypoint.", + "guards": [ + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + } + ] + }, { "path": "scripts/conformance/signals-queries-published-artifacts.sh", "line": "\"composer\",", diff --git a/tests/Unit/Support/composer_trust_test.py b/tests/Unit/Support/composer_trust_test.py index 39ddd3be0..7177d81d2 100644 --- a/tests/Unit/Support/composer_trust_test.py +++ b/tests/Unit/Support/composer_trust_test.py @@ -34,20 +34,32 @@ ROOT = Path(__file__).resolve().parents[3] CI = ROOT / "scripts" / "ci" PINNED_COMPOSER = "composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee" -SEPARATOR = r"""[\s'",]+""" +# A backslash-newline continues a shell command, so it separates arguments too. +SEPARATOR = r"""(?:[\s'",]|\\\n)+""" # Composer image references, pinned or not, and Composer network commands. COMPOSER_IMAGE = re.compile(r"""(? None: "scripts/conformance/runner.sh": "# runs in the pinned image\ncomposer require durable-workflow/sdk\n", "scripts/conformance/self-update.sh": "composer self-update --2\n", ".github/workflows/audit.yml": "jobs:\n audit:\n steps:\n - run: composer audit --locked\n", + # The split form that escaped the inventory in server-parity.yml. + ".github/workflows/split.yml": "jobs:\n parity:\n steps:\n - run: |\n" + " docker run --rm --user=\"$(id -u):$(id -g)\" --entrypoint composer \\\n" + " -e COMPOSER_HOME=/tmp/composer -v \"$PWD:/source\" -w /source \"$image\" \\\n" + " install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress\n", + "scripts/conformance/entrypoint-path.sh": "docker run --entrypoint=/usr/bin/composer \"$image\" update\n", + "scripts/conformance/entrypoint-phar.sh": "docker run --entrypoint 'composer.phar' \"$image\"\n", + "scripts/conformance/continued.sh": "composer \\\n --no-interaction \\\n install\n", + "scripts/conformance/other-runner.sh": "sh tools/run-composer.sh -- install\n", } for relative, text in cases.items(): (work / relative).parent.mkdir(parents=True, exist_ok=True) @@ -293,3 +317,85 @@ def test_a_checked_cafile_with_an_empty_capath_refuses(self) -> None: self.assertEqual([], paths, "Composer reached the server through the withdrawn root") self.assertNotEqual(0, result.returncode) + + +WRAPPER_SCRIPT = CI / "run-composer.sh" +FLOOR_SCRIPT = CI / "brotli-safe-composer.sh" + + +class RunComposerTest(unittest.TestCase): + """run-composer.sh: the pinned image after the Brotli floor, with a CA pair the caller cannot replace.""" + + def wrapper(self, *arguments: str) -> tuple[subprocess.CompletedProcess[str], bool]: + """Runs the wrapper with a docker stub that records whether it was called.""" + work = Path(tempfile.mkdtemp(prefix="dw-run-composer-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + marker = work / "docker-called" + stub = work / "docker" + stub.write_text(f"#!/bin/sh\ntouch '{marker}'\nexit 99\n") + stub.chmod(0o755) + result = subprocess.run(["bash", str(WRAPPER_SCRIPT), *arguments], capture_output=True, text=True, + env={**os.environ, "PATH": f"{work}:{os.environ['PATH']}"}, timeout=60) + return result, marker.exists() + + def test_the_caller_cannot_replace_the_ca_pair_or_the_entrypoint(self) -> None: + for options in (["-e", "COMPOSER_CAFILE=/etc/ssl/certs/ca-certificates.crt"], ["-eCOMPOSER_CAFILE=/x"], + ["--env", "COMPOSER_CAPATH=/etc/ssl/certs"], ["--entrypoint=sh"], ["--entrypoint", "sh"], + ["--env-file", "composer.env"]): + with self.subTest(options): + result, docker_called = self.wrapper("--rm", *options, "--", "install") + + self.assertEqual(2, result.returncode, result.stderr) + self.assertIn("refusing", result.stderr) + self.assertFalse(docker_called, "the wrapper reached docker before refusing") + + def test_composer_arguments_need_the_separator(self) -> None: + result, docker_called = self.wrapper("--rm", "-v", "/tmp:/app", "install") + + self.assertEqual(2, result.returncode) + self.assertFalse(docker_called) + + def test_the_ca_pair_and_entrypoint_follow_the_callers_options(self) -> None: + text = WRAPPER_SCRIPT.read_text() + command = text[text.index("docker run --user"):] + self.assertLess(command.index('"${options[@]}"'), command.index("COMPOSER_CAFILE=/checked-ca/ca.pem")) + self.assertLess(command.index("COMPOSER_CAPATH=/checked-ca/no-ca-directory"), command.index("--entrypoint composer")) + self.assertRegex(command, r'--entrypoint composer "\$image" "\$@"') + self.assertRegex(text, r'image=\$\(sh "\$here/brotli-safe-composer\.sh"\)') + self.assertRegex(text, r'sh "\$here/checked-ca\.sh" "\$ca"') + + def test_the_floor_refuses_the_pinned_image_and_accepts_its_own(self) -> None: + docker_or_skip(self) + refused = subprocess.run(["sh", str(FLOOR_SCRIPT), "--check", PINNED_COMPOSER], + capture_output=True, text=True, timeout=300) + + self.assertNotEqual(0, refused.returncode, "the pinned image ships brotli-libs 1.2.0-r1") + self.assertIn("lacks brotli-libs>=1.2.1-r0", refused.stderr) + + built = subprocess.run(["sh", str(FLOOR_SCRIPT)], capture_output=True, text=True, timeout=600) + self.assertEqual(0, built.returncode, built.stderr) + checked = subprocess.run(["sh", str(FLOOR_SCRIPT), "--check", built.stdout.strip()], + capture_output=True, text=True, timeout=300) + self.assertEqual(0, checked.returncode, checked.stderr) + + def test_composer_runs_as_the_caller_with_the_checked_pair(self) -> None: + docker_or_skip(self) + work = Path(tempfile.mkdtemp(prefix="dw-run-composer-app-")) + self.addCleanup(shutil.rmtree, work, ignore_errors=True) + work.chmod(0o755) + (work / "pkg").mkdir() + (work / "pkg" / "composer.json").write_text(json.dumps({"name": "dw/probe", "version": "1.0.0"})) + (work / "composer.json").write_text(json.dumps({ + "repositories": [{"type": "path", "url": "pkg", "options": {"symlink": False}}], + "require": {"dw/probe": "1.0.0"}, + })) + run = lambda *arguments: subprocess.run( # noqa: E731 + ["bash", str(WRAPPER_SCRIPT), "--rm", "--network", "none", "-v", f"{work}:/app", "-w", "/app", "--", + *arguments], capture_output=True, text=True, timeout=600) + + installed = run("install", "--no-interaction") + + self.assertEqual(0, installed.returncode, installed.stdout + installed.stderr) + self.assertEqual(os.getuid(), (work / "vendor" / "dw" / "probe" / "composer.json").stat().st_uid) + self.assertEqual("/checked-ca/ca.pem", run("config", "cafile").stdout.strip()) + self.assertEqual("/checked-ca/no-ca-directory", run("config", "capath").stdout.strip()) From ece5e467751a3e45e8a5ea14eca008d40b0faca3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 16:13:37 +0000 Subject: [PATCH 14/21] Refuse Composer trust mount overrides --- scripts/ci/brotli-safe-composer.sh | 4 +++- scripts/ci/run-composer.sh | 6 +++++- tests/Unit/Support/composer_trust_test.py | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/ci/brotli-safe-composer.sh b/scripts/ci/brotli-safe-composer.sh index dde36efba..7fb4c3c8b 100755 --- a/scripts/ci/brotli-safe-composer.sh +++ b/scripts/ci/brotli-safe-composer.sh @@ -20,7 +20,9 @@ floor='brotli-libs>=1.2.1-r0' # Refuses IMAGE unless its installed brotli-libs meets the floor and every # Brotli decoder its PHP links belongs to that package. check() { - docker run --rm --network none --user 0 --entrypoint sh "$1" -c ' + local checked_image + checked_image=$1 + docker run --rm --network none --user 0 --entrypoint sh "$checked_image" -c ' set -eu apk info -e "$1" > /dev/null || { echo "Composer image lacks $1" >&2; exit 1; } for library in $(ldd "$(command -v php)" | awk "/libbrotlidec/ {print \$3}"); do diff --git a/scripts/ci/run-composer.sh b/scripts/ci/run-composer.sh index 89eded9af..ed48f33d7 100644 --- a/scripts/ci/run-composer.sh +++ b/scripts/ci/run-composer.sh @@ -17,9 +17,13 @@ here=$(dirname "$0") options=() while (($#)) && [[ $1 != -- ]]; do case "$1" in - *COMPOSER_CAFILE* | *COMPOSER_CAPATH* | --entrypoint* | --env-file*) + *COMPOSER_CAFILE* | *COMPOSER_CAPATH* | --entrypoint* | --env-file* | --volumes-from*) echo "run-composer.sh sets the CA pair and the entrypoint itself; refusing $1" >&2 exit 2 ;; + */checked-ca*) + echo "run-composer.sh reserves /checked-ca for its checked CA pair; refusing $1" >&2 + exit 2 ;; + *) ;; esac options+=("$1") shift diff --git a/tests/Unit/Support/composer_trust_test.py b/tests/Unit/Support/composer_trust_test.py index 7177d81d2..7727117de 100644 --- a/tests/Unit/Support/composer_trust_test.py +++ b/tests/Unit/Support/composer_trust_test.py @@ -341,7 +341,15 @@ def wrapper(self, *arguments: str) -> tuple[subprocess.CompletedProcess[str], bo def test_the_caller_cannot_replace_the_ca_pair_or_the_entrypoint(self) -> None: for options in (["-e", "COMPOSER_CAFILE=/etc/ssl/certs/ca-certificates.crt"], ["-eCOMPOSER_CAFILE=/x"], ["--env", "COMPOSER_CAPATH=/etc/ssl/certs"], ["--entrypoint=sh"], ["--entrypoint", "sh"], - ["--env-file", "composer.env"]): + ["--env-file", "composer.env"], ["-v", "/tmp/root.pem:/checked-ca/ca.pem:ro"], + ["--volume=/tmp/trust:/checked-ca:ro"], + ["--mount", "type=bind,src=/tmp/empty,dst=/checked-ca/no-ca-directory"], + ["--mount=type=bind,src=/tmp/empty,dst=/checked-ca/no-ca-directory"], + ["--tmpfs", "/checked-ca"], ["--tmpfs=/checked-ca"], + ["--device", "/dev/null:/checked-ca/ca.pem"], + ["--device=/dev/null:/checked-ca/ca.pem"], + ["--volumes-from", "untrusted-container"], + ["--volumes-from=untrusted-container"]): with self.subTest(options): result, docker_called = self.wrapper("--rm", *options, "--", "install") @@ -355,6 +363,12 @@ def test_composer_arguments_need_the_separator(self) -> None: self.assertEqual(2, result.returncode) self.assertFalse(docker_called) + def test_unrelated_docker_options_reach_the_pinned_image_builder(self) -> None: + result, docker_called = self.wrapper("--rm", "-v", "/tmp:/app", "--", "install") + + self.assertNotEqual(2, result.returncode, result.stderr) + self.assertTrue(docker_called) + def test_the_ca_pair_and_entrypoint_follow_the_callers_options(self) -> None: text = WRAPPER_SCRIPT.read_text() command = text[text.index("docker run --user"):] From 802f843a93a801040f0685e7f37f66686510173f Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 17:17:45 +0000 Subject: [PATCH 15/21] Move the five parity fixture locks to the fixed TLS trust crates Closes #703. The corrected-markers, legacy-marker-aliases, published-sdk-patch, query-authority and sdk-directions adapters each build their Cargo.lock with cargo build --locked, and each still resolved rustls-webpki 0.103.15 and webpki-roots 1.0.9 (RUSTSEC-2026-0381/0382, GHSA-6q48-6c7w-9gw2). Only those two packages move, to 0.103.16 and 1.0.10, with the checksums already reviewed in rust/Cargo.lock. cargo_lock_trust_test.py walks the tree, requires every Cargo.lock outside the listed immutable evidence to resolve at least those versions, keeps the evidence lock's recorded digest, and names each failing lock. It runs in the PHPUnit feature workflow's repository contracts. --- .github/workflows/phpunit-feature.yml | 3 + .../corrected-markers/rust/Cargo.lock | 8 +- .../legacy-marker-aliases/rust/Cargo.lock | 8 +- .../published-sdk-patch/rust/Cargo.lock | 8 +- .../query-authority/rust/Cargo.lock | 8 +- .../sdk-directions/rust/Cargo.lock | 8 +- .../PhpunitFeatureWorkflowContractTest.php | 1 + tests/Unit/Support/cargo_lock_trust_test.py | 132 ++++++++++++++++++ 8 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Support/cargo_lock_trust_test.py diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 54d4d8569..feb4403c2 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -123,6 +123,9 @@ jobs: DW_REQUIRE_CARGO_TRUST_TEST: '1' run: python3 -m unittest tests/Unit/Support/cargo_trust_test.py + - name: Prove every active Cargo.lock resolves the fixed TLS trust crates + run: python3 -m unittest tests/Unit/Support/cargo_lock_trust_test.py + - name: Prove Composer runs pinned or confined to a checked CA file env: DW_REQUIRE_COMPOSER_TRUST_TEST: '1' diff --git a/scripts/conformance/server-parity/corrected-markers/rust/Cargo.lock b/scripts/conformance/server-parity/corrected-markers/rust/Cargo.lock index 3a7d16015..68dbd5832 100644 --- a/scripts/conformance/server-parity/corrected-markers/rust/Cargo.lock +++ b/scripts/conformance/server-parity/corrected-markers/rust/Cargo.lock @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1575,9 +1575,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/scripts/conformance/server-parity/legacy-marker-aliases/rust/Cargo.lock b/scripts/conformance/server-parity/legacy-marker-aliases/rust/Cargo.lock index 8ddd8ea39..cbed98a62 100644 --- a/scripts/conformance/server-parity/legacy-marker-aliases/rust/Cargo.lock +++ b/scripts/conformance/server-parity/legacy-marker-aliases/rust/Cargo.lock @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1575,9 +1575,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock b/scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock index 99ed5b552..2ca55b940 100644 --- a/scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock +++ b/scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1575,9 +1575,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/scripts/conformance/server-parity/query-authority/rust/Cargo.lock b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock index 644a6030d..fa544b94f 100644 --- a/scripts/conformance/server-parity/query-authority/rust/Cargo.lock +++ b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1576,9 +1576,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/scripts/conformance/server-parity/sdk-directions/rust/Cargo.lock b/scripts/conformance/server-parity/sdk-directions/rust/Cargo.lock index 2b497336c..7600cc45d 100644 --- a/scripts/conformance/server-parity/sdk-directions/rust/Cargo.lock +++ b/scripts/conformance/server-parity/sdk-directions/rust/Cargo.lock @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1575,9 +1575,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/tests/Unit/PhpunitFeatureWorkflowContractTest.php b/tests/Unit/PhpunitFeatureWorkflowContractTest.php index d5ec16236..0b665589f 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -163,6 +163,7 @@ public function test_preflight_validates_release_metadata_and_corpus_tools(): vo 'python scripts/ci/test-regression-corpus-policy.py', 'python3 -m unittest tests/Unit/Support/patched_pip_test.py', 'python3 -m unittest tests/Unit/Support/cargo_trust_test.py', + 'python3 -m unittest tests/Unit/Support/cargo_lock_trust_test.py', 'python3 -m unittest tests/Unit/Support/composer_trust_test.py', ] as $needle) { $this->assertStringContainsString($needle, $this->workflow); diff --git a/tests/Unit/Support/cargo_lock_trust_test.py b/tests/Unit/Support/cargo_lock_trust_test.py new file mode 100644 index 000000000..efab6db6a --- /dev/null +++ b/tests/Unit/Support/cargo_lock_trust_test.py @@ -0,0 +1,132 @@ +"""Every active Cargo.lock resolves the fixed TLS trust crates. + +RUSTSEC-2026-0381 and RUSTSEC-2026-0382 (GHSA-6q48-6c7w-9gw2) fix the +withdrawn emSign roots in rustls-webpki 0.103.16 and webpki-roots 1.0.10. +Each Cargo.lock in the repository is an executable input: CI builds it with +`cargo build --locked`. So every lock found by walking the tree must resolve +at least those versions. Immutable historical evidence is the only exemption. +It is listed by path, and its bytes must stay exactly as recorded. + +Run with: python3 -m unittest tests/Unit/Support/cargo_lock_trust_test.py +""" + +from __future__ import annotations + +import hashlib +import os +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] + +FLOORS = { + "rustls-webpki": (0, 103, 16), + "webpki-roots": (1, 0, 10), +} + +# Archived evidence of a published Rust SDK run; it is never built again. +IMMUTABLE_EVIDENCE = { + "docs/evidence/server-237-20260929-published-rust212/Cargo.lock": + "ed31eb89b3ce1cd5a25a3a4b1751870cdc0a07cea0877751c101bc0a103c5475", +} + +# Locks the walk must find, so a moved or renamed tree cannot empty the check. +REQUIRED = { + "rust/Cargo.lock", + "benchmarks/capacity/v1/bindings/rust/Cargo.lock", + "tests/Fixtures/ActivityLeaseConsumers/rust/Cargo.lock", + "scripts/conformance/server-parity/corrected-markers/rust/Cargo.lock", + "scripts/conformance/server-parity/legacy-marker-aliases/rust/Cargo.lock", + "scripts/conformance/server-parity/published-sdk-patch/rust/Cargo.lock", + "scripts/conformance/server-parity/query-authority/rust/Cargo.lock", + "scripts/conformance/server-parity/sdk-directions/rust/Cargo.lock", +} + +SKIPPED_DIRECTORIES = {".git", "node_modules", "target"} + +PACKAGE = re.compile(r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"', re.MULTILINE) + + +def find_locks(root: Path) -> list[str]: + found = [] + for directory, subdirectories, files in os.walk(root): + subdirectories[:] = sorted(d for d in subdirectories if d not in SKIPPED_DIRECTORIES) + if "Cargo.lock" in files: + found.append((Path(directory) / "Cargo.lock").relative_to(root).as_posix()) + return sorted(found) + + +def version(text: str) -> tuple[int, ...]: + core = text.split("+", 1)[0].split("-", 1)[0] + return tuple(int(part) for part in core.split(".")) + + +def stale_packages(lock_text: str) -> list[str]: + """The trust crates in one lock that resolve below their fixed floor.""" + stale = [] + for name, resolved in PACKAGE.findall(lock_text): + floor = FLOORS.get(name) + if floor is not None and version(resolved) < floor: + stale.append(f"{name} {resolved}") + return stale + + +class CargoLockTrustTest(unittest.TestCase): + maxDiff = None # name every failing lock + + def test_every_active_lock_resolves_the_fixed_trust_crates(self) -> None: + locks = find_locks(ROOT) + missing = REQUIRED - set(locks) + self.assertEqual(set(), missing, "known locks the walk did not find") + failures = {} + for path in locks: + if path in IMMUTABLE_EVIDENCE: + continue + stale = stale_packages((ROOT / path).read_text(encoding="utf-8")) + if stale: + failures[path] = stale + self.assertEqual({}, failures, "active locks below the RUSTSEC-2026-0381/0382 floors") + + def test_the_known_tls_locks_resolve_both_crates(self) -> None: + for path in sorted(REQUIRED): + with self.subTest(path=path): + names = {name for name, _ in PACKAGE.findall((ROOT / path).read_text(encoding="utf-8"))} + self.assertTrue(set(FLOORS) <= names, f"{path} no longer resolves the trust crates") + + def test_immutable_evidence_keeps_its_recorded_bytes(self) -> None: + for path, digest in IMMUTABLE_EVIDENCE.items(): + with self.subTest(path=path): + self.assertEqual(digest, hashlib.sha256((ROOT / path).read_bytes()).hexdigest()) + + def test_stale_versions_are_refused(self) -> None: + def lock(webpki: str, roots: str) -> str: + return ( + '[[package]]\nname = "rustls-webpki"\nversion = "' + webpki + '"\n' + 'source = "registry+https://github.com/rust-lang/crates.io-index"\n\n' + '[[package]]\nname = "webpki-roots"\nversion = "' + roots + '"\n' + ) + + self.assertEqual([], stale_packages(lock("0.103.16", "1.0.10"))) + self.assertEqual([], stale_packages(lock("0.104.0", "1.1.0"))) + self.assertEqual(["rustls-webpki 0.103.15"], stale_packages(lock("0.103.15", "1.0.10"))) + self.assertEqual(["webpki-roots 1.0.9"], stale_packages(lock("0.103.16", "1.0.9"))) + self.assertEqual(["rustls-webpki 0.102.8", "webpki-roots 0.26.11"], stale_packages(lock("0.102.8", "0.26.11"))) + + def test_a_new_lock_outside_evidence_is_checked(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "fixtures/new/rust").mkdir(parents=True) + (root / "fixtures/new/rust/Cargo.lock").write_text( + '[[package]]\nname = "webpki-roots"\nversion = "1.0.9"\n', encoding="utf-8") + (root / "target").mkdir() + (root / "target/Cargo.lock").write_text("", encoding="utf-8") + self.assertEqual(["fixtures/new/rust/Cargo.lock"], find_locks(root)) + self.assertEqual(["webpki-roots 1.0.9"], + stale_packages((root / "fixtures/new/rust/Cargo.lock").read_text(encoding="utf-8"))) + + +if __name__ == "__main__": + unittest.main() From e95aed0082b6c951a818bfa62a0354d34fdc0328 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 17:30:19 +0000 Subject: [PATCH 16/21] Record the corrected-markers adapter's new lock digest The corrected-markers profile records the sha256 of its Rust consumer's Cargo.lock so its identity check and contracts catch an unreviewed change. The lock changed only for the reviewed rustls-webpki/webpki-roots move in the previous commit (#703), so its two artifact manifests now record that lock. The published artifacts, SDK pins and every other recorded value are unchanged. The other four adapters do not record their lock digest. --- .../corrected-markers/artifacts-workflow-2.5.16.json | 2 +- .../ServerParityProfiles/corrected-markers/artifacts.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts-workflow-2.5.16.json b/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts-workflow-2.5.16.json index 285090296..95e8e5ec1 100644 --- a/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts-workflow-2.5.16.json +++ b/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts-workflow-2.5.16.json @@ -45,7 +45,7 @@ "sdk_composer_lock_sha256": "42e814ee38813d3dafcf2e4cc371327d661536adda653e6cf1518a91a46087ca", "sdk_python": "2.5.13", "sdk_rust": "3.5.4", - "rust_consumer_cargo_lock_sha256": "047dd96c114ec56408bc9bce16570ab76cab799088803563e8a19e607eb1d9fd", + "rust_consumer_cargo_lock_sha256": "904d81cb766cd55a791585eb3424a21c36930d5bd6e2307c407981ab342b9c4d", "published_sdk_artifacts": { "php": { "version": "2.3.9", diff --git a/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts.json b/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts.json index 2ba7375bd..6f343ceb7 100644 --- a/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts.json +++ b/tests/Fixtures/ServerParityProfiles/corrected-markers/artifacts.json @@ -45,7 +45,7 @@ "sdk_composer_lock_sha256": "42e814ee38813d3dafcf2e4cc371327d661536adda653e6cf1518a91a46087ca", "sdk_python": "2.5.13", "sdk_rust": "3.5.4", - "rust_consumer_cargo_lock_sha256": "047dd96c114ec56408bc9bce16570ab76cab799088803563e8a19e607eb1d9fd", + "rust_consumer_cargo_lock_sha256": "904d81cb766cd55a791585eb3424a21c36930d5bd6e2307c407981ab342b9c4d", "published_sdk_artifacts": { "php": { "version": "2.3.9", From 70566d33c88f8b55a2f0a31eeb09cda28b02ccd1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 20:12:50 +0000 Subject: [PATCH 17/21] Read Cargo.lock as TOML and compare trust crates by SemVer The lock inventory missed a stale trust crate when another TOML key sat between name and version, and it treated a prerelease of the fixed release as the fixed release. Locks are now parsed with tomllib, versions compare by SemVer precedence, and an unreadable lock or version fails the check. --- tests/Unit/Support/cargo_lock_trust_test.py | 84 ++++++++++++++++++--- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/tests/Unit/Support/cargo_lock_trust_test.py b/tests/Unit/Support/cargo_lock_trust_test.py index efab6db6a..fd6de6dc6 100644 --- a/tests/Unit/Support/cargo_lock_trust_test.py +++ b/tests/Unit/Support/cargo_lock_trust_test.py @@ -7,6 +7,11 @@ at least those versions. Immutable historical evidence is the only exemption. It is listed by path, and its bytes must stay exactly as recorded. +Locks are read as TOML, so the order of a package's fields does not matter. +Versions compare by SemVer precedence: a prerelease of the fixed release, such +as 0.103.16-alpha.1, is below the floor. A lock that is not valid TOML, or a +trust crate whose version is not SemVer, fails the check. + Run with: python3 -m unittest tests/Unit/Support/cargo_lock_trust_test.py """ @@ -15,6 +20,7 @@ import hashlib import os import re +import tomllib import unittest from pathlib import Path @@ -45,7 +51,11 @@ SKIPPED_DIRECTORIES = {".git", "node_modules", "target"} -PACKAGE = re.compile(r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"', re.MULTILINE) +SEMVER = re.compile( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) def find_locks(root: Path) -> list[str]: @@ -57,17 +67,37 @@ def find_locks(root: Path) -> list[str]: return sorted(found) -def version(text: str) -> tuple[int, ...]: - core = text.split("+", 1)[0].split("-", 1)[0] - return tuple(int(part) for part in core.split(".")) +def packages(lock_text: str) -> list[tuple[object, object]]: + """Each package's name and version, read as TOML in any field order.""" + lock = tomllib.loads(lock_text) + entries = lock.get("package", []) + if not isinstance(entries, list): + raise ValueError("the lock's package entry is not an array of tables") + return [(entry.get("name"), entry.get("version")) if isinstance(entry, dict) else (None, None) + for entry in entries] + + +def below_floor(resolved: object, floor: tuple[int, int, int]) -> bool: + """SemVer precedence: a release below the floor, or a prerelease of it. + + Build metadata does not change precedence. A version that is not SemVer + counts as below the floor. + """ + if not isinstance(resolved, str): + return True + match = SEMVER.fullmatch(resolved) + if match is None: + return True + core = tuple(int(part) for part in match.group(1, 2, 3)) + return core < floor or (core == floor and match.group(4) is not None) def stale_packages(lock_text: str) -> list[str]: """The trust crates in one lock that resolve below their fixed floor.""" stale = [] - for name, resolved in PACKAGE.findall(lock_text): - floor = FLOORS.get(name) - if floor is not None and version(resolved) < floor: + for name, resolved in packages(lock_text): + floor = FLOORS.get(name) if isinstance(name, str) else None + if floor is not None and below_floor(resolved, floor): stale.append(f"{name} {resolved}") return stale @@ -91,7 +121,7 @@ def test_every_active_lock_resolves_the_fixed_trust_crates(self) -> None: def test_the_known_tls_locks_resolve_both_crates(self) -> None: for path in sorted(REQUIRED): with self.subTest(path=path): - names = {name for name, _ in PACKAGE.findall((ROOT / path).read_text(encoding="utf-8"))} + names = {name for name, _ in packages((ROOT / path).read_text(encoding="utf-8"))} self.assertTrue(set(FLOORS) <= names, f"{path} no longer resolves the trust crates") def test_immutable_evidence_keeps_its_recorded_bytes(self) -> None: @@ -113,6 +143,41 @@ def lock(webpki: str, roots: str) -> str: self.assertEqual(["webpki-roots 1.0.9"], stale_packages(lock("0.103.16", "1.0.9"))) self.assertEqual(["rustls-webpki 0.102.8", "webpki-roots 0.26.11"], stale_packages(lock("0.102.8", "0.26.11"))) + def test_field_order_does_not_hide_a_stale_crate(self) -> None: + source = 'source = "registry+https://github.com/rust-lang/crates.io-index"\n' + reordered = ( + '[[package]]\nname = "rustls-webpki"\n' + source + 'version = "0.103.15"\n\n' + '[[package]]\nversion = "1.0.9"\nchecksum = "00"\nname = "webpki-roots"\n' + ) + self.assertEqual(["rustls-webpki 0.103.15", "webpki-roots 1.0.9"], stale_packages(reordered)) + fixed = '[[package]]\n' + source + 'version = "0.103.16"\nname = "rustls-webpki"\n' + self.assertEqual([], stale_packages(fixed)) + + def test_a_prerelease_of_the_fixed_release_is_refused(self) -> None: + def lock(webpki: str, roots: str) -> str: + return ( + '[[package]]\nname = "rustls-webpki"\nversion = "' + webpki + '"\n\n' + '[[package]]\nname = "webpki-roots"\nversion = "' + roots + '"\n' + ) + + self.assertEqual(["rustls-webpki 0.103.16-alpha.1", "webpki-roots 1.0.10-rc.1"], + stale_packages(lock("0.103.16-alpha.1", "1.0.10-rc.1"))) + # A later release's prerelease and build metadata stay at or above the floor. + self.assertEqual([], stale_packages(lock("0.103.17-alpha.1", "1.0.10+build.5"))) + self.assertEqual([], stale_packages(lock("0.104.0-rc.1", "1.1.0-beta"))) + + def test_unreadable_versions_and_locks_fail_closed(self) -> None: + for resolved in ["0.103", "v0.103.16", "0.103.16.1", "0.103.016", "", "latest"]: + with self.subTest(resolved=resolved): + self.assertEqual([f"rustls-webpki {resolved}"], + stale_packages(f'[[package]]\nname = "rustls-webpki"\nversion = "{resolved}"\n')) + self.assertEqual(["rustls-webpki None"], stale_packages('[[package]]\nname = "rustls-webpki"\n')) + self.assertEqual(["webpki-roots 1"], stale_packages('[[package]]\nname = "webpki-roots"\nversion = 1\n')) + with self.assertRaises(tomllib.TOMLDecodeError): + stale_packages('[[package]]\nname = "webpki-roots"\nversion = "1.0.9\n') + with self.assertRaises(ValueError): + stale_packages('package = "webpki-roots"\n') + def test_a_new_lock_outside_evidence_is_checked(self) -> None: import tempfile @@ -120,7 +185,8 @@ def test_a_new_lock_outside_evidence_is_checked(self) -> None: root = Path(directory) (root / "fixtures/new/rust").mkdir(parents=True) (root / "fixtures/new/rust/Cargo.lock").write_text( - '[[package]]\nname = "webpki-roots"\nversion = "1.0.9"\n', encoding="utf-8") + '[[package]]\nversion = "1.0.9"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\n' + 'name = "webpki-roots"\n', encoding="utf-8") (root / "target").mkdir() (root / "target/Cargo.lock").write_text("", encoding="utf-8") self.assertEqual(["fixtures/new/rust/Cargo.lock"], find_locks(root)) From c00475d3fd6f07d440173730c767b591f572117d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Fri, 23 Oct 2026 20:24:27 +0000 Subject: [PATCH 18/21] Refuse leading zeroes in numeric prerelease identifiers SemVer forbids a leading zero in a numeric prerelease identifier, so 0.103.17-01 is not a version. The lock inventory now validates each prerelease identifier and fails closed on such a version; build metadata keeps its leading zeroes. --- tests/Unit/Support/cargo_lock_trust_test.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Support/cargo_lock_trust_test.py b/tests/Unit/Support/cargo_lock_trust_test.py index fd6de6dc6..fd57885a6 100644 --- a/tests/Unit/Support/cargo_lock_trust_test.py +++ b/tests/Unit/Support/cargo_lock_trust_test.py @@ -51,9 +51,13 @@ SKIPPED_DIRECTORIES = {".git", "node_modules", "target"} +# A prerelease identifier is numeric without a leading zero, or alphanumeric +# with at least one non-digit. Build metadata identifiers may have leading +# zeroes. +PRERELEASE_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" SEMVER = re.compile( r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" - r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:-(" + PRERELEASE_IDENTIFIER + r"(?:\." + PRERELEASE_IDENTIFIER + r")*))?" r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" ) @@ -167,10 +171,19 @@ def lock(webpki: str, roots: str) -> str: self.assertEqual([], stale_packages(lock("0.104.0-rc.1", "1.1.0-beta"))) def test_unreadable_versions_and_locks_fail_closed(self) -> None: - for resolved in ["0.103", "v0.103.16", "0.103.16.1", "0.103.016", "", "latest"]: + # Leading zeroes in a numeric prerelease identifier are not SemVer, + # even on a release above the floor. + for resolved in ["0.103", "v0.103.16", "0.103.16.1", "0.103.016", "", "latest", "0.103.17-01", + "0.104.0-rc.01", "0.103.17-", "0.103.17-alpha..1"]: with self.subTest(resolved=resolved): self.assertEqual([f"rustls-webpki {resolved}"], stale_packages(f'[[package]]\nname = "rustls-webpki"\nversion = "{resolved}"\n')) + self.assertEqual(["webpki-roots 1.0.11-01"], + stale_packages('[[package]]\nname = "webpki-roots"\nversion = "1.0.11-01"\n')) + # Valid prerelease and build identifiers above the floor still pass. + for resolved in ["0.103.17-0", "0.103.17-0a.1", "0.103.17-alpha-1", "0.103.17-rc.10", "0.103.17+001.build"]: + with self.subTest(resolved=resolved): + self.assertEqual([], stale_packages(f'[[package]]\nname = "rustls-webpki"\nversion = "{resolved}"\n')) self.assertEqual(["rustls-webpki None"], stale_packages('[[package]]\nname = "rustls-webpki"\n')) self.assertEqual(["webpki-roots 1"], stale_packages('[[package]]\nname = "webpki-roots"\nversion = 1\n')) with self.assertRaises(tomllib.TOMLDecodeError): From 2298674a944562a3c8d4b6ab1bd573f69cbe9a8c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:10:10 +0000 Subject: [PATCH 19/21] Run the split parity parts' Composer installs through run-composer.sh Main split the server-parity parts after this branch moved the workflow's Composer installs off the image under test. Its nine new part steps still ran Composer in that image with its system store. Route them through scripts/ci/run-composer.sh like the other three, and record twelve wrapper sinks in composer-sinks.json. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/server-parity.yml | 27 ++++++++---------------- tests/Unit/Support/composer-sinks.json | 29 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index d72a001cd..048267f7c 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -694,8 +694,7 @@ jobs: } trap container_log_tails ERR image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress # Each profile brings its own published PHP and embedded databases and # shares this part's native node, as the profiles shared the corpus's. @@ -723,8 +722,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -776,8 +774,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -1296,8 +1293,7 @@ jobs: } trap container_log_tails ERR image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress # Each profile brings its own published PHP and embedded databases and # shares this part's native node, as the profiles shared the corpus's. @@ -1325,8 +1321,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -1380,8 +1375,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run -d --name "$RESOURCE_SCOPE-native-b" --network "$RESOURCE_SCOPE" --network-alias=parity-native-b \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ @@ -1906,8 +1900,7 @@ jobs: } trap container_log_tails ERR image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence # The profiles' published PHP containers run as 1000:1000; grant that @@ -1942,8 +1935,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence # The cohorts' published PHP containers run as 1000:1000; grant that @@ -2010,8 +2002,7 @@ jobs: run: | set -euo pipefail image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; "ghcr.io/durable-workflow/server@")' tests/Fixtures/ServerParityBaselines/php-2.11.1.json) - docker run --rm --user="$(id -u):$(id -g)" --entrypoint composer \ - -e COMPOSER_HOME=/tmp/composer -v "$PWD:/source" -w /source "$image" \ + bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress mkdir -p parity-state parity-evidence # The cohorts' published PHP containers run as 1000:1000; grant that diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index 8a55763ac..b81a9fc9e 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -64,9 +64,36 @@ { "path": ".github/workflows/server-parity.yml", "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", - "count": 3, + "count": 12, "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either, an entrypoint, an env file, /checked-ca or --volumes-from.", "guards": [ + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, + { + "type": "composer-wrapper" + }, { "type": "composer-wrapper" }, From 090ff8504f9e3f695fe21d4e66d825a2e4ba91a0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:50:33 +0000 Subject: [PATCH 20/21] Take Debian's fixed libbrotli1 instead of rebuilding it Debian's security team published brotli 1.1.0-2+deb13u1 in trixie-security on October 23: "Detect and report invalid stream earlier (CVE-2026-111497)", the upstream fix the rebuild applied. With that source as the candidate, `apt-get source brotli=1.1.0-2` fetched +deb13u1's files and the pinned source hashes failed, so the Server image no longer built. The rebuild's 1.1.0-2+b7+dw1 also ranks below Debian's fix. The image now installs Debian's signed libbrotli1 with its other packages, after the CA store check, over the base image's 1.1.0-2+b7. verify-brotli.sh requires at least 1.1.0-2+deb13u1 (dpkg --compare-versions) and keeps the single-decoder, linked-object, Node and error-code checks. The build stage, pinned sources and vendored patch go; brotli_trust_test.py checks the new wiring and refuses a rebuild stage, a missing package or an install before the trust check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/phpunit-feature.yml | 2 +- Dockerfile | 17 +- docker/brotli/CVE-2026-111497.patch | 234 ------------------------ docker/brotli/build.sh | 39 ---- docker/brotli/source.sha256 | 3 - docker/brotli/verify-brotli.sh | 14 +- docker/brotli/version | 2 +- tests/Unit/Support/brotli_trust_test.py | 84 ++++----- 8 files changed, 47 insertions(+), 348 deletions(-) delete mode 100644 docker/brotli/CVE-2026-111497.patch delete mode 100755 docker/brotli/build.sh delete mode 100644 docker/brotli/source.sha256 diff --git a/.github/workflows/phpunit-feature.yml b/.github/workflows/phpunit-feature.yml index 764259b0f..fbafb0523 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -149,7 +149,7 @@ jobs: DW_REQUIRE_COMPOSER_TRUST_TEST: '1' run: python3 -m unittest tests/Unit/Support/composer_trust_test.py tests/Unit/Support/release_composer_audit_test.py - - name: Prove the image builds, installs and checks the fixed Brotli decoder + - name: Prove the image installs and checks the fixed Brotli decoder run: python3 -m unittest tests/Unit/Support/brotli_trust_test.py feature: diff --git a/Dockerfile b/Dockerfile index c569db098..9ae79b249 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,15 +24,6 @@ RUN git clone --depth 1 --branch "${PHPREDIS_VERSION}" https://github.com/phpred exit 1; \ fi -# Debian 13 has no fixed libbrotli1 for CVE-2026-111497 yet: rebuild Debian's -# own source with upstream's fix, for each release architecture (see -# docker/brotli/build.sh). The runtime stage installs only that package. -FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS brotli -COPY docker/trust/ /usr/local/lib/server-trust/ -RUN sh /usr/local/lib/server-trust/verify-trust.sh system -COPY docker/brotli/ /usr/src/server-brotli/ -RUN sh /usr/src/server-brotli/build.sh - # The fixed official Apache runtime is pinned for both release architectures. FROM php@sha256:d89e8b3d2033c3179dcd9999d913670eb12a07410a95e60205c64e7d0fac7aef AS base @@ -45,9 +36,10 @@ COPY --from=phpredis-source /phpredis /usr/src/php/ext/redis # The image's trust checks; `server-verify-trust` reruns them on any image. COPY docker/trust/ /usr/local/lib/server-trust/ -# The fixed Brotli decoder and its check, `server-verify-brotli`. +# The Brotli decoder check, `server-verify-brotli`. Debian's signed +# libbrotli1 1.1.0-2+deb13u1 (trixie-security) carries the CVE-2026-111497 +# fix; the package list below takes it over the base image's 1.1.0-2+b7. COPY docker/brotli/ /usr/local/lib/server-brotli/ -COPY --from=brotli /out/ /usr/src/server-brotli-deb/ # Published conformance runners execute from the exact Server image. # Keep their Node and Python runtimes available alongside the service. Node @@ -58,6 +50,7 @@ RUN chmod 0755 /usr/local/lib/server-trust/verify-trust.sh \ && server-verify-trust system \ && apt-get update && apt-get install -y --no-install-recommends \ curl \ + libbrotli1 \ libpq-dev \ libstdc++6 \ libzip-dev \ @@ -65,8 +58,6 @@ RUN chmod 0755 /usr/local/lib/server-trust/verify-trust.sh \ python3-venv \ unzip \ util-linux \ - && dpkg -i /usr/src/server-brotli-deb/libbrotli1_*.deb \ - && rm -rf /usr/src/server-brotli-deb \ && ln -s /usr/local/lib/server-brotli/verify-brotli.sh /usr/local/bin/server-verify-brotli \ && docker-php-ext-install opcache redis pdo pdo_mysql pdo_pgsql pcntl zip bcmath \ && groupmod --gid 1000 www-data \ diff --git a/docker/brotli/CVE-2026-111497.patch b/docker/brotli/CVE-2026-111497.patch deleted file mode 100644 index 6aaa8f48a..000000000 --- a/docker/brotli/CVE-2026-111497.patch +++ /dev/null @@ -1,234 +0,0 @@ -From da9806362874b9bd4b552a3af360b5d6347beb12 Mon Sep 17 00:00:00 2001 -From: Evgenii Kliuchnikov -Date: Fri, 27 Feb 2026 05:10:32 -0800 -Subject: [PATCH] Detect and report invalid stream earlier. - -PiperOrigin-RevId: 876198329 -(cherry picked from commit 0ed4268a4401abcb4472096340477cf4af7d1a2b) ---- - c/dec/decode.c | 89 ++++++++++++++++++++++++++------------- - c/include/brotli/decode.h | 4 +- - 2 files changed, 60 insertions(+), 33 deletions(-) - -diff --git a/c/dec/decode.c b/c/dec/decode.c -index 220c7e85c..2b4576556 100644 ---- a/c/dec/decode.c -+++ b/c/dec/decode.c -@@ -1124,7 +1124,7 @@ static BrotliDecoderErrorCode DecodeContextMap(brotli_reg_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) { - brotli_reg_t max_block_type = s->num_block_types[tree_type]; - const HuffmanCode* type_tree = &s->block_type_trees[ -@@ -1135,7 +1135,7 @@ static BROTLI_INLINE BROTLI_BOOL DecodeBlockTypeAndLength( - brotli_reg_t* ringbuffer = &s->block_type_rb[tree_type * 2]; - brotli_reg_t block_type; - if (max_block_type <= 1) { -- return BROTLI_FALSE; -+ return BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH; - } - - /* Read 0..15 + 3..39 bits. */ -@@ -1145,11 +1145,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; - } - } - -@@ -1165,7 +1167,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( -@@ -1202,62 +1204,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( -- BrotliDecoderState* s) { -+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); - } -@@ -1887,6 +1895,9 @@ static BROTLI_INLINE BROTLI_BOOL CheckInputAmount( - return BrotliCheckInputAmount(br); - } - -+/* 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) { \ -@@ -1899,6 +1910,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; -@@ -1938,7 +1965,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. */ -@@ -2046,7 +2073,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)); - } -@@ -2240,7 +2267,7 @@ static BROTLI_INLINE BrotliDecoderErrorCode ProcessCommandsInternal( - } - - NextLiteralBlock: -- BROTLI_SAFE(DecodeLiteralBlockSwitch(s)); -+ BROTLI_SAFE_WITH_STATUS(DecodeLiteralBlockSwitch(s)); - goto CommandInner; - - saveStateAndReturn: -@@ -2462,6 +2489,8 @@ BrotliDecoderResult BrotliDecoderDecompressStream( - if (result != BROTLI_DECODER_SUCCESS) { - break; - } -+ BROTLI_DCHECK(s->meta_block_remaining_len <= -+ (int)BROTLI_BLOCK_SIZE_CAP); - BROTLI_LOG_UINT(s->is_last_metablock); - BROTLI_LOG_UINT(s->meta_block_remaining_len); - BROTLI_LOG_UINT(s->is_metadata); -diff --git a/c/include/brotli/decode.h b/c/include/brotli/decode.h -index af1aa23f6..18ca1482d 100644 ---- a/c/include/brotli/decode.h -+++ b/c/include/brotli/decode.h -@@ -85,9 +85,7 @@ 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 \ -- \ -- /* -17 code is reserved */ \ -- \ -+ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR \ - BROTLI_ERROR_CODE(_ERROR_, COMPOUND_DICTIONARY, -18) SEPARATOR \ - BROTLI_ERROR_CODE(_ERROR_, DICTIONARY_NOT_SET, -19) SEPARATOR \ - BROTLI_ERROR_CODE(_ERROR_, INVALID_ARGUMENTS, -20) SEPARATOR \ diff --git a/docker/brotli/build.sh b/docker/brotli/build.sh deleted file mode 100755 index d6ccf910b..000000000 --- a/docker/brotli/build.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -# Debian 13 has no fixed libbrotli1 for CVE-2026-111497 yet. Rebuild -# Debian's own brotli 1.1.0-2 source with upstream's fix for the 1.1 line -# (google/brotli da9806362874b9bd4b552a3af360b5d6347beb12, released as -# v1.1.1) for the platform being built. The source comes through apt's signed -# Sources index and must also match the hashes in source.sha256. The package -# keeps Debian's file layout and names itself 1.1.0-2+b7+dw1: above the -# trixie build it replaces, below any future Debian fix (+deb13uN). -set -eu - -here=$(dirname "$(readlink -f "$0")") -version=$(cat "$here/version") -work=/usr/src/server-brotli-build - -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 "$work" && cd "$work" -apt-get source --download-only brotli=1.1.0-2 -sha256sum -c "$here/source.sha256" -dpkg-source -x brotli_1.1.0-2.dsc brotli-1.1.0 -cd brotli-1.1.0 -apt-get build-dep -y --no-install-recommends -P nopython . - -cp "$here/CVE-2026-111497.patch" debian/patches/ -echo CVE-2026-111497.patch >> debian/patches/series -{ - printf 'brotli (%s) trixie-security; urgency=high\n\n' "$version" - printf ' * Durable Workflow rebuild: apply upstream da98063 (v1.1.1),\n' - printf ' "Detect and report invalid stream earlier." (CVE-2026-111497).\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 - -DEB_BUILD_PROFILES=nopython DEB_BUILD_OPTIONS='nocheck noddebs' dpkg-buildpackage -B -uc -us -Pnopython -mkdir -p /out -cp "$work/libbrotli1_${version}_$(dpkg --print-architecture).deb" /out/ -dpkg-deb --field "/out/libbrotli1_${version}_$(dpkg --print-architecture).deb" Package Version diff --git a/docker/brotli/source.sha256 b/docker/brotli/source.sha256 deleted file mode 100644 index fd414609b..000000000 --- a/docker/brotli/source.sha256 +++ /dev/null @@ -1,3 +0,0 @@ -39b06802a852629132d549a7f7449dee7f435e801706714a4bc2ea2f15b28f36 brotli_1.1.0-2.dsc -10973f4b4199eafa1d5735ef661ddb2ec2f97319ee9fd1824d4aabe08cff5265 brotli_1.1.0.orig.tar.gz -3d913a3740bcad9a294007575a6beb1846beadbd62b44fb2bf9fdaeddea3236f brotli_1.1.0-2.debian.tar.xz diff --git a/docker/brotli/verify-brotli.sh b/docker/brotli/verify-brotli.sh index 67f03f778..03df394f6 100755 --- a/docker/brotli/verify-brotli.sh +++ b/docker/brotli/verify-brotli.sh @@ -1,18 +1,18 @@ #!/bin/sh # Refuses an image whose Brotli decoders lack the CVE-2026-111497 fix. The -# system decoder must be the rebuilt libbrotli1 (see build.sh), be the only -# libbrotlidec on the image and be the one every linked object loads. Node -# must be a release carrying its own fixed copy. No malformed stream is used: -# the fix's new error code identifies the fixed decoder, and a valid stream -# must still round trip. +# system decoder must be Debian's libbrotli1 at or above the trixie-security +# fix named in `version`, be the only libbrotlidec on the image and be the one +# every linked object loads. Node must be a release carrying its own fixed +# copy. No malformed stream is used: the fix's new error code identifies the +# fixed decoder, and a valid stream must still round trip. set -eu here=$(dirname "$(readlink -f "$0")") -expected=$(cat "$here/version") +minimum=$(cat "$here/version") fail() { echo "$*" >&2; exit 1; } installed=$(dpkg-query -W -f '${Version}' libbrotli1) -[ "$installed" = "$expected" ] || fail "libbrotli1 $installed is not the fixed $expected." +dpkg --compare-versions "$installed" ge "$minimum" || fail "libbrotli1 $installed is older than the fixed $minimum." library=$(readlink -f "$(dpkg -L libbrotli1 | grep '/libbrotlidec\.so\.1$')") case $(dpkg -S "$library" 2>/dev/null) in libbrotli1:*) ;; diff --git a/docker/brotli/version b/docker/brotli/version index f1380c9a6..3074ddbfb 100644 --- a/docker/brotli/version +++ b/docker/brotli/version @@ -1 +1 @@ -1.1.0-2+b7+dw1 +1.1.0-2+deb13u1 diff --git a/tests/Unit/Support/brotli_trust_test.py b/tests/Unit/Support/brotli_trust_test.py index 917016096..139d6b1a9 100644 --- a/tests/Unit/Support/brotli_trust_test.py +++ b/tests/Unit/Support/brotli_trust_test.py @@ -1,17 +1,16 @@ """The Server image's Brotli decoders carry the CVE-2026-111497 fix. -Debian 13 has no fixed libbrotli1 yet, so the image rebuilds Debian's own -brotli 1.1.0-2 with upstream's fix for the 1.1 line (docker/brotli). These -checks keep the vendored patch, the pinned sources, the package version and -the Dockerfile wiring as reviewed. The image-level check is -docker/brotli/verify-brotli.sh, which the build runs. +Debian's security team fixed CVE-2026-111497 in libbrotli1 1.1.0-2+deb13u1 +(trixie-security), so the image installs Debian's signed package over the +base image's 1.1.0-2+b7 instead of rebuilding it. These checks keep the +minimum version and the Dockerfile wiring as reviewed. The image-level check +is docker/brotli/verify-brotli.sh, which the build runs. Run with: python3 -m unittest tests/Unit/Support/brotli_trust_test.py """ from __future__ import annotations -import hashlib import os import re import subprocess @@ -21,14 +20,7 @@ ROOT = Path(__file__).resolve().parents[3] BROTLI = ROOT / "docker" / "brotli" -PATCH_SHA256 = "3c1e4587947e4c61dd9c7c4619b1ae12d6bedbfd3c33c23981bf31269e18d52c" -UPSTREAM_FIX = "da9806362874b9bd4b552a3af360b5d6347beb12" -SOURCES = { - "brotli_1.1.0-2.dsc": "39b06802a852629132d549a7f7449dee7f435e801706714a4bc2ea2f15b28f36", - "brotli_1.1.0.orig.tar.gz": "10973f4b4199eafa1d5735ef661ddb2ec2f97319ee9fd1824d4aabe08cff5265", - "brotli_1.1.0-2.debian.tar.xz": "3d913a3740bcad9a294007575a6beb1846beadbd62b44fb2bf9fdaeddea3236f", -} -VERSION = "1.1.0-2+b7+dw1" +MINIMUM = "1.1.0-2+deb13u1" def debian_compare(left: str, right: str) -> int: @@ -77,17 +69,16 @@ def stages(dockerfile: str) -> dict[str, str]: def dockerfile_problems(dockerfile: str) -> list[str]: problems = [] named = stages(dockerfile) - builder, base = named.get("brotli", ""), named.get("base", "") - image = lambda text: (re.match(r"FROM (\S+)", text) or [None, None])[1] - if not builder: - return ["no brotli builder stage"] - if image(builder) != image(base) or "@sha256:" not in (image(builder) or ""): - problems.append("the builder must use the runtime's exact pinned base") - if builder.find("verify-trust.sh system") < 0 or builder.find("verify-trust.sh system") > builder.find("build.sh"): - problems.append("the builder must check its CA store before it downloads") - install = base.find("dpkg -i /usr/src/server-brotli-deb/libbrotli1_") - if "COPY --from=brotli /out/ /usr/src/server-brotli-deb/" not in base or install < 0: - problems.append("the runtime must install the rebuilt libbrotli1") + base = named.get("base", "") + if "brotli" in named or "server-brotli-deb" in dockerfile or "server-brotli/build.sh" in dockerfile: + problems.append("the image must take Debian's fixed libbrotli1, not a rebuild") + trust = base.find("server-verify-trust system") + install = base.find("apt-get install") + package = base.find("\n libbrotli1 \\\n", install) + if install < 0 or package < 0 or base.find("&& ", install, package) >= 0: + problems.append("the runtime's package list must take libbrotli1") + if trust < 0 or install < 0 or trust > install: + problems.append("the runtime must check its CA store before it downloads") final = base.find("server-verify-trust final") if final < 0 or base.find("server-verify-brotli", final) < 0: problems.append("the runtime must verify its decoders before production downloads") @@ -102,28 +93,18 @@ def dockerfile_problems(dockerfile: str) -> list[str]: class BrotliTrustTest(unittest.TestCase): - def test_the_vendored_patch_is_upstreams_fix(self) -> None: - patch = (BROTLI / "CVE-2026-111497.patch").read_bytes() - self.assertEqual(hashlib.sha256(patch).hexdigest(), PATCH_SHA256) - text = patch.decode() - self.assertTrue(text.startswith(f"From {UPSTREAM_FIX} ")) - self.assertEqual(re.findall(r"(?m)^diff --git a/(\S+)", text), ["c/dec/decode.c", "c/include/brotli/decode.h"]) - self.assertIn("+ BROTLI_ERROR_CODE(_ERROR_FORMAT_, BLOCK_SWITCH, -17) SEPARATOR", text) - - def test_the_debian_sources_are_pinned(self) -> None: - lines = (BROTLI / "source.sha256").read_text().splitlines() - self.assertEqual({name: digest for digest, name in (line.split(" ") for line in lines)}, SOURCES) - build = (BROTLI / "build.sh").read_text() - self.assertLess(build.index('sha256sum -c "$here/source.sha256"'), build.index("dpkg-source -x")) - self.assertIn("echo CVE-2026-111497.patch >> debian/patches/series", build) - self.assertIn("apt-get source --download-only brotli=1.1.0-2", build) - - def test_the_version_replaces_trixies_and_yields_to_a_debian_fix(self) -> None: - self.assertEqual((BROTLI / "version").read_text().strip(), VERSION) - self.assertGreater(debian_compare(VERSION, "1.1.0-2+b7"), 0) - self.assertLess(debian_compare(VERSION, "1.1.0-2+deb13u1"), 0) - self.assertLess(debian_compare(VERSION, "1.1.1-1"), 0) + def test_the_minimum_is_debians_security_fix(self) -> None: + self.assertEqual((BROTLI / "version").read_text().strip(), MINIMUM) + self.assertGreater(debian_compare(MINIMUM, "1.1.0-2+b7"), 0, "above the base image's package") + self.assertGreater(debian_compare(MINIMUM, "1.1.0-2+b7+dw1"), 0, "above the retired rebuild") + self.assertLess(debian_compare(MINIMUM, "1.1.0-2+deb13u2"), 0, "a later Debian fix still passes") self.assertEqual(debian_compare("1.0~rc1-1", "1.0-1"), -1) + verify = (BROTLI / "verify-brotli.sh").read_text() + self.assertIn('dpkg --compare-versions "$installed" ge "$minimum"', verify) + + def test_no_rebuild_remains(self) -> None: + for name in ("build.sh", "source.sha256", "CVE-2026-111497.patch"): + self.assertFalse((BROTLI / name).exists(), name) def test_the_check_identifies_the_fix_without_a_malformed_stream(self) -> None: check = (BROTLI / "brotli-check.py").read_text() @@ -172,25 +153,28 @@ def inventory(root: Path, library: Path) -> subprocess.CompletedProcess: self.assertNotEqual(inventory(root, root / "usr" / "lib" / "libbrotlidec.so.1").returncode, 0, "the expected library must be the resolved file, not a link") - def test_the_dockerfile_builds_installs_and_verifies_the_fix(self) -> None: + def test_the_dockerfile_installs_and_verifies_the_fix(self) -> None: self.assertEqual(dockerfile_problems((ROOT / "Dockerfile").read_text()), []) def test_unwired_dockerfiles_fail(self) -> None: dockerfile = (ROOT / "Dockerfile").read_text() + rebuild = ("FROM php@sha256:" + "0" * 64 + " AS brotli\nRUN sh /usr/src/server-brotli/build.sh\n\n" + "# The fixed official Apache runtime") for label, mutated in [ - ("no install", dockerfile.replace(" && dpkg -i /usr/src/server-brotli-deb/libbrotli1_*.deb \\\n", "")), + ("no install", dockerfile.replace(" libbrotli1 \\\n", "")), + ("install before the trust check", dockerfile.replace( + " && server-verify-trust system \\\n && apt-get update", " && apt-get update", 1)), + ("a rebuild stage", dockerfile.replace("# The fixed official Apache runtime", rebuild, 1)), ("no runtime check", dockerfile.replace("RUN server-verify-trust final \\\n && server-verify-brotli\n", "RUN server-verify-trust final\n")), ("no final check", dockerfile.replace(" && rm -rf /var/lib/apt/lists/* \\\n && server-verify-brotli\n", " && rm -rf /var/lib/apt/lists/*\n")), ("floor after clone", dockerfile.replace('RUN apk add --no-cache --upgrade "brotli-libs>=1.2.1-r0" \\\n' ' && apk info -e "brotli-libs>=1.2.1-r0"\n\n', "")), - ("other base", re.sub(r"(?m)^(FROM php@sha256:)[0-9a-f]+( AS brotli)$", r"\g<1>" + "0" * 64 + r"\2", dockerfile)), ]: with self.subTest(label): self.assertNotEqual(mutated, dockerfile, label) self.assertNotEqual(dockerfile_problems(mutated), [], label) - if __name__ == "__main__": unittest.main() From d1c86c700425828e74ed20d6a7282d59e6fd4fc4 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:57:39 +0000 Subject: [PATCH 21/21] Compare the lock's trust crates with assertLessEqual SonarCloud python:S5906 (issue AaFpzx9JNfE_D0g1luyI). The subset check is unchanged: assertLessEqual applies the same <= to the two sets. Co-Authored-By: Claude Opus 5.5 --- tests/Unit/Support/cargo_lock_trust_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Support/cargo_lock_trust_test.py b/tests/Unit/Support/cargo_lock_trust_test.py index fd57885a6..6dc42f73e 100644 --- a/tests/Unit/Support/cargo_lock_trust_test.py +++ b/tests/Unit/Support/cargo_lock_trust_test.py @@ -126,7 +126,7 @@ def test_the_known_tls_locks_resolve_both_crates(self) -> None: for path in sorted(REQUIRED): with self.subTest(path=path): names = {name for name, _ in packages((ROOT / path).read_text(encoding="utf-8"))} - self.assertTrue(set(FLOORS) <= names, f"{path} no longer resolves the trust crates") + self.assertLessEqual(set(FLOORS), names, f"{path} no longer resolves the trust crates") def test_immutable_evidence_keeps_its_recorded_bytes(self) -> None: for path, digest in IMMUTABLE_EVIDENCE.items():