diff --git a/.devcontainer/docker/Dockerfile b/.devcontainer/docker/Dockerfile index 1f6c80a5..4a7c4b16 100644 --- a/.devcontainer/docker/Dockerfile +++ b/.devcontainer/docker/Dockerfile @@ -1,10 +1,10 @@ -FROM node:22-bookworm-slim AS node +FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS node -FROM composer:2 AS composer +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer -FROM docker:27.5.1-cli AS docker-cli +FROM docker:27.5.1-cli@sha256:851f91d241214e7c6db86513b270d58776379aacc5eb9c4a87e5b47115e3065c AS docker-cli -FROM rust:1.86.0-slim-bookworm AS rust +FROM rust:1.86.0-slim-bookworm@sha256:57d415bbd61ce11e2d5f73de068103c7bd9f3188dc132c97cef4a8f62989e944 AS rust RUN set -eux; \ rm -rf /usr/local/rustup/downloads /usr/local/rustup/tmp; \ @@ -14,7 +14,7 @@ RUN set -eux; \ rustc --version; \ cargo --version -FROM mariadb:11.4 AS mysql-seed +FROM mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873 AS mysql-seed ENV MYSQL_ROOT_PASSWORD=password \ MYSQL_ROOT_HOST=% \ @@ -84,7 +84,7 @@ RUN set -eux; \ .; \ test -s /tmp/sample-app-mysql-seed.tar -FROM php:8.4-cli-bookworm +FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 ARG VCS_REF=unknown ARG DURABLE_WORKFLOW_CLI_VERSION @@ -117,8 +117,11 @@ COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr COPY --from=rust /usr/local/cargo /usr/local/cargo COPY --from=rust /usr/local/rustup /usr/local/rustup COPY docker/install-phpredis.sh /usr/local/bin/install-phpredis +COPY .devcontainer/docker/verify-php-trust.php .devcontainer/docker/verify-node-trust.mjs /usr/local/share/sample-app/ RUN set -eux; \ + php -r 'exit(PHP_MAJOR_VERSION === 8 && PHP_MINOR_VERSION === 4 && PHP_VERSION_ID >= 80427 ? 0 : 1);'; \ + php /usr/local/share/sample-app/verify-php-trust.php "$(command -v composer)"; \ ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime; \ echo "${TZ}" > /etc/timezone; \ apt-get update; \ @@ -228,6 +231,8 @@ RUN set -eux; \ COPY polyglot/qualified-artifact-tuple.json /usr/local/share/sample-app/qualified-artifact-tuple.json RUN set -eux; \ + php /usr/local/share/sample-app/verify-php-trust.php "$(command -v composer)"; \ + node /usr/local/share/sample-app/verify-node-trust.mjs; \ cli_version="${DURABLE_WORKFLOW_CLI_VERSION:-$(php -r '$tuple = json_decode(file_get_contents("/usr/local/share/sample-app/qualified-artifact-tuple.json"), true, flags: JSON_THROW_ON_ERROR); echo $tuple["artifacts"]["cli"];')}"; \ test -n "$cli_version"; \ curl -fsSL https://durable-workflow.com/install.sh -o /tmp/dw-install.sh; \ @@ -238,11 +243,22 @@ RUN set -eux; \ dw --version COPY playground/templates/python/requirements.txt /tmp/sample-app-python-playground-requirements.txt +COPY .devcontainer/docker/verify-python-trust.py /usr/local/share/sample-app/verify-python-trust.py RUN set -eux; \ python_sdk_version="$(php -r '$tuple = json_decode(file_get_contents("/usr/local/share/sample-app/qualified-artifact-tuple.json"), true, flags: JSON_THROW_ON_ERROR); echo $tuple["artifacts"]["sdk-python"];')"; \ python_distribution_version="$(printf '%s' "$python_sdk_version" | sed 's/-rc\./rc/')"; \ python3 -m venv "$VIRTUAL_ENV"; \ + curl --proto '=https' --proto-redir '=https' -fsSL --max-time 120 \ + https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl \ + -o /tmp/pip-26.2.2-py3-none-any.whl; \ + printf '%s %s\n' \ + fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70 \ + /tmp/pip-26.2.2-py3-none-any.whl | sha256sum --check --strict -; \ + "$VIRTUAL_ENV/bin/python" -I -m pip --isolated --disable-pip-version-check install \ + --no-index --no-deps --upgrade /tmp/pip-26.2.2-py3-none-any.whl; \ + rm /tmp/pip-26.2.2-py3-none-any.whl; \ + "$VIRTUAL_ENV/bin/python" /usr/local/share/sample-app/verify-python-trust.py --pip-only; \ "$VIRTUAL_ENV/bin/pip" install \ --no-input \ --no-cache-dir \ diff --git a/.devcontainer/docker/verify-image.sh b/.devcontainer/docker/verify-image.sh index d0ce87e7..6a275fc8 100755 --- a/.devcontainer/docker/verify-image.sh +++ b/.devcontainer/docker/verify-image.sh @@ -3,17 +3,20 @@ set -euo pipefail [[ "$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')" == "8.4" ]] -[[ "$(with-disposable-composer-state composer --no-ansi --version | awk '{print $3}' | cut -d. -f1)" == "2" ]] -[[ "$(node --version | sed -E 's/^v([0-9]+).*/\1/')" == "22" ]] +php /usr/local/share/sample-app/verify-php-trust.php "$(command -v composer)" +node /usr/local/share/sample-app/verify-node-trust.mjs python -c 'import sys; assert sys.version_info >= (3, 10)' +python /usr/local/share/sample-app/verify-python-trust.py python -c 'import durable_workflow' python -m venv --help >/dev/null -rust_version="$(rustc --version | sed -E 's/^rustc ([0-9]+\.[0-9]+).*/\1/')" -[[ "$(printf '%s\n' 1.86 "$rust_version" | sort -V | head -n 1)" == "1.86" ]] +[[ "$(rustc --version | awk '{print $2}')" == "1.86.0" ]] +[[ "$(cargo --version | awk '{print $2}')" == "1.86.0" ]] +[[ "$(docker --version | sed -E 's/^Docker version ([^,]+),.*/\1/')" == "27.5.1" ]] +compose_version="$(docker compose version --short)" +[[ "${compose_version#v}" == "2.33.0" ]] cli_version="${DURABLE_WORKFLOW_CLI_VERSION:-$(php -r '$tuple = json_decode(file_get_contents("/usr/local/share/sample-app/qualified-artifact-tuple.json"), true, flags: JSON_THROW_ON_ERROR); echo $tuple["artifacts"]["cli"];')}" [[ -n "$cli_version" ]] [[ "$(dw --version | awk '$1 == "dw" { print $2 }')" == "$cli_version" ]] -docker compose version >/dev/null for extension in bcmath curl gd intl mbstring pcntl pdo_mysql pdo_sqlite redis zip; do php --ri "$extension" >/dev/null diff --git a/.devcontainer/docker/verify-node-trust.mjs b/.devcontainer/docker/verify-node-trust.mjs new file mode 100644 index 00000000..c47e2a0e --- /dev/null +++ b/.devcontainer/docker/verify-node-trust.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { X509Certificate } from 'node:crypto'; +import { rootCertificates } from 'node:tls'; + +const [major, minor, patch] = process.versions.node.split('.').map(Number); +assert(major === 22 && (minor > 23 || (minor === 23 && patch >= 4)), 'Node 22.23.4 or newer on the 22 line is required'); +const blocked = new Set(['CN=AAA Certificate Services', 'CN=emSign ECC Root CA - G3']); +assert(rootCertificates.length > 0, 'Node root store is empty'); +for (const pem of rootCertificates) { + const subject = new X509Certificate(pem).subject.split('\n'); + assert(!subject.some(component => blocked.has(component)), 'Blocked certificate in Node embedded trust'); +} +console.log(`Verified Node ${process.versions.node} embedded CA roots`); diff --git a/.devcontainer/docker/verify-php-trust.php b/.devcontainer/docker/verify-php-trust.php new file mode 100644 index 00000000..36da8d26 --- /dev/null +++ b/.devcontainer/docker/verify-php-trust.php @@ -0,0 +1,34 @@ +> "$GITHUB_ENV" fi + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22.23.4' + - name: Verify Node before running application tooling + run: node docker/verify-node-trust.mjs + - name: Run application tests env: DB_CONNECTION: mysql @@ -133,13 +155,26 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - name: Select authenticated dependency trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + clients: pip,python,composer,curl,git + - name: Set up PHP uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 with: php-version: ${{ matrix.php }} extensions: mbstring, intl, pdo_mysql, bcmath, gd, zip coverage: none - tools: composer:v2 + tools: composer:2.10.3 + + - name: Verify actual Composer trust before dependency requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + with: + working-directory: microservice - name: Install and test env: @@ -177,6 +212,8 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.13' + - name: Bootstrap patched pip trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits run: >- diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 7ac05cb2..11f39906 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -12,16 +12,41 @@ permissions: jobs: compose: - name: docker compose sample workflows - runs-on: ubuntu-latest + name: docker compose sample workflows (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + platform: linux/amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 env: - COMPOSE_PROJECT_NAME: sample-app-smoke-${{ github.run_id }}-${{ github.run_attempt }} + COMPOSE_PROJECT_NAME: sample-app-smoke-${{ strategy.job-index }}-${{ github.run_id }}-${{ github.run_attempt }} APP_PORT: '0' + SAMPLE_APP_COMMIT: ${{ github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Build and start stack - run: docker compose up -d --build --wait app worker + - name: Build the production input without a prior cache + run: | + test "$(git rev-parse HEAD)" = "$SAMPLE_APP_COMMIT" + docker compose build --pull --no-cache app + - name: Start the qualified build + run: docker compose up -d --wait app worker + - name: Verify the copied runtime and browser + env: + EXPECTED_PLATFORM: ${{ matrix.platform }} + run: | + image_id="$(docker compose images -q app)" + test "$(docker image inspect "$image_id" --format '{{.Os}}/{{.Architecture}}')" = "$EXPECTED_PLATFORM" + test "$(docker image inspect "$image_id" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$SAMPLE_APP_COMMIT" + docker image inspect "$image_id" --format 'image={{.Id}} platform={{.Os}}/{{.Architecture}} revision={{index .Config.Labels "org.opencontainers.image.revision"}}' + docker compose exec -T app node docker/verify-node-trust.mjs + docker compose exec -T app node --test tests/Node/ProductionNodeTrustTest.mjs + docker compose exec -T app php /usr/local/share/sample-app/verify-runtime-trust.php /usr/bin/composer + docker compose exec -T app node docker/playwright-smoke.js - name: Run embedded workflow samples run: scripts/compose-smoke.sh - name: Dump logs @@ -30,3 +55,15 @@ jobs: - name: Tear down stack if: always() run: docker compose down -v --remove-orphans + + qualify: + name: docker compose sample workflows + needs: [compose] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Require both native image jobs + env: + IMAGE_RESULT: ${{ needs.compose.result }} + run: test "$IMAGE_RESULT" = success diff --git a/Dockerfile b/Dockerfile index 62815d1d..2685d266 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,19 @@ -FROM php:8.4-cli AS base +FROM composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee AS composer +FROM php:8.4.27-cli-bookworm@sha256:496b5e02fe9fe44fcfb715023b931048111dc4e0bc719789d83c906be9f04b05 AS base + +COPY --from=composer /usr/bin/composer /usr/bin/composer COPY docker/install-phpredis.sh /usr/local/bin/install-phpredis +COPY docker/verify-runtime-trust.php docker/verify-node-trust.mjs /usr/local/share/sample-app/ -RUN apt-get update && apt-get install -y \ - curl ffmpeg libnspr4 libnss3 libpq-dev libzip-dev unzip git \ +RUN php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ + && apt-get update && apt-get install -y --no-install-recommends \ + curl ffmpeg git libnspr4 libnss3 libpq-dev libzip-dev unzip \ && docker-php-ext-install pdo pdo_mysql pcntl zip bcmath \ && sh /usr/local/bin/install-phpredis \ + && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ && rm -rf /var/lib/apt/lists/* -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - WORKDIR /app # ── Dependencies ───────────────────────────────────────── @@ -25,17 +29,19 @@ ARG SAMPLE_APP_COMMIT= COPY composer.json composer.lock ./ COPY scripts/install-composer-artifacts.sh /usr/local/bin/install-composer-artifacts -RUN bash /usr/local/bin/install-composer-artifacts +RUN php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ + && bash /usr/local/bin/install-composer-artifacts COPY . . RUN composer dump-autoload --optimize # ── Frontend assets ────────────────────────────────────── -FROM node:22-slim AS assets +FROM node:22.23.4-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS assets WORKDIR /app +COPY docker/verify-node-trust.mjs /usr/local/share/sample-app/verify-node-trust.mjs COPY package.json package-lock.json* ./ -RUN npm ci +RUN node /usr/local/share/sample-app/verify-node-trust.mjs && npm ci --ignore-scripts COPY resources/ resources/ COPY vite.config.js ./ @@ -46,6 +52,7 @@ FROM base AS production ARG SAMPLE_APP_COMMIT= ENV SAMPLE_APP_COMMIT=${SAMPLE_APP_COMMIT} +LABEL org.opencontainers.image.revision="${SAMPLE_APP_COMMIT}" COPY --from=vendor /app /app COPY --from=assets /usr/local/bin/node /usr/local/bin/node @@ -54,17 +61,16 @@ COPY --from=assets /app/node_modules /app/node_modules COPY --from=assets /app/public/build /app/public/build COPY .env.example /app/.env.example -RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ +RUN node /usr/local/share/sample-app/verify-node-trust.mjs \ + && ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ && npx playwright install --with-deps chromium \ - && node docker/playwright-smoke.js - -# Create .env so artisan commands work at build time -RUN cp .env.example .env 2>/dev/null || echo "APP_KEY=" > .env -RUN php artisan key:generate --force - -# Publish Waterline assets -RUN php artisan vendor:publish --tag=waterline-assets --force 2>/dev/null || true + && node docker/playwright-smoke.js \ + && node /usr/local/share/sample-app/verify-node-trust.mjs \ + && php /usr/local/share/sample-app/verify-runtime-trust.php "$(command -v composer)" \ + && (cp .env.example .env 2>/dev/null || echo "APP_KEY=" > .env) \ + && php artisan key:generate --force \ + && (php artisan vendor:publish --tag=waterline-assets --force 2>/dev/null || true) COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint RUN chmod +x /usr/local/bin/app-entrypoint diff --git a/docker/verify-node-trust.mjs b/docker/verify-node-trust.mjs new file mode 100644 index 00000000..d7112711 --- /dev/null +++ b/docker/verify-node-trust.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { X509Certificate } from 'node:crypto'; +import * as tls from 'node:tls'; + +const [major, minor, patch] = process.versions.node.split('.').map(Number); +assert(major === 22 && (minor > 23 || (minor === 23 && patch >= 4)), 'Node 22.23.4 or newer on the 22 line is required'); +assert.notEqual(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0', 'Node TLS verification must remain enabled'); +assert.equal(typeof tls.getCACertificates, 'function', 'Node must expose its effective default CA store'); + +const certificates = tls.getCACertificates('default'); +assert(certificates.length > 0, 'Node default CA store is empty'); +const blocked = new Set(['AAA Certificate Services', 'emSign ECC Root CA - G3']); +for (const pem of certificates) { + const certificate = new X509Certificate(pem); + const commonNames = certificate.toLegacyObject().subject.CN; + for (const commonName of Array.isArray(commonNames) ? commonNames : [commonNames]) { + assert(!blocked.has(commonName), 'Blocked certificate in the effective Node default CA store'); + } +} +console.log(JSON.stringify({ node: process.versions.node, architecture: process.arch, default_roots: certificates.length, blocked_roots: 0 })); diff --git a/docker/verify-runtime-trust.php b/docker/verify-runtime-trust.php new file mode 100644 index 00000000..456bc3ae --- /dev/null +++ b/docker/verify-runtime-trust.php @@ -0,0 +1,67 @@ +merge(['config' => $manifest['config'] ?? []], 'project'); +} +if ($config->get('disable-tls') || !$config->get('secure-http')) { + throw new ProductionTrustRefused('Composer TLS and secure HTTP must remain enabled.'); +} +$locations = openssl_get_cert_locations(); +$paths = array_filter([ + '/etc/ssl/certs/ca-certificates.crt', $locations['default_cert_file'], + getenv('SSL_CERT_FILE'), ini_get('openssl.cafile'), + $config->get('cafile') ?: Composer\CaBundle\CaBundle::getSystemCaRootBundlePath(), +]); +$directories = array_filter([ + $locations['default_cert_dir'], getenv('SSL_CERT_DIR'), + ini_get('openssl.capath'), $config->get('capath'), +]); +foreach (array_unique($directories) as $directory) { + if (!is_dir($directory)) { + throw new ProductionTrustRefused('Cannot inspect an effective CA directory.'); + } + foreach (['*.pem', '*.crt', '[a-f0-9]*.[0-9]'] as $pattern) { + $paths = array_merge($paths, glob($directory.'/'.$pattern) ?: []); + } +} +$bundles = 0; +foreach (array_unique($paths) as $path) { + inspectSampleCaBundle($path); + $bundles++; +} +echo json_encode(['php' => PHP_VERSION, 'composer' => Composer\Composer::VERSION, 'inspected_stores' => $bundles, 'blocked_roots' => 0], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/tests/Node/ProductionNodeTrustTest.mjs b/tests/Node/ProductionNodeTrustTest.mjs new file mode 100644 index 00000000..2f0dce01 --- /dev/null +++ b/tests/Node/ProductionNodeTrustTest.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const verifier = fileURLToPath(new URL('../../docker/verify-node-trust.mjs', import.meta.url)); +const syntheticRoot = fileURLToPath(new URL('../fixtures/security/withdrawn-test-root.pem', import.meta.url)); +const cleanEnvironment = { ...process.env }; +delete cleanEnvironment.NODE_EXTRA_CA_CERTS; +delete cleanEnvironment.NODE_OPTIONS; +delete cleanEnvironment.NODE_TLS_REJECT_UNAUTHORIZED; + +function invoke(environment = {}, preload = '') { + const arguments_ = preload ? ['--import', `data:text/javascript,${encodeURIComponent(preload)}`] : []; + return spawnSync(process.execPath, [...arguments_, verifier], { + env: { ...cleanEnvironment, ...environment }, encoding: 'utf8', timeout: 10_000, + }); +} + +test('the actual fixed runtime exposes a nonempty clean default store', () => { + const result = invoke(); + assert.equal(result.status, 0, result.stderr); + const evidence = JSON.parse(result.stdout); + assert.equal(evidence.node, process.versions.node); + assert(evidence.default_roots > 0); + assert.equal(evidence.blocked_roots, 0); +}); + +test('a blocked extra root is detected in the actual child default store', () => { + const result = invoke({ NODE_EXTRA_CA_CERTS: syntheticRoot }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Blocked certificate in the effective Node default CA store/); +}); + +test('a changed default store is inspected rather than only the bundled roots', () => { + const certificate = readFileSync(syntheticRoot, 'utf8'); + const result = invoke({}, `import { setDefaultCACertificates } from 'node:tls'; setDefaultCACertificates([${JSON.stringify(certificate)}]);`); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Blocked certificate in the effective Node default CA store/); +}); + +test('an empty default store cannot produce a false clean result', () => { + const result = invoke({}, "import { setDefaultCACertificates } from 'node:tls'; setDefaultCACertificates([]);"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Node default CA store is empty/); +}); + +test('TLS verification cannot be disabled', () => { + const result = invoke({ NODE_TLS_REJECT_UNAUTHORIZED: '0' }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Node TLS verification must remain enabled/); +}); diff --git a/tests/Unit/DevcontainerImageContractTest.php b/tests/Unit/DevcontainerImageContractTest.php index e879b50d..f27b6bc8 100644 --- a/tests/Unit/DevcontainerImageContractTest.php +++ b/tests/Unit/DevcontainerImageContractTest.php @@ -65,6 +65,31 @@ public function test_qualification_runs_the_real_codespaces_startup_once(): void $this->assertStringNotContainsString('evidence_type', $script); } + public function test_image_checks_effective_trust_before_network_installation(): void + { + $dockerfile = (string) file_get_contents($this->path('.devcontainer/docker/Dockerfile')); + preg_match_all('/^FROM (\S+)/mi', $dockerfile, $inputs); + $this->assertNotEmpty($inputs[1]); + foreach ($inputs[1] as $input) { + $this->assertMatchesRegularExpression('/^[^@]+:[^@]+@sha256:[a-f0-9]{64}$/D', $input); + } + $runtime = substr($dockerfile, strrpos($dockerfile, "\nFROM ")); + $verifier = 'php /usr/local/share/sample-app/verify-php-trust.php'; + $firstCheck = strpos($runtime, $verifier); + $firstDownload = strpos($runtime, 'apt-get update'); + $lastInstall = strrpos($runtime, 'apt-get install'); + $recheck = strrpos($runtime, $verifier); + $nodeCheck = strpos($runtime, 'node /usr/local/share/sample-app/verify-node-trust.mjs'); + $cliDownload = strpos($runtime, 'curl -fsSL'); + + foreach ([$firstCheck, $firstDownload, $lastInstall, $recheck, $nodeCheck, $cliDownload] as $position) { + $this->assertIsInt($position); + } + $this->assertLessThan($firstDownload, $firstCheck); + $this->assertGreaterThan($lastInstall, $recheck); + $this->assertLessThan($cliDownload, $nodeCheck); + } + public function test_pull_request_build_cannot_publish_or_use_registry_credentials(): void { $workflow = (string) file_get_contents($this->path('.github/workflows/devcontainer-image-pr.yml')); @@ -78,6 +103,37 @@ public function test_pull_request_build_cannot_publish_or_use_registry_credentia $this->assertStringNotContainsString('packages: write', $workflow); } + public function test_composer_jobs_check_trust_before_dependency_resolution(): void + { + $workflow = Yaml::parseFile($this->path('.github/workflows/ci.yml')); + + foreach (['test', 'microservice-test'] as $job) { + $steps = $workflow['jobs'][$job]['steps']; + $setup = null; + $bootstrap = null; + foreach ($steps as $index => $step) { + if (str_starts_with($step['uses'] ?? '', 'durable-workflow/.github/.github/actions/bootstrap-patched-pip@')) { + $bootstrap = $index; + $this->assertSame('75587fc33ff40c34f32ef777293dad56bb6f48c7', explode('@', $step['uses'])[1]); + $this->assertStringContainsString('composer,curl,git', $step['with']['clients']); + } + if (str_starts_with($step['uses'] ?? '', 'shivammathur/setup-php@')) { + $setup = $index; + break; + } + } + $this->assertNotNull($bootstrap); + $this->assertNotNull($setup); + $this->assertLessThan($setup, $bootstrap); + $this->assertSame('composer:2.10.3', $steps[$setup]['with']['tools']); + $this->assertSame( + 'durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7', + $steps[$setup + 1]['uses'], + ); + $this->assertSame($job === 'microservice-test' ? 'microservice' : '.', $steps[$setup + 1]['with']['working-directory'] ?? '.'); + } + } + public function test_main_publication_builds_both_architectures_before_promotion(): void { $workflow = Yaml::parseFile($this->path('.github/workflows/devcontainer-image.yml')); diff --git a/tests/Unit/PlaygroundContractTest.php b/tests/Unit/PlaygroundContractTest.php index 2076ced0..678d71aa 100644 --- a/tests/Unit/PlaygroundContractTest.php +++ b/tests/Unit/PlaygroundContractTest.php @@ -308,7 +308,7 @@ public function test_development_image_bakes_tools_and_sdk_caches_before_post_cr $startContainer = file_get_contents($this->path('.devcontainer/docker/start-container')) ?: ''; $compose = Yaml::parseFile($this->path('.devcontainer/docker/docker-compose.yml')); - $this->assertStringContainsString('FROM rust:1.86.0-slim-bookworm AS rust', $dockerfile); + $this->assertMatchesRegularExpression('/^FROM rust:1\.86\.0-slim-bookworm@sha256:[0-9a-f]{64} AS rust$/m', $dockerfile); $this->assertStringContainsString('python3-venv', $dockerfile); $this->assertStringContainsString('polyglot/qualified-artifact-tuple.json', $dockerfile); $this->assertStringContainsString('playground/php-runtime/composer.json playground/php-runtime/composer.lock', $dockerfile); diff --git a/tests/Unit/ProductionImageTrustContractTest.php b/tests/Unit/ProductionImageTrustContractTest.php new file mode 100644 index 00000000..da390cbf --- /dev/null +++ b/tests/Unit/ProductionImageTrustContractTest.php @@ -0,0 +1,65 @@ +dockerfile(); + preg_match_all('/^FROM (\S+)/mi', $source, $inputs); + $external = array_values(array_filter($inputs[1], static fn (string $input): bool => $input !== 'base')); + + $this->assertCount(3, $external); + foreach ($external as $input) { + $this->assertMatchesRegularExpression('/^[^@]+:[^@]+@sha256:[a-f0-9]{64}$/D', $input); + } + $this->assertStringContainsString('node:22.23.4-bookworm-slim@', $source); + $this->assertStringContainsString('composer:2.10.3@', $source); + $this->assertStringContainsString('php:8.4.27-cli-bookworm@', $source); + } + + public function test_node_is_checked_before_asset_and_final_browser_downloads(): void + { + $source = $this->dockerfile(); + $guard = 'node /usr/local/share/sample-app/verify-node-trust.mjs'; + $assetCheck = strpos($source, $guard); + $assetDownload = strpos($source, 'npm ci'); + $finalCheck = strpos($source, $guard, $assetDownload); + $browserDownload = strpos($source, 'npx playwright install'); + + foreach ([$assetCheck, $assetDownload, $finalCheck, $browserDownload] as $position) { + $this->assertIsInt($position); + } + $this->assertLessThan($assetDownload, $assetCheck); + $this->assertStringContainsString('npm ci --ignore-scripts', $source); + $this->assertGreaterThan($assetDownload, $finalCheck); + $this->assertLessThan($browserDownload, $finalCheck); + } + + public function test_php_trust_precedes_os_and_application_dependency_requests(): void + { + $source = $this->dockerfile(); + $guard = 'php /usr/local/share/sample-app/verify-runtime-trust.php'; + $firstCheck = strpos($source, $guard); + $osDownload = strpos($source, 'apt-get update'); + $vendorCheck = strpos($source, $guard, $osDownload); + $vendorDownload = strpos($source, 'bash /usr/local/bin/install-composer-artifacts'); + + foreach ([$firstCheck, $osDownload, $vendorCheck, $vendorDownload] as $position) { + $this->assertIsInt($position); + } + $this->assertLessThan($osDownload, $firstCheck); + $this->assertGreaterThan($osDownload, $vendorCheck); + $this->assertLessThan($vendorDownload, $vendorCheck); + } + + private function dockerfile(): string + { + return (string) file_get_contents(dirname(__DIR__, 2).'/Dockerfile'); + } +} diff --git a/tests/fixtures/security/withdrawn-test-root.pem b/tests/fixtures/security/withdrawn-test-root.pem new file mode 100644 index 00000000..244d8e59 --- /dev/null +++ b/tests/fixtures/security/withdrawn-test-root.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhTCCAm2gAwIBAgIUfkLnWHoP7xN2d+ZNrRsViu8LohUwDQYJKoZIhvcNAQEL +BQAwUjEgMB4GA1UEAwwXZW1TaWduIEVDQyBSb290IENBIC0gRzMxLjAsBgNVBAoM +JUR1cmFibGUgV29ya2Zsb3cgc3ludGhldGljIENJIGZpeHR1cmUwHhcNMjYxMDIy +MTczMjMwWhcNMzYxMDE5MTczMjMwWjBSMSAwHgYDVQQDDBdlbVNpZ24gRUNDIFJv +b3QgQ0EgLSBHMzEuMCwGA1UECgwlRHVyYWJsZSBXb3JrZmxvdyBzeW50aGV0aWMg +Q0kgZml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMoiW+8S +G4vdpbX9vvA+hI5bveZIMI/BNszq8CphuZk+sLXeO+F7qXV+A0hBnoFYkA9h8FFn +TA/gCVcWDlxDfP4BMiJYNlWMLh3oRO8D3r6nonnqGDxqSbBjjCefEuUnqnKJmzgN +xqw5lzO9DujjGKCneAETNZCqtaGjigEPhfjZKzr3Bm0Oo0mgVbelN2biaFoXw3GB +BwWMkuCoiKOxofqFHSrVQB978nBCViYCeUfBZ4VqXY/gOssZLfuLRArvVNGNkT10 +BmgOxAVIHUZ+PlYiVlCC8m3OZB18qEoa5z0ZqnbG341ra9V+bWWD9sI9AAUpcARK +yHj6Q+xB+CyPc9sCAwEAAaNTMFEwHQYDVR0OBBYEFPjVC05YKqnwwzD1aCKe28TB +GRWLMB8GA1UdIwQYMBaAFPjVC05YKqnwwzD1aCKe28TBGRWLMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggEBALu3djRd4FtLX0HtivVdnauJ3eobTDAt ++1HsqzEm4GzAgUl0z7J5U6LSsqPLr+KXC6r4ZrXQoae7dp+vXbTtkzk6BHjqVh6S +GecDWIUNOtagf32MTOAmI2ERjqxr9wN9WipQLAQP3kpEH1sZS1Wn0XtJrT7JZ9Fl +opQYCGt1LFzwuYGbcucHuOLvp2o5lUGI7R0AuF3oQwJmD4CXnHUEs8cq4+i34wcY +zyRacUl79CfW+HGjWDmR7nPvri4RKJnnMo8WtNSzT0Ub0d5lpCcJYddR0rAjf/EG +u+zsZKSFCDqrvrZ0ncqiUVUiFvZEdEEi0rjWjRCEJ5Vq/4KgGSRGpMM= +-----END CERTIFICATE-----