diff --git a/.github/workflows/hardened-test.yml b/.github/workflows/hardened-test.yml new file mode 100644 index 00000000..4b7c2abb --- /dev/null +++ b/.github/workflows/hardened-test.yml @@ -0,0 +1,172 @@ +name: Hardened test image (DHI pilot) + +# EXPERIMENTAL. Builds the `min` flavor from Docker Hardened Images (Dockerfile.dhi), +# compares its Trivy result with the regular `min` image and -- only when explicitly +# requested -- pushes it as `php-min-v5-hardened-test`. amd64 only. +# Runs build + test + scan on pull requests that touch the pilot files, and on manual +# dispatch. Publishing is only possible on a manual dispatch with publish=true. + +on: + pull_request: + paths: + - 'Dockerfile.dhi' + - '.github/workflows/hardened-test.yml' + - 'files/conf/php/php.ini' + - 'files/conf/php-fpm/php-fpm.conf' + workflow_dispatch: + inputs: + php: + description: 'PHP minor version (must exist as dhi.io/php:-debian13-{dev,fpm} and as php:-fpm-trixie)' + required: false + default: '8.5' + type: string + publish: + description: 'Push pimcore/pimcore:php-min-v5-hardened-test to Docker Hub. Off by default: build, test and scan only.' + required: false + default: false + type: boolean + +env: + IMAGE_NAME: pimcore/pimcore + # inputs.* are empty on pull_request runs + PHP: ${{ inputs.php || '8.5' }} + PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} + +permissions: + contents: read + +jobs: + dhi-min: + name: "Build, test and scan DHI min (php ${{ inputs.php || '8.5' }})" + runs-on: ubuntu-22.04 + # Fork PRs get no repository secrets, and the dhi.io login needs them: run only for + # branches of this repository (pull_request from the same repo, or a dispatch). + if: >- + github.repository == 'pimcore/docker' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + # dhi.io accepts the Docker Hub account credentials (password or access token). + - name: Login to dhi.io + run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login dhi.io -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Login to DockerHub Registry + if: ${{ env.PUBLISH == 'true' }} + run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Install Trivy + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install -y trivy + + - name: Build regular min image (baseline) + run: | + set -eux + docker build --load --pull \ + --target pimcore_php_min \ + --build-arg PHP_VERSION="${PHP}" \ + --build-arg DEBIAN_VERSION=trixie \ + --tag plain-min . + + - name: Build DHI min image + run: | + set -eux + docker build --load --pull \ + --file Dockerfile.dhi \ + --target pimcore_php_min \ + --build-arg PHP_VERSION="${PHP}" \ + --tag dhi-min . + + # The image has no shell: every check is a direct `php` / `php-fpm` / `composer` invocation. + - name: Smoke checks + run: | + set -eux + + # same extension list as test.yml + for ext in amqp bcmath exif gd intl pcntl pdo_mysql sockets zip; do + docker run --rm dhi-min php -m | grep -Fx "$ext" + done + docker run --rm dhi-min php -m | grep -Fx 'Zend OPcache' || echo "::warning::Zend OPcache is not loaded in the CLI of dhi-min (check how DHI enables opcache)" + + # gd must have jpeg, webp and freetype support + docker run --rm dhi-min php -r '$i = gd_info(); foreach (["JPEG Support","WebP Support","FreeType Support"] as $k) { if (empty($i[$k])) { fwrite(STDERR, "gd: $k missing\n"); exit(1); } }' + + # the CLI binary and the fpm binary must agree on the extension ABI + docker run --rm dhi-min php -i | grep -E '^(extension_dir|Thread Safety|Debug Build) ' | tee cli.abi + docker run --rm dhi-min php-fpm -i | grep -E '^(extension_dir|Thread Safety|Debug Build) ' | tee fpm.abi + diff cli.abi fpm.abi + + # fpm config (incl. our zz-www.conf env overrides) is valid and loads all extensions + docker run --rm dhi-min php-fpm -t + + # runs as the DHI nonroot user + docker run --rm dhi-min php -r 'exit(posix_getuid() === 65532 ? 0 : 1);' + + # pimcore.ini env substitution works + docker run --rm -e PHP_MEMORY_LIMIT=333M dhi-min php -r 'exit(ini_get("memory_limit") === "333M" ? 0 : 1);' + + # composer starts without root and without a shell. Anything that spawns a + # process (create-project, install with scripts, `rm -rf` in composer's + # Filesystem) needs /bin/sh and fails in a distroless image: the application + # has to be built in another image/stage and copied in. So only --version here. + docker run --rm dhi-min composer --version + + # a real PHP workload: the pimcore composer.json platform requirements are met + docker run --rm dhi-min php -r 'foreach (["SimpleXML","curl","dom","exif","fileinfo","gd","iconv","intl","json","mbstring","openssl","pdo","pdo_mysql","zip","zlib"] as $e) { if (!extension_loaded($e)) { fwrite(STDERR, "missing ext-$e\n"); exit(1); } }' + + - name: Trivy scan - regular min vs DHI min + run: | + set -euo pipefail + { + echo "## DHI pilot: regular min vs DHI min (php ${PHP}, amd64)" + echo "" + echo "| Image | CRITICAL | HIGH | MEDIUM | LOW | UNKNOWN | total | fixable |" + echo "|-------|----------|------|--------|-----|---------|-------|---------|" + } > summary.md + for img in plain-min dhi-min; do + trivy image --pkg-types os,library --format json -o "trivy-${img}.json" "$img" + trivy image --pkg-types os,library --format table -o "trivy-${img}.txt" "$img" + jq -r --arg img "$img" ' + [.Results[]?.Vulnerabilities[]?] as $v + | [$img] + + (["CRITICAL","HIGH","MEDIUM","LOW","UNKNOWN"] | map(. as $s | ($v | map(select(.Severity == $s)) | length))) + + [($v | length), ($v | map(select(.FixedVersion != null and .FixedVersion != "")) | length)] + | "| " + (map(tostring) | join(" | ")) + " |"' "trivy-${img}.json" >> summary.md + done + { + echo "" + echo "Counts are vulnerability rows (one per package and CVE), all severities, fixable and unfixable, OS and library packages." + echo "Full tables: \`trivy-plain-min.txt\` and \`trivy-dhi-min.txt\` in the \`trivy-dhi-pilot\` artifact." + } >> summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" + cat summary.md + + - name: Upload Trivy results + if: always() + uses: actions/upload-artifact@v7 + with: + name: trivy-dhi-pilot_php${{ inputs.php || '8.5' }} + path: | + trivy-*.json + trivy-*.txt + summary.md + if-no-files-found: ignore + + - name: Publish -hardened-test tag + if: ${{ env.PUBLISH == 'true' }} + run: | + set -eux + TAG="${IMAGE_NAME}:php${PHP}-min-v5-hardened-test" + docker tag dhi-min "$TAG" + docker push "$TAG" + echo "Published ${TAG} (amd64 only, experimental)" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile.dhi b/Dockerfile.dhi new file mode 100644 index 00000000..e37a6b0c --- /dev/null +++ b/Dockerfile.dhi @@ -0,0 +1,173 @@ +# syntax=docker/dockerfile:1 +# +# EXPERIMENTAL -- Docker Hardened Images (DHI) pilot for the `min` flavor. +# Published only as `php-min-v5-hardened-test` from .github/workflows/hardened-test.yml. +# Not part of the regular release matrix. See README.md "Hardened images". +# +# Differences to the regular Dockerfile: +# - base is dhi.io/php (Debian 13 only, Apache-2.0, needs `docker login dhi.io`) +# - runtime is distroless: no shell, no apt, runs as uid 65532 (nonroot) +# - PHP lives under /opt/php-; /usr/local/etc/php is a symlink to its ini dir +# - no docker-php-ext-* helpers, so extensions are built with phpize in the -dev stage +# and copied (with the shared libraries they need) into the runtime stage +# - the -fpm runtime is built with --disable-cli; the `php` CLI is taken from the +# DHI CLI runtime image of the same version + +ARG PHP_VERSION="8.5" + +# --------------------------------------------------------------------------- +# builder: root, bash, apt, gcc, pecl, phpize, PHP source in $PHP_SRC_DIR +# --------------------------------------------------------------------------- +FROM dhi.io/php:${PHP_VERSION}-debian13-dev AS builder + +# Build dependencies. The -dev image's apt points at dhi.io/deb (hardened rebuilds of +# Debian packages) and falls back to upstream Debian for everything else. +# libpcre2-dev and zlib1g-dev: PHP is built against the system pcre2/zlib, and the -dev image +# ships only the runtime libraries, not the headers the extension builds need. +# libbrotli1/libbrotli-dev are pinned to one version: the DHI repo intermittently serves +# them out of sync, which breaks libfreetype-dev (docker-hardened-images/catalog#677, #682). +# The -dev candidate is the anchor: its exact-version runtime dependency always exists in +# the repo, while the newest runtime build may have no matching -dev package yet. The base +# image can already carry that newer runtime build, hence --allow-downgrades. +RUN set -eux; \ + apt-get update; \ + BROTLI="$(apt-cache policy libbrotli-dev | awk '/Candidate:/ {print $2}')"; \ + apt-get install -y --no-install-recommends --allow-downgrades \ + "libbrotli1=${BROTLI}" \ + "libbrotli-dev=${BROTLI}" \ + libfreetype-dev \ + libjpeg62-turbo-dev \ + libpcre2-dev \ + libpng-dev \ + librabbitmq-dev \ + libwebp-dev \ + libzip-dev \ + zlib1g-dev \ + ; \ + rm -rf /var/lib/apt/lists/* + +# Extensions bundled with PHP but not compiled into the DHI image. +# Each one is built out-of-tree with phpize and installed into `php-config --extension-dir`. +RUN set -eux; \ + for ext in bcmath exif pcntl sockets zip; do \ + ( cd "${PHP_SRC_DIR}/ext/${ext}" && phpize && ./configure && make -j"$(nproc)" && make install ); \ + done; \ + ( cd "${PHP_SRC_DIR}/ext/gd" && phpize \ + && ./configure --with-jpeg --with-webp --with-freetype \ + && make -j"$(nproc)" && make install ); \ + ( cd "${PHP_SRC_DIR}/ext/pdo_mysql" && phpize \ + && ./configure --with-pdo-mysql=mysqlnd \ + && make -j"$(nproc)" && make install ) + +# PECL extension: amqp (librabbitmq from the DHI apt repo). +# `pecl download` + phpize avoids the interactive prompt of `pecl install amqp`. +RUN set -eux; \ + cd /tmp; \ + pecl download amqp; \ + mkdir amqp && tar -xzf amqp-*.tgz -C amqp --strip-components=1; \ + cd amqp && phpize && ./configure && make -j"$(nproc)" && make install; \ + rm -rf /tmp/amqp /tmp/amqp-*.tgz + +# --------------------------------------------------------------------------- +# runtime-probe: the file list of the runtime image, used to copy only the shared +# libraries the runtime does NOT already ship (keeps the DHI base untouched) +# --------------------------------------------------------------------------- +FROM dhi.io/php:${PHP_VERSION}-debian13-fpm AS runtime-probe + +FROM builder AS payload + +COPY --from=runtime-probe /usr/lib /probe/usr/lib + +# Stage everything the runtime needs under /out: +# - the extension .so files +# - /usr/bin/env: the composer phar starts with `#!/usr/bin/env php` (its stub is covered +# by the phar signature, so the shebang cannot be rewritten); env needs only libc +# - every shared library the above link against that the runtime image lacks +# - the dpkg status record of each package those files come from, in +# /var/lib/dpkg/status.d/ (the distroless convention Trivy reads next to the main +# status file), so the copied libraries stay visible to vulnerability scanners +# - one ini file per extension in the scan dir ($PHP_INI_DIR/conf.d) +RUN set -eux; \ + EXTDIR="$(php-config --extension-dir)"; \ + mkdir -p "/out${EXTDIR}" "/out${PHP_PREFIX}/etc/php/conf.d" /out/usr/bin /out/var/lib/dpkg/status.d; \ + cp "${EXTDIR}"/*.so "/out${EXTDIR}/"; \ + cp -L /usr/bin/env /out/usr/bin/env; \ + record_pkg() { \ + pkg="$(dpkg -S "$1" | head -n1 | cut -d: -f1)"; \ + [ -n "$pkg" ] || { echo "no package owns $1" >&2; exit 1; }; \ + [ -e "/out/var/lib/dpkg/status.d/${pkg}" ] || dpkg-query --status "$pkg" > "/out/var/lib/dpkg/status.d/${pkg}"; \ + }; \ + record_pkg /usr/bin/env; \ + for bin in "${EXTDIR}"/*.so /usr/bin/env; do \ + ldd "$bin" | awk '/=> \// {print $3}'; \ + done | sort -u | while read -r lib; do \ + # merged-/usr: /lib//x -> /usr/lib//x + norm="$(printf '%s' "$lib" | sed -E 's#^/lib/#/usr/lib/#')"; \ + case "$norm" in /usr/lib/*) ;; *) echo "unexpected library path: $lib" >&2; exit 1;; esac; \ + if [ ! -e "/probe${norm}" ]; then \ + mkdir -p "/out$(dirname "$norm")"; \ + cp -L "$lib" "/out${norm}"; \ + record_pkg "$norm"; \ + fi; \ + done; \ + for ext in bcmath exif gd pcntl pdo_mysql sockets zip amqp; do \ + echo "extension=${ext}.so" > "/out${PHP_PREFIX}/etc/php/conf.d/10-${ext}.ini"; \ + done; \ + find /out -type f | sort + +# --------------------------------------------------------------------------- +# cli: the DHI CLI runtime of the same version, source of the `php` binary +# --------------------------------------------------------------------------- +FROM dhi.io/php:${PHP_VERSION}-debian13 AS cli + +# --------------------------------------------------------------------------- +# runtime: distroless, nonroot (uid 65532), no shell, no package manager +# --------------------------------------------------------------------------- +FROM dhi.io/php:${PHP_VERSION}-debian13-fpm AS pimcore_php_min + +# Paths below use PHP_PREFIX (ENV from the DHI base, e.g. /opt/php-8.5) rather than the +# PHP_VERSION build arg, so they stay correct whatever the base image sets PHP_VERSION to. +COPY --from=payload /out/ / +COPY --from=cli ${PHP_PREFIX}/bin/php ${PHP_PREFIX}/bin/php + +COPY files/conf/php/php.ini ${PHP_PREFIX}/etc/php/conf.d/20-pimcore.ini +COPY files/conf/php-fpm/php-fpm.conf ${PHP_PREFIX}/etc/php-fpm.d/zz-www.conf + +# env php.ini +ENV PHP_TIMEZONE="UTC" +ENV PHP_MEMORY_LIMIT="256M" +ENV PHP_POST_MAX_SIZE="100M" +ENV PHP_UPLOAD_MAX_FILESIZE="100M" +ENV PHP_DISPLAY_STARTUP_ERRORS=1 +ENV PHP_MAX_EXECUTION_TIME="30" +ENV PHP_ERROR_REPORTING="E_ALL" +ENV PHP_EXPOSE_PHP="Off" + +# opcache settings +ENV OPCACHE_ENABLE=1 +ENV OPCACHE_ENABLE_CLI=0 +ENV OPCACHE_MEMORY_CONSUMPTION=128 +ENV OPCACHE_MAX_ACCELERATED_FILES=10000 +ENV OPCACHE_VALIDATE_TIMESTAMPS=1 +ENV OPCACHE_CONSISTENCY_CHECKS=0 + +# fpm settings +ENV PHP_FPM_LISTEN=0.0.0.0:9000 +ENV PHP_FPM_PM=dynamic +ENV PHP_FPM_PM_MAX_CHILDREN=5 +ENV PHP_FPM_PM_START_SERVERS=2 +ENV PHP_FPM_PM_MAX_SPARE_SERVERS=3 +ENV PHP_FPM_PM_MIN_SPARE_SERVERS=1 +ENV PHP_FPM_PM_MAX_REQUESTS=10000 + +# Composer runs as uid 65532 here (not root), so it needs a writable home. +ENV COMPOSER_HOME=/tmp/composer +ENV COMPOSER_MEMORY_LIMIT=-1 +COPY --from=composer/composer:2-bin /composer /usr/local/bin/composer + +WORKDIR /var/www/html + +# DHI sets ENTRYPOINT to the php-fpm binary. Reset it so `docker run php bin/console` +# works exactly like with the regular images (there is no shell for an entrypoint script). +ENTRYPOINT [] +CMD ["php-fpm"] diff --git a/README.md b/README.md index ffd1cf7e..1880ae62 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,14 @@ Additionally we're offering 2 special tag suffixes: We're also offering special tags for specific PHP versions, e.g. `php8.2.5-v2.0`. ## Hardened images -For our stable release tags we publish each image in two flavors so you can choose your trade-off: + +> **Status (2026-10-28): publishing of `-hardened` tags is paused.** The last `-hardened` tags were +> pushed on 2026-07-31 and are not refreshed. Use the plain tags; they are the supported images and +> are rebuilt on every scheduled run with all Debian security updates available at build time. +> Hardened publishing resumes once there is a strategy that delivers it consistently. +> The description below documents what the `-hardened` flavor is when it is enabled. + +For our stable release tags the release workflow can publish each image in two flavors so you can choose your trade-off: - **plain** (default, unsuffixed) – the image exactly as built from the Dockerfile, e.g. `php8.5-debug-v5`. Published as-is; it may carry known OS-level CVEs. - **hardened** (`-hardened` suffix) – the same image with OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. @@ -69,6 +76,30 @@ oras pull ghcr.io/pimcore/pimcore@ -o ./sbom Attaching is best-effort (it's skipped if the registry rejects OCI referrers), so if `oras discover` comes up empty, fall back to the guaranteed copy: download the `sboms_*` artifact from the corresponding release workflow run. +### Experimental: `-hardened-test` (Docker Hardened Images pilot) + +`pimcore/pimcore:php8.5-min-v5-hardened-test` is an **experimental, unsupported** image built from +[Docker Hardened Images](https://docs.docker.com/dhi/) (`dhi.io/php`, Apache-2.0) instead of the +official `php` image, see [`Dockerfile.dhi`](Dockerfile.dhi). It exists to measure whether a hardened +base image is a better path than Copa. It may change or disappear without notice. + +- **`min` flavor only, PHP 8.5, Debian 13, amd64 only.** No `default`, `max`, `debug` or `supervisord` + flavor: DHI does not provide ghostscript, ImageMagick, ffmpeg, poppler, LibreOffice or Chromium, so + those tools would come unpatched from plain Debian and a distroless runtime cannot install them. +- **Distroless runtime:** no shell, no package manager, runs as uid/gid `65532` (`nonroot`), not root. + Mounted volumes must be writable by that user. Use `docker debug` for troubleshooting. +- PHP lives under `/opt/php-8.5`; `/usr/local/etc/php` is a symlink to its ini directory, so + `conf.d` overrides work as with the regular images. +- Same extensions as `min` (`amqp bcmath exif gd intl pcntl pdo_mysql sockets zip`), same + `php.ini`/`php-fpm` environment variables, `php` CLI and `composer` included. +- **Runtime image only.** Without `/bin/sh`, anything that spawns a process fails: `composer + create-project`/`install` (composer shells out for `rm -rf`, `git`, `unzip`), composer scripts and + Pimcore commands that call external tools. Build the application with the regular image or a + `-dev` stage and copy it in. +- Built, tested and scanned by `hardened-test.yml` on pull requests that touch it and on manual dispatch; + published only on a manual dispatch with `publish=true`. It is not part of the release matrix, has no + `-latest`/ghcr tags and no attached SBOM. + ## Known CVEs For every **published stable release image** we publish a CVE and hardening report,