Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 42 additions & 5 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
40 changes: 23 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 ─────────────────────────────────────────
Expand All @@ -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 ./
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docker/verify-node-trust.mjs
Original file line number Diff line number Diff line change
@@ -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 }));
67 changes: 67 additions & 0 deletions docker/verify-runtime-trust.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

final class ProductionTrustRefused extends RuntimeException {}

if (PHP_MAJOR_VERSION !== 8 || PHP_MINOR_VERSION !== 4 || PHP_VERSION_ID < 80427) {
throw new ProductionTrustRefused('PHP 8.4.27 or newer on the 8.4 line is required.');
}
Phar::loadPhar($argv[1]);
require_once 'phar://composer.phar/vendor/autoload.php';
if (!str_starts_with(Composer\Composer::VERSION, '2.') || version_compare(Composer\Composer::VERSION, '2.10.3', '<')) {
throw new ProductionTrustRefused('Composer 2.10.3 or newer on the 2 line is required.');
}

function inspectSampleCaBundle(string $path): int
{
$contents = file_get_contents($path);
if ($contents === false || !preg_match_all('/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s', $contents, $certificates)) {
throw new ProductionTrustRefused('The effective CA store must contain PEM certificates.');
}
foreach ($certificates[0] as $certificate) {
$parsed = openssl_x509_parse($certificate);
if ($parsed === false) {
throw new ProductionTrustRefused('Malformed certificate in an effective CA store.');
}
foreach ((array) ($parsed['subject']['CN'] ?? []) as $subject) {
if (in_array($subject, ['AAA Certificate Services', 'emSign ECC Root CA - G3'], true)) {
throw new ProductionTrustRefused('Blocked certificate in an effective CA store.');
}
}
}
return count($certificates[0]);
}

$config = Composer\Factory::createConfig(null, getcwd());
if (is_file('composer.json')) {
$manifest = json_decode((string) file_get_contents('composer.json'), true, flags: JSON_THROW_ON_ERROR);
$config->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;
53 changes: 53 additions & 0 deletions tests/Node/ProductionNodeTrustTest.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
65 changes: 65 additions & 0 deletions tests/Unit/ProductionImageTrustContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

final class ProductionImageTrustContractTest extends TestCase
{
public function test_external_runtime_and_composer_inputs_are_immutable(): void
{
$source = $this->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');
}
}
21 changes: 21 additions & 0 deletions tests/fixtures/security/withdrawn-test-root.pem
Original file line number Diff line number Diff line change
@@ -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-----
Loading