From 7d388aac645f51f0561f9da2544472760fc563a0 Mon Sep 17 00:00:00 2001 From: Louis Lotter Date: Fri, 12 Jun 2026 09:11:24 +0200 Subject: [PATCH 1/2] STAC-25033: verify pushed image binary arch matches requested platform The manifest of a pushed image always claims whatever --platform requested, regardless of what the Dockerfile actually compiled or copied in. A Dockerfile that hardcodes GOARCH (or copies a foreign-arch binary) therefore publishes an image that scans clean, gets signed, and only fails on the target nodes with 'exec format error'. Found while reviewing the kafkaup-operator migration, whose scaffold Dockerfile hardcodes GOARCH=amd64 while the release workflow pushes both amd64 and arm64. Add a verification step between push and cosign signing: pull the pushed digest for the requested platform, extract the entrypoint (or cmd) binary via docker create + docker cp -L (no container start, so no emulation needed), and compare the ELF e_machine field against the requested arch. Mismatch fails the action before signing. Indeterminate cases (no entrypoint, relative path, non-ELF entrypoint such as shell scripts) warn and pass so existing script-entrypoint consumers are unaffected. A verify-binary-arch input (default true) can disable the check and its image pull-back. Also add .github/actions/** to the zizmor lint workflow path filters so composite-action changes trigger the security audit; this change itself was the first to miss it. Co-Authored-By: Claude Fable 5 --- .github/actions/push-single-arch/action.yml | 91 +++++++++++++++++++++ .github/workflows/lint-workflows.yml | 2 + 2 files changed, 93 insertions(+) diff --git a/.github/actions/push-single-arch/action.yml b/.github/actions/push-single-arch/action.yml index 44279f3..cc3fea0 100644 --- a/.github/actions/push-single-arch/action.yml +++ b/.github/actions/push-single-arch/action.yml @@ -52,6 +52,18 @@ inputs: description: "Newline-delimited key=value labels to apply to the image, appended after docker/metadata-action's defaults (last-write-wins on duplicate keys). Pass apply-oci-labels' output here, or any other source." required: false default: "" + verify-binary-arch: + description: | + After the push, pull the image back for the requested platform and check + that its entrypoint (or cmd) binary is an ELF binary built for that + architecture. Catches Dockerfiles that hardcode GOARCH or copy a + foreign-arch binary, which otherwise ship images whose manifest claims + one platform while the binary inside targets another (runtime + `exec format error`). Images whose entrypoint is not an arch-specific + ELF binary (e.g. shell scripts) are skipped with a warning. Set to + "false" to skip the verification (and its image pull-back) entirely. + required: false + default: "true" runs: using: composite @@ -127,6 +139,85 @@ runs: secrets: ${{ inputs.buildkit-secrets }} tags: ${{ inputs.image }}:${{ inputs.tag }}-${{ inputs.arch }} + # The manifest of the pushed image always claims the platform that was + # requested via --platform, regardless of what the Dockerfile actually + # compiled or copied in. Verify the claim by reading the ELF header of the + # entrypoint binary, so arch-mismatched images fail here instead of with + # `exec format error` on the target nodes — and before they get signed. + # `docker create` never starts the container, so no emulation is needed to + # inspect a foreign-arch image. + - name: Verify binary architecture matches linux/${{ inputs.arch }} + if: ${{ inputs.verify-binary-arch == 'true' }} + shell: bash + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ steps.push-image.outputs.digest }} + ARCH: ${{ inputs.arch }} + run: | + set -euo pipefail + + # Docker architecture -> ELF e_machine (see elf.h). + case "${ARCH}" in + amd64) expected_machine=62 ;; # EM_X86_64 + arm64) expected_machine=183 ;; # EM_AARCH64 + arm) expected_machine=40 ;; # EM_ARM + 386) expected_machine=3 ;; # EM_386 + ppc64le) expected_machine=21 ;; # EM_PPC64 + s390x) expected_machine=22 ;; # EM_S390 + riscv64) expected_machine=243 ;; # EM_RISCV + *) + echo "::warning::No ELF machine mapping for arch '${ARCH}'; skipping binary architecture verification." + exit 0 + ;; + esac + + ref="${IMAGE}@${DIGEST}" + docker pull --platform "linux/${ARCH}" "${ref}" >/dev/null + + entry="$(docker image inspect --format '{{if .Config.Entrypoint}}{{index .Config.Entrypoint 0}}{{else if .Config.Cmd}}{{index .Config.Cmd 0}}{{end}}' "${ref}")" + if [ -z "${entry}" ]; then + echo "::warning::Image declares no entrypoint or cmd; skipping binary architecture verification." + exit 0 + fi + case "${entry}" in + /*) ;; + *) + echo "::warning::Entrypoint '${entry}' is not an absolute path; skipping binary architecture verification." + exit 0 + ;; + esac + + container_id="$(docker create --platform "linux/${ARCH}" "${ref}")" + trap 'docker rm -f "${container_id}" >/dev/null 2>&1 || true' EXIT + workdir="$(mktemp -d)" + # -L dereferences symlinked entrypoints (e.g. /app -> /real-binary). + if ! docker cp -L "${container_id}:${entry}" "${workdir}/entrypoint" 2>/dev/null; then + echo "::warning::Could not extract entrypoint '${entry}' from the image; skipping binary architecture verification." + exit 0 + fi + + bin="${workdir}/entrypoint" + magic="$(dd if="${bin}" bs=1 count=4 2>/dev/null | od -An -tx1 | tr -d ' \n' || true)" + if [ "${magic}" != "7f454c46" ]; then + echo "::warning::Entrypoint '${entry}' is not an ELF binary (e.g. a script); skipping binary architecture verification." + exit 0 + fi + # EI_DATA (byte 5): 1 = little-endian, 2 = big-endian; e_machine is the + # 16-bit field at offset 18 in the file's endianness. + ei_data="$(dd if="${bin}" bs=1 skip=5 count=1 2>/dev/null | od -An -tu1 | tr -d ' \n' || true)" + read -r b1 b2 <<< "$(dd if="${bin}" bs=1 skip=18 count=2 2>/dev/null | od -An -tu1 | tr -s ' ' | sed 's/^ //' || true)" + if [ "${ei_data}" = "2" ]; then + machine=$(( b1 * 256 + b2 )) + else + machine=$(( b2 * 256 + b1 )) + fi + + if [ "${machine}" -ne "${expected_machine}" ]; then + echo "::error::Pushed image ${ref} claims linux/${ARCH} but its entrypoint '${entry}' is an ELF binary with e_machine ${machine} (expected ${expected_machine}). The Dockerfile likely hardcodes GOOS/GOARCH or copies a binary built for another architecture; use ARG TARGETOS TARGETARCH (or fix the copied artifact) so the build follows the requested platform." + exit 1 + fi + echo "Entrypoint '${entry}' ELF machine ${machine} matches linux/${ARCH}." + ####################### # Sign the image ####################### diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml index 0ddf99e..a0306d7 100644 --- a/.github/workflows/lint-workflows.yml +++ b/.github/workflows/lint-workflows.yml @@ -7,12 +7,14 @@ on: branches: [main] paths: - ".github/workflows/**" + - ".github/actions/**" - "action.yml" - "zizmor.yml" push: branches: [main] paths: - ".github/workflows/**" + - ".github/actions/**" - "action.yml" - "zizmor.yml" From ace2a56cbe0666d4fca24b8e9da5da564570deb0 Mon Sep 17 00:00:00 2001 From: Louis Lotter Date: Fri, 12 Jun 2026 09:17:05 +0200 Subject: [PATCH 2/2] STAC-25033: simplify verification using file(1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled ELF header parser (dd/od, endianness handling, e_machine table) with file(1), which ships on all GitHub-hosted runners and prints the architecture in one line. Also drop the verify-binary-arch opt-out input — the skip paths already cover every legitimate image shape (script entrypoints, no entrypoint, extraction failure), so there is no valid reason to publish an ELF entrypoint whose architecture differs from the platform — and the explicit docker pull and absolute-path pre-check, which docker create and the docker cp failure path already cover. Map only amd64/arm64 since those are the architectures we publish; anything else skips with a warning. Re-validated against the same local test images: arch-mismatch fails, match passes, symlinked entrypoint dereferences, script entrypoint and cmd-only images skip. Co-Authored-By: Claude Fable 5 --- .github/actions/push-single-arch/action.yml | 97 ++++++--------------- 1 file changed, 28 insertions(+), 69 deletions(-) diff --git a/.github/actions/push-single-arch/action.yml b/.github/actions/push-single-arch/action.yml index cc3fea0..a58ac15 100644 --- a/.github/actions/push-single-arch/action.yml +++ b/.github/actions/push-single-arch/action.yml @@ -52,18 +52,6 @@ inputs: description: "Newline-delimited key=value labels to apply to the image, appended after docker/metadata-action's defaults (last-write-wins on duplicate keys). Pass apply-oci-labels' output here, or any other source." required: false default: "" - verify-binary-arch: - description: | - After the push, pull the image back for the requested platform and check - that its entrypoint (or cmd) binary is an ELF binary built for that - architecture. Catches Dockerfiles that hardcode GOARCH or copy a - foreign-arch binary, which otherwise ship images whose manifest claims - one platform while the binary inside targets another (runtime - `exec format error`). Images whose entrypoint is not an arch-specific - ELF binary (e.g. shell scripts) are skipped with a warning. Set to - "false" to skip the verification (and its image pull-back) entirely. - required: false - default: "true" runs: using: composite @@ -139,15 +127,14 @@ runs: secrets: ${{ inputs.buildkit-secrets }} tags: ${{ inputs.image }}:${{ inputs.tag }}-${{ inputs.arch }} - # The manifest of the pushed image always claims the platform that was - # requested via --platform, regardless of what the Dockerfile actually - # compiled or copied in. Verify the claim by reading the ELF header of the - # entrypoint binary, so arch-mismatched images fail here instead of with - # `exec format error` on the target nodes — and before they get signed. - # `docker create` never starts the container, so no emulation is needed to - # inspect a foreign-arch image. + # The pushed manifest always claims whatever --platform requested; it says + # nothing about what the Dockerfile actually compiled or copied in. Check + # the entrypoint binary's ELF architecture with file(1) so arch-mismatched + # images (e.g. a hardcoded GOARCH) fail here, before signing, instead of + # with `exec format error` on the target nodes. `docker create` never + # starts the container, so foreign-arch images need no emulation. Only a + # definitive ELF mismatch fails; non-ELF entrypoints (e.g. scripts) pass. - name: Verify binary architecture matches linux/${{ inputs.arch }} - if: ${{ inputs.verify-binary-arch == 'true' }} shell: bash env: IMAGE: ${{ inputs.image }} @@ -156,68 +143,40 @@ runs: run: | set -euo pipefail - # Docker architecture -> ELF e_machine (see elf.h). case "${ARCH}" in - amd64) expected_machine=62 ;; # EM_X86_64 - arm64) expected_machine=183 ;; # EM_AARCH64 - arm) expected_machine=40 ;; # EM_ARM - 386) expected_machine=3 ;; # EM_386 - ppc64le) expected_machine=21 ;; # EM_PPC64 - s390x) expected_machine=22 ;; # EM_S390 - riscv64) expected_machine=243 ;; # EM_RISCV + amd64) want="x86-64" ;; + arm64) want="aarch64" ;; *) - echo "::warning::No ELF machine mapping for arch '${ARCH}'; skipping binary architecture verification." + echo "::warning::No ELF pattern for arch '${ARCH}'; skipping binary architecture verification." exit 0 ;; esac - ref="${IMAGE}@${DIGEST}" - docker pull --platform "linux/${ARCH}" "${ref}" >/dev/null + cid="$(docker create --platform "linux/${ARCH}" "${IMAGE}@${DIGEST}")" + trap 'docker rm -f "${cid}" >/dev/null' EXIT + entry="$(docker inspect --format '{{if .Config.Entrypoint}}{{index .Config.Entrypoint 0}}{{else if .Config.Cmd}}{{index .Config.Cmd 0}}{{end}}' "${cid}")" - entry="$(docker image inspect --format '{{if .Config.Entrypoint}}{{index .Config.Entrypoint 0}}{{else if .Config.Cmd}}{{index .Config.Cmd 0}}{{end}}' "${ref}")" - if [ -z "${entry}" ]; then - echo "::warning::Image declares no entrypoint or cmd; skipping binary architecture verification." + bin="${RUNNER_TEMP}/entrypoint-binary" + # -L dereferences symlinked entrypoints (e.g. /app -> /real-binary). + if [ -z "${entry}" ] || ! docker cp -L "${cid}:${entry}" "${bin}" 2>/dev/null; then + echo "::warning::Cannot extract entrypoint '${entry:-}' from the image; skipping binary architecture verification." exit 0 fi - case "${entry}" in - /*) ;; + + desc="$(file -b "${bin}")" + case "${desc}" in + *ELF*"${want}"*) + echo "Entrypoint '${entry}' matches linux/${ARCH}: ${desc}" + ;; + *ELF*) + echo "::error::Image claims linux/${ARCH} but its entrypoint '${entry}' is: ${desc}. The Dockerfile likely hardcodes GOOS/GOARCH or copies a binary built for another architecture." + exit 1 + ;; *) - echo "::warning::Entrypoint '${entry}' is not an absolute path; skipping binary architecture verification." - exit 0 + echo "Entrypoint '${entry}' is not an ELF binary (${desc}); nothing to verify." ;; esac - container_id="$(docker create --platform "linux/${ARCH}" "${ref}")" - trap 'docker rm -f "${container_id}" >/dev/null 2>&1 || true' EXIT - workdir="$(mktemp -d)" - # -L dereferences symlinked entrypoints (e.g. /app -> /real-binary). - if ! docker cp -L "${container_id}:${entry}" "${workdir}/entrypoint" 2>/dev/null; then - echo "::warning::Could not extract entrypoint '${entry}' from the image; skipping binary architecture verification." - exit 0 - fi - - bin="${workdir}/entrypoint" - magic="$(dd if="${bin}" bs=1 count=4 2>/dev/null | od -An -tx1 | tr -d ' \n' || true)" - if [ "${magic}" != "7f454c46" ]; then - echo "::warning::Entrypoint '${entry}' is not an ELF binary (e.g. a script); skipping binary architecture verification." - exit 0 - fi - # EI_DATA (byte 5): 1 = little-endian, 2 = big-endian; e_machine is the - # 16-bit field at offset 18 in the file's endianness. - ei_data="$(dd if="${bin}" bs=1 skip=5 count=1 2>/dev/null | od -An -tu1 | tr -d ' \n' || true)" - read -r b1 b2 <<< "$(dd if="${bin}" bs=1 skip=18 count=2 2>/dev/null | od -An -tu1 | tr -s ' ' | sed 's/^ //' || true)" - if [ "${ei_data}" = "2" ]; then - machine=$(( b1 * 256 + b2 )) - else - machine=$(( b2 * 256 + b1 )) - fi - - if [ "${machine}" -ne "${expected_machine}" ]; then - echo "::error::Pushed image ${ref} claims linux/${ARCH} but its entrypoint '${entry}' is an ELF binary with e_machine ${machine} (expected ${expected_machine}). The Dockerfile likely hardcodes GOOS/GOARCH or copies a binary built for another architecture; use ARG TARGETOS TARGETARCH (or fix the copied artifact) so the build follows the requested platform." - exit 1 - fi - echo "Entrypoint '${entry}' ELF machine ${machine} matches linux/${ARCH}." - ####################### # Sign the image #######################