diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c80642..b9d9861 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,13 @@ on: publish: description: 'Publish images to registries' required: false - default: true + default: false type: boolean + fail_on_severity: + description: 'Comma-separated list of severities that fail the build if post-patch CVEs remain (e.g. CRITICAL,HIGH). Valid values: CRITICAL, HIGH, MEDIUM, LOW. Use NONE to disable the gate entirely.' + required: false + default: 'CRITICAL,HIGH' + type: string push: tags: - 'v*.*' @@ -16,6 +21,9 @@ on: env: IMAGE_NAME: pimcore/pimcore + COPA_VERSION: "0.14.1" + BUILDKIT_VERSION: "0.30.0" + TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" jobs: build-php: @@ -28,21 +36,20 @@ jobs: - ubuntu-22.04 - ubuntu-22.04-arm build: - - { tag: '1.x', php: '8.1', distro: bullseye, version-override: "v1-dev", latest-tag: false } - - { tag: '1.x', php: '8.2', distro: bullseye, version-override: "v1-dev", latest-tag: false } - - { tag: 'v1.6', php: '8.1', distro: bullseye, version-override: "", latest-tag: true } - - { tag: 'v1.6', php: '8.2', distro: bullseye, version-override: "", latest-tag: false } - - { tag: 'v2.3', php: '8.2', distro: bullseye, version-override: "", latest-tag: false } - - { tag: '2.x', php: '8.2', distro: bullseye, version-override: "v2-dev", latest-tag: false } - - { tag: 'v3.8', php: '8.2', distro: bookworm, version-override: "", latest-tag: true } - - { tag: 'v3.8', php: '8.3', distro: bookworm, version-override: "", latest-tag: true } - - { tag: '3.x', php: '8.2', distro: bookworm, version-override: "v3-dev", latest-tag: false } - - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false } - - { tag: 'v4.1', php: '8.4', distro: bookworm, version-override: "", latest-tag: true } - - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false } - - { tag: '5.x', php: '8.5', distro: trixie, version-override: "", latest-tag: false } - - { tag: 'v5.2', php: '8.5', distro: trixie, version-override: "", latest-tag: true } - - { tag: '5.x', php: '8.5', distro: trixie, version-override: "v5-dev", latest-tag: false } + - { tag: '1.x', php: '8.1', distro: bullseye, version-override: "v1-dev", latest-tag: false, hardened: false } + - { tag: '1.x', php: '8.2', distro: bullseye, version-override: "v1-dev", latest-tag: false, hardened: false } + - { tag: 'v1.6', php: '8.1', distro: bullseye, version-override: "", latest-tag: true, hardened: true } + - { tag: 'v1.6', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, hardened: true } + - { tag: 'v2.3', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, hardened: true } + - { tag: '2.x', php: '8.2', distro: bullseye, version-override: "v2-dev", latest-tag: false, hardened: false } + - { tag: 'v3.8', php: '8.2', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: 'v3.8', php: '8.3', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: '3.x', php: '8.2', distro: bookworm, version-override: "v3-dev", latest-tag: false, hardened: false } + - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false, hardened: false } + - { tag: 'v4.2', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false, hardened: false } + - { tag: 'v5.1', php: '8.5', distro: trixie, version-override: "", latest-tag: true, hardened: true } + - { tag: '5.x', php: '8.5', distro: trixie, version-override: "v5-dev", latest-tag: false, hardened: false } steps: - uses: actions/checkout@v5 @@ -58,105 +65,286 @@ jobs: - name: Login to GitHub Container Registry run: echo ${{ secrets.IMAGES_REPO_TOKEN }} | docker login ghcr.io -u ${{ secrets.IMAGES_REPO_USERNAME }} --password-stdin - - name: Configure and build images - id: vars + - name: Install Copa and Trivy + if: ${{ matrix.build.hardened }} + run: | + set -eux + # Install Trivy + 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 + + # Install Copa + COPA_ARCH="$(dpkg --print-architecture)" + curl -fsSL -o copa.tar.gz "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" + curl -fsSL -o copacetic_checksums.txt "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copacetic_checksums.txt" + # Verify checksum before extracting + EXPECTED_SHA=$(grep -F "copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" copacetic_checksums.txt | awk '{print $1}') + ACTUAL_SHA=$(sha256sum copa.tar.gz | awk '{print $1}') + if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::Copa checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 + fi + tar -xzf copa.tar.gz copa + sudo mv copa /usr/local/bin/copa + rm copa.tar.gz copacetic_checksums.txt + + - name: Start buildkit daemon + if: ${{ matrix.build.hardened }} + run: | + docker run --detach --rm --privileged \ + -p 127.0.0.1:8888:8888/tcp \ + --name buildkitd \ + --entrypoint buildkitd \ + moby/buildkit:v${{ env.BUILDKIT_VERSION }} \ + --addr tcp://0.0.0.0:8888 + + # Wait for buildkit to be ready + for i in $(seq 1 60); do + if docker exec buildkitd buildctl --addr tcp://127.0.0.1:8888 debug workers >/dev/null 2>&1; then + echo "BuildKit is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "::error::BuildKit failed to start within 60 seconds" + exit 1 + fi + sleep 1 + done + + - name: Build plain images env: VERSION_OVERRIDE: "${{ matrix.build.version-override }}" ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} - PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} run: | - set -eux; - sudo apt-get update - - echo ${{ matrix.runner}} + set -eux + mkdir -p .docker-state - if [[ "${{ matrix.build.tag }}" =~ ^v?1.[0-9x]+$ ]]; then + if [[ "${{ matrix.build.tag }}" =~ ^v?1\.[0-9x]+$ ]]; then imageVariants=("fpm" "debug" "supervisord") else imageVariants=("min" "default" "max" "debug" "supervisord") fi - for imageVariant in ${imageVariants[@]}; do - echo "Building image variant $imageVariant" - DOCKER_PLATFORMS=linux/amd64,linux/arm64 - PHP_VERSION=${{ matrix.build.php }} - DEBIAN_VERSION="${{ matrix.build.distro }}" + printf '%s\n' "${imageVariants[@]}" > .docker-state/variants.txt + + PHP_SUB_VERSION=$(docker run -i --rm php:${{ matrix.build.php }}-fpm-${{ matrix.build.distro }} php -r 'echo PHP_VERSION;') + + for imageVariant in "${imageVariants[@]}"; do + echo "Building plain image: $imageVariant" + mkdir -p ".docker-state/${imageVariant}" + VERSION="${{ matrix.build.tag }}" - # for the latest dev branch we use "dev" as the version and not the name of the branch - if [ ! -z "$VERSION_OVERRIDE" ]; then + if [ -n "$VERSION_OVERRIDE" ]; then VERSION="$VERSION_OVERRIDE" fi - PHP_SUB_VERSION=$(docker run -i --rm php:${{ matrix.build.php }}-fpm-${{ matrix.build.distro }} php -r 'echo PHP_VERSION;') - if [ "$imageVariant" = "fpm" ] || [ "$imageVariant" = "default" ]; then + + if [ "$imageVariant" = "fpm" ] || [ "$imageVariant" = "default" ]; then BASE_TAG="php${{ matrix.build.php }}" BASE_TAG_DETAILED="php${PHP_SUB_VERSION}" else - BASE_TAG="php${{ matrix.build.php }}-$imageVariant" - BASE_TAG_DETAILED="php${PHP_SUB_VERSION}-$imageVariant" + BASE_TAG="php${{ matrix.build.php }}-${imageVariant}" + BASE_TAG_DETAILED="php${PHP_SUB_VERSION}-${imageVariant}" fi - # DEBUG / TEST - #BASE_TAG="testv3-$BASE_TAG" - #BASE_TAG_DETAILED="testv3-$BASE_TAG_DETAILED" - TAG="${BASE_TAG}-${VERSION}-${ARCH_TAG}" - TAG_DETAILED="${BASE_TAG_DETAILED}-${VERSION}-${ARCH_TAG}" + PLAIN_IMAGE="${IMAGE_NAME}:${TAG}" + + # Write plain tags one per line; avoids quoting issues in later steps. + { + echo "${IMAGE_NAME}:${TAG}" + echo "${IMAGE_NAME}:${BASE_TAG_DETAILED}-${VERSION}-${ARCH_TAG}" + echo "ghcr.io/pimcore/pimcore:${TAG}" + echo "ghcr.io/pimcore/pimcore:${BASE_TAG_DETAILED}-${VERSION}-${ARCH_TAG}" + if [ "true" = "${{ matrix.build.latest-tag }}" ]; then + echo "${IMAGE_NAME}:${BASE_TAG}-latest-${ARCH_TAG}" + echo "ghcr.io/pimcore/pimcore:${BASE_TAG}-latest-${ARCH_TAG}" + fi + if [[ $VERSION =~ ^v[0-9]+\.[0-9]+$ ]]; then + VERSION_MAJOR="${VERSION%.*}" + echo "${IMAGE_NAME}:${BASE_TAG}-${VERSION_MAJOR}-${ARCH_TAG}" + echo "ghcr.io/pimcore/pimcore:${BASE_TAG}-${VERSION_MAJOR}-${ARCH_TAG}" + fi + } > ".docker-state/${imageVariant}/plain_tags.txt" + + echo "${PLAIN_IMAGE}" > ".docker-state/${imageVariant}/plain_image.txt" + echo "${BASE_TAG}" > ".docker-state/${imageVariant}/base_tag.txt" + echo "${VERSION}" > ".docker-state/${imageVariant}/version.txt" + echo "${TAG}" > ".docker-state/${imageVariant}/tag.txt" + + docker build --load \ + --provenance=false \ + --platform "linux/${ARCH_TAG}" \ + --target="pimcore_php_${imageVariant}" \ + --build-arg PHP_VERSION="${{ matrix.build.php }}" \ + --build-arg DEBIAN_VERSION="${{ matrix.build.distro }}" \ + --tag "${PLAIN_IMAGE}" . + done + + - name: Scan, patch, and gate hardened images + if: ${{ matrix.build.hardened }} + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL,HIGH' }} + TRIVY_DB_REPOSITORY: ${{ env.TRIVY_DB_REPOSITORY }} + run: | + set -eux + mkdir -p trivy-reports + + mapfile -t imageVariants < .docker-state/variants.txt - GHCR_TAG="ghcr.io/pimcore/pimcore:${TAG}" - GHCR_TAG_DETAILED="ghcr.io/pimcore/pimcore:${TAG_DETAILED}" + for imageVariant in "${imageVariants[@]}"; do + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + BASE_TAG=$(< ".docker-state/${imageVariant}/base_tag.txt") + VERSION=$(< ".docker-state/${imageVariant}/version.txt") + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" - TAGS="--tag ${IMAGE_NAME}:${TAG}" - TAGS="$TAGS --tag ${IMAGE_NAME}:${TAG_DETAILED}" + echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" + trivy image --pkg-types os --ignore-unfixed --format json \ + -o /tmp/trivy-report.json "${PLAIN_IMAGE}" - TAGS="$TAGS --tag $GHCR_TAG" - TAGS="$TAGS --tag $GHCR_TAG_DETAILED" + if [ -s /tmp/trivy-report.json ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' /tmp/trivy-report.json > /dev/null 2>&1; then + copa patch -i "${PLAIN_IMAGE}" \ + -r /tmp/trivy-report.json \ + -t "${HARDENED_IMAGE}" \ + -a tcp://127.0.0.1:8888 - # Tag latest with Version build too - if [ "true" = "${{ matrix.build.latest-tag }}" ]; then - TAGS="$TAGS --tag ${IMAGE_NAME}:${BASE_TAG}-latest-${ARCH_TAG}" - TAGS="$TAGS --tag ghcr.io/pimcore/pimcore:${BASE_TAG}-latest-${ARCH_TAG}" + if ! docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1; then + echo "::error::Hardened image not found for ${PLAIN_IMAGE}" + exit 1 + fi + echo "Successfully patched ${PLAIN_IMAGE} into ${HARDENED_IMAGE}" + else + # Nothing fixable: hardened tag mirrors plain so it always exists. + echo "No fixable OS vulnerabilities found; hardened image mirrors plain" + docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" fi - # Create tag for major version - if [[ $VERSION =~ ^v[0-9]+.[0-9]+$ ]]; then - VERSION_MAJOR="${VERSION//.[0-9]/}" - TAG_MAJOR="${BASE_TAG}-${VERSION_MAJOR}-${ARCH_TAG}" - GHCR_TAG_MAJOR="ghcr.io/pimcore/pimcore:${TAG_MAJOR}" - TAGS="$TAGS --tag ${IMAGE_NAME}:${TAG_MAJOR}" - TAGS="$TAGS --tag $GHCR_TAG_MAJOR" + rm -f /tmp/trivy-report.json + + # Derive hardened tags by inserting -hardened before the arch suffix on each plain tag. + while IFS= read -r plain_tag; do + echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" + done < ".docker-state/${imageVariant}/plain_tags.txt" \ + > ".docker-state/${imageVariant}/hardened_tags.txt" + echo "${HARDENED_IMAGE}" > ".docker-state/${imageVariant}/hardened_image.txt" + + # Post-patch vulnerability gate -- runs before any push; failure aborts the step + # so neither plain nor hardened tags ship for this variant. + if [ "$FAIL_ON_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${FAIL_ON_SEVERITY}+)" + + IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + REPORT_JSON="trivy-reports/${TAG}-hardened_${IMAGE_HASH}.json" + REPORT_TXT="trivy-reports/${TAG}-hardened_${IMAGE_HASH}.txt" + + # Scan to JSON -- source for both the downloadable artifact and the gate. + # Not soft: a Trivy error here should abort the step. + trivy image --pkg-types os --ignore-unfixed \ + --severity "$FAIL_ON_SEVERITY" \ + --format json \ + -o "${REPORT_JSON}" \ + "${HARDENED_IMAGE}" + + # Scan to table for human-readable output only (soft -- display cannot gate). + trivy image --pkg-types os --ignore-unfixed \ + --severity "$FAIL_ON_SEVERITY" \ + --format table \ + -o /tmp/trivy-os-${TAG}.txt \ + "${HARDENED_IMAGE}" || true + cp /tmp/trivy-os-${TAG}.txt "${REPORT_TXT}" 2>/dev/null || true + + { + echo "## Trivy Scan: ${HARDENED_IMAGE}" + echo "" + echo "### OS Vulnerabilities (${FAIL_ON_SEVERITY}+)" + echo '```' + cat /tmp/trivy-os-${TAG}.txt 2>/dev/null || echo "No results" + echo '```' + echo "" + } >> "$GITHUB_STEP_SUMMARY" + rm -f /tmp/trivy-os-${TAG}.txt + + # Gate on the JSON findings -- no third Trivy invocation needed. + if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then + echo "::error::${HARDENED_IMAGE} has unfixed ${FAIL_ON_SEVERITY} vulnerabilities after patching" + exit 1 + fi fi + done - docker buildx build --output "type=image,push=$PUSH" \ - --provenance=false \ - --sbom=true \ - --platform "linux/${ARCH_TAG}" \ - --target="pimcore_php_$imageVariant" \ - --build-arg PHP_VERSION="${PHP_VERSION}" \ - --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ - ${TAGS} . + - name: Tag, push, and aggregate + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + run: | + set -eux + + mapfile -t imageVariants < .docker-state/variants.txt + + for imageVariant in "${imageVariants[@]}"; do + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" + ALL_TAGS=("${PLAIN_TAGS[@]}") - docker inspect ${IMAGE_NAME}:${TAG} || true; + HARDENED_IMAGE="" + if [ -f ".docker-state/${imageVariant}/hardened_image.txt" ]; then + HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") + mapfile -t HARDENED_TAGS < ".docker-state/${imageVariant}/hardened_tags.txt" + ALL_TAGS+=("${HARDENED_TAGS[@]}") + fi + + # Apply every tag to its source image (plain or hardened). + for additional_tag in "${ALL_TAGS[@]}"; do + case "$additional_tag" in + *-hardened-${ARCH_TAG}) src_image="${HARDENED_IMAGE}" ;; + *) src_image="${PLAIN_IMAGE}" ;; + esac + if [ "$additional_tag" != "$src_image" ]; then + docker tag "$src_image" "$additional_tag" + fi + done - # Only aggregate tags if we're publishing + # Push and aggregate logical tags (parallel push for speed). if [[ "$PUSH" == "true" ]]; then - CLEAN_TAGS="${TAGS//-arm64/}" - CLEAN_TAGS="${CLEAN_TAGS//-amd64/}" - CLEAN_TAGS="${CLEAN_TAGS//--tag /}" - - read -r -a TAGS_ARRAY <<< "$CLEAN_TAGS" - - for tag in "${TAGS_ARRAY[@]}"; do - echo "Processing tag: $tag" - echo "$tag" >> aggregated_tags.txt + printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + for tag in "${ALL_TAGS[@]}"; do + logical_tag="${tag//-arm64/}" + logical_tag="${logical_tag//-amd64/}" + echo "$logical_tag" >> aggregated_tags.txt done fi + # Clean up per variant to reclaim disk space before the next variant. + # ALL_TAGS already includes PLAIN_IMAGE and HARDENED_IMAGE as their + # first entries, so a single loop covers everything. + for additional_tag in "${ALL_TAGS[@]}"; do + docker rmi "$additional_tag" 2>/dev/null || true + done done + - name: Stop buildkit daemon + if: ${{ always() && matrix.build.hardened }} + run: docker stop buildkitd || true + + - name: Upload trivy reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: trivy-reports_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} + path: trivy-reports/ + if-no-files-found: ignore + - name: Upload aggregated tags if: github.event_name != 'workflow_dispatch' || inputs.publish uses: actions/upload-artifact@v7 with: name: aggregated_tags_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} path: aggregated_tags.txt + if-no-files-found: ignore process-tags: runs-on: ubuntu-22.04 diff --git a/README.md b/README.md index 4e117ef..be57b20 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,21 @@ 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: + +- **plain** (default, unsuffixed) – the image exactly as built from the Dockerfile, e.g. `php8.5-debug-v5`. +- **hardened** (`-hardened` suffix) – the same image with known OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. Every hardened image is scanned with [Trivy](https://github.com/aquasecurity/trivy) and must pass a `CRITICAL,HIGH` vulnerability gate before it's published. + +```text +php8.5-debug-v5 # plain image, as built +php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa +``` + +The `-hardened` suffix works with every tag form (e.g. `php8.5-debug-latest-hardened`, `php8.5.3-debug-v5-hardened`). + +Pick **hardened** for production or anywhere images are vulnerability-scanned. Pick **plain** when you need the unmodified base (e.g. for reproducible builds or when you run your own patching pipeline). The hardened flavor is only available for stable release tags – development tags (`-dev`) are published as plain only. + ## Container registries Our images are available on both Docker Hub and the GitHub Container Registry, so you can choose the one that best fits your workflow. Use either of the following commands: diff --git a/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md b/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md new file mode 100644 index 0000000..3e867d5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md @@ -0,0 +1,114 @@ +# Design: `-hardened` tag for Copa-patched images + +**Date:** 2026-06-15 +**Status:** Approved +**Affected files:** `.github/workflows/release.yml`, `README.md` + +## Problem + +Today, for every matrix build marked `imagePatch: true` (the stable releases: +`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.1`), the release workflow scans the freshly +built image with Trivy, patches OS-level CVEs with Copa, and then **replaces the +plain image in place** under the same tags (`release.yml` lines ~191–219). The +patched image is retagged as the original tag, the original is deleted, and all +downstream tags point at the patched bytes. + +Consequence: users have no way to pull the un-patched ("plain") image for those +releases — Copa hardening is mandatory and invisible. We want users to choose: + +- `php8.5-debug-v5` — the plain image, exactly as built from the Dockerfile. +- `php8.5-debug-v5-hardened` — the Copa-patched ("hardened") image. + +## Decisions (confirmed with maintainer) + +1. **Default tag = plain.** The unsuffixed tag (`php8.5-debug-v5`) is the + un-patched image. The hardened image gets a `-hardened` suffix. Existing + pullers of the unsuffixed tag will receive the plain image going forward + (they lose the implicit auto-patching they get today). +2. **Scope = only `hardened: true` builds.** Dev/rolling tags (`1.x`, `2.x`, + `3.x`, `4.x`, `5.x`, and all `*-dev` overrides) remain plain-only, exactly as + today. No `-hardened` variant is produced for them. +3. **Severity gate applies to the hardened image only.** The plain image is + published as-is and may carry known CVEs; only the hardened image must pass + the `fail_on_severity` gate (`CRITICAL,HIGH` by default). +4. **Gate ordering = all-or-nothing per variant.** The hardened gate runs + *before any push*. If the hardened image cannot pass the gate, neither the + plain nor the hardened tags are published for that image variant — preserving + the current "failed gate = nothing ships" contract. + +## Tag scheme + +The `-hardened` marker is inserted **before** the internal `-amd64` / `-arm64` +architecture suffix. This lets the existing `process-tags` job (which strips the +arch suffix and creates a multi-arch manifest) produce `…-hardened` manifests +with no changes to that job. + +For a `hardened: true` build, both tag sets are produced and pushed: + +| Tag role | Plain (default, unchanged) | Hardened (new) | +|-----------------|----------------------------|---------------------------------------| +| primary | `php8.5-debug-v5` | `php8.5-debug-v5-hardened` | +| detailed (PHP) | `php8.5.3-debug-v5` | `php8.5.3-debug-v5-hardened` | +| latest | `php8.5-debug-latest` | `php8.5-debug-latest-hardened` | +| major | `php8.5-debug-v5`* | `php8.5-debug-v5-hardened`* | + +(*) major tag only when `version-override` is empty and version matches `vN.N`, +per existing logic. Internally every tag above carries an `-amd64`/`-arm64` +suffix that the manifest job merges away. + +For `hardened: false` builds: only the plain set is produced (unchanged). + +## Build flow (per image variant, inside the existing loop) + +1. **Build plain image** as today (`docker build --load … --target …`), tagged + as the plain primary `${IMAGE_NAME}:${TAG}`. **Remove the current in-place + patch-and-replace logic** so the plain tag keeps the un-patched bytes. +2. **Construct the plain tag list** exactly as today (primary, detailed, GHCR + mirrors, `-latest` when `latest-tag: true`, major when applicable). +3. **If `hardened: true`** — derive the hardened image *from the plain build* + (no second `docker build`): + - Run Trivy (`--pkg-types os --ignore-unfixed`) against the plain image. + - If fixable OS vulnerabilities exist, run `copa patch` to produce the + hardened image and tag it as the hardened primary. + - If no fixable OS vulnerabilities exist, `docker tag` the plain image as the + hardened primary (same content) so the `-hardened` tag always exists for + these builds. + - Construct the hardened tag list = the plain tag list with `-hardened` + inserted before the arch suffix. +4. **Severity gate** runs on the hardened image only (when `hardened: true` + and `fail_on_severity != NONE`), *before any push*. On failure the step + aborts (`set -e`), so nothing ships for the variant. Trivy reports and the + GitHub step-summary continue to be produced from the hardened image. +5. **Apply tags** — plain tags to the plain image, hardened tags to the hardened + image. +6. **Push** (when `PUSH == true`) both tag sets. +7. **Aggregate** both plain and hardened logical tags (arch suffix stripped) into + `aggregated_tags.txt` for the `process-tags` manifest job. +8. **Cleanup** both images to reclaim disk, as today. + +## Unchanged components + +- **`process-tags` job** — no changes. It dedups aggregated tags and creates a + multi-arch manifest per logical tag; hardened logical tags flow through the + same arch-stripping path automatically. +- **`test.yml`** — builds and scans images locally without publishing or tagging + hardened variants; no changes. +- **Dockerfile** — no changes; hardening is a post-build Copa step, not a build + target. + +## Documentation + +Add a short **"Hardened images"** section to `README.md` that: +- Explains the two tag flavors: unsuffixed = plain (built from the Dockerfile), + `-hardened` = Copa-patched for OS-level CVEs. +- States that `-hardened` is available only for stable release tags. +- Gives guidance on when to pick each (e.g. hardened for production / + vulnerability-scanned environments; plain for reproducibility or when you run + your own patching pipeline). + +## Out of scope (YAGNI) + +- No `-hardened` variant for dev/rolling images. +- No new workflow input to toggle hardened production; it follows the existing + `hardened` matrix flag. +- No changes to the gate's default severities or report formats.