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/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..03ca253 --- /dev/null +++ b/.github/scripts/merge-manifests.sh @@ -0,0 +1,46 @@ +#!/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 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/scripts/scan-patch-gate.sh b/.github/scripts/scan-patch-gate.sh new file mode 100755 index 0000000..b6f4402 --- /dev/null +++ b/.github/scripts/scan-patch-gate.sh @@ -0,0 +1,111 @@ +#!/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:-}" +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" + # 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" + # 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 +} + +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" + +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 + # 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" + 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})" + 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" + + 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" + + 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): 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" +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 new file mode 100755 index 0000000..c015845 --- /dev/null +++ b/.github/scripts/tests/run.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +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 "== 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 +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=$? +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 "== 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)"; 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" + +# 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_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" + +# 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") +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)"; 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)"; 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" + +# 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" + +# 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" + +# --- 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; } + +# 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 || 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" 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..68db377 --- /dev/null +++ b/.github/scripts/tests/stubs/docker @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 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 +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/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 diff --git a/.github/scripts/tests/stubs/trivy b/.github/scripts/tests/stubs/trivy new file mode 100755 index 0000000..a8f80f2 --- /dev/null +++ b/.github/scripts/tests/stubs/trivy @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Stub trivy. Scenario via env: STUB_FIXABLE=yes|no (initial OS scan), +# 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). +# 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="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2;; + --severity) sev="$2"; shift 2;; + --format) fmt="$2"; shift 2;; + --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) + 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) +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 + [ "${STUB_FIXABLE:-yes}" = "no" ] && v='[]' || v='[{"VulnerabilityID":"CVE-FIX"}]' +fi +printf '{"Results":[{"Vulnerabilities":%s}]}\n' "$v" > "$out" +exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 446d8c5..f17fb9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,16 @@ on: required: 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.' + 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 + default: 'CRITICAL,HIGH' + type: string push: tags: - 'v*.*' @@ -16,6 +26,9 @@ on: env: IMAGE_NAME: pimcore/pimcore + COPA_VERSION: "0.14.1" + ORAS_VERSION: "1.2.0" + TRIVY_DB_REPOSITORY: "ghcr.io/aquasecurity/trivy-db:2" jobs: build-php: @@ -23,27 +36,75 @@ jobs: runs-on: ${{ matrix.runner }} if: github.repository == 'pimcore/docker' strategy: + fail-fast: false matrix: runner: - 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 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: 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 @@ -53,115 +114,298 @@ jobs: - name: Login to GitHub Container Registry run: echo ${{ secrets.IMAGES_REPO_TOKEN }} | docker login ghcr.io -u ${{ secrets.IMAGES_REPO_USERNAME }} --password-stdin - - name: Configure and build images - id: vars + - name: Install Trivy and oras + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y wget curl apt-transport-https gnupg lsb-release jq + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list + sudo apt-get update + sudo apt-get install -y trivy + + ORAS_VERSION="${ORAS_VERSION}" _ci/.github/scripts/install-oras.sh + + - name: Build plain images env: VERSION_OVERRIDE: "${{ matrix.build.version-override }}" ARCH_TAG: ${{ contains(matrix.runner, 'arm') && 'arm64' || 'amd64' }} - PUSH: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} run: | - set -eux; - sudo apt-get update - - echo ${{ matrix.runner}} + set -eux + mkdir -p .docker-state - if [[ "${{ matrix.build.tag }}" =~ ^v?1.[0-9x]+$ ]]; then + if [[ "${{ matrix.build.tag }}" =~ ^v?1\.[0-9x]+$ ]]; then imageVariants=("fpm" "debug" "supervisord") else imageVariants=("min" "default" "max" "debug" "supervisord") fi - for imageVariant in ${imageVariants[@]}; do - echo "Building image variant $imageVariant" - DOCKER_PLATFORMS=linux/amd64,linux/arm64 - PHP_VERSION=${{ matrix.build.php }} - DEBIAN_VERSION="${{ matrix.build.distro }}" + printf '%s\n' "${imageVariants[@]}" > .docker-state/variants.txt + + PHP_SUB_VERSION=$(docker run -i --rm php:${{ matrix.build.php }}-fpm-${{ matrix.build.distro }} php -r 'echo PHP_VERSION;') + + for imageVariant in "${imageVariants[@]}"; do + echo "Building plain image: $imageVariant" + mkdir -p ".docker-state/${imageVariant}" + VERSION="${{ matrix.build.tag }}" - # for the latest dev branch we use "dev" as the version and not the name of the branch - if [ ! -z "$VERSION_OVERRIDE" ]; then + if [ -n "$VERSION_OVERRIDE" ]; then VERSION="$VERSION_OVERRIDE" fi - PHP_SUB_VERSION=$(docker run -i --rm php:${{ matrix.build.php }}-fpm-${{ matrix.build.distro }} php -r 'echo PHP_VERSION;') - if [ "$imageVariant" = "fpm" ] || [ "$imageVariant" = "default" ]; then + + if [ "$imageVariant" = "fpm" ] || [ "$imageVariant" = "default" ]; then BASE_TAG="php${{ matrix.build.php }}" BASE_TAG_DETAILED="php${PHP_SUB_VERSION}" else - BASE_TAG="php${{ matrix.build.php }}-$imageVariant" - BASE_TAG_DETAILED="php${PHP_SUB_VERSION}-$imageVariant" + BASE_TAG="php${{ matrix.build.php }}-${imageVariant}" + BASE_TAG_DETAILED="php${PHP_SUB_VERSION}-${imageVariant}" fi - # DEBUG / TEST - #BASE_TAG="testv3-$BASE_TAG" - #BASE_TAG_DETAILED="testv3-$BASE_TAG_DETAILED" - TAG="${BASE_TAG}-${VERSION}-${ARCH_TAG}" - TAG_DETAILED="${BASE_TAG_DETAILED}-${VERSION}-${ARCH_TAG}" - - GHCR_TAG="ghcr.io/pimcore/pimcore:${TAG}" - GHCR_TAG_DETAILED="ghcr.io/pimcore/pimcore:${TAG_DETAILED}" + PLAIN_IMAGE="${IMAGE_NAME}:${TAG}" - TAGS="--tag ${IMAGE_NAME}:${TAG}" - TAGS="$TAGS --tag ${IMAGE_NAME}:${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" - TAGS="$TAGS --tag $GHCR_TAG" - TAGS="$TAGS --tag $GHCR_TAG_DETAILED" - - # 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" - docker buildx build --output "type=image,push=$PUSH" \ + docker build --load \ --provenance=false \ - --sbom=true \ --platform "linux/${ARCH_TAG}" \ - --target="pimcore_php_$imageVariant" \ - --build-arg PHP_VERSION="${PHP_VERSION}" \ - --build-arg DEBIAN_VERSION="${DEBIAN_VERSION}" \ - ${TAGS} . + --target="pimcore_php_${imageVariant}" \ + --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: Push plain images + env: + 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" - docker inspect ${IMAGE_NAME}:${TAG} || true; + for plain_tag in "${PLAIN_TAGS[@]}"; do + if [ "$plain_tag" != "$PLAIN_IMAGE" ]; then + docker tag "$PLAIN_IMAGE" "$plain_tag" + fi + done - # Only aggregate tags if we're publishing + # 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 - 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 - done + printf '%s\n' "${PLAIN_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + _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, 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 + # 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: | + 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: 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 + + # 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 + + mapfile -t imageVariants < .docker-state/variants.txt + for imageVariant in "${imageVariants[@]}"; do + _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_HARDENED: ${{ github.event_name == 'workflow_dispatch' && inputs.publish && inputs.publish_hardened }} + 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_HARDENED" == "true" ]]; then + printf '%s\n' "${HARDENED_TAGS[@]}" | xargs -P 4 -I {} docker push "{}" + + 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}" + + # 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 + - 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" ] || continue + docker rmi "$(< "$f")" 2>/dev/null || true + done + done + + - name: Upload trivy reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: trivy-reports_${{ matrix.runner }}_${{ matrix.build.tag }}_${{ matrix.build.php }}_${{ matrix.build.distro }}_${{ matrix.build.version-override }}_${{ matrix.build.latest-tag }} + path: trivy-reports/ + if-no-files-found: ignore + + - name: Upload 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 + 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 }} 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; only their -hardened tags were skipped (plain image handling is unaffected)" + 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.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 + - 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 @@ -172,31 +416,17 @@ jobs: uses: actions/download-artifact@v8 with: 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 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 - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" - else - echo "Error: Missing per-arch image for $tag, skipping" - fi - - done + _ci/.github/scripts/merge-manifests.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1463de2..24c15cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,14 +18,14 @@ 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 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 }}" \ @@ -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 diff --git a/README.md b/README.md index 38a0a5e..8d01244 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,33 @@ 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`. 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 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. + +**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. + +> **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 +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 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. Use either of the following commands: 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..b72f654 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-sbom-on-logical-multiarch-tags.md @@ -0,0 +1,360 @@ +# 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" +# 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" +``` + +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. 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.