diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d5bb7de18..a06e04762 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -167,16 +167,13 @@ sboms: - "--output" - "json=$document" +# the signature, certificate, and transparency log proof all land in a single sigstore bundle. signs: - cmd: .tool/cosign - signature: "${artifact}.sig" - certificate: "${artifact}.pem" + signature: "${artifact}.sigstore.json" args: - "sign-blob" - - "--use-signing-config=false" - - "--oidc-issuer=https://token.actions.githubusercontent.com" - - "--output-certificate=${certificate}" - - "--output-signature=${signature}" + - "--bundle=${signature}" - "${artifact}" - "--yes" artifacts: checksum diff --git a/install.sh b/install.sh index bf9222d06..7fb08d0d4 100755 --- a/install.sh +++ b/install.sh @@ -19,6 +19,8 @@ VERIFY_SIGN=false VERIFY_SIGN_SUPPORTED_VERSION=v0.104.0 # this is the earliest tag in the repo where the -v flag was introduced to this install.sh script VERIFY_SIGN_FLAG_VERSION=v1.6.0 +# releases at or after this version are signed with a sigstore bundle (.sigstore.json); earlier releases ship a separate .sig and .pem +VERIFY_SIGN_BUNDLE_VERSION=v1.53.0 # do not change the name of this parameter (this must always be backwards compatible) DOWNLOAD_TAG_INSTALL_SCRIPT=${DOWNLOAD_TAG_INSTALL_SCRIPT:-true} @@ -386,8 +388,8 @@ download_github_release_checksums_files() ( complete_url=$(github_release_asset_url "${download_url}" "${name}" "${version}" "${filename}") output_path="${output_dir}/${complete_filename}" - http_download "${output_path}" "${complete_url}" "" - asset_file_exists "${output_path}" + http_download "${output_path}" "${complete_url}" "" || return 1 + asset_file_exists "${output_path}" || return 1 log_trace "download_github_release_checksums_files() returned '${output_path}' for file '${complete_filename}'" @@ -585,28 +587,31 @@ download_and_install_asset() ( install_asset "${asset_filepath}" "${install_path}" "${binary}" ) -# verify_sign [checksums-file-path] [certificate-reference] [signature-reference] [version] +# verify_sign [checksums-file-path] [verification-material-flags...] # # attempts verify the signature of the checksums file from the release workflow in Github Actions run against the main branch. +# the verification material is either "--bundle " or (for older releases) "--certificate --signature ". # verify_sign() { checksums_file=$1 - cert_reference=$2 - sig_reference=$3 + shift - log_trace "verifying artifact $1" + log_trace "verifying artifact ${checksums_file}" log_file=$(mktemp) ${COSIGN_BINARY} \ verify-blob "$checksums_file" \ - --certificate "$cert_reference" \ - --signature "$sig_reference" \ + "$@" \ --certificate-identity "https://github.com/${OWNER}/${REPO}/.github/workflows/release.yaml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > "${log_file}" 2>&1 if [ $? -ne 0 ]; then log_err "$(cat "${log_file}")" + # cosign older than v2.4.2 can't read the bundle's verification material and reports a missing cert + if [ "$1" = "--bundle" ] && grep -q "bundle does not contain cert" "${log_file}"; then + log_err "note: releases >= ${VERIFY_SIGN_BUNDLE_VERSION} are verified with a sigstore bundle, which requires cosign v2.5.0 or newer (check '${COSIGN_BINARY} version')" + fi rm -f "${log_file}" return 1 fi @@ -630,7 +635,10 @@ download_asset() ( log_trace "download_asset(url=${download_url}, destination=${destination}, name=${name}, os=${os}, arch=${arch}, version=${version}, format=${format})" - checksums_filepath=$(download_github_release_checksums "${download_url}" "${name}" "${version}" "${destination}") + if ! checksums_filepath=$(download_github_release_checksums "${download_url}" "${name}" "${version}" "${destination}"); then + log_err "unable to download checksums file" + return 1 + fi log_trace "checksums content:\n$(cat ${checksums_filepath})" @@ -642,13 +650,25 @@ download_asset() ( fi if [ "$VERIFY_SIGN" = true ]; then - checksum_sig_file_url=$(github_release_checksums_sig_url "${download_url}" "${name}" "${version}") - log_trace "checksums signature url: ${checksum_sig_file_url}" + if compare_semver "${version}" "${VERIFY_SIGN_BUNDLE_VERSION}"; then + if ! checksums_bundle_filepath=$(download_github_release_checksums_files "${download_url}" "${name}" "${version}" "${destination}" "checksums.txt.sigstore.json"); then + log_err "unable to download checksums signature bundle" + return 1 + fi + log_trace "checksums bundle: ${checksums_bundle_filepath}" - checksums_cert_file_url=$(github_release_checksums_cert_url "${download_url}" "${name}" "${version}") - log_trace "checksums certificate url: ${checksums_cert_file_url}" + set -- --bundle "${checksums_bundle_filepath}" + else + checksum_sig_file_url=$(github_release_checksums_sig_url "${download_url}" "${name}" "${version}") + log_trace "checksums signature url: ${checksum_sig_file_url}" - if ! verify_sign "${checksums_filepath}" "${checksums_cert_file_url}" "${checksum_sig_file_url}"; then + checksums_cert_file_url=$(github_release_checksums_cert_url "${download_url}" "${name}" "${version}") + log_trace "checksums certificate url: ${checksums_cert_file_url}" + + set -- --certificate "${checksums_cert_file_url}" --signature "${checksum_sig_file_url}" + fi + + if ! verify_sign "${checksums_filepath}" "$@"; then log_err "signature verification failed" return 1 fi @@ -657,9 +677,15 @@ download_asset() ( asset_url="${download_url}/${asset_filename}" asset_filepath="${destination}/${asset_filename}" - http_download "${asset_filepath}" "${asset_url}" "" + if ! http_download "${asset_filepath}" "${asset_url}" ""; then + log_err "unable to download asset '${asset_url}'" + return 1 + fi - hash_sha256_verify "${asset_filepath}" "${checksums_filepath}" + # this is what ties the asset to the (optionally signature verified) checksums file, so a mismatch must stop the install + if ! hash_sha256_verify "${asset_filepath}" "${checksums_filepath}"; then + return 1 + fi log_trace "download_asset_by_checksums_file() returned '${asset_filepath}'" diff --git a/test/install/2_download_release_asset_test.sh b/test/install/2_download_release_asset_test.sh index 003477149..4ea173e15 100755 --- a/test/install/2_download_release_asset_test.sh +++ b/test/install/2_download_release_asset_test.sh @@ -43,3 +43,9 @@ run_test_case test_download_release_asset "${release}" "linux" "amd64" "deb" "ap run_test_case test_download_release_asset "${release}" "linux" "arm64" "tar.gz" "application/gzip" run_test_case test_download_release_asset "${release}" "linux" "arm64" "rpm" "application/x-rpm" run_test_case test_download_release_asset "${release}" "linux" "arm64" "deb" "application/vnd.debian.binary-package" + +# releases before VERIFY_SIGN_BUNDLE_VERSION are signed with a separate .sig and .pem instead of a sigstore bundle, +# so keep exercising that path against a fixed release +legacy_release="v1.52.0" + +run_test_case test_download_release_asset "${legacy_release}" "linux" "amd64" "tar.gz" "application/gzip" diff --git a/test/install/4_prep_signature_verification_test.sh b/test/install/4_prep_signature_verification_test.sh index 401364ac0..545aaf4b9 100755 --- a/test/install/4_prep_signature_verification_test.sh +++ b/test/install/4_prep_signature_verification_test.sh @@ -87,3 +87,61 @@ test_prep_signature_verification() { } run_test_case test_prep_signature_verification + +# ensure the verification material is passed through to cosign and the old-cosign hint only shows when warranted +test_verify_sign() { + # verify_sign [checksums-file-path] [verification-material-flags...] + + OG_COSIGN_BINARY=${COSIGN_BINARY} + + tmpdir=$(mktemp -d) + args_file="${tmpdir}/args" + hint="requires cosign v2.5.0 or newer" + + # stub cosign that records its arguments, then prints STUB_OUTPUT and exits with STUB_EXIT + COSIGN_BINARY="${tmpdir}/cosign" + cat > "${COSIGN_BINARY}" < "${args_file}" +echo "\${STUB_OUTPUT}" +exit "\${STUB_EXIT}" +STUB + chmod +x "${COSIGN_BINARY}" + + identity="--certificate-identity https://github.com/${OWNER}/${REPO}/.github/workflows/release.yaml@refs/heads/main --certificate-oidc-issuer https://token.actions.githubusercontent.com" + + # bundle verification succeeds + STUB_OUTPUT="Verified OK" STUB_EXIT=0 verify_sign "checksums.txt" --bundle "checksums.txt.sigstore.json" >/dev/null 2>&1 + assertEquals "0" "$?" "bundle verification should succeed" + assertEquals "verify-blob checksums.txt --bundle checksums.txt.sigstore.json ${identity}" "$(cat "${args_file}")" "unexpected cosign args for bundle" + + # legacy verification succeeds + STUB_OUTPUT="Verified OK" STUB_EXIT=0 verify_sign "checksums.txt" --certificate "c.pem" --signature "c.sig" >/dev/null 2>&1 + assertEquals "0" "$?" "legacy verification should succeed" + assertEquals "verify-blob checksums.txt --certificate c.pem --signature c.sig ${identity}" "$(cat "${args_file}")" "unexpected cosign args for legacy" + + # cosign too old to read the bundle: fail with the hint (this is what cosign v2.2.4 through v2.4.1 print) + output=$(STUB_OUTPUT="Error: bundle does not contain cert for verification, please provide public key" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "old cosign should fail verification" + assertContains "${output}" "${hint}" "old cosign should get the version hint" + + # bad signature (e.g. tampered checksums): fail without the hint + output=$(STUB_OUTPUT="Error: invalid signature when validating ASN.1 encoded signature" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "bad signature should fail verification" + assertNotContains "${output}" "${hint}" "bad signature should not get the version hint" + + # unparseable bundle (e.g. a 404 body): fail without the hint + output=$(STUB_OUTPUT="Error: invalid character 'N' looking for beginning of value" STUB_EXIT=1 verify_sign "checksums.txt" --bundle "b.json" 2>&1) + assertEquals "1" "$?" "unparseable bundle should fail verification" + assertNotContains "${output}" "${hint}" "unparseable bundle should not get the version hint" + + # legacy failure never gets the bundle hint, even with the same output + output=$(STUB_OUTPUT="Error: bundle does not contain cert for verification, please provide public key" STUB_EXIT=1 verify_sign "checksums.txt" --certificate "c.pem" --signature "c.sig" 2>&1) + assertEquals "1" "$?" "legacy failure should fail verification" + assertNotContains "${output}" "${hint}" "legacy failure should not get the version hint" + + COSIGN_BINARY=${OG_COSIGN_BINARY} + rm -rf -- "${tmpdir}" +} + +run_test_case test_verify_sign diff --git a/test/install/5_download_asset_failures_test.sh b/test/install/5_download_asset_failures_test.sh new file mode 100755 index 000000000..edc51234f --- /dev/null +++ b/test/install/5_download_asset_failures_test.sh @@ -0,0 +1,84 @@ +. test_harness.sh + +# serve release assets from a local fixture dir instead of the network (a missing file is a failed download) +http_download() { + src="${FIXTURE_DIR}/${2##*/}" + [ -f "${src}" ] || return 1 + cp "${src}" "$1" +} + +# ensure every missing or mismatched piece of a release stops download_asset before anything is installed +test_download_asset_failures() { + # download_asset [release-url-prefix] [download-path] [name] [os] [arch] [version] [format] + + # we are expecting error messages, which is confusing to look at in passing tests... disable logging for now + log_set_priority -1 + + OG_COSIGN_BINARY=${COSIGN_BINARY} + + tmpdir=$(mktemp -d) + FIXTURE_DIR="${tmpdir}/release" + dest="${tmpdir}/dest" + args_file="${tmpdir}/cosign-args" + mkdir -p "${FIXTURE_DIR}" "${dest}" + + # stub cosign that records it was called and always verifies + COSIGN_BINARY="${tmpdir}/cosign" + printf '#!/bin/sh\necho "$@" > "%s"\n' "${args_file}" > "${COSIGN_BINARY}" + chmod +x "${COSIGN_BINARY}" + + asset="syft_1.54.0_linux_amd64.tar.gz" + echo "the real asset" > "${FIXTURE_DIR}/${asset}" + echo "$(hash_sha256 "${FIXTURE_DIR}/${asset}") ${asset}" > "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" + echo "{}" > "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" + + run() { + rm -rf "${dest:?}"/* "${args_file}" + download_asset "https://example.invalid/v1.54.0" "${dest}" "syft" "linux" "amd64" "1.54.0" "tar.gz" + } + + # happy path, verified and unverified + VERIFY_SIGN=false + asset_path=$(run) + assertEquals "0" "$?" "complete release should download" + assertEquals "${dest}/${asset}" "${asset_path}" "unexpected asset path" + + VERIFY_SIGN=true + run >/dev/null + assertEquals "0" "$?" "complete release should verify" + assertContains "$(cat "${args_file}")" "--bundle ${dest}/syft_1.54.0_checksums.txt.sigstore.json" "bundle should be passed to cosign" + + # missing bundle: fail before cosign is ever called + mv "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" "${tmpdir}/bundle" + run >/dev/null + assertEquals "1" "$?" "missing bundle should fail" + assertFilesDoesNotExist "${args_file}" "cosign should not be called without a bundle" + mv "${tmpdir}/bundle" "${FIXTURE_DIR}/syft_1.54.0_checksums.txt.sigstore.json" + + for VERIFY_SIGN in true false; do + # asset does not match the checksums file + echo "a tampered asset" > "${FIXTURE_DIR}/${asset}" + run >/dev/null + assertEquals "1" "$?" "checksum mismatch should fail (verify=${VERIFY_SIGN})" + echo "the real asset" > "${FIXTURE_DIR}/${asset}" + + # missing asset + mv "${FIXTURE_DIR}/${asset}" "${tmpdir}/asset" + run >/dev/null + assertEquals "1" "$?" "missing asset should fail (verify=${VERIFY_SIGN})" + mv "${tmpdir}/asset" "${FIXTURE_DIR}/${asset}" + + # missing checksums file + mv "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" "${tmpdir}/checksums" + run >/dev/null + assertEquals "1" "$?" "missing checksums should fail (verify=${VERIFY_SIGN})" + mv "${tmpdir}/checksums" "${FIXTURE_DIR}/syft_1.54.0_checksums.txt" + done + + COSIGN_BINARY=${OG_COSIGN_BINARY} + rm -rf -- "${tmpdir}" + + log_set_priority 0 +} + +run_test_case test_download_asset_failures diff --git a/test/install/environments/Dockerfile-alpine-3.6 b/test/install/environments/Dockerfile-alpine-3.6 index 69df678a6..31cfbae30 100644 --- a/test/install/environments/Dockerfile-alpine-3.6 +++ b/test/install/environments/Dockerfile-alpine-3.6 @@ -1,5 +1,9 @@ FROM alpine:3.6 RUN apk update && apk add python3 wget curl unzip make ca-certificates jq -RUN curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64" && \ - mv cosign-linux-amd64 /usr/local/bin/cosign && \ +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +RUN curl -sSfL -o /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" && \ + echo "${COSIGN_SHA256} /usr/local/bin/cosign" | sha256sum -c - && \ chmod +x /usr/local/bin/cosign diff --git a/test/install/environments/Dockerfile-busybox-1.36 b/test/install/environments/Dockerfile-busybox-1.36 index e2cf2d722..2f9747f90 100644 --- a/test/install/environments/Dockerfile-busybox-1.36 +++ b/test/install/environments/Dockerfile-busybox-1.36 @@ -5,17 +5,22 @@ RUN apk update && apk add ca-certificates # so let docker pull the image that matches the hosts architecture first and then pull the correct asset FROM busybox:1.36.1-musl +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256_AMD64=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +ARG COSIGN_SHA256_ARM64=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a RUN ARCH=$(uname -m) && \ if [ "$ARCH" = "x86_64" ]; then \ - COSIGN_ARCH="amd64"; \ + COSIGN_ARCH="amd64"; COSIGN_SHA256="${COSIGN_SHA256_AMD64}"; \ elif [ "$ARCH" = "aarch64" ]; then \ - COSIGN_ARCH="arm64"; \ + COSIGN_ARCH="arm64"; COSIGN_SHA256="${COSIGN_SHA256_ARM64}"; \ else \ echo "Unsupported architecture: $ARCH" && exit 1; \ fi && \ - echo "Downloading cosign for $COSIGN_ARCH" && \ - wget https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-${COSIGN_ARCH} && \ - mv cosign-linux-${COSIGN_ARCH} /bin/cosign && \ + echo "Downloading cosign ${COSIGN_VERSION} for $COSIGN_ARCH" && \ + wget -O /bin/cosign https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${COSIGN_ARCH} && \ + echo "${COSIGN_SHA256} /bin/cosign" | sha256sum -c - && \ chmod +x /bin/cosign COPY --from=certs /etc/ssl/certs /etc/ssl/certs diff --git a/test/install/environments/Dockerfile-ubuntu-20.04 b/test/install/environments/Dockerfile-ubuntu-20.04 index 87c09e66f..967610232 100644 --- a/test/install/environments/Dockerfile-ubuntu-20.04 +++ b/test/install/environments/Dockerfile-ubuntu-20.04 @@ -1,5 +1,9 @@ FROM --platform=linux/amd64 ubuntu:20.04@sha256:33a5cc25d22c45900796a1aca487ad7a7cb09f09ea00b779e3b2026b4fc2faba RUN apt update -y && apt install make python3 curl unzip jq -y -RUN LATEST_VERSION=$(curl https://api.github.com/repos/sigstore/cosign/releases/latest | grep tag_name | cut -d : -f2 | tr -d "v\", ") && \ - curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign_${LATEST_VERSION}_amd64.deb" && \ - dpkg -i cosign_${LATEST_VERSION}_amd64.deb +# cosign is pinned (and checksum verified) so the install tests always run with a cosign that can verify +# sigstore bundles (releases >= v1.53.0 need v2.5.0 or newer) +ARG COSIGN_VERSION=v3.1.3 +ARG COSIGN_SHA256=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 +RUN curl -sSfL -o /usr/local/bin/cosign "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" && \ + echo "${COSIGN_SHA256} /usr/local/bin/cosign" | sha256sum -c - && \ + chmod +x /usr/local/bin/cosign diff --git a/test/install/test_harness.sh b/test/install/test_harness.sh index 8db34de36..e38b8b092 100644 --- a/test/install/test_harness.sh +++ b/test/install/test_harness.sh @@ -56,6 +56,31 @@ assertFileExists() { fi } +assertContains() { + haystack=$1 + needle=$2 + msg=$3 + case "$haystack" in + *"$needle"*) ;; + *) + echo "assertContains failed: '$needle' not found in '$haystack' $msg" + exit 2 + ;; + esac +} + +assertNotContains() { + haystack=$1 + needle=$2 + msg=$3 + case "$haystack" in + *"$needle"*) + echo "assertNotContains failed: '$needle' found in '$haystack' $msg" + exit 2 + ;; + esac +} + assertFilesEqual() { want=$1 got=$2