From ad514f8cec9ae80016897c6747916a5f0d42e78e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Tue, 26 May 2026 11:50:02 +0200 Subject: [PATCH 01/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] 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/75] =?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 From 1e80144a2f4246d66a71fec21689e177e5960aaa Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 14:14:43 +0200 Subject: [PATCH 29/75] Treat fail_on_severity as an inclusive threshold, validate values Normalise fail_on_severity into an inclusive Trivy filter list before the gate: naming a severity now also gates everything above it (e.g. HIGH -> HIGH,CRITICAL), so higher severities can't slip through Trivy's exact-match --severity filter. Also reject invalid values with a clear ::error:: and accept lowercase/whitespace. Default CRITICAL,HIGH behaviour is unchanged. Re-applies the one fix from the earlier review round not already covered by upstream (summary-before-gate ordering is already handled). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 46 ++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9d9861..174b05c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,6 +193,40 @@ jobs: set -eux mkdir -p trivy-reports + # Normalise the severity gate into a validated, inclusive Trivy filter list. + # fail_on_severity is a *threshold*: naming a severity also gates everything + # above it (e.g. HIGH -> HIGH,CRITICAL), since Trivy's --severity is otherwise + # an exact filter that would let higher severities slip through. NONE disables it. + SEVERITY_ORDER="UNKNOWN LOW MEDIUM HIGH CRITICAL" + GATE_SEVERITY="$FAIL_ON_SEVERITY" + if [ "$GATE_SEVERITY" != "NONE" ]; then + min_rank=-1 + IFS=',' read -r -a requested_severities <<< "${GATE_SEVERITY^^}" + for sev in "${requested_severities[@]}"; do + sev="${sev// /}" + [ -z "$sev" ] && continue + rank=-1; i=0 + for known in $SEVERITY_ORDER; do + if [ "$known" = "$sev" ]; then rank=$i; fi + i=$((i + 1)) + done + if [ "$rank" -lt 0 ]; then + echo "::error::Invalid fail_on_severity value '${sev}'. Allowed: ${SEVERITY_ORDER// /, }, or NONE." + exit 1 + fi + if [ "$min_rank" -lt 0 ] || [ "$rank" -lt "$min_rank" ]; then min_rank=$rank; fi + done + # Rebuild as the inclusive range from the lowest requested severity up to CRITICAL. + GATE_SEVERITY=""; i=0 + for known in $SEVERITY_ORDER; do + if [ "$i" -ge "$min_rank" ]; then + GATE_SEVERITY="${GATE_SEVERITY:+$GATE_SEVERITY,}$known" + fi + i=$((i + 1)) + done + echo "Severity gate: fail_on_severity='${FAIL_ON_SEVERITY}' -> '${GATE_SEVERITY}'" + fi + mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do @@ -233,8 +267,8 @@ jobs: # 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}+)" + if [ "$GATE_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${GATE_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" @@ -243,14 +277,14 @@ jobs: # 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" \ + --severity "$GATE_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" \ + --severity "$GATE_SEVERITY" \ --format table \ -o /tmp/trivy-os-${TAG}.txt \ "${HARDENED_IMAGE}" || true @@ -259,7 +293,7 @@ jobs: { echo "## Trivy Scan: ${HARDENED_IMAGE}" echo "" - echo "### OS Vulnerabilities (${FAIL_ON_SEVERITY}+)" + echo "### OS Vulnerabilities (${GATE_SEVERITY})" echo '```' cat /tmp/trivy-os-${TAG}.txt 2>/dev/null || echo "No results" echo '```' @@ -269,7 +303,7 @@ jobs: # Gate on the JSON findings -- no third Trivy invocation needed. if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then - echo "::error::${HARDENED_IMAGE} has unfixed ${FAIL_ON_SEVERITY} vulnerabilities after patching" + echo "::error::${HARDENED_IMAGE} has unfixed ${GATE_SEVERITY} vulnerabilities after patching" exit 1 fi fi From 1e5a9b8c31c6b1e39804c1fe0ae09c11e4b973b1 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 14:32:07 +0200 Subject: [PATCH 30/75] Add spec: plain-always-publish gate, SBOM restoration, hardened package docs Co-Authored-By: Claude Fable 5 (1M context) --- ...2-copa-plain-always-publish-sbom-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md new file mode 100644 index 0000000..c8e6a93 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -0,0 +1,216 @@ +# Design: plain-always-publish gate, SBOM restoration, and hardened package docs + +**Date:** 2026-07-02 +**Status:** Approved (pending user review) +**Branch:** `image_copa` (PR #247) +**Affected files:** `.github/workflows/release.yml`, `README.md`, +`.github/scripts/generate-package-docs.sh` (new), `docs/hardened-packages/` (new, +CI-generated), `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (decision +4 superseded) + +## Problem + +Review of the current `image_copa` workflow against the maintainer's requirements found +four gaps: + +1. **Gate failure blocks plain publishing.** The post-patch severity gate `exit 1`s inside + the `Scan, patch, and gate hardened images` step, killing the job before `Tag, push, + and aggregate` runs. When the hardened image still carries CRITICAL/HIGH CVEs, neither + plain nor hardened tags publish for that matrix entry — and variants after the failing + one in the loop are lost too. Requirement: **plain images must always publish as-is, + even when they contain CVEs.** (This supersedes decision 4 — "all-or-nothing" — of the + 2026-06-15 spec.) +2. **Matrix and manifest fragility.** `strategy.fail-fast` defaults to `true`, so one + failing leg cancels all in-progress legs. And `process-tags` (`needs: build-php`, + no `always()`) is skipped entirely if any leg fails — no multi-arch manifests get + created for *any* line, even ones that passed. +3. **SBOM regression (compliance).** On `5.x`, `docker buildx build --sbom=true --output + type=image,push=$PUSH` attaches an SPDX SBOM attestation to every pushed image. The + Copa restructure switched to `docker build --load` (required so Copa can patch the + local image) and silently dropped SBOM generation. **SBOMs are a legal requirement for + the published images.** Additionally, Copa-patched images never had SBOMs — Copa does + not produce or update attestations — so the `-hardened` flavor needs its own SBOM + regardless. +4. **No package/versions documentation.** Nothing records which libraries each image + contains, or what the `-hardened` flavor changed versus plain. + +Confirmed as already correct (no change): plain images are never patched; every variant +(min/default/max/debug/supervisord) of a `hardened: true` entry gets the full `-hardened` +tag set; the `-hardened` tag is created even when nothing was fixable (mirrors plain). + +## Decisions (confirmed with maintainer, 2026-07-02) + +1. **Scope stays stable-only.** `-hardened` is produced only for `hardened: true` matrix + entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.1`). Dev/rolling lines stay plain-only. +2. **Gate policy: publish plain, skip hardened, job red.** Plain tags always publish. A + variant whose hardened image fails the gate (or whose scan/patch errors) does not get + its `-hardened` tags pushed; other variants continue; the job ends red — after pushes + and artifact uploads — so maintainers notice. +3. **SBOMs are required by law and Trivy-generated SBOMs satisfy the requirement.** + Generated for **all** published images (plain for every matrix entry, hardened where + produced), per architecture. +4. **Package docs are committed MD files** in the repo, derived from the SBOMs. + +## Part 1 — Gate restructure (`release.yml`) + +### Scan, patch, and gate step + +Per variant, replace every hard `exit 1` (gate findings, Copa failure, missing hardened +image, Trivy scan error) with: + +- write a marker file `.docker-state//gate_failed.txt` containing a one-line + reason, +- do **not** write `hardened_image.txt` / `hardened_tags.txt` for that variant (the push + step keys off `hardened_image.txt`), +- emit `::error::` and append the failure to `$GITHUB_STEP_SUMMARY`, +- `continue` to the next variant. + +The step itself always exits 0. The existing severity normalisation (`GATE_SEVERITY`) +and Trivy report artifacts are unchanged. + +### Tag, push, and aggregate step + +Unchanged logic — it already pushes hardened tags only when `hardened_image.txt` exists. +Effect under the new markers: plain always pushes; gate-failed variants' `-hardened` tags +are not pushed and remain at their previously published state in the registries +(documented in README). Aggregation likewise skips absent hardened tags, so `process-tags` +never sees them. + +### New final step: `Fail if severity gate failed` + +Last step of the job (after `Stop buildkit daemon`, `Upload trivy reports`, `Upload +aggregated tags`): + +```sh +if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then + grep -H . .docker-state/*/gate_failed.txt + echo "::error::One or more variants failed the severity gate; their -hardened tags were not published" + exit 1 +fi +``` + +Runs only for `hardened: true` entries (`if: ${{ matrix.build.hardened }}`). + +### Resilience fixes + +- `strategy.fail-fast: false` on the `build-php` matrix. +- `process-tags`: `if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }}`. + Its existing per-arch existence check (`docker buildx imagetools inspect`, skip with + message when an arch is missing) already handles asymmetric outcomes — e.g. amd64 passes + the gate but arm64 fails → no new multi-arch `-hardened` manifest; the previously + published one stays. The pushed single-arch `-hardened-amd64` tag is harmless and + overwritten next run. + +## Part 2 — SBOM generation and publication + +### Generation + +- **Trivy is installed on every leg** (split the current install step: Trivy + unconditional; Copa + BuildKit daemon remain `if: matrix.build.hardened`). +- After building each plain image, and after each hardened image **passes the gate** + (gate-failed variants get no hardened SBOM — absence is the machine-readable signal the + docs job keys off): + `trivy image --format spdx-json -o sboms/.spdx.json ` (SPDX to match what + the `5.x` buildx attestation emitted). Runs on **both arch legs** — SBOMs are per-arch, + as buildx attestations were. +- Upload `sboms/` as a per-leg artifact (`sboms____...`), `if: always()`. + +### Registry attachment (durable, per-image) + +After the pushes of a variant complete, attach that image's SBOM as an OCI referrer — +**once per image digest per registry** (all tags of an image share the digest, so one +attach on the primary tag covers them; repeat for the GHCR mirror): + +```sh +oras attach --artifact-type application/spdx+json "" "sboms/.spdx.json" +``` + +- `oras` installed via pinned release binary with checksum verification (same pattern as + the Copa install). +- Attachment is **non-fatal** (`|| echo "::warning::..."`): GHCR supports OCI referrers; + Docker Hub support is newer — a registry rejecting referrers must not break publishing. + The artifact upload is the guaranteed fallback in that case. +- Referrers bind to digests, so they survive the `imagetools create` manifest merge in + `process-tags` (per-arch digests remain referenced by the multi-arch manifest). + +This restores the `5.x` guarantee (SBOM attached to every pushed image) and extends it to +the `-hardened` flavor, which the buildx attestation could never cover. + +## Part 3 — Hardened package docs (committed MD) + +### Data flow + +1. The **amd64 leg** of each `hardened: true` entry already has, per variant, the plain + and hardened SPDX SBOMs in `sboms/` (from Part 2). No extra scanning needed. +2. New job **`publish-package-docs`** (after `build-php`; `if: ${{ always() && + (github.event_name != 'workflow_dispatch' || inputs.publish) && github.repository == + 'pimcore/docker' }}`; `permissions: contents: write`): + - checks out the repository **default branch** (not a matrix ref), + - downloads the amd64 `sboms_*` artifacts of hardened entries, + - runs `.github/scripts/generate-package-docs.sh` (jq over SPDX `packages[]` + name/versionInfo) to write one file per hardened matrix entry: + `docs/hardened-packages/-php.md` (e.g. + `docs/hardened-packages/v5.1-php8.5.md`), + - commits and pushes with the default `GITHUB_TOKEN` (bot pushes do not re-trigger + workflows); commit message `Update hardened image package docs`; no-op when nothing + changed; one `git pull --rebase` retry on push rejection. + +### Document format (per file) + +- Header: generation timestamp (UTC), source image tags + digests, arch note + ("amd64; arm64 package versions may differ marginally"). +- Per variant (min/default/max/debug/supervisord): + - **"Packages changed by hardening"** table: `package | plain version | hardened + version` — the Copa delta, empty-state text when hardening changed nothing. + - Collapsible (`
`) **full inventory** table: `package | plain | hardened`, + one row per package union, `–` when absent from a flavor. +- Gate-failed variants: their hardened SBOM is absent by construction (Part 2), so the + generator writes those sections from the plain SBOM only, with the note: "hardened tag + not updated this run (severity gate failed)". Variants with both SBOMs get the full + diff. + +## Part 4 — README update + +Rewrite the `## Hardened images` section: + +- **What Copa does:** after the plain image is built, it is scanned with Trivy; Copa + applies the available Debian security fixes for OS-level packages as an additional + image layer. PHP, extensions, and application-level content are byte-identical to the + plain image — only OS package versions differ. +- **Scope:** `-hardened` exists for stable release tags only; `-dev` tags are plain-only. +- **Gate semantics:** plain tags always publish. Hardened tags publish only when the + patched image passes the `fail_on_severity` gate (default `CRITICAL,HIGH`, threshold + semantics); when the gate fails, the `-hardened` tag temporarily lags behind plain until + a fix is available upstream. +- **Usage:** pull examples (`php8.5-debug-v5-hardened`), guidance on when to choose each + flavor. +- **SBOMs & package docs:** every published image has an SPDX SBOM (registry referrer + + CI artifact); link to `docs/hardened-packages/` for the per-image package inventories + and hardening deltas. + +## Part 5 — Spec supersession + +Add a note to `2026-06-15-hardened-image-tag-design.md` under decision 4: superseded by +this spec (plain-always-publish, deferred red). No other edits to the old spec. + +## Out of scope (YAGNI) + +- No `-hardened` for dev/rolling lines. +- No buildx attestation restoration (`--sbom=true` cannot survive `--load`; re-pushing via + buildx would risk publishing bytes that differ from the gated image). The Trivy SBOM + + `oras` referrer replaces it. +- No SBOM signing (cosign) — can be layered on later if compliance requires signatures. +- No package docs for plain-only (dev) lines; their SBOMs exist as artifacts/referrers. +- No change to gate defaults, severity normalisation, or Trivy report artifacts. + +## Testing + +- **Workflow lint:** `bash -n` on every extracted `run:` block; YAML parse check. +- **Gate logic:** unit-test the marker/continue flow by extracting the loop into a script + with stubbed `trivy`/`copa`/`docker` (failing variant 2 of 3 → variants 1 and 3 push + plain+hardened, variant 2 plain only, final step exits 1). +- **Docs generator:** run `.github/scripts/generate-package-docs.sh` against two fixture + SPDX files (differing versions, added/removed package) and assert the MD output. +- **Live validation:** `workflow_dispatch` with `publish: false` builds, patches, gates, + and generates SBOMs without pushing; the docs job is skipped (publish-gated), validated + on the first real publish run. From 0c971340c10582d51dc5b82991266cf5950f2afa Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 14:51:17 +0200 Subject: [PATCH 31/75] Spec: record declined buildx-attestation hybrid for SBOM approach Co-Authored-By: Claude Fable 5 (1M context) --- .../2026-07-02-copa-plain-always-publish-sbom-design.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index c8e6a93..9e401fe 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -198,7 +198,11 @@ this spec (plain-always-publish, deferred red). No other edits to the old spec. - No `-hardened` for dev/rolling lines. - No buildx attestation restoration (`--sbom=true` cannot survive `--load`; re-pushing via buildx would risk publishing bytes that differ from the gated image). The Trivy SBOM + - `oras` referrer replaces it. + `oras` referrer replaces it. A hybrid (containerd image store so `--load` keeps + attestations for plain, Trivy for hardened) was considered and declined on 2026-07-02: + it needs a daemon-reconfig spike, keeps two SBOM mechanisms permanently, and — since + even `5.x` only carries attestations on per-arch tags — buys no extra coverage over the + referrer approach. - No SBOM signing (cosign) — can be layered on later if compliance requires signatures. - No package docs for plain-only (dev) lines; their SBOMs exist as artifacts/referrers. - No change to gate defaults, severity normalisation, or Trivy report artifacts. From 63a116079b20017d9b3aa5c4ce645765f75f237b Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 14:54:30 +0200 Subject: [PATCH 32/75] Spec: split scope into PR #247 (gate+SBOM+README) and package-docs follow-up Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2-copa-plain-always-publish-sbom-design.md | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index 9e401fe..3c99c0e 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -51,6 +51,21 @@ tag set; the `-hardened` tag is created even when nothing was fixable (mirrors p produced), per architecture. 4. **Package docs are committed MD files** in the repo, derived from the SBOMs. +## Scope & sequencing (confirmed 2026-07-02) + +This spec lands in two PRs: + +- **PR #247 (this work):** Part 1 (gate restructure + resilience), Part 2 (SBOM + generation + oras attachment), Part 4 (README), Part 5 (spec supersession). These are + the must-haves — they unblock publishing and satisfy the SBOM legal requirement. +- **Follow-up PR:** Part 3 (the `publish-package-docs` self-committing job + generator + script). It is the riskiest, non-blocking piece (bot commits, push token, race + handling) and depends only on the SBOM artifacts that Part 2 already produces, so it can + land independently without touching the publish path again. + +The implementation plan for this cycle therefore covers Parts 1, 2, 4, and 5. Part 3 is +specified here for continuity but planned/implemented separately. + ## Part 1 — Gate restructure (`release.yml`) ### Scan, patch, and gate step @@ -136,7 +151,11 @@ oras attach --artifact-type application/spdx+json "" "sboms/.s This restores the `5.x` guarantee (SBOM attached to every pushed image) and extends it to the `-hardened` flavor, which the buildx attestation could never cover. -## Part 3 — Hardened package docs (committed MD) +## Part 3 — Hardened package docs (committed MD) — FOLLOW-UP PR + +> Not in PR #247. Specified here for continuity; planned and implemented separately. +> Consumes the SBOM artifacts produced by Part 2, so it needs no further change to the +> publish path. ### Data flow @@ -213,8 +232,12 @@ this spec (plain-always-publish, deferred red). No other edits to the old spec. - **Gate logic:** unit-test the marker/continue flow by extracting the loop into a script with stubbed `trivy`/`copa`/`docker` (failing variant 2 of 3 → variants 1 and 3 push plain+hardened, variant 2 plain only, final step exits 1). -- **Docs generator:** run `.github/scripts/generate-package-docs.sh` against two fixture - SPDX files (differing versions, added/removed package) and assert the MD output. +- **SBOM:** assert `trivy image --format spdx-json` produces a valid SPDX file with + `packages[].versionInfo` populated for a sample image; confirm `oras attach` failure is + swallowed with a warning (stub a rejecting registry). +- **Docs generator (follow-up PR):** run `.github/scripts/generate-package-docs.sh` + against two fixture SPDX files (differing versions, added/removed package) and assert the + MD output. - **Live validation:** `workflow_dispatch` with `publish: false` builds, patches, gates, and generates SBOMs without pushing; the docs job is skipped (publish-gated), validated on the first real publish run. From f28da6cce34da232118ce98e9651b8c0c0461737 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:01:46 +0200 Subject: [PATCH 33/75] Add implementation plan for plain-always-publish + SBOM (PR #247 scope) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-02-copa-plain-always-publish-sbom.md | 779 ++++++++++++++++++ ...2-copa-plain-always-publish-sbom-design.md | 14 +- 2 files changed, 790 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md diff --git a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md new file mode 100644 index 0000000..5c39a67 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md @@ -0,0 +1,779 @@ +# Copa plain-always-publish + SBOM Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make plain images always publish (even with CVEs) while `-hardened` tags publish only when they pass the severity gate, and generate a legally-required SPDX SBOM for every published image. + +**Architecture:** The `release.yml` workflow builds plain images, then (for `hardened: true` entries) scans+patches+gates each variant. The gate logic and SBOM attachment are extracted into `.github/scripts/*.sh` so they are unit-testable with stubbed `trivy`/`copa`/`docker`/`oras`. A gate failure writes a per-variant marker and skips only that variant's hardened tags; a final step turns the job red after pushes. SBOMs are generated with Trivy (SPDX-JSON) and attached to pushed images as OCI referrers via `oras`. + +**Tech Stack:** GitHub Actions, Bash 5, Trivy, Copacetic (Copa), oras, jq, Docker Buildx. + +## Global Constraints + +- Registry / image name: `pimcore/pimcore` (Docker Hub) and `ghcr.io/pimcore/pimcore` (verbatim). +- `-hardened` produced **only** for `hardened: true` matrix entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.1`); dev/rolling lines stay plain-only. +- Plain images **always publish**, even with CVEs. Only `-hardened` is gated. +- SBOM format: **SPDX-JSON** (`trivy image --format spdx-json`), for every published image, per architecture. +- `oras attach` is **non-fatal** — a registry rejecting referrers must only warn. +- Severity gate is a **threshold**: `fail_on_severity` normalises to an inclusive list (`HIGH` → `HIGH,CRITICAL`); `NONE` disables it. (Already implemented inline as `GATE_SEVERITY` — do not remove.) +- Copa `-t` takes a **full image reference** (`${IMAGE_NAME}:...`), not a bare tag. +- Pinned tool versions live in `env:` (`COPA_VERSION`, `BUILDKIT_VERSION`); add `ORAS_VERSION`, `ACTIONLINT_VERSION` the same way. +- Shell: every extracted script starts with `#!/usr/bin/env bash` and `set -euo pipefail`. + +--- + +## File Structure + +- `.github/scripts/attach-sbom.sh` (new) — attach one SBOM to one image ref via `oras`, non-fatal. +- `.github/scripts/scan-patch-gate.sh` (new) — per-variant scan → patch/mirror → gate → on pass: write hardened outputs + hardened SBOM; on fail: write `gate_failed.txt`, skip hardened outputs, exit 0. +- `.github/scripts/tests/stubs/{trivy,copa,docker,oras}` (new) — arg-inspecting stubs on `PATH`. +- `.github/scripts/tests/run.sh` (new) — stub-driven test runner for the two scripts. +- `.github/workflows/release.yml` (modify) — install split, plain SBOM, wire gate script, push-attach, final fail step, `fail-fast: false`, `process-tags` `always()`. +- `.github/workflows/test.yml` (modify) — add a fast `scripts` job running actionlint + `run.sh`. +- `README.md` (modify) — rewrite "Hardened images" section. +- `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (modify) — supersession note. + +--- + +### Task 1: `attach-sbom.sh` (non-fatal SBOM attach) + +**Files:** +- Create: `.github/scripts/attach-sbom.sh` +- Create: `.github/scripts/tests/stubs/oras` +- Create: `.github/scripts/tests/run.sh` (started here, extended in Task 2) + +**Interfaces:** +- Produces: `attach-sbom.sh ` — always exits 0; prints `Attached ...` on success, `::warning::...` on failure/missing file. + +- [ ] **Step 1: Write the stub `oras` and the failing test** + +Create `.github/scripts/tests/stubs/oras`: + +```bash +#!/usr/bin/env bash +# Stub oras: succeeds unless STUB_ORAS=fail. Records the call for assertions. +echo "oras $*" >> "${STUB_LOG:-/dev/null}" +if [ "${STUB_ORAS:-ok}" = "fail" ]; then + echo "stub oras: simulated referrer rejection" >&2 + exit 1 +fi +exit 0 +``` + +Create `.github/scripts/tests/run.sh`: + +```bash +#!/usr/bin/env bash +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${HERE}/../../.." && pwd)" +export PATH="${HERE}/stubs:${PATH}" +fail=0 +assert_contains() { # + if printf '%s' "$1" | grep -qF -- "$2"; then echo " ok: $3"; else echo " FAIL: $3 (missing '$2')"; fail=1; fi +} +assert_file() { [ -e "$1" ] && echo " ok: $2 exists" || { echo " FAIL: $2 missing"; fail=1; }; } +assert_no_file() { [ ! -e "$1" ] && echo " ok: $2 absent" || { echo " FAIL: $2 should be absent"; fail=1; }; } + +echo "== attach-sbom.sh ==" +work="$(mktemp -d)"; echo '{}' > "${work}/s.spdx.json" + +# success path +out="$(STUB_ORAS=ok "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? +assert_contains "$out" "Attached" "success prints Attached" +[ "$rc" = "0" ] && echo " ok: exit 0 on success" || { echo " FAIL: exit $rc"; fail=1; } + +# failure path is swallowed +out="$(STUB_ORAS=fail "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? +assert_contains "$out" "::warning::" "failure prints warning" +[ "$rc" = "0" ] && echo " ok: exit 0 on failure" || { echo " FAIL: exit $rc"; fail=1; } + +# missing file +out="$("${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:x /nope.json 2>&1)"; rc=$? +assert_contains "$out" "::warning::" "missing file warns" +[ "$rc" = "0" ] && echo " ok: exit 0 on missing file" || { echo " FAIL: exit $rc"; fail=1; } + +echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" +exit "$fail" +``` + +- [ ] **Step 2: Make stubs + runner executable and run to verify it fails** + +Run: +```bash +chmod +x .github/scripts/tests/stubs/oras .github/scripts/tests/run.sh +.github/scripts/tests/run.sh; echo "exit=$?" +``` +Expected: FAIL — `attach-sbom.sh` does not exist yet (`No such file or directory`), `exit=1`. + +- [ ] **Step 3: Write `attach-sbom.sh`** + +Create `.github/scripts/attach-sbom.sh`: + +```bash +#!/usr/bin/env bash +# Attach an SPDX SBOM to a pushed image as an OCI referrer. +# Non-fatal: a registry that rejects referrers must not break publishing. +set -euo pipefail + +ref="${1:?usage: attach-sbom.sh }" +sbom="${2:?usage: attach-sbom.sh }" + +if [ ! -s "$sbom" ]; then + echo "::warning::SBOM '$sbom' missing or empty; skipping attach for ${ref}" + exit 0 +fi + +if oras attach --artifact-type application/spdx+json "$ref" "${sbom}:application/spdx+json"; then + echo "Attached SBOM ${sbom} to ${ref}" +else + echo "::warning::Failed to attach SBOM to ${ref} (registry may not support OCI referrers)" +fi +exit 0 +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: +```bash +chmod +x .github/scripts/attach-sbom.sh +.github/scripts/tests/run.sh; echo "exit=$?" +``` +Expected: PASS — all `attach-sbom.sh` assertions `ok`, `ALL TESTS PASSED`, `exit=0`. + +- [ ] **Step 5: bash -n both scripts** + +Run: +```bash +bash -n .github/scripts/attach-sbom.sh && bash -n .github/scripts/tests/run.sh && echo "SYNTAX OK" +``` +Expected: `SYNTAX OK`. + +- [ ] **Step 6: Commit** + +```bash +git add .github/scripts/attach-sbom.sh .github/scripts/tests/run.sh .github/scripts/tests/stubs/oras +git commit -m "Add non-fatal SBOM attach helper (oras) with stub tests" +``` + +--- + +### Task 2: `scan-patch-gate.sh` (per-variant gate with plain-always-publish) + +**Files:** +- Create: `.github/scripts/scan-patch-gate.sh` +- Create: `.github/scripts/tests/stubs/{trivy,copa,docker}` +- Modify: `.github/scripts/tests/run.sh` (append the gate scenarios) + +**Interfaces:** +- Consumes (env): `IMAGE_NAME`, `ARCH_TAG`, `GATE_SEVERITY`, optional `STATE_DIR` (default `.docker-state`), `SBOM_DIR` (default `sboms`), `REPORT_DIR` (default `trivy-reports`), `BUILDKIT_ADDR` (default `tcp://127.0.0.1:8888`), `GITHUB_STEP_SUMMARY`. +- Consumes (files): `${STATE_DIR}//{plain_image,base_tag,version,tag,plain_tags}.txt`. +- Produces on pass: `${STATE_DIR}//{hardened_image,hardened_tags,hardened_sbom}.txt`, the hardened SPDX in `${SBOM_DIR}/`, a Trivy report in `${REPORT_DIR}/`. Produces on fail: `${STATE_DIR}//gate_failed.txt`; removes any hardened outputs. **Always exits 0** unless a genuine infra error (missing state file) occurs. + +- [ ] **Step 1: Write the stubs** + +Create `.github/scripts/tests/stubs/trivy`: + +```bash +#!/usr/bin/env bash +# Stub trivy. Scenario via env: STUB_FIXABLE=yes|no (initial OS scan), +# STUB_GATE=pass|fail (severity-filtered gate scan). SPDX just writes a minimal doc. +out=""; sev=""; fmt="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2;; + --severity) sev="$2"; shift 2;; + --format) fmt="$2"; shift 2;; + *) shift;; + esac +done +case "$fmt" in + spdx-json) printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; + table) echo "stub trivy table report" > "$out"; exit 0;; +esac +# JSON vulnerability scan +if [ -n "$sev" ]; then + [ "${STUB_GATE:-pass}" = "fail" ] && v='[{"VulnerabilityID":"CVE-GATE"}]' || v='[]' +else + [ "${STUB_FIXABLE:-yes}" = "no" ] && v='[]' || v='[{"VulnerabilityID":"CVE-FIX"}]' +fi +printf '{"Results":[{"Vulnerabilities":%s}]}\n' "$v" > "$out" +exit 0 +``` + +Create `.github/scripts/tests/stubs/copa`: + +```bash +#!/usr/bin/env bash +echo "copa $*" >> "${STUB_LOG:-/dev/null}" +[ "${STUB_COPA:-ok}" = "fail" ] && { echo "stub copa: simulated failure" >&2; exit 1; } +exit 0 +``` + +Create `.github/scripts/tests/stubs/docker`: + +```bash +#!/usr/bin/env bash +# Stub docker: 'image inspect' exists-check exits 0; with --format prints a fake id. +if [ "$1 $2" = "image inspect" ]; then + if printf '%s ' "$@" | grep -q -- '--format'; then echo "sha256:deadbeefcafe0000"; fi + exit 0 +fi +exit 0 +``` + +- [ ] **Step 2: Append gate scenarios to `run.sh`** + +Add before the final summary lines (`echo; [ "$fail" = "0" ] ...`) in `.github/scripts/tests/run.sh`: + +```bash +echo "== scan-patch-gate.sh ==" +setup_variant() { # + local d="$1/.docker-state/$2"; mkdir -p "$d" + echo "pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_image.txt" + echo "php8.5-$2" > "$d/base_tag.txt" + echo "v5.1" > "$d/version.txt" + echo "php8.5-$2-v5.1-amd64" > "$d/tag.txt" + printf '%s\n' \ + "pimcore/pimcore:php8.5-$2-v5.1-amd64" \ + "ghcr.io/pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_tags.txt" +} +run_gate() { # runs scan-patch-gate.sh in with env already exported + ( cd "$1" && IMAGE_NAME=pimcore/pimcore ARCH_TAG=amd64 \ + "${ROOT}/.github/scripts/scan-patch-gate.sh" "$2" ) 2>&1 +} + +# Scenario A: fixable vulns, gate passes -> hardened published +wA="$(mktemp -d)"; setup_variant "$wA" default +outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass run_gate "$wA" default)"; rcA=$? +[ "$rcA" = 0 ] && echo " ok: A exit 0" || { echo " FAIL: A exit $rcA"; fail=1; } +assert_file "$wA/.docker-state/default/hardened_image.txt" "A hardened_image" +assert_file "$wA/.docker-state/default/hardened_tags.txt" "A hardened_tags" +assert_file "$wA/.docker-state/default/hardened_sbom.txt" "A hardened_sbom" +assert_no_file "$wA/.docker-state/default/gate_failed.txt" "A gate_failed" +assert_contains "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "hardened-amd64" "A tags carry -hardened" + +# Scenario B: gate fails -> plain only, marker written, exit 0 +wB="$(mktemp -d)"; setup_variant "$wB" max +outB="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=fail run_gate "$wB" max)"; rcB=$? +[ "$rcB" = 0 ] && echo " ok: B exit 0 (does not abort step)" || { echo " FAIL: B exit $rcB"; fail=1; } +assert_file "$wB/.docker-state/max/gate_failed.txt" "B gate_failed marker" +assert_no_file "$wB/.docker-state/max/hardened_image.txt" "B hardened_image" +assert_contains "$outB" "::error::" "B emits ::error::" + +# Scenario C: nothing fixable -> hardened mirrors plain, gate passes +wC="$(mktemp -d)"; setup_variant "$wC" min +outC="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=no STUB_GATE=pass run_gate "$wC" min)"; rcC=$? +[ "$rcC" = 0 ] && echo " ok: C exit 0" || { echo " FAIL: C exit $rcC"; fail=1; } +assert_file "$wC/.docker-state/min/hardened_image.txt" "C hardened_image (mirror)" +assert_no_file "$wC/.docker-state/min/gate_failed.txt" "C gate_failed" + +# Scenario D: gate disabled (NONE) -> hardened published without gate scan +wD="$(mktemp -d)"; setup_variant "$wD" debug +outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes run_gate "$wD" debug)"; rcD=$? +assert_file "$wD/.docker-state/debug/hardened_image.txt" "D hardened_image (NONE)" +``` + +- [ ] **Step 3: Run tests to verify the new scenarios fail** + +Run: +```bash +chmod +x .github/scripts/tests/stubs/trivy .github/scripts/tests/stubs/copa .github/scripts/tests/stubs/docker +.github/scripts/tests/run.sh; echo "exit=$?" +``` +Expected: FAIL — `scan-patch-gate.sh` not found; scenario A–D assertions FAIL; `exit=1`. + +- [ ] **Step 4: Write `scan-patch-gate.sh`** + +Create `.github/scripts/scan-patch-gate.sh`: + +```bash +#!/usr/bin/env bash +# Per-variant: scan the plain image, patch with Copa (or mirror if nothing fixable), +# gate the hardened image on GATE_SEVERITY, and -- only if it passes -- publish the +# hardened outputs and generate its SPDX SBOM. A gate failure (or scan/patch error) +# writes gate_failed.txt, skips the hardened outputs, and exits 0 so the plain image +# still ships and other variants continue. Genuine infra errors abort (set -e). +set -euo pipefail + +variant="${1:?usage: scan-patch-gate.sh }" +: "${IMAGE_NAME:?}"; : "${ARCH_TAG:?}"; : "${GATE_SEVERITY:?}" +STATE_DIR="${STATE_DIR:-.docker-state}" +SBOM_DIR="${SBOM_DIR:-sboms}" +REPORT_DIR="${REPORT_DIR:-trivy-reports}" +BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}" +vdir="${STATE_DIR}/${variant}" +mkdir -p "$SBOM_DIR" "$REPORT_DIR" + +PLAIN_IMAGE=$(< "${vdir}/plain_image.txt") +BASE_TAG=$(< "${vdir}/base_tag.txt") +VERSION=$(< "${vdir}/version.txt") +TAG=$(< "${vdir}/tag.txt") +HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" +report="/tmp/spg-${variant}.json" + +fail_gate() { # -- record + skip hardened, but let plain ship + echo "::error::${variant}: $1" + { echo "## Gate failed: ${HARDENED_IMAGE}"; echo ""; echo "$1"; echo ""; } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + echo "$1" > "${vdir}/gate_failed.txt" + rm -f "${vdir}/hardened_image.txt" "${vdir}/hardened_tags.txt" "${vdir}/hardened_sbom.txt" + rm -f "$report" + exit 0 +} + +echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" +trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ + || fail_gate "Trivy scan of plain image failed" + +if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null 2>&1; then + copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ + || fail_gate "Copa patch failed" + docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1 \ + || fail_gate "Hardened image not found after copa patch" + echo "Successfully patched ${PLAIN_IMAGE} into ${HARDENED_IMAGE}" +else + echo "No fixable OS vulnerabilities found; hardened image mirrors plain" + docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" +fi +rm -f "$report" + +if [ "$GATE_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${GATE_SEVERITY})" + IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + REPORT_JSON="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.json" + REPORT_TXT="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.txt" + + trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ + --format json -o "${REPORT_JSON}" "${HARDENED_IMAGE}" \ + || fail_gate "Trivy gate scan failed" + + trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ + --format table -o "/tmp/spg-${variant}.txt" "${HARDENED_IMAGE}" || true + cp "/tmp/spg-${variant}.txt" "${REPORT_TXT}" 2>/dev/null || true + { + echo "## Trivy Scan: ${HARDENED_IMAGE}" + echo "" + echo "### OS Vulnerabilities (${GATE_SEVERITY})" + echo '```' + cat "/tmp/spg-${variant}.txt" 2>/dev/null || echo "No results" + echo '```' + echo "" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + rm -f "/tmp/spg-${variant}.txt" + + if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then + fail_gate "unfixed ${GATE_SEVERITY} vulnerabilities remain after patching" + fi +fi + +# Gate passed (or disabled): publish hardened tags + SBOM. +while IFS= read -r plain_tag; do + echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" +done < "${vdir}/plain_tags.txt" > "${vdir}/hardened_tags.txt" +echo "${HARDENED_IMAGE}" > "${vdir}/hardened_image.txt" + +HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" +trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" +echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" +echo "Published hardened outputs for ${variant}" +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: +```bash +chmod +x .github/scripts/scan-patch-gate.sh +.github/scripts/tests/run.sh; echo "exit=$?" +``` +Expected: PASS — every scenario A–D `ok`, `ALL TESTS PASSED`, `exit=0`. + +- [ ] **Step 6: bash -n** + +Run: +```bash +bash -n .github/scripts/scan-patch-gate.sh && echo "SYNTAX OK" +``` +Expected: `SYNTAX OK`. + +- [ ] **Step 7: Commit** + +```bash +git add .github/scripts/scan-patch-gate.sh .github/scripts/tests/ +git commit -m "Add scan-patch-gate script: plain always ships, hardened gated, per-variant markers + SBOM" +``` + +--- + +### Task 3: Wire scripts into `release.yml` — installs, plain SBOM, gate step, fail-fast + +**Files:** +- Modify: `.github/workflows/release.yml` + +**Interfaces:** +- Consumes: `.github/scripts/scan-patch-gate.sh`, `.github/scripts/attach-sbom.sh` (Task 4 uses attach). +- Produces: plain SBOMs in `sboms/`, `.docker-state//plain_sbom.txt`; hardened outputs via the script. + +- [ ] **Step 1: Add pinned versions to `env:`** + +Modify the top-level `env:` block (after `BUILDKIT_VERSION`): + +```yaml +env: + IMAGE_NAME: pimcore/pimcore + COPA_VERSION: "0.14.1" + BUILDKIT_VERSION: "0.30.0" + ORAS_VERSION: "1.2.0" + TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" +``` + +- [ ] **Step 2: Add `fail-fast: false` to the matrix** + +Modify `strategy:` under the `build-php` job: + +```yaml + strategy: + fail-fast: false + matrix: +``` + +- [ ] **Step 3: Split the install step — Trivy + oras unconditional; Copa hardened-only** + +Replace the single `Install Copa and Trivy` step (`if: matrix.build.hardened`) with two steps. First, an unconditional install (place it before `Build plain images`): + +```yaml + - name: Install Trivy and oras + run: | + set -eux + sudo apt-get update + sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install -y trivy + + ORAS_ARCH="$(dpkg --print-architecture)" + curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" + EXPECTED_SHA=$(grep -F "oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" oras_checksums.txt | awk '{print $1}') + ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') + if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 + fi + tar -xzf oras.tar.gz oras + sudo mv oras /usr/local/bin/oras + rm oras.tar.gz oras_checksums.txt +``` + +Then a Copa-only step (keep `if: matrix.build.hardened`), containing only the Copa install block from the old step (the Trivy block is now above): + +```yaml + - name: Install Copa + if: ${{ matrix.build.hardened }} + run: | + set -eux + 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" + EXPECTED_SHA=$(grep -F "copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" copacetic_checksums.txt | awk '{print $1}') + ACTUAL_SHA=$(sha256sum copa.tar.gz | awk '{print $1}') + if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::Copa checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 + fi + tar -xzf copa.tar.gz copa + sudo mv copa /usr/local/bin/copa + rm copa.tar.gz copacetic_checksums.txt +``` + +Leave the `Start buildkit daemon` step unchanged (`if: matrix.build.hardened`). + +- [ ] **Step 4: Generate the plain SBOM in the `Build plain images` step** + +In `.github/workflows/release.yml`, inside the `Build plain images` `run:` loop, immediately after the `docker build --load ... --tag "${PLAIN_IMAGE}" .` command (still inside the `for imageVariant` loop), append: + +```bash + mkdir -p sboms + PLAIN_SBOM="sboms/${TAG}.spdx.json" + trivy image --format spdx-json -o "${PLAIN_SBOM}" "${PLAIN_IMAGE}" + echo "${PLAIN_SBOM}" > ".docker-state/${imageVariant}/plain_sbom.txt" +``` + +- [ ] **Step 5: Replace the gate loop body with a call to the script** + +In the `Scan, patch, and gate hardened images` step, keep the env block and the inline `GATE_SEVERITY` normalisation (lines defining `SEVERITY_ORDER` … `fi`). Replace the `for imageVariant ... done` loop (everything from `mapfile -t imageVariants` onward) with: + +```bash + export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY + export BUILDKIT_ADDR="tcp://127.0.0.1:8888" + + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + .github/scripts/scan-patch-gate.sh "${imageVariant}" + done +``` + +(`ARCH_TAG` is already in this step's `env:`; `GATE_SEVERITY` is set by the inline normalisation above; `export` makes them visible to the script.) + +- [ ] **Step 6: Install actionlint and lint the workflow** + +Run: +```bash +ALINT=/tmp/actionlint +curl -fsSL -o /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz +tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint +"$ALINT" -color .github/workflows/release.yml; echo "actionlint exit=$?" +``` +Expected: `actionlint exit=0` (no errors). If shellcheck-style warnings appear inside `run:` blocks, fix them. + +- [ ] **Step 7: bash -n the changed run-blocks** + +Run: +```bash +for step in "Build plain images" "Scan, patch, and gate hardened images"; do + START=$(grep -n "name: ${step}" .github/workflows/release.yml | head -1 | cut -d: -f1) + END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) + awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ + | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/blk.sh + bash -n /tmp/blk.sh && echo "OK: ${step}" || echo "SYNTAX FAIL: ${step}" +done +``` +Expected: `OK: Build plain images` and `OK: Scan, patch, and gate hardened images`. + +- [ ] **Step 8: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release.yml: unconditional Trivy+oras, plain SBOM, delegate gate to script, fail-fast: false" +``` + +--- + +### Task 4: `release.yml` — push-step SBOM attach, deferred fail step, process-tags always() + +**Files:** +- Modify: `.github/workflows/release.yml` + +**Interfaces:** +- Consumes: `.github/scripts/attach-sbom.sh`; `.docker-state//{plain_sbom,hardened_image,hardened_sbom,tag}.txt`; `gate_failed.txt` markers. + +- [ ] **Step 1: Attach SBOMs after push in the `Tag, push, and aggregate` step** + +In the `Tag, push, and aggregate` step's loop, the block currently reads plain/hardened state. Add reading `TAG` and the SBOM paths at the top of the loop body (next to the existing `PLAIN_IMAGE=$(< ...)`): + +```bash + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") +``` + +Then, inside the existing `if [[ "$PUSH" == "true" ]]; then` block, after the `printf ... | xargs -P 4 ... docker push` line (and before/after the aggregation loop is fine), add the attach calls: + +```bash + # Attach the SPDX SBOM to each pushed image (once per digest per registry). + .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" + if [ -n "${HARDENED_IMAGE}" ] && [ -f ".docker-state/${imageVariant}/hardened_sbom.txt" ]; then + HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") + HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" + .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" + fi +``` + +(`HARDENED_IMAGE` is already set earlier in this loop to `""` or the value from `hardened_image.txt`, so a gate-failed variant — which has no `hardened_image.txt` — skips the hardened attach automatically.) + +- [ ] **Step 2: Add the deferred "Fail if severity gate failed" step** + +Add this step **after** `Upload aggregated tags` (so pushes, report upload, and tag upload all run first), still inside the `build-php` job: + +```yaml + - name: Fail if severity gate failed + if: ${{ matrix.build.hardened }} + run: | + if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then + echo "The following variants failed the severity gate; their -hardened tags were NOT published:" + grep -H . .docker-state/*/gate_failed.txt + echo "::error::One or more variants failed the severity gate (plain images were published as-is)" + exit 1 + fi + echo "All hardened variants passed the severity gate." +``` + +- [ ] **Step 3: Make `process-tags` run even if a leg failed** + +Modify the `process-tags` job condition: + +```yaml + process-tags: + runs-on: ubuntu-22.04 + needs: build-php + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} +``` + +- [ ] **Step 4: actionlint** + +Run: +```bash +/tmp/actionlint -color .github/workflows/release.yml; echo "actionlint exit=$?" +``` +Expected: `actionlint exit=0`. + +- [ ] **Step 5: bash -n the push step** + +Run: +```bash +START=$(grep -n "name: Tag, push, and aggregate" .github/workflows/release.yml | head -1 | cut -d: -f1) +END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) +awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ + | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/push.sh +bash -n /tmp/push.sh && echo "SYNTAX OK" +``` +Expected: `SYNTAX OK`. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release.yml: attach SBOMs on push, defer gate failure to end, run process-tags on always()" +``` + +--- + +### Task 5: Add a fast `scripts` test job to `test.yml` + +**Files:** +- Modify: `.github/workflows/test.yml` + +- [ ] **Step 1: Add the job** + +Add a second job to `.github/workflows/test.yml` (sibling of the existing `test` job): + +```yaml + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Install actionlint + run: | + curl -fsSL -o actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint + - name: Lint workflows + run: actionlint -color + - name: Run script unit tests + run: .github/scripts/tests/run.sh +``` + +- [ ] **Step 2: Verify the job's script test passes locally** + +Run: +```bash +.github/scripts/tests/run.sh; echo "exit=$?" +``` +Expected: `ALL TESTS PASSED`, `exit=0`. + +- [ ] **Step 3: actionlint the edited test.yml** + +Run: +```bash +/tmp/actionlint -color .github/workflows/test.yml; echo "actionlint exit=$?" +``` +Expected: `actionlint exit=0`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/test.yml +git commit -m "test.yml: add scripts job running actionlint and script unit tests" +``` + +--- + +### Task 6: README — rewrite the "Hardened images" section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the section** + +Replace the current `## Hardened images` section in `README.md` (from the `## Hardened images` heading up to the next `## ` heading) with: + +```markdown +## 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`. It is published as-is and may carry known OS-level CVEs. +- **hardened** (`-hardened` suffix) – the same image with OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. + +**What hardening does:** after the plain image is built, it is scanned with [Trivy](https://github.com/aquasecurity/trivy) and Copa applies the available Debian security updates for OS-level packages as an extra image layer. PHP, its extensions, and all application-level content are identical to the plain image — only OS package versions differ. + +**Scope & guarantees:** +- `-hardened` exists for **stable release tags only**; development tags (`-dev`) are published plain-only. +- The plain tag **always publishes**, even when CVEs remain. +- The `-hardened` tag publishes only when the patched image passes the vulnerability gate (`CRITICAL,HIGH` by default). If a fix is not yet available upstream, the gate fails and the `-hardened` tag temporarily stays at its previous version until the plain image can be patched clean — so a `-hardened` tag never regresses to a vulnerable state. + +```text +php8.5-debug-v5 # plain image, as built (may contain CVEs) +php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa, gate-clean +``` + +**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM, attached to the image in the registry as an OCI referrer and uploaded as a build artifact. +``` + +- [ ] **Step 2: Verify the section renders and links are intact** + +Run: +```bash +grep -n "## Hardened images" README.md && grep -c "hardened" README.md +``` +Expected: the heading is found once; `hardened` appears multiple times. Eyeball the block for correct Markdown (code fences balanced). + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "README: document Copa hardening, plain-always-publish gate semantics, and SBOMs" +``` + +--- + +### Task 7: Supersede decision 4 in the 2026-06-15 spec + +**Files:** +- Modify: `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` + +- [ ] **Step 1: Add the supersession note** + +Under "## Decisions (confirmed with maintainer)", append to decision 4 (the "Gate ordering = all-or-nothing per variant" item): + +```markdown +> **Superseded 2026-07-02** (see `2026-07-02-copa-plain-always-publish-sbom-design.md`): +> the gate no longer blocks plain publishing. Plain images always publish; a hardened +> gate failure skips only that variant's `-hardened` tags and turns the job red at the end. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md +git commit -m "spec: mark all-or-nothing gate decision superseded by 2026-07-02 spec" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Part 1 (gate restructure, markers, plain-always) → Task 2 (script) + Task 4 (deferred fail step) + Task 3 (fail-fast). ✅ +- Part 1 resilience (`fail-fast: false`, `process-tags always()`) → Task 3 Step 2, Task 4 Step 3. ✅ +- Part 2 (SBOM: Trivy on all legs, plain SBOM, hardened SBOM, oras attach) → Task 3 (installs + plain SBOM), Task 2 (hardened SBOM), Task 1 + Task 4 (attach). ✅ +- Part 4 (README) → Task 6. ✅ +- Part 5 (spec supersession) → Task 7. ✅ +- Testability (stub-driven gate simulation, SBOM attach swallow, actionlint) → Tasks 1, 2, 5. ✅ +- Part 3 (package docs job) → **out of scope** for this plan (follow-up PR), per spec. ✅ + +**Placeholder scan:** none — all steps carry full code/commands. + +**Type/name consistency:** state files (`plain_image.txt`, `base_tag.txt`, `version.txt`, `tag.txt`, `plain_tags.txt`, `plain_sbom.txt`, `hardened_image.txt`, `hardened_tags.txt`, `hardened_sbom.txt`, `gate_failed.txt`) are written and read with identical names across Tasks 2–4. `scan-patch-gate.sh` env contract (`IMAGE_NAME`, `ARCH_TAG`, `GATE_SEVERITY`, `BUILDKIT_ADDR`) matches the exports added in Task 3 Step 5. `attach-sbom.sh ` signature matches its calls in Task 4 Step 1. + +**Known follow-ups (not blocking):** Part 3 package-docs job; optional cosign signing of SBOMs. diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index 3c99c0e..d64c9ce 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -4,9 +4,17 @@ **Status:** Approved (pending user review) **Branch:** `image_copa` (PR #247) **Affected files:** `.github/workflows/release.yml`, `README.md`, -`.github/scripts/generate-package-docs.sh` (new), `docs/hardened-packages/` (new, -CI-generated), `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (decision -4 superseded) +`.github/scripts/scan-patch-gate.sh` (new), `.github/scripts/attach-sbom.sh` (new), +`.github/scripts/tests/` (new, stub-driven tests), +`.github/scripts/generate-package-docs.sh` (new, follow-up PR), +`docs/hardened-packages/` (new, CI-generated, follow-up PR), +`docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (decision 4 superseded) + +**Implementation note:** the per-variant scan/patch/gate loop (Part 1) and the `oras` +attach (Part 2) are extracted into small scripts under `.github/scripts/` so the workflow +steps stay thin and the behavior is unit-testable with stubbed `trivy`/`copa`/`docker`/ +`oras` on `PATH`. Severity normalisation stays inline in the step (it runs once, before +the loop). ## Problem From 30f17e533f0824c86428d154af6a7c4b2682cd84 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:06:32 +0200 Subject: [PATCH 34/75] Add non-fatal SBOM attach helper (oras) with stub tests --- .github/scripts/attach-sbom.sh | 19 +++++++++++++++++++ .github/scripts/tests/run.sh | 32 ++++++++++++++++++++++++++++++++ .github/scripts/tests/stubs/oras | 8 ++++++++ 3 files changed, 59 insertions(+) create mode 100755 .github/scripts/attach-sbom.sh create mode 100755 .github/scripts/tests/run.sh create mode 100755 .github/scripts/tests/stubs/oras diff --git a/.github/scripts/attach-sbom.sh b/.github/scripts/attach-sbom.sh new file mode 100755 index 0000000..befc890 --- /dev/null +++ b/.github/scripts/attach-sbom.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Attach an SPDX SBOM to a pushed image as an OCI referrer. +# Non-fatal: a registry that rejects referrers must not break publishing. +set -euo pipefail + +ref="${1:?usage: attach-sbom.sh }" +sbom="${2:?usage: attach-sbom.sh }" + +if [ ! -s "$sbom" ]; then + echo "::warning::SBOM '$sbom' missing or empty; skipping attach for ${ref}" + exit 0 +fi + +if oras attach --artifact-type application/spdx+json "$ref" "${sbom}:application/spdx+json"; then + echo "Attached SBOM ${sbom} to ${ref}" +else + echo "::warning::Failed to attach SBOM to ${ref} (registry may not support OCI referrers)" +fi +exit 0 diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh new file mode 100755 index 0000000..f842870 --- /dev/null +++ b/.github/scripts/tests/run.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${HERE}/../../.." && pwd)" +export PATH="${HERE}/stubs:${PATH}" +fail=0 +assert_contains() { # + if printf '%s' "$1" | grep -qF -- "$2"; then echo " ok: $3"; else echo " FAIL: $3 (missing '$2')"; fail=1; fi +} +assert_file() { [ -e "$1" ] && echo " ok: $2 exists" || { echo " FAIL: $2 missing"; fail=1; }; } +assert_no_file() { [ ! -e "$1" ] && echo " ok: $2 absent" || { echo " FAIL: $2 should be absent"; fail=1; }; } + +echo "== attach-sbom.sh ==" +work="$(mktemp -d)"; echo '{}' > "${work}/s.spdx.json" + +# success path +out="$(STUB_ORAS=ok "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? +assert_contains "$out" "Attached" "success prints Attached" +[ "$rc" = "0" ] && echo " ok: exit 0 on success" || { echo " FAIL: exit $rc"; fail=1; } + +# failure path is swallowed +out="$(STUB_ORAS=fail "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? +assert_contains "$out" "::warning::" "failure prints warning" +[ "$rc" = "0" ] && echo " ok: exit 0 on failure" || { echo " FAIL: exit $rc"; fail=1; } + +# missing file +out="$("${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:x /nope.json 2>&1)"; rc=$? +assert_contains "$out" "::warning::" "missing file warns" +[ "$rc" = "0" ] && echo " ok: exit 0 on missing file" || { echo " FAIL: exit $rc"; fail=1; } + +echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" +exit "$fail" diff --git a/.github/scripts/tests/stubs/oras b/.github/scripts/tests/stubs/oras new file mode 100755 index 0000000..f4da6bf --- /dev/null +++ b/.github/scripts/tests/stubs/oras @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Stub oras: succeeds unless STUB_ORAS=fail. Records the call for assertions. +echo "oras $*" >> "${STUB_LOG:-/dev/null}" +if [ "${STUB_ORAS:-ok}" = "fail" ]; then + echo "stub oras: simulated referrer rejection" >&2 + exit 1 +fi +exit 0 From 925e7b6a9ed7f6febe051bcbf6bdb3af452eae8c Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:11:27 +0200 Subject: [PATCH 35/75] Add scan-patch-gate script: plain always ships, hardened gated, per-variant markers + SBOM Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/scan-patch-gate.sh | 88 ++++++++++++++++++++++++++++++ .github/scripts/tests/run.sh | 46 ++++++++++++++++ .github/scripts/tests/stubs/copa | 4 ++ .github/scripts/tests/stubs/docker | 7 +++ .github/scripts/tests/stubs/trivy | 24 ++++++++ 5 files changed, 169 insertions(+) create mode 100755 .github/scripts/scan-patch-gate.sh create mode 100755 .github/scripts/tests/stubs/copa create mode 100755 .github/scripts/tests/stubs/docker create mode 100755 .github/scripts/tests/stubs/trivy diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh new file mode 100755 index 0000000..641443d --- /dev/null +++ b/.github/scripts/scan-patch-gate.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Per-variant: scan the plain image, patch with Copa (or mirror if nothing fixable), +# gate the hardened image on GATE_SEVERITY, and -- only if it passes -- publish the +# hardened outputs and generate its SPDX SBOM. A gate failure (or scan/patch error) +# writes gate_failed.txt, skips the hardened outputs, and exits 0 so the plain image +# still ships and other variants continue. Genuine infra errors abort (set -e). +set -euo pipefail + +variant="${1:?usage: scan-patch-gate.sh }" +: "${IMAGE_NAME:?}"; : "${ARCH_TAG:?}"; : "${GATE_SEVERITY:?}" +STATE_DIR="${STATE_DIR:-.docker-state}" +SBOM_DIR="${SBOM_DIR:-sboms}" +REPORT_DIR="${REPORT_DIR:-trivy-reports}" +BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}" +vdir="${STATE_DIR}/${variant}" +mkdir -p "$SBOM_DIR" "$REPORT_DIR" + +PLAIN_IMAGE=$(< "${vdir}/plain_image.txt") +BASE_TAG=$(< "${vdir}/base_tag.txt") +VERSION=$(< "${vdir}/version.txt") +TAG=$(< "${vdir}/tag.txt") +HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" +report="/tmp/spg-${variant}.json" + +fail_gate() { # -- record + skip hardened, but let plain ship + echo "::error::${variant}: $1" + { echo "## Gate failed: ${HARDENED_IMAGE}"; echo ""; echo "$1"; echo ""; } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + echo "$1" > "${vdir}/gate_failed.txt" + rm -f "${vdir}/hardened_image.txt" "${vdir}/hardened_tags.txt" "${vdir}/hardened_sbom.txt" + rm -f "$report" + exit 0 +} + +echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" +trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ + || fail_gate "Trivy scan of plain image failed" + +if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null 2>&1; then + copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ + || fail_gate "Copa patch failed" + docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1 \ + || fail_gate "Hardened image not found after copa patch" + echo "Successfully patched ${PLAIN_IMAGE} into ${HARDENED_IMAGE}" +else + echo "No fixable OS vulnerabilities found; hardened image mirrors plain" + docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" +fi +rm -f "$report" + +if [ "$GATE_SEVERITY" != "NONE" ]; then + echo "Running post-patch scan (fail on ${GATE_SEVERITY})" + IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + REPORT_JSON="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.json" + REPORT_TXT="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.txt" + + trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ + --format json -o "${REPORT_JSON}" "${HARDENED_IMAGE}" \ + || fail_gate "Trivy gate scan failed" + + trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ + --format table -o "/tmp/spg-${variant}.txt" "${HARDENED_IMAGE}" || true + cp "/tmp/spg-${variant}.txt" "${REPORT_TXT}" 2>/dev/null || true + { + echo "## Trivy Scan: ${HARDENED_IMAGE}" + echo "" + echo "### OS Vulnerabilities (${GATE_SEVERITY})" + echo '```' + cat "/tmp/spg-${variant}.txt" 2>/dev/null || echo "No results" + echo '```' + echo "" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + rm -f "/tmp/spg-${variant}.txt" + + if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then + fail_gate "unfixed ${GATE_SEVERITY} vulnerabilities remain after patching" + fi +fi + +# Gate passed (or disabled): publish hardened tags + SBOM. +while IFS= read -r plain_tag; do + echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" +done < "${vdir}/plain_tags.txt" > "${vdir}/hardened_tags.txt" +echo "${HARDENED_IMAGE}" > "${vdir}/hardened_image.txt" + +HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" +trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" +echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" +echo "Published hardened outputs for ${variant}" diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index f842870..57f0a2b 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -28,5 +28,51 @@ out="$("${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:x /nope.json 2>& assert_contains "$out" "::warning::" "missing file warns" [ "$rc" = "0" ] && echo " ok: exit 0 on missing file" || { echo " FAIL: exit $rc"; fail=1; } +echo "== scan-patch-gate.sh ==" +setup_variant() { # + local d="$1/.docker-state/$2"; mkdir -p "$d" + echo "pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_image.txt" + echo "php8.5-$2" > "$d/base_tag.txt" + echo "v5.1" > "$d/version.txt" + echo "php8.5-$2-v5.1-amd64" > "$d/tag.txt" + printf '%s\n' \ + "pimcore/pimcore:php8.5-$2-v5.1-amd64" \ + "ghcr.io/pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_tags.txt" +} +run_gate() { # runs scan-patch-gate.sh in with env already exported + ( cd "$1" && IMAGE_NAME=pimcore/pimcore ARCH_TAG=amd64 \ + "${ROOT}/.github/scripts/scan-patch-gate.sh" "$2" ) 2>&1 +} + +# Scenario A: fixable vulns, gate passes -> hardened published +wA="$(mktemp -d)"; setup_variant "$wA" default +outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass run_gate "$wA" default)"; rcA=$? +[ "$rcA" = 0 ] && echo " ok: A exit 0" || { echo " FAIL: A exit $rcA"; fail=1; } +assert_file "$wA/.docker-state/default/hardened_image.txt" "A hardened_image" +assert_file "$wA/.docker-state/default/hardened_tags.txt" "A hardened_tags" +assert_file "$wA/.docker-state/default/hardened_sbom.txt" "A hardened_sbom" +assert_no_file "$wA/.docker-state/default/gate_failed.txt" "A gate_failed" +assert_contains "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "hardened-amd64" "A tags carry -hardened" + +# Scenario B: gate fails -> plain only, marker written, exit 0 +wB="$(mktemp -d)"; setup_variant "$wB" max +outB="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=fail run_gate "$wB" max)"; rcB=$? +[ "$rcB" = 0 ] && echo " ok: B exit 0 (does not abort step)" || { echo " FAIL: B exit $rcB"; fail=1; } +assert_file "$wB/.docker-state/max/gate_failed.txt" "B gate_failed marker" +assert_no_file "$wB/.docker-state/max/hardened_image.txt" "B hardened_image" +assert_contains "$outB" "::error::" "B emits ::error::" + +# Scenario C: nothing fixable -> hardened mirrors plain, gate passes +wC="$(mktemp -d)"; setup_variant "$wC" min +outC="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=no STUB_GATE=pass run_gate "$wC" min)"; rcC=$? +[ "$rcC" = 0 ] && echo " ok: C exit 0" || { echo " FAIL: C exit $rcC"; fail=1; } +assert_file "$wC/.docker-state/min/hardened_image.txt" "C hardened_image (mirror)" +assert_no_file "$wC/.docker-state/min/gate_failed.txt" "C gate_failed" + +# Scenario D: gate disabled (NONE) -> hardened published without gate scan +wD="$(mktemp -d)"; setup_variant "$wD" debug +outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes run_gate "$wD" debug)"; rcD=$? +assert_file "$wD/.docker-state/debug/hardened_image.txt" "D hardened_image (NONE)" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/scripts/tests/stubs/copa b/.github/scripts/tests/stubs/copa new file mode 100755 index 0000000..44e9d8a --- /dev/null +++ b/.github/scripts/tests/stubs/copa @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "copa $*" >> "${STUB_LOG:-/dev/null}" +[ "${STUB_COPA:-ok}" = "fail" ] && { echo "stub copa: simulated failure" >&2; exit 1; } +exit 0 diff --git a/.github/scripts/tests/stubs/docker b/.github/scripts/tests/stubs/docker new file mode 100755 index 0000000..097e6fb --- /dev/null +++ b/.github/scripts/tests/stubs/docker @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Stub docker: 'image inspect' exists-check exits 0; with --format prints a fake id. +if [ "$1 $2" = "image inspect" ]; then + if printf '%s ' "$@" | grep -q -- '--format'; then echo "sha256:deadbeefcafe0000"; fi + exit 0 +fi +exit 0 diff --git a/.github/scripts/tests/stubs/trivy b/.github/scripts/tests/stubs/trivy new file mode 100755 index 0000000..f34ad98 --- /dev/null +++ b/.github/scripts/tests/stubs/trivy @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Stub trivy. Scenario via env: STUB_FIXABLE=yes|no (initial OS scan), +# STUB_GATE=pass|fail (severity-filtered gate scan). SPDX just writes a minimal doc. +out=""; sev=""; fmt="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2;; + --severity) sev="$2"; shift 2;; + --format) fmt="$2"; shift 2;; + *) shift;; + esac +done +case "$fmt" in + spdx-json) printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; + table) echo "stub trivy table report" > "$out"; exit 0;; +esac +# JSON vulnerability scan +if [ -n "$sev" ]; then + [ "${STUB_GATE:-pass}" = "fail" ] && v='[{"VulnerabilityID":"CVE-GATE"}]' || v='[]' +else + [ "${STUB_FIXABLE:-yes}" = "no" ] && v='[]' || v='[{"VulnerabilityID":"CVE-FIX"}]' +fi +printf '{"Results":[{"Vulnerabilities":%s}]}\n' "$v" > "$out" +exit 0 From ea021e099734ea0553d1ba9e53b6426357b7538e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:20:16 +0200 Subject: [PATCH 36/75] scan-patch-gate: fail closed on bad report, guard hash+SBOM, atomic publish, stronger tests --- .github/scripts/scan-patch-gate.sh | 23 ++++++++++++++++------- .github/scripts/tests/run.sh | 6 +++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index 641443d..2102db8 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -35,7 +35,9 @@ echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ || fail_gate "Trivy scan of plain image failed" -if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null 2>&1; then +jq empty "$report" 2>/dev/null || fail_gate "Trivy report of plain image is not valid JSON" + +if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null; then copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ || fail_gate "Copa patch failed" docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1 \ @@ -49,7 +51,11 @@ rm -f "$report" if [ "$GATE_SEVERITY" != "NONE" ]; then echo "Running post-patch scan (fail on ${GATE_SEVERITY})" - IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) + if ! HARDENED_ID=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}'); then + fail_gate "could not inspect hardened image for report hash" + fi + IMAGE_HASH="${HARDENED_ID#sha256:}" + IMAGE_HASH="${IMAGE_HASH:0:12}" REPORT_JSON="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.json" REPORT_TXT="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.txt" @@ -71,18 +77,21 @@ if [ "$GATE_SEVERITY" != "NONE" ]; then } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" rm -f "/tmp/spg-${variant}.txt" + jq empty "${REPORT_JSON}" 2>/dev/null || fail_gate "Trivy gate report is not valid JSON" + if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then fail_gate "unfixed ${GATE_SEVERITY} vulnerabilities remain after patching" fi fi -# Gate passed (or disabled): publish hardened tags + SBOM. +# Gate passed (or disabled): generate the SBOM first, then publish the markers atomically. +HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" +trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" \ + || fail_gate "hardened SBOM generation failed" + while IFS= read -r plain_tag; do echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" done < "${vdir}/plain_tags.txt" > "${vdir}/hardened_tags.txt" echo "${HARDENED_IMAGE}" > "${vdir}/hardened_image.txt" - -HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" -trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" -echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" +echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" echo "Published hardened outputs for ${variant}" diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 57f0a2b..74904b5 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -46,13 +46,14 @@ run_gate() { # runs scan-patch-gate.sh in with env already exported # Scenario A: fixable vulns, gate passes -> hardened published wA="$(mktemp -d)"; setup_variant "$wA" default -outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass run_gate "$wA" default)"; rcA=$? +outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wA/copa.log" run_gate "$wA" default)"; rcA=$? [ "$rcA" = 0 ] && echo " ok: A exit 0" || { echo " FAIL: A exit $rcA"; fail=1; } assert_file "$wA/.docker-state/default/hardened_image.txt" "A hardened_image" assert_file "$wA/.docker-state/default/hardened_tags.txt" "A hardened_tags" assert_file "$wA/.docker-state/default/hardened_sbom.txt" "A hardened_sbom" assert_no_file "$wA/.docker-state/default/gate_failed.txt" "A gate_failed" assert_contains "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "hardened-amd64" "A tags carry -hardened" +assert_contains "$(cat "$wA/copa.log" 2>/dev/null)" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference" # Scenario B: gate fails -> plain only, marker written, exit 0 wB="$(mktemp -d)"; setup_variant "$wB" max @@ -72,7 +73,10 @@ assert_no_file "$wC/.docker-state/min/gate_failed.txt" "C gate_failed" # Scenario D: gate disabled (NONE) -> hardened published without gate scan wD="$(mktemp -d)"; setup_variant "$wD" debug outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes run_gate "$wD" debug)"; rcD=$? +[ "$rcD" = 0 ] && echo " ok: D exit 0" || { echo " FAIL: D exit $rcD"; fail=1; } assert_file "$wD/.docker-state/debug/hardened_image.txt" "D hardened_image (NONE)" +assert_file "$wD/.docker-state/debug/hardened_tags.txt" "D hardened_tags (NONE)" +assert_file "$wD/.docker-state/debug/hardened_sbom.txt" "D hardened_sbom (NONE)" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From e85868dbd0a0a0bbec052f2fc4024ac21d5c2095 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:26:52 +0200 Subject: [PATCH 37/75] release.yml: unconditional Trivy+oras, plain SBOM, delegate gate to script, fail-fast: false --- .github/workflows/release.yml | 111 +++++++++------------------------- 1 file changed, 29 insertions(+), 82 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 174b05c..d9513ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ env: IMAGE_NAME: pimcore/pimcore COPA_VERSION: "0.14.1" BUILDKIT_VERSION: "0.30.0" + ORAS_VERSION: "1.2.0" TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" jobs: @@ -31,6 +32,7 @@ jobs: runs-on: ${{ matrix.runner }} if: github.repository == 'pimcore/docker' strategy: + fail-fast: false matrix: runner: - ubuntu-22.04 @@ -65,11 +67,9 @@ 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 - if: ${{ matrix.build.hardened }} + - name: Install Trivy and oras run: | set -eux - # Install Trivy sudo apt-get update sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null @@ -77,7 +77,23 @@ jobs: sudo apt-get update sudo apt-get install -y trivy - # Install Copa + ORAS_ARCH="$(dpkg --print-architecture)" + curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" + EXPECTED_SHA=$(grep -F "oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" oras_checksums.txt | awk '{print $1}') + ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') + if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 + fi + tar -xzf oras.tar.gz oras + sudo mv oras /usr/local/bin/oras + rm oras.tar.gz oras_checksums.txt + + - name: Install Copa + if: ${{ matrix.build.hardened }} + run: | + set -eux 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" @@ -181,6 +197,11 @@ jobs: --build-arg PHP_VERSION="${{ matrix.build.php }}" \ --build-arg DEBIAN_VERSION="${{ matrix.build.distro }}" \ --tag "${PLAIN_IMAGE}" . + + mkdir -p sboms + PLAIN_SBOM="sboms/${TAG}.spdx.json" + trivy image --format spdx-json -o "${PLAIN_SBOM}" "${PLAIN_IMAGE}" + echo "${PLAIN_SBOM}" > ".docker-state/${imageVariant}/plain_sbom.txt" done - name: Scan, patch, and gate hardened images @@ -227,86 +248,12 @@ jobs: echo "Severity gate: fail_on_severity='${FAIL_ON_SEVERITY}' -> '${GATE_SEVERITY}'" fi - mapfile -t imageVariants < .docker-state/variants.txt + export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY + export BUILDKIT_ADDR="tcp://127.0.0.1:8888" + mapfile -t imageVariants < .docker-state/variants.txt 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}" - - 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 - copa patch -i "${PLAIN_IMAGE}" \ - -r /tmp/trivy-report.json \ - -t "${HARDENED_IMAGE}" \ - -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: 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 [ "$GATE_SEVERITY" != "NONE" ]; then - echo "Running post-patch scan (fail on ${GATE_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 "$GATE_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 "$GATE_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 (${GATE_SEVERITY})" - echo '```' - cat /tmp/trivy-os-${TAG}.txt 2>/dev/null || echo "No results" - echo '```' - echo "" - } >> "$GITHUB_STEP_SUMMARY" - rm -f /tmp/trivy-os-${TAG}.txt - - # Gate on the JSON findings -- no third Trivy invocation needed. - if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then - echo "::error::${HARDENED_IMAGE} has unfixed ${GATE_SEVERITY} vulnerabilities after patching" - exit 1 - fi - fi + .github/scripts/scan-patch-gate.sh "${imageVariant}" done - name: Tag, push, and aggregate From 147c4a579fc111c8f8904b509f3efc73936740b4 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:32:04 +0200 Subject: [PATCH 38/75] release.yml: attach SBOMs on push, defer gate failure to end, run process-tags on always() --- .github/workflows/release.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9513ed..e9d81a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,6 +267,8 @@ jobs: for imageVariant in "${imageVariants[@]}"; do PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" ALL_TAGS=("${PLAIN_TAGS[@]}") @@ -292,6 +294,16 @@ jobs: if [[ "$PUSH" == "true" ]]; then printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + # Attach the SPDX SBOM to each pushed image (once per digest per registry). + .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" + if [ -n "${HARDENED_IMAGE}" ] && [ -f ".docker-state/${imageVariant}/hardened_sbom.txt" ]; then + HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") + HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" + .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" + fi + for tag in "${ALL_TAGS[@]}"; do logical_tag="${tag//-arm64/}" logical_tag="${logical_tag//-amd64/}" @@ -326,11 +338,22 @@ jobs: 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 - + + - name: Fail if severity gate failed + if: ${{ matrix.build.hardened }} + run: | + if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then + echo "The following variants failed the severity gate; their -hardened tags were NOT published:" + grep -H . .docker-state/*/gate_failed.txt + echo "::error::One or more variants failed the severity gate (plain images were published as-is)" + exit 1 + fi + echo "All hardened variants passed the severity gate." + process-tags: runs-on: ubuntu-22.04 needs: build-php - if: github.event_name != 'workflow_dispatch' || inputs.publish + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} steps: - name: Set up Docker Buildx From 571a65188a60151d9d313210b8ca6c5fa0b9f7f3 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:36:18 +0200 Subject: [PATCH 39/75] test.yml: add scripts job running actionlint and script unit tests Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1463de2..9f83463 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,3 +80,16 @@ jobs: ignore-unfixed: true vuln-type: 'os,library' severity: 'CRITICAL,HIGH' + scripts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Install actionlint + run: | + curl -fsSL -o actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz + tar -xzf actionlint.tar.gz actionlint + sudo mv actionlint /usr/local/bin/actionlint + - name: Lint workflows + run: actionlint -color + - name: Run script unit tests + run: .github/scripts/tests/run.sh From 3e605a23d384091e1196d2d11a6f551c1a851f56 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:37:44 +0200 Subject: [PATCH 40/75] test.yml: bump actions/checkout v2 -> v5 so the new actionlint gate passes Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f83463..9474557 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - { php: '8.5', distro: trixie, composerOptions: '--ignore-platform-reqs' } steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Build Image run: | set -ex From bd68186c26e6fe046ade7e55c7c264be8d847dbd Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:40:28 +0200 Subject: [PATCH 41/75] README: document Copa hardening, plain-always-publish gate semantics, and SBOMs Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index be57b20..51f4478 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,22 @@ 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. +- **plain** (default, unsuffixed) – the image exactly as built from the Dockerfile, e.g. `php8.5-debug-v5`. It is published as-is and may carry known OS-level CVEs. +- **hardened** (`-hardened` suffix) – the same image with OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. + +**What hardening does:** after the plain image is built, it is scanned with [Trivy](https://github.com/aquasecurity/trivy) and Copa applies the available Debian security updates for OS-level packages as an extra image layer. PHP, its extensions, and all application-level content are identical to the plain image — only OS package versions differ. + +**Scope & guarantees:** +- `-hardened` exists for **stable release tags only**; development tags (`-dev`) are published plain-only. +- The plain tag **always publishes**, even when CVEs remain. +- The `-hardened` tag publishes only when the patched image passes the vulnerability gate (`CRITICAL,HIGH` by default). If a fix is not yet available upstream, the gate fails and the `-hardened` tag temporarily stays at its previous version until the plain image can be patched clean — so a `-hardened` tag never regresses to a vulnerable state. ```text -php8.5-debug-v5 # plain image, as built -php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa +php8.5-debug-v5 # plain image, as built (may contain CVEs) +php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa, gate-clean ``` -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. +**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM, attached to the image in the registry as an OCI referrer and uploaded as a build artifact. ## 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. From f008140038ed0c8609841d4a2af2f40475a8d18e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:45:16 +0200 Subject: [PATCH 42/75] release.yml: upload SBOMs as build artifact (guaranteed fallback to the best-effort oras attach) --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9d81a7..100cf9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -331,6 +331,14 @@ jobs: path: trivy-reports/ if-no-files-found: ignore + - name: Upload SBOMs + if: always() + uses: actions/upload-artifact@v7 + with: + name: sboms_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} + path: sboms/ + if-no-files-found: ignore + - name: Upload aggregated tags if: github.event_name != 'workflow_dispatch' || inputs.publish uses: actions/upload-artifact@v7 From 4a7c2eeff57869119d2ddb88490b9f63595ee365 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:46:27 +0200 Subject: [PATCH 43/75] spec: mark all-or-nothing gate decision superseded by 2026-07-02 spec --- .../superpowers/specs/2026-06-15-hardened-image-tag-design.md | 4 ++++ 1 file changed, 4 insertions(+) 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 3e867d5..9f44aca 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 @@ -36,6 +36,10 @@ releases — Copa hardening is mandatory and invisible. We want users to choose: plain nor the hardened tags are published for that image variant — preserving the current "failed gate = nothing ships" contract. +> **Superseded 2026-07-02** (see `2026-07-02-copa-plain-always-publish-sbom-design.md`): +> the gate no longer blocks plain publishing. Plain images always publish; a hardened +> gate failure skips only that variant's `-hardened` tags and turns the job red at the end. + ## Tag scheme The `-hardened` marker is inserted **before** the internal `-amd64` / `-arm64` From 0af4eb8dc9a921b2ccd4d3646030eabde4f658a8 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" Date: Thu, 2 Jul 2026 15:53:50 +0200 Subject: [PATCH 44/75] release.yml: restore pimcore/docker repo guard on process-tags The process-tags always() condition (added so a red build-php leg still publishes manifests for passing lines) inadvertently dropped the implicit repository guard: build-php has 'if: github.repository == pimcore/docker', so on forks it is skipped and process-tags used to skip with it. Under always() it would run on forks and fail on empty-secret docker login. Co-Authored-By: Claude Opus 4.8 (1M context) --- .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 100cf9d..e020678 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -361,7 +361,7 @@ jobs: process-tags: runs-on: ubuntu-22.04 needs: build-php - if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} + if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} steps: - name: Set up Docker Buildx From c534a0a9d2f1916d8db9ccab78f95b11bd5fd467 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:05:07 +0200 Subject: [PATCH 45/75] spec/plan: split publish path so plain ships before the gate (Task 8) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-02-copa-plain-always-publish-sbom.md | 156 ++++++++++++++++++ ...2-copa-plain-always-publish-sbom-design.md | 40 ++++- 2 files changed, 190 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md index 5c39a67..758228c 100644 --- a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md +++ b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md @@ -777,3 +777,159 @@ git commit -m "spec: mark all-or-nothing gate decision superseded by 2026-07-02 **Type/name consistency:** state files (`plain_image.txt`, `base_tag.txt`, `version.txt`, `tag.txt`, `plain_tags.txt`, `plain_sbom.txt`, `hardened_image.txt`, `hardened_tags.txt`, `hardened_sbom.txt`, `gate_failed.txt`) are written and read with identical names across Tasks 2–4. `scan-patch-gate.sh` env contract (`IMAGE_NAME`, `ARCH_TAG`, `GATE_SEVERITY`, `BUILDKIT_ADDR`) matches the exports added in Task 3 Step 5. `attach-sbom.sh ` signature matches its calls in Task 4 Step 1. **Known follow-ups (not blocking):** Part 3 package-docs job; optional cosign signing of SBOMs. + +--- + +### Task 8: Split the publish path so plain ships before the gate (added 2026-07-02) + +**Why:** the final review found that the single `Tag, push, and aggregate` step runs +*after* the gate step with the implicit `if: success()`, so an unforeseen non-zero exit of +the gate step would skip publishing the already-built plain images. Decision: make "plain +always ships" ironclad by pushing plain **before** the gate and hardened **after** it. + +**Files:** +- Modify: `.github/workflows/release.yml` + +**Interfaces:** unchanged — same `.docker-state//*.txt` files and +`.github/scripts/attach-sbom.sh`. Scripts are NOT modified; the existing unit tests remain +valid. + +- [ ] **Step 1: Add `Push plain images` immediately after `Build plain images` (before `Scan, patch, and gate hardened images`)** + +```yaml + - name: Push plain images + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + run: | + set -eux + + mapfile -t imageVariants < .docker-state/variants.txt + + for imageVariant in "${imageVariants[@]}"; do + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") + mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" + + for plain_tag in "${PLAIN_TAGS[@]}"; do + if [ "$plain_tag" != "$PLAIN_IMAGE" ]; then + docker tag "$PLAIN_IMAGE" "$plain_tag" + fi + done + + # Plain ships unconditionally, before the gate ever runs. + # Do NOT rmi here: the gate step patches this image into the hardened one. + if [[ "$PUSH" == "true" ]]; then + printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" + + for tag in "${PLAIN_TAGS[@]}"; do + logical_tag="${tag//-arm64/}" + logical_tag="${logical_tag//-amd64/}" + echo "$logical_tag" >> aggregated_tags.txt + done + fi + done +``` + +- [ ] **Step 2: Replace the `Tag, push, and aggregate` step with `Push hardened images` (placed after `Scan, patch, and gate hardened images`)** + +Delete the entire existing `Tag, push, and aggregate` step and put this in its place: + +```yaml + - name: Push hardened images + if: ${{ matrix.build.hardened }} + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + run: | + set -eux + + mapfile -t imageVariants < .docker-state/variants.txt + + for imageVariant in "${imageVariants[@]}"; do + # Variants whose gate failed have no hardened_image.txt -> skip (plain already shipped). + [ -f ".docker-state/${imageVariant}/hardened_image.txt" ] || continue + + HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") + HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") + mapfile -t HARDENED_TAGS < ".docker-state/${imageVariant}/hardened_tags.txt" + + for hardened_tag in "${HARDENED_TAGS[@]}"; do + if [ "$hardened_tag" != "$HARDENED_IMAGE" ]; then + docker tag "$HARDENED_IMAGE" "$hardened_tag" + fi + done + + if [[ "$PUSH" == "true" ]]; then + printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" + .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" + + for tag in "${HARDENED_TAGS[@]}"; do + logical_tag="${tag//-arm64/}" + logical_tag="${logical_tag//-amd64/}" + echo "$logical_tag" >> aggregated_tags.txt + done + fi + done +``` + +- [ ] **Step 3: Add `Clean up images` (after `Push hardened images`, before `Stop buildkit daemon`)** + +```yaml + - name: Clean up images + if: ${{ always() }} + run: | + set -u + [ -f .docker-state/variants.txt ] || exit 0 + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + for tf in plain_tags hardened_tags; do + f=".docker-state/${imageVariant}/${tf}.txt" + [ -f "$f" ] || continue + while IFS= read -r t; do docker rmi "$t" 2>/dev/null || true; done < "$f" + done + for imf in plain_image hardened_image; do + f=".docker-state/${imageVariant}/${imf}.txt" + [ -f "$f" ] && docker rmi "$(< "$f")" 2>/dev/null || true + done + done +``` + +- [ ] **Step 4: Confirm step order and leave the rest untouched** + +The `build-php` job step order must now be: `Build plain images` → `Push plain images` → +`Scan, patch, and gate hardened images` → `Push hardened images` → `Clean up images` → +`Stop buildkit daemon` → `Upload trivy reports` → `Upload SBOMs` → `Upload aggregated +tags` → `Fail if severity gate failed`. Do not change any step other than the three +added/replaced here. `process-tags` (with its `always() && github.repository == 'pimcore/docker' && …` guard) is untouched. + +- [ ] **Step 5: Lint and syntax-check** + +Run: +```bash +ALINT=$(command -v actionlint || echo /tmp/actionlint) +"$ALINT" .github/workflows/release.yml; echo "actionlint exit=$?" +for step in "Push plain images" "Push hardened images" "Clean up images"; do + START=$(grep -n "name: ${step}" .github/workflows/release.yml | head -1 | cut -d: -f1) + END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) + awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ + | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/blk.sh + bash -n /tmp/blk.sh && echo "OK: ${step}" || echo "SYNTAX FAIL: ${step}" +done +.github/scripts/tests/run.sh >/dev/null 2>&1 && echo "script unit tests still pass" || echo "SCRIPT TESTS FAIL" +``` +Expected: `actionlint exit=0`; `OK:` for all three steps; script unit tests still pass (scripts unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release.yml: push plain before the gate, hardened after (plain always ships)" +``` diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index d64c9ce..ce2dc13 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -91,12 +91,40 @@ image, Trivy scan error) with: The step itself always exits 0. The existing severity normalisation (`GATE_SEVERITY`) and Trivy report artifacts are unchanged. -### Tag, push, and aggregate step - -Unchanged logic — it already pushes hardened tags only when `hardened_image.txt` exists. -Effect under the new markers: plain always pushes; gate-failed variants' `-hardened` tags -are not pushed and remain at their previously published state in the registries -(documented in README). Aggregation likewise skips absent hardened tags, so `process-tags` +### Publish ordering — plain ships *before* the gate (revised 2026-07-02) + +To make "plain always ships" ironclad — not merely "ships unless the gate step hits an +unforeseen error" — the single combined push step is split so the plain push happens +**before** the scan/patch/gate step, and hardened is pushed **after** it. New `build-php` +step order on a hardened leg: + +1. **Build plain images** — builds every variant, writes state + plain SBOMs (unchanged). +2. **Push plain images** (`if PUSH`) — tag + push the plain tag set, attach the plain + SBOM, aggregate the plain logical tags. Runs right after the build and depends only on + it, so the gate can never prevent plain from shipping. Does **not** `docker rmi` (the + gate still needs the plain image on hardened legs). +3. **Scan, patch, and gate hardened images** (`if hardened`) — Copa builds the hardened + image from the already-pushed plain image and gates it; per-variant markers as above; + step exits 0. +4. **Push hardened images** (`if hardened`, default `success()`) — for each variant that + has `hardened_image.txt`, tag + push the hardened tag set, attach the hardened SBOM, + aggregate the hardened logical tags. Because it defaults to `success()`, an *unforeseen + crash* of the gate step skips hardened push (plain already shipped, job goes red from + the crash); a normal gate *failure* (fail_gate → exit 0) still runs this step, which + simply skips the failed variants (no `hardened_image.txt`). +5. **Cleanup images** (`if: always()`) — `docker rmi` the plain and hardened images for + every variant, reclaiming disk regardless of outcome. + +Outcomes: +- Build fails → nothing pushed (can't publish what wasn't built). +- Build ok, gate step crashes → **plain already pushed**; hardened skipped; job red. +- Build ok, gate fail_gate on a variant → plain pushed; that variant's `-hardened` skipped + and left at its previously published state; other variants' hardened pushed; job red via + the deferred fail step. +- All pass → plain + hardened pushed; green. + +Aggregation: both push steps append their logical tags (arch suffix stripped) to +`aggregated_tags.txt`; gate-failed variants contribute no hardened tags, so `process-tags` never sees them. ### New final step: `Fail if severity gate failed` From 4080235ec7f29d4c853a0ae0b183fc6e7afe4352 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:07:19 +0200 Subject: [PATCH 46/75] release.yml: push plain before the gate, hardened after (plain always ships) --- .github/workflows/release.yml | 105 ++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e020678..29b20ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -204,6 +204,43 @@ jobs: echo "${PLAIN_SBOM}" > ".docker-state/${imageVariant}/plain_sbom.txt" done + - name: Push plain images + env: + ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} + PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + run: | + set -eux + + mapfile -t imageVariants < .docker-state/variants.txt + + for imageVariant in "${imageVariants[@]}"; do + PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") + TAG=$(< ".docker-state/${imageVariant}/tag.txt") + PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") + mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" + + for plain_tag in "${PLAIN_TAGS[@]}"; do + if [ "$plain_tag" != "$PLAIN_IMAGE" ]; then + docker tag "$PLAIN_IMAGE" "$plain_tag" + fi + done + + # Plain ships unconditionally, before the gate ever runs. + # Do NOT rmi here: the gate step patches this image into the hardened one. + if [[ "$PUSH" == "true" ]]; then + printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" + + for tag in "${PLAIN_TAGS[@]}"; do + logical_tag="${tag//-arm64/}" + logical_tag="${logical_tag//-amd64/}" + echo "$logical_tag" >> aggregated_tags.txt + done + fi + done + - name: Scan, patch, and gate hardened images if: ${{ matrix.build.hardened }} env: @@ -256,7 +293,8 @@ jobs: .github/scripts/scan-patch-gate.sh "${imageVariant}" done - - name: Tag, push, and aggregate + - name: Push hardened images + if: ${{ matrix.build.hardened }} env: ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} @@ -266,56 +304,49 @@ jobs: mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do - PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") - TAG=$(< ".docker-state/${imageVariant}/tag.txt") - PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") - mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" - ALL_TAGS=("${PLAIN_TAGS[@]}") + # Variants whose gate failed have no hardened_image.txt -> skip (plain already shipped). + [ -f ".docker-state/${imageVariant}/hardened_image.txt" ] || continue - HARDENED_IMAGE="" - if [ -f ".docker-state/${imageVariant}/hardened_image.txt" ]; then - HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") - mapfile -t HARDENED_TAGS < ".docker-state/${imageVariant}/hardened_tags.txt" - ALL_TAGS+=("${HARDENED_TAGS[@]}") - fi + HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") + HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") + mapfile -t HARDENED_TAGS < ".docker-state/${imageVariant}/hardened_tags.txt" - # Apply every tag to its source image (plain or hardened). - for additional_tag in "${ALL_TAGS[@]}"; do - case "$additional_tag" in - *-hardened-${ARCH_TAG}) src_image="${HARDENED_IMAGE}" ;; - *) src_image="${PLAIN_IMAGE}" ;; - esac - if [ "$additional_tag" != "$src_image" ]; then - docker tag "$src_image" "$additional_tag" + for hardened_tag in "${HARDENED_TAGS[@]}"; do + if [ "$hardened_tag" != "$HARDENED_IMAGE" ]; then + docker tag "$HARDENED_IMAGE" "$hardened_tag" fi done - # Push and aggregate logical tags (parallel push for speed). if [[ "$PUSH" == "true" ]]; then - printf '%s\n' "${ALL_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - # Attach the SPDX SBOM to each pushed image (once per digest per registry). - .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" - if [ -n "${HARDENED_IMAGE}" ] && [ -f ".docker-state/${imageVariant}/hardened_sbom.txt" ]; then - HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") - HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" - .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - fi + HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" + .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" + .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - for tag in "${ALL_TAGS[@]}"; do + for tag in "${HARDENED_TAGS[@]}"; do logical_tag="${tag//-arm64/}" logical_tag="${logical_tag//-amd64/}" echo "$logical_tag" >> aggregated_tags.txt done fi + done - # Clean up per variant to reclaim disk space before the next variant. - # ALL_TAGS already includes PLAIN_IMAGE and HARDENED_IMAGE as their - # first entries, so a single loop covers everything. - for additional_tag in "${ALL_TAGS[@]}"; do - docker rmi "$additional_tag" 2>/dev/null || true + - name: Clean up images + if: ${{ always() }} + run: | + set -u + [ -f .docker-state/variants.txt ] || exit 0 + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + for tf in plain_tags hardened_tags; do + f=".docker-state/${imageVariant}/${tf}.txt" + [ -f "$f" ] || continue + while IFS= read -r t; do docker rmi "$t" 2>/dev/null || true; done < "$f" + done + for imf in plain_image hardened_image; do + f=".docker-state/${imageVariant}/${imf}.txt" + [ -f "$f" ] && docker rmi "$(< "$f")" 2>/dev/null || true done done From ed61f317bd01174ad1e89b7ed5bcfe5a7563d0a2 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:11:53 +0200 Subject: [PATCH 47/75] release.yml: drop dead ARCH_TAG env from the split push steps Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29b20ff..13a0193 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,7 +206,6 @@ jobs: - name: Push plain images env: - ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} run: | set -eux @@ -296,7 +295,6 @@ jobs: - name: Push hardened images if: ${{ matrix.build.hardened }} env: - ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} run: | set -eux From 2449e03d9690784747b9aa7f26369ab99fb1b9a2 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:43:14 +0200 Subject: [PATCH 48/75] release.yml: fix critical/important review findings - C1: check out .github/scripts from the workflow ref into _ci/ (the build ref matrix.build.tag has no scripts -> exit 127); prefix all script calls with _ci/. - C2: stable 5-line v5.1 -> v5.2 (5.x already ships v5.2; avoids a downgrade). - I1: Upload aggregated tags gains always() so a gate-step crash can't strand plain images without their multi-arch logical tags. - I3: aggregate full per-arch tags and merge in process-tags only when BOTH arches were pushed THIS run (no stale-arch mixed manifests); download only aggregated_tags_* artifacts. - fail_on_severity: case-insensitive NONE, error on empty/comma-only input (was silently strictest); clearer threshold-semantics input description. - Install Trivy step gains pipefail so a failed key fetch can't be masked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 103 ++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13a0193..408be8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: default: false type: boolean fail_on_severity: - description: 'Comma-separated list of severities that fail the build if post-patch CVEs remain (e.g. CRITICAL,HIGH). Valid values: CRITICAL, HIGH, MEDIUM, LOW. Use NONE to disable the gate entirely.' + description: 'Severity THRESHOLD for the post-patch gate: naming a severity also gates everything above it (e.g. HIGH gates HIGH,CRITICAL). Case-insensitive. Valid values: UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL (or a comma-separated set — the lowest one wins). Use NONE to disable the gate entirely.' required: false default: 'CRITICAL,HIGH' type: string @@ -50,7 +50,7 @@ jobs: - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false, hardened: false } - { tag: 'v4.2', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, hardened: true } - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false, hardened: false } - - { tag: 'v5.1', php: '8.5', distro: trixie, version-override: "", latest-tag: true, hardened: true } + - { tag: 'v5.2', 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: @@ -58,6 +58,17 @@ jobs: with: ref: ${{ matrix.build.tag }} + # The build ref above (matrix.build.tag) is a release branch/tag that predates + # this pipeline and does NOT contain .github/scripts. Check the CI scripts out + # separately from the workflow's own commit (github.sha) into _ci/ so the steps + # below can call them regardless of which build ref is checked out into the root. + - name: Check out CI scripts from the workflow ref + uses: actions/checkout@v5 + with: + path: _ci + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -69,7 +80,7 @@ jobs: - name: Install Trivy and oras run: | - set -eux + set -euxo pipefail sudo apt-get update sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null @@ -229,14 +240,12 @@ jobs: if [[ "$PUSH" == "true" ]]; then printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" + _ci/.github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" + _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" - for tag in "${PLAIN_TAGS[@]}"; do - logical_tag="${tag//-arm64/}" - logical_tag="${logical_tag//-amd64/}" - echo "$logical_tag" >> aggregated_tags.txt - done + # Record the full per-arch tags pushed THIS run; process-tags merges a + # logical tag only when both arches were pushed in the same run. + printf '%s\n' "${PLAIN_TAGS[@]}" >> aggregated_tags.txt fi done @@ -254,11 +263,16 @@ jobs: # fail_on_severity is a *threshold*: naming a severity also gates everything # above it (e.g. HIGH -> HIGH,CRITICAL), since Trivy's --severity is otherwise # an exact filter that would let higher severities slip through. NONE disables it. + # fail_on_severity is a case-insensitive THRESHOLD. NONE disables the gate; + # any other value normalises to the inclusive range from the lowest named + # severity up to CRITICAL, so Trivy's exact --severity filter can't let a + # higher severity slip through. SEVERITY_ORDER="UNKNOWN LOW MEDIUM HIGH CRITICAL" - GATE_SEVERITY="$FAIL_ON_SEVERITY" - if [ "$GATE_SEVERITY" != "NONE" ]; then + if [ "${FAIL_ON_SEVERITY^^}" = "NONE" ]; then + GATE_SEVERITY="NONE" + else min_rank=-1 - IFS=',' read -r -a requested_severities <<< "${GATE_SEVERITY^^}" + IFS=',' read -r -a requested_severities <<< "${FAIL_ON_SEVERITY^^}" for sev in "${requested_severities[@]}"; do sev="${sev// /}" [ -z "$sev" ] && continue @@ -273,6 +287,10 @@ jobs: fi if [ "$min_rank" -lt 0 ] || [ "$rank" -lt "$min_rank" ]; then min_rank=$rank; fi done + if [ "$min_rank" -lt 0 ]; then + echo "::error::fail_on_severity='${FAIL_ON_SEVERITY}' names no valid severity. Use ${SEVERITY_ORDER// /, }, or NONE." + exit 1 + fi # Rebuild as the inclusive range from the lowest requested severity up to CRITICAL. GATE_SEVERITY=""; i=0 for known in $SEVERITY_ORDER; do @@ -289,7 +307,7 @@ jobs: mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do - .github/scripts/scan-patch-gate.sh "${imageVariant}" + _ci/.github/scripts/scan-patch-gate.sh "${imageVariant}" done - name: Push hardened images @@ -319,14 +337,12 @@ jobs: printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" - .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" + _ci/.github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" + _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - for tag in "${HARDENED_TAGS[@]}"; do - logical_tag="${tag//-arm64/}" - logical_tag="${logical_tag//-amd64/}" - echo "$logical_tag" >> aggregated_tags.txt - done + # Record the full per-arch tags pushed THIS run; process-tags merges a + # logical tag only when both arches were pushed in the same run. + printf '%s\n' "${HARDENED_TAGS[@]}" >> aggregated_tags.txt fi done @@ -369,7 +385,7 @@ jobs: if-no-files-found: ignore - name: Upload aggregated tags - if: github.event_name != 'workflow_dispatch' || inputs.publish + if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} uses: actions/upload-artifact@v7 with: name: aggregated_tags_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} @@ -406,31 +422,36 @@ jobs: uses: actions/download-artifact@v8 with: path: artifacts + pattern: aggregated_tags_* - name: Process tags run: | + set -uo pipefail find artifacts -type f -name "aggregated_tags.txt" -exec cat {} + > all_aggregated_tags.txt - readarray -t TAGS_ARRAY < all_aggregated_tags.txt - - declare -A UNIQUE_TAGS - for tag in "${TAGS_ARRAY[@]}"; do - UNIQUE_TAGS["$tag"]=1 - done - - for tag in "${!UNIQUE_TAGS[@]}"; do - - echo "Processing tag: $tag" - - # Verify both per-arch images exist in the registry before merging - if docker buildx imagetools inspect "${tag}-amd64" > /dev/null 2>&1 \ - && docker buildx imagetools inspect "${tag}-arm64" > /dev/null 2>&1; then + # aggregated_tags.txt holds the full per-arch tags (…-amd64 / …-arm64) pushed + # THIS run. Merge a logical tag only when BOTH arches were pushed in the same + # run, using exactly those per-arch tags — never a stale arch left in the + # registry from a previous run (which would produce a mixed-generation manifest). + declare -A HAS_AMD64 HAS_ARM64 LOGICAL + while IFS= read -r t; do + [ -z "$t" ] && continue + case "$t" in + *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *) echo "Skipping tag without arch suffix: $t" ;; + esac + done < all_aggregated_tags.txt + + for lt in "${!LOGICAL[@]}"; do + if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then + echo "Creating multi-arch manifest: $lt" docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" + --tag "$lt" \ + "${lt}-amd64" \ + "${lt}-arm64" \ + || echo "::warning::Failed to create manifest for $lt" else - echo "Error: Missing per-arch image for $tag, skipping" + echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" fi - done \ No newline at end of file From 65e0aead09e1f6f84f7c39d88bc014b558d9659e Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:45:21 +0200 Subject: [PATCH 49/75] docs: correct hardened gate guarantee, SBOM claim, threshold semantics; v5.2; review notes - README: drop the false 'never regresses / gate-clean' claim (the gate uses --ignore-unfixed, so unfixable CVEs pass); state threshold semantics, plain-always, flavor guidance, and SBOM referrer as best-effort with the artifact as the guaranteed copy. - specs: stable 5-line v5.1 -> v5.2; add post-review notes (Copa-local-image false positive; scheduled/default-branch rollout requirement). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 +++++++------ .../2026-06-15-hardened-image-tag-design.md | 2 +- ...2-copa-plain-always-publish-sbom-design.md | 25 ++++++++++++++++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 51f4478..88c666e 100644 --- a/README.md +++ b/README.md @@ -30,22 +30,23 @@ 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`. It is published as-is and may carry known OS-level CVEs. +- **plain** (default, unsuffixed) – the image exactly as built from the Dockerfile, e.g. `php8.5-debug-v5`. Published as-is; it may carry known OS-level CVEs. - **hardened** (`-hardened` suffix) – the same image with OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. -**What hardening does:** after the plain image is built, it is scanned with [Trivy](https://github.com/aquasecurity/trivy) and Copa applies the available Debian security updates for OS-level packages as an extra image layer. PHP, its extensions, and all application-level content are identical to the plain image — only OS package versions differ. +**What hardening does:** after the plain image is built it is scanned with [Trivy](https://github.com/aquasecurity/trivy) and Copa applies the Debian security updates that are *available* for the affected OS packages, as an extra image layer. PHP, its extensions, and all application-level content are identical to the plain image — only OS package versions differ. -**Scope & guarantees:** -- `-hardened` exists for **stable release tags only**; development tags (`-dev`) are published plain-only. -- The plain tag **always publishes**, even when CVEs remain. -- The `-hardened` tag publishes only when the patched image passes the vulnerability gate (`CRITICAL,HIGH` by default). If a fix is not yet available upstream, the gate fails and the `-hardened` tag temporarily stays at its previous version until the plain image can be patched clean — so a `-hardened` tag never regresses to a vulnerable state. +**What the gate does (and does not) guarantee:** the `-hardened` tag is published only when, after patching, no **fixable** CVE at or above the `fail_on_severity` threshold remains — i.e. Copa applied every fix that was available. `fail_on_severity` is a **threshold** (default `CRITICAL,HIGH`): naming a severity also gates everything above it (e.g. `HIGH` gates HIGH and CRITICAL), and `NONE` disables the gate. The gate does **not** shield against CVEs that have **no upstream fix yet** — those are excluded from the scan and remain in *both* the plain and hardened images until Debian ships a fix. So `-hardened` means "all currently-fixable OS CVEs at the threshold are patched", not "zero known CVEs". + +**Scope:** `-hardened` exists for **stable release tags only**; development tags (`-dev`) are plain-only. The plain tag **always publishes**, even when CVEs remain. + +**Choosing a flavor:** prefer **hardened** for production or vulnerability-scanned environments where you want the latest available OS fixes baked in; use **plain** when you need the image exactly as built (reproducibility, or you run your own patching/scanning pipeline). ```text php8.5-debug-v5 # plain image, as built (may contain CVEs) -php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa, gate-clean +php8.5-debug-v5-hardened # same image, all available OS CVE fixes applied ``` -**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM, attached to the image in the registry as an OCI referrer and uploaded as a build artifact. +**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM. It is always uploaded as a build artifact, and — where the target registry supports OCI referrers — also attached to the published image (discoverable with `oras discover`). ## 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. 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 9f44aca..3163578 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.2`, `v5.1`), the release workflow scans the freshly +`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`), 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 diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index ce2dc13..a60a34c 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -49,7 +49,7 @@ tag set; the `-hardened` tag is created even when nothing was fixable (mirrors p ## Decisions (confirmed with maintainer, 2026-07-02) 1. **Scope stays stable-only.** `-hardened` is produced only for `hardened: true` matrix - entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.1`). Dev/rolling lines stay plain-only. + entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`). Dev/rolling lines stay plain-only. 2. **Gate policy: publish plain, skip hardened, job red.** Plain tags always publish. A variant whose hardened image fails the gate (or whose scan/patch errors) does not get its `-hardened` tags pushed; other variants continue; the job ends red — after pushes @@ -277,3 +277,26 @@ this spec (plain-always-publish, deferred red). No other edits to the old spec. - **Live validation:** `workflow_dispatch` with `publish: false` builds, patches, gates, and generates SBOMs without pushing; the docs job is skipped (publish-gated), validated on the first real publish run. + +## Post-review notes (2026-07-02, after the multi-dimension branch review) + +The exhaustive branch review surfaced two items that are **not** code changes but must be +recorded: + +- **Copa image source (investigated, not a defect).** A finding suspected that Copa, using + the tcp-addressed buildkitd container (`-a tcp://127.0.0.1:8888`), pulls `PLAIN_IMAGE` + from the registry rather than the local Docker daemon — which would make `publish: false` + dry-runs patch the *previously published* image. This was judged a **false positive**: + the plain image is always `docker build --load`-ed into the local daemon regardless of + `PUSH`, and the pre-existing pipeline patched that same local image via the identical + tcp buildkitd setup, so Copa demonstrably operates on the freshly built local image. + (If a future Copa/buildkit upgrade changes image resolution, re-verify with a + `publish: false` dispatch.) +- **Rollout / trigger scope (I5).** `schedule:` runs use the workflow file on the + **default branch**, and `push: tags:` runs use the file at the pushed tag. The `_ci` + checkout resolves scripts from `github.sha` (the workflow's own commit), so the pipeline + is correct for whatever ref actually runs it — but the new pipeline only takes effect for + the scheduled/tag cadence once this change (workflow **and** `.github/scripts/`) has + landed on the default branch and been forward-merged along the active line chain. + Until then, scheduled publishes keep running the old pipeline. This must be part of the + merge/rollout plan, not just the PR merge. From 62e69c3c0b28448a6889828f181d1cd649e0f819 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:58:01 +0200 Subject: [PATCH 50/75] tests: assert which image is scanned/SBOM'd, exact hardened tags, gate-skip/copa-fail/bad-JSON coverage --- .github/scripts/tests/run.sh | 89 ++++++++++++++++++++++++++---- .github/scripts/tests/stubs/docker | 5 +- .github/scripts/tests/stubs/trivy | 28 ++++++++-- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 74904b5..c4c33b1 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -3,15 +3,46 @@ set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${HERE}/../../.." && pwd)" export PATH="${HERE}/stubs:${PATH}" + +# The real scripts (and these tests' assertions) depend on real jq for JSON +# validation/queries; the stubs deliberately do not stub jq. Fail clearly +# rather than let jq-not-found surface as a confusing mid-scenario error. +if ! command -v jq >/dev/null 2>&1; then + echo "FAIL: 'jq' is required to run scan-patch-gate.sh (and these tests) but was not found on PATH." >&2 + exit 1 +fi + +# Collect every scenario temp dir/file so repeated local runs don't litter /tmp. +tmpdirs=() +cleanup() { + local d + for d in "${tmpdirs[@]}"; do + [ -n "$d" ] && rm -rf "$d" + done +} +trap cleanup EXIT + fail=0 assert_contains() { # if printf '%s' "$1" | grep -qF -- "$2"; then echo " ok: $3"; else echo " FAIL: $3 (missing '$2')"; fail=1; fi } +assert_not_contains() { # + if printf '%s' "$1" | grep -qF -- "$2"; then echo " FAIL: $3 (unexpectedly found '$2')"; fail=1; else echo " ok: $3"; fi +} +assert_eq() { # + if [ "$1" = "$2" ]; then echo " ok: $3"; else echo " FAIL: $3 (expected: [$2] got: [$1])"; fail=1; fi +} assert_file() { [ -e "$1" ] && echo " ok: $2 exists" || { echo " FAIL: $2 missing"; fail=1; }; } assert_no_file() { [ ! -e "$1" ] && echo " ok: $2 absent" || { echo " FAIL: $2 should be absent"; fail=1; }; } +assert_nonempty_file() { [ -s "$1" ] && echo " ok: $2 non-empty" || { echo " FAIL: $2 missing or empty"; fail=1; }; } +assert_no_glob() { # (true if nothing matches the pattern) + local matches + matches="$(compgen -G "$1" 2>/dev/null || true)" + [ -z "$matches" ] && echo " ok: $2" || { echo " FAIL: $2 (found: $matches)"; fail=1; } +} echo "== attach-sbom.sh ==" -work="$(mktemp -d)"; echo '{}' > "${work}/s.spdx.json" +work="$(mktemp -d)"; tmpdirs+=("$work"); echo '{}' > "${work}/s.spdx.json" # success path out="$(STUB_ORAS=ok "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? @@ -45,38 +76,74 @@ run_gate() { # runs scan-patch-gate.sh in with env already exported } # Scenario A: fixable vulns, gate passes -> hardened published -wA="$(mktemp -d)"; setup_variant "$wA" default -outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wA/copa.log" run_gate "$wA" default)"; rcA=$? +wA="$(mktemp -d)"; tmpdirs+=("$wA"); setup_variant "$wA" default +outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wA/stub.log" run_gate "$wA" default)"; rcA=$? [ "$rcA" = 0 ] && echo " ok: A exit 0" || { echo " FAIL: A exit $rcA"; fail=1; } assert_file "$wA/.docker-state/default/hardened_image.txt" "A hardened_image" assert_file "$wA/.docker-state/default/hardened_tags.txt" "A hardened_tags" assert_file "$wA/.docker-state/default/hardened_sbom.txt" "A hardened_sbom" assert_no_file "$wA/.docker-state/default/gate_failed.txt" "A gate_failed" -assert_contains "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "hardened-amd64" "A tags carry -hardened" -assert_contains "$(cat "$wA/copa.log" 2>/dev/null)" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference" + +# Exact content, not a substring: a derivation regression that drops a tag +# (or mangles the suffix swap) must fail this. +expectedA_tags=$'pimcore/pimcore:php8.5-default-v5.1-hardened-amd64\nghcr.io/pimcore/pimcore:php8.5-default-v5.1-hardened-amd64' +assert_eq "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "$expectedA_tags" "A hardened_tags exact derived content (both tags)" + +logA="$(cat "$wA/stub.log" 2>/dev/null)" +assert_contains "$logA" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference" +assert_contains "$logA" "format=json severity=CRITICAL,HIGH image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A post-patch GATE scan targeted the HARDENED image" +assert_contains "$logA" "format=spdx-json severity= image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A SPDX SBOM generation targeted the HARDENED image" # Scenario B: gate fails -> plain only, marker written, exit 0 -wB="$(mktemp -d)"; setup_variant "$wB" max -outB="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=fail run_gate "$wB" max)"; rcB=$? +wB="$(mktemp -d)"; tmpdirs+=("$wB"); setup_variant "$wB" max +summaryB="$(mktemp)"; tmpdirs+=("$summaryB") +outB="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=fail STUB_LOG="$wB/stub.log" GITHUB_STEP_SUMMARY="$summaryB" run_gate "$wB" max)"; rcB=$? [ "$rcB" = 0 ] && echo " ok: B exit 0 (does not abort step)" || { echo " FAIL: B exit $rcB"; fail=1; } assert_file "$wB/.docker-state/max/gate_failed.txt" "B gate_failed marker" assert_no_file "$wB/.docker-state/max/hardened_image.txt" "B hardened_image" assert_contains "$outB" "::error::" "B emits ::error::" +assert_nonempty_file "$wB/.docker-state/max/gate_failed.txt" "B gate_failed marker content" +assert_contains "$(cat "$wB/.docker-state/max/gate_failed.txt")" "unfixed" "B gate_failed contains the failure reason" +assert_contains "$(cat "$summaryB")" "## Gate failed:" "B step summary recorded the gate-failure section" # Scenario C: nothing fixable -> hardened mirrors plain, gate passes -wC="$(mktemp -d)"; setup_variant "$wC" min -outC="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=no STUB_GATE=pass run_gate "$wC" min)"; rcC=$? +wC="$(mktemp -d)"; tmpdirs+=("$wC"); setup_variant "$wC" min +outC="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=no STUB_GATE=pass STUB_LOG="$wC/stub.log" run_gate "$wC" min)"; rcC=$? [ "$rcC" = 0 ] && echo " ok: C exit 0" || { echo " FAIL: C exit $rcC"; fail=1; } assert_file "$wC/.docker-state/min/hardened_image.txt" "C hardened_image (mirror)" assert_no_file "$wC/.docker-state/min/gate_failed.txt" "C gate_failed" +logC="$(cat "$wC/stub.log" 2>/dev/null)" +assert_not_contains "$logC" "copa patch" "C copa NOT invoked (nothing fixable)" +assert_contains "$logC" "docker tag pimcore/pimcore:php8.5-min-v5.1-amd64 pimcore/pimcore:php8.5-min-v5.1-hardened-amd64" "C docker tag mirrors plain -> hardened in correct order" # Scenario D: gate disabled (NONE) -> hardened published without gate scan -wD="$(mktemp -d)"; setup_variant "$wD" debug -outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes run_gate "$wD" debug)"; rcD=$? +wD="$(mktemp -d)"; tmpdirs+=("$wD"); setup_variant "$wD" debug +outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes STUB_GATE=fail STUB_LOG="$wD/stub.log" run_gate "$wD" debug)"; rcD=$? [ "$rcD" = 0 ] && echo " ok: D exit 0" || { echo " FAIL: D exit $rcD"; fail=1; } assert_file "$wD/.docker-state/debug/hardened_image.txt" "D hardened_image (NONE)" assert_file "$wD/.docker-state/debug/hardened_tags.txt" "D hardened_tags (NONE)" assert_file "$wD/.docker-state/debug/hardened_sbom.txt" "D hardened_sbom (NONE)" +# STUB_GATE=fail would have produced gate_failed.txt (and skipped the hardened +# markers above) had the gate scan actually run; its absence plus the markers +# above prove the gate scan was skipped for GATE_SEVERITY=NONE. +assert_no_file "$wD/.docker-state/debug/gate_failed.txt" "D gate_failed absent (gate was skipped despite STUB_GATE=fail)" +assert_no_glob "$wD/trivy-reports/*hardened*" "D no gate report written under trivy-reports/ (gate scan skipped)" + +# Scenario E: copa patch fails -> contained via fail_gate, not a hard abort +wE="$(mktemp -d)"; tmpdirs+=("$wE"); setup_variant "$wE" copafail +outE="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_COPA=fail STUB_LOG="$wE/stub.log" run_gate "$wE" copafail)"; rcE=$? +[ "$rcE" = 0 ] && echo " ok: E exit 0 (copa failure contained)" || { echo " FAIL: E exit $rcE"; fail=1; } +assert_file "$wE/.docker-state/copafail/gate_failed.txt" "E gate_failed marker" +assert_no_file "$wE/.docker-state/copafail/hardened_image.txt" "E hardened_image absent" +assert_contains "$(cat "$wE/.docker-state/copafail/gate_failed.txt")" "Copa patch failed" "E gate_failed reason mentions copa failure" + +# Scenario F: initial Trivy report is malformed JSON -> fail-closed, not a hard abort +wF="$(mktemp -d)"; tmpdirs+=("$wF"); setup_variant "$wF" badjson +outF="$(GATE_SEVERITY=CRITICAL,HIGH STUB_BADJSON=1 STUB_LOG="$wF/stub.log" run_gate "$wF" badjson)"; rcF=$? +[ "$rcF" = 0 ] && echo " ok: F exit 0 (malformed report contained)" || { echo " FAIL: F exit $rcF"; fail=1; } +assert_file "$wF/.docker-state/badjson/gate_failed.txt" "F gate_failed marker" +assert_no_file "$wF/.docker-state/badjson/hardened_image.txt" "F hardened_image absent" +assert_contains "$(cat "$wF/.docker-state/badjson/gate_failed.txt")" "not valid JSON" "F gate_failed reason mentions invalid JSON" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/scripts/tests/stubs/docker b/.github/scripts/tests/stubs/docker index 097e6fb..8d0a566 100755 --- a/.github/scripts/tests/stubs/docker +++ b/.github/scripts/tests/stubs/docker @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# Stub docker: 'image inspect' exists-check exits 0; with --format prints a fake id. +# Stub docker: records every invocation to STUB_LOG (so callers can assert +# e.g. `docker tag ` direction). 'image inspect' exists-check +# exits 0; with --format prints a fake sha256: id. +echo "docker $*" >> "${STUB_LOG:-/dev/null}" if [ "$1 $2" = "image inspect" ]; then if printf '%s ' "$@" | grep -q -- '--format'; then echo "sha256:deadbeefcafe0000"; fi exit 0 diff --git a/.github/scripts/tests/stubs/trivy b/.github/scripts/tests/stubs/trivy index f34ad98..b946df3 100755 --- a/.github/scripts/tests/stubs/trivy +++ b/.github/scripts/tests/stubs/trivy @@ -1,20 +1,40 @@ #!/usr/bin/env bash # Stub trivy. Scenario via env: STUB_FIXABLE=yes|no (initial OS scan), -# STUB_GATE=pass|fail (severity-filtered gate scan). SPDX just writes a minimal doc. -out=""; sev=""; fmt="" +# STUB_GATE=pass|fail (severity-filtered gate scan), STUB_BADJSON=1 (the +# initial JSON vulnerability scan -- --format json, no --severity, no +# spdx-json -- writes malformed JSON instead of a report, to exercise the +# caller's `jq empty` fail-closed path on the plain-image scan specifically; +# scoped to no-severity so it doesn't also corrupt the separate gate-scan +# report and mask a regression in the initial check alone). SPDX just writes +# a minimal doc. Every invocation is recorded to STUB_LOG, including a parsed +# summary of which image reference (the trailing non-flag argument) was +# targeted, so callers can assert scan/SBOM target. +echo "trivy $*" >> "${STUB_LOG:-/dev/null}" + +out=""; sev=""; fmt=""; img="" while [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift 2;; --severity) sev="$2"; shift 2;; --format) fmt="$2"; shift 2;; - *) shift;; + --pkg-types) shift 2;; + --ignore-unfixed) shift;; + image) shift;; + -*) shift;; + *) img="$1"; shift;; esac done +echo "trivy-call format=${fmt} severity=${sev} image=${img}" >> "${STUB_LOG:-/dev/null}" + case "$fmt" in spdx-json) printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; table) echo "stub trivy table report" > "$out"; exit 0;; esac -# JSON vulnerability scan +# JSON vulnerability scan (initial or severity-gated) +if [ -z "$sev" ] && [ "${STUB_BADJSON:-0}" = "1" ]; then + printf '{ not valid' > "$out" + exit 0 +fi if [ -n "$sev" ]; then [ "${STUB_GATE:-pass}" = "fail" ] && v='[{"VulnerabilityID":"CVE-GATE"}]' || v='[]' else From 738d320c877258743414976e5fc00728fa0b9552 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:19:16 +0200 Subject: [PATCH 51/75] docs: finish v5.1->v5.2 sweep; correct false gate/SBOM claims echoed in the plan Re-review found the v5.2 correction missed the spec Part 3 example filename and the plan's Global Constraints, and the plan's Task 6 still embedded the pre-correction README text (never-regresses / gate-clean / guaranteed attach). Aligned both with the shipped README + matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-02-copa-plain-always-publish-sbom.md | 8 ++++---- .../2026-07-02-copa-plain-always-publish-sbom-design.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md index 758228c..cc68bf6 100644 --- a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md +++ b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md @@ -11,7 +11,7 @@ ## Global Constraints - Registry / image name: `pimcore/pimcore` (Docker Hub) and `ghcr.io/pimcore/pimcore` (verbatim). -- `-hardened` produced **only** for `hardened: true` matrix entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.1`); dev/rolling lines stay plain-only. +- `-hardened` produced **only** for `hardened: true` matrix entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`); dev/rolling lines stay plain-only. - Plain images **always publish**, even with CVEs. Only `-hardened` is gated. - SBOM format: **SPDX-JSON** (`trivy image --format spdx-json`), for every published image, per architecture. - `oras attach` is **non-fatal** — a registry rejecting referrers must only warn. @@ -710,14 +710,14 @@ For our stable release tags we publish each image in two flavors so you can choo **Scope & guarantees:** - `-hardened` exists for **stable release tags only**; development tags (`-dev`) are published plain-only. - The plain tag **always publishes**, even when CVEs remain. -- The `-hardened` tag publishes only when the patched image passes the vulnerability gate (`CRITICAL,HIGH` by default). If a fix is not yet available upstream, the gate fails and the `-hardened` tag temporarily stays at its previous version until the plain image can be patched clean — so a `-hardened` tag never regresses to a vulnerable state. +- The `-hardened` tag publishes only when, after patching, no *fixable* CVE at or above the `fail_on_severity` threshold (default `CRITICAL,HIGH`) remains. It does **not** shield against CVEs with no upstream fix yet — those are excluded from the scan (`--ignore-unfixed`) and remain in *both* flavors until Debian ships a fix. `fail_on_severity` is a threshold (naming a severity gates it and everything above; `NONE` disables). ```text php8.5-debug-v5 # plain image, as built (may contain CVEs) -php8.5-debug-v5-hardened # same image, OS CVEs patched with Copa, gate-clean +php8.5-debug-v5-hardened # same image, all available OS CVE fixes applied ``` -**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM, attached to the image in the registry as an OCI referrer and uploaded as a build artifact. +**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM — always uploaded as a build artifact, and attached to the image as an OCI referrer where the registry supports it. ``` - [ ] **Step 2: Verify the section renders and links are intact** diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index a60a34c..c52c425 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -205,7 +205,7 @@ the `-hardened` flavor, which the buildx attestation could never cover. - runs `.github/scripts/generate-package-docs.sh` (jq over SPDX `packages[]` name/versionInfo) to write one file per hardened matrix entry: `docs/hardened-packages/-php.md` (e.g. - `docs/hardened-packages/v5.1-php8.5.md`), + `docs/hardened-packages/v5.2-php8.5.md`), - commits and pushes with the default `GITHUB_TOKEN` (bot pushes do not re-trigger workflows); commit message `Update hardened image package docs`; no-op when nothing changed; one `git pull --rebase` retry on push rejection. From 50bbaa3a9282dacdf517df424b5be9cdb4ae3fb9 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:20:35 +0200 Subject: [PATCH 52/75] Extract fail_on_severity normalization into a tested script (review R1) The threshold/validation/case-insensitive-NONE logic was inline in release.yml and had no automated coverage (tests inject the already-normalised GATE_SEVERITY). Move it to .github/scripts/normalize-severity.sh (called via _ci/), which also trims the gate step; unit tests are added next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/normalize-severity.sh | 51 +++++++++++++++++++++++++++ .github/workflows/release.yml | 47 +++--------------------- 2 files changed, 56 insertions(+), 42 deletions(-) create mode 100755 .github/scripts/normalize-severity.sh diff --git a/.github/scripts/normalize-severity.sh b/.github/scripts/normalize-severity.sh new file mode 100755 index 0000000..6ab2d52 --- /dev/null +++ b/.github/scripts/normalize-severity.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Normalise a fail_on_severity value into a validated, inclusive Trivy --severity list. +# +# fail_on_severity is a case-insensitive THRESHOLD: naming a severity also gates +# everything above it (e.g. HIGH -> HIGH,CRITICAL), because Trivy's --severity is an +# exact filter that would otherwise let higher severities slip through. A comma-separated +# set is allowed; the lowest-ranked member wins. NONE disables the gate. +# +# Prints the normalised value to stdout ("NONE" when disabled). Exits 1 (with an +# ::error:: on stderr) when the input names no valid severity. +set -euo pipefail + +input="${1:?usage: normalize-severity.sh }" +SEVERITY_ORDER="UNKNOWN LOW MEDIUM HIGH CRITICAL" + +if [ "${input^^}" = "NONE" ]; then + echo "NONE" + exit 0 +fi + +min_rank=-1 +IFS=',' read -r -a requested <<< "${input^^}" +for sev in "${requested[@]}"; do + sev="${sev// /}" + [ -z "$sev" ] && continue + rank=-1; i=0 + for known in $SEVERITY_ORDER; do + if [ "$known" = "$sev" ]; then rank=$i; fi + i=$((i + 1)) + done + if [ "$rank" -lt 0 ]; then + echo "::error::Invalid fail_on_severity value '${sev}'. Allowed: ${SEVERITY_ORDER// /, }, or NONE." >&2 + exit 1 + fi + if [ "$min_rank" -lt 0 ] || [ "$rank" -lt "$min_rank" ]; then min_rank=$rank; fi +done + +if [ "$min_rank" -lt 0 ]; then + echo "::error::fail_on_severity='${input}' names no valid severity. Use ${SEVERITY_ORDER// /, }, or NONE." >&2 + exit 1 +fi + +# Emit the inclusive range from the lowest requested severity up to CRITICAL. +out=""; i=0 +for known in $SEVERITY_ORDER; do + if [ "$i" -ge "$min_rank" ]; then + out="${out:+$out,}$known" + fi + i=$((i + 1)) +done +echo "$out" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 408be8c..fe89077 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -259,48 +259,11 @@ jobs: set -eux mkdir -p trivy-reports - # Normalise the severity gate into a validated, inclusive Trivy filter list. - # fail_on_severity is a *threshold*: naming a severity also gates everything - # above it (e.g. HIGH -> HIGH,CRITICAL), since Trivy's --severity is otherwise - # an exact filter that would let higher severities slip through. NONE disables it. - # fail_on_severity is a case-insensitive THRESHOLD. NONE disables the gate; - # any other value normalises to the inclusive range from the lowest named - # severity up to CRITICAL, so Trivy's exact --severity filter can't let a - # higher severity slip through. - SEVERITY_ORDER="UNKNOWN LOW MEDIUM HIGH CRITICAL" - if [ "${FAIL_ON_SEVERITY^^}" = "NONE" ]; then - GATE_SEVERITY="NONE" - else - min_rank=-1 - IFS=',' read -r -a requested_severities <<< "${FAIL_ON_SEVERITY^^}" - for sev in "${requested_severities[@]}"; do - sev="${sev// /}" - [ -z "$sev" ] && continue - rank=-1; i=0 - for known in $SEVERITY_ORDER; do - if [ "$known" = "$sev" ]; then rank=$i; fi - i=$((i + 1)) - done - if [ "$rank" -lt 0 ]; then - echo "::error::Invalid fail_on_severity value '${sev}'. Allowed: ${SEVERITY_ORDER// /, }, or NONE." - exit 1 - fi - if [ "$min_rank" -lt 0 ] || [ "$rank" -lt "$min_rank" ]; then min_rank=$rank; fi - done - if [ "$min_rank" -lt 0 ]; then - echo "::error::fail_on_severity='${FAIL_ON_SEVERITY}' names no valid severity. Use ${SEVERITY_ORDER// /, }, or NONE." - exit 1 - fi - # Rebuild as the inclusive range from the lowest requested severity up to CRITICAL. - GATE_SEVERITY=""; i=0 - for known in $SEVERITY_ORDER; do - if [ "$i" -ge "$min_rank" ]; then - GATE_SEVERITY="${GATE_SEVERITY:+$GATE_SEVERITY,}$known" - fi - i=$((i + 1)) - done - echo "Severity gate: fail_on_severity='${FAIL_ON_SEVERITY}' -> '${GATE_SEVERITY}'" - fi + # Normalise fail_on_severity into a validated, inclusive Trivy --severity + # list (threshold semantics; NONE disables). Logic + unit tests live in + # .github/scripts/normalize-severity.sh; an invalid value exits non-zero here. + GATE_SEVERITY="$(_ci/.github/scripts/normalize-severity.sh "$FAIL_ON_SEVERITY")" + echo "Severity gate: fail_on_severity='${FAIL_ON_SEVERITY}' -> '${GATE_SEVERITY}'" export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY export BUILDKIT_ADDR="tcp://127.0.0.1:8888" From 4826ba83f870eebfdcf913fd8db0112696f1c03a Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:25:38 +0200 Subject: [PATCH 53/75] tests: cover normalize-severity, gate --ignore-unfixed/--pkg-types, and oras arg shape --- .github/scripts/tests/run.sh | 51 +++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index c4c33b1..262d097 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -41,13 +41,55 @@ assert_no_glob() { # (true if nothing matches the pattern) [ -z "$matches" ] && echo " ok: $2" || { echo " FAIL: $2 (found: $matches)"; fail=1; } } +echo "== normalize-severity.sh ==" +nsErr="$(mktemp)"; tmpdirs+=("$nsErr") +run_norm() { # ; sets NS_OUT (stdout) and NS_RC (exit code); stderr captured to $nsErr + NS_OUT="$("${ROOT}/.github/scripts/normalize-severity.sh" "$1" 2>"$nsErr")"; NS_RC=$? +} + +run_norm "CRITICAL,HIGH" +assert_eq "$NS_OUT" "HIGH,CRITICAL" "normalize CRITICAL,HIGH -> HIGH,CRITICAL" +assert_eq "$NS_RC" "0" "normalize CRITICAL,HIGH exit 0" + +run_norm "HIGH" +assert_eq "$NS_OUT" "HIGH,CRITICAL" "normalize HIGH -> HIGH,CRITICAL (threshold expansion)" +assert_eq "$NS_RC" "0" "normalize HIGH exit 0" + +run_norm "none" +assert_eq "$NS_OUT" "NONE" "normalize lowercase 'none' -> NONE" +assert_eq "$NS_RC" "0" "normalize 'none' exit 0" + +run_norm "None" +assert_eq "$NS_OUT" "NONE" "normalize 'None' -> NONE" +assert_eq "$NS_RC" "0" "normalize 'None' exit 0" + +run_norm "medium" +assert_eq "$NS_OUT" "MEDIUM,HIGH,CRITICAL" "normalize 'medium' -> MEDIUM,HIGH,CRITICAL (expansion + case-insensitive)" +assert_eq "$NS_RC" "0" "normalize 'medium' exit 0" + +run_norm "critical,low" +assert_eq "$NS_OUT" "LOW,MEDIUM,HIGH,CRITICAL" "normalize 'critical,low' -> LOW,MEDIUM,HIGH,CRITICAL (lowest member wins)" +assert_eq "$NS_RC" "0" "normalize 'critical,low' exit 0" + +run_norm "," +assert_eq "$NS_RC" "1" "normalize ',' exits 1" +assert_contains "$(cat "$nsErr")" "names no valid severity" "normalize ',' stderr names no valid severity" + +run_norm "BOGUS" +assert_eq "$NS_RC" "1" "normalize 'BOGUS' exits 1" +assert_contains "$(cat "$nsErr")" "Invalid fail_on_severity value" "normalize 'BOGUS' stderr flags invalid value" + echo "== attach-sbom.sh ==" work="$(mktemp -d)"; tmpdirs+=("$work"); echo '{}' > "${work}/s.spdx.json" # success path -out="$(STUB_ORAS=ok "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? +orasLog="$(mktemp)"; tmpdirs+=("$orasLog") +out="$(STUB_ORAS=ok STUB_LOG="$orasLog" "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? assert_contains "$out" "Attached" "success prints Attached" [ "$rc" = "0" ] && echo " ok: exit 0 on success" || { echo " FAIL: exit $rc"; fail=1; } +orasCallLog="$(cat "$orasLog" 2>/dev/null)" +assert_contains "$orasCallLog" "attach --artifact-type application/spdx+json" "oras invoked with attach --artifact-type application/spdx+json" +assert_contains "$orasCallLog" "${work}/s.spdx.json:application/spdx+json" "oras blob arg carries :application/spdx+json media-type suffix" # failure path is swallowed out="$(STUB_ORAS=fail "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? @@ -94,6 +136,13 @@ assert_contains "$logA" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" assert_contains "$logA" "format=json severity=CRITICAL,HIGH image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A post-patch GATE scan targeted the HARDENED image" assert_contains "$logA" "format=spdx-json severity= image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A SPDX SBOM generation targeted the HARDENED image" +# R4: the gate scan must retain --ignore-unfixed and --pkg-types os in the RAW +# invocation (not just the parsed summary above) -- dropping either would let +# already-unfixed-upstream or non-OS vulnerabilities leak past the gate silently. +gateRawA="$(grep -- '--format json' "$wA/stub.log" | grep -- '--severity' | grep 'hardened')" +assert_contains "$gateRawA" "--ignore-unfixed" "A raw gate scan invocation carries --ignore-unfixed" +assert_contains "$gateRawA" "--pkg-types os" "A raw gate scan invocation carries --pkg-types os" + # Scenario B: gate fails -> plain only, marker written, exit 0 wB="$(mktemp -d)"; tmpdirs+=("$wB"); setup_variant "$wB" max summaryB="$(mktemp)"; tmpdirs+=("$summaryB") From 8f56942a7795d7742e615d97fc887db927ec2a71 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:26:33 +0200 Subject: [PATCH 54/75] ci: fix shellcheck findings surfaced by actionlint on the runner The runner has shellcheck installed, so actionlint runs it on every run: block (my local run lacked shellcheck and missed these): - release.yml SC2295: quote inner expansion in ${HARDENED_IMAGE#"${IMAGE_NAME}":} - release.yml SC2015: replace [ -f ] && rmi || true with an explicit guard in Clean up - test.yml SC2068 (error): quote "${imageVariants[@]}" in the pre-existing test job Also quote the matching SC2295 in scan-patch-gate.sh:93 (core scripts now shellcheck-clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/scan-patch-gate.sh | 2 +- .github/workflows/release.yml | 5 +++-- .github/workflows/test.yml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index 2102db8..2ffaadf 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -90,7 +90,7 @@ trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" \ || fail_gate "hardened SBOM generation failed" while IFS= read -r plain_tag; do - echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" + echo "${plain_tag%-"${ARCH_TAG}"}-hardened-${ARCH_TAG}" done < "${vdir}/plain_tags.txt" > "${vdir}/hardened_tags.txt" echo "${HARDENED_IMAGE}" > "${vdir}/hardened_image.txt" echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26a8ff5..05a7c1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -299,7 +299,7 @@ jobs: if [[ "$PUSH" == "true" ]]; then printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" + HARDENED_TAG="${HARDENED_IMAGE#"${IMAGE_NAME}":}" _ci/.github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" @@ -323,7 +323,8 @@ jobs: done for imf in plain_image hardened_image; do f=".docker-state/${imageVariant}/${imf}.txt" - [ -f "$f" ] && docker rmi "$(< "$f")" 2>/dev/null || true + [ -f "$f" ] || continue + docker rmi "$(< "$f")" 2>/dev/null || true done done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9474557..24c15cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: imageVariants=("min" "default" "max" "debug" "supervisord") - for imageVariant in ${imageVariants[@]}; do + for imageVariant in "${imageVariants[@]}"; do docker build --tag pimcore-image \ --target="pimcore_php_$imageVariant" \ --build-arg PHP_VERSION="${{ matrix.php }}" \ From 5d44536939a29a2320d19af2bba8ebffaf2cb93b Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:33:59 +0200 Subject: [PATCH 55/75] Address Copilot high-severity review findings - CRITICAL: Copa checksum grep matched both copa_*.tar.gz and *.tar.gz.sbom.json -> two hashes -> every hardened leg failed the checksum. Match filename exactly (awk $2==f) + fail-fast on missing entry; same hardening for the oras checksum. - Move Install Copa + Start buildkit AFTER 'Push plain images' so a scan/patch infra failure can't abort a leg before plain ships (plain-always-publish). - process-tags: stop swallowing 'imagetools create' failures; record and exit 1 so an incomplete multi-arch publish turns the job red instead of green. - scan-patch-gate fail_gate: docker rmi the hardened image before deleting the state files cleanup reads, so failed variants don't leak large images. - spec: retract the Copa-image-source 'false positive' note; document that real publishes are correct (plain pushed before the gate) but publish=false dry-runs may patch a stale/absent image -- open follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/scan-patch-gate.sh | 4 + .github/workflows/release.yml | 114 +++++++++++------- ...2-copa-plain-always-publish-sbom-design.md | 27 +++-- 3 files changed, 91 insertions(+), 54 deletions(-) diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index 2ffaadf..0479913 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -26,6 +26,10 @@ fail_gate() { # -- record + skip hardened, but let plain ship echo "::error::${variant}: $1" { echo "## Gate failed: ${HARDENED_IMAGE}"; echo ""; echo "$1"; echo ""; } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" echo "$1" > "${vdir}/gate_failed.txt" + # Remove the hardened image now: we're about to delete the state files the cleanup + # step reads, so it can no longer reclaim it -- avoid leaking large images across + # failed variants on a reused runner. + docker rmi "${HARDENED_IMAGE}" 2>/dev/null || true rm -f "${vdir}/hardened_image.txt" "${vdir}/hardened_tags.txt" "${vdir}/hardened_sbom.txt" rm -f "$report" exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05a7c1e..60d4850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,8 +91,14 @@ jobs: ORAS_ARCH="$(dpkg --print-architecture)" curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" - EXPECTED_SHA=$(grep -F "oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" oras_checksums.txt | awk '{print $1}') + # Match the filename exactly ($2 == f): a substring match can also hit + # a sibling entry like *.tar.gz.sbom.json and return two hashes. + EXPECTED_SHA=$(awk -v f="oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" '$2 == f {print $1}' oras_checksums.txt) ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') + if [ -z "$EXPECTED_SHA" ]; then + echo "::error::No oras checksum entry for oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + exit 1 + fi if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" exit 1 @@ -101,47 +107,6 @@ jobs: sudo mv oras /usr/local/bin/oras rm oras.tar.gz oras_checksums.txt - - name: Install Copa - if: ${{ matrix.build.hardened }} - run: | - set -eux - COPA_ARCH="$(dpkg --print-architecture)" - curl -fsSL -o copa.tar.gz "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" - curl -fsSL -o copacetic_checksums.txt "https://github.com/project-copacetic/copacetic/releases/download/v${COPA_VERSION}/copacetic_checksums.txt" - # Verify checksum before extracting - EXPECTED_SHA=$(grep -F "copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" copacetic_checksums.txt | awk '{print $1}') - ACTUAL_SHA=$(sha256sum copa.tar.gz | awk '{print $1}') - if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then - echo "::error::Copa checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" - exit 1 - fi - tar -xzf copa.tar.gz copa - sudo mv copa /usr/local/bin/copa - rm copa.tar.gz copacetic_checksums.txt - - - name: Start buildkit daemon - if: ${{ matrix.build.hardened }} - run: | - docker run --detach --rm --privileged \ - -p 127.0.0.1:8888:8888/tcp \ - --name buildkitd \ - --entrypoint buildkitd \ - moby/buildkit:v${{ env.BUILDKIT_VERSION }} \ - --addr tcp://0.0.0.0:8888 - - # Wait for buildkit to be ready - for i in $(seq 1 60); do - if docker exec buildkitd buildctl --addr tcp://127.0.0.1:8888 debug workers >/dev/null 2>&1; then - echo "BuildKit is ready" - break - fi - if [ "$i" -eq 60 ]; then - echo "::error::BuildKit failed to start within 60 seconds" - exit 1 - fi - sleep 1 - done - - name: Build plain images env: VERSION_OVERRIDE: "${{ matrix.build.version-override }}" @@ -249,6 +214,55 @@ jobs: fi done + # Copa + BuildKit are installed AFTER the plain push, so a failure in this + # scan/patch infrastructure aborts the leg only after plain has already shipped + # (plain-always-publish), never before it. + - name: Install Copa + if: ${{ matrix.build.hardened }} + run: | + set -eux + 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. Match the filename exactly ($2 == f): + # a substring match also hits copa_..._linux_..._tar.gz.sbom.json (two hashes). + EXPECTED_SHA=$(awk -v f="copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" '$2 == f {print $1}' copacetic_checksums.txt) + ACTUAL_SHA=$(sha256sum copa.tar.gz | awk '{print $1}') + if [ -z "$EXPECTED_SHA" ]; then + echo "::error::No Copa checksum entry for copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" + exit 1 + fi + if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::Copa checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 + fi + tar -xzf copa.tar.gz copa + sudo mv copa /usr/local/bin/copa + rm copa.tar.gz copacetic_checksums.txt + + - name: Start buildkit daemon + if: ${{ matrix.build.hardened }} + run: | + docker run --detach --rm --privileged \ + -p 127.0.0.1:8888:8888/tcp \ + --name buildkitd \ + --entrypoint buildkitd \ + moby/buildkit:v${{ env.BUILDKIT_VERSION }} \ + --addr tcp://0.0.0.0:8888 + + # Wait for buildkit to be ready + for i in $(seq 1 60); do + if docker exec buildkitd buildctl --addr tcp://127.0.0.1:8888 debug workers >/dev/null 2>&1; then + echo "BuildKit is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "::error::BuildKit failed to start within 60 seconds" + exit 1 + fi + sleep 1 + done + - name: Scan, patch, and gate hardened images if: ${{ matrix.build.hardened }} env: @@ -407,15 +421,25 @@ jobs: esac done < all_aggregated_tags.txt + failed=0 for lt in "${!LOGICAL[@]}"; do if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then echo "Creating multi-arch manifest: $lt" - docker buildx imagetools create \ + # Both arches were pushed this run: a create failure means the logical + # tag was NOT updated -> record it and fail the job (don't leave green). + if ! docker buildx imagetools create \ --tag "$lt" \ "${lt}-amd64" \ - "${lt}-arm64" \ - || echo "::warning::Failed to create manifest for $lt" + "${lt}-arm64"; then + echo "::error::Failed to create multi-arch manifest for $lt" + failed=1 + fi else echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" fi done + + if [ "$failed" -ne 0 ]; then + echo "::error::One or more multi-arch manifests failed to publish" + exit 1 + fi diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index c52c425..71569ca 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -283,15 +283,24 @@ this spec (plain-always-publish, deferred red). No other edits to the old spec. The exhaustive branch review surfaced two items that are **not** code changes but must be recorded: -- **Copa image source (investigated, not a defect).** A finding suspected that Copa, using - the tcp-addressed buildkitd container (`-a tcp://127.0.0.1:8888`), pulls `PLAIN_IMAGE` - from the registry rather than the local Docker daemon — which would make `publish: false` - dry-runs patch the *previously published* image. This was judged a **false positive**: - the plain image is always `docker build --load`-ed into the local daemon regardless of - `PUSH`, and the pre-existing pipeline patched that same local image via the identical - tcp buildkitd setup, so Copa demonstrably operates on the freshly built local image. - (If a future Copa/buildkit upgrade changes image resolution, re-verify with a - `publish: false` dispatch.) +- **Copa image source (OPEN — needs CI validation; earlier "false positive" was wrong).** + Copa runs against the standalone `buildkitd` container (`-a tcp://127.0.0.1:8888`), whose + image store is isolated from the host Docker daemon that `docker build --load` populated. + It does not automatically see the local `PLAIN_IMAGE`; it resolves the reference through + BuildKit, which pulls from the registry. Consequence: + - **`publish: true` (real publishes / cron / tag): correct.** Because plain is now pushed + *before* the gate (this spec's publish-ordering change), `PLAIN_IMAGE` is in the + registry when Copa runs, so BuildKit pulls exactly the just-built image. + - **`publish: false` (dry-run, the new dispatch default): NOT reliable.** The fresh image + isn't in the registry, so Copa may patch a *previously published* image (or fail for a + never-published tag). A dry-run therefore does not faithfully exercise the hardened + path. An earlier note here called this a false positive on the premise that the + pre-existing pipeline patched a local image the same way — that premise was unfounded + (the Copa checksum bug meant hardened legs almost certainly never completed before), so + it is retracted. + Options if dry-run fidelity is required: skip the gate/hardened steps on `publish: false`, + or make the fresh image available to `buildkitd` (shared store / local registry). Tracked + for a follow-up; real publishing is unaffected. - **Rollout / trigger scope (I5).** `schedule:` runs use the workflow file on the **default branch**, and `push: tags:` runs use the file at the pushed tag. The `_ci` checkout resolves scripts from `github.sha` (the workflow's own commit), so the pipeline From cc0c601c145419411dbe72c5af800abeac00dff1 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:42:39 +0200 Subject: [PATCH 56/75] Add publish_hardened dispatch input (default false) to gate hardened publishing Hardened images are always built + scanned + gated for hardened matrix entries; the new input controls only the registry push. -hardened tags push iff the run is a workflow_dispatch with publish=true AND publish_hardened=true. Plain publishing is unchanged. Scheduled/tag runs and plain-only test dispatches build+gate hardened but do not push it, so plain publish keeps working exactly as now while hardened stays opt-in until validated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 13 ++++++++++-- ...2-copa-plain-always-publish-sbom-design.md | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60d4850..affca50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: required: false default: false type: boolean + publish_hardened: + description: 'Also publish the Copa-hardened (-hardened) tags. Requires publish=true. Off by default so hardened stays unpublished (built + scanned + gated but not pushed) on scheduled/tag runs and during testing, until explicitly enabled on a manual dispatch.' + required: false + default: false + type: boolean fail_on_severity: description: 'Severity THRESHOLD for the post-patch gate: naming a severity also gates everything above it (e.g. HIGH gates HIGH,CRITICAL). Case-insensitive. Valid values: UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL (or a comma-separated set — the lowest one wins). Use NONE to disable the gate entirely.' required: false @@ -287,10 +292,14 @@ jobs: _ci/.github/scripts/scan-patch-gate.sh "${imageVariant}" done + # Hardened images are always built + scanned + gated above; publishing them is a + # separate opt-in. PUSH_HARDENED is true only on a manual dispatch with both + # publish=true and publish_hardened=true, so scheduled/tag runs (and plain-only + # test dispatches) build and gate the hardened images but do not push them. - name: Push hardened images if: ${{ matrix.build.hardened }} env: - PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} + PUSH_HARDENED: ${{ github.event_name == 'workflow_dispatch' && inputs.publish && inputs.publish_hardened }} run: | set -eux @@ -310,7 +319,7 @@ jobs: fi done - if [[ "$PUSH" == "true" ]]; then + if [[ "$PUSH_HARDENED" == "true" ]]; then printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" HARDENED_TAG="${HARDENED_IMAGE#"${IMAGE_NAME}":}" diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index 71569ca..06fc724 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -278,6 +278,26 @@ this spec (plain-always-publish, deferred red). No other edits to the old spec. and generates SBOMs without pushing; the docs job is skipped (publish-gated), validated on the first real publish run. +## Hardened-publish rollout gate (`publish_hardened`, 2026-07-16) + +A `publish_hardened` `workflow_dispatch` input (boolean, default `false`) gates **publishing** +of the `-hardened` tags, independently of plain publishing: + +- Hardened images are **always built, scanned, patched, and gated** for `hardened: true` + matrix entries (unchanged) — the input only controls the registry push. +- `-hardened` tags are pushed **only when** `github.event_name == 'workflow_dispatch' && + inputs.publish && inputs.publish_hardened`. So: + - **Scheduled / tag-push runs push plain only** — hardened is built + gated but not + published until someone opts in. (Deliberate safe rollout; revisit the formula once + hardened is validated in production.) + - A **`publish=true, publish_hardened=false` dispatch** publishes plain and exercises the + full hardened build/gate (Copa pulls the just-pushed plain image, so the gate is + accurate) without pushing `-hardened` — the intended test mode. +- The deferred "Fail if severity gate failed" step still runs whenever hardened is built, + so a gate failure turns the job red even on a non-publishing run (honest signal that + patching left CVEs). README describes the two-flavor scheme as the target state; until + `publish_hardened` is enabled, `-hardened` tags are not refreshed in the registries. + ## Post-review notes (2026-07-02, after the multi-dimension branch review) The exhaustive branch review surfaced two items that are **not** code changes but must be From de0c51f6ab7341fee59eacd07ef256636c4c6a0d Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:14:40 +0200 Subject: [PATCH 57/75] Add spec: containerd image store so Copa patches locally (test hardened w/o publishing) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...7-17-containerd-store-local-copa-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md diff --git a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md new file mode 100644 index 0000000..470fcbe --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md @@ -0,0 +1,129 @@ +# Design: containerd image store so Copa patches locally (test hardened without publishing) + +**Date:** 2026-07-17 +**Status:** Approved (pending user review) +**Branch:** `image_copa` (PR #247) +**Affected files:** `.github/workflows/release.yml`, `.github/scripts/scan-patch-gate.sh`, +`README.md`, `docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md` +(I4 note) + +## Problem (proven, 2026-07-17 spike) + +The hardened path runs Copa against a **standalone tcp buildkitd** container +(`-a tcp://127.0.0.1:8888`). A spike with the workflow's exact setup, on a local +never-pushed image, showed Copa **pull the target from the registry**: + +``` +Patching: linux/amd64 -> docker.io/library/spiketest:patchedB +… GET https://index.docker.io/v2/library/spiketest/manifests/local: UNAUTHORIZED +``` + +Consequences: +- On `publish: false` (the default dispatch), the freshly built plain image is in neither + the registry nor the standalone buildkit's store, so **Copa cannot patch it** — a + dry-run cannot exercise the hardened path (this is finding "I4"). +- Real runs work only because plain is pushed *before* the gate, so Copa pulls the + just-pushed image. + +The spike also showed *why* the standalone buildkitd exists: Copa's required `mergeop` / +`diffop` are "only enabled with the containerd image store backend," which the default +Docker daemon lacks. + +**Goal:** let Copa patch the **locally built** plain image, so the hardened path can be +exercised with **zero pushes** (`publish: false`), and so real runs no longer depend on a +registry round-trip. + +## Decision (confirmed with maintainer 2026-07-17) + +1. **Enable Docker's containerd image store on the hardened (stable) legs only.** This + gives dockerd's *embedded* BuildKit the `mergeop`/`diffop` Copa needs **and** a shared + image store, so Copa patches the local image directly. Dev/rolling legs keep the current + daemon/store, untouched (smaller blast radius). The change is daemon-wide per leg, so the + 5 stable legs' *plain* build/push also move to the containerd store. +2. **Validate via a `publish: false` dispatch before trusting it for scheduled publishing**; + keep the standalone-buildkitd approach documented as rollback. + +## Design + +`release.yml` (hardened legs only unless noted): + +1. **New step `Enable containerd image store`** — first step after the checkouts and before + `Set up Docker Buildx`, `if: ${{ matrix.build.hardened }}`: + - merge `{"features":{"containerd-snapshotter":true}}` into `/etc/docker/daemon.json` + (preserving any existing keys via `jq`), `sudo systemctl restart docker`, wait until + `docker info` responds, and verify the driver is `io.containerd.snapshotter.*`. +2. **Drop** `Start buildkit daemon` and `Stop buildkit daemon` (no standalone buildkitd). +3. **`scan-patch-gate.sh`:** invoke `copa patch` **without** `-a` when no address is + configured — i.e. append `-a "${BUILDKIT_ADDR}"` only when `BUILDKIT_ADDR` is non-empty. + The gate step stops exporting `BUILDKIT_ADDR`, so Copa uses its default connection, which + under the containerd store resolves to dockerd's embedded BuildKit and **sees local + images**. +4. **Copa must use the docker driver (embedded dockerd BuildKit), not an isolated + `docker-container` buildx builder.** The `publish: false` validation confirms this; if + Copa selects an isolated builder, pin the default builder to `default` (docker driver) + before the gate, or fall back to the local-registry approach (see Rollback). + +Everything else — plain build, plain push, the gate logic, hardened push (`PUSH_HARDENED`), +cleanup, SBOM/CVE data, `process-tags` — is unchanged. `process-tags` runs in its own job on +an unmodified runner and is unaffected (it operates on the registry). + +## Effect + +- Copa patches the local plain image on every hardened run → the gate and SBOM are valid + regardless of publishing; **I4 is resolved for real runs**. +- **`publish: false` → build + Copa patch + gate + SBOM entirely locally, pushing nothing** + = the "test the hardened path without publishing" mode, with **no new input**. +- `publish: true` + `publish_hardened: false` → plain published, hardened built + gated + locally, not pushed (as before, now with an accurate gate). +- `publish: true` + `publish_hardened: true` → plain + hardened published. + +## Validation gate (before relying on it for cron) + +1. `workflow_dispatch` with `publish: false` on `image_copa` — expect: hardened legs enable + the containerd store, build, Copa-patch, gate, and generate SBOMs, with **zero** pushes + to Docker Hub / GHCR; the deferred gate step reports pass/fail. +2. `workflow_dispatch` with `publish: true, publish_hardened: false` — expect: plain tags + published, hardened built + gated but **not** pushed. +3. Only after both pass: allow the scheduled cadence to exercise it. + +## Rollback + +Revert commits 1–3: restore the `Start`/`Stop buildkit daemon` steps and the +`-a tcp://127.0.0.1:8888` Copa address, and remove the containerd-store step. Copa then +pulls the target from the registry, which requires plain to be pushed before the gate +(the pre-change behavior). Alternative if the containerd route proves flaky on the runners: +run a local `registry:2` sidecar reachable by a standalone buildkitd, push plain there, and +point Copa at it (Option 2 from the discussion) — keeps everything local, more plumbing. + +## Risk / uncertainty (explicit) + +- The exact Copa↔BuildKit selection under the containerd store on GitHub-hosted runners is + **confirmed by the validation run**, not yet proven end-to-end here (the local spike could + not enable the containerd store without disrupting the session daemon). +- The 5 stable legs' plain build/push move to the containerd store; the containerd store is + the modern Docker default and supports `build --load`, `tag`, `push`, `buildx`, and + `manifest`, but the `publish: false` → `publish: true` validation sequence is what guards + the plain-publishing path against regressions. + +## Docs updates + +- `docs/…/2026-07-02-…-design.md`: update the I4 "Copa image source" note from + "open / needs validation" to "resolved by the containerd image store; Copa patches the + local image; `publish: false` is a full local test." +- `README.md`: note that a `workflow_dispatch` with `publish: false` performs a full + hardened dry-run (build + patch + gate) without publishing. + +## Out of scope (YAGNI) + +- Enabling the containerd store on dev/rolling legs. +- A separate `dry_run` input (the existing `publish: false` is the test mode). +- The local-registry sidecar (documented only as a fallback). + +## Testing + +- Workflow lint: `actionlint` + shellcheck on the changed `run:` blocks. +- `scan-patch-gate.sh`: the existing stub tests still pass; add/adjust a stub assertion that + Copa is invoked **without** `-a` when `BUILDKIT_ADDR` is unset (and with `-a` when set, to + keep the rollback path covered). +- Live: the two-step `publish: false` → `publish: true, publish_hardened: false` validation + dispatch above. From 5b5a82326dc4a4dac5ba04e4ea245347494ccc68 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:20:38 +0200 Subject: [PATCH 58/75] Add implementation plan: containerd image store for local Copa patching Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-17-containerd-store-local-copa.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md diff --git a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md new file mode 100644 index 0000000..a9027ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md @@ -0,0 +1,294 @@ +# Containerd Image Store for Local Copa Patching — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Copa patch the locally built plain image (via Docker's containerd image store) so the hardened path runs entirely locally — a `publish: false` dispatch becomes a full hardened dry-run, and real runs no longer depend on a registry round-trip. + +**Architecture:** On the **hardened (stable) matrix legs only**, enable Docker's containerd image store before any builder is created. That gives dockerd's *embedded* BuildKit the `mergeop`/`diffop` Copa requires **and** a shared image store, so Copa (using its default connection) patches the local image. The standalone `buildkitd` container and its `tcp://127.0.0.1:8888` address are removed. `scan-patch-gate.sh` passes `-a` to Copa only when a `BUILDKIT_ADDR` is explicitly set (keeping the rollback path a one-line change). + +**Tech Stack:** GitHub Actions, Docker (containerd-snapshotter), Copacetic (Copa) 0.14.1, Trivy, bash, actionlint + shellcheck, stub-based bash unit tests. + +## Global Constraints + +- **Scope: hardened legs only.** Every daemon change guards on `if: ${{ matrix.build.hardened }}`. Dev/rolling legs (`hardened: false`) are untouched. (Verbatim from spec: "Enable Docker's containerd image store on the hardened (stable) legs only.") +- **Plain always ships.** Plain build + push happen before the gate and must be unaffected; the gate only ever *adds* the hardened tag. Never let a hardened-path change block plain publishing. +- **No new workflow input.** The existing `publish: false` is the dry-run mode. (Verbatim: "with no new input.") +- **Copa version:** pinned via `COPA_VERSION` env (0.14.1) — unchanged. +- **`copa patch` receives `-a ` only when `BUILDKIT_ADDR` is non-empty**, otherwise Copa uses its default connection (docker driver → dockerd embedded BuildKit under the containerd store). +- Shell blocks that change must pass `actionlint` + `shellcheck` (the CI "Lint workflows" + "Run script unit tests" steps in [.github/workflows/test.yml](.github/workflows/test.yml)). + +--- + +### Task 1: `scan-patch-gate.sh` — pass `-a` to Copa only when an address is set + +**Files:** +- Modify: [.github/scripts/scan-patch-gate.sh:14](.github/scripts/scan-patch-gate.sh#L14) and `:45-46` +- Test: [.github/scripts/tests/run.sh](.github/scripts/tests/run.sh) (Scenario A assertions + new Scenario G) + +**Interfaces:** +- Consumes: env `BUILDKIT_ADDR` (optional). When empty/unset → Copa default connection. When set → `copa patch … -a "$BUILDKIT_ADDR"`. +- Produces: no signature change. Same state files (`hardened_image.txt`, etc.). The only observable change is the `copa` command line. + +- [ ] **Step 1: Add the failing test assertions** + +In [.github/scripts/tests/run.sh](.github/scripts/tests/run.sh), Scenario A runs with `BUILDKIT_ADDR` **unset**. Add an assertion (right after the existing line `assert_contains "$logA" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference"`) that Copa is invoked **without** `-a`: + +```bash +assert_not_contains "$logA" " -a " "A copa invoked WITHOUT -a when BUILDKIT_ADDR unset (containerd store / default connection)" +``` + +Then add a new scenario at the end of the scenario list (after Scenario F, before the final pass/fail summary), covering the rollback path where an address **is** supplied: + +```bash +# Scenario G: BUILDKIT_ADDR set -> copa receives -a (rollback / standalone buildkitd path) +wG="$(mktemp -d)"; tmpdirs+=("$wG"); setup_variant "$wG" default +outG="$(BUILDKIT_ADDR=tcp://127.0.0.1:8888 GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wG/stub.log" run_gate "$wG" default)"; rcG=$? +[ "$rcG" = 0 ] && echo " ok: G exit 0" || { echo " FAIL: G exit $rcG"; fail=1; } +logG="$(cat "$wG/stub.log" 2>/dev/null)" +assert_contains "$logG" "-a tcp://127.0.0.1:8888" "G copa receives -a when BUILDKIT_ADDR set" +``` + +- [ ] **Step 2: Run the tests to verify the new assertions fail** + +Run: `.github/scripts/tests/run.sh` +Expected: FAIL — the current script always defaults `BUILDKIT_ADDR` to `tcp://127.0.0.1:8888`, so Scenario A's log **contains** `-a` (new `assert_not_contains` fails). Scenario G passes already (address happens to match the old default), but it must remain green after the fix. + +- [ ] **Step 3: Change the default to empty** + +In [.github/scripts/scan-patch-gate.sh:14](.github/scripts/scan-patch-gate.sh#L14): + +```bash +BUILDKIT_ADDR="${BUILDKIT_ADDR:-}" +``` + +(was `BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}"`) + +- [ ] **Step 4: Build the `-a` argument conditionally** + +Replace [.github/scripts/scan-patch-gate.sh:45-46](.github/scripts/scan-patch-gate.sh#L45-L46) — currently: + +```bash + copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ + || fail_gate "Copa patch failed" +``` + +with: + +```bash + # Pass -a only when an address is configured. With the containerd image store + # enabled, BUILDKIT_ADDR is unset and Copa uses its default connection + # (docker driver -> dockerd's embedded BuildKit), which sees the local image. + # Setting BUILDKIT_ADDR (e.g. a standalone buildkitd) restores the -a path. + copa_addr=() + [ -n "${BUILDKIT_ADDR}" ] && copa_addr=(-a "${BUILDKIT_ADDR}") + copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" "${copa_addr[@]}" \ + || fail_gate "Copa patch failed" +``` + +Note: the script runs under `set -euo pipefail`; on the runner's bash 5.x `"${copa_addr[@]}"` with an empty array expands to nothing without tripping `set -u`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `.github/scripts/tests/run.sh` +Expected: PASS — all scenarios green, including Scenario A (no `-a`) and Scenario G (`-a tcp://127.0.0.1:8888`). + +- [ ] **Step 6: Shellcheck the script** + +Run: `shellcheck .github/scripts/scan-patch-gate.sh` +Expected: no new findings (clean, or unchanged from baseline). + +- [ ] **Step 7: Commit** + +```bash +git add .github/scripts/scan-patch-gate.sh .github/scripts/tests/run.sh +git commit -m "scan-patch-gate: pass copa -a only when BUILDKIT_ADDR is set + +Default connection (docker driver under the containerd image store) sees +the locally built plain image, so no standalone buildkitd address is needed. +Setting BUILDKIT_ADDR restores the -a path for rollback." +``` + +--- + +### Task 2: `release.yml` — enable the containerd image store; drop the standalone buildkitd + +**Files:** +- Modify: [.github/workflows/release.yml](.github/workflows/release.yml) — add one step (~line 75, before `Set up Docker Buildx`), delete two steps (`Start buildkit daemon` ~248-269, `Stop buildkit daemon` ~354-356), edit the gate step (remove the `BUILDKIT_ADDR` export ~288). + +**Interfaces:** +- Consumes: `matrix.build.hardened` (bool). The `Install Copa` step and `Scan, patch, and gate hardened images` step are unchanged except for the removed export. +- Produces: on hardened legs, a daemon running the containerd image store before any build; `scan-patch-gate.sh` invoked with `BUILDKIT_ADDR` unset (Task 1's default-connection path). + +- [ ] **Step 1: Add the `Enable containerd image store` step** + +Insert immediately **after** the `Check out CI scripts from the workflow ref` step and **before** `Set up Docker Buildx` (around [.github/workflows/release.yml:75](.github/workflows/release.yml#L75)). It must run before any builder is created, because it restarts the daemon: + +```yaml + - name: Enable containerd image store + if: ${{ matrix.build.hardened }} + run: | + set -euxo pipefail + # Copa's mergeop/diffop (required to patch) are only available with the + # containerd image store backend, which also gives dockerd's embedded + # BuildKit a shared image store. With it enabled, Copa's default connection + # patches the locally built plain image -- no registry round-trip and no + # standalone buildkitd. Enable it on the hardened legs only, before any + # builder is created (this restarts the daemon). + sudo mkdir -p /etc/docker + if [ -s /etc/docker/daemon.json ]; then + existing="$(sudo cat /etc/docker/daemon.json)" + else + existing='{}' + fi + printf '%s' "$existing" \ + | jq '.features = ((.features // {}) + {"containerd-snapshotter": true})' \ + | sudo tee /etc/docker/daemon.json >/dev/null + sudo systemctl restart docker + # Wait for the daemon to come back up. + for i in $(seq 1 30); do + if docker info >/dev/null 2>&1; then break; fi + if [ "$i" -eq 30 ]; then + echo "::error::Docker did not come back after restart" + exit 1 + fi + sleep 1 + done + # Verify the containerd snapshotter storage backend is active. + if ! docker info | grep -q 'io.containerd.snapshotter'; then + echo "::error::containerd image store is not active after restart" + docker info || true + exit 1 + fi +``` + +- [ ] **Step 2: Delete the `Start buildkit daemon` step** + +Remove the entire step at [.github/workflows/release.yml:248-269](.github/workflows/release.yml#L248-L269) (`- name: Start buildkit daemon` … through the closing `done` of its readiness loop). The standalone buildkitd is no longer used. + +- [ ] **Step 3: Delete the `Stop buildkit daemon` step** + +Remove the entire step at [.github/workflows/release.yml:354-356](.github/workflows/release.yml#L354-L356): + +```yaml + - name: Stop buildkit daemon + if: ${{ always() && matrix.build.hardened }} + run: docker stop buildkitd || true +``` + +- [ ] **Step 4: Remove the `BUILDKIT_ADDR` export from the gate step** + +In the `Scan, patch, and gate hardened images` step, delete [.github/workflows/release.yml:288](.github/workflows/release.yml#L288): + +```bash + export BUILDKIT_ADDR="tcp://127.0.0.1:8888" +``` + +Leave the preceding `export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY` line intact. With `BUILDKIT_ADDR` unset, `scan-patch-gate.sh` (Task 1) invokes Copa on its default connection. + +- [ ] **Step 5: Lint the workflow** + +Run: +```bash +actionlint .github/workflows/release.yml +shellcheck -e SC2016 - <<'SH' +$(sed -n '/name: Enable containerd image store/,/verify the containerd/p' .github/workflows/release.yml) +SH +``` +Expected: `actionlint` clean. (The `shellcheck` line is a convenience — the authoritative check is CI's "Lint workflows" job, which runs `actionlint -color` and picks up shellcheck on the embedded `run:` blocks. If `actionlint` is not installed locally, install it: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` or download the release binary used in [.github/workflows/test.yml:89](.github/workflows/test.yml#L89).) + +- [ ] **Step 6: Sanity-check the YAML structure** + +Run: +```bash +grep -n 'Enable containerd image store\|Start buildkit daemon\|Stop buildkit daemon\|BUILDKIT_ADDR\|Set up Docker Buildx' .github/workflows/release.yml +``` +Expected: `Enable containerd image store` appears once (before the first `Set up Docker Buildx`); `Start buildkit daemon` and `Stop buildkit daemon` are **gone**; no `BUILDKIT_ADDR` reference remains in `release.yml`. + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/release.yml +git commit -m "release: enable containerd image store on hardened legs, drop standalone buildkitd + +Copa now patches the locally built plain image via dockerd's embedded +BuildKit (containerd store), so a publish:false dispatch is a full hardened +dry-run and real runs no longer depend on a registry round-trip." +``` + +--- + +### Task 3: Docs — resolve the I4 note and document the dry-run mode + +**Files:** +- Modify: [docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md](docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md) (I4 "Copa image source" note) +- Modify: [README.md](README.md) (hardened images section) + +**Interfaces:** none (documentation only). + +- [ ] **Step 1: Locate the I4 note in the 2026-07-02 spec** + +Run: `grep -n 'I4\|Copa image source\|registry' docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md` +Read the surrounding lines to get the exact current wording. + +- [ ] **Step 2: Update the I4 note** + +Change the I4 "Copa image source" note from its "open / needs validation" wording to resolved. Replace the note's status/body with: + +```markdown +**I4 — Copa image source (RESOLVED 2026-07-17):** Copa no longer pulls the target +from the registry. The hardened legs enable Docker's containerd image store, so +Copa's default connection (dockerd's embedded BuildKit) patches the **locally built** +plain image directly. Consequences: `publish: false` is a full hardened dry-run +(build + patch + gate + SBOM, zero pushes), and real runs no longer depend on plain +being pushed before the gate. See +`docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md`. +``` + +Match the surrounding heading style found in Step 1 (adjust the `**…**` / `###` prefix to whatever the file uses for the other findings). + +- [ ] **Step 3: Locate the hardened section in README** + +Run: `grep -n 'Hardened\|publish_hardened\|workflow_dispatch\|dry' README.md` +Read the hardened images section. + +- [ ] **Step 4: Add a dry-run note to the README hardened section** + +Add a short sentence to the hardened images section (near the `publish_hardened` explanation) stating the dry-run capability. Use wording consistent with the section's existing voice; the content must be: + +```markdown +> **Testing the hardened path without publishing:** trigger the release workflow via +> **workflow_dispatch** with `publish: false`. The stable images are built, Copa-patched, +> scanned, and gated entirely on the runner (using the containerd image store) — **nothing +> is pushed** to Docker Hub or GHCR. Use `publish: true` with `publish_hardened: false` to +> publish the plain tags while still building and gating the hardened images locally. +``` + +- [ ] **Step 5: Verify the docs read correctly** + +Run: `grep -n 'RESOLVED 2026-07-17\|dry-run\|publish: false' docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md README.md` +Expected: the I4 note shows RESOLVED; README shows the dry-run note. + +- [ ] **Step 6: Commit** + +```bash +git add docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md README.md +git commit -m "docs: resolve I4 (Copa patches local image via containerd store); document publish:false dry-run" +``` + +--- + +## Validation (live, after all tasks — user-gated, not part of task commits) + +Per the spec's validation gate — run these before relying on the change for the scheduled cadence: + +1. `workflow_dispatch` on `image_copa` with `publish: false` → hardened legs enable the containerd store, build, Copa-patch, gate, and produce SBOMs with **zero** pushes; the deferred gate step reports pass/fail. +2. `workflow_dispatch` with `publish: true, publish_hardened: false` → plain tags published, hardened built + gated but **not** pushed. +3. Only after both pass: allow the scheduled cadence to exercise it. + +**If Copa selects an isolated `docker-container` builder instead of the docker driver** (spec risk note): pin the default builder to `default` (docker driver) before the gate loop, or fall back to the local-`registry:2` sidecar (spec Rollback). The `publish: false` run is what confirms which path Copa took. + +## Self-Review + +- **Spec coverage:** containerd-store enable step (Task 2/Step 1) ✓; drop buildkitd (Task 2/Steps 2-3) ✓; conditional `-a` / default connection (Task 1) ✓; docs I4 + README dry-run (Task 3) ✓; `publish:false` = dry-run with no new input (Constraints + Task 3) ✓; stub test for `-a` present/absent (Task 1/Step 1) ✓; validation gate (Validation section) ✓; rollback (Validation note + commit messages reference it) ✓. +- **Placeholder scan:** none — every code/edit step shows exact text. +- **Type/name consistency:** `BUILDKIT_ADDR`, `copa_addr`, `matrix.build.hardened`, step names match across tasks and the current file. From f2408589977a599cad04cf5a9f719b52508f0456 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:27:07 +0200 Subject: [PATCH 59/75] scan-patch-gate: pass copa -a only when BUILDKIT_ADDR is set Default connection (docker driver under the containerd image store) sees the locally built plain image, so no standalone buildkitd address is needed. Setting BUILDKIT_ADDR restores the -a path for rollback. --- .github/scripts/scan-patch-gate.sh | 10 ++++++++-- .github/scripts/tests/run.sh | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index 0479913..8a96265 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -11,7 +11,7 @@ variant="${1:?usage: scan-patch-gate.sh }" STATE_DIR="${STATE_DIR:-.docker-state}" SBOM_DIR="${SBOM_DIR:-sboms}" REPORT_DIR="${REPORT_DIR:-trivy-reports}" -BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}" +BUILDKIT_ADDR="${BUILDKIT_ADDR:-}" vdir="${STATE_DIR}/${variant}" mkdir -p "$SBOM_DIR" "$REPORT_DIR" @@ -42,7 +42,13 @@ trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_ jq empty "$report" 2>/dev/null || fail_gate "Trivy report of plain image is not valid JSON" if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null; then - copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ + # Pass -a only when an address is configured. With the containerd image store + # enabled, BUILDKIT_ADDR is unset and Copa uses its default connection + # (docker driver -> dockerd's embedded BuildKit), which sees the local image. + # Setting BUILDKIT_ADDR (e.g. a standalone buildkitd) restores the -a path. + copa_addr=() + [ -n "${BUILDKIT_ADDR}" ] && copa_addr=(-a "${BUILDKIT_ADDR}") + copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" "${copa_addr[@]}" \ || fail_gate "Copa patch failed" docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1 \ || fail_gate "Hardened image not found after copa patch" diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 262d097..8463d9f 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -133,6 +133,7 @@ assert_eq "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "$expectedA_tag logA="$(cat "$wA/stub.log" 2>/dev/null)" assert_contains "$logA" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference" +assert_not_contains "$logA" " -a " "A copa invoked WITHOUT -a when BUILDKIT_ADDR unset (containerd store / default connection)" assert_contains "$logA" "format=json severity=CRITICAL,HIGH image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A post-patch GATE scan targeted the HARDENED image" assert_contains "$logA" "format=spdx-json severity= image=pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A SPDX SBOM generation targeted the HARDENED image" @@ -194,5 +195,12 @@ assert_file "$wF/.docker-state/badjson/gate_failed.txt" "F gate_failed marker" assert_no_file "$wF/.docker-state/badjson/hardened_image.txt" "F hardened_image absent" assert_contains "$(cat "$wF/.docker-state/badjson/gate_failed.txt")" "not valid JSON" "F gate_failed reason mentions invalid JSON" +# Scenario G: BUILDKIT_ADDR set -> copa receives -a (rollback / standalone buildkitd path) +wG="$(mktemp -d)"; tmpdirs+=("$wG"); setup_variant "$wG" default +outG="$(BUILDKIT_ADDR=tcp://127.0.0.1:8888 GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wG/stub.log" run_gate "$wG" default)"; rcG=$? +[ "$rcG" = 0 ] && echo " ok: G exit 0" || { echo " FAIL: G exit $rcG"; fail=1; } +logG="$(cat "$wG/stub.log" 2>/dev/null)" +assert_contains "$logG" "-a tcp://127.0.0.1:8888" "G copa receives -a when BUILDKIT_ADDR set" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" From 585fd94f2292fb37442725c0a6996a9411f09aeb Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:35:22 +0200 Subject: [PATCH 60/75] release: enable containerd image store on hardened legs, drop standalone buildkitd Copa now patches the locally built plain image via dockerd's embedded BuildKit (containerd store), so a publish:false dispatch is a full hardened dry-run and real runs no longer depend on a registry round-trip. --- .github/workflows/release.yml | 64 ++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index affca50..50e97fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,42 @@ jobs: sparse-checkout: .github/scripts sparse-checkout-cone-mode: false + - name: Enable containerd image store + if: ${{ matrix.build.hardened }} + run: | + set -euxo pipefail + # Copa's mergeop/diffop (required to patch) are only available with the + # containerd image store backend, which also gives dockerd's embedded + # BuildKit a shared image store. With it enabled, Copa's default connection + # patches the locally built plain image -- no registry round-trip and no + # standalone buildkitd. Enable it on the hardened legs only, before any + # builder is created (this restarts the daemon). + sudo mkdir -p /etc/docker + if [ -s /etc/docker/daemon.json ]; then + existing="$(sudo cat /etc/docker/daemon.json)" + else + existing='{}' + fi + printf '%s' "$existing" \ + | jq '.features = ((.features // {}) + {"containerd-snapshotter": true})' \ + | sudo tee /etc/docker/daemon.json >/dev/null + sudo systemctl restart docker + # Wait for the daemon to come back up. + for i in $(seq 1 30); do + if docker info >/dev/null 2>&1; then break; fi + if [ "$i" -eq 30 ]; then + echo "::error::Docker did not come back after restart" + exit 1 + fi + sleep 1 + done + # Verify the containerd snapshotter storage backend is active. + if ! docker info | grep -q 'io.containerd.snapshotter'; then + echo "::error::containerd image store is not active after restart" + docker info || true + exit 1 + fi + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -245,29 +281,6 @@ jobs: sudo mv copa /usr/local/bin/copa rm copa.tar.gz copacetic_checksums.txt - - name: Start buildkit daemon - if: ${{ matrix.build.hardened }} - run: | - docker run --detach --rm --privileged \ - -p 127.0.0.1:8888:8888/tcp \ - --name buildkitd \ - --entrypoint buildkitd \ - moby/buildkit:v${{ env.BUILDKIT_VERSION }} \ - --addr tcp://0.0.0.0:8888 - - # Wait for buildkit to be ready - for i in $(seq 1 60); do - if docker exec buildkitd buildctl --addr tcp://127.0.0.1:8888 debug workers >/dev/null 2>&1; then - echo "BuildKit is ready" - break - fi - if [ "$i" -eq 60 ]; then - echo "::error::BuildKit failed to start within 60 seconds" - exit 1 - fi - sleep 1 - done - - name: Scan, patch, and gate hardened images if: ${{ matrix.build.hardened }} env: @@ -285,7 +298,6 @@ jobs: echo "Severity gate: fail_on_severity='${FAIL_ON_SEVERITY}' -> '${GATE_SEVERITY}'" export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY - export BUILDKIT_ADDR="tcp://127.0.0.1:8888" mapfile -t imageVariants < .docker-state/variants.txt for imageVariant in "${imageVariants[@]}"; do @@ -351,10 +363,6 @@ jobs: done done - - name: Stop buildkit daemon - if: ${{ always() && matrix.build.hardened }} - run: docker stop buildkitd || true - - name: Upload trivy reports if: always() uses: actions/upload-artifact@v7 From 50caad1bea904b569a1c85fd25db8020400c7a39 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:44:06 +0200 Subject: [PATCH 61/75] docs: resolve I4 (Copa patches local image via containerd store); document publish:false dry-run --- README.md | 6 +++++ ...2-copa-plain-always-publish-sbom-design.md | 25 ++++++------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 88c666e..37cc2c9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ For our stable release tags we publish each image in two flavors so you can choo **Scope:** `-hardened` exists for **stable release tags only**; development tags (`-dev`) are plain-only. The plain tag **always publishes**, even when CVEs remain. +> **Testing the hardened path without publishing:** trigger the release workflow via +> **workflow_dispatch** with `publish: false`. The stable images are built, Copa-patched, +> scanned, and gated entirely on the runner (using the containerd image store) — **nothing +> is pushed** to Docker Hub or GHCR. Use `publish: true` with `publish_hardened: false` to +> publish the plain tags while still building and gating the hardened images locally. + **Choosing a flavor:** prefer **hardened** for production or vulnerability-scanned environments where you want the latest available OS fixes baked in; use **plain** when you need the image exactly as built (reproducibility, or you run your own patching/scanning pipeline). ```text diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index 06fc724..596939b 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -303,24 +303,13 @@ of the `-hardened` tags, independently of plain publishing: The exhaustive branch review surfaced two items that are **not** code changes but must be recorded: -- **Copa image source (OPEN — needs CI validation; earlier "false positive" was wrong).** - Copa runs against the standalone `buildkitd` container (`-a tcp://127.0.0.1:8888`), whose - image store is isolated from the host Docker daemon that `docker build --load` populated. - It does not automatically see the local `PLAIN_IMAGE`; it resolves the reference through - BuildKit, which pulls from the registry. Consequence: - - **`publish: true` (real publishes / cron / tag): correct.** Because plain is now pushed - *before* the gate (this spec's publish-ordering change), `PLAIN_IMAGE` is in the - registry when Copa runs, so BuildKit pulls exactly the just-built image. - - **`publish: false` (dry-run, the new dispatch default): NOT reliable.** The fresh image - isn't in the registry, so Copa may patch a *previously published* image (or fail for a - never-published tag). A dry-run therefore does not faithfully exercise the hardened - path. An earlier note here called this a false positive on the premise that the - pre-existing pipeline patched a local image the same way — that premise was unfounded - (the Copa checksum bug meant hardened legs almost certainly never completed before), so - it is retracted. - Options if dry-run fidelity is required: skip the gate/hardened steps on `publish: false`, - or make the fresh image available to `buildkitd` (shared store / local registry). Tracked - for a follow-up; real publishing is unaffected. +- **I4 — Copa image source (RESOLVED 2026-07-17):** Copa no longer pulls the target + from the registry. The hardened legs enable Docker's containerd image store, so + Copa's default connection (dockerd's embedded BuildKit) patches the **locally built** + plain image directly. Consequences: `publish: false` is a full hardened dry-run + (build + patch + gate + SBOM, zero pushes), and real runs no longer depend on plain + being pushed before the gate. See + `docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md`. - **Rollout / trigger scope (I5).** `schedule:` runs use the workflow file on the **default branch**, and `push: tags:` runs use the file at the pushed tag. The `_ci` checkout resolves scripts from `github.sha` (the workflow's own commit), so the pipeline From 15c6475e44746654e93461285d6bf018c8540db2 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:50:05 +0200 Subject: [PATCH 62/75] docs: fix stale Copa-pulls-from-registry claim contradicting resolved I4 note --- .../2026-07-02-copa-plain-always-publish-sbom-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md index 596939b..5e130bc 100644 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md @@ -291,8 +291,9 @@ of the `-hardened` tags, independently of plain publishing: published until someone opts in. (Deliberate safe rollout; revisit the formula once hardened is validated in production.) - A **`publish=true, publish_hardened=false` dispatch** publishes plain and exercises the - full hardened build/gate (Copa pulls the just-pushed plain image, so the gate is - accurate) without pushing `-hardened` — the intended test mode. + full hardened build/gate (Copa patches the locally built plain image via the containerd + store, so the gate is accurate — see I4 below) without pushing `-hardened` — the intended + test mode. - The deferred "Fail if severity gate failed" step still runs whenever hardened is built, so a gate failure turns the job red even on a non-publishing run (honest signal that patching left CVEs). README describes the two-flavor scheme as the target state; until From 21f7569c00159cf72076bd760573fe187cb8c813 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:08:24 +0200 Subject: [PATCH 63/75] Final-review fixes: drop dead BUILDKIT_VERSION, fix stale plain-ships comment, source-verify Copa driver order + un-foolable validation - release.yml: remove now-unused BUILDKIT_VERSION env (sole consumer, the deleted standalone buildkitd step, is gone); reword the Install Copa comment so the plain-always-ships reasoning is accurate now that the containerd enable step runs before the plain build. - 2026-07-17 spec + plan: record that Copa v0.14.1 autoClient tries the docker driver first (dockerd /grpc, independent of the buildx builder), so no builder pin is needed; strengthen the publish:false validation to a debug-log check (a green gate alone is not proof, since stable tags already exist in the registry). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 10 +++--- .../2026-07-17-containerd-store-local-copa.md | 3 +- ...7-17-containerd-store-local-copa-design.md | 32 +++++++++++++++---- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50e97fb..04542f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,6 @@ on: env: IMAGE_NAME: pimcore/pimcore COPA_VERSION: "0.14.1" - BUILDKIT_VERSION: "0.30.0" ORAS_VERSION: "1.2.0" TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" @@ -255,9 +254,12 @@ jobs: fi done - # Copa + BuildKit are installed AFTER the plain push, so a failure in this - # scan/patch infrastructure aborts the leg only after plain has already shipped - # (plain-always-publish), never before it. + # Copa is installed AFTER the plain push, and gate failures are contained + # (scan-patch-gate.sh records the failure and exits 0), so a problem in the + # scan/patch/gate path never blocks plain publishing (plain-always-publish). + # Note: the containerd image store is enabled earlier, before the plain build, + # because it must be active before the image Copa patches locally is built -- + # a failure of that early step aborts the hardened leg before plain ships. - name: Install Copa if: ${{ matrix.build.hardened }} run: | diff --git a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md index a9027ef..7edc814 100644 --- a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md +++ b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md @@ -282,10 +282,11 @@ git commit -m "docs: resolve I4 (Copa patches local image via containerd store); Per the spec's validation gate — run these before relying on the change for the scheduled cadence: 1. `workflow_dispatch` on `image_copa` with `publish: false` → hardened legs enable the containerd store, build, Copa-patch, gate, and produce SBOMs with **zero** pushes; the deferred gate step reports pass/fail. + - **Confirm Copa patched the *local* image, not a registry pull.** A green gate alone is not proof: the stable plain tags already exist in the registry from prior runs, so a registry-pulling Copa would silently patch the previously published image and still pass. Make the check un-foolable — run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, or raise its log level) and confirm the log shows the **docker driver** connected (it must *not* print `Could not use docker driver` and fall through to buildx/buildkitd). Per Copa v0.14.1 `autoClient`, the docker driver is tried first and, with the containerd store active, is the one used. 2. `workflow_dispatch` with `publish: true, publish_hardened: false` → plain tags published, hardened built + gated but **not** pushed. 3. Only after both pass: allow the scheduled cadence to exercise it. -**If Copa selects an isolated `docker-container` builder instead of the docker driver** (spec risk note): pin the default builder to `default` (docker driver) before the gate loop, or fall back to the local-`registry:2` sidecar (spec Rollback). The `publish: false` run is what confirms which path Copa took. +**Contingency (not expected — driver selection is source-verified).** Copa v0.14.1 tries the docker driver first and it is independent of the selected buildx builder (`pkg/buildkit/drivers.go`, `connhelpers/docker.go`), so the isolated `docker-container` builder should never be chosen. If the debug-log check above nonetheless shows Copa failing over off the docker driver, pin the builder with `docker buildx use default` before the gate loop, or fall back to the local-`registry:2` sidecar (spec Rollback). ## Self-Review diff --git a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md index 470fcbe..8672a97 100644 --- a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md +++ b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md @@ -58,10 +58,16 @@ registry round-trip. The gate step stops exporting `BUILDKIT_ADDR`, so Copa uses its default connection, which under the containerd store resolves to dockerd's embedded BuildKit and **sees local images**. -4. **Copa must use the docker driver (embedded dockerd BuildKit), not an isolated - `docker-container` buildx builder.** The `publish: false` validation confirms this; if - Copa selects an isolated builder, pin the default builder to `default` (docker driver) - before the gate, or fall back to the local-registry approach (see Rollback). +4. **Copa uses the docker driver (dockerd's embedded BuildKit), not the isolated + `docker-container` buildx builder.** Verified against Copa v0.14.1 source + (`pkg/buildkit/drivers.go` `autoClient`): with no `-a`, Copa tries the **docker driver + first**, then the buildx driver, then the default buildkitd socket. The docker connhelper + (`pkg/buildkit/connhelpers/docker.go`) dials dockerd's `/grpc` endpoint directly (via + `DOCKER_HOST` / the docker context), so it is **independent of whichever builder + `docker buildx` has selected**. With the containerd store enabled, the docker driver passes + Copa's `CapMergeOp`/`CapDiffOp` validation and is the one used — the buildx + `docker-container` builder (a later fallback) is never reached, so **no builder pin is + required**. The `publish: false` validation run confirms this end-to-end on the runner. Everything else — plain build, plain push, the gate logic, hardened push (`PUSH_HARDENED`), cleanup, SBOM/CVE data, `process-tags` — is unchanged. `process-tags` runs in its own job on @@ -82,6 +88,13 @@ an unmodified runner and is unaffected (it operates on the registry). 1. `workflow_dispatch` with `publish: false` on `image_copa` — expect: hardened legs enable the containerd store, build, Copa-patch, gate, and generate SBOMs, with **zero** pushes to Docker Hub / GHCR; the deferred gate step reports pass/fail. + - **Un-foolable check that Copa patched the *local* image (not a registry pull):** a green + gate alone is **not** proof — the stable plain tags already exist in the registry from + prior runs, so a registry-pulling Copa would silently patch the previously published + image and still pass. Run Copa with debug logging for this validation (add `--debug` to + the `copa patch` call, or raise its log level) and confirm the log shows the **docker + driver** connected — i.e. it does *not* print `Could not use docker driver` and fall + through to buildx/buildkitd. 2. `workflow_dispatch` with `publish: true, publish_hardened: false` — expect: plain tags published, hardened built + gated but **not** pushed. 3. Only after both pass: allow the scheduled cadence to exercise it. @@ -97,9 +110,14 @@ point Copa at it (Option 2 from the discussion) — keeps everything local, more ## Risk / uncertainty (explicit) -- The exact Copa↔BuildKit selection under the containerd store on GitHub-hosted runners is - **confirmed by the validation run**, not yet proven end-to-end here (the local spike could - not enable the containerd store without disrupting the session daemon). +- Copa's **driver selection** under the containerd store is verified from Copa v0.14.1 source + (see Design §4): the docker driver is tried first and, once the store is active, is the one + used — independent of the selected buildx builder. What the local spike could **not** prove + is the full **end-to-end** patch on a GitHub-hosted runner (the spike could not enable the + containerd store without disrupting the session daemon): that dockerd's embedded BuildKit + resolves the locally built image from the shared containerd store during a Copa patch. The + `publish: false` validation run is that end-to-end confirmation — see the Validation gate + for how to make the check un-foolable (a green gate alone is not proof). - The 5 stable legs' plain build/push move to the containerd store; the containerd store is the modern Docker default and supports `build --load`, `tag`, `push`, `buildx`, and `manifest`, but the `publish: false` → `publish: true` validation sequence is what guards From ab6aa3e7ce9e1c326aaf752a48e2e3e058dc0548 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:13:59 +0200 Subject: [PATCH 64/75] docs: soften 'un-foolable' validation wording; offer digest comparison as strongest check Re-review noted the debug-log check verifies the driver (a sound proxy for image source given the verified-active containerd store), not the digest directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-17-containerd-store-local-copa.md | 2 +- ...6-07-17-containerd-store-local-copa-design.md | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md index 7edc814..5dae34e 100644 --- a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md +++ b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md @@ -282,7 +282,7 @@ git commit -m "docs: resolve I4 (Copa patches local image via containerd store); Per the spec's validation gate — run these before relying on the change for the scheduled cadence: 1. `workflow_dispatch` on `image_copa` with `publish: false` → hardened legs enable the containerd store, build, Copa-patch, gate, and produce SBOMs with **zero** pushes; the deferred gate step reports pass/fail. - - **Confirm Copa patched the *local* image, not a registry pull.** A green gate alone is not proof: the stable plain tags already exist in the registry from prior runs, so a registry-pulling Copa would silently patch the previously published image and still pass. Make the check un-foolable — run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, or raise its log level) and confirm the log shows the **docker driver** connected (it must *not* print `Could not use docker driver` and fall through to buildx/buildkitd). Per Copa v0.14.1 `autoClient`, the docker driver is tried first and, with the containerd store active, is the one used. + - **Confirm Copa patched the *local* image, not a registry pull.** A green gate alone is not proof: the stable plain tags already exist in the registry from prior runs, so a registry-pulling Copa would silently patch the previously published image and still pass. Run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, or raise its log level) and confirm the log shows the **docker driver** connected (it must *not* print `Could not use docker driver` and fall through to buildx/buildkitd). Per Copa v0.14.1 `autoClient`, the docker driver is tried first and, with the containerd store active, is the one used. (Strongest check: also compare the locally built plain image's digest against the base of the patched hardened image.) 2. `workflow_dispatch` with `publish: true, publish_hardened: false` → plain tags published, hardened built + gated but **not** pushed. 3. Only after both pass: allow the scheduled cadence to exercise it. diff --git a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md index 8672a97..ecbbc6b 100644 --- a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md +++ b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md @@ -88,13 +88,15 @@ an unmodified runner and is unaffected (it operates on the registry). 1. `workflow_dispatch` with `publish: false` on `image_copa` — expect: hardened legs enable the containerd store, build, Copa-patch, gate, and generate SBOMs, with **zero** pushes to Docker Hub / GHCR; the deferred gate step reports pass/fail. - - **Un-foolable check that Copa patched the *local* image (not a registry pull):** a green - gate alone is **not** proof — the stable plain tags already exist in the registry from - prior runs, so a registry-pulling Copa would silently patch the previously published - image and still pass. Run Copa with debug logging for this validation (add `--debug` to - the `copa patch` call, or raise its log level) and confirm the log shows the **docker - driver** connected — i.e. it does *not* print `Could not use docker driver` and fall - through to buildx/buildkitd. + - **Confirm Copa patched the *local* image (not a registry pull):** a green gate alone is + **not** proof — the stable plain tags already exist in the registry from prior runs, so a + registry-pulling Copa would silently patch the previously published image and still pass. + Run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, + or raise its log level) and confirm the log shows the **docker driver** connected — i.e. + it does *not* print `Could not use docker driver` and fall through to buildx/buildkitd. + (Since the containerd store is verified active in the enable step, "docker driver + connected" implies the local store was used. For the strongest check, also compare the + locally built plain image's digest against the base of the patched hardened image.) 2. `workflow_dispatch` with `publish: true, publish_hardened: false` — expect: plain tags published, hardened built + gated but **not** pushed. 3. Only after both pass: allow the scheduled cadence to exercise it. From d5e3073a1053cf577838c3016e0907834d759936 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:40:22 +0200 Subject: [PATCH 65/75] scan-patch-gate: drop partial hardened SBOM on failure; cover post-patch fail-closed branches Addresses the two newest Copilot review comments (2026-07-17): - fail_gate now removes a partially written HARDENED_SBOM so the always-runs artifact upload never exposes a hardened SBOM for a variant whose gate failed. - Add stub knobs STUB_BADJSON_GATE (malformed gate report) and STUB_SBOM=fail (partial SBOM then non-zero) + Scenarios H and I, covering the post-patch jq-empty fail-closed branch and the SBOM-cleanup path. Both mutation-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/scan-patch-gate.sh | 4 ++++ .github/scripts/tests/run.sh | 22 ++++++++++++++++++++++ .github/scripts/tests/stubs/trivy | 23 ++++++++++++++++++----- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.github/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh index 8a96265..b6f4402 100755 --- a/.github/scripts/scan-patch-gate.sh +++ b/.github/scripts/scan-patch-gate.sh @@ -32,6 +32,10 @@ fail_gate() { # -- record + skip hardened, but let plain ship docker rmi "${HARDENED_IMAGE}" 2>/dev/null || true rm -f "${vdir}/hardened_image.txt" "${vdir}/hardened_tags.txt" "${vdir}/hardened_sbom.txt" rm -f "$report" + # If SBOM generation created/truncated its output before failing, drop the partial file + # so the always-runs artifact upload never exposes a hardened SBOM for a variant whose + # gate failed (HARDENED_SBOM is unset for failures before the SBOM step -> no-op). + [ -n "${HARDENED_SBOM:-}" ] && rm -f "${HARDENED_SBOM}" exit 0 } diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 8463d9f..c3a8518 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -202,5 +202,27 @@ outG="$(BUILDKIT_ADDR=tcp://127.0.0.1:8888 GATE_SEVERITY=CRITICAL,HIGH STUB_FIXA logG="$(cat "$wG/stub.log" 2>/dev/null)" assert_contains "$logG" "-a tcp://127.0.0.1:8888" "G copa receives -a when BUILDKIT_ADDR set" +# Scenario H: the POST-PATCH gate report is malformed JSON -> fail-closed at the gate +# jq-empty check (line ~90). Distinct from Scenario F, which corrupts the INITIAL plain +# scan; here the initial scan is valid and fixable, copa patches, and only the gate report +# is bad -- exercising the second fail-closed branch that Scenario F cannot reach. +wH="$(mktemp -d)"; tmpdirs+=("$wH"); setup_variant "$wH" gatebadjson +outH="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_BADJSON_GATE=1 STUB_LOG="$wH/stub.log" run_gate "$wH" gatebadjson)"; rcH=$? +[ "$rcH" = 0 ] && echo " ok: H exit 0 (gate report malformed, contained)" || { echo " FAIL: H exit $rcH"; fail=1; } +assert_file "$wH/.docker-state/gatebadjson/gate_failed.txt" "H gate_failed marker" +assert_no_file "$wH/.docker-state/gatebadjson/hardened_image.txt" "H hardened_image absent" +assert_contains "$(cat "$wH/.docker-state/gatebadjson/gate_failed.txt")" "gate report is not valid JSON" "H gate_failed reason mentions gate report invalid JSON" + +# Scenario I: hardened SBOM generation fails after the gate passed. fail_gate must not leave +# a partial SBOM in the sboms/ dir (the always-runs artifact upload would otherwise expose a +# hardened SBOM for a variant that was never published). +wI="$(mktemp -d)"; tmpdirs+=("$wI"); setup_variant "$wI" sbomfail +outI="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_SBOM=fail STUB_LOG="$wI/stub.log" run_gate "$wI" sbomfail)"; rcI=$? +[ "$rcI" = 0 ] && echo " ok: I exit 0 (SBOM failure contained)" || { echo " FAIL: I exit $rcI"; fail=1; } +assert_file "$wI/.docker-state/sbomfail/gate_failed.txt" "I gate_failed marker" +assert_no_file "$wI/.docker-state/sbomfail/hardened_image.txt" "I hardened_image absent" +assert_no_file "$wI/sboms/php8.5-sbomfail-v5.1-hardened-amd64.spdx.json" "I partial hardened SBOM removed from sboms/" +assert_contains "$(cat "$wI/.docker-state/sbomfail/gate_failed.txt")" "hardened SBOM generation failed" "I gate_failed reason mentions SBOM failure" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/scripts/tests/stubs/trivy b/.github/scripts/tests/stubs/trivy index b946df3..a8f80f2 100755 --- a/.github/scripts/tests/stubs/trivy +++ b/.github/scripts/tests/stubs/trivy @@ -5,10 +5,14 @@ # spdx-json -- writes malformed JSON instead of a report, to exercise the # caller's `jq empty` fail-closed path on the plain-image scan specifically; # scoped to no-severity so it doesn't also corrupt the separate gate-scan -# report and mask a regression in the initial check alone). SPDX just writes -# a minimal doc. Every invocation is recorded to STUB_LOG, including a parsed -# summary of which image reference (the trailing non-flag argument) was -# targeted, so callers can assert scan/SBOM target. +# report and mask a regression in the initial check alone). +# STUB_BADJSON_GATE=1 does the same for the severity-filtered GATE scan, to +# exercise the caller's post-patch `jq empty` fail-closed path specifically. +# STUB_SBOM=fail makes spdx-json SBOM generation write a partial file and then +# exit non-zero (simulating Trivy truncating output before failing); otherwise +# SPDX just writes a minimal doc. Every invocation is recorded to STUB_LOG, +# including a parsed summary of which image reference (the trailing non-flag +# argument) was targeted, so callers can assert scan/SBOM target. echo "trivy $*" >> "${STUB_LOG:-/dev/null}" out=""; sev=""; fmt=""; img="" @@ -27,7 +31,12 @@ done echo "trivy-call format=${fmt} severity=${sev} image=${img}" >> "${STUB_LOG:-/dev/null}" case "$fmt" in - spdx-json) printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; + spdx-json) + if [ "${STUB_SBOM:-ok}" = "fail" ]; then + printf '{ partial spdx' > "$out" # simulate Trivy truncating output, then failing + exit 1 + fi + printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; table) echo "stub trivy table report" > "$out"; exit 0;; esac # JSON vulnerability scan (initial or severity-gated) @@ -35,6 +44,10 @@ if [ -z "$sev" ] && [ "${STUB_BADJSON:-0}" = "1" ]; then printf '{ not valid' > "$out" exit 0 fi +if [ -n "$sev" ] && [ "${STUB_BADJSON_GATE:-0}" = "1" ]; then + printf '{ not valid' > "$out" + exit 0 +fi if [ -n "$sev" ]; then [ "${STUB_GATE:-pass}" = "fail" ] && v='[{"VulnerabilityID":"CVE-GATE"}]' || v='[]' else From d0b9d29c60dc02aab0a3a1ddf8a7ecadf38d5ce1 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:41:51 +0200 Subject: [PATCH 66/75] PR comments --- ...26-07-02-copa-plain-always-publish-sbom.md | 935 ------------------ .../2026-07-17-containerd-store-local-copa.md | 295 ------ .../2026-06-15-hardened-image-tag-design.md | 118 --- ...2-copa-plain-always-publish-sbom-design.md | 321 ------ ...7-17-containerd-store-local-copa-design.md | 149 --- 5 files changed, 1818 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md delete mode 100644 docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md delete mode 100644 docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md delete mode 100644 docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md delete mode 100644 docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md diff --git a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md b/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md deleted file mode 100644 index cc68bf6..0000000 --- a/docs/superpowers/plans/2026-07-02-copa-plain-always-publish-sbom.md +++ /dev/null @@ -1,935 +0,0 @@ -# Copa plain-always-publish + SBOM Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make plain images always publish (even with CVEs) while `-hardened` tags publish only when they pass the severity gate, and generate a legally-required SPDX SBOM for every published image. - -**Architecture:** The `release.yml` workflow builds plain images, then (for `hardened: true` entries) scans+patches+gates each variant. The gate logic and SBOM attachment are extracted into `.github/scripts/*.sh` so they are unit-testable with stubbed `trivy`/`copa`/`docker`/`oras`. A gate failure writes a per-variant marker and skips only that variant's hardened tags; a final step turns the job red after pushes. SBOMs are generated with Trivy (SPDX-JSON) and attached to pushed images as OCI referrers via `oras`. - -**Tech Stack:** GitHub Actions, Bash 5, Trivy, Copacetic (Copa), oras, jq, Docker Buildx. - -## Global Constraints - -- Registry / image name: `pimcore/pimcore` (Docker Hub) and `ghcr.io/pimcore/pimcore` (verbatim). -- `-hardened` produced **only** for `hardened: true` matrix entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`); dev/rolling lines stay plain-only. -- Plain images **always publish**, even with CVEs. Only `-hardened` is gated. -- SBOM format: **SPDX-JSON** (`trivy image --format spdx-json`), for every published image, per architecture. -- `oras attach` is **non-fatal** — a registry rejecting referrers must only warn. -- Severity gate is a **threshold**: `fail_on_severity` normalises to an inclusive list (`HIGH` → `HIGH,CRITICAL`); `NONE` disables it. (Already implemented inline as `GATE_SEVERITY` — do not remove.) -- Copa `-t` takes a **full image reference** (`${IMAGE_NAME}:...`), not a bare tag. -- Pinned tool versions live in `env:` (`COPA_VERSION`, `BUILDKIT_VERSION`); add `ORAS_VERSION`, `ACTIONLINT_VERSION` the same way. -- Shell: every extracted script starts with `#!/usr/bin/env bash` and `set -euo pipefail`. - ---- - -## File Structure - -- `.github/scripts/attach-sbom.sh` (new) — attach one SBOM to one image ref via `oras`, non-fatal. -- `.github/scripts/scan-patch-gate.sh` (new) — per-variant scan → patch/mirror → gate → on pass: write hardened outputs + hardened SBOM; on fail: write `gate_failed.txt`, skip hardened outputs, exit 0. -- `.github/scripts/tests/stubs/{trivy,copa,docker,oras}` (new) — arg-inspecting stubs on `PATH`. -- `.github/scripts/tests/run.sh` (new) — stub-driven test runner for the two scripts. -- `.github/workflows/release.yml` (modify) — install split, plain SBOM, wire gate script, push-attach, final fail step, `fail-fast: false`, `process-tags` `always()`. -- `.github/workflows/test.yml` (modify) — add a fast `scripts` job running actionlint + `run.sh`. -- `README.md` (modify) — rewrite "Hardened images" section. -- `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (modify) — supersession note. - ---- - -### Task 1: `attach-sbom.sh` (non-fatal SBOM attach) - -**Files:** -- Create: `.github/scripts/attach-sbom.sh` -- Create: `.github/scripts/tests/stubs/oras` -- Create: `.github/scripts/tests/run.sh` (started here, extended in Task 2) - -**Interfaces:** -- Produces: `attach-sbom.sh ` — always exits 0; prints `Attached ...` on success, `::warning::...` on failure/missing file. - -- [ ] **Step 1: Write the stub `oras` and the failing test** - -Create `.github/scripts/tests/stubs/oras`: - -```bash -#!/usr/bin/env bash -# Stub oras: succeeds unless STUB_ORAS=fail. Records the call for assertions. -echo "oras $*" >> "${STUB_LOG:-/dev/null}" -if [ "${STUB_ORAS:-ok}" = "fail" ]; then - echo "stub oras: simulated referrer rejection" >&2 - exit 1 -fi -exit 0 -``` - -Create `.github/scripts/tests/run.sh`: - -```bash -#!/usr/bin/env bash -set -uo pipefail -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT="$(cd "${HERE}/../../.." && pwd)" -export PATH="${HERE}/stubs:${PATH}" -fail=0 -assert_contains() { # - if printf '%s' "$1" | grep -qF -- "$2"; then echo " ok: $3"; else echo " FAIL: $3 (missing '$2')"; fail=1; fi -} -assert_file() { [ -e "$1" ] && echo " ok: $2 exists" || { echo " FAIL: $2 missing"; fail=1; }; } -assert_no_file() { [ ! -e "$1" ] && echo " ok: $2 absent" || { echo " FAIL: $2 should be absent"; fail=1; }; } - -echo "== attach-sbom.sh ==" -work="$(mktemp -d)"; echo '{}' > "${work}/s.spdx.json" - -# success path -out="$(STUB_ORAS=ok "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? -assert_contains "$out" "Attached" "success prints Attached" -[ "$rc" = "0" ] && echo " ok: exit 0 on success" || { echo " FAIL: exit $rc"; fail=1; } - -# failure path is swallowed -out="$(STUB_ORAS=fail "${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:php8.5-v5-amd64 "${work}/s.spdx.json" 2>&1)"; rc=$? -assert_contains "$out" "::warning::" "failure prints warning" -[ "$rc" = "0" ] && echo " ok: exit 0 on failure" || { echo " FAIL: exit $rc"; fail=1; } - -# missing file -out="$("${ROOT}/.github/scripts/attach-sbom.sh" pimcore/pimcore:x /nope.json 2>&1)"; rc=$? -assert_contains "$out" "::warning::" "missing file warns" -[ "$rc" = "0" ] && echo " ok: exit 0 on missing file" || { echo " FAIL: exit $rc"; fail=1; } - -echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" -exit "$fail" -``` - -- [ ] **Step 2: Make stubs + runner executable and run to verify it fails** - -Run: -```bash -chmod +x .github/scripts/tests/stubs/oras .github/scripts/tests/run.sh -.github/scripts/tests/run.sh; echo "exit=$?" -``` -Expected: FAIL — `attach-sbom.sh` does not exist yet (`No such file or directory`), `exit=1`. - -- [ ] **Step 3: Write `attach-sbom.sh`** - -Create `.github/scripts/attach-sbom.sh`: - -```bash -#!/usr/bin/env bash -# Attach an SPDX SBOM to a pushed image as an OCI referrer. -# Non-fatal: a registry that rejects referrers must not break publishing. -set -euo pipefail - -ref="${1:?usage: attach-sbom.sh }" -sbom="${2:?usage: attach-sbom.sh }" - -if [ ! -s "$sbom" ]; then - echo "::warning::SBOM '$sbom' missing or empty; skipping attach for ${ref}" - exit 0 -fi - -if oras attach --artifact-type application/spdx+json "$ref" "${sbom}:application/spdx+json"; then - echo "Attached SBOM ${sbom} to ${ref}" -else - echo "::warning::Failed to attach SBOM to ${ref} (registry may not support OCI referrers)" -fi -exit 0 -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: -```bash -chmod +x .github/scripts/attach-sbom.sh -.github/scripts/tests/run.sh; echo "exit=$?" -``` -Expected: PASS — all `attach-sbom.sh` assertions `ok`, `ALL TESTS PASSED`, `exit=0`. - -- [ ] **Step 5: bash -n both scripts** - -Run: -```bash -bash -n .github/scripts/attach-sbom.sh && bash -n .github/scripts/tests/run.sh && echo "SYNTAX OK" -``` -Expected: `SYNTAX OK`. - -- [ ] **Step 6: Commit** - -```bash -git add .github/scripts/attach-sbom.sh .github/scripts/tests/run.sh .github/scripts/tests/stubs/oras -git commit -m "Add non-fatal SBOM attach helper (oras) with stub tests" -``` - ---- - -### Task 2: `scan-patch-gate.sh` (per-variant gate with plain-always-publish) - -**Files:** -- Create: `.github/scripts/scan-patch-gate.sh` -- Create: `.github/scripts/tests/stubs/{trivy,copa,docker}` -- Modify: `.github/scripts/tests/run.sh` (append the gate scenarios) - -**Interfaces:** -- Consumes (env): `IMAGE_NAME`, `ARCH_TAG`, `GATE_SEVERITY`, optional `STATE_DIR` (default `.docker-state`), `SBOM_DIR` (default `sboms`), `REPORT_DIR` (default `trivy-reports`), `BUILDKIT_ADDR` (default `tcp://127.0.0.1:8888`), `GITHUB_STEP_SUMMARY`. -- Consumes (files): `${STATE_DIR}//{plain_image,base_tag,version,tag,plain_tags}.txt`. -- Produces on pass: `${STATE_DIR}//{hardened_image,hardened_tags,hardened_sbom}.txt`, the hardened SPDX in `${SBOM_DIR}/`, a Trivy report in `${REPORT_DIR}/`. Produces on fail: `${STATE_DIR}//gate_failed.txt`; removes any hardened outputs. **Always exits 0** unless a genuine infra error (missing state file) occurs. - -- [ ] **Step 1: Write the stubs** - -Create `.github/scripts/tests/stubs/trivy`: - -```bash -#!/usr/bin/env bash -# Stub trivy. Scenario via env: STUB_FIXABLE=yes|no (initial OS scan), -# STUB_GATE=pass|fail (severity-filtered gate scan). SPDX just writes a minimal doc. -out=""; sev=""; fmt="" -while [ $# -gt 0 ]; do - case "$1" in - -o) out="$2"; shift 2;; - --severity) sev="$2"; shift 2;; - --format) fmt="$2"; shift 2;; - *) shift;; - esac -done -case "$fmt" in - spdx-json) printf '{"spdxVersion":"SPDX-2.3","packages":[{"name":"libc6","versionInfo":"2.36-1"}]}\n' > "$out"; exit 0;; - table) echo "stub trivy table report" > "$out"; exit 0;; -esac -# JSON vulnerability scan -if [ -n "$sev" ]; then - [ "${STUB_GATE:-pass}" = "fail" ] && v='[{"VulnerabilityID":"CVE-GATE"}]' || v='[]' -else - [ "${STUB_FIXABLE:-yes}" = "no" ] && v='[]' || v='[{"VulnerabilityID":"CVE-FIX"}]' -fi -printf '{"Results":[{"Vulnerabilities":%s}]}\n' "$v" > "$out" -exit 0 -``` - -Create `.github/scripts/tests/stubs/copa`: - -```bash -#!/usr/bin/env bash -echo "copa $*" >> "${STUB_LOG:-/dev/null}" -[ "${STUB_COPA:-ok}" = "fail" ] && { echo "stub copa: simulated failure" >&2; exit 1; } -exit 0 -``` - -Create `.github/scripts/tests/stubs/docker`: - -```bash -#!/usr/bin/env bash -# Stub docker: 'image inspect' exists-check exits 0; with --format prints a fake id. -if [ "$1 $2" = "image inspect" ]; then - if printf '%s ' "$@" | grep -q -- '--format'; then echo "sha256:deadbeefcafe0000"; fi - exit 0 -fi -exit 0 -``` - -- [ ] **Step 2: Append gate scenarios to `run.sh`** - -Add before the final summary lines (`echo; [ "$fail" = "0" ] ...`) in `.github/scripts/tests/run.sh`: - -```bash -echo "== scan-patch-gate.sh ==" -setup_variant() { # - local d="$1/.docker-state/$2"; mkdir -p "$d" - echo "pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_image.txt" - echo "php8.5-$2" > "$d/base_tag.txt" - echo "v5.1" > "$d/version.txt" - echo "php8.5-$2-v5.1-amd64" > "$d/tag.txt" - printf '%s\n' \ - "pimcore/pimcore:php8.5-$2-v5.1-amd64" \ - "ghcr.io/pimcore/pimcore:php8.5-$2-v5.1-amd64" > "$d/plain_tags.txt" -} -run_gate() { # runs scan-patch-gate.sh in with env already exported - ( cd "$1" && IMAGE_NAME=pimcore/pimcore ARCH_TAG=amd64 \ - "${ROOT}/.github/scripts/scan-patch-gate.sh" "$2" ) 2>&1 -} - -# Scenario A: fixable vulns, gate passes -> hardened published -wA="$(mktemp -d)"; setup_variant "$wA" default -outA="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass run_gate "$wA" default)"; rcA=$? -[ "$rcA" = 0 ] && echo " ok: A exit 0" || { echo " FAIL: A exit $rcA"; fail=1; } -assert_file "$wA/.docker-state/default/hardened_image.txt" "A hardened_image" -assert_file "$wA/.docker-state/default/hardened_tags.txt" "A hardened_tags" -assert_file "$wA/.docker-state/default/hardened_sbom.txt" "A hardened_sbom" -assert_no_file "$wA/.docker-state/default/gate_failed.txt" "A gate_failed" -assert_contains "$(cat "$wA/.docker-state/default/hardened_tags.txt")" "hardened-amd64" "A tags carry -hardened" - -# Scenario B: gate fails -> plain only, marker written, exit 0 -wB="$(mktemp -d)"; setup_variant "$wB" max -outB="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=fail run_gate "$wB" max)"; rcB=$? -[ "$rcB" = 0 ] && echo " ok: B exit 0 (does not abort step)" || { echo " FAIL: B exit $rcB"; fail=1; } -assert_file "$wB/.docker-state/max/gate_failed.txt" "B gate_failed marker" -assert_no_file "$wB/.docker-state/max/hardened_image.txt" "B hardened_image" -assert_contains "$outB" "::error::" "B emits ::error::" - -# Scenario C: nothing fixable -> hardened mirrors plain, gate passes -wC="$(mktemp -d)"; setup_variant "$wC" min -outC="$(GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=no STUB_GATE=pass run_gate "$wC" min)"; rcC=$? -[ "$rcC" = 0 ] && echo " ok: C exit 0" || { echo " FAIL: C exit $rcC"; fail=1; } -assert_file "$wC/.docker-state/min/hardened_image.txt" "C hardened_image (mirror)" -assert_no_file "$wC/.docker-state/min/gate_failed.txt" "C gate_failed" - -# Scenario D: gate disabled (NONE) -> hardened published without gate scan -wD="$(mktemp -d)"; setup_variant "$wD" debug -outD="$(GATE_SEVERITY=NONE STUB_FIXABLE=yes run_gate "$wD" debug)"; rcD=$? -assert_file "$wD/.docker-state/debug/hardened_image.txt" "D hardened_image (NONE)" -``` - -- [ ] **Step 3: Run tests to verify the new scenarios fail** - -Run: -```bash -chmod +x .github/scripts/tests/stubs/trivy .github/scripts/tests/stubs/copa .github/scripts/tests/stubs/docker -.github/scripts/tests/run.sh; echo "exit=$?" -``` -Expected: FAIL — `scan-patch-gate.sh` not found; scenario A–D assertions FAIL; `exit=1`. - -- [ ] **Step 4: Write `scan-patch-gate.sh`** - -Create `.github/scripts/scan-patch-gate.sh`: - -```bash -#!/usr/bin/env bash -# Per-variant: scan the plain image, patch with Copa (or mirror if nothing fixable), -# gate the hardened image on GATE_SEVERITY, and -- only if it passes -- publish the -# hardened outputs and generate its SPDX SBOM. A gate failure (or scan/patch error) -# writes gate_failed.txt, skips the hardened outputs, and exits 0 so the plain image -# still ships and other variants continue. Genuine infra errors abort (set -e). -set -euo pipefail - -variant="${1:?usage: scan-patch-gate.sh }" -: "${IMAGE_NAME:?}"; : "${ARCH_TAG:?}"; : "${GATE_SEVERITY:?}" -STATE_DIR="${STATE_DIR:-.docker-state}" -SBOM_DIR="${SBOM_DIR:-sboms}" -REPORT_DIR="${REPORT_DIR:-trivy-reports}" -BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}" -vdir="${STATE_DIR}/${variant}" -mkdir -p "$SBOM_DIR" "$REPORT_DIR" - -PLAIN_IMAGE=$(< "${vdir}/plain_image.txt") -BASE_TAG=$(< "${vdir}/base_tag.txt") -VERSION=$(< "${vdir}/version.txt") -TAG=$(< "${vdir}/tag.txt") -HARDENED_IMAGE="${IMAGE_NAME}:${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}" -report="/tmp/spg-${variant}.json" - -fail_gate() { # -- record + skip hardened, but let plain ship - echo "::error::${variant}: $1" - { echo "## Gate failed: ${HARDENED_IMAGE}"; echo ""; echo "$1"; echo ""; } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - echo "$1" > "${vdir}/gate_failed.txt" - rm -f "${vdir}/hardened_image.txt" "${vdir}/hardened_tags.txt" "${vdir}/hardened_sbom.txt" - rm -f "$report" - exit 0 -} - -echo "Scanning plain image ${PLAIN_IMAGE} for OS vulnerabilities" -trivy image --pkg-types os --ignore-unfixed --format json -o "$report" "${PLAIN_IMAGE}" \ - || fail_gate "Trivy scan of plain image failed" - -if [ -s "$report" ] && jq -e '.Results[]? | select(.Vulnerabilities != null and (.Vulnerabilities | length > 0))' "$report" > /dev/null 2>&1; then - copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ - || fail_gate "Copa patch failed" - docker image inspect "${HARDENED_IMAGE}" > /dev/null 2>&1 \ - || fail_gate "Hardened image not found after copa patch" - echo "Successfully patched ${PLAIN_IMAGE} into ${HARDENED_IMAGE}" -else - echo "No fixable OS vulnerabilities found; hardened image mirrors plain" - docker tag "${PLAIN_IMAGE}" "${HARDENED_IMAGE}" -fi -rm -f "$report" - -if [ "$GATE_SEVERITY" != "NONE" ]; then - echo "Running post-patch scan (fail on ${GATE_SEVERITY})" - IMAGE_HASH=$(docker image inspect "${HARDENED_IMAGE}" --format '{{.Id}}' | sed 's/sha256://' | head -c 12) - REPORT_JSON="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.json" - REPORT_TXT="${REPORT_DIR}/${TAG}-hardened_${IMAGE_HASH}.txt" - - trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ - --format json -o "${REPORT_JSON}" "${HARDENED_IMAGE}" \ - || fail_gate "Trivy gate scan failed" - - trivy image --pkg-types os --ignore-unfixed --severity "$GATE_SEVERITY" \ - --format table -o "/tmp/spg-${variant}.txt" "${HARDENED_IMAGE}" || true - cp "/tmp/spg-${variant}.txt" "${REPORT_TXT}" 2>/dev/null || true - { - echo "## Trivy Scan: ${HARDENED_IMAGE}" - echo "" - echo "### OS Vulnerabilities (${GATE_SEVERITY})" - echo '```' - cat "/tmp/spg-${variant}.txt" 2>/dev/null || echo "No results" - echo '```' - echo "" - } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - rm -f "/tmp/spg-${variant}.txt" - - if jq -e '.Results[]? | select((.Vulnerabilities // []) | length > 0)' "${REPORT_JSON}" > /dev/null; then - fail_gate "unfixed ${GATE_SEVERITY} vulnerabilities remain after patching" - fi -fi - -# Gate passed (or disabled): publish hardened tags + SBOM. -while IFS= read -r plain_tag; do - echo "${plain_tag%-${ARCH_TAG}}-hardened-${ARCH_TAG}" -done < "${vdir}/plain_tags.txt" > "${vdir}/hardened_tags.txt" -echo "${HARDENED_IMAGE}" > "${vdir}/hardened_image.txt" - -HARDENED_SBOM="${SBOM_DIR}/${BASE_TAG}-${VERSION}-hardened-${ARCH_TAG}.spdx.json" -trivy image --format spdx-json -o "${HARDENED_SBOM}" "${HARDENED_IMAGE}" -echo "${HARDENED_SBOM}" > "${vdir}/hardened_sbom.txt" -echo "Published hardened outputs for ${variant}" -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: -```bash -chmod +x .github/scripts/scan-patch-gate.sh -.github/scripts/tests/run.sh; echo "exit=$?" -``` -Expected: PASS — every scenario A–D `ok`, `ALL TESTS PASSED`, `exit=0`. - -- [ ] **Step 6: bash -n** - -Run: -```bash -bash -n .github/scripts/scan-patch-gate.sh && echo "SYNTAX OK" -``` -Expected: `SYNTAX OK`. - -- [ ] **Step 7: Commit** - -```bash -git add .github/scripts/scan-patch-gate.sh .github/scripts/tests/ -git commit -m "Add scan-patch-gate script: plain always ships, hardened gated, per-variant markers + SBOM" -``` - ---- - -### Task 3: Wire scripts into `release.yml` — installs, plain SBOM, gate step, fail-fast - -**Files:** -- Modify: `.github/workflows/release.yml` - -**Interfaces:** -- Consumes: `.github/scripts/scan-patch-gate.sh`, `.github/scripts/attach-sbom.sh` (Task 4 uses attach). -- Produces: plain SBOMs in `sboms/`, `.docker-state//plain_sbom.txt`; hardened outputs via the script. - -- [ ] **Step 1: Add pinned versions to `env:`** - -Modify the top-level `env:` block (after `BUILDKIT_VERSION`): - -```yaml -env: - IMAGE_NAME: pimcore/pimcore - COPA_VERSION: "0.14.1" - BUILDKIT_VERSION: "0.30.0" - ORAS_VERSION: "1.2.0" - TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" -``` - -- [ ] **Step 2: Add `fail-fast: false` to the matrix** - -Modify `strategy:` under the `build-php` job: - -```yaml - strategy: - fail-fast: false - matrix: -``` - -- [ ] **Step 3: Split the install step — Trivy + oras unconditional; Copa hardened-only** - -Replace the single `Install Copa and Trivy` step (`if: matrix.build.hardened`) with two steps. First, an unconditional install (place it before `Build plain images`): - -```yaml - - name: Install Trivy and oras - run: | - set -eux - sudo apt-get update - sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null - echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list - sudo apt-get update - sudo apt-get install -y trivy - - ORAS_ARCH="$(dpkg --print-architecture)" - curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" - curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" - EXPECTED_SHA=$(grep -F "oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" oras_checksums.txt | awk '{print $1}') - ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') - if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then - echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" - exit 1 - fi - tar -xzf oras.tar.gz oras - sudo mv oras /usr/local/bin/oras - rm oras.tar.gz oras_checksums.txt -``` - -Then a Copa-only step (keep `if: matrix.build.hardened`), containing only the Copa install block from the old step (the Trivy block is now above): - -```yaml - - name: Install Copa - if: ${{ matrix.build.hardened }} - run: | - set -eux - 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" - EXPECTED_SHA=$(grep -F "copa_${COPA_VERSION}_linux_${COPA_ARCH}.tar.gz" copacetic_checksums.txt | awk '{print $1}') - ACTUAL_SHA=$(sha256sum copa.tar.gz | awk '{print $1}') - if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then - echo "::error::Copa checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" - exit 1 - fi - tar -xzf copa.tar.gz copa - sudo mv copa /usr/local/bin/copa - rm copa.tar.gz copacetic_checksums.txt -``` - -Leave the `Start buildkit daemon` step unchanged (`if: matrix.build.hardened`). - -- [ ] **Step 4: Generate the plain SBOM in the `Build plain images` step** - -In `.github/workflows/release.yml`, inside the `Build plain images` `run:` loop, immediately after the `docker build --load ... --tag "${PLAIN_IMAGE}" .` command (still inside the `for imageVariant` loop), append: - -```bash - mkdir -p sboms - PLAIN_SBOM="sboms/${TAG}.spdx.json" - trivy image --format spdx-json -o "${PLAIN_SBOM}" "${PLAIN_IMAGE}" - echo "${PLAIN_SBOM}" > ".docker-state/${imageVariant}/plain_sbom.txt" -``` - -- [ ] **Step 5: Replace the gate loop body with a call to the script** - -In the `Scan, patch, and gate hardened images` step, keep the env block and the inline `GATE_SEVERITY` normalisation (lines defining `SEVERITY_ORDER` … `fi`). Replace the `for imageVariant ... done` loop (everything from `mapfile -t imageVariants` onward) with: - -```bash - export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY - export BUILDKIT_ADDR="tcp://127.0.0.1:8888" - - mapfile -t imageVariants < .docker-state/variants.txt - for imageVariant in "${imageVariants[@]}"; do - .github/scripts/scan-patch-gate.sh "${imageVariant}" - done -``` - -(`ARCH_TAG` is already in this step's `env:`; `GATE_SEVERITY` is set by the inline normalisation above; `export` makes them visible to the script.) - -- [ ] **Step 6: Install actionlint and lint the workflow** - -Run: -```bash -ALINT=/tmp/actionlint -curl -fsSL -o /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz -tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint -"$ALINT" -color .github/workflows/release.yml; echo "actionlint exit=$?" -``` -Expected: `actionlint exit=0` (no errors). If shellcheck-style warnings appear inside `run:` blocks, fix them. - -- [ ] **Step 7: bash -n the changed run-blocks** - -Run: -```bash -for step in "Build plain images" "Scan, patch, and gate hardened images"; do - START=$(grep -n "name: ${step}" .github/workflows/release.yml | head -1 | cut -d: -f1) - END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) - awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ - | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/blk.sh - bash -n /tmp/blk.sh && echo "OK: ${step}" || echo "SYNTAX FAIL: ${step}" -done -``` -Expected: `OK: Build plain images` and `OK: Scan, patch, and gate hardened images`. - -- [ ] **Step 8: Commit** - -```bash -git add .github/workflows/release.yml -git commit -m "release.yml: unconditional Trivy+oras, plain SBOM, delegate gate to script, fail-fast: false" -``` - ---- - -### Task 4: `release.yml` — push-step SBOM attach, deferred fail step, process-tags always() - -**Files:** -- Modify: `.github/workflows/release.yml` - -**Interfaces:** -- Consumes: `.github/scripts/attach-sbom.sh`; `.docker-state//{plain_sbom,hardened_image,hardened_sbom,tag}.txt`; `gate_failed.txt` markers. - -- [ ] **Step 1: Attach SBOMs after push in the `Tag, push, and aggregate` step** - -In the `Tag, push, and aggregate` step's loop, the block currently reads plain/hardened state. Add reading `TAG` and the SBOM paths at the top of the loop body (next to the existing `PLAIN_IMAGE=$(< ...)`): - -```bash - TAG=$(< ".docker-state/${imageVariant}/tag.txt") - PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") -``` - -Then, inside the existing `if [[ "$PUSH" == "true" ]]; then` block, after the `printf ... | xargs -P 4 ... docker push` line (and before/after the aggregation loop is fine), add the attach calls: - -```bash - # Attach the SPDX SBOM to each pushed image (once per digest per registry). - .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" - if [ -n "${HARDENED_IMAGE}" ] && [ -f ".docker-state/${imageVariant}/hardened_sbom.txt" ]; then - HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") - HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" - .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - fi -``` - -(`HARDENED_IMAGE` is already set earlier in this loop to `""` or the value from `hardened_image.txt`, so a gate-failed variant — which has no `hardened_image.txt` — skips the hardened attach automatically.) - -- [ ] **Step 2: Add the deferred "Fail if severity gate failed" step** - -Add this step **after** `Upload aggregated tags` (so pushes, report upload, and tag upload all run first), still inside the `build-php` job: - -```yaml - - name: Fail if severity gate failed - if: ${{ matrix.build.hardened }} - run: | - if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then - echo "The following variants failed the severity gate; their -hardened tags were NOT published:" - grep -H . .docker-state/*/gate_failed.txt - echo "::error::One or more variants failed the severity gate (plain images were published as-is)" - exit 1 - fi - echo "All hardened variants passed the severity gate." -``` - -- [ ] **Step 3: Make `process-tags` run even if a leg failed** - -Modify the `process-tags` job condition: - -```yaml - process-tags: - runs-on: ubuntu-22.04 - needs: build-php - if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }} -``` - -- [ ] **Step 4: actionlint** - -Run: -```bash -/tmp/actionlint -color .github/workflows/release.yml; echo "actionlint exit=$?" -``` -Expected: `actionlint exit=0`. - -- [ ] **Step 5: bash -n the push step** - -Run: -```bash -START=$(grep -n "name: Tag, push, and aggregate" .github/workflows/release.yml | head -1 | cut -d: -f1) -END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) -awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ - | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/push.sh -bash -n /tmp/push.sh && echo "SYNTAX OK" -``` -Expected: `SYNTAX OK`. - -- [ ] **Step 6: Commit** - -```bash -git add .github/workflows/release.yml -git commit -m "release.yml: attach SBOMs on push, defer gate failure to end, run process-tags on always()" -``` - ---- - -### Task 5: Add a fast `scripts` test job to `test.yml` - -**Files:** -- Modify: `.github/workflows/test.yml` - -- [ ] **Step 1: Add the job** - -Add a second job to `.github/workflows/test.yml` (sibling of the existing `test` job): - -```yaml - scripts: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - name: Install actionlint - run: | - curl -fsSL -o actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz - tar -xzf actionlint.tar.gz actionlint - sudo mv actionlint /usr/local/bin/actionlint - - name: Lint workflows - run: actionlint -color - - name: Run script unit tests - run: .github/scripts/tests/run.sh -``` - -- [ ] **Step 2: Verify the job's script test passes locally** - -Run: -```bash -.github/scripts/tests/run.sh; echo "exit=$?" -``` -Expected: `ALL TESTS PASSED`, `exit=0`. - -- [ ] **Step 3: actionlint the edited test.yml** - -Run: -```bash -/tmp/actionlint -color .github/workflows/test.yml; echo "actionlint exit=$?" -``` -Expected: `actionlint exit=0`. - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/test.yml -git commit -m "test.yml: add scripts job running actionlint and script unit tests" -``` - ---- - -### Task 6: README — rewrite the "Hardened images" section - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Replace the section** - -Replace the current `## Hardened images` section in `README.md` (from the `## Hardened images` heading up to the next `## ` heading) with: - -```markdown -## 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`. It is published as-is and may carry known OS-level CVEs. -- **hardened** (`-hardened` suffix) – the same image with OS-level CVEs patched in via [Copacetic (Copa)](https://github.com/project-copacetic/copacetic), e.g. `php8.5-debug-v5-hardened`. - -**What hardening does:** after the plain image is built, it is scanned with [Trivy](https://github.com/aquasecurity/trivy) and Copa applies the available Debian security updates for OS-level packages as an extra image layer. PHP, its extensions, and all application-level content are identical to the plain image — only OS package versions differ. - -**Scope & guarantees:** -- `-hardened` exists for **stable release tags only**; development tags (`-dev`) are published plain-only. -- The plain tag **always publishes**, even when CVEs remain. -- The `-hardened` tag publishes only when, after patching, no *fixable* CVE at or above the `fail_on_severity` threshold (default `CRITICAL,HIGH`) remains. It does **not** shield against CVEs with no upstream fix yet — those are excluded from the scan (`--ignore-unfixed`) and remain in *both* flavors until Debian ships a fix. `fail_on_severity` is a threshold (naming a severity gates it and everything above; `NONE` disables). - -```text -php8.5-debug-v5 # plain image, as built (may contain CVEs) -php8.5-debug-v5-hardened # same image, all available OS CVE fixes applied -``` - -**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM — always uploaded as a build artifact, and attached to the image as an OCI referrer where the registry supports it. -``` - -- [ ] **Step 2: Verify the section renders and links are intact** - -Run: -```bash -grep -n "## Hardened images" README.md && grep -c "hardened" README.md -``` -Expected: the heading is found once; `hardened` appears multiple times. Eyeball the block for correct Markdown (code fences balanced). - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "README: document Copa hardening, plain-always-publish gate semantics, and SBOMs" -``` - ---- - -### Task 7: Supersede decision 4 in the 2026-06-15 spec - -**Files:** -- Modify: `docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` - -- [ ] **Step 1: Add the supersession note** - -Under "## Decisions (confirmed with maintainer)", append to decision 4 (the "Gate ordering = all-or-nothing per variant" item): - -```markdown -> **Superseded 2026-07-02** (see `2026-07-02-copa-plain-always-publish-sbom-design.md`): -> the gate no longer blocks plain publishing. Plain images always publish; a hardened -> gate failure skips only that variant's `-hardened` tags and turns the job red at the end. -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md -git commit -m "spec: mark all-or-nothing gate decision superseded by 2026-07-02 spec" -``` - ---- - -## Self-Review - -**Spec coverage:** -- Part 1 (gate restructure, markers, plain-always) → Task 2 (script) + Task 4 (deferred fail step) + Task 3 (fail-fast). ✅ -- Part 1 resilience (`fail-fast: false`, `process-tags always()`) → Task 3 Step 2, Task 4 Step 3. ✅ -- Part 2 (SBOM: Trivy on all legs, plain SBOM, hardened SBOM, oras attach) → Task 3 (installs + plain SBOM), Task 2 (hardened SBOM), Task 1 + Task 4 (attach). ✅ -- Part 4 (README) → Task 6. ✅ -- Part 5 (spec supersession) → Task 7. ✅ -- Testability (stub-driven gate simulation, SBOM attach swallow, actionlint) → Tasks 1, 2, 5. ✅ -- Part 3 (package docs job) → **out of scope** for this plan (follow-up PR), per spec. ✅ - -**Placeholder scan:** none — all steps carry full code/commands. - -**Type/name consistency:** state files (`plain_image.txt`, `base_tag.txt`, `version.txt`, `tag.txt`, `plain_tags.txt`, `plain_sbom.txt`, `hardened_image.txt`, `hardened_tags.txt`, `hardened_sbom.txt`, `gate_failed.txt`) are written and read with identical names across Tasks 2–4. `scan-patch-gate.sh` env contract (`IMAGE_NAME`, `ARCH_TAG`, `GATE_SEVERITY`, `BUILDKIT_ADDR`) matches the exports added in Task 3 Step 5. `attach-sbom.sh ` signature matches its calls in Task 4 Step 1. - -**Known follow-ups (not blocking):** Part 3 package-docs job; optional cosign signing of SBOMs. - ---- - -### Task 8: Split the publish path so plain ships before the gate (added 2026-07-02) - -**Why:** the final review found that the single `Tag, push, and aggregate` step runs -*after* the gate step with the implicit `if: success()`, so an unforeseen non-zero exit of -the gate step would skip publishing the already-built plain images. Decision: make "plain -always ships" ironclad by pushing plain **before** the gate and hardened **after** it. - -**Files:** -- Modify: `.github/workflows/release.yml` - -**Interfaces:** unchanged — same `.docker-state//*.txt` files and -`.github/scripts/attach-sbom.sh`. Scripts are NOT modified; the existing unit tests remain -valid. - -- [ ] **Step 1: Add `Push plain images` immediately after `Build plain images` (before `Scan, patch, and gate hardened images`)** - -```yaml - - name: Push plain images - env: - ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} - PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} - run: | - set -eux - - mapfile -t imageVariants < .docker-state/variants.txt - - for imageVariant in "${imageVariants[@]}"; do - PLAIN_IMAGE=$(< ".docker-state/${imageVariant}/plain_image.txt") - TAG=$(< ".docker-state/${imageVariant}/tag.txt") - PLAIN_SBOM=$(< ".docker-state/${imageVariant}/plain_sbom.txt") - mapfile -t PLAIN_TAGS < ".docker-state/${imageVariant}/plain_tags.txt" - - for plain_tag in "${PLAIN_TAGS[@]}"; do - if [ "$plain_tag" != "$PLAIN_IMAGE" ]; then - docker tag "$PLAIN_IMAGE" "$plain_tag" - fi - done - - # Plain ships unconditionally, before the gate ever runs. - # Do NOT rmi here: the gate step patches this image into the hardened one. - if [[ "$PUSH" == "true" ]]; then - printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - - .github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" - - for tag in "${PLAIN_TAGS[@]}"; do - logical_tag="${tag//-arm64/}" - logical_tag="${logical_tag//-amd64/}" - echo "$logical_tag" >> aggregated_tags.txt - done - fi - done -``` - -- [ ] **Step 2: Replace the `Tag, push, and aggregate` step with `Push hardened images` (placed after `Scan, patch, and gate hardened images`)** - -Delete the entire existing `Tag, push, and aggregate` step and put this in its place: - -```yaml - - name: Push hardened images - if: ${{ matrix.build.hardened }} - env: - ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} - PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} - run: | - set -eux - - mapfile -t imageVariants < .docker-state/variants.txt - - for imageVariant in "${imageVariants[@]}"; do - # Variants whose gate failed have no hardened_image.txt -> skip (plain already shipped). - [ -f ".docker-state/${imageVariant}/hardened_image.txt" ] || continue - - HARDENED_IMAGE=$(< ".docker-state/${imageVariant}/hardened_image.txt") - HARDENED_SBOM=$(< ".docker-state/${imageVariant}/hardened_sbom.txt") - mapfile -t HARDENED_TAGS < ".docker-state/${imageVariant}/hardened_tags.txt" - - for hardened_tag in "${HARDENED_TAGS[@]}"; do - if [ "$hardened_tag" != "$HARDENED_IMAGE" ]; then - docker tag "$HARDENED_IMAGE" "$hardened_tag" - fi - done - - if [[ "$PUSH" == "true" ]]; then - printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" - - HARDENED_TAG="${HARDENED_IMAGE#${IMAGE_NAME}:}" - .github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" - .github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - - for tag in "${HARDENED_TAGS[@]}"; do - logical_tag="${tag//-arm64/}" - logical_tag="${logical_tag//-amd64/}" - echo "$logical_tag" >> aggregated_tags.txt - done - fi - done -``` - -- [ ] **Step 3: Add `Clean up images` (after `Push hardened images`, before `Stop buildkit daemon`)** - -```yaml - - name: Clean up images - if: ${{ always() }} - run: | - set -u - [ -f .docker-state/variants.txt ] || exit 0 - mapfile -t imageVariants < .docker-state/variants.txt - for imageVariant in "${imageVariants[@]}"; do - for tf in plain_tags hardened_tags; do - f=".docker-state/${imageVariant}/${tf}.txt" - [ -f "$f" ] || continue - while IFS= read -r t; do docker rmi "$t" 2>/dev/null || true; done < "$f" - done - for imf in plain_image hardened_image; do - f=".docker-state/${imageVariant}/${imf}.txt" - [ -f "$f" ] && docker rmi "$(< "$f")" 2>/dev/null || true - done - done -``` - -- [ ] **Step 4: Confirm step order and leave the rest untouched** - -The `build-php` job step order must now be: `Build plain images` → `Push plain images` → -`Scan, patch, and gate hardened images` → `Push hardened images` → `Clean up images` → -`Stop buildkit daemon` → `Upload trivy reports` → `Upload SBOMs` → `Upload aggregated -tags` → `Fail if severity gate failed`. Do not change any step other than the three -added/replaced here. `process-tags` (with its `always() && github.repository == 'pimcore/docker' && …` guard) is untouched. - -- [ ] **Step 5: Lint and syntax-check** - -Run: -```bash -ALINT=$(command -v actionlint || echo /tmp/actionlint) -"$ALINT" .github/workflows/release.yml; echo "actionlint exit=$?" -for step in "Push plain images" "Push hardened images" "Clean up images"; do - START=$(grep -n "name: ${step}" .github/workflows/release.yml | head -1 | cut -d: -f1) - END=$(awk -v s="$START" 'NR>s && /^ - name:/{print NR; exit}' .github/workflows/release.yml) - awk -v s="$START" -v e="$((END-1))" 'NR>=s && NR<=e' .github/workflows/release.yml \ - | sed -E 's/\$\{\{[^}]*\}\}/x/g' | sed -n '/run: |/,$p' | tail -n +2 > /tmp/blk.sh - bash -n /tmp/blk.sh && echo "OK: ${step}" || echo "SYNTAX FAIL: ${step}" -done -.github/scripts/tests/run.sh >/dev/null 2>&1 && echo "script unit tests still pass" || echo "SCRIPT TESTS FAIL" -``` -Expected: `actionlint exit=0`; `OK:` for all three steps; script unit tests still pass (scripts unchanged). - -- [ ] **Step 6: Commit** - -```bash -git add .github/workflows/release.yml -git commit -m "release.yml: push plain before the gate, hardened after (plain always ships)" -``` diff --git a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md b/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md deleted file mode 100644 index 5dae34e..0000000 --- a/docs/superpowers/plans/2026-07-17-containerd-store-local-copa.md +++ /dev/null @@ -1,295 +0,0 @@ -# Containerd Image Store for Local Copa Patching — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let Copa patch the locally built plain image (via Docker's containerd image store) so the hardened path runs entirely locally — a `publish: false` dispatch becomes a full hardened dry-run, and real runs no longer depend on a registry round-trip. - -**Architecture:** On the **hardened (stable) matrix legs only**, enable Docker's containerd image store before any builder is created. That gives dockerd's *embedded* BuildKit the `mergeop`/`diffop` Copa requires **and** a shared image store, so Copa (using its default connection) patches the local image. The standalone `buildkitd` container and its `tcp://127.0.0.1:8888` address are removed. `scan-patch-gate.sh` passes `-a` to Copa only when a `BUILDKIT_ADDR` is explicitly set (keeping the rollback path a one-line change). - -**Tech Stack:** GitHub Actions, Docker (containerd-snapshotter), Copacetic (Copa) 0.14.1, Trivy, bash, actionlint + shellcheck, stub-based bash unit tests. - -## Global Constraints - -- **Scope: hardened legs only.** Every daemon change guards on `if: ${{ matrix.build.hardened }}`. Dev/rolling legs (`hardened: false`) are untouched. (Verbatim from spec: "Enable Docker's containerd image store on the hardened (stable) legs only.") -- **Plain always ships.** Plain build + push happen before the gate and must be unaffected; the gate only ever *adds* the hardened tag. Never let a hardened-path change block plain publishing. -- **No new workflow input.** The existing `publish: false` is the dry-run mode. (Verbatim: "with no new input.") -- **Copa version:** pinned via `COPA_VERSION` env (0.14.1) — unchanged. -- **`copa patch` receives `-a ` only when `BUILDKIT_ADDR` is non-empty**, otherwise Copa uses its default connection (docker driver → dockerd embedded BuildKit under the containerd store). -- Shell blocks that change must pass `actionlint` + `shellcheck` (the CI "Lint workflows" + "Run script unit tests" steps in [.github/workflows/test.yml](.github/workflows/test.yml)). - ---- - -### Task 1: `scan-patch-gate.sh` — pass `-a` to Copa only when an address is set - -**Files:** -- Modify: [.github/scripts/scan-patch-gate.sh:14](.github/scripts/scan-patch-gate.sh#L14) and `:45-46` -- Test: [.github/scripts/tests/run.sh](.github/scripts/tests/run.sh) (Scenario A assertions + new Scenario G) - -**Interfaces:** -- Consumes: env `BUILDKIT_ADDR` (optional). When empty/unset → Copa default connection. When set → `copa patch … -a "$BUILDKIT_ADDR"`. -- Produces: no signature change. Same state files (`hardened_image.txt`, etc.). The only observable change is the `copa` command line. - -- [ ] **Step 1: Add the failing test assertions** - -In [.github/scripts/tests/run.sh](.github/scripts/tests/run.sh), Scenario A runs with `BUILDKIT_ADDR` **unset**. Add an assertion (right after the existing line `assert_contains "$logA" "-t pimcore/pimcore:php8.5-default-v5.1-hardened-amd64" "A copa invoked with full hardened image reference"`) that Copa is invoked **without** `-a`: - -```bash -assert_not_contains "$logA" " -a " "A copa invoked WITHOUT -a when BUILDKIT_ADDR unset (containerd store / default connection)" -``` - -Then add a new scenario at the end of the scenario list (after Scenario F, before the final pass/fail summary), covering the rollback path where an address **is** supplied: - -```bash -# Scenario G: BUILDKIT_ADDR set -> copa receives -a (rollback / standalone buildkitd path) -wG="$(mktemp -d)"; tmpdirs+=("$wG"); setup_variant "$wG" default -outG="$(BUILDKIT_ADDR=tcp://127.0.0.1:8888 GATE_SEVERITY=CRITICAL,HIGH STUB_FIXABLE=yes STUB_GATE=pass STUB_LOG="$wG/stub.log" run_gate "$wG" default)"; rcG=$? -[ "$rcG" = 0 ] && echo " ok: G exit 0" || { echo " FAIL: G exit $rcG"; fail=1; } -logG="$(cat "$wG/stub.log" 2>/dev/null)" -assert_contains "$logG" "-a tcp://127.0.0.1:8888" "G copa receives -a when BUILDKIT_ADDR set" -``` - -- [ ] **Step 2: Run the tests to verify the new assertions fail** - -Run: `.github/scripts/tests/run.sh` -Expected: FAIL — the current script always defaults `BUILDKIT_ADDR` to `tcp://127.0.0.1:8888`, so Scenario A's log **contains** `-a` (new `assert_not_contains` fails). Scenario G passes already (address happens to match the old default), but it must remain green after the fix. - -- [ ] **Step 3: Change the default to empty** - -In [.github/scripts/scan-patch-gate.sh:14](.github/scripts/scan-patch-gate.sh#L14): - -```bash -BUILDKIT_ADDR="${BUILDKIT_ADDR:-}" -``` - -(was `BUILDKIT_ADDR="${BUILDKIT_ADDR:-tcp://127.0.0.1:8888}"`) - -- [ ] **Step 4: Build the `-a` argument conditionally** - -Replace [.github/scripts/scan-patch-gate.sh:45-46](.github/scripts/scan-patch-gate.sh#L45-L46) — currently: - -```bash - copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" -a "${BUILDKIT_ADDR}" \ - || fail_gate "Copa patch failed" -``` - -with: - -```bash - # Pass -a only when an address is configured. With the containerd image store - # enabled, BUILDKIT_ADDR is unset and Copa uses its default connection - # (docker driver -> dockerd's embedded BuildKit), which sees the local image. - # Setting BUILDKIT_ADDR (e.g. a standalone buildkitd) restores the -a path. - copa_addr=() - [ -n "${BUILDKIT_ADDR}" ] && copa_addr=(-a "${BUILDKIT_ADDR}") - copa patch -i "${PLAIN_IMAGE}" -r "$report" -t "${HARDENED_IMAGE}" "${copa_addr[@]}" \ - || fail_gate "Copa patch failed" -``` - -Note: the script runs under `set -euo pipefail`; on the runner's bash 5.x `"${copa_addr[@]}"` with an empty array expands to nothing without tripping `set -u`. - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `.github/scripts/tests/run.sh` -Expected: PASS — all scenarios green, including Scenario A (no `-a`) and Scenario G (`-a tcp://127.0.0.1:8888`). - -- [ ] **Step 6: Shellcheck the script** - -Run: `shellcheck .github/scripts/scan-patch-gate.sh` -Expected: no new findings (clean, or unchanged from baseline). - -- [ ] **Step 7: Commit** - -```bash -git add .github/scripts/scan-patch-gate.sh .github/scripts/tests/run.sh -git commit -m "scan-patch-gate: pass copa -a only when BUILDKIT_ADDR is set - -Default connection (docker driver under the containerd image store) sees -the locally built plain image, so no standalone buildkitd address is needed. -Setting BUILDKIT_ADDR restores the -a path for rollback." -``` - ---- - -### Task 2: `release.yml` — enable the containerd image store; drop the standalone buildkitd - -**Files:** -- Modify: [.github/workflows/release.yml](.github/workflows/release.yml) — add one step (~line 75, before `Set up Docker Buildx`), delete two steps (`Start buildkit daemon` ~248-269, `Stop buildkit daemon` ~354-356), edit the gate step (remove the `BUILDKIT_ADDR` export ~288). - -**Interfaces:** -- Consumes: `matrix.build.hardened` (bool). The `Install Copa` step and `Scan, patch, and gate hardened images` step are unchanged except for the removed export. -- Produces: on hardened legs, a daemon running the containerd image store before any build; `scan-patch-gate.sh` invoked with `BUILDKIT_ADDR` unset (Task 1's default-connection path). - -- [ ] **Step 1: Add the `Enable containerd image store` step** - -Insert immediately **after** the `Check out CI scripts from the workflow ref` step and **before** `Set up Docker Buildx` (around [.github/workflows/release.yml:75](.github/workflows/release.yml#L75)). It must run before any builder is created, because it restarts the daemon: - -```yaml - - name: Enable containerd image store - if: ${{ matrix.build.hardened }} - run: | - set -euxo pipefail - # Copa's mergeop/diffop (required to patch) are only available with the - # containerd image store backend, which also gives dockerd's embedded - # BuildKit a shared image store. With it enabled, Copa's default connection - # patches the locally built plain image -- no registry round-trip and no - # standalone buildkitd. Enable it on the hardened legs only, before any - # builder is created (this restarts the daemon). - sudo mkdir -p /etc/docker - if [ -s /etc/docker/daemon.json ]; then - existing="$(sudo cat /etc/docker/daemon.json)" - else - existing='{}' - fi - printf '%s' "$existing" \ - | jq '.features = ((.features // {}) + {"containerd-snapshotter": true})' \ - | sudo tee /etc/docker/daemon.json >/dev/null - sudo systemctl restart docker - # Wait for the daemon to come back up. - for i in $(seq 1 30); do - if docker info >/dev/null 2>&1; then break; fi - if [ "$i" -eq 30 ]; then - echo "::error::Docker did not come back after restart" - exit 1 - fi - sleep 1 - done - # Verify the containerd snapshotter storage backend is active. - if ! docker info | grep -q 'io.containerd.snapshotter'; then - echo "::error::containerd image store is not active after restart" - docker info || true - exit 1 - fi -``` - -- [ ] **Step 2: Delete the `Start buildkit daemon` step** - -Remove the entire step at [.github/workflows/release.yml:248-269](.github/workflows/release.yml#L248-L269) (`- name: Start buildkit daemon` … through the closing `done` of its readiness loop). The standalone buildkitd is no longer used. - -- [ ] **Step 3: Delete the `Stop buildkit daemon` step** - -Remove the entire step at [.github/workflows/release.yml:354-356](.github/workflows/release.yml#L354-L356): - -```yaml - - name: Stop buildkit daemon - if: ${{ always() && matrix.build.hardened }} - run: docker stop buildkitd || true -``` - -- [ ] **Step 4: Remove the `BUILDKIT_ADDR` export from the gate step** - -In the `Scan, patch, and gate hardened images` step, delete [.github/workflows/release.yml:288](.github/workflows/release.yml#L288): - -```bash - export BUILDKIT_ADDR="tcp://127.0.0.1:8888" -``` - -Leave the preceding `export IMAGE_NAME GATE_SEVERITY ARCH_TAG TRIVY_DB_REPOSITORY` line intact. With `BUILDKIT_ADDR` unset, `scan-patch-gate.sh` (Task 1) invokes Copa on its default connection. - -- [ ] **Step 5: Lint the workflow** - -Run: -```bash -actionlint .github/workflows/release.yml -shellcheck -e SC2016 - <<'SH' -$(sed -n '/name: Enable containerd image store/,/verify the containerd/p' .github/workflows/release.yml) -SH -``` -Expected: `actionlint` clean. (The `shellcheck` line is a convenience — the authoritative check is CI's "Lint workflows" job, which runs `actionlint -color` and picks up shellcheck on the embedded `run:` blocks. If `actionlint` is not installed locally, install it: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` or download the release binary used in [.github/workflows/test.yml:89](.github/workflows/test.yml#L89).) - -- [ ] **Step 6: Sanity-check the YAML structure** - -Run: -```bash -grep -n 'Enable containerd image store\|Start buildkit daemon\|Stop buildkit daemon\|BUILDKIT_ADDR\|Set up Docker Buildx' .github/workflows/release.yml -``` -Expected: `Enable containerd image store` appears once (before the first `Set up Docker Buildx`); `Start buildkit daemon` and `Stop buildkit daemon` are **gone**; no `BUILDKIT_ADDR` reference remains in `release.yml`. - -- [ ] **Step 7: Commit** - -```bash -git add .github/workflows/release.yml -git commit -m "release: enable containerd image store on hardened legs, drop standalone buildkitd - -Copa now patches the locally built plain image via dockerd's embedded -BuildKit (containerd store), so a publish:false dispatch is a full hardened -dry-run and real runs no longer depend on a registry round-trip." -``` - ---- - -### Task 3: Docs — resolve the I4 note and document the dry-run mode - -**Files:** -- Modify: [docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md](docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md) (I4 "Copa image source" note) -- Modify: [README.md](README.md) (hardened images section) - -**Interfaces:** none (documentation only). - -- [ ] **Step 1: Locate the I4 note in the 2026-07-02 spec** - -Run: `grep -n 'I4\|Copa image source\|registry' docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md` -Read the surrounding lines to get the exact current wording. - -- [ ] **Step 2: Update the I4 note** - -Change the I4 "Copa image source" note from its "open / needs validation" wording to resolved. Replace the note's status/body with: - -```markdown -**I4 — Copa image source (RESOLVED 2026-07-17):** Copa no longer pulls the target -from the registry. The hardened legs enable Docker's containerd image store, so -Copa's default connection (dockerd's embedded BuildKit) patches the **locally built** -plain image directly. Consequences: `publish: false` is a full hardened dry-run -(build + patch + gate + SBOM, zero pushes), and real runs no longer depend on plain -being pushed before the gate. See -`docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md`. -``` - -Match the surrounding heading style found in Step 1 (adjust the `**…**` / `###` prefix to whatever the file uses for the other findings). - -- [ ] **Step 3: Locate the hardened section in README** - -Run: `grep -n 'Hardened\|publish_hardened\|workflow_dispatch\|dry' README.md` -Read the hardened images section. - -- [ ] **Step 4: Add a dry-run note to the README hardened section** - -Add a short sentence to the hardened images section (near the `publish_hardened` explanation) stating the dry-run capability. Use wording consistent with the section's existing voice; the content must be: - -```markdown -> **Testing the hardened path without publishing:** trigger the release workflow via -> **workflow_dispatch** with `publish: false`. The stable images are built, Copa-patched, -> scanned, and gated entirely on the runner (using the containerd image store) — **nothing -> is pushed** to Docker Hub or GHCR. Use `publish: true` with `publish_hardened: false` to -> publish the plain tags while still building and gating the hardened images locally. -``` - -- [ ] **Step 5: Verify the docs read correctly** - -Run: `grep -n 'RESOLVED 2026-07-17\|dry-run\|publish: false' docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md README.md` -Expected: the I4 note shows RESOLVED; README shows the dry-run note. - -- [ ] **Step 6: Commit** - -```bash -git add docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md README.md -git commit -m "docs: resolve I4 (Copa patches local image via containerd store); document publish:false dry-run" -``` - ---- - -## Validation (live, after all tasks — user-gated, not part of task commits) - -Per the spec's validation gate — run these before relying on the change for the scheduled cadence: - -1. `workflow_dispatch` on `image_copa` with `publish: false` → hardened legs enable the containerd store, build, Copa-patch, gate, and produce SBOMs with **zero** pushes; the deferred gate step reports pass/fail. - - **Confirm Copa patched the *local* image, not a registry pull.** A green gate alone is not proof: the stable plain tags already exist in the registry from prior runs, so a registry-pulling Copa would silently patch the previously published image and still pass. Run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, or raise its log level) and confirm the log shows the **docker driver** connected (it must *not* print `Could not use docker driver` and fall through to buildx/buildkitd). Per Copa v0.14.1 `autoClient`, the docker driver is tried first and, with the containerd store active, is the one used. (Strongest check: also compare the locally built plain image's digest against the base of the patched hardened image.) -2. `workflow_dispatch` with `publish: true, publish_hardened: false` → plain tags published, hardened built + gated but **not** pushed. -3. Only after both pass: allow the scheduled cadence to exercise it. - -**Contingency (not expected — driver selection is source-verified).** Copa v0.14.1 tries the docker driver first and it is independent of the selected buildx builder (`pkg/buildkit/drivers.go`, `connhelpers/docker.go`), so the isolated `docker-container` builder should never be chosen. If the debug-log check above nonetheless shows Copa failing over off the docker driver, pin the builder with `docker buildx use default` before the gate loop, or fall back to the local-`registry:2` sidecar (spec Rollback). - -## Self-Review - -- **Spec coverage:** containerd-store enable step (Task 2/Step 1) ✓; drop buildkitd (Task 2/Steps 2-3) ✓; conditional `-a` / default connection (Task 1) ✓; docs I4 + README dry-run (Task 3) ✓; `publish:false` = dry-run with no new input (Constraints + Task 3) ✓; stub test for `-a` present/absent (Task 1/Step 1) ✓; validation gate (Validation section) ✓; rollback (Validation note + commit messages reference it) ✓. -- **Placeholder scan:** none — every code/edit step shows exact text. -- **Type/name consistency:** `BUILDKIT_ADDR`, `copa_addr`, `matrix.build.hardened`, step names match across tasks and the current file. 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 deleted file mode 100644 index 3163578..0000000 --- a/docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# Design: `-hardened` tag for Copa-patched images - -**Date:** 2026-06-15 -**Status:** Approved -**Affected files:** `.github/workflows/release.yml`, `README.md` - -## Problem - -Today, for every matrix build marked `imagePatch: true` (the stable releases: -`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`), the release workflow scans the freshly -built image with Trivy, patches OS-level CVEs with Copa, and then **replaces the -plain image in place** under the same tags (`release.yml` lines ~191–219). The -patched image is retagged as the original tag, the original is deleted, and all -downstream tags point at the patched bytes. - -Consequence: users have no way to pull the un-patched ("plain") image for those -releases — Copa hardening is mandatory and invisible. We want users to choose: - -- `php8.5-debug-v5` — the plain image, exactly as built from the Dockerfile. -- `php8.5-debug-v5-hardened` — the Copa-patched ("hardened") image. - -## Decisions (confirmed with maintainer) - -1. **Default tag = plain.** The unsuffixed tag (`php8.5-debug-v5`) is the - un-patched image. The hardened image gets a `-hardened` suffix. Existing - pullers of the unsuffixed tag will receive the plain image going forward - (they lose the implicit auto-patching they get today). -2. **Scope = only `hardened: true` builds.** Dev/rolling tags (`1.x`, `2.x`, - `3.x`, `4.x`, `5.x`, and all `*-dev` overrides) remain plain-only, exactly as - today. No `-hardened` variant is produced for them. -3. **Severity gate applies to the hardened image only.** The plain image is - published as-is and may carry known CVEs; only the hardened image must pass - the `fail_on_severity` gate (`CRITICAL,HIGH` by default). -4. **Gate ordering = all-or-nothing per variant.** The hardened gate runs - *before any push*. If the hardened image cannot pass the gate, neither the - plain nor the hardened tags are published for that image variant — preserving - the current "failed gate = nothing ships" contract. - -> **Superseded 2026-07-02** (see `2026-07-02-copa-plain-always-publish-sbom-design.md`): -> the gate no longer blocks plain publishing. Plain images always publish; a hardened -> gate failure skips only that variant's `-hardened` tags and turns the job red at the end. - -## Tag scheme - -The `-hardened` marker is inserted **before** the internal `-amd64` / `-arm64` -architecture suffix. This lets the existing `process-tags` job (which strips the -arch suffix and creates a multi-arch manifest) produce `…-hardened` manifests -with no changes to that job. - -For a `hardened: true` build, both tag sets are produced and pushed: - -| Tag role | Plain (default, unchanged) | Hardened (new) | -|-----------------|----------------------------|---------------------------------------| -| primary | `php8.5-debug-v5` | `php8.5-debug-v5-hardened` | -| detailed (PHP) | `php8.5.3-debug-v5` | `php8.5.3-debug-v5-hardened` | -| latest | `php8.5-debug-latest` | `php8.5-debug-latest-hardened` | -| major | `php8.5-debug-v5`* | `php8.5-debug-v5-hardened`* | - -(*) major tag only when `version-override` is empty and version matches `vN.N`, -per existing logic. Internally every tag above carries an `-amd64`/`-arm64` -suffix that the manifest job merges away. - -For `hardened: false` builds: only the plain set is produced (unchanged). - -## Build flow (per image variant, inside the existing loop) - -1. **Build plain image** as today (`docker build --load … --target …`), tagged - as the plain primary `${IMAGE_NAME}:${TAG}`. **Remove the current in-place - patch-and-replace logic** so the plain tag keeps the un-patched bytes. -2. **Construct the plain tag list** exactly as today (primary, detailed, GHCR - mirrors, `-latest` when `latest-tag: true`, major when applicable). -3. **If `hardened: true`** — derive the hardened image *from the plain build* - (no second `docker build`): - - Run Trivy (`--pkg-types os --ignore-unfixed`) against the plain image. - - If fixable OS vulnerabilities exist, run `copa patch` to produce the - hardened image and tag it as the hardened primary. - - If no fixable OS vulnerabilities exist, `docker tag` the plain image as the - hardened primary (same content) so the `-hardened` tag always exists for - these builds. - - Construct the hardened tag list = the plain tag list with `-hardened` - inserted before the arch suffix. -4. **Severity gate** runs on the hardened image only (when `hardened: true` - and `fail_on_severity != NONE`), *before any push*. On failure the step - aborts (`set -e`), so nothing ships for the variant. Trivy reports and the - GitHub step-summary continue to be produced from the hardened image. -5. **Apply tags** — plain tags to the plain image, hardened tags to the hardened - image. -6. **Push** (when `PUSH == true`) both tag sets. -7. **Aggregate** both plain and hardened logical tags (arch suffix stripped) into - `aggregated_tags.txt` for the `process-tags` manifest job. -8. **Cleanup** both images to reclaim disk, as today. - -## Unchanged components - -- **`process-tags` job** — no changes. It dedups aggregated tags and creates a - multi-arch manifest per logical tag; hardened logical tags flow through the - same arch-stripping path automatically. -- **`test.yml`** — builds and scans images locally without publishing or tagging - hardened variants; no changes. -- **Dockerfile** — no changes; hardening is a post-build Copa step, not a build - target. - -## Documentation - -Add a short **"Hardened images"** section to `README.md` that: -- Explains the two tag flavors: unsuffixed = plain (built from the Dockerfile), - `-hardened` = Copa-patched for OS-level CVEs. -- States that `-hardened` is available only for stable release tags. -- Gives guidance on when to pick each (e.g. hardened for production / - vulnerability-scanned environments; plain for reproducibility or when you run - your own patching pipeline). - -## Out of scope (YAGNI) - -- No `-hardened` variant for dev/rolling images. -- No new workflow input to toggle hardened production; it follows the existing - `hardened` matrix flag. -- No changes to the gate's default severities or report formats. diff --git a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md b/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md deleted file mode 100644 index 5e130bc..0000000 --- a/docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md +++ /dev/null @@ -1,321 +0,0 @@ -# Design: plain-always-publish gate, SBOM restoration, and hardened package docs - -**Date:** 2026-07-02 -**Status:** Approved (pending user review) -**Branch:** `image_copa` (PR #247) -**Affected files:** `.github/workflows/release.yml`, `README.md`, -`.github/scripts/scan-patch-gate.sh` (new), `.github/scripts/attach-sbom.sh` (new), -`.github/scripts/tests/` (new, stub-driven tests), -`.github/scripts/generate-package-docs.sh` (new, follow-up PR), -`docs/hardened-packages/` (new, CI-generated, follow-up PR), -`docs/superpowers/specs/2026-06-15-hardened-image-tag-design.md` (decision 4 superseded) - -**Implementation note:** the per-variant scan/patch/gate loop (Part 1) and the `oras` -attach (Part 2) are extracted into small scripts under `.github/scripts/` so the workflow -steps stay thin and the behavior is unit-testable with stubbed `trivy`/`copa`/`docker`/ -`oras` on `PATH`. Severity normalisation stays inline in the step (it runs once, before -the loop). - -## Problem - -Review of the current `image_copa` workflow against the maintainer's requirements found -four gaps: - -1. **Gate failure blocks plain publishing.** The post-patch severity gate `exit 1`s inside - the `Scan, patch, and gate hardened images` step, killing the job before `Tag, push, - and aggregate` runs. When the hardened image still carries CRITICAL/HIGH CVEs, neither - plain nor hardened tags publish for that matrix entry — and variants after the failing - one in the loop are lost too. Requirement: **plain images must always publish as-is, - even when they contain CVEs.** (This supersedes decision 4 — "all-or-nothing" — of the - 2026-06-15 spec.) -2. **Matrix and manifest fragility.** `strategy.fail-fast` defaults to `true`, so one - failing leg cancels all in-progress legs. And `process-tags` (`needs: build-php`, - no `always()`) is skipped entirely if any leg fails — no multi-arch manifests get - created for *any* line, even ones that passed. -3. **SBOM regression (compliance).** On `5.x`, `docker buildx build --sbom=true --output - type=image,push=$PUSH` attaches an SPDX SBOM attestation to every pushed image. The - Copa restructure switched to `docker build --load` (required so Copa can patch the - local image) and silently dropped SBOM generation. **SBOMs are a legal requirement for - the published images.** Additionally, Copa-patched images never had SBOMs — Copa does - not produce or update attestations — so the `-hardened` flavor needs its own SBOM - regardless. -4. **No package/versions documentation.** Nothing records which libraries each image - contains, or what the `-hardened` flavor changed versus plain. - -Confirmed as already correct (no change): plain images are never patched; every variant -(min/default/max/debug/supervisord) of a `hardened: true` entry gets the full `-hardened` -tag set; the `-hardened` tag is created even when nothing was fixable (mirrors plain). - -## Decisions (confirmed with maintainer, 2026-07-02) - -1. **Scope stays stable-only.** `-hardened` is produced only for `hardened: true` matrix - entries (`v1.6`, `v2.3`, `v3.8`, `v4.2`, `v5.2`). Dev/rolling lines stay plain-only. -2. **Gate policy: publish plain, skip hardened, job red.** Plain tags always publish. A - variant whose hardened image fails the gate (or whose scan/patch errors) does not get - its `-hardened` tags pushed; other variants continue; the job ends red — after pushes - and artifact uploads — so maintainers notice. -3. **SBOMs are required by law and Trivy-generated SBOMs satisfy the requirement.** - Generated for **all** published images (plain for every matrix entry, hardened where - produced), per architecture. -4. **Package docs are committed MD files** in the repo, derived from the SBOMs. - -## Scope & sequencing (confirmed 2026-07-02) - -This spec lands in two PRs: - -- **PR #247 (this work):** Part 1 (gate restructure + resilience), Part 2 (SBOM - generation + oras attachment), Part 4 (README), Part 5 (spec supersession). These are - the must-haves — they unblock publishing and satisfy the SBOM legal requirement. -- **Follow-up PR:** Part 3 (the `publish-package-docs` self-committing job + generator - script). It is the riskiest, non-blocking piece (bot commits, push token, race - handling) and depends only on the SBOM artifacts that Part 2 already produces, so it can - land independently without touching the publish path again. - -The implementation plan for this cycle therefore covers Parts 1, 2, 4, and 5. Part 3 is -specified here for continuity but planned/implemented separately. - -## Part 1 — Gate restructure (`release.yml`) - -### Scan, patch, and gate step - -Per variant, replace every hard `exit 1` (gate findings, Copa failure, missing hardened -image, Trivy scan error) with: - -- write a marker file `.docker-state//gate_failed.txt` containing a one-line - reason, -- do **not** write `hardened_image.txt` / `hardened_tags.txt` for that variant (the push - step keys off `hardened_image.txt`), -- emit `::error::` and append the failure to `$GITHUB_STEP_SUMMARY`, -- `continue` to the next variant. - -The step itself always exits 0. The existing severity normalisation (`GATE_SEVERITY`) -and Trivy report artifacts are unchanged. - -### Publish ordering — plain ships *before* the gate (revised 2026-07-02) - -To make "plain always ships" ironclad — not merely "ships unless the gate step hits an -unforeseen error" — the single combined push step is split so the plain push happens -**before** the scan/patch/gate step, and hardened is pushed **after** it. New `build-php` -step order on a hardened leg: - -1. **Build plain images** — builds every variant, writes state + plain SBOMs (unchanged). -2. **Push plain images** (`if PUSH`) — tag + push the plain tag set, attach the plain - SBOM, aggregate the plain logical tags. Runs right after the build and depends only on - it, so the gate can never prevent plain from shipping. Does **not** `docker rmi` (the - gate still needs the plain image on hardened legs). -3. **Scan, patch, and gate hardened images** (`if hardened`) — Copa builds the hardened - image from the already-pushed plain image and gates it; per-variant markers as above; - step exits 0. -4. **Push hardened images** (`if hardened`, default `success()`) — for each variant that - has `hardened_image.txt`, tag + push the hardened tag set, attach the hardened SBOM, - aggregate the hardened logical tags. Because it defaults to `success()`, an *unforeseen - crash* of the gate step skips hardened push (plain already shipped, job goes red from - the crash); a normal gate *failure* (fail_gate → exit 0) still runs this step, which - simply skips the failed variants (no `hardened_image.txt`). -5. **Cleanup images** (`if: always()`) — `docker rmi` the plain and hardened images for - every variant, reclaiming disk regardless of outcome. - -Outcomes: -- Build fails → nothing pushed (can't publish what wasn't built). -- Build ok, gate step crashes → **plain already pushed**; hardened skipped; job red. -- Build ok, gate fail_gate on a variant → plain pushed; that variant's `-hardened` skipped - and left at its previously published state; other variants' hardened pushed; job red via - the deferred fail step. -- All pass → plain + hardened pushed; green. - -Aggregation: both push steps append their logical tags (arch suffix stripped) to -`aggregated_tags.txt`; gate-failed variants contribute no hardened tags, so `process-tags` -never sees them. - -### New final step: `Fail if severity gate failed` - -Last step of the job (after `Stop buildkit daemon`, `Upload trivy reports`, `Upload -aggregated tags`): - -```sh -if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then - grep -H . .docker-state/*/gate_failed.txt - echo "::error::One or more variants failed the severity gate; their -hardened tags were not published" - exit 1 -fi -``` - -Runs only for `hardened: true` entries (`if: ${{ matrix.build.hardened }}`). - -### Resilience fixes - -- `strategy.fail-fast: false` on the `build-php` matrix. -- `process-tags`: `if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.publish) }}`. - Its existing per-arch existence check (`docker buildx imagetools inspect`, skip with - message when an arch is missing) already handles asymmetric outcomes — e.g. amd64 passes - the gate but arm64 fails → no new multi-arch `-hardened` manifest; the previously - published one stays. The pushed single-arch `-hardened-amd64` tag is harmless and - overwritten next run. - -## Part 2 — SBOM generation and publication - -### Generation - -- **Trivy is installed on every leg** (split the current install step: Trivy - unconditional; Copa + BuildKit daemon remain `if: matrix.build.hardened`). -- After building each plain image, and after each hardened image **passes the gate** - (gate-failed variants get no hardened SBOM — absence is the machine-readable signal the - docs job keys off): - `trivy image --format spdx-json -o sboms/.spdx.json ` (SPDX to match what - the `5.x` buildx attestation emitted). Runs on **both arch legs** — SBOMs are per-arch, - as buildx attestations were. -- Upload `sboms/` as a per-leg artifact (`sboms____...`), `if: always()`. - -### Registry attachment (durable, per-image) - -After the pushes of a variant complete, attach that image's SBOM as an OCI referrer — -**once per image digest per registry** (all tags of an image share the digest, so one -attach on the primary tag covers them; repeat for the GHCR mirror): - -```sh -oras attach --artifact-type application/spdx+json "" "sboms/.spdx.json" -``` - -- `oras` installed via pinned release binary with checksum verification (same pattern as - the Copa install). -- Attachment is **non-fatal** (`|| echo "::warning::..."`): GHCR supports OCI referrers; - Docker Hub support is newer — a registry rejecting referrers must not break publishing. - The artifact upload is the guaranteed fallback in that case. -- Referrers bind to digests, so they survive the `imagetools create` manifest merge in - `process-tags` (per-arch digests remain referenced by the multi-arch manifest). - -This restores the `5.x` guarantee (SBOM attached to every pushed image) and extends it to -the `-hardened` flavor, which the buildx attestation could never cover. - -## Part 3 — Hardened package docs (committed MD) — FOLLOW-UP PR - -> Not in PR #247. Specified here for continuity; planned and implemented separately. -> Consumes the SBOM artifacts produced by Part 2, so it needs no further change to the -> publish path. - -### Data flow - -1. The **amd64 leg** of each `hardened: true` entry already has, per variant, the plain - and hardened SPDX SBOMs in `sboms/` (from Part 2). No extra scanning needed. -2. New job **`publish-package-docs`** (after `build-php`; `if: ${{ always() && - (github.event_name != 'workflow_dispatch' || inputs.publish) && github.repository == - 'pimcore/docker' }}`; `permissions: contents: write`): - - checks out the repository **default branch** (not a matrix ref), - - downloads the amd64 `sboms_*` artifacts of hardened entries, - - runs `.github/scripts/generate-package-docs.sh` (jq over SPDX `packages[]` - name/versionInfo) to write one file per hardened matrix entry: - `docs/hardened-packages/-php.md` (e.g. - `docs/hardened-packages/v5.2-php8.5.md`), - - commits and pushes with the default `GITHUB_TOKEN` (bot pushes do not re-trigger - workflows); commit message `Update hardened image package docs`; no-op when nothing - changed; one `git pull --rebase` retry on push rejection. - -### Document format (per file) - -- Header: generation timestamp (UTC), source image tags + digests, arch note - ("amd64; arm64 package versions may differ marginally"). -- Per variant (min/default/max/debug/supervisord): - - **"Packages changed by hardening"** table: `package | plain version | hardened - version` — the Copa delta, empty-state text when hardening changed nothing. - - Collapsible (`
`) **full inventory** table: `package | plain | hardened`, - one row per package union, `–` when absent from a flavor. -- Gate-failed variants: their hardened SBOM is absent by construction (Part 2), so the - generator writes those sections from the plain SBOM only, with the note: "hardened tag - not updated this run (severity gate failed)". Variants with both SBOMs get the full - diff. - -## Part 4 — README update - -Rewrite the `## Hardened images` section: - -- **What Copa does:** after the plain image is built, it is scanned with Trivy; Copa - applies the available Debian security fixes for OS-level packages as an additional - image layer. PHP, extensions, and application-level content are byte-identical to the - plain image — only OS package versions differ. -- **Scope:** `-hardened` exists for stable release tags only; `-dev` tags are plain-only. -- **Gate semantics:** plain tags always publish. Hardened tags publish only when the - patched image passes the `fail_on_severity` gate (default `CRITICAL,HIGH`, threshold - semantics); when the gate fails, the `-hardened` tag temporarily lags behind plain until - a fix is available upstream. -- **Usage:** pull examples (`php8.5-debug-v5-hardened`), guidance on when to choose each - flavor. -- **SBOMs & package docs:** every published image has an SPDX SBOM (registry referrer + - CI artifact); link to `docs/hardened-packages/` for the per-image package inventories - and hardening deltas. - -## Part 5 — Spec supersession - -Add a note to `2026-06-15-hardened-image-tag-design.md` under decision 4: superseded by -this spec (plain-always-publish, deferred red). No other edits to the old spec. - -## Out of scope (YAGNI) - -- No `-hardened` for dev/rolling lines. -- No buildx attestation restoration (`--sbom=true` cannot survive `--load`; re-pushing via - buildx would risk publishing bytes that differ from the gated image). The Trivy SBOM + - `oras` referrer replaces it. A hybrid (containerd image store so `--load` keeps - attestations for plain, Trivy for hardened) was considered and declined on 2026-07-02: - it needs a daemon-reconfig spike, keeps two SBOM mechanisms permanently, and — since - even `5.x` only carries attestations on per-arch tags — buys no extra coverage over the - referrer approach. -- No SBOM signing (cosign) — can be layered on later if compliance requires signatures. -- No package docs for plain-only (dev) lines; their SBOMs exist as artifacts/referrers. -- No change to gate defaults, severity normalisation, or Trivy report artifacts. - -## Testing - -- **Workflow lint:** `bash -n` on every extracted `run:` block; YAML parse check. -- **Gate logic:** unit-test the marker/continue flow by extracting the loop into a script - with stubbed `trivy`/`copa`/`docker` (failing variant 2 of 3 → variants 1 and 3 push - plain+hardened, variant 2 plain only, final step exits 1). -- **SBOM:** assert `trivy image --format spdx-json` produces a valid SPDX file with - `packages[].versionInfo` populated for a sample image; confirm `oras attach` failure is - swallowed with a warning (stub a rejecting registry). -- **Docs generator (follow-up PR):** run `.github/scripts/generate-package-docs.sh` - against two fixture SPDX files (differing versions, added/removed package) and assert the - MD output. -- **Live validation:** `workflow_dispatch` with `publish: false` builds, patches, gates, - and generates SBOMs without pushing; the docs job is skipped (publish-gated), validated - on the first real publish run. - -## Hardened-publish rollout gate (`publish_hardened`, 2026-07-16) - -A `publish_hardened` `workflow_dispatch` input (boolean, default `false`) gates **publishing** -of the `-hardened` tags, independently of plain publishing: - -- Hardened images are **always built, scanned, patched, and gated** for `hardened: true` - matrix entries (unchanged) — the input only controls the registry push. -- `-hardened` tags are pushed **only when** `github.event_name == 'workflow_dispatch' && - inputs.publish && inputs.publish_hardened`. So: - - **Scheduled / tag-push runs push plain only** — hardened is built + gated but not - published until someone opts in. (Deliberate safe rollout; revisit the formula once - hardened is validated in production.) - - A **`publish=true, publish_hardened=false` dispatch** publishes plain and exercises the - full hardened build/gate (Copa patches the locally built plain image via the containerd - store, so the gate is accurate — see I4 below) without pushing `-hardened` — the intended - test mode. -- The deferred "Fail if severity gate failed" step still runs whenever hardened is built, - so a gate failure turns the job red even on a non-publishing run (honest signal that - patching left CVEs). README describes the two-flavor scheme as the target state; until - `publish_hardened` is enabled, `-hardened` tags are not refreshed in the registries. - -## Post-review notes (2026-07-02, after the multi-dimension branch review) - -The exhaustive branch review surfaced two items that are **not** code changes but must be -recorded: - -- **I4 — Copa image source (RESOLVED 2026-07-17):** Copa no longer pulls the target - from the registry. The hardened legs enable Docker's containerd image store, so - Copa's default connection (dockerd's embedded BuildKit) patches the **locally built** - plain image directly. Consequences: `publish: false` is a full hardened dry-run - (build + patch + gate + SBOM, zero pushes), and real runs no longer depend on plain - being pushed before the gate. See - `docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md`. -- **Rollout / trigger scope (I5).** `schedule:` runs use the workflow file on the - **default branch**, and `push: tags:` runs use the file at the pushed tag. The `_ci` - checkout resolves scripts from `github.sha` (the workflow's own commit), so the pipeline - is correct for whatever ref actually runs it — but the new pipeline only takes effect for - the scheduled/tag cadence once this change (workflow **and** `.github/scripts/`) has - landed on the default branch and been forward-merged along the active line chain. - Until then, scheduled publishes keep running the old pipeline. This must be part of the - merge/rollout plan, not just the PR merge. diff --git a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md b/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md deleted file mode 100644 index ecbbc6b..0000000 --- a/docs/superpowers/specs/2026-07-17-containerd-store-local-copa-design.md +++ /dev/null @@ -1,149 +0,0 @@ -# Design: containerd image store so Copa patches locally (test hardened without publishing) - -**Date:** 2026-07-17 -**Status:** Approved (pending user review) -**Branch:** `image_copa` (PR #247) -**Affected files:** `.github/workflows/release.yml`, `.github/scripts/scan-patch-gate.sh`, -`README.md`, `docs/superpowers/specs/2026-07-02-copa-plain-always-publish-sbom-design.md` -(I4 note) - -## Problem (proven, 2026-07-17 spike) - -The hardened path runs Copa against a **standalone tcp buildkitd** container -(`-a tcp://127.0.0.1:8888`). A spike with the workflow's exact setup, on a local -never-pushed image, showed Copa **pull the target from the registry**: - -``` -Patching: linux/amd64 -> docker.io/library/spiketest:patchedB -… GET https://index.docker.io/v2/library/spiketest/manifests/local: UNAUTHORIZED -``` - -Consequences: -- On `publish: false` (the default dispatch), the freshly built plain image is in neither - the registry nor the standalone buildkit's store, so **Copa cannot patch it** — a - dry-run cannot exercise the hardened path (this is finding "I4"). -- Real runs work only because plain is pushed *before* the gate, so Copa pulls the - just-pushed image. - -The spike also showed *why* the standalone buildkitd exists: Copa's required `mergeop` / -`diffop` are "only enabled with the containerd image store backend," which the default -Docker daemon lacks. - -**Goal:** let Copa patch the **locally built** plain image, so the hardened path can be -exercised with **zero pushes** (`publish: false`), and so real runs no longer depend on a -registry round-trip. - -## Decision (confirmed with maintainer 2026-07-17) - -1. **Enable Docker's containerd image store on the hardened (stable) legs only.** This - gives dockerd's *embedded* BuildKit the `mergeop`/`diffop` Copa needs **and** a shared - image store, so Copa patches the local image directly. Dev/rolling legs keep the current - daemon/store, untouched (smaller blast radius). The change is daemon-wide per leg, so the - 5 stable legs' *plain* build/push also move to the containerd store. -2. **Validate via a `publish: false` dispatch before trusting it for scheduled publishing**; - keep the standalone-buildkitd approach documented as rollback. - -## Design - -`release.yml` (hardened legs only unless noted): - -1. **New step `Enable containerd image store`** — first step after the checkouts and before - `Set up Docker Buildx`, `if: ${{ matrix.build.hardened }}`: - - merge `{"features":{"containerd-snapshotter":true}}` into `/etc/docker/daemon.json` - (preserving any existing keys via `jq`), `sudo systemctl restart docker`, wait until - `docker info` responds, and verify the driver is `io.containerd.snapshotter.*`. -2. **Drop** `Start buildkit daemon` and `Stop buildkit daemon` (no standalone buildkitd). -3. **`scan-patch-gate.sh`:** invoke `copa patch` **without** `-a` when no address is - configured — i.e. append `-a "${BUILDKIT_ADDR}"` only when `BUILDKIT_ADDR` is non-empty. - The gate step stops exporting `BUILDKIT_ADDR`, so Copa uses its default connection, which - under the containerd store resolves to dockerd's embedded BuildKit and **sees local - images**. -4. **Copa uses the docker driver (dockerd's embedded BuildKit), not the isolated - `docker-container` buildx builder.** Verified against Copa v0.14.1 source - (`pkg/buildkit/drivers.go` `autoClient`): with no `-a`, Copa tries the **docker driver - first**, then the buildx driver, then the default buildkitd socket. The docker connhelper - (`pkg/buildkit/connhelpers/docker.go`) dials dockerd's `/grpc` endpoint directly (via - `DOCKER_HOST` / the docker context), so it is **independent of whichever builder - `docker buildx` has selected**. With the containerd store enabled, the docker driver passes - Copa's `CapMergeOp`/`CapDiffOp` validation and is the one used — the buildx - `docker-container` builder (a later fallback) is never reached, so **no builder pin is - required**. The `publish: false` validation run confirms this end-to-end on the runner. - -Everything else — plain build, plain push, the gate logic, hardened push (`PUSH_HARDENED`), -cleanup, SBOM/CVE data, `process-tags` — is unchanged. `process-tags` runs in its own job on -an unmodified runner and is unaffected (it operates on the registry). - -## Effect - -- Copa patches the local plain image on every hardened run → the gate and SBOM are valid - regardless of publishing; **I4 is resolved for real runs**. -- **`publish: false` → build + Copa patch + gate + SBOM entirely locally, pushing nothing** - = the "test the hardened path without publishing" mode, with **no new input**. -- `publish: true` + `publish_hardened: false` → plain published, hardened built + gated - locally, not pushed (as before, now with an accurate gate). -- `publish: true` + `publish_hardened: true` → plain + hardened published. - -## Validation gate (before relying on it for cron) - -1. `workflow_dispatch` with `publish: false` on `image_copa` — expect: hardened legs enable - the containerd store, build, Copa-patch, gate, and generate SBOMs, with **zero** pushes - to Docker Hub / GHCR; the deferred gate step reports pass/fail. - - **Confirm Copa patched the *local* image (not a registry pull):** a green gate alone is - **not** proof — the stable plain tags already exist in the registry from prior runs, so a - registry-pulling Copa would silently patch the previously published image and still pass. - Run Copa with debug logging for this validation (add `--debug` to the `copa patch` call, - or raise its log level) and confirm the log shows the **docker driver** connected — i.e. - it does *not* print `Could not use docker driver` and fall through to buildx/buildkitd. - (Since the containerd store is verified active in the enable step, "docker driver - connected" implies the local store was used. For the strongest check, also compare the - locally built plain image's digest against the base of the patched hardened image.) -2. `workflow_dispatch` with `publish: true, publish_hardened: false` — expect: plain tags - published, hardened built + gated but **not** pushed. -3. Only after both pass: allow the scheduled cadence to exercise it. - -## Rollback - -Revert commits 1–3: restore the `Start`/`Stop buildkit daemon` steps and the -`-a tcp://127.0.0.1:8888` Copa address, and remove the containerd-store step. Copa then -pulls the target from the registry, which requires plain to be pushed before the gate -(the pre-change behavior). Alternative if the containerd route proves flaky on the runners: -run a local `registry:2` sidecar reachable by a standalone buildkitd, push plain there, and -point Copa at it (Option 2 from the discussion) — keeps everything local, more plumbing. - -## Risk / uncertainty (explicit) - -- Copa's **driver selection** under the containerd store is verified from Copa v0.14.1 source - (see Design §4): the docker driver is tried first and, once the store is active, is the one - used — independent of the selected buildx builder. What the local spike could **not** prove - is the full **end-to-end** patch on a GitHub-hosted runner (the spike could not enable the - containerd store without disrupting the session daemon): that dockerd's embedded BuildKit - resolves the locally built image from the shared containerd store during a Copa patch. The - `publish: false` validation run is that end-to-end confirmation — see the Validation gate - for how to make the check un-foolable (a green gate alone is not proof). -- The 5 stable legs' plain build/push move to the containerd store; the containerd store is - the modern Docker default and supports `build --load`, `tag`, `push`, `buildx`, and - `manifest`, but the `publish: false` → `publish: true` validation sequence is what guards - the plain-publishing path against regressions. - -## Docs updates - -- `docs/…/2026-07-02-…-design.md`: update the I4 "Copa image source" note from - "open / needs validation" to "resolved by the containerd image store; Copa patches the - local image; `publish: false` is a full local test." -- `README.md`: note that a `workflow_dispatch` with `publish: false` performs a full - hardened dry-run (build + patch + gate) without publishing. - -## Out of scope (YAGNI) - -- Enabling the containerd store on dev/rolling legs. -- A separate `dry_run` input (the existing `publish: false` is the test mode). -- The local-registry sidecar (documented only as a fallback). - -## Testing - -- Workflow lint: `actionlint` + shellcheck on the changed `run:` blocks. -- `scan-patch-gate.sh`: the existing stub tests still pass; add/adjust a stub assertion that - Copa is invoked **without** `-a` when `BUILDKIT_ADDR` is unset (and with `-a` when set, to - keep the rollback path covered). -- Live: the two-step `publish: false` → `publish: true, publish_hardened: false` validation - dispatch above. From 34bce5ccf16aa8872be74d6dbcc8351209cba7e2 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:45:21 +0200 Subject: [PATCH 67/75] Default publish non-copa true --- .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 3f673fa..a810044 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: false + default: true type: boolean publish_hardened: description: 'Also publish the Copa-hardened (-hardened) tags. Requires publish=true. Off by default so hardened stays unpublished (built + scanned + gated but not pushed) on scheduled/tag runs and during testing, until explicitly enabled on a manual dispatch.' From e29607a5be3079e66c314826790ff2ce31aefcf2 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:50:52 +0200 Subject: [PATCH 68/75] release: restore hardened matrix flag (fix actionlint 'hardened not defined') The 5.x merge (c3586e2) reintroduced the full production matrix without the per-entry 'hardened' field, but 5 steps still gate on matrix.build.hardened -> actionlint failed (property not defined). Re-add hardened: true on the stable v* legs (v2.3, v3.8 x8.2/8.3, v4.2, v5.2) and hardened: false on the *.x dev legs, matching the README contract (-hardened is for stable release tags; -dev tags are plain-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a810044..378c14d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,16 +42,16 @@ jobs: - ubuntu-22.04 - ubuntu-22.04-arm build: - - { 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.2', 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: 'v5.2', php: '8.5', distro: trixie, version-override: "", latest-tag: true } - - { tag: '5.x', php: '8.5', distro: trixie, version-override: "v5-dev", latest-tag: false } + - { tag: 'v2.3', php: '8.2', distro: bullseye, version-override: "", latest-tag: false, hardened: true } + - { tag: '2.x', php: '8.2', distro: bullseye, version-override: "v2-dev", latest-tag: false, hardened: false } + - { tag: 'v3.8', php: '8.2', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: 'v3.8', php: '8.3', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: '3.x', php: '8.2', distro: bookworm, version-override: "v3-dev", latest-tag: false, hardened: false } + - { tag: '3.x', php: '8.3', distro: bookworm, version-override: "v3-dev", latest-tag: false, hardened: false } + - { tag: 'v4.2', php: '8.4', distro: bookworm, version-override: "", latest-tag: true, hardened: true } + - { tag: '4.x', php: '8.4', distro: bookworm, version-override: "v4-dev", latest-tag: false, hardened: false } + - { tag: 'v5.2', 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 From 8d771244a95bc0838700071bb47d6b53cc25d751 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:18:47 +0200 Subject: [PATCH 69/75] release: fix misleading gate-failure message (no publish claim on dry-run) The severity-gate failure error claimed '(plain images were published as-is)', which is false on a publish:false dry-run where nothing is pushed. Reword to state only that the -hardened tags were skipped and plain handling is unaffected -- accurate in both publish and dry-run modes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .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 378c14d..f95d833 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -391,7 +391,7 @@ jobs: if compgen -G '.docker-state/*/gate_failed.txt' > /dev/null; then echo "The following variants failed the severity gate; their -hardened tags were NOT published:" grep -H . .docker-state/*/gate_failed.txt - echo "::error::One or more variants failed the severity gate (plain images were published as-is)" + echo "::error::One or more variants failed the severity gate; only their -hardened tags were skipped (plain image handling is unaffected)" exit 1 fi echo "All hardened variants passed the severity gate." From 478a0260492d847c7a98a431c7a89e395518d9bd Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:13:22 +0200 Subject: [PATCH 70/75] Add spec: SBOM referrers on the logical multi-arch tags Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0-sbom-on-logical-multiarch-tags-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-sbom-on-logical-multiarch-tags-design.md diff --git a/docs/superpowers/specs/2026-07-20-sbom-on-logical-multiarch-tags-design.md b/docs/superpowers/specs/2026-07-20-sbom-on-logical-multiarch-tags-design.md new file mode 100644 index 0000000..f040190 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-sbom-on-logical-multiarch-tags-design.md @@ -0,0 +1,97 @@ +# Design: SBOM referrers on the logical multi-arch tags + +**Date:** 2026-07-20 +**Status:** Approved (design confirmed with maintainer) +**Branch:** `image_copa` (PR #247) +**Affected files:** `.github/workflows/release.yml`, `.github/scripts/merge-manifests.sh` (new), +`.github/scripts/tests/run.sh` + `stubs/` (new stub), `README.md` + +## Problem (confirmed, Copilot r3615441608) + +SBOMs are attached as OCI referrers **only to the architecture-specific child tags** +(`…-amd64` / `…-arm64`) in the `build-php` job ([release.yml:244-245,336-337]). The +`process-tags` job then creates the **logical multi-arch tags** users actually pull +(`php8.5-v5.2`, `…-latest`, major, detailed) with `docker buildx imagetools create`, and +attaches nothing. OCI referrers bind to a specific subject **digest**; the logical tag +resolves to the *index* digest, which has no referrer. So `oras discover ` +returns nothing. + +**Requirement (maintainer):** *SBOMs for all images we publish* — the SBOM must be +discoverable via `oras discover` on the tags users pull, i.e. the logical tags, including +the `-latest` / major / detailed aliases. + +## Approach + +Carry the SBOM association through aggregation, and attach at manifest-merge time. + +1. **`build-php` aggregation (plain + hardened push).** Each line appended to + `aggregated_tags.txt` becomes `TAB`-separated: `` + instead of just ``. Every per-arch tag variant of an image (primary, + detailed, `-latest`, major, ghcr) is paired with **that image's per-arch SBOM file** + (the same `sboms/.spdx.json` already generated and uploaded). Plain rows + use the plain SBOM; hardened rows use the hardened SBOM. + +2. **Extract the `process-tags` merge loop into `.github/scripts/merge-manifests.sh`.** + Behavior-preserving move of the existing per-arch → logical merge (the + `HAS_AMD64`/`HAS_ARM64`/`LOGICAL` logic, the both-arches-required guard, and the + fail-the-job-on-`imagetools create`-error behavior), so the mapping is unit-testable. + The script reads `all_aggregated_tags.txt`, now with the `tagsbom` format, and + records `SBOM_AMD64[$lt]` / `SBOM_ARM64[$lt]` alongside the presence flags. + +3. **Attach per-arch SBOMs to each logical tag.** After a successful + `docker buildx imagetools create --tag "$lt" "$lt-amd64" "$lt-arm64"`, the script calls + `attach-sbom.sh "$lt" "${SBOM_AMD64[$lt]}"` and `attach-sbom.sh "$lt" "${SBOM_ARM64[$lt]}"` + — two referrers on the index subject, one per architecture. Attachment stays + **best-effort / non-fatal** (unchanged `attach-sbom.sh` behavior); a rejected referrer + never fails the job, and the workflow artifact remains the authoritative SBOM copy. + +4. **`process-tags` job wiring.** Add three things the job lacks today: + - a checkout of the CI scripts (`_ci` sparse-checkout of `.github/scripts` from the + workflow ref, mirroring `build-php`), so `merge-manifests.sh` and `attach-sbom.sh` + are available; + - install **oras** (extract the existing oras-install shell — checksum-verified — so it + is reused, not duplicated ad hoc). Trivy is not needed here. + - download the `sboms_*` artifacts (`actions/download-artifact@v8`, + `pattern: sboms_*`, `merge-multiple: true`, `path: sboms`) so the recorded + `sboms/.spdx.json` relpaths resolve on disk. + +5. **README.** Update the SBOM sentence (line ~55) so the `oras discover` claim is true for + the logical tags, not only the per-arch tags. + +## Decisions (baked in) + +- **Two per-arch SBOM referrers on the index**, not one merged SBOM — each SPDX accurately + describes one platform; `oras discover ` lists both. (A combined multi-arch + SBOM is out of scope — Trivy scans per platform.) +- **All logical aliases covered** (`-latest`, major, detailed), because the SBOM path + travels with each per-arch tag through aggregation. +- **Best-effort attach retained**; the uploaded artifact stays the guaranteed copy. +- **oras install is shared, not reforked** — reuse the existing checksum-verified install + logic so `ORAS_VERSION` pinning and verification are identical in both jobs. + +## Testing + +- Extract makes the merge logic unit-testable. New stub tests in + `.github/scripts/tests/run.sh` (with a `docker` stub covering `buildx imagetools create` + and an `attach-sbom`/`oras` stub) assert, over a synthetic `all_aggregated_tags.txt`: + - a logical tag with **both** arches present → `imagetools create` invoked with both + per-arch tags, then `attach-sbom` invoked **twice** for that logical tag (amd64 + arm64 + SBOM paths); + - a logical tag with **only one** arch present → skipped, no create, no attach; + - `imagetools create` failure → script exits non-zero (job fails), matching current + behavior; + - attach failure is **non-fatal** (script still exits 0 when creates succeed). + Mutation-verify each new assertion catches its target. +- `actionlint` + `shellcheck` clean on the changed workflow and the new script. + +## Out of scope (YAGNI) + +- A single combined/merged multi-arch SBOM. +- Changing the per-arch child referrers or the artifact upload (both stay). +- Signing/attestation beyond SBOM referrers. + +## Rollback + +Revert the commits: `process-tags` returns to inline merge with no attach, `aggregated_tags` +returns to bare tags. The per-arch child referrers and the workflow artifact remain, so the +SBOM still exists for every image — only logical-tag discovery reverts. From 4b01605843775a3ce1578cf4d7ecc32bd6c2725d Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:16:55 +0200 Subject: [PATCH 71/75] Add plan: SBOM referrers on logical multi-arch tags Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-20-sbom-on-logical-multiarch-tags.md | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md diff --git a/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md b/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md new file mode 100644 index 0000000..b2555a3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md @@ -0,0 +1,358 @@ +# SBOM Referrers on Logical Multi-Arch Tags — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the SBOM discoverable via `oras discover` on the logical multi-arch tags users pull (`php8.5-v5.2`, `-latest`, major, detailed), not only on the `…-amd64`/`…-arm64` child tags. + +**Architecture:** Carry each image's per-arch SBOM path alongside its tag through `aggregated_tags.txt`; extract the `process-tags` merge loop into a testable script that, after creating each logical manifest, attaches both per-arch SBOMs to it as best-effort OCI referrers. Extract the oras install so `process-tags` can reuse it. + +**Tech Stack:** GitHub Actions, Docker buildx imagetools, oras, bash, actionlint + shellcheck, stub-based bash unit tests. + +## Global Constraints + +- **SBOM attach stays best-effort / non-fatal.** A rejected referrer must never fail a job; the uploaded workflow artifact remains the authoritative SBOM copy. (`attach-sbom.sh` already behaves this way — do not change it.) +- **Two per-arch SBOM referrers per logical tag** (amd64 + arm64), not one merged SBOM. +- **All logical aliases covered** (`-latest`, major, detailed) — the SBOM path travels with every per-arch tag. +- **Behavior-preserving extraction:** Task 1 must not change what the workflow does; only where the code lives. +- **oras install reused, not duplicated:** the checksum-verified install logic lives in one script, called from both jobs. `ORAS_VERSION` env is the single source of the pinned version. +- Changed shell/workflow must pass `actionlint` + `shellcheck` (CI's "Lint workflows" + "Run script unit tests"). + +--- + +### Task 1: Extract oras install and the process-tags merge loop into scripts (behavior-preserving) + +**Files:** +- Create: `.github/scripts/install-oras.sh` +- Create: `.github/scripts/merge-manifests.sh` +- Modify: `.github/workflows/release.yml` (build-php "Install Trivy and oras" step; process-tags job — add `_ci` checkout + call script) +- Test: `.github/scripts/tests/run.sh` (+ `.github/scripts/tests/stubs/docker` if not already present) + +**Interfaces:** +- Produces: `install-oras.sh` (reads `ORAS_VERSION` from env, installs oras to `/usr/local/bin`). `merge-manifests.sh` (reads `AGG_FILE`, default `all_aggregated_tags.txt`; lines are bare per-arch tags; creates a logical manifest when both arches present; exits non-zero if any `imagetools create` failed). + +- [ ] **Step 1: Create `install-oras.sh` from the existing build-php oras logic** + +Move the oras portion of the current "Install Trivy and oras" step ([release.yml:127-144](.github/workflows/release.yml#L127-L144)) verbatim into a script: + +```bash +#!/usr/bin/env bash +# Install oras (checksum-verified) to /usr/local/bin. Version from ORAS_VERSION. +set -euxo pipefail +: "${ORAS_VERSION:?ORAS_VERSION must be set}" +ORAS_ARCH="$(dpkg --print-architecture)" +curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" +curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" +# Match the filename exactly ($2 == f): a substring match can also hit +# a sibling entry like *.tar.gz.sbom.json and return two hashes. +EXPECTED_SHA=$(awk -v f="oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" '$2 == f {print $1}' oras_checksums.txt) +ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') +if [ -z "$EXPECTED_SHA" ]; then + echo "::error::No oras checksum entry for oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + exit 1 +fi +if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 +fi +tar -xzf oras.tar.gz oras +sudo mv oras /usr/local/bin/oras +rm oras.tar.gz oras_checksums.txt +``` + +`chmod +x .github/scripts/install-oras.sh`. + +- [ ] **Step 2: Point build-php at the script** + +In the "Install Trivy and oras" step, replace the inlined oras block (lines 127-144) with a call, keeping the trivy install above it unchanged: + +```yaml + # (trivy install lines above stay unchanged) + ORAS_VERSION="${ORAS_VERSION}" _ci/.github/scripts/install-oras.sh +``` + +(Confirm `_ci/.github/scripts` is already checked out in build-php — it is, via the "Check out CI scripts" step.) + +- [ ] **Step 3: Create `merge-manifests.sh` as an exact move of the process-tags loop** + +```bash +#!/usr/bin/env bash +# Merge per-arch tags pushed THIS run into logical multi-arch manifests. +# Reads AGG_FILE (default all_aggregated_tags.txt), one per-arch tag per line. +set -uo pipefail +AGG_FILE="${AGG_FILE:-all_aggregated_tags.txt}" + +declare -A HAS_AMD64 HAS_ARM64 LOGICAL +while IFS= read -r t; do + [ -z "$t" ] && continue + case "$t" in + *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *) echo "Skipping tag without arch suffix: $t" ;; + esac +done < "$AGG_FILE" + +failed=0 +for lt in "${!LOGICAL[@]}"; do + if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then + echo "Creating multi-arch manifest: $lt" + if ! docker buildx imagetools create --tag "$lt" "${lt}-amd64" "${lt}-arm64"; then + echo "::error::Failed to create multi-arch manifest for $lt" + failed=1 + fi + else + echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" + fi +done + +if [ "$failed" -ne 0 ]; then + echo "::error::One or more multi-arch manifests failed to publish" + exit 1 +fi +exit 0 +``` + +`chmod +x`. This is the current inline logic verbatim. + +- [ ] **Step 4: Rewire the process-tags job to check out scripts and call merge-manifests.sh** + +In the `process-tags` job, add a CI-scripts checkout as the first step (mirroring build-php), and replace the inline merge loop in "Process tags" ([release.yml:420-461](.github/workflows/release.yml#L420-L461)) with the `cat` + script call: + +```yaml + - name: Check out CI scripts from the workflow ref + uses: actions/checkout@v5 + with: + path: _ci + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false +``` +(place it before "Set up Docker Buildx") + +And the "Process tags" run body becomes: +```bash + set -uo pipefail + find artifacts -type f -name "aggregated_tags.txt" -exec cat {} + > all_aggregated_tags.txt + _ci/.github/scripts/merge-manifests.sh +``` + +- [ ] **Step 5: Add a `docker` stub (if absent) and merge-manifests tests** + +Check `.github/scripts/tests/stubs/` for a `docker` stub. If none handles `buildx imagetools create`, add/extend one that logs and honors a failure knob: + +```bash +#!/usr/bin/env bash +echo "docker $*" >> "${STUB_LOG:-/dev/null}" +# imagetools create failure knob: STUB_IMAGETOOLS=fail +if [ "${1:-}" = "buildx" ] && [ "${2:-}" = "imagetools" ] && [ "${3:-}" = "create" ]; then + [ "${STUB_IMAGETOOLS:-ok}" = "fail" ] && { echo "stub docker: imagetools create failed" >&2; exit 1; } + exit 0 +fi +exit 0 +``` +(If a `docker` stub already exists for the scan-patch-gate tests, extend it with the `imagetools create` branch rather than replacing it — preserve its existing `rmi`/`image inspect`/`tag` behavior.) + +Add merge-manifests scenarios to `run.sh`: + +```bash +# --- merge-manifests.sh --- +echo "merge-manifests.sh:" +wM="$(mktemp -d)"; tmpdirs+=("$wM") +printf '%s\n' \ + "pimcore/pimcore:php8.5-v5.2-amd64" \ + "pimcore/pimcore:php8.5-v5.2-arm64" \ + "pimcore/pimcore:php8.5-latest-amd64" \ + "pimcore/pimcore:php8.5-onlyone-amd64" > "$wM/agg.txt" +logM="$wM/stub.log" +outM="$(AGG_FILE="$wM/agg.txt" STUB_LOG="$logM" "${ROOT}/.github/scripts/merge-manifests.sh")"; rcM=$? +[ "$rcM" = 0 ] && echo " ok: M exit 0" || { echo " FAIL: M exit $rcM"; fail=1; } +assert_contains "$(cat "$logM")" "buildx imagetools create --tag pimcore/pimcore:php8.5-v5.2 pimcore/pimcore:php8.5-v5.2-amd64 pimcore/pimcore:php8.5-v5.2-arm64" "M both-arch tag merged" +assert_not_contains "$outM" "Creating multi-arch manifest: pimcore/pimcore:php8.5-onlyone" "M single-arch tag skipped (not created)" +assert_contains "$outM" "Skipping pimcore/pimcore:php8.5-onlyone" "M single-arch tag reported as skipped" + +# imagetools create failure -> non-zero exit +wMf="$(mktemp -d)"; tmpdirs+=("$wMf") +printf '%s\n' "pimcore/pimcore:x-amd64" "pimcore/pimcore:x-arm64" > "$wMf/agg.txt" +AGG_FILE="$wMf/agg.txt" STUB_IMAGETOOLS=fail STUB_LOG="$wMf/stub.log" "${ROOT}/.github/scripts/merge-manifests.sh"; rcMf=$? +[ "$rcMf" != 0 ] && echo " ok: Mf exit non-zero on create failure" || { echo " FAIL: Mf should fail"; fail=1; } +``` + +- [ ] **Step 6: Run tests, actionlint, shellcheck** + +Run: +```bash +.github/scripts/tests/run.sh +actionlint .github/workflows/release.yml +shellcheck .github/scripts/install-oras.sh .github/scripts/merge-manifests.sh +``` +Expected: all tests pass; actionlint clean; shellcheck clean on the two new scripts. Mutation-check the "both-arch merged" and "create failure → non-zero" assertions (temporarily break each, confirm the test fails, restore). + +- [ ] **Step 7: Commit** + +```bash +git add .github/scripts/install-oras.sh .github/scripts/merge-manifests.sh .github/scripts/tests/ .github/workflows/release.yml +git commit -m "release: extract oras install + process-tags merge into tested scripts (no behavior change)" +``` + +--- + +### Task 2: Attach per-arch SBOMs to the logical tags + +**Files:** +- Modify: `.github/workflows/release.yml` (plain + hardened aggregation; process-tags: install oras, download SBOMs) +- Modify: `.github/scripts/merge-manifests.sh` (parse `tagsbom`, attach after create) +- Modify: `.github/scripts/tests/run.sh` +- Modify: `README.md` + +**Interfaces:** +- Consumes: `merge-manifests.sh` from Task 1; `attach-sbom.sh` (existing: `attach-sbom.sh `, best-effort). +- Produces: `aggregated_tags.txt` lines are now ``. + +- [ ] **Step 1: Write the failing test — attach invoked twice per logical tag** + +Extend the merge-manifests scenario in `run.sh` so the agg file has the `tagsbom` format and assert `attach-sbom` runs for the logical tag with both SBOMs. Because `merge-manifests.sh` calls the real `attach-sbom.sh`, which calls `oras`, assert against the `oras` stub log (an `oras` stub already exists for the attach-sbom tests): + +```bash +# Task 2: tagsbom format -> attach both per-arch SBOMs to the logical tag +wS="$(mktemp -d)"; tmpdirs+=("$wS"); mkdir -p "$wS/sboms" +: > "$wS/sboms/php8.5-v5.2-amd64.spdx.json" +: > "$wS/sboms/php8.5-v5.2-arm64.spdx.json" +printf '%s\t%s\n' \ + "pimcore/pimcore:php8.5-v5.2-amd64" "sboms/php8.5-v5.2-amd64.spdx.json" \ + "pimcore/pimcore:php8.5-v5.2-arm64" "sboms/php8.5-v5.2-arm64.spdx.json" > "$wS/agg.txt" +logS="$wS/stub.log" +outS="$( cd "$wS" && AGG_FILE="$wS/agg.txt" STUB_LOG="$logS" STUB_ORAS=ok "${ROOT}/.github/scripts/merge-manifests.sh" )"; rcS=$? +[ "$rcS" = 0 ] && echo " ok: S exit 0" || { echo " FAIL: S exit $rcS"; fail=1; } +oras_attaches="$(grep -c 'attach' "$logS" 2>/dev/null || echo 0)" +[ "$oras_attaches" = "2" ] && echo " ok: S two SBOM referrers attached to logical tag" || { echo " FAIL: S expected 2 attach calls, got $oras_attaches"; fail=1; } +assert_contains "$(cat "$logS")" "pimcore/pimcore:php8.5-v5.2" "S attach targeted the logical tag" +``` + +Run `.github/scripts/tests/run.sh` → expect FAIL (Task 1's script parses bare tags, ignores the sbom field, and does not attach). Capture the RED. + +- [ ] **Step 2: Update `merge-manifests.sh` to parse the sbom field and attach** + +```bash +#!/usr/bin/env bash +# Merge per-arch tags pushed THIS run into logical multi-arch manifests, and attach each +# image's per-arch SBOMs to the logical tag as best-effort OCI referrers. +# AGG_FILE lines: "\t". +set -uo pipefail +AGG_FILE="${AGG_FILE:-all_aggregated_tags.txt}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +declare -A HAS_AMD64 HAS_ARM64 LOGICAL SBOM_AMD64 SBOM_ARM64 +while IFS=$'\t' read -r t sbom; do + [ -z "$t" ] && continue + case "$t" in + *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1; SBOM_AMD64["$lt"]="$sbom" ;; + *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1; SBOM_ARM64["$lt"]="$sbom" ;; + *) echo "Skipping tag without arch suffix: $t" ;; + esac +done < "$AGG_FILE" + +failed=0 +for lt in "${!LOGICAL[@]}"; do + if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then + echo "Creating multi-arch manifest: $lt" + if ! docker buildx imagetools create --tag "$lt" "${lt}-amd64" "${lt}-arm64"; then + echo "::error::Failed to create multi-arch manifest for $lt" + failed=1 + continue + fi + # Attach each per-arch SBOM to the logical (index) tag so `oras discover ` + # finds it. Best-effort: attach-sbom.sh never fails the job. + for sb in "${SBOM_AMD64[$lt]:-}" "${SBOM_ARM64[$lt]:-}"; do + if [ -n "$sb" ] && [ -f "$sb" ]; then + "$HERE/attach-sbom.sh" "$lt" "$sb" + else + echo "No SBOM file for $lt referrer (path: '${sb:-}') -- skipping (artifact copy still uploaded)" + fi + done + else + echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" + fi +done + +if [ "$failed" -ne 0 ]; then + echo "::error::One or more multi-arch manifests failed to publish" + exit 1 +fi +exit 0 +``` + +Run the tests → expect PASS (both prior scenarios still green — bare-tag lines now parse as `t` with empty `sbom`, which is skipped safely; and the new S scenario attaches twice). **Note:** Task 1's Scenario M uses bare tags with no tab — confirm they still merge (the `IFS=$'\t' read -r t sbom` reads the whole line into `t` when there's no tab, so `t` keeps the tag and `sbom` is empty → attach skipped, merge still happens). Verify M stays green; if not, adjust M to the tab format. + +- [ ] **Step 3: Change build-php aggregation to write `tagsbom`** + +Plain push — replace [release.yml:249](.github/workflows/release.yml#L249): +```bash + for t in "${PLAIN_TAGS[@]}"; do + printf '%s\t%s\n' "$t" "${PLAIN_SBOM}" + done >> aggregated_tags.txt +``` +(`PLAIN_SBOM` is already read at line 230.) + +Hardened push — replace [release.yml:341](.github/workflows/release.yml#L341): +```bash + for t in "${HARDENED_TAGS[@]}"; do + printf '%s\t%s\n' "$t" "${HARDENED_SBOM}" + done >> aggregated_tags.txt +``` +(`HARDENED_SBOM` is already read at line 323.) + +- [ ] **Step 4: Wire process-tags to install oras and download the SBOMs** + +In the `process-tags` job, after the CI-scripts checkout (Task 1) and Buildx setup, add: +```yaml + - name: Install oras + run: ORAS_VERSION="${ORAS_VERSION}" _ci/.github/scripts/install-oras.sh +``` +And add a SBOM download step before "Process tags": +```yaml + - name: Download SBOMs + uses: actions/download-artifact@v8 + with: + path: sboms + pattern: sboms_* + merge-multiple: true +``` +`ORAS_VERSION` is a top-level `env:` value, available to the job. + +- [ ] **Step 5: Verify the downloaded SBOM layout matches the recorded relpath** + +The recorded relpath is `sboms/.spdx.json`. The "Upload SBOMs" step uses `path: sboms/`, so the artifact stores files at its root (`.spdx.json`); `download-artifact` with `merge-multiple: true` + `path: sboms` places them at `sboms/.spdx.json` — matching. Confirm by reading the current "Upload SBOMs" step ([release.yml:372-377](.github/workflows/release.yml#L372)). If the upload path nests differently, make the download path consistent so `sboms/.spdx.json` resolves in the process-tags workdir. Document the confirmed layout in the task report. + +- [ ] **Step 6: Update README** + +Change the SBOM sentence (~[README.md:55](README.md#L55)) so the discovery claim is accurate for the logical tags, e.g.: +> **SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM. It is always uploaded as a build artifact, and — where the registry supports OCI referrers — attached to the published image so it is discoverable with `oras discover` on the tag you pull (the multi-arch tag carries a referrer per architecture). + +Match the surrounding README voice. + +- [ ] **Step 7: Run tests, actionlint, shellcheck; mutation-check** + +```bash +.github/scripts/tests/run.sh +actionlint .github/workflows/release.yml +shellcheck .github/scripts/merge-manifests.sh +``` +All green/clean. Mutation-check the S scenario: temporarily make `merge-manifests.sh` attach only one SBOM (or none), confirm S fails (expects 2), restore. + +- [ ] **Step 8: Commit** + +```bash +git add .github/workflows/release.yml .github/scripts/merge-manifests.sh .github/scripts/tests/ README.md +git commit -m "release: attach per-arch SBOMs to logical multi-arch tags (oras discover now works on the tags users pull)" +``` + +--- + +## Validation (live, user-gated) + +On a `publish: true` dispatch (or after merge), run `oras discover pimcore/pimcore:php8.5-v5.2` +and confirm two `application/spdx+json` referrers appear; repeat for a `-latest` alias. + +## Self-Review + +- **Spec coverage:** aggregation tag+sbom (T2 S3) ✓; extract merge (T1 S3) ✓; extract oras (T1 S1-2) ✓; attach per-arch SBOMs to logical tags (T2 S2) ✓; process-tags checkout+oras+download (T1 S4, T2 S4) ✓; best-effort retained (T2 S2, `attach-sbom.sh` untouched) ✓; README (T2 S6) ✓; tests incl. both-arches/one-arch/create-failure/attach-twice (T1 S5, T2 S1) ✓. +- **Placeholder scan:** none — full code in each step. +- **Type/name consistency:** `AGG_FILE`, `SBOM_AMD64/ARM64`, `HAS_AMD64/ARM64`, `LOGICAL`, `ORAS_VERSION`, `PLAIN_SBOM`, `HARDENED_SBOM`, `attach-sbom.sh ` consistent across tasks and the current files. From 5553c6da80a4550c6e2bf60616de6c5675c84e06 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:17:26 +0200 Subject: [PATCH 72/75] plan: use non-empty SBOM files in the attach test (attach-sbom skips empty) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-20-sbom-on-logical-multiarch-tags.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md b/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md index b2555a3..b72f654 100644 --- a/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md +++ b/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md @@ -213,8 +213,10 @@ Extend the merge-manifests scenario in `run.sh` so the agg file has the `tagsbom format -> attach both per-arch SBOMs to the logical tag wS="$(mktemp -d)"; tmpdirs+=("$wS"); mkdir -p "$wS/sboms" -: > "$wS/sboms/php8.5-v5.2-amd64.spdx.json" -: > "$wS/sboms/php8.5-v5.2-arm64.spdx.json" +# NON-EMPTY: attach-sbom.sh skips empty SBOM files ([ ! -s ]), so an empty file +# would produce zero oras calls and a false test failure. +echo '{"spdxVersion":"SPDX-2.3"}' > "$wS/sboms/php8.5-v5.2-amd64.spdx.json" +echo '{"spdxVersion":"SPDX-2.3"}' > "$wS/sboms/php8.5-v5.2-arm64.spdx.json" printf '%s\t%s\n' \ "pimcore/pimcore:php8.5-v5.2-amd64" "sboms/php8.5-v5.2-amd64.spdx.json" \ "pimcore/pimcore:php8.5-v5.2-arm64" "sboms/php8.5-v5.2-arm64.spdx.json" > "$wS/agg.txt" From f9d3eb2675ad6a9a8999795a29832b01fbf91829 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:23:14 +0200 Subject: [PATCH 73/75] release: extract oras install + process-tags merge into tested scripts (no behavior change) --- .github/scripts/install-oras.sh | 22 ++++++++++ .github/scripts/merge-manifests.sh | 34 +++++++++++++++ .github/scripts/tests/run.sh | 21 ++++++++++ .github/scripts/tests/stubs/docker | 5 +++ .github/workflows/release.yml | 66 +++++------------------------- 5 files changed, 92 insertions(+), 56 deletions(-) create mode 100755 .github/scripts/install-oras.sh create mode 100755 .github/scripts/merge-manifests.sh diff --git a/.github/scripts/install-oras.sh b/.github/scripts/install-oras.sh new file mode 100755 index 0000000..4d10ced --- /dev/null +++ b/.github/scripts/install-oras.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Install oras (checksum-verified) to /usr/local/bin. Version from ORAS_VERSION. +set -euxo pipefail +: "${ORAS_VERSION:?ORAS_VERSION must be set}" +ORAS_ARCH="$(dpkg --print-architecture)" +curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" +curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" +# Match the filename exactly ($2 == f): a substring match can also hit +# a sibling entry like *.tar.gz.sbom.json and return two hashes. +EXPECTED_SHA=$(awk -v f="oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" '$2 == f {print $1}' oras_checksums.txt) +ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') +if [ -z "$EXPECTED_SHA" ]; then + echo "::error::No oras checksum entry for oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" + exit 1 +fi +if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then + echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" + exit 1 +fi +tar -xzf oras.tar.gz oras +sudo mv oras /usr/local/bin/oras +rm oras.tar.gz oras_checksums.txt diff --git a/.github/scripts/merge-manifests.sh b/.github/scripts/merge-manifests.sh new file mode 100755 index 0000000..8f3c517 --- /dev/null +++ b/.github/scripts/merge-manifests.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Merge per-arch tags pushed THIS run into logical multi-arch manifests. +# Reads AGG_FILE (default all_aggregated_tags.txt), one per-arch tag per line. +set -uo pipefail +AGG_FILE="${AGG_FILE:-all_aggregated_tags.txt}" + +declare -A HAS_AMD64 HAS_ARM64 LOGICAL +while IFS= read -r t; do + [ -z "$t" ] && continue + case "$t" in + *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *) echo "Skipping tag without arch suffix: $t" ;; + esac +done < "$AGG_FILE" + +failed=0 +for lt in "${!LOGICAL[@]}"; do + if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then + echo "Creating multi-arch manifest: $lt" + if ! docker buildx imagetools create --tag "$lt" "${lt}-amd64" "${lt}-arm64"; then + echo "::error::Failed to create multi-arch manifest for $lt" + failed=1 + fi + else + echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" + fi +done + +if [ "$failed" -ne 0 ]; then + echo "::error::One or more multi-arch manifests failed to publish" + exit 1 +fi +exit 0 diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index c3a8518..6060a69 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -224,5 +224,26 @@ assert_no_file "$wI/.docker-state/sbomfail/hardened_image.txt" "I hardened_image assert_no_file "$wI/sboms/php8.5-sbomfail-v5.1-hardened-amd64.spdx.json" "I partial hardened SBOM removed from sboms/" assert_contains "$(cat "$wI/.docker-state/sbomfail/gate_failed.txt")" "hardened SBOM generation failed" "I gate_failed reason mentions SBOM failure" +# --- merge-manifests.sh --- +echo "merge-manifests.sh:" +wM="$(mktemp -d)"; tmpdirs+=("$wM") +printf '%s\n' \ + "pimcore/pimcore:php8.5-v5.2-amd64" \ + "pimcore/pimcore:php8.5-v5.2-arm64" \ + "pimcore/pimcore:php8.5-latest-amd64" \ + "pimcore/pimcore:php8.5-onlyone-amd64" > "$wM/agg.txt" +logM="$wM/stub.log" +outM="$(AGG_FILE="$wM/agg.txt" STUB_LOG="$logM" "${ROOT}/.github/scripts/merge-manifests.sh")"; rcM=$? +[ "$rcM" = 0 ] && echo " ok: M exit 0" || { echo " FAIL: M exit $rcM"; fail=1; } +assert_contains "$(cat "$logM")" "buildx imagetools create --tag pimcore/pimcore:php8.5-v5.2 pimcore/pimcore:php8.5-v5.2-amd64 pimcore/pimcore:php8.5-v5.2-arm64" "M both-arch tag merged" +assert_not_contains "$outM" "Creating multi-arch manifest: pimcore/pimcore:php8.5-onlyone" "M single-arch tag skipped (not created)" +assert_contains "$outM" "Skipping pimcore/pimcore:php8.5-onlyone" "M single-arch tag reported as skipped" + +# imagetools create failure -> non-zero exit +wMf="$(mktemp -d)"; tmpdirs+=("$wMf") +printf '%s\n' "pimcore/pimcore:x-amd64" "pimcore/pimcore:x-arm64" > "$wMf/agg.txt" +AGG_FILE="$wMf/agg.txt" STUB_IMAGETOOLS=fail STUB_LOG="$wMf/stub.log" "${ROOT}/.github/scripts/merge-manifests.sh"; rcMf=$? +[ "$rcMf" != 0 ] && echo " ok: Mf exit non-zero on create failure" || { echo " FAIL: Mf should fail"; fail=1; } + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/scripts/tests/stubs/docker b/.github/scripts/tests/stubs/docker index 8d0a566..68db377 100755 --- a/.github/scripts/tests/stubs/docker +++ b/.github/scripts/tests/stubs/docker @@ -7,4 +7,9 @@ if [ "$1 $2" = "image inspect" ]; then if printf '%s ' "$@" | grep -q -- '--format'; then echo "sha256:deadbeefcafe0000"; fi exit 0 fi +# imagetools create failure knob: STUB_IMAGETOOLS=fail +if [ "${1:-}" = "buildx" ] && [ "${2:-}" = "imagetools" ] && [ "${3:-}" = "create" ]; then + [ "${STUB_IMAGETOOLS:-ok}" = "fail" ] && { echo "stub docker: imagetools create failed" >&2; exit 1; } + exit 0 +fi exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95d833..63f3eb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,24 +124,7 @@ jobs: sudo apt-get update sudo apt-get install -y trivy - ORAS_ARCH="$(dpkg --print-architecture)" - curl -fsSL -o oras.tar.gz "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" - curl -fsSL -o oras_checksums.txt "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_checksums.txt" - # Match the filename exactly ($2 == f): a substring match can also hit - # a sibling entry like *.tar.gz.sbom.json and return two hashes. - EXPECTED_SHA=$(awk -v f="oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" '$2 == f {print $1}' oras_checksums.txt) - ACTUAL_SHA=$(sha256sum oras.tar.gz | awk '{print $1}') - if [ -z "$EXPECTED_SHA" ]; then - echo "::error::No oras checksum entry for oras_${ORAS_VERSION}_linux_${ORAS_ARCH}.tar.gz" - exit 1 - fi - if [ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]; then - echo "::error::oras checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}" - exit 1 - fi - tar -xzf oras.tar.gz oras - sudo mv oras /usr/local/bin/oras - rm oras.tar.gz oras_checksums.txt + ORAS_VERSION="${ORAS_VERSION}" _ci/.github/scripts/install-oras.sh - name: Build plain images env: @@ -401,7 +384,14 @@ jobs: needs: build-php if: ${{ always() && github.repository == 'pimcore/docker' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} steps: - + + - name: Check out CI scripts from the workflow ref + uses: actions/checkout@v5 + with: + path: _ci + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -421,40 +411,4 @@ jobs: run: | set -uo pipefail find artifacts -type f -name "aggregated_tags.txt" -exec cat {} + > all_aggregated_tags.txt - - # aggregated_tags.txt holds the full per-arch tags (…-amd64 / …-arm64) pushed - # THIS run. Merge a logical tag only when BOTH arches were pushed in the same - # run, using exactly those per-arch tags — never a stale arch left in the - # registry from a previous run (which would produce a mixed-generation manifest). - declare -A HAS_AMD64 HAS_ARM64 LOGICAL - while IFS= read -r t; do - [ -z "$t" ] && continue - case "$t" in - *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1 ;; - *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1 ;; - *) echo "Skipping tag without arch suffix: $t" ;; - esac - done < all_aggregated_tags.txt - - failed=0 - for lt in "${!LOGICAL[@]}"; do - if [ -n "${HAS_AMD64[$lt]:-}" ] && [ -n "${HAS_ARM64[$lt]:-}" ]; then - echo "Creating multi-arch manifest: $lt" - # Both arches were pushed this run: a create failure means the logical - # tag was NOT updated -> record it and fail the job (don't leave green). - if ! docker buildx imagetools create \ - --tag "$lt" \ - "${lt}-amd64" \ - "${lt}-arm64"; then - echo "::error::Failed to create multi-arch manifest for $lt" - failed=1 - fi - else - echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" - fi - done - - if [ "$failed" -ne 0 ]; then - echo "::error::One or more multi-arch manifests failed to publish" - exit 1 - fi + _ci/.github/scripts/merge-manifests.sh From c365a9c4001734da9250aa9c6463774b5cf0dc70 Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:31:20 +0200 Subject: [PATCH 74/75] release: attach per-arch SBOMs to logical multi-arch tags (oras discover now works on the tags users pull) --- .github/scripts/merge-manifests.sh | 24 ++++++++++++++++++------ .github/scripts/tests/run.sh | 16 ++++++++++++++++ .github/workflows/release.yml | 30 ++++++++++++++++++++++++------ README.md | 2 +- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.github/scripts/merge-manifests.sh b/.github/scripts/merge-manifests.sh index 8f3c517..03ca253 100755 --- a/.github/scripts/merge-manifests.sh +++ b/.github/scripts/merge-manifests.sh @@ -1,15 +1,17 @@ #!/usr/bin/env bash -# Merge per-arch tags pushed THIS run into logical multi-arch manifests. -# Reads AGG_FILE (default all_aggregated_tags.txt), one per-arch tag per line. +# Merge per-arch tags pushed THIS run into logical multi-arch manifests, and attach each +# image's per-arch SBOMs to the logical tag as best-effort OCI referrers. +# AGG_FILE lines: "\t". set -uo pipefail AGG_FILE="${AGG_FILE:-all_aggregated_tags.txt}" +HERE="$(cd "$(dirname "$0")" && pwd)" -declare -A HAS_AMD64 HAS_ARM64 LOGICAL -while IFS= read -r t; do +declare -A HAS_AMD64 HAS_ARM64 LOGICAL SBOM_AMD64 SBOM_ARM64 +while IFS=$'\t' read -r t sbom; do [ -z "$t" ] && continue case "$t" in - *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1 ;; - *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1 ;; + *-amd64) lt="${t%-amd64}"; HAS_AMD64["$lt"]=1; LOGICAL["$lt"]=1; SBOM_AMD64["$lt"]="$sbom" ;; + *-arm64) lt="${t%-arm64}"; HAS_ARM64["$lt"]=1; LOGICAL["$lt"]=1; SBOM_ARM64["$lt"]="$sbom" ;; *) echo "Skipping tag without arch suffix: $t" ;; esac done < "$AGG_FILE" @@ -21,7 +23,17 @@ for lt in "${!LOGICAL[@]}"; do if ! docker buildx imagetools create --tag "$lt" "${lt}-amd64" "${lt}-arm64"; then echo "::error::Failed to create multi-arch manifest for $lt" failed=1 + continue fi + # Attach each per-arch SBOM to the logical (index) tag so `oras discover ` + # finds it. Best-effort: attach-sbom.sh never fails the job. + for sb in "${SBOM_AMD64[$lt]:-}" "${SBOM_ARM64[$lt]:-}"; do + if [ -n "$sb" ] && [ -f "$sb" ]; then + "$HERE/attach-sbom.sh" "$lt" "$sb" + else + echo "No SBOM file for $lt referrer (path: '${sb:-}') -- skipping (artifact copy still uploaded)" + fi + done else echo "Skipping $lt: only one arch pushed this run (amd64=${HAS_AMD64[$lt]:-0} arm64=${HAS_ARM64[$lt]:-0}); previous manifest left unchanged" fi diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 6060a69..19d9b54 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -245,5 +245,21 @@ printf '%s\n' "pimcore/pimcore:x-amd64" "pimcore/pimcore:x-arm64" > "$wMf/agg.tx AGG_FILE="$wMf/agg.txt" STUB_IMAGETOOLS=fail STUB_LOG="$wMf/stub.log" "${ROOT}/.github/scripts/merge-manifests.sh"; rcMf=$? [ "$rcMf" != 0 ] && echo " ok: Mf exit non-zero on create failure" || { echo " FAIL: Mf should fail"; fail=1; } +# Task 2: tagsbom format -> attach both per-arch SBOMs to the logical tag +wS="$(mktemp -d)"; tmpdirs+=("$wS"); mkdir -p "$wS/sboms" +# NON-EMPTY: attach-sbom.sh skips empty SBOM files ([ ! -s ]), so an empty file +# would produce zero oras calls and a false test failure. +echo '{"spdxVersion":"SPDX-2.3"}' > "$wS/sboms/php8.5-v5.2-amd64.spdx.json" +echo '{"spdxVersion":"SPDX-2.3"}' > "$wS/sboms/php8.5-v5.2-arm64.spdx.json" +printf '%s\t%s\n' \ + "pimcore/pimcore:php8.5-v5.2-amd64" "sboms/php8.5-v5.2-amd64.spdx.json" \ + "pimcore/pimcore:php8.5-v5.2-arm64" "sboms/php8.5-v5.2-arm64.spdx.json" > "$wS/agg.txt" +logS="$wS/stub.log" +outS="$( cd "$wS" && AGG_FILE="$wS/agg.txt" STUB_LOG="$logS" STUB_ORAS=ok "${ROOT}/.github/scripts/merge-manifests.sh" )"; rcS=$? +[ "$rcS" = 0 ] && echo " ok: S exit 0" || { echo " FAIL: S exit $rcS"; fail=1; } +oras_attaches="$(grep -c 'attach' "$logS" 2>/dev/null || echo 0)" +[ "$oras_attaches" = "2" ] && echo " ok: S two SBOM referrers attached to logical tag" || { echo " FAIL: S expected 2 attach calls, got $oras_attaches"; fail=1; } +assert_contains "$(cat "$logS")" "pimcore/pimcore:php8.5-v5.2" "S attach targeted the logical tag" + echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63f3eb6..f17fb9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -227,9 +227,13 @@ jobs: _ci/.github/scripts/attach-sbom.sh "${PLAIN_IMAGE}" "${PLAIN_SBOM}" _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${TAG}" "${PLAIN_SBOM}" - # Record the full per-arch tags pushed THIS run; process-tags merges a - # logical tag only when both arches were pushed in the same run. - printf '%s\n' "${PLAIN_TAGS[@]}" >> aggregated_tags.txt + # Record the full per-arch tags pushed THIS run, paired with the SBOM + # that documents that image; process-tags merges a logical tag only + # when both arches were pushed in the same run, and attaches both + # per-arch SBOMs to it as OCI referrers. + for t in "${PLAIN_TAGS[@]}"; do + printf '%s\t%s\n' "$t" "${PLAIN_SBOM}" + done >> aggregated_tags.txt fi done @@ -319,9 +323,13 @@ jobs: _ci/.github/scripts/attach-sbom.sh "${HARDENED_IMAGE}" "${HARDENED_SBOM}" _ci/.github/scripts/attach-sbom.sh "ghcr.io/pimcore/pimcore:${HARDENED_TAG}" "${HARDENED_SBOM}" - # Record the full per-arch tags pushed THIS run; process-tags merges a - # logical tag only when both arches were pushed in the same run. - printf '%s\n' "${HARDENED_TAGS[@]}" >> aggregated_tags.txt + # Record the full per-arch tags pushed THIS run, paired with the SBOM + # that documents that image; process-tags merges a logical tag only + # when both arches were pushed in the same run, and attaches both + # per-arch SBOMs to it as OCI referrers. + for t in "${HARDENED_TAGS[@]}"; do + printf '%s\t%s\n' "$t" "${HARDENED_SBOM}" + done >> aggregated_tags.txt fi done @@ -395,6 +403,9 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Install oras + run: ORAS_VERSION="${ORAS_VERSION}" _ci/.github/scripts/install-oras.sh + - name: Login to DockerHub Registry run: echo ${{ secrets.DOCKERHUB_PASSWORD }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin @@ -407,6 +418,13 @@ jobs: path: artifacts pattern: aggregated_tags_* + - name: Download SBOMs + uses: actions/download-artifact@v8 + with: + path: sboms + pattern: sboms_* + merge-multiple: true + - name: Process tags run: | set -uo pipefail diff --git a/README.md b/README.md index fe884fb..8d01244 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ php8.5-debug-v5 # plain image, as built (may contain CVEs) php8.5-debug-v5-hardened # same image, all available OS CVE fixes applied ``` -**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM. It is always uploaded as a build artifact, and — where the target registry supports OCI referrers — also attached to the published image (discoverable with `oras discover`). +**SBOMs:** every published image (plain and hardened, per architecture) ships an SPDX SBOM. It is always uploaded as a build artifact, and — where the registry supports OCI referrers — attached to the published image so it is discoverable with `oras discover` on the tag you pull (the multi-arch tag carries a referrer per architecture). ## 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. From a7afc9a664c4efb1e9bb2c29b19e7abf1d53c0ae Mon Sep 17 00:00:00 2001 From: "nebojsa.ilic" <7668379+bluvulture@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:43:28 +0200 Subject: [PATCH 75/75] test: assert SBOM referrer targets the LOGICAL tag (close vacuous-assertion gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review (Fable) found the S assertion was vacuous: 'pimcore/pimcore:php8.5-v5.2' is a substring of the imagetools-create line already in the log, so it passed even if attach targeted the child (…-amd64) ref. Replace with exact subject-binding assertions per arch, and fix the garbled 'got 0\n0' count message. Mutation-verified: attaching to ${lt}-amd64 instead of $lt now fails both assertions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/tests/run.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/scripts/tests/run.sh b/.github/scripts/tests/run.sh index 19d9b54..c015845 100755 --- a/.github/scripts/tests/run.sh +++ b/.github/scripts/tests/run.sh @@ -257,9 +257,13 @@ printf '%s\t%s\n' \ logS="$wS/stub.log" outS="$( cd "$wS" && AGG_FILE="$wS/agg.txt" STUB_LOG="$logS" STUB_ORAS=ok "${ROOT}/.github/scripts/merge-manifests.sh" )"; rcS=$? [ "$rcS" = 0 ] && echo " ok: S exit 0" || { echo " FAIL: S exit $rcS"; fail=1; } -oras_attaches="$(grep -c 'attach' "$logS" 2>/dev/null || echo 0)" -[ "$oras_attaches" = "2" ] && echo " ok: S two SBOM referrers attached to logical tag" || { echo " FAIL: S expected 2 attach calls, got $oras_attaches"; fail=1; } -assert_contains "$(cat "$logS")" "pimcore/pimcore:php8.5-v5.2" "S attach targeted the logical tag" +oras_attaches="$(grep -c 'attach' "$logS" 2>/dev/null || true)" +[ "${oras_attaches:-0}" = "2" ] && echo " ok: S two SBOM referrers attached to logical tag" || { echo " FAIL: S expected 2 attach calls, got ${oras_attaches:-0}"; fail=1; } +# Exact subject binding: each referrer must target the LOGICAL tag (no arch suffix), +# keyed to the matching per-arch SBOM. Catches a regression that attaches to the child +# (…-amd64/…-arm64) ref instead of the index -- which would defeat the whole feature. +assert_contains "$(cat "$logS")" "attach --artifact-type application/spdx+json pimcore/pimcore:php8.5-v5.2 sboms/php8.5-v5.2-amd64.spdx.json" "S amd64 SBOM attached to the LOGICAL tag" +assert_contains "$(cat "$logS")" "attach --artifact-type application/spdx+json pimcore/pimcore:php8.5-v5.2 sboms/php8.5-v5.2-arm64.spdx.json" "S arm64 SBOM attached to the LOGICAL tag" echo; [ "$fail" = "0" ] && echo "ALL TESTS PASSED" || echo "TESTS FAILED" exit "$fail"