diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91e69d5b..f5e3b4a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,12 @@ jobs: } >> "$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 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/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-----