From ad514f8cec9ae80016897c6747916a5f0d42e78e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 11:50:02 +0200 Subject: [PATCH 01/28] Added copa to the images build Changed publishing process to publish only patched images --- .github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0534d82..829648d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: publish: description: 'Publish images to registries' required: false - default: true + default: false type: boolean push: tags: @@ -55,6 +55,46 @@ 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: Install Copa and Trivy + run: | + set -eux + # Install Trivy + sudo apt-get update + sudo apt-get install -y wget apt-transport-https gnupg lsb-release + 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_VERSION=$(curl -s https://api.github.com/repos/project-copacetic/copacetic/releases/latest | jq -r '.tag_name' | sed 's/^v//') + curl -fsSL -o copa.tar.gz "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copa_${COPA_VERSION}_linux_$(dpkg --print-architecture).tar.gz" + tar -xzf copa.tar.gz copa + sudo mv copa /usr/local/bin/copa + rm copa.tar.gz + + - name: Start buildkit daemon + run: | + docker run --detach --rm --privileged \ + -p 127.0.0.1:8888:8888/tcp \ + --name buildkitd \ + --entrypoint buildkitd \ + moby/buildkit:latest \ + --addr tcp://0.0.0.0:8888 + + # Wait for buildkit to be ready + for i in $(seq 1 30); do + if docker exec buildkitd buildctl debug workers >/dev/null 2>&1; then + echo "BuildKit is ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "::error::BuildKit failed to start within 30 seconds" + exit 1 + fi + sleep 1 + done + - name: Configure and build images id: vars env: @@ -63,7 +103,6 @@ jobs: PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} run: | set -eux; - sudo apt-get update echo ${{ matrix.runner}} @@ -121,13 +160,56 @@ jobs: TAGS="$TAGS --tag $GHCR_TAG_MAJOR" fi - docker build --output "type=image,push=$PUSH" \ + # Build and load image locally + docker build --load \ --provenance=false \ --platform "linux/${ARCH_TAG}" \ --target="pimcore_php_$imageVariant" \ --build-arg PHP_VERSION="${PHP_VERSION}" \ --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ - ${TAGS} . + --tag "${IMAGE_NAME}:${TAG}" . + + # Patch OS-level vulnerabilities with Copa + echo "Scanning and patching image ${IMAGE_NAME}:${TAG}" + trivy image --vuln-type os --ignore-unfixed --format json \ + -o /tmp/trivy-report.json "${IMAGE_NAME}:${TAG}" + + 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 "${IMAGE_NAME}:${TAG}" \ + -r /tmp/trivy-report.json \ + -t "${TAG}-patched" \ + -a tcp://127.0.0.1:8888 + + # Verify the patched image exists + if ! docker image inspect "${IMAGE_NAME}:${TAG}-patched" > /dev/null 2>&1; then + echo "::error::Patched image not found for ${IMAGE_NAME}:${TAG}" + exit 1 + fi + + docker rmi "${IMAGE_NAME}:${TAG}" + docker tag "${IMAGE_NAME}:${TAG}-patched" "${IMAGE_NAME}:${TAG}" + docker rmi "${IMAGE_NAME}:${TAG}-patched" + echo "Successfully patched ${IMAGE_NAME}:${TAG}" + else + echo "No fixable OS vulnerabilities found, skipping Copa patch" + fi + rm -f /tmp/trivy-report.json + + # Apply all tags to the (patched) image + CLEAN_TAGS_FOR_TAGGING="${TAGS//--tag /}" + read -r -a ALL_TAGS <<< "$CLEAN_TAGS_FOR_TAGGING" + for additional_tag in "${ALL_TAGS[@]}"; do + if [ "$additional_tag" != "${IMAGE_NAME}:${TAG}" ]; then + docker tag "${IMAGE_NAME}:${TAG}" "$additional_tag" + fi + done + + # Push if publishing + if [[ "$PUSH" == "true" ]]; then + for additional_tag in "${ALL_TAGS[@]}"; do + docker push "$additional_tag" + done + fi docker inspect ${IMAGE_NAME}:${TAG} || true; @@ -145,8 +227,18 @@ jobs: done fi + # Clean up to save disk space + docker rmi "${IMAGE_NAME}:${TAG}" || true + for additional_tag in "${ALL_TAGS[@]}"; do + docker rmi "$additional_tag" 2>/dev/null || true + done + done + - name: Stop buildkit daemon + if: always() + run: docker stop buildkitd || true + - name: Upload aggregated tags if: github.event_name != 'workflow_dispatch' || inputs.publish uses: actions/upload-artifact@v7 From f21770e97ae711a05a8730a6c2a5f35bf216387a Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 15:44:52 +0200 Subject: [PATCH 02/28] Buildkit pinned to version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 829648d..aa7c4f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: -p 127.0.0.1:8888:8888/tcp \ --name buildkitd \ --entrypoint buildkitd \ - moby/buildkit:latest \ + moby/buildkit:0.30.0 \ --addr tcp://0.0.0.0:8888 # Wait for buildkit to be ready From 8bda2a1e1a1687865384fcdc94c643f358c2d124 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 16:06:26 +0200 Subject: [PATCH 03/28] Buildkit startup increased --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa7c4f2..e095f37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,12 +83,12 @@ jobs: --addr tcp://0.0.0.0:8888 # Wait for buildkit to be ready - for i in $(seq 1 30); do + for i in $(seq 1 60); do if docker exec buildkitd buildctl debug workers >/dev/null 2>&1; then echo "BuildKit is ready" break fi - if [ "$i" -eq 30 ]; then + if [ "$i" -eq 60 ]; then echo "::error::BuildKit failed to start within 30 seconds" exit 1 fi From fe0c54a5c22d76aafb0bf1da06303eeefb950245 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 16:08:17 +0200 Subject: [PATCH 04/28] Buildkit startup increased --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e095f37..e505e5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: -p 127.0.0.1:8888:8888/tcp \ --name buildkitd \ --entrypoint buildkitd \ - moby/buildkit:0.30.0 \ + moby/buildkit:v0.30.0 \ --addr tcp://0.0.0.0:8888 # Wait for buildkit to be ready @@ -89,7 +89,7 @@ jobs: break fi if [ "$i" -eq 60 ]; then - echo "::error::BuildKit failed to start within 30 seconds" + echo "::error::BuildKit failed to start within 60 seconds" exit 1 fi sleep 1 From f311e6fd88291cd192d5e3767be0e5492fbb1c7e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 16:12:45 +0200 Subject: [PATCH 05/28] Buildkit probe --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e505e5a..1e5b175 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,7 @@ jobs: # Wait for buildkit to be ready for i in $(seq 1 60); do - if docker exec buildkitd buildctl debug workers >/dev/null 2>&1; then + 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 From a063264acb518aad54cfbd3a001b057cb2e6b7da Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 16:37:14 +0200 Subject: [PATCH 06/28] Trivy severity gate --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e5b175..b971975 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: required: false default: false type: boolean + fail_on_severity: + description: 'Fail build if post-patch CVEs remain at this severity (CRITICAL, HIGH, MEDIUM, LOW, or NONE to disable)' + required: false + default: 'CRITICAL' + type: string push: tags: - 'v*.*' @@ -101,6 +106,7 @@ jobs: VERSION_OVERRIDE: "${{ matrix.build.version-override }}" ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL' }} run: | set -eux; @@ -195,6 +201,16 @@ jobs: fi rm -f /tmp/trivy-report.json + # Post-patch vulnerability gate + FAIL_SEVERITY="${FAIL_ON_SEVERITY:-CRITICAL}" + if [ "$FAIL_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" + trivy image --vuln-type os --ignore-unfixed \ + --exit-code 1 \ + --severity "$FAIL_SEVERITY" \ + "${IMAGE_NAME}:${TAG}" + fi + # Apply all tags to the (patched) image CLEAN_TAGS_FOR_TAGGING="${TAGS//--tag /}" read -r -a ALL_TAGS <<< "$CLEAN_TAGS_FOR_TAGGING" From 34f7c618d99fc66c264352abfccb201304d2b18b Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 17:13:27 +0200 Subject: [PATCH 07/28] Trivy severity gate --- .github/workflows/release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b971975..7ef08df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: fail_on_severity: description: 'Fail build if post-patch CVEs remain at this severity (CRITICAL, HIGH, MEDIUM, LOW, or NONE to disable)' required: false - default: 'CRITICAL' + default: 'CRITICAL,HIGH' type: string push: tags: @@ -209,6 +209,12 @@ jobs: --exit-code 1 \ --severity "$FAIL_SEVERITY" \ "${IMAGE_NAME}:${TAG}" + + echo "Running filesystem/library scan (fail on ${FAIL_SEVERITY}+)" + trivy image --vuln-type library --ignore-unfixed \ + --exit-code 1 \ + --severity "$FAIL_SEVERITY" \ + "${IMAGE_NAME}:${TAG}" fi # Apply all tags to the (patched) image From 348d92f12f5086ae499bc88c943f51115c758311 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 17:15:27 +0200 Subject: [PATCH 08/28] Trivy severity gate --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ef08df..101ca30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,16 +205,46 @@ jobs: FAIL_SEVERITY="${FAIL_ON_SEVERITY:-CRITICAL}" if [ "$FAIL_SEVERITY" != "NONE" ]; then echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" + + # OS scan + trivy image --vuln-type os --ignore-unfixed \ + --severity "$FAIL_SEVERITY" \ + --format table \ + -o /tmp/trivy-os-${TAG}.txt \ + "${IMAGE_NAME}:${TAG}" || true trivy image --vuln-type os --ignore-unfixed \ --exit-code 1 \ --severity "$FAIL_SEVERITY" \ "${IMAGE_NAME}:${TAG}" + # Library scan echo "Running filesystem/library scan (fail on ${FAIL_SEVERITY}+)" + trivy image --vuln-type library --ignore-unfixed \ + --severity "$FAIL_SEVERITY" \ + --format table \ + -o /tmp/trivy-lib-${TAG}.txt \ + "${IMAGE_NAME}:${TAG}" || true trivy image --vuln-type library --ignore-unfixed \ --exit-code 1 \ --severity "$FAIL_SEVERITY" \ "${IMAGE_NAME}:${TAG}" + + # Attach scan results to GitHub Actions job summary + { + echo "## Trivy Scan: ${IMAGE_NAME}:${TAG}" + echo "" + echo "### OS Vulnerabilities (${FAIL_SEVERITY}+)" + echo '```' + cat /tmp/trivy-os-${TAG}.txt 2>/dev/null || echo "No results" + echo '```' + echo "" + echo "### Library Vulnerabilities (${FAIL_SEVERITY}+)" + echo '```' + cat /tmp/trivy-lib-${TAG}.txt 2>/dev/null || echo "No results" + echo '```' + echo "" + } >> "$GITHUB_STEP_SUMMARY" + rm -f /tmp/trivy-os-${TAG}.txt /tmp/trivy-lib-${TAG}.txt fi # Apply all tags to the (patched) image From 7329f31de0424e647982a988c4715c8ccf472ebf Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 17:38:33 +0200 Subject: [PATCH 09/28] Trivy severity gate --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 101ca30..3e9ab8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,7 +72,7 @@ jobs: sudo apt-get install -y trivy # Install Copa - COPA_VERSION=$(curl -s https://api.github.com/repos/project-copacetic/copacetic/releases/latest | jq -r '.tag_name' | sed 's/^v//') + COPA_VERSION="0.14.1" curl -fsSL -o copa.tar.gz "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copa_${COPA_VERSION}_linux_$(dpkg --print-architecture).tar.gz" tar -xzf copa.tar.gz copa sudo mv copa /usr/local/bin/copa From 5f084204ebc582640bb63f8845c8267fc1be005c Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 17:46:44 +0200 Subject: [PATCH 10/28] Trivy severity gate --- .github/workflows/release.yml | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e9ab8d..29a8feb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,7 +177,7 @@ jobs: # Patch OS-level vulnerabilities with Copa echo "Scanning and patching image ${IMAGE_NAME}:${TAG}" - trivy image --vuln-type os --ignore-unfixed --format json \ + trivy image --pkg-types os --ignore-unfixed --format json \ -o /tmp/trivy-report.json "${IMAGE_NAME}:${TAG}" 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 @@ -206,25 +206,12 @@ jobs: if [ "$FAIL_SEVERITY" != "NONE" ]; then echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" - # OS scan - trivy image --vuln-type os --ignore-unfixed \ + trivy image --pkg-types os --ignore-unfixed \ --severity "$FAIL_SEVERITY" \ --format table \ -o /tmp/trivy-os-${TAG}.txt \ "${IMAGE_NAME}:${TAG}" || true - trivy image --vuln-type os --ignore-unfixed \ - --exit-code 1 \ - --severity "$FAIL_SEVERITY" \ - "${IMAGE_NAME}:${TAG}" - - # Library scan - echo "Running filesystem/library scan (fail on ${FAIL_SEVERITY}+)" - trivy image --vuln-type library --ignore-unfixed \ - --severity "$FAIL_SEVERITY" \ - --format table \ - -o /tmp/trivy-lib-${TAG}.txt \ - "${IMAGE_NAME}:${TAG}" || true - trivy image --vuln-type library --ignore-unfixed \ + trivy image --pkg-types os --ignore-unfixed \ --exit-code 1 \ --severity "$FAIL_SEVERITY" \ "${IMAGE_NAME}:${TAG}" @@ -238,13 +225,8 @@ jobs: cat /tmp/trivy-os-${TAG}.txt 2>/dev/null || echo "No results" echo '```' echo "" - echo "### Library Vulnerabilities (${FAIL_SEVERITY}+)" - echo '```' - cat /tmp/trivy-lib-${TAG}.txt 2>/dev/null || echo "No results" - echo '```' - echo "" } >> "$GITHUB_STEP_SUMMARY" - rm -f /tmp/trivy-os-${TAG}.txt /tmp/trivy-lib-${TAG}.txt + rm -f /tmp/trivy-os-${TAG}.txt fi # Apply all tags to the (patched) image From 12f5c12987642f85eae37101864591e6d826a22d Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 18:07:45 +0200 Subject: [PATCH 11/28] Trivy severity gate --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29a8feb..e2af4b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,6 +107,7 @@ jobs: ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL' }} + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 run: | set -eux; From 8fdacb9bc93a49c22852f4663fef55dcc072ee06 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Wed, 27 May 2026 10:31:46 +0200 Subject: [PATCH 12/28] Applied codereview remarks --- .github/workflows/release.yml | 19 +++-- Dockerfile.trivy-test | 24 +++++++ testimage.sh | 128 ++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 Dockerfile.trivy-test create mode 100755 testimage.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2af4b4..9039875 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: default: false type: boolean fail_on_severity: - description: 'Fail build if post-patch CVEs remain at this severity (CRITICAL, HIGH, MEDIUM, LOW, or NONE to disable)' + 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 @@ -65,7 +65,7 @@ jobs: set -eux # Install Trivy sudo apt-get update - sudo apt-get install -y wget apt-transport-https gnupg lsb-release + 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 @@ -73,10 +73,19 @@ jobs: # Install Copa COPA_VERSION="0.14.1" - curl -fsSL -o copa.tar.gz "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copa_${COPA_VERSION}_linux_$(dpkg --print-architecture).tar.gz" + 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 "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 + rm copa.tar.gz copacetic_checksums.txt - name: Start buildkit daemon run: | @@ -106,7 +115,7 @@ jobs: VERSION_OVERRIDE: "${{ matrix.build.version-override }}" ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} - FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL' }} + FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL,HIGH' }} TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 run: | set -eux; diff --git a/Dockerfile.trivy-test b/Dockerfile.trivy-test new file mode 100644 index 0000000..49487cb --- /dev/null +++ b/Dockerfile.trivy-test @@ -0,0 +1,24 @@ +FROM pimcore/pimcore:php8.1-v1-dev + +USER root + +RUN apt-get update && \ + apt-get install -y wget apt-transport-https gnupg lsb-release && \ + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor -o /usr/share/keyrings/trivy.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee /etc/apt/sources.list.d/trivy.list && \ + apt-get update && \ + apt-get install -y trivy && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /var/www/.cache && \ + chown -R www-data:www-data /var/www/.cache + +ENV XDG_CACHE_HOME=/var/www/.cache + +USER www-data + +WORKDIR /var/www/html + +# Run: docker exec trivy filesystem --severity HIGH,CRITICAL --format table / +CMD ["tail", "-f", "/dev/null"] diff --git a/testimage.sh b/testimage.sh new file mode 100755 index 0000000..2ed7aa0 --- /dev/null +++ b/testimage.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +set -euo pipefail + +REF="origin/1.x" +IMAGE_NAME="pimcore/pimcore" +LOCAL_TAG="php8.1-v1-dev" +WORKFLOW_TAG="php8.1-v1-dev-amd64" +PATCHED_TAG="${LOCAL_TAG}-copa" +PHP_VERSION="8.1" +DEBIAN_VERSION="bullseye" +TARGET="pimcore_php_fpm" +ARCH="amd64" +BUILDKIT_CONTAINER="buildkitd-copa-local" +WORKDIR="$(mktemp -d)" + +for bin in git tar docker trivy jq copa diff sort mktemp; do + command -v "$bin" >/dev/null 2>&1 || { + echo "Missing required command: $bin" >&2 + exit 1 + } +done + +cleanup() { + docker rm -f "$BUILDKIT_CONTAINER" >/dev/null 2>&1 || true + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +echo "== Fetch 2.x and export build context ==" +git fetch origin 2.x +git archive "$REF" | tar -x -C "$WORKDIR" + +echo +echo "== Build original image from 2.x ==" +docker build --load \ + --provenance=false \ + --platform "linux/${ARCH}" \ + --target "${TARGET}" \ + --build-arg PHP_VERSION="${PHP_VERSION}" \ + --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ + --tag "${IMAGE_NAME}:${WORKFLOW_TAG}" \ + --tag "${IMAGE_NAME}:${LOCAL_TAG}" \ + "$WORKDIR" + +echo +echo "== Trivy scan without Copa ==" +trivy image --pkg-types os --ignore-unfixed \ + --format table \ + -o /tmp/trivy-before.txt \ + "${IMAGE_NAME}:${LOCAL_TAG}" || true +cat /tmp/trivy-before.txt + +echo +echo "== Save package inventory before patch ==" +docker run --rm "${IMAGE_NAME}:${LOCAL_TAG}" \ + dpkg-query -W -f='${Package} ${Version}\n' | sort > /tmp/pkg-before.txt + +echo +echo "== Export Trivy JSON report ==" +trivy image --pkg-types os --ignore-unfixed \ + --format json \ + -o /tmp/trivy-report.json \ + "${IMAGE_NAME}:${LOCAL_TAG}" + +if jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' /tmp/trivy-report.json >/dev/null 2>&1; then + echo + echo "== Start BuildKit for Copa ==" + docker rm -f "$BUILDKIT_CONTAINER" >/dev/null 2>&1 || true + docker run --detach --rm --privileged \ + -p 127.0.0.1:8889:8888/tcp \ + --name "$BUILDKIT_CONTAINER" \ + --entrypoint buildkitd \ + moby/buildkit:v0.30.0 \ + --addr tcp://0.0.0.0:8888 >/dev/null + + # for i in $(seq 1 60); do + # if docker exec "$BUILDKIT_CONTAINER" buildctl --addr tcp://127.0.0.1:8889 debug workers >/dev/null 2>&1; then + # break + # fi + # if [ "$i" -eq 60 ]; then + # echo "BuildKit failed to start within 60 seconds" >&2 + # exit 1 + # fi + # sleep 1 + # done + + echo + echo "== Patch image with Copa ==" + copa patch \ + -i "${IMAGE_NAME}:${LOCAL_TAG}" \ + -r /tmp/trivy-report.json \ + -t "${PATCHED_TAG}" \ + -a tcp://127.0.0.1:8889 + + echo + echo "== Trivy scan with Copa ==" + trivy image --pkg-types os --ignore-unfixed \ + --format table \ + -o /tmp/trivy-after.txt \ + "${IMAGE_NAME}:${PATCHED_TAG}" || true + cat /tmp/trivy-after.txt + + echo + echo "== Save package inventory after patch ==" + docker run --rm "${IMAGE_NAME}:${PATCHED_TAG}" \ + dpkg-query -W -f='${Package} ${Version}\n' | sort > /tmp/pkg-after.txt + + echo + echo "== Package diff: original vs patched ==" + diff -u /tmp/pkg-before.txt /tmp/pkg-after.txt || true + + echo + echo "== Image IDs ==" + docker image inspect "${IMAGE_NAME}:${LOCAL_TAG}" --format 'original {{.RepoTags}} {{.Id}}' + docker image inspect "${IMAGE_NAME}:${PATCHED_TAG}" --format 'patched {{.RepoTags}} {{.Id}}' +else + echo + echo "No OS vulnerabilities reported by Trivy. Copa patch step skipped." +fi + +echo +echo "Artifacts written to:" +echo " /tmp/trivy-before.txt" +echo " /tmp/trivy-report.json" +echo " /tmp/pkg-before.txt" +echo " /tmp/trivy-after.txt" +echo " /tmp/pkg-after.txt" \ No newline at end of file From 0438ccd9995e174e94722738d288abfee0fad40f Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Wed, 27 May 2026 10:38:19 +0200 Subject: [PATCH 13/28] Improvements --- .github/workflows/release.yml | 29 +++++++- Dockerfile.trivy-test | 24 ------- testimage.sh | 128 ---------------------------------- 3 files changed, 26 insertions(+), 155 deletions(-) delete mode 100644 Dockerfile.trivy-test delete mode 100755 testimage.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9039875..6fa1c95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,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: @@ -72,7 +75,6 @@ jobs: sudo apt-get install -y trivy # Install Copa - COPA_VERSION="0.14.1" 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" @@ -93,7 +95,7 @@ jobs: -p 127.0.0.1:8888:8888/tcp \ --name buildkitd \ --entrypoint buildkitd \ - moby/buildkit:v0.30.0 \ + moby/buildkit:v${{ env.BUILDKIT_VERSION }} \ --addr tcp://0.0.0.0:8888 # Wait for buildkit to be ready @@ -116,10 +118,11 @@ jobs: ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL,HIGH' }} - TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + TRIVY_DB_REPOSITORY: ${{ env.TRIVY_DB_REPOSITORY }} run: | set -eux; + mkdir -p trivy-reports echo ${{ matrix.runner}} if [[ "${{ matrix.build.tag }}" =~ ^v?1.[0-9x]+$ ]]; then @@ -216,11 +219,23 @@ jobs: if [ "$FAIL_SEVERITY" != "NONE" ]; then echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" + # Get the image hash for report naming + IMAGE_HASH=$(docker image inspect "${IMAGE_NAME}:${TAG}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + trivy image --pkg-types os --ignore-unfixed \ --severity "$FAIL_SEVERITY" \ --format table \ -o /tmp/trivy-os-${TAG}.txt \ "${IMAGE_NAME}:${TAG}" || true + + # Save report with image hash for artifact upload + trivy image --pkg-types os --ignore-unfixed \ + --severity "$FAIL_SEVERITY" \ + --format json \ + -o "trivy-reports/${TAG}_${IMAGE_HASH}.json" \ + "${IMAGE_NAME}:${TAG}" || true + cp /tmp/trivy-os-${TAG}.txt "trivy-reports/${TAG}_${IMAGE_HASH}.txt" 2>/dev/null || true + trivy image --pkg-types os --ignore-unfixed \ --exit-code 1 \ --severity "$FAIL_SEVERITY" \ @@ -283,6 +298,14 @@ jobs: if: always() 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 }} + 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 diff --git a/Dockerfile.trivy-test b/Dockerfile.trivy-test deleted file mode 100644 index 49487cb..0000000 --- a/Dockerfile.trivy-test +++ /dev/null @@ -1,24 +0,0 @@ -FROM pimcore/pimcore:php8.1-v1-dev - -USER root - -RUN apt-get update && \ - apt-get install -y wget apt-transport-https gnupg lsb-release && \ - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor -o /usr/share/keyrings/trivy.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee /etc/apt/sources.list.d/trivy.list && \ - apt-get update && \ - apt-get install -y trivy && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /var/www/.cache && \ - chown -R www-data:www-data /var/www/.cache - -ENV XDG_CACHE_HOME=/var/www/.cache - -USER www-data - -WORKDIR /var/www/html - -# Run: docker exec trivy filesystem --severity HIGH,CRITICAL --format table / -CMD ["tail", "-f", "/dev/null"] diff --git a/testimage.sh b/testimage.sh deleted file mode 100755 index 2ed7aa0..0000000 --- a/testimage.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -REF="origin/1.x" -IMAGE_NAME="pimcore/pimcore" -LOCAL_TAG="php8.1-v1-dev" -WORKFLOW_TAG="php8.1-v1-dev-amd64" -PATCHED_TAG="${LOCAL_TAG}-copa" -PHP_VERSION="8.1" -DEBIAN_VERSION="bullseye" -TARGET="pimcore_php_fpm" -ARCH="amd64" -BUILDKIT_CONTAINER="buildkitd-copa-local" -WORKDIR="$(mktemp -d)" - -for bin in git tar docker trivy jq copa diff sort mktemp; do - command -v "$bin" >/dev/null 2>&1 || { - echo "Missing required command: $bin" >&2 - exit 1 - } -done - -cleanup() { - docker rm -f "$BUILDKIT_CONTAINER" >/dev/null 2>&1 || true - rm -rf "$WORKDIR" -} -trap cleanup EXIT - -echo "== Fetch 2.x and export build context ==" -git fetch origin 2.x -git archive "$REF" | tar -x -C "$WORKDIR" - -echo -echo "== Build original image from 2.x ==" -docker build --load \ - --provenance=false \ - --platform "linux/${ARCH}" \ - --target "${TARGET}" \ - --build-arg PHP_VERSION="${PHP_VERSION}" \ - --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ - --tag "${IMAGE_NAME}:${WORKFLOW_TAG}" \ - --tag "${IMAGE_NAME}:${LOCAL_TAG}" \ - "$WORKDIR" - -echo -echo "== Trivy scan without Copa ==" -trivy image --pkg-types os --ignore-unfixed \ - --format table \ - -o /tmp/trivy-before.txt \ - "${IMAGE_NAME}:${LOCAL_TAG}" || true -cat /tmp/trivy-before.txt - -echo -echo "== Save package inventory before patch ==" -docker run --rm "${IMAGE_NAME}:${LOCAL_TAG}" \ - dpkg-query -W -f='${Package} ${Version}\n' | sort > /tmp/pkg-before.txt - -echo -echo "== Export Trivy JSON report ==" -trivy image --pkg-types os --ignore-unfixed \ - --format json \ - -o /tmp/trivy-report.json \ - "${IMAGE_NAME}:${LOCAL_TAG}" - -if jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' /tmp/trivy-report.json >/dev/null 2>&1; then - echo - echo "== Start BuildKit for Copa ==" - docker rm -f "$BUILDKIT_CONTAINER" >/dev/null 2>&1 || true - docker run --detach --rm --privileged \ - -p 127.0.0.1:8889:8888/tcp \ - --name "$BUILDKIT_CONTAINER" \ - --entrypoint buildkitd \ - moby/buildkit:v0.30.0 \ - --addr tcp://0.0.0.0:8888 >/dev/null - - # for i in $(seq 1 60); do - # if docker exec "$BUILDKIT_CONTAINER" buildctl --addr tcp://127.0.0.1:8889 debug workers >/dev/null 2>&1; then - # break - # fi - # if [ "$i" -eq 60 ]; then - # echo "BuildKit failed to start within 60 seconds" >&2 - # exit 1 - # fi - # sleep 1 - # done - - echo - echo "== Patch image with Copa ==" - copa patch \ - -i "${IMAGE_NAME}:${LOCAL_TAG}" \ - -r /tmp/trivy-report.json \ - -t "${PATCHED_TAG}" \ - -a tcp://127.0.0.1:8889 - - echo - echo "== Trivy scan with Copa ==" - trivy image --pkg-types os --ignore-unfixed \ - --format table \ - -o /tmp/trivy-after.txt \ - "${IMAGE_NAME}:${PATCHED_TAG}" || true - cat /tmp/trivy-after.txt - - echo - echo "== Save package inventory after patch ==" - docker run --rm "${IMAGE_NAME}:${PATCHED_TAG}" \ - dpkg-query -W -f='${Package} ${Version}\n' | sort > /tmp/pkg-after.txt - - echo - echo "== Package diff: original vs patched ==" - diff -u /tmp/pkg-before.txt /tmp/pkg-after.txt || true - - echo - echo "== Image IDs ==" - docker image inspect "${IMAGE_NAME}:${LOCAL_TAG}" --format 'original {{.RepoTags}} {{.Id}}' - docker image inspect "${IMAGE_NAME}:${PATCHED_TAG}" --format 'patched {{.RepoTags}} {{.Id}}' -else - echo - echo "No OS vulnerabilities reported by Trivy. Copa patch step skipped." -fi - -echo -echo "Artifacts written to:" -echo " /tmp/trivy-before.txt" -echo " /tmp/trivy-report.json" -echo " /tmp/pkg-before.txt" -echo " /tmp/trivy-after.txt" -echo " /tmp/pkg-after.txt" \ No newline at end of file From fa172d8b2607f34830b3e1a8f3ff32e9a64897ca Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Wed, 27 May 2026 11:02:51 +0200 Subject: [PATCH 14/28] Applied codereview remarks --- .github/workflows/release.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fa1c95..4c6e670 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: # 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 + 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 @@ -215,7 +215,7 @@ jobs: rm -f /tmp/trivy-report.json # Post-patch vulnerability gate - FAIL_SEVERITY="${FAIL_ON_SEVERITY:-CRITICAL}" + FAIL_SEVERITY="$FAIL_ON_SEVERITY" if [ "$FAIL_SEVERITY" != "NONE" ]; then echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" @@ -263,11 +263,9 @@ jobs: fi done - # Push if publishing + # Push if publishing (parallel for speed) if [[ "$PUSH" == "true" ]]; then - for additional_tag in "${ALL_TAGS[@]}"; do - docker push "$additional_tag" - done + printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" fi docker inspect ${IMAGE_NAME}:${TAG} || true; @@ -302,7 +300,7 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: trivy-reports_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }} + 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 From e675d559ac091fdd9756cc7d48b5c9225f1b2560 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Mon, 15 Jun 2026 12:07:16 +0200 Subject: [PATCH 15/28] Added build flag --- .github/workflows/release.yml | 76 ++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c6e670..a987599 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,21 +36,21 @@ 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.1', 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, imagePatch: false } + - { tag: '1.x', php: '8.2', distro: bullseye, version-override: "v1-dev", latest-tag: false, imagePatch: false } + - { tag: 'v1.6', php: '8.1', distro: bullseye, version-override: "", latest-tag: true, imagePatch: true } + - { tag: 'v1.6', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, imagePatch: true } + - { tag: 'v2.3', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, imagePatch: true } + - { tag: '2.x', php: '8.2', distro: bullseye, version-override: "v2-dev", latest-tag: false, imagePatch: false } + - { tag: 'v3.8', php: '8.2', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } + - { tag: 'v3.8', php: '8.3', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } + - { tag: '3.x', php: '8.2', distro: bookworm, version-override: "v3-dev", latest-tag: false, imagePatch: false } + - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false, imagePatch: false } + - { tag: 'v4.1', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } + - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false, imagePatch: false } + - { tag: '5.x', php: '8.5', distro: trixie, version-override: "", latest-tag: false, imagePatch: false } + - { tag: 'v5.1', php: '8.5', distro: trixie, version-override: "", latest-tag: true, imagePatch: true } + - { tag: '5.x', php: '8.5', distro: trixie, version-override: "v5-dev", latest-tag: false, imagePatch: false } steps: - uses: actions/checkout@v5 @@ -189,30 +189,34 @@ jobs: --tag "${IMAGE_NAME}:${TAG}" . # Patch OS-level vulnerabilities with Copa - echo "Scanning and patching image ${IMAGE_NAME}:${TAG}" - trivy image --pkg-types os --ignore-unfixed --format json \ - -o /tmp/trivy-report.json "${IMAGE_NAME}:${TAG}" - - 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 "${IMAGE_NAME}:${TAG}" \ - -r /tmp/trivy-report.json \ - -t "${TAG}-patched" \ - -a tcp://127.0.0.1:8888 - - # Verify the patched image exists - if ! docker image inspect "${IMAGE_NAME}:${TAG}-patched" > /dev/null 2>&1; then - echo "::error::Patched image not found for ${IMAGE_NAME}:${TAG}" - exit 1 + if [ "${{ matrix.build.imagePatch }}" = "true" ]; then + echo "Scanning and patching image ${IMAGE_NAME}:${TAG}" + trivy image --pkg-types os --ignore-unfixed --format json \ + -o /tmp/trivy-report.json "${IMAGE_NAME}:${TAG}" + + 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 "${IMAGE_NAME}:${TAG}" \ + -r /tmp/trivy-report.json \ + -t "${TAG}-patched" \ + -a tcp://127.0.0.1:8888 + + # Verify the patched image exists + if ! docker image inspect "${IMAGE_NAME}:${TAG}-patched" > /dev/null 2>&1; then + echo "::error::Patched image not found for ${IMAGE_NAME}:${TAG}" + exit 1 + fi + + docker rmi "${IMAGE_NAME}:${TAG}" + docker tag "${IMAGE_NAME}:${TAG}-patched" "${IMAGE_NAME}:${TAG}" + docker rmi "${IMAGE_NAME}:${TAG}-patched" + echo "Successfully patched ${IMAGE_NAME}:${TAG}" + else + echo "No fixable OS vulnerabilities found, skipping Copa patch" fi - - docker rmi "${IMAGE_NAME}:${TAG}" - docker tag "${IMAGE_NAME}:${TAG}-patched" "${IMAGE_NAME}:${TAG}" - docker rmi "${IMAGE_NAME}:${TAG}-patched" - echo "Successfully patched ${IMAGE_NAME}:${TAG}" + rm -f /tmp/trivy-report.json else - echo "No fixable OS vulnerabilities found, skipping Copa patch" + echo "Copa patching skipped (imagePatch: false)" fi - rm -f /tmp/trivy-report.json # Post-patch vulnerability gate FAIL_SEVERITY="$FAIL_ON_SEVERITY" From da9ec18c0e1de05cbfa4be73845f0074215e1b0f Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Mon, 15 Jun 2026 13:17:16 +0200 Subject: [PATCH 16/28] Add design spec for -hardened image tag Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-15-hardened-image-tag-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md 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..27a01bb --- /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.1`, `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 `imagePatch: 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 an `imagePatch: 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 `imagePatch: 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 `imagePatch: 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 `imagePatch: 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 + `imagePatch` matrix flag. +- No changes to the gate's default severities or report formats. From 85763f308145afe85ac105eac3bad2fdbe20dd06 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Mon, 15 Jun 2026 14:02:18 +0200 Subject: [PATCH 17/28] Publish plain and -hardened image flavors Previously Copa-patched images replaced the plain tags in place, so users could only pull the hardened image for stable releases. Now each imagePatch build publishes both: the plain image under the unsuffixed tag and the Copa-patched image under a -hardened suffix. The severity gate runs on the hardened image only; the plain image is published as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 167 +++++++++++++++++++--------------- README.md | 15 +++ 2 files changed, 109 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a987599..de1004d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,91 +179,112 @@ jobs: TAGS="$TAGS --tag $GHCR_TAG_MAJOR" fi - # Build and load image locally + # Build and load the plain image locally. The plain image is + # published as-is under the unsuffixed tags; it is never patched. + PLAIN_IMAGE="${IMAGE_NAME}:${TAG}" docker build --load \ --provenance=false \ --platform "linux/${ARCH_TAG}" \ --target="pimcore_php_$imageVariant" \ --build-arg PHP_VERSION="${PHP_VERSION}" \ --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ - --tag "${IMAGE_NAME}:${TAG}" . + --tag "${PLAIN_IMAGE}" . - # Patch OS-level vulnerabilities with Copa + # Plain tag set (every entry carries the -${ARCH_TAG} suffix). + CLEAN_PLAIN_TAGS="${TAGS//--tag /}" + read -r -a PLAIN_TAGS <<< "$CLEAN_PLAIN_TAGS" + + # ALL_TAGS accumulates everything we push, aggregate and clean up. + ALL_TAGS=("${PLAIN_TAGS[@]}") + + # Produce the hardened (Copa-patched) image and its -hardened tag set. if [ "${{ matrix.build.imagePatch }}" = "true" ]; then - echo "Scanning and patching image ${IMAGE_NAME}:${TAG}" + HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" + + echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" trivy image --pkg-types os --ignore-unfixed --format json \ - -o /tmp/trivy-report.json "${IMAGE_NAME}:${TAG}" + -o /tmp/trivy-report.json "${PLAIN_IMAGE}" 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 "${IMAGE_NAME}:${TAG}" \ + # Patch from the plain image into a new -hardened tag; the plain image is left intact. + copa patch -i "${PLAIN_IMAGE}" \ -r /tmp/trivy-report.json \ - -t "${TAG}-patched" \ + -t "${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" \ -a tcp://127.0.0.1:8888 - # Verify the patched image exists - if ! docker image inspect "${IMAGE_NAME}:${TAG}-patched" > /dev/null 2>&1; then - echo "::error::Patched image not found for ${IMAGE_NAME}:${TAG}" + if ! docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1; then + echo "::error::Hardened image not found for ${PLAIN_IMAGE}" exit 1 fi - - docker rmi "${IMAGE_NAME}:${TAG}" - docker tag "${IMAGE_NAME}:${TAG}-patched" "${IMAGE_NAME}:${TAG}" - docker rmi "${IMAGE_NAME}:${TAG}-patched" - echo "Successfully patched ${IMAGE_NAME}:${TAG}" + echo "Successfully patched ${PLAIN_IMAGE} into ${HARDENED_IMAGE}" else - echo "No fixable OS vulnerabilities found, skipping Copa patch" + # Nothing fixable: the hardened tag mirrors the plain image so it always exists. + echo "No fixable OS vulnerabilities found; hardened image mirrors plain" + docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" fi rm -f /tmp/trivy-report.json + + # Derive the hardened tag set by inserting -hardened before the arch suffix. + HARDENED_TAGS=() + for plain_tag in "${PLAIN_TAGS[@]}"; do + HARDENED_TAGS+=("${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}") + done + + # Post-patch vulnerability gate, run on the hardened image only. + # The plain image is intentionally ungated. A failure here aborts the + # step before any push, so nothing ships for this variant. + FAIL_SEVERITY="$FAIL_ON_SEVERITY" + if [ "$FAIL_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" + + # Get the image hash for report naming + IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + + trivy image --pkg-types os --ignore-unfixed \ + --severity "$FAIL_SEVERITY" \ + --format table \ + -o /tmp/trivy-os-${TAG}.txt \ + "${HARDENED_IMAGE}" || true + + # Save report with image hash for artifact upload + trivy image --pkg-types os --ignore-unfixed \ + --severity "$FAIL_SEVERITY" \ + --format json \ + -o "trivy-reports/${TAG}-hardened_${IMAGE_HASH}.json" \ + "${HARDENED_IMAGE}" || true + cp /tmp/trivy-os-${TAG}.txt "trivy-reports/${TAG}-hardened_${IMAGE_HASH}.txt" 2>/dev/null || true + + trivy image --pkg-types os --ignore-unfixed \ + --exit-code 1 \ + --severity "$FAIL_SEVERITY" \ + "${HARDENED_IMAGE}" + + # Attach scan results to GitHub Actions job summary + { + echo "## Trivy Scan: ${HARDENED_IMAGE}" + echo "" + echo "### OS Vulnerabilities (${FAIL_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 + fi + + ALL_TAGS+=("${HARDENED_TAGS[@]}") else echo "Copa patching skipped (imagePatch: false)" fi - # Post-patch vulnerability gate - FAIL_SEVERITY="$FAIL_ON_SEVERITY" - if [ "$FAIL_SEVERITY" != "NONE" ]; then - echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" - - # Get the image hash for report naming - IMAGE_HASH=$(docker image inspect "${IMAGE_NAME}:${TAG}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) - - trivy image --pkg-types os --ignore-unfixed \ - --severity "$FAIL_SEVERITY" \ - --format table \ - -o /tmp/trivy-os-${TAG}.txt \ - "${IMAGE_NAME}:${TAG}" || true - - # Save report with image hash for artifact upload - trivy image --pkg-types os --ignore-unfixed \ - --severity "$FAIL_SEVERITY" \ - --format json \ - -o "trivy-reports/${TAG}_${IMAGE_HASH}.json" \ - "${IMAGE_NAME}:${TAG}" || true - cp /tmp/trivy-os-${TAG}.txt "trivy-reports/${TAG}_${IMAGE_HASH}.txt" 2>/dev/null || true - - trivy image --pkg-types os --ignore-unfixed \ - --exit-code 1 \ - --severity "$FAIL_SEVERITY" \ - "${IMAGE_NAME}:${TAG}" - - # Attach scan results to GitHub Actions job summary - { - echo "## Trivy Scan: ${IMAGE_NAME}:${TAG}" - echo "" - echo "### OS Vulnerabilities (${FAIL_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 - fi - - # Apply all tags to the (patched) image - CLEAN_TAGS_FOR_TAGGING="${TAGS//--tag /}" - read -r -a ALL_TAGS <<< "$CLEAN_TAGS_FOR_TAGGING" + # Apply every tag to its source image (plain or hardened). for additional_tag in "${ALL_TAGS[@]}"; do - if [ "$additional_tag" != "${IMAGE_NAME}:${TAG}" ]; then - docker tag "${IMAGE_NAME}:${TAG}" "$additional_tag" + 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 @@ -272,24 +293,24 @@ jobs: printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" fi - docker inspect ${IMAGE_NAME}:${TAG} || true; + docker inspect "${PLAIN_IMAGE}" || true; - # Only aggregate tags if we're publishing + # Only aggregate tags if we're publishing. Strip the arch suffix so the + # process-tags job can merge per-arch tags into a multi-arch manifest. 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 + for tag in "${ALL_TAGS[@]}"; do + logical_tag="${tag//-arm64/}" + logical_tag="${logical_tag//-amd64/}" + echo "Processing tag: $logical_tag" + echo "$logical_tag" >> aggregated_tags.txt done fi # Clean up to save disk space - docker rmi "${IMAGE_NAME}:${TAG}" || true + docker rmi "${PLAIN_IMAGE}" || true + if [ "${{ matrix.build.imagePatch }}" = "true" ]; then + docker rmi "${HARDENED_IMAGE}" || true + fi for additional_tag in "${ALL_TAGS[@]}"; do docker rmi "$additional_tag" 2>/dev/null || true done 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: From 3047b81f3903e4a1671fbf36ba4b781e8e886d16 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:33:30 +0200 Subject: [PATCH 18/28] Rename matrix key imagePatch to hardened; update design spec imagePatch described the internal mechanism (Copa patching). hardened matches the user-visible -hardened image tag and aligns with the language used everywhere else in the workflow and spec. Updates all matrix entries in release.yml and the non-historical sections of the design spec (Decisions, Tag scheme, Build flow, Out-of-scope). The Problem section keeps imagePatch as historical context describing the state before this change. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 36 +++++++++---------- .../2026-06-15-hardened-image-tag-design.md | 12 +++---- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de1004d..2f54174 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,21 +36,21 @@ jobs: - ubuntu-22.04 - ubuntu-22.04-arm build: - - { tag: '1.x', php: '8.1', distro: bullseye, version-override: "v1-dev", latest-tag: false, imagePatch: false } - - { tag: '1.x', php: '8.2', distro: bullseye, version-override: "v1-dev", latest-tag: false, imagePatch: false } - - { tag: 'v1.6', php: '8.1', distro: bullseye, version-override: "", latest-tag: true, imagePatch: true } - - { tag: 'v1.6', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, imagePatch: true } - - { tag: 'v2.3', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, imagePatch: true } - - { tag: '2.x', php: '8.2', distro: bullseye, version-override: "v2-dev", latest-tag: false, imagePatch: false } - - { tag: 'v3.8', php: '8.2', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } - - { tag: 'v3.8', php: '8.3', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } - - { tag: '3.x', php: '8.2', distro: bookworm, version-override: "v3-dev", latest-tag: false, imagePatch: false } - - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false, imagePatch: false } - - { tag: 'v4.1', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, imagePatch: true } - - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false, imagePatch: false } - - { tag: '5.x', php: '8.5', distro: trixie, version-override: "", latest-tag: false, imagePatch: false } - - { tag: 'v5.1', php: '8.5', distro: trixie, version-override: "", latest-tag: true, imagePatch: true } - - { tag: '5.x', php: '8.5', distro: trixie, version-override: "v5-dev", latest-tag: false, imagePatch: 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.1', 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: '5.x', php: '8.5', distro: trixie, version-override: "", 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 @@ -198,7 +198,7 @@ jobs: ALL_TAGS=("${PLAIN_TAGS[@]}") # Produce the hardened (Copa-patched) image and its -hardened tag set. - if [ "${{ matrix.build.imagePatch }}" = "true" ]; then + if [ "${{ matrix.build.hardened }}" = "true" ]; then HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" @@ -274,7 +274,7 @@ jobs: ALL_TAGS+=("${HARDENED_TAGS[@]}") else - echo "Copa patching skipped (imagePatch: false)" + echo "Copa patching skipped (hardened: false)" fi # Apply every tag to its source image (plain or hardened). @@ -308,7 +308,7 @@ jobs: # Clean up to save disk space docker rmi "${PLAIN_IMAGE}" || true - if [ "${{ matrix.build.imagePatch }}" = "true" ]; then + if [ "${{ matrix.build.hardened }}" = "true" ]; then docker rmi "${HARDENED_IMAGE}" || true fi for additional_tag in "${ALL_TAGS[@]}"; do 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 index 27a01bb..1113a6d 100644 --- a/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md +++ b/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md @@ -25,7 +25,7 @@ releases — Copa hardening is mandatory and invisible. We want users to choose: 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 `imagePatch: true` builds.** Dev/rolling tags (`1.x`, `2.x`, +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 @@ -43,7 +43,7 @@ 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 an `imagePatch: true` build, both tag sets are produced and pushed: +For a `hardened: true` build, both tag sets are produced and pushed: | Tag role | Plain (default, unchanged) | Hardened (new) | |-----------------|----------------------------|---------------------------------------| @@ -56,7 +56,7 @@ For an `imagePatch: true` build, both tag sets are produced and pushed: per existing logic. Internally every tag above carries an `-amd64`/`-arm64` suffix that the manifest job merges away. -For `imagePatch: false` builds: only the plain set is produced (unchanged). +For `hardened: false` builds: only the plain set is produced (unchanged). ## Build flow (per image variant, inside the existing loop) @@ -65,7 +65,7 @@ For `imagePatch: false` builds: only the plain set is produced (unchanged). 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 `imagePatch: true`** — derive the hardened image *from the plain build* +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 @@ -75,7 +75,7 @@ For `imagePatch: false` builds: only the plain set is produced (unchanged). 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 `imagePatch: true` +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. @@ -110,5 +110,5 @@ Add a short **"Hardened images"** section to `README.md` that: - No `-hardened` variant for dev/rolling images. - No new workflow input to toggle hardened production; it follows the existing - `imagePatch` matrix flag. + `hardened` matrix flag. - No changes to the gate's default severities or report formats. From 5567217d23676cb0b8ac266d9ed383e00d17c858 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:34:05 +0200 Subject: [PATCH 19/28] Update build matrix: bump v4.1 to v4.2, drop plain-only 5.x entry v4.2 was released and the matrix was not updated yet. The 5.x entry with an empty version-override had no practical effect: every other rolling-branch entry pairs a plain-only row (hardened: false with a vN-dev version-override) with a stable release row. The plain 5.x with no override duplicated the dev row without providing the dev version, so jobs ran against the branch tip but tagged their images without a meaningful version string. Removing it keeps the pattern consistent. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f54174..be9b3a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,9 +46,8 @@ jobs: - { 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.1', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { 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: '5.x', php: '8.5', distro: trixie, version-override: "", 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 } From 979c8e604685e1efecdda2a06effbbc04491fa2c Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:44:48 +0200 Subject: [PATCH 20/28] Refactor: split monolithic build step into three focused steps The single "Configure and build images" step was doing four unrelated things: building, patching, gating, and pushing. At ~200 lines it was hard to follow and made it impossible to skip Copa/Trivy work on jobs that do not need it. Split into three steps: "Build plain images" -- builds every image variant and writes per- variant state (plain_image.txt, plain_tags.txt, base_tag.txt, version.txt, tag.txt) to .docker-state// for later steps to consume. PHP_SUB_VERSION is now fetched once before the loop instead of once per variant. "Scan, patch, and gate hardened images" -- reads the state files, runs Copa and Trivy for hardened: true jobs only. Reduces post-patch Trivy from three invocations to two: one JSON scan (artifact + gate source) and one table scan (human display). The gate now reads the existing JSON via jq instead of running a third scan with --exit-code. Writes hardened_image.txt and hardened_tags.txt for the next step. "Tag, push, and aggregate" -- reads state for all variants, applies tags, pushes in parallel, aggregates logical tags, and cleans up per variant to keep disk usage bounded across the loop. Also fixes VERSION_MAJOR extraction: the old VERSION replacement substitution stripped every digit after a dot (e.g. v5.10 became v5.1 instead of v5). Using parameter expansion strips only the last component. The regex guarding the block is also tightened so the unescaped dot no longer matches any character. --- .github/workflows/release.yml | 291 +++++++++++++++++----------------- 1 file changed, 149 insertions(+), 142 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be9b3a3..0d690e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,170 +110,185 @@ jobs: sleep 1 done - - name: Configure and build images - id: vars + - 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 }} - FAIL_ON_SEVERITY: ${{ inputs.fail_on_severity || 'CRITICAL,HIGH' }} - TRIVY_DB_REPOSITORY: ${{ env.TRIVY_DB_REPOSITORY }} run: | - set -eux; - - mkdir -p trivy-reports - 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 }}" + echo "${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}" - - GHCR_TAG="ghcr.io/pimcore/pimcore:${TAG}" - GHCR_TAG_DETAILED="ghcr.io/pimcore/pimcore:${TAG_DETAILED}" - - TAGS="--tag ${IMAGE_NAME}:${TAG}" - TAGS="$TAGS --tag ${IMAGE_NAME}:${TAG_DETAILED}" + PLAIN_IMAGE="${IMAGE_NAME}:${TAG}" - TAGS="$TAGS --tag $GHCR_TAG" - TAGS="$TAGS --tag $GHCR_TAG_DETAILED" + # 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" - # 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}" - 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" - fi + 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" - # Build and load the plain image locally. The plain image is - # published as-is under the unsuffixed tags; it is never patched. - PLAIN_IMAGE="${IMAGE_NAME}:${TAG}" docker build --load \ --provenance=false \ --platform "linux/${ARCH_TAG}" \ - --target="pimcore_php_$imageVariant" \ - --build-arg PHP_VERSION="${PHP_VERSION}" \ - --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ + --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 - # Plain tag set (every entry carries the -${ARCH_TAG} suffix). - CLEAN_PLAIN_TAGS="${TAGS//--tag /}" - read -r -a PLAIN_TAGS <<< "$CLEAN_PLAIN_TAGS" + read -r -a imageVariants < .docker-state/variants.txt - # ALL_TAGS accumulates everything we push, aggregate and clean up. - ALL_TAGS=("${PLAIN_TAGS[@]}") + 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}" - # Produce the hardened (Copa-patched) image and its -hardened tag set. - if [ "${{ matrix.build.hardened }}" = "true" ]; then - HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" - - 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}" - - 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 - # Patch from the plain image into a new -hardened tag; the plain image is left intact. - copa patch -i "${PLAIN_IMAGE}" \ - -r /tmp/trivy-report.json \ - -t "${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" \ - -a tcp://127.0.0.1:8888 - - 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: the hardened tag mirrors the plain image so it always exists. - echo "No fixable OS vulnerabilities found; hardened image mirrors plain" - docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" - fi - rm -f /tmp/trivy-report.json + 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}" - # Derive the hardened tag set by inserting -hardened before the arch suffix. - HARDENED_TAGS=() - for plain_tag in "${PLAIN_TAGS[@]}"; do - HARDENED_TAGS+=("${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}") - done + 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 "${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" \ + -a tcp://127.0.0.1:8888 - # Post-patch vulnerability gate, run on the hardened image only. - # The plain image is intentionally ungated. A failure here aborts the - # step before any push, so nothing ships for this variant. - FAIL_SEVERITY="$FAIL_ON_SEVERITY" - if [ "$FAIL_SEVERITY" != "NONE" ]; then - echo "Running post-patch scan (fail on ${FAIL_SEVERITY}+)" - - # Get the image hash for report naming - IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) - - trivy image --pkg-types os --ignore-unfixed \ - --severity "$FAIL_SEVERITY" \ - --format table \ - -o /tmp/trivy-os-${TAG}.txt \ - "${HARDENED_IMAGE}" || true - - # Save report with image hash for artifact upload - trivy image --pkg-types os --ignore-unfixed \ - --severity "$FAIL_SEVERITY" \ - --format json \ - -o "trivy-reports/${TAG}-hardened_${IMAGE_HASH}.json" \ - "${HARDENED_IMAGE}" || true - cp /tmp/trivy-os-${TAG}.txt "trivy-reports/${TAG}-hardened_${IMAGE_HASH}.txt" 2>/dev/null || true - - trivy image --pkg-types os --ignore-unfixed \ - --exit-code 1 \ - --severity "$FAIL_SEVERITY" \ - "${HARDENED_IMAGE}" - - # Attach scan results to GitHub Actions job summary - { - echo "## Trivy Scan: ${HARDENED_IMAGE}" - echo "" - echo "### OS Vulnerabilities (${FAIL_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 + 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 + 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 2>&1; then + echo "::error::${HARDENED_IMAGE} has unfixed ${FAIL_ON_SEVERITY} vulnerabilities after patching" + exit 1 + fi + fi + done + - 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 + + read -r -a 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[@]}") + + 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[@]}") - else - echo "Copa patching skipped (hardened: false)" fi # Apply every tag to its source image (plain or hardened). @@ -287,33 +302,25 @@ jobs: fi done - # Push if publishing (parallel for speed) + # Push and aggregate logical tags (parallel push for speed). if [[ "$PUSH" == "true" ]]; then printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - fi - docker inspect "${PLAIN_IMAGE}" || true; - - # Only aggregate tags if we're publishing. Strip the arch suffix so the - # process-tags job can merge per-arch tags into a multi-arch manifest. - if [[ "$PUSH" == "true" ]]; then for tag in "${ALL_TAGS[@]}"; do logical_tag="${tag//-arm64/}" logical_tag="${logical_tag//-amd64/}" - echo "Processing tag: $logical_tag" echo "$logical_tag" >> aggregated_tags.txt done fi - # Clean up to save disk space + # Clean up per variant to reclaim disk space before the next variant. docker rmi "${PLAIN_IMAGE}" || true - if [ "${{ matrix.build.hardened }}" = "true" ]; then + if [ -n "${HARDENED_IMAGE}" ]; then docker rmi "${HARDENED_IMAGE}" || true fi for additional_tag in "${ALL_TAGS[@]}"; do docker rmi "$additional_tag" 2>/dev/null || true done - done - name: Stop buildkit daemon From 556b609a334851e6195b8f3d1b19b5f3ac1d1034 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:45:24 +0200 Subject: [PATCH 21/28] Skip Copa/Trivy install and BuildKit for non-hardened matrix jobs Previously the "Install Copa and Trivy" and "Start buildkit daemon" steps ran on all 28 matrix jobs, even those with hardened: false that never call copa or trivy. Each wasted ~30-60s installing packages and starting a container that would never be used. Adding if: matrix.build.hardened to both steps skips them on plain-only jobs. The "Stop buildkit daemon" step is also guarded the same way since there is nothing to stop when buildkitd was never started. --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d690e1..3c017bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,7 @@ jobs: run: echo ${{ secrets.IMAGES_REPO_TOKEN }} | docker login ghcr.io -u ${{ secrets.IMAGES_REPO_USERNAME }} --password-stdin - name: Install Copa and Trivy + if: ${{ matrix.build.hardened }} run: | set -eux # Install Trivy @@ -89,6 +90,7 @@ jobs: 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 \ @@ -324,7 +326,7 @@ jobs: done - name: Stop buildkit daemon - if: always() + if: ${{ always() && matrix.build.hardened }} run: docker stop buildkitd || true - name: Upload trivy reports From 53f1c58ce40095504ee1476974633b0f7187a9a8 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:45:40 +0200 Subject: [PATCH 22/28] Use grep -F for Copa checksum lookup to avoid regex interpretation The filename passed to grep contains dots, which grep treats as wildcards in regex mode. With grep -F (fixed string), the match is literal, so copa_0.14.1_linux_amd64.tar.gz cannot accidentally match copa_0X14Y1_linux_amd64.tar.gz or any other near-miss in the checksums file. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c017bb..1e45c3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: 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 "copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" copacetic_checksums.txt | awk '{print $1}') + 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}" From 935cf97f1210a6d276f31972269c9473373a63fd Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:45:56 +0200 Subject: [PATCH 23/28] Add if-no-files-found: ignore to aggregated tags artifact upload When PUSH is false (workflow_dispatch without publish: true) the aggregated_tags.txt file is never written. Without this option the upload-artifact action would error on the missing file even though the step is correctly gated to only run when publishing. Ignore silences that spurious failure without masking genuine upload problems. --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e45c3b..2520bc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -343,6 +343,7 @@ jobs: 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 From 835aed15e267dfab25621afad23e1dcaed534b41 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:25:45 +0200 Subject: [PATCH 24/28] Fix variants.txt inconsistency: use newline-separated format and mapfile variants.txt was written space-separated with echo "${imageVariants[*]}" and read back with read -r -a. Switch to one-variant-per-line with printf '%s\n' to match the newline-separated format used by every other state file, and use mapfile -t consistently at all three read sites. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7585578..2554ea4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,7 @@ jobs: else imageVariants=("min" "default" "max" "debug" "supervisord") fi - echo "${imageVariants[*]}" > .docker-state/variants.txt + 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;') @@ -193,7 +193,7 @@ jobs: set -eux mkdir -p trivy-reports - read -r -a imageVariants < .docker-state/variants.txt + mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") @@ -282,7 +282,7 @@ jobs: run: | set -eux - read -r -a imageVariants < .docker-state/variants.txt + mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") From e7c5647452119f2564e0787f163d8d4ab6f7f9e0 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:28:18 +0200 Subject: [PATCH 25/28] Fix jq vulnerability gate silently ignoring parse errors The 2>&1 redirect swallowed jq stderr, so a malformed or missing REPORT_JSON would cause jq to exit non-zero and silently skip the gate, potentially allowing a broken hardened image to ship. Keep stdout redirected to /dev/null (output is unused) but let stderr surface so any jq error aborts the step. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2554ea4..367e0f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -268,7 +268,7 @@ jobs: 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 2>&1; then + 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 From f00a6b13a1b97ee0c630ce9f91862555a29a7940 Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:28:42 +0200 Subject: [PATCH 26/28] Fix double docker rmi of PLAIN_IMAGE and HARDENED_IMAGE in cleanup Both images were removed explicitly before the loop, then removed again inside the loop because ALL_TAGS already contains them as its first entries (from plain_tags.txt and hardened_tags.txt). The redundant pre-loop rmi calls are harmless due to || true but confusing. Remove them and let the single loop cover everything. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 367e0f9..f4157d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,10 +319,8 @@ jobs: fi # Clean up per variant to reclaim disk space before the next variant. - docker rmi "${PLAIN_IMAGE}" || true - if [ -n "${HARDENED_IMAGE}" ]; then - docker rmi "${HARDENED_IMAGE}" || true - fi + # 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 From 0588547149c7d6beaebc3b7677985b2be38810ff Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:38:33 +0200 Subject: [PATCH 27/28] Fix Copa patch -t using bare tag instead of full image reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copa patch -t was passed "${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" (e.g. php8.5-v5-dev-hardened-amd64), so Copa produced an image named php8.5-v5-dev-hardened-amd64:latest. The subsequent docker image inspect "${HARDENED_IMAGE}" looked for pimcore/pimcore:php8.5-v5-dev-hardened-amd64 and always failed even on a successful patch. Pass "${HARDENED_IMAGE}" directly — it already carries the full ${IMAGE_NAME}: prefix — so Copa tags its output consistently with what the rest of the step expects. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4157d8..b9d9861 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -209,7 +209,7 @@ jobs: 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 "${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" \ + -t "${HARDENED_IMAGE}" \ -a tcp://127.0.0.1:8888 if ! docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1; then From 53bda969a3be50426f21089c929b8e1edd46328a Mon Sep 17 00:00:00 2001 From: berfinyuksel <99557970+berfinyuksel@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:50 +0200 Subject: [PATCH 28/28] =?UTF-8?q?Update=20spec:=20v4.1=20=E2=86=92=20v4.2?= =?UTF-8?q?=20to=20match=20release=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix was bumped from v4.1 to v4.2 in a prior commit; the design doc still referenced v4.1 in the stable-release list. Co-Authored-By: Claude Sonnet 4.6 --- docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1113a6d..3e867d5 100644 --- a/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md +++ b/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md @@ -7,7 +7,7 @@ ## Problem Today, for every matrix build marked `imagePatch: true` (the stable releases: -`v1.6`, `v2.3`, `v3.8`, `v4.1`, `v5.1`), the release workflow scans the freshly +`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