diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 1c86a2265..b2f7d4e0b 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -41,6 +41,16 @@ jobs: terraform: ./**/terraform/** nix: ./**/nix/** + python-sigstore: + needs: [detect-changes] + if: contains(fromJSON(needs.detect-changes.outputs.features), 'python') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: "Test Python Sigstore verification" + run: bash test/python/sigstore_verification_test.sh + test: needs: [detect-changes] runs-on: ubuntu-latest diff --git a/src/python/NOTES.md b/src/python/NOTES.md index c1d3ca0b2..9e8d3a77c 100644 --- a/src/python/NOTES.md +++ b/src/python/NOTES.md @@ -9,3 +9,7 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise ## Additional Python versions `additionalVersions` accepts a comma-separated list. Comma splitting is scoped to reading this list so package-manager commands keep normal shell word splitting during each installation. + +## Source Verification + +The feature verifies the authenticity and integrity of downloaded Python source archives. Python 3.14 and later use Sigstore (via Cosign) instead of GPG. For Python 3.14 and later, verification follows a fail-open policy: unsuccessful or unavailable verification is reported as a warning and does not block installation. \ No newline at end of file diff --git a/src/python/devcontainer-feature.json b/src/python/devcontainer-feature.json index 8012bbc8e..218977bb3 100644 --- a/src/python/devcontainer-feature.json +++ b/src/python/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "python", - "version": "1.8.1", + "version": "1.9.0", "name": "Python", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/python", "description": "Installs the provided version of Python, as well as PIPX, and other common Python utilities. JupyterLab is conditionally installed with the python feature. Note: May require source code compilation.", diff --git a/src/python/install.sh b/src/python/install.sh index 3eb028b80..94a907edf 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -16,7 +16,8 @@ PYTHON_INSTALL_PATH="${INSTALLPATH:-"/usr/local/python"}" OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" PYTHON_LAST_KNOWN_VERSION="3.14.7" OPENSSL3_LAST_KNOWN_VERSION="3.0.22" -COSIGN_LAST_KNOWN_VERSION="3.1.3" +COSIGN_PINNED_VERSION="3.1.4" +COSIGN_LAST_KNOWN_VERSION="3.1.4" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} @@ -40,6 +41,22 @@ KEYSERVER_PROXY="${HTTPPROXY:-"${HTTP_PROXY:-""}"}" set -e +# Keep Cosign binaries and downloaded Sigstore bundles available across Python installs. +COSIGN_CACHE_DIR="" +COSIGN_LATEST_VERSION="" +COSIGN_LATEST_READY="false" +COSIGN_PINNED_BINARY="" +COSIGN_LATEST_BINARY="" +COSIGN_ACTIVE_BINARY="" + +cleanup_cosign_cache() { + if [ -n "${COSIGN_CACHE_DIR}" ] && [ -d "${COSIGN_CACHE_DIR}" ]; then + rm -rf -- "${COSIGN_CACHE_DIR}" || echo "(!) WARNING: Failed to remove temporary Cosign cache." >&2 + fi + return 0 +} +trap cleanup_cosign_cache EXIT + if [ "$(id -u)" -ne 0 ]; then echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 @@ -517,101 +534,267 @@ get_architecture() { echo ${architecture} } -# Install cosign with multi-distro support -install_cosign() { +# Fetch Python.org's signer table and return the identity and issuer for a series. +get_sigstore_signer_policy() { + local series="$1" + local policy_file="$2" + local policy_attempt=1 - COSIGN_VERSION="latest" - local cosign_url='https://github.com/sigstore/cosign' - local architecture=$(get_architecture) + if [ ! -s "${policy_file}" ]; then + while [ "${policy_attempt}" -le 3 ]; do + if curl -fsSL --connect-timeout 20 --max-time 60 -o "${policy_file}" "https://www.python.org/downloads/metadata/sigstore/"; then + break + fi + echo "(!) Signer policy download attempt ${policy_attempt}/3 failed" >&2 + rm -f "${policy_file}" + policy_attempt=$((policy_attempt + 1)) + if [ "${policy_attempt}" -le 3 ]; then + sleep 2 + fi + done + fi - find_version_from_git_tags COSIGN_VERSION "${cosign_url}" "tags/v" "." "false" "" "${COSIGN_LAST_KNOWN_VERSION}" + if [ ! -s "${policy_file}" ]; then + echo "(!) Failed to download Python.org's Sigstore signer policy" >&2 + return 1 + fi - # Remove 'v' prefix if present for download URL - local version_for_url="${COSIGN_VERSION#v}" + local policy + if ! policy="$(awk -v wanted_series="${series}" ' +function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value +} +function process_row(value, cells, count, field_number) { + sub(/^.*]*>/, "", value) + sub(/<\/tr>.*/, "", value) + gsub(/<\/t[dh][^>]*>/, "\034", value) + gsub(/<[^>]*>/, "", value) + gsub(/ | /, " ", value) + value = trim(value) + sub(/\034$/, "", value) + count = split(value, cells, "\034") + for (field_number = 1; field_number <= count; field_number++) { + cells[field_number] = trim(cells[field_number]) + } + if (count == 4 && cells[1] == "Release" && cells[2] == "PEP" && cells[3] == "Release manager" && cells[4] == "OIDC Issuer") { + in_policy_table = 1 + } else if (in_policy_table && count == 4 && cells[1] == wanted_series) { + match_count++ + expected_identity = cells[3] + expected_issuer = cells[4] + } +} +/])/ { in_table = 1; in_policy_table = 0 } +{ + if (in_table) { + if (row == "" && /])/) { + row = $0 + } else if (row != "") { + row = row " " $0 + } + if (row != "" && /<\/tr>/) { + process_row(row) + row = "" + } + } + if (/<\/table>/) { + in_table = 0 + in_policy_table = 0 + } +} +END { + if (match_count != 1 || expected_identity !~ /^[^[:space:]<>@]+@[^[:space:]<>@]+$/ || expected_issuer !~ /^https:\/\/[^[:space:]<>]+$/) { + exit 1 + } + printf "%s\t%s\n", expected_identity, expected_issuer +} +' "${policy_file}")"; then + echo "(!) Could not parse Python.org's Sigstore signer policy for Python ${series}" >&2 + return 1 + fi + printf '%s\n' "${policy}" +} - local cosign_filename="/tmp/cosign_${version_for_url}_${architecture}.deb" - local cosign_url="https://github.com/sigstore/cosign/releases/download/v${version_for_url}/cosign_${version_for_url}_${architecture}.deb" - - echo "Downloading cosign from: ${cosign_url}" - - if curl -L -f --fail-with-body "${cosign_url}" -o "$cosign_filename" 2>/dev/null; then - echo "(*) Successfully downloaded cosign v${COSIGN_VERSION}" - else - echo -e "\n(!) Failed to fetch cosign v${COSIGN_VERSION}..." - # Try previous version - find_prev_version_from_git_tags COSIGN_VERSION "https://github.com/sigstore/cosign" - echo -e "\nAttempting to install previous cosign ${COSIGN_VERSION} version as fallback mechanism" - - version_for_url="${COSIGN_VERSION#v}" - cosign_filename="/tmp/cosign_${version_for_url}_${architecture}.deb" - cosign_url="https://github.com/sigstore/cosign/releases/download/v${version_for_url}/cosign_${version_for_url}_${architecture}.deb" - - if ! curl -L -f --fail-with-body "${cosign_url}" -o "$cosign_filename" 2>/dev/null; then - echo "(!) Failed to download cosign v${COSIGN_VERSION} as fallback" +# Prepare a trusted pinned Cosign binary and, when available, a verified latest v3 binary. +prepare_cosign_binaries() { + local cosign_architecture="$1" + local checksum="$2" + local cosign_repo="https://github.com/sigstore/cosign" + local asset_name="cosign-linux-${cosign_architecture}" + local pinned_tmp latest_tmp latest_bundle version_result + + if [ -z "${COSIGN_CACHE_DIR}" ]; then + COSIGN_CACHE_DIR="$(mktemp -d)" || { + echo "(!) Failed to create temporary Cosign cache directory." >&2 + return 1 + } + chmod 700 "${COSIGN_CACHE_DIR}" + fi + + COSIGN_PINNED_BINARY="${COSIGN_CACHE_DIR}/cosign-${COSIGN_PINNED_VERSION}-${cosign_architecture}" + + # Bootstrap trust: the pinned binary is verified against a checksum reviewed in this feature. + if [ ! -x "${COSIGN_PINNED_BINARY}" ]; then + pinned_tmp="${COSIGN_PINNED_BINARY}.download" + if ! curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 \ + -o "${pinned_tmp}" "https://github.com/sigstore/cosign/releases/download/v${COSIGN_PINNED_VERSION}/${asset_name}"; then + echo "(!) Failed to download pinned Cosign v${COSIGN_PINNED_VERSION}." >&2 + rm -f "${pinned_tmp}" + return 1 + fi + if ! echo "${checksum} ${pinned_tmp}" | sha256sum -c -; then + echo "(!) Pinned Cosign v${COSIGN_PINNED_VERSION} checksum verification failed." >&2 + rm -f "${pinned_tmp}" + return 1 + fi + if ! chmod 0755 "${pinned_tmp}" || ! mv "${pinned_tmp}" "${COSIGN_PINNED_BINARY}"; then + echo "(!) Failed to prepare pinned Cosign v${COSIGN_PINNED_VERSION}." >&2 + rm -f "${pinned_tmp}" return 1 fi fi - - # Install the package - if [ -f "$cosign_filename" ]; then - dpkg -i "$cosign_filename" - rm "$cosign_filename" - echo "Installation of cosign succeeded with ${COSIGN_VERSION}." - else - echo "(!) Failed to install cosign package" - return 1 + + if [ "${COSIGN_LATEST_READY}" != "true" ]; then + # Keep automatic updates on the v3 line so this feature's verify-blob CLI remains compatible. + # Run the existing resolver in a subshell so a lookup failure does not terminate the installer. + if version_result="$( ( COSIGN_VERSION="3"; find_version_from_git_tags COSIGN_VERSION "${cosign_repo}" "tags/v" "." "false" "" "${COSIGN_LAST_KNOWN_VERSION}" ) 2>/dev/null )"; then + COSIGN_LATEST_VERSION="${version_result#COSIGN_VERSION=}" + else + COSIGN_LATEST_VERSION="${COSIGN_PINNED_VERSION}" + echo "(!) Could not resolve the latest Cosign v3 release; using pinned v${COSIGN_PINNED_VERSION}." >&2 + fi + if [[ ! "${COSIGN_LATEST_VERSION}" =~ ^3\.[0-9]+\.[0-9]+$ ]]; then + COSIGN_LATEST_VERSION="${COSIGN_PINNED_VERSION}" + echo "(!) Cosign version lookup returned an invalid v3 tag; using pinned v${COSIGN_PINNED_VERSION}." >&2 + fi + COSIGN_LATEST_READY="true" + + if [ "${COSIGN_LATEST_VERSION}" = "${COSIGN_PINNED_VERSION}" ]; then + # The pinned binary is already the latest; do not download it twice. + COSIGN_LATEST_BINARY="${COSIGN_PINNED_BINARY}" + else + COSIGN_LATEST_BINARY="${COSIGN_CACHE_DIR}/cosign-${COSIGN_LATEST_VERSION}-${cosign_architecture}" + latest_tmp="${COSIGN_LATEST_BINARY}.download" + latest_bundle="${COSIGN_CACHE_DIR}/${asset_name}.sigstore.json" + + if [ ! -x "${COSIGN_LATEST_BINARY}" ]; then + if curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 \ + -o "${latest_tmp}" "https://github.com/sigstore/cosign/releases/download/v${COSIGN_LATEST_VERSION}/${asset_name}" \ + && curl -fsSL --connect-timeout 20 --max-time 60 --retry 2 --retry-delay 2 \ + -o "${latest_bundle}" "https://github.com/sigstore/cosign/releases/download/v${COSIGN_LATEST_VERSION}/${asset_name}.sigstore.json"; then + if ! chmod 0755 "${latest_tmp}"; then + echo "(!) Failed to prepare downloaded Cosign v${COSIGN_LATEST_VERSION}; retaining pinned Cosign." >&2 + rm -f "${latest_tmp}" "${latest_bundle}" + COSIGN_LATEST_BINARY="${COSIGN_PINNED_BINARY}" + else + echo "(*) Verifying downloaded Cosign v${COSIGN_LATEST_VERSION} with pinned Cosign v${COSIGN_PINNED_VERSION}..." + if "${COSIGN_PINNED_BINARY}" verify-blob "${latest_tmp}" \ + --bundle "${latest_bundle}" \ + --certificate-identity "keyless@projectsigstore.iam.gserviceaccount.com" \ + --certificate-oidc-issuer "https://accounts.google.com" \ + && mv "${latest_tmp}" "${COSIGN_LATEST_BINARY}"; then + echo "(*) Cosign v${COSIGN_LATEST_VERSION} signature verification successful." + else + echo "(!) Latest Cosign signature verification failed; retaining pinned Cosign." >&2 + rm -f "${latest_tmp}" "${COSIGN_LATEST_BINARY}" + COSIGN_LATEST_BINARY="${COSIGN_PINNED_BINARY}" + fi + fi + else + echo "(!) Failed to download latest Cosign v${COSIGN_LATEST_VERSION} or its Sigstore bundle; retaining pinned Cosign." >&2 + rm -f "${latest_tmp}" "${latest_bundle}" + COSIGN_LATEST_BINARY="${COSIGN_PINNED_BINARY}" + fi + fi + rm -f "${latest_bundle}" + fi fi + COSIGN_ACTIVE_BINARY="${COSIGN_LATEST_BINARY:-${COSIGN_PINNED_BINARY}}" + return 0 } -# COSIGN signature verification for python versions >= 3.14 +# COSIGN signature verification for Python versions >= 3.14 cosign_verification() { local VERSION="$1" + local series="${VERSION%.*}" + local identity issuer signer_policy + local architecture cosign_architecture checksum + local bundle_file bundle_attempt=1 + + architecture="$(get_architecture)" || return 1 + case "${architecture}" in + amd64) cosign_architecture="amd64"; checksum="424e2c97d0b45fd7a6b608a87f090f35633f4c23ece5f092d48bba11aaf4bbfa" ;; + arm64) cosign_architecture="arm64"; checksum="ade83b7aac62e131cacfe96d958948b2150cc9efec46a570a2649b8e27dedad2" ;; + armhf) cosign_architecture="arm"; checksum="09e77a313910cf5c4774508d85d017d9c60983f41bf657f83ecbd3892f7952cb" ;; + *) echo "(!) No checksum-verified pinned Cosign binary available for architecture ${architecture}." >&2; return 1 ;; + esac - # Ensure cosign is installed - if ! type cosign > /dev/null 2>&1; then - echo "(*) cosign not found, installing..." - if ! install_cosign; then - echo "(!) Failed to install cosign" - return 1 - fi - else - echo "(*) cosign is already available on the system" + if [ -z "${COSIGN_CACHE_DIR}" ]; then + COSIGN_CACHE_DIR="$(mktemp -d)" || return 1 + chmod 700 "${COSIGN_CACHE_DIR}" fi - - echo "(*) Attempting COSIGN verification for Python ${VERSION}..." - - # Check if COSIGN signature files exist (these don't exist yet for Python releases) - local cosign_sig_url="${cpython_tgz_url}.sig" - local cosign_cert_url="${cpython_tgz_url}.pem" - - # Download COSIGN signature and certificate files with proper error handling - echo "(*) Checking for cosign signature files..." - if ! curl -sSL -f --fail-with-body -o "/tmp/python-src/${cpython_tgz_filename}.sig" "${cosign_sig_url}" 2>/dev/null; then - echo "(!) COSIGN signature file not available for Python ${VERSION}" - echo " Signature URL: ${cosign_sig_url}" + + # Reuse Python.org's signer table from the same cache across additional Python versions. + if ! signer_policy="$(get_sigstore_signer_policy "${series}" "${COSIGN_CACHE_DIR}/python-sigstore-policy.html")"; then + echo "(!) Could not resolve a Sigstore signer policy for Python ${series}." >&2 return 1 fi - - if ! curl -sSL -f --fail-with-body -o "/tmp/python-src/${cpython_tgz_filename}.pem" "${cosign_cert_url}" 2>/dev/null; then - echo "(!) COSIGN certificate file not available for Python ${VERSION}" - echo " Certificate URL: ${cosign_cert_url}" + IFS=$'\t' read -r identity issuer <<< "${signer_policy}" + + if ! prepare_cosign_binaries "${cosign_architecture}" "${checksum}"; then return 1 fi - - # Perform COSIGN verification - if cosign verify-blob \ - --certificate "/tmp/python-src/${cpython_tgz_filename}.pem" \ - --signature "/tmp/python-src/${cpython_tgz_filename}.sig" \ - --certificate-identity-regexp=".*" \ - --certificate-oidc-issuer-regexp=".*" \ + + bundle_file="${COSIGN_CACHE_DIR}/python-${VERSION}.sigstore" + while [ "${bundle_attempt}" -le 3 ]; do + if curl -fsSL --connect-timeout 20 --max-time 60 --retry 1 --retry-delay 2 \ + -o "${bundle_file}" "${cpython_tgz_url}.sigstore"; then + break + fi + echo "(!) Sigstore bundle download attempt ${bundle_attempt}/3 failed" >&2 + rm -f "${bundle_file}" + bundle_attempt=$((bundle_attempt + 1)) + if [ "${bundle_attempt}" -le 3 ]; then + sleep 2 + fi + done + if [ "${bundle_attempt}" -gt 3 ]; then + echo "(!) Failed to download the Sigstore bundle for Python ${VERSION}." >&2 + rm -f "${bundle_file}" + return 1 + fi + + echo "(*) Verifying Python ${VERSION} with Sigstore signer ${identity}..." + if "${COSIGN_ACTIVE_BINARY}" verify-blob \ + --bundle "${bundle_file}" \ + --certificate-identity "${identity}" \ + --certificate-oidc-issuer "${issuer}" \ "/tmp/python-src/${cpython_tgz_filename}"; then - echo "(*) COSIGN signature verification successful" + rm -f "${bundle_file}" + echo "(*) Python ${VERSION} Sigstore verification successful." return 0 - else - echo "(!) COSIGN signature verification failed" - return 1 fi + + # A verified latest Cosign release can still have a regression; retry with the pinned verifier. + if [ "${COSIGN_ACTIVE_BINARY}" != "${COSIGN_PINNED_BINARY}" ]; then + echo "(!) Latest Cosign failed to verify Python ${VERSION}; retrying with pinned Cosign v${COSIGN_PINNED_VERSION}." >&2 + if "${COSIGN_PINNED_BINARY}" verify-blob \ + --bundle "${bundle_file}" \ + --certificate-identity "${identity}" \ + --certificate-oidc-issuer "${issuer}" \ + "/tmp/python-src/${cpython_tgz_filename}"; then + rm -f "${bundle_file}" + echo "(*) Python ${VERSION} Sigstore verification successful with pinned Cosign." + return 0 + fi + fi + + rm -f "${bundle_file}" + echo "(!) Sigstore verification failed with both available Cosign verifiers." >&2 + return 1 } # GPG verification for python versions < 3.14 @@ -684,10 +867,9 @@ install_from_source() { if cosign_verification "$VERSION"; then echo "(*) COSIGN verification successful." else - echo "(*) COSIGN verification failed or not available for Python ${VERSION}" - echo "(*) WARNING: Installing Python ${VERSION} without signature verification" - echo "(*) This is expected for newly released versions where cosign signatures are not yet available" - echo "(*) Python 3.14+ discontinued PGP signatures in favor of cosign, but cosign signatures may take time to be published" + echo "(!) WARNING: Sigstore verification failed or could not be completed for Python ${VERSION}." >&2 + echo "(!) WARNING: Python ${VERSION} source authenticity and integrity have NOT been verified." >&2 + echo "(!) Continuing installation under the existing fail-open policy." >&2 fi else echo "(*) Python < 3.14 detected. Using GPG signature verification..." diff --git a/test/python/sigstore_verification_test.sh b/test/python/sigstore_verification_test.sh new file mode 100644 index 000000000..427899214 --- /dev/null +++ b/test/python/sigstore_verification_test.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +install_script="${INSTALL_SH:-${repository_root}/src/python/install.sh}" +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT +mkdir -p "${work_dir}/tmp" +export TMPDIR="${work_dir}/tmp" +export MOCK_LOG="${work_dir}/cosign-args" +export MOCK_CALLS="${work_dir}/cosign-calls" +export MOCK_REJECT="false" +export MOCK_REJECT_LATEST="false" +export MOCK_REJECT_RELEASE="false" +mock_architecture="x86_64" +mock_download_failure="" +mock_checksum_failure="false" +mock_resolved_version="3.1.4" +mock_resolver_failure="false" +download_log="${work_dir}/downloads" +checksum_log="${work_dir}/checksum" +COSIGN_PINNED_VERSION="3.1.4" +COSIGN_LAST_KNOWN_VERSION="${COSIGN_PINNED_VERSION}" +COSIGN_CACHE_DIR="" +COSIGN_LATEST_VERSION="" +COSIGN_LATEST_READY="false" +COSIGN_PINNED_BINARY="" +COSIGN_LATEST_BINARY="" +COSIGN_ACTIVE_BINARY="" + +cat > "${work_dir}/cosign" <<'COSIGN' +#!/usr/bin/env bash +printf '%s\n' "$@" > "${MOCK_LOG}" +printf '%s|%s\n' "$(basename "$0")" "$*" >> "${MOCK_CALLS}" +if [[ "$*" == *'keyless@projectsigstore.iam.gserviceaccount.com'* ]]; then + [[ "${MOCK_REJECT_RELEASE}" != "true" ]] +elif [[ "$(basename "$0")" == cosign-3.2.0-* ]]; then + [[ "${MOCK_REJECT_LATEST}" != "true" && "${MOCK_REJECT}" != "true" ]] +else + [[ "${MOCK_REJECT}" != "true" ]] +fi +COSIGN +chmod 0755 "${work_dir}/cosign" + +uname() { printf '%s\n' "${mock_architecture}"; } +sleep() { :; } + +find_version_from_git_tags() { + local variable_name="$1" + [[ "${7:-}" == "${COSIGN_LAST_KNOWN_VERSION}" ]] || return 1 + [[ "${mock_resolver_failure}" != "true" ]] || return 1 + printf '%s=%s\n' "${variable_name}" "${mock_resolved_version}" +} + +curl() { + local destination="" url="" + while [[ $# -gt 0 ]]; do + if [[ "$1" == "-o" ]]; then + destination="$2" + shift 2 + else + url="$1" + shift + fi + done + printf '%s\n' "${url}" >> "${download_log}" + if [[ -n "${mock_download_failure}" && "${url}" == *"${mock_download_failure}"* ]]; then + return 22 + fi + if [[ "${url}" == */downloads/metadata/sigstore/ ]]; then + cat > "${destination}" <<'POLICY' + + + + + + + +
ReleasePEPRelease managerOIDC Issuer
3.14PEP 745hugo@python.orghttps://github.com/login/oauth
3.15PEP 790hugo@python.orghttps://github.com/login/oauth
3.16PEP 826savannah@python.orghttps://github.com/login/oauth
+POLICY + elif [[ "${url}" == *.sigstore || "${url}" == *.sigstore.json ]]; then + printf 'bundle\n' > "${destination}" + else + cp "${work_dir}/cosign" "${destination}" + fi +} + +sha256sum() { + cat >> "${checksum_log}" + [[ "${mock_checksum_failure}" != "true" ]] +} + +# Stop at the GPG function itself, independent of the preceding comment's case. +helper_functions="$(awk ' + /^get_architecture\(\)[[:space:]]*\{[[:space:]]*$/ { copying = 1 } + copying && /^gpg_verification\(\)[[:space:]]*\{[[:space:]]*$/ { exit } + copying { print } +' "${install_script}")" + +[[ "${helper_functions}" == *'cosign_verification() {'* ]] || { + echo "Failed to extract Sigstore helpers from ${install_script}" >&2 + exit 1 +} + +source /dev/stdin <<< "${helper_functions}" + +cpython_tgz_filename="Python-3.14.10.tgz" +cpython_tgz_url="https://www.python.org/ftp/python/3.14.10/${cpython_tgz_filename}" +policy_url="https://www.python.org/downloads/metadata/sigstore/" + +reset_cache() { + if [[ -n "${COSIGN_CACHE_DIR}" && -d "${COSIGN_CACHE_DIR}" ]]; then + rm -rf -- "${COSIGN_CACHE_DIR}" + fi + COSIGN_CACHE_DIR="" + COSIGN_LATEST_VERSION="" + COSIGN_LATEST_READY="false" + COSIGN_PINNED_BINARY="" + COSIGN_LATEST_BINARY="" + COSIGN_ACTIVE_BINARY="" + [[ -z "$(find "${TMPDIR}" -mindepth 1 -print -quit)" ]] +} +assert_downloaded() { grep -Fqx -- "$1" "${download_log}"; } +assert_called() { grep -Fq -- "$1" "${MOCK_CALLS}"; } + +# Pinned verifier: signer selection, arguments, checksum, and cache reuse. +cosign_verification 3.14.10 +assert_downloaded "${policy_url}" +assert_downloaded 'https://github.com/sigstore/cosign/releases/download/v3.1.4/cosign-linux-amd64' +assert_downloaded "${cpython_tgz_url}.sigstore" +grep -Fq -- '424e2c97d0b45fd7a6b608a87f090f35633f4c23ece5f092d48bba11aaf4bbfa' "${checksum_log}" +grep -Fxq -- 'hugo@python.org' "${MOCK_LOG}" +grep -Fxq -- 'https://github.com/login/oauth' "${MOCK_LOG}" +cosign_verification 3.15.0 +grep -Fxq -- 'hugo@python.org' "${MOCK_LOG}" +cosign_verification 3.16.0 +grep -Fxq -- 'savannah@python.org' "${MOCK_LOG}" +[[ "$(grep -Fc -- "${policy_url}" "${download_log}")" -eq 1 ]] +[[ "$(grep -Fc -- '/v3.1.4/cosign-linux-amd64' "${download_log}")" -eq 1 ]] +reset_cache + +mock_architecture="armv7l" +cosign_verification 3.14.10 +assert_downloaded 'https://github.com/sigstore/cosign/releases/download/v3.1.4/cosign-linux-arm' +grep -Fq -- '09e77a313910cf5c4774508d85d017d9c60983f41bf657f83ecbd3892f7952cb' "${checksum_log}" +reset_cache +mock_architecture="x86_64" + +export MOCK_REJECT="true" +if cosign_verification 3.14.10; then exit 1; fi +export MOCK_REJECT="false" +reset_cache + +mock_checksum_failure="true" +if cosign_verification 3.14.10; then exit 1; fi +mock_checksum_failure="false" +reset_cache + +mock_download_failure="/downloads/metadata/sigstore/" +if cosign_verification 3.14.10; then exit 1; fi +reset_cache +mock_download_failure=".sigstore" +if cosign_verification 3.14.10; then exit 1; fi +reset_cache +mock_download_failure="cosign-linux-amd64" +if cosign_verification 3.14.10; then exit 1; fi +mock_download_failure="" +reset_cache + +: > "${download_log}" +if cosign_verification 3.99.0; then exit 1; fi +assert_downloaded "${policy_url}" +if grep -Eq 'cosign-linux-|\.sigstore$' "${download_log}"; then exit 1; fi +reset_cache + +: > "${download_log}" +mock_architecture="unsupported" +if cosign_verification 3.14.10; then exit 1; fi +[[ ! -s "${download_log}" ]] +reset_cache +mock_architecture="x86_64" + +# Promote a newer Cosign only after pinned Cosign verifies its Sigstore bundle. +mock_resolved_version="3.2.0" +: > "${MOCK_CALLS}" +: > "${download_log}" +cosign_verification 3.14.10 +assert_downloaded 'https://github.com/sigstore/cosign/releases/download/v3.2.0/cosign-linux-amd64' +assert_downloaded 'https://github.com/sigstore/cosign/releases/download/v3.2.0/cosign-linux-amd64.sigstore.json' +assert_called 'cosign-3.1.4-amd64|verify-blob' +assert_called 'keyless@projectsigstore.iam.gserviceaccount.com' +assert_called 'https://accounts.google.com' +assert_called 'cosign-3.2.0-amd64|verify-blob' +[[ "${COSIGN_ACTIVE_BINARY}" == *'/cosign-3.2.0-amd64' ]] +reset_cache + +# Reject an unauthenticated newer Cosign binary; continue with the pinned one. +export MOCK_REJECT_RELEASE="true" +: > "${MOCK_CALLS}" +cosign_verification 3.14.10 +[[ "${COSIGN_ACTIVE_BINARY}" == "${COSIGN_PINNED_BINARY}" ]] +assert_called 'keyless@projectsigstore.iam.gserviceaccount.com' +if grep -Fq 'cosign-3.2.0-amd64|verify-blob' "${MOCK_CALLS}"; then exit 1; fi +export MOCK_REJECT_RELEASE="false" +reset_cache + +# Newer verifier rejects Python; pinned verifier retries successfully. +export MOCK_REJECT_LATEST="true" +: > "${MOCK_CALLS}" +cosign_verification 3.14.10 +assert_called 'cosign-3.2.0-amd64|verify-blob' +assert_called 'cosign-3.1.4-amd64|verify-blob' +export MOCK_REJECT_LATEST="false" +reset_cache + +# Both verifiers reject Python; verification must fail. +export MOCK_REJECT="true" +if cosign_verification 3.14.10; then exit 1; fi +export MOCK_REJECT="false" +reset_cache + +# Newer release download or bundle download fails; pinned remains usable. +mock_download_failure="/v3.2.0/cosign-linux-amd64.sigstore.json" +cosign_verification 3.14.10 +[[ "${COSIGN_ACTIVE_BINARY}" == "${COSIGN_PINNED_BINARY}" ]] +mock_download_failure="" +reset_cache + +# Newer executable download fails; pinned remains usable. +mock_download_failure="/v3.2.0/cosign-linux-amd64" +cosign_verification 3.14.10 +[[ "${COSIGN_ACTIVE_BINARY}" == "${COSIGN_PINNED_BINARY}" ]] +mock_download_failure="" +reset_cache + +# Invalid major-version resolution must not promote an unsupported verifier. +mock_resolved_version="4.0.0" +cosign_verification 3.14.10 +[[ "${COSIGN_ACTIVE_BINARY}" == "${COSIGN_PINNED_BINARY}" ]] +mock_resolved_version="3.2.0" +reset_cache + +# Tag lookup failure must retain the pinned trusted verifier. +mock_resolver_failure="true" +cosign_verification 3.14.10 +[[ "${COSIGN_ACTIVE_BINARY}" == "${COSIGN_PINNED_BINARY}" ]] +mock_resolver_failure="false" +reset_cache + +printf 'Sigstore verification regression checks passed\n' \ No newline at end of file