diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 444321009..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: @@ -61,10 +56,22 @@ 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@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + - 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: | - python3 -m pip install --only-binary :all: PyYAML==6.0.2 - python3 scripts/ci/test_database_tls_chart.py + 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 run: | @@ -129,13 +136,15 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ inputs.source_ref }} - 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@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 with: @@ -143,6 +152,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/.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 780197f92..fbafb0523 100644 --- a/.github/workflows/phpunit-feature.yml +++ b/.github/workflows/phpunit-feature.yml @@ -48,6 +48,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@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Install policy dependencies run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits @@ -93,13 +95,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: @@ -107,7 +114,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: @@ -124,6 +133,25 @@ 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 + + - 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 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' + run: python3 -m unittest tests/Unit/Support/composer_trust_test.py tests/Unit/Support/release_composer_audit_test.py + + - name: Prove the image 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 @@ -146,8 +174,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 @@ -201,7 +227,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..daa771a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,7 @@ 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 + run: sh scripts/ci/audit-immutable-release.sh release-source - name: Validate release publish context and credentials id: release_publish diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 3541956bb..319fe568a 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -117,36 +117,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 \ - python -m pip 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 @@ -525,8 +496,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 \ @@ -748,8 +719,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 its lane's native node, as the profiles shared the corpus's. @@ -797,8 +767,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 \ @@ -850,8 +819,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 \ @@ -1144,8 +1112,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 \ @@ -1370,8 +1338,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 its lane's native node, as the profiles shared the corpus's. @@ -1418,8 +1385,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 \ @@ -1473,8 +1439,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 \ @@ -1775,8 +1740,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/ServerParitySdkDirectionLanesTest.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/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.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/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs > parity-evidence/comparator-tests.txt @@ -1999,8 +1964,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 @@ -2044,8 +2008,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 @@ -2112,8 +2075,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/.github/workflows/server-perf.yml b/.github/workflows/server-perf.yml index 3a1a41888..deacfc226 100644 --- a/.github/workflows/server-perf.yml +++ b/.github/workflows/server-perf.yml @@ -174,6 +174,22 @@ 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@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. + - 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: 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 @@ -183,15 +199,16 @@ 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 rm -rf benchmarks/capacity/v1/bindings/php/vendor - python3 -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 \ @@ -217,7 +234,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/Dockerfile b/Dockerfile index 9daf44b38..9ae79b249 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)" \ @@ -31,6 +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 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/ # Published conformance runners execute from the exact Server image. # Keep their Node and Python runtimes available alongside the service. Node @@ -41,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 \ @@ -48,6 +58,7 @@ RUN chmod 0755 /usr/local/lib/server-trust/verify-trust.sh \ python3-venv \ unzip \ util-linux \ + && 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 \ @@ -56,8 +67,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 @@ -102,7 +115,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/benchmarks/capacity/v1/README.md b/benchmarks/capacity/v1/README.md index 713d018d4..dc3042337 100644 --- a/benchmarks/capacity/v1/README.md +++ b/benchmarks/capacity/v1/README.md @@ -62,9 +62,15 @@ 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 -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) +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/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 7bfab2985..104fceeb3 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", @@ -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 -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", - "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/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/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/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/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 new file mode 100755 index 000000000..03df394f6 --- /dev/null +++ b/docker/brotli/verify-brotli.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Refuses an image whose Brotli decoders lack the CVE-2026-111497 fix. The +# 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")") +minimum=$(cat "$here/version") +fail() { echo "$*" >&2; exit 1; } + +installed=$(dpkg-query -W -f '${Version}' libbrotli1) +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:*) ;; + *) fail "$library is not owned by libbrotli1." ;; +esac + +# 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 + [ "$(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..3074ddbfb --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.1.0-2+deb13u1 diff --git a/scripts/benchmark/capacity_suite.py b/scripts/benchmark/capacity_suite.py index 8eb350e59..17b9f6c48 100755 --- a/scripts/benchmark/capacity_suite.py +++ b/scripts/benchmark/capacity_suite.py @@ -1171,42 +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" - ) - locked_requirements = ( - (adapter_root / "requirements.lock").read_text().splitlines() - ) - if f"durable-workflow=={expected_version}" not in locked_requirements or any( - "==" not in requirement - for requirement in locked_requirements - if requirement - ): - raise ContractError( - "Python adapter lock must pin its complete dependency graph" - ) - 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: 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/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/brotli-safe-composer.sh b/scripts/ci/brotli-safe-composer.sh new file mode 100755 index 000000000..7fb4c3c8b --- /dev/null +++ b/scripts/ci/brotli-safe-composer.sh @@ -0,0 +1,49 @@ +#!/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() { + 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 + 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/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/patched-pip.sh b/scripts/ci/patched-pip.sh new file mode 100755 index 000000000..cb6553f9d --- /dev/null +++ b/scripts/ci/patched-pip.sh @@ -0,0 +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, pip's exit +# status is kept, and the temporary venv is removed afterwards. +# +# Usage: patched-pip.sh install ARGS... +set -eu + +here=$(dirname "$0") +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" -I -m pip "$@" || status=$? +exit "$status" 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/ci/run-composer.sh b/scripts/ci/run-composer.sh new file mode 100644 index 000000000..ed48f33d7 --- /dev/null +++ b/scripts/ci/run-composer.sh @@ -0,0 +1,47 @@ +#!/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* | --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 +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/activities-published-artifacts.sh b/scripts/conformance/activities-published-artifacts.sh index 5e95a351a..d4e80af87 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 \ @@ -231,13 +253,14 @@ prepare_focused_python_sdk() { local distribution mkdir -p "$distribution_dir" if python3 -m venv "$venv" >/dev/null 2>"$install_log" \ - && "$venv/bin/python" -m pip download --disable-pip-version-check --no-deps \ + && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ + && "$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 @@ -364,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/child-workflows-install-probe.py b/scripts/conformance/child-workflows-install-probe.py index 45fa9a115..02a9ed9e4 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" - 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) + # 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), "-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) 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/child-workflows-published-artifacts.sh b/scripts/conformance/child-workflows-published-artifacts.sh index 944a7feeb..ab6bff9b8 100755 --- a/scripts/conformance/child-workflows-published-artifacts.sh +++ b/scripts/conformance/child-workflows-published-artifacts.sh @@ -244,7 +244,8 @@ 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 \ - && "$venv/bin/python" -m pip install --disable-pip-version-check --no-input "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$install_log" 2>&1; then + && sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$venv/bin/python" >>"$install_log" 2>&1 \ + && "$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/heartbeats-published-artifacts.mjs b/scripts/conformance/heartbeats-published-artifacts.mjs index 3da0c40b5..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'); @@ -1404,6 +1405,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 +1421,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 +1436,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/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/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 ea30df993..583ad15ac 100755 --- a/scripts/conformance/namespaces-published-artifacts.sh +++ b/scripts/conformance/namespaces-published-artifacts.sh @@ -511,11 +511,11 @@ 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 +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 @@ -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 4e88eedae..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, @@ -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); @@ -2278,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), @@ -2285,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 e9d3dbf25..e4fce558e 100755 --- a/scripts/conformance/principal-attribution-published-artifacts.sh +++ b/scripts/conformance/principal-attribution-published-artifacts.sh @@ -837,8 +837,15 @@ 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), + "-I", "-m", "pip", "install", @@ -889,7 +896,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, ] @@ -1017,7 +1024,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 @@ -1405,7 +1412,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 @@ -1417,7 +1424,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" \ @@ -1438,7 +1445,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 @@ -1455,7 +1462,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 @@ -1473,7 +1480,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 \ @@ -2129,7 +2136,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 @@ -2156,7 +2163,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 a2c7c53bb..3066dce16 100755 --- a/scripts/conformance/python-published-artifacts.sh +++ b/scripts/conformance/python-published-artifacts.sh @@ -474,9 +474,10 @@ 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 \ + || 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 \ @@ -484,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 @@ -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 bea69c424..10e59d133 100755 --- a/scripts/conformance/replay-published-artifacts.sh +++ b/scripts/conformance/replay-published-artifacts.sh @@ -1651,12 +1651,18 @@ 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" \ - "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 -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 + 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" \ @@ -1664,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" @@ -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 2524f8586..2bfdc7d9e 100755 --- a/scripts/conformance/sagas-published-artifacts.sh +++ b/scripts/conformance/sagas-published-artifacts.sh @@ -1102,11 +1102,11 @@ 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 -python -m pip install "durable-workflow==$python_version" httpx +sh "$script_dir/../ci/bootstrap-patched-pip.sh" "$run_root/.venv/bin/python" +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 \ +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 1dd2f7c1e..36a5935ee 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 @@ -1348,7 +1350,7 @@ async function resolveLatestPublishedPhpSdkVersion() { const command = [ 'run', '--rm', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', 'show', '--all', '--format=json', @@ -2053,10 +2055,10 @@ 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}`], + ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], path.join(resultDir, 'schedules-python-lifecycle-install.log'), ); markArtifactSource(artifactSources, 'sdk-python', pythonPackageArtifactSource(pythonVersion), artifactVersions); @@ -6694,10 +6696,10 @@ 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}`], + ['-I', '-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], path.join(resultDir, 'schedules-cross-language-python-install.log'), ); markArtifactSource(artifactSources, 'sdk-python', pythonPackageArtifactSource(pythonVersion), artifactVersions); @@ -6727,7 +6729,7 @@ async function installSchedulesPhpArtifact( `${phpRoot}:/app`, '-w', '/app', - 'composer:2', + 'composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee', 'require', '--no-interaction', '--no-progress', @@ -6748,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') ?: '';", ], @@ -6886,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, @@ -7889,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-adapters.sh b/scripts/conformance/server-parity/corrected-marker-adapters.sh index 677e49f88..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,20 +24,20 @@ 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 \ -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/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/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-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/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/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/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/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-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/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/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/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/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/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/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/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/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/scripts/conformance/signals-queries-published-artifacts.sh b/scripts/conformance/signals-queries-published-artifacts.sh index de3a26b99..ce8118834 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 @@ -1892,15 +1891,18 @@ 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) download = run_command( [ str(python_bin), + "-I", "-m", "pip", "download", @@ -1917,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, ) @@ -1973,7 +1975,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): @@ -13779,6 +13782,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(), @@ -13787,6 +13791,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, @@ -13913,6 +13935,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 e4f766c06..c293dde53 100755 --- a/scripts/conformance/skew-published-artifacts.sh +++ b/scripts/conformance/skew-published-artifacts.sh @@ -473,8 +473,8 @@ 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 \ - && "$run_root/.venv/bin/python" -m pip install "durable-workflow==${DW_PYTHON_SDK_VERSION}" >>"$result_dir/python-install.log" 2>&1; then + && 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" -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" @@ -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-mixed.Dockerfile b/scripts/conformance/worker-versioning-mixed.Dockerfile index 0cd59e52e..2d5550981 100644 --- a/scripts/conformance/worker-versioning-mixed.Dockerfile +++ b/scripts/conformance/worker-versioning-mixed.Dockerfile @@ -1,15 +1,30 @@ -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 +# 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. +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-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/worker-versioning-published-workers.mjs b/scripts/conformance/worker-versioning-published-workers.mjs index f26962957..4b7e8c568 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; @@ -190,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', @@ -376,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', @@ -1123,8 +1128,8 @@ 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(pythonBin, ['-m', 'pip', 'install', `durable-workflow==${pythonVersion}`], { + runRequired('sh', [PATCHED_PIP_BOOTSTRAP, pythonBin], { logPath: installLog, append: true }); + runRequired(pythonBin, ['-I', '-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, @@ -1534,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/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..5f4bdd11d 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" -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 123d987f2..56449c77a 100755 --- a/scripts/conformance/workflow-updates-published-artifacts.sh +++ b/scripts/conformance/workflow-updates-published-artifacts.sh @@ -3023,12 +3023,19 @@ 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 + 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 - if ! PIP_CONFIG_FILE=/dev/null "$venv_python" -m pip --isolated install \ + # --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" -I -m pip --isolated install \ + --use-deprecated=legacy-certs \ --disable-pip-version-check \ --no-input \ --index-url https://pypi.org/simple \ @@ -4254,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/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/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/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", 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 diff --git a/tests/Integration/StorageAdmission/sdk/Dockerfile b/tests/Integration/StorageAdmission/sdk/Dockerfile index f82d6d71b..b583aafb5 100644 --- a/tests/Integration/StorageAdmission/sdk/Dockerfile +++ b/tests/Integration/StorageAdmission/sdk/Dockerfile @@ -1,6 +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 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 +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 \ @@ -13,10 +29,17 @@ 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/ 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/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 b404d7c95..bf053cbec 100644 --- a/tests/Unit/PhpunitFeatureWorkflowContractTest.php +++ b/tests/Unit/PhpunitFeatureWorkflowContractTest.php @@ -161,6 +161,10 @@ 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', + '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/ReleaseImagePublishWorkflowContractTest.php b/tests/Unit/ReleaseImagePublishWorkflowContractTest.php index 6854d5c27..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( - 'composer audit --working-dir=release-source --locked --no-dev --no-interaction', + 'sh scripts/ci/audit-immutable-release.sh release-source', $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 9db137392..1ff884589 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); @@ -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.'); @@ -780,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="" @@ -1321,6 +1339,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/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/brotli_trust_test.py b/tests/Unit/Support/brotli_trust_test.py new file mode 100644 index 000000000..139d6b1a9 --- /dev/null +++ b/tests/Unit/Support/brotli_trust_test.py @@ -0,0 +1,180 @@ +"""The Server image's Brotli decoders carry the CVE-2026-111497 fix. + +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 os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +BROTLI = ROOT / "docker" / "brotli" +MINIMUM = "1.1.0-2+deb13u1" + + +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) + 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") + 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_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() + 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", "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_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(" 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', "")), + ]: + 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/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/cargo_lock_trust_test.py b/tests/Unit/Support/cargo_lock_trust_test.py new file mode 100644 index 000000000..6dc42f73e --- /dev/null +++ b/tests/Unit/Support/cargo_lock_trust_test.py @@ -0,0 +1,211 @@ +"""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. + +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 +""" + +from __future__ import annotations + +import hashlib +import os +import re +import tomllib +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"} + +# 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"(?:-(" + PRERELEASE_IDENTIFIER + r"(?:\." + PRERELEASE_IDENTIFIER + r")*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) + + +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 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 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 + + +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 packages((ROOT / path).read_text(encoding="utf-8"))} + 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(): + 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_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: + # 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): + 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 + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "fixtures/new/rust").mkdir(parents=True) + (root / "fixtures/new/rust/Cargo.lock").write_text( + '[[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)) + 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() diff --git a/tests/Unit/Support/cargo_trust_test.py b/tests/Unit/Support/cargo_trust_test.py new file mode 100644 index 000000000..3de615c92 --- /dev/null +++ b/tests/Unit/Support/cargo_trust_test.py @@ -0,0 +1,168 @@ +"""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 re +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 ") + 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 new file mode 100644 index 000000000..b81a9fc9e --- /dev/null +++ b/tests/Unit/Support/composer-sinks.json @@ -0,0 +1,1479 @@ +{ + "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.", + "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.", + "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.", + "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`.)", + "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.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, + { + "path": ".github/workflows/server-parity.yml", + "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "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" + }, + { + "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 \\", + "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": "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.", + "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.", + "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.", + "guards": [ + { + "type": "server-verify", + "line": "RUN server-verify-trust final \\", + "distance": 50 + } + ] + }, + { + "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.", + "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.", + "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.", + "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.", + "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.", + "guards": [ + { + "type": "pinned-reference", + "line": null, + "distance": null + } + ] + }, + { + "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", + "count": 1, + "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.", + "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/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", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "guards": [ + { + "type": "documentation", + "line": null, + "distance": null + } + ] + }, + { + "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", + "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.", + "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.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 7 + } + ] + }, + { + "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", + "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.", + "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.", + "guards": [ + { + "type": "trust-guard", + "line": "sh \"$(dirname \"$0\")/../../ci/composer-trust-guard.sh\" >&2", + "distance": 4 + } + ] + }, + { + "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\",", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "guards": [ + { + "type": "checked-ca", + "line": "ENV COMPOSER_CAFILE=/etc/composer-trust/ca.pem COMPOSER_CAPATH=/etc/composer-trust/no-ca-directory", + "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/composer_trust_test.py b/tests/Unit/Support/composer_trust_test.py new file mode 100644 index 000000000..7727117de --- /dev/null +++ b/tests/Unit/Support/composer_trust_test.py @@ -0,0 +1,415 @@ +"""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 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" +# 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"""(? 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]]: + 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]]: + 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 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): + 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", + # 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) + (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) + + +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"], ["-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") + + 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_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"):] + 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()) diff --git a/tests/Unit/Support/patched_pip_test.py b/tests/Unit/Support/patched_pip_test.py new file mode 100644 index 000000000..e2f70b01f --- /dev/null +++ b/tests/Unit/Support/patched_pip_test.py @@ -0,0 +1,575 @@ +"""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, 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 json +import os +import re +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" +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 +""" + +SEPARATOR = r"""[\s'",]+""" +# 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)@""" +) +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" + + +# 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()] + 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 + 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]]: + 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: + stem = directory / re.sub(r"\W+", "-", common_name) + 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() + + +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") + 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) + environment = { + **os.environ, + "FAKE_LOG": str(self.work / "calls.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("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 _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 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.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.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() + + + 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.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) + + 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) + + 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 + # 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(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}") + + self.assertEqual([], loose) + + +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..aa2dc8d46 --- /dev/null +++ b/tests/Unit/Support/pip-sinks.json @@ -0,0 +1,529 @@ +{ + "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": [ + { + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "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.", + "guards": [ + { + "type": "accepted-action", + "line": null, + "distance": null + } + ] + }, + { + "path": "benchmarks/capacity/v1/README.md", + "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. 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 -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. 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.", + "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.", + "guards": [ + { + "type": "comment", + "line": null, + "distance": null + } + ] + }, + { + "path": "scripts/ci/patched-pip.sh", + "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). 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\" -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). 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\" -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). 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), \"-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). 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\" -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). 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\" -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). 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).", + "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).", + "guards": [ + { + "type": "bootstrap", + "line": "code, output = run(bootstrap_command, timeout=180)", + "distance": 7 + } + ] + }, + { + "path": "scripts/conformance/python-published-artifacts.sh", + "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). 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 -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). 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 -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). 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 -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). 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 -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). 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": "['-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). 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.", + "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).", + "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), \"-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). 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\" -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). 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.", + "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 -I -m pip install durable-workflow==${pythonVersion}`,", + "count": 3, + "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, ['-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). 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\" -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). 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\" -I -m pip --version > \"$result_dir/python-sdk-pip-version.log\" 2>&1; then", + "count": 1, + "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\" -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. 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).", + "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).", + "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).", + "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/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() 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/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 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(