From d9d10a6fba1a334cfe75df09f3720898967e4333 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Tue, 10 Nov 2026 09:49:03 +0000 Subject: [PATCH 01/10] [python] Fix Sigstore verification for Python 3.14+ --- src/python/NOTES.md | 5 + src/python/README.md | 4 + src/python/devcontainer-feature.json | 2 +- src/python/install.sh | 226 ++++++++++++++-------- test/python/sigstore_verification_test.sh | 128 ++++++++++++ 5 files changed, 285 insertions(+), 80 deletions(-) create mode 100644 test/python/sigstore_verification_test.sh diff --git a/src/python/NOTES.md b/src/python/NOTES.md index 79a308cf5..4a8aeb673 100644 --- a/src/python/NOTES.md +++ b/src/python/NOTES.md @@ -5,3 +5,8 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise Linux, Fedora, Alma, and RockyLinux distributions with the apt, yum, dnf, or microdnf package manager installed. `bash` is required to execute the `install.sh` script. + +## Mantainance notes + +Python 3.14+ transitioned from GPG to Sigstore for source archive verification. The Cosign version is intentionally pinned to balance security and maintenance overhead; review the pin periodically and update it, along with its checksums, when upstream vulnerabilities or significant releases warrant it. + diff --git a/src/python/README.md b/src/python/README.md index 90d79aee8..84436367f 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -41,6 +41,10 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise `bash` is required to execute the `install.sh` script. +## Source Verification + +Python 3.14 and later use Sigstore-based source verification with Cosign. Verification follows a compatibility-oriented fail-open policy, with verification exceptions reported through installer warnings. + --- diff --git a/src/python/devcontainer-feature.json b/src/python/devcontainer-feature.json index 6a4121415..8012bbc8e 100644 --- a/src/python/devcontainer-feature.json +++ b/src/python/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "python", - "version": "1.8.0", + "version": "1.8.1", "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 be895927d..22ef755bc 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -507,103 +507,171 @@ get_architecture() { aarch64 | armv8*) architecture="arm64" ;; aarch32 | armv7* | armvhf*) architecture="armhf" ;; i?86) architecture="386" ;; - *) echo "(!) Architecture $(uname -m) unsupported"; exit 1 ;; + ppc64le) architecture="ppc64le" ;; + s390x) architecture="s390x" ;; + riscv64) architecture="riscv64" ;; + *) echo "(!) Architecture $(uname -m) unsupported" >&2; return 1 ;; esac echo ${architecture} } -# Install cosign with multi-distro support -install_cosign() { - - COSIGN_VERSION="latest" - local cosign_url='https://github.com/sigstore/cosign' - local architecture=$(get_architecture) - - find_version_from_git_tags COSIGN_VERSION "${cosign_url}" - - # Remove 'v' prefix if present for download URL - local version_for_url="${COSIGN_VERSION#v}" - - 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" - return 1 +# 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 + 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 - 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" + 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 + if [ "${policy_attempt}" -gt 3 ]; then + echo "(!) Failed to download Python.org's Sigstore signer policy" >&2 return 1 fi + 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 + rm -f "${policy_file}" + return 1 + fi + rm -f "${policy_file}" + printf '%s\n' "${policy}" } -# 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 + 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" ;; + ppc64le) cosign_architecture="ppc64le"; checksum="0887fca23319ea017cc827e6a845ba83722a36bf9e932f35c747ca83c14cec49" ;; + s390x) cosign_architecture="s390x"; checksum="777978d576af893a3daf1fcf3b0e9178cebcbc083a669f6125b6c97b8e1fe433" ;; + riscv64) cosign_architecture="riscv64"; checksum="24f3865df0fd9b055827b888d56e13583112ac7d4c87b45967af1f0febf35455" ;; + *) echo "(!) No cosign binary available for architecture ${architecture}"; 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" + # Pin Cosign so its binary checksum remains a reviewed trust input. + local cosign_version="3.1.4" + local cosign_dir bundle_file + cosign_dir="$(mktemp -d)" || return 1 + bundle_file="${cosign_dir}/python.sigstore" + + # Resolve the signer policy before downloading Cosign or its verification bundle. + if ! signer_policy="$(get_sigstore_signer_policy "${series}" "${cosign_dir}/policy.html")"; then + echo "(!) Could not resolve a Sigstore signer policy for Python ${series}" + rm -rf "${cosign_dir}" + return 1 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}" + IFS=$'\t' read -r identity issuer <<< "${signer_policy}" + + if ! curl -fsSL -o "${cosign_dir}/cosign" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then + echo "(!) Failed to download cosign v${cosign_version}" + rm -rf "${cosign_dir}" 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}" + if ! echo "${checksum} ${cosign_dir}/cosign" | sha256sum -c -; then + echo "(!) cosign binary checksum verification failed" + rm -rf "${cosign_dir}" 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=".*" \ + if ! chmod 0755 "${cosign_dir}/cosign"; then + echo "(!) Failed to make cosign executable" + rm -rf "${cosign_dir}" + return 1 + fi + local bundle_attempt=1 + while [ "${bundle_attempt}" -le 3 ]; do + if curl -fsSL --connect-timeout 20 --max-time 60 -o "${bundle_file}" "${cpython_tgz_url}.sigstore"; then + break + fi + echo "(!) Sigstore bundle download attempt ${bundle_attempt}/3 failed" + 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}" + rm -rf "${cosign_dir}" + return 1 + fi + + echo "(*) Verifying Python ${VERSION} with Sigstore signer ${identity}..." + if "${cosign_dir}/cosign" verify-blob \ + --bundle "${bundle_file}" \ + --certificate-identity "${identity}" \ + --certificate-oidc-issuer "${issuer}" \ "/tmp/python-src/${cpython_tgz_filename}"; then + rm -rf "${cosign_dir}" echo "(*) COSIGN signature verification successful" return 0 else + rm -rf "${cosign_dir}" echo "(!) COSIGN signature verification failed" return 1 fi @@ -665,10 +733,8 @@ 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}." + echo "(!) Continuing without verifying the source archive's authenticity or integrity." fi else echo "(*) Python < 3.14 detected. Using GPG signature verification..." @@ -864,6 +930,7 @@ case ${ADJUSTED_ID} in ca-certificates \ curl \ dirmngr \ + gawk \ gcc \ gnupg2 \ libbz2-dev \ @@ -888,6 +955,7 @@ case ${ADJUSTED_ID} in bzip2-devel \ ca-certificates \ findutils \ + gawk \ gcc \ gnupg2 \ libffi-devel \ diff --git a/test/python/sigstore_verification_test.sh b/test/python/sigstore_verification_test.sh new file mode 100644 index 000000000..698012fd9 --- /dev/null +++ b/test/python/sigstore_verification_test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT +mkdir "${work_dir}/tmp" +export TMPDIR="${work_dir}/tmp" +export MOCK_LOG="${work_dir}/cosign-args" +export MOCK_REJECT="false" +mock_architecture="x86_64" +mock_download_failure="" +mock_checksum_failure="false" +download_log="${work_dir}/downloads" +checksum_log="${work_dir}/checksum" + +cat > "${work_dir}/cosign" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "${MOCK_LOG}" +[[ "${MOCK_REJECT}" != "true" ]] +EOF + +uname() { + printf '%s\n' "${mock_architecture}" +} + +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 ]]; then + printf 'bundle\n' > "${destination}" + else + cp "${work_dir}/cosign" "${destination}" + fi +} + +sha256sum() { + cat > "${checksum_log}" + [[ "${mock_checksum_failure}" != "true" ]] +} + +source <(sed -n '/^get_architecture() {$/,/^# GPG verification for python versions "${download_log}" +if cosign_verification 3.99.0; then exit 1; fi +grep -Fx -- "${policy_url}" "${download_log}" +if grep -E 'cosign-linux-|\.sigstore$' "${download_log}"; then exit 1; fi +: > "${download_log}" +mock_architecture="unsupported" +if cosign_verification 3.14.10; then exit 1; fi +[[ ! -s "${download_log}" ]] +assert_clean + +printf 'Sigstore verification regression checks passed\n' \ No newline at end of file From 52c8536a0dc19653fcdf62b1038c9d0e9fad4c7e Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Tue, 10 Nov 2026 10:21:04 +0000 Subject: [PATCH 02/10] revert changes done directly to readme --- src/python/NOTES.md | 4 ++++ src/python/README.md | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/python/NOTES.md b/src/python/NOTES.md index c1d3ca0b2..69f1f4c8a 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), while earlier versions use GPG. For compatibility, 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/README.md b/src/python/README.md index 84436367f..46d570edd 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -41,11 +41,6 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise `bash` is required to execute the `install.sh` script. -## Source Verification - -Python 3.14 and later use Sigstore-based source verification with Cosign. Verification follows a compatibility-oriented fail-open policy, with verification exceptions reported through installer warnings. - - --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/python/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ From 61ff2f14cb98bb3fccbc0ef60424b2728ef5db8e Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Tue, 10 Nov 2026 10:22:31 +0000 Subject: [PATCH 03/10] revert readme to default state --- src/python/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/README.md b/src/python/README.md index 46d570edd..90d79aee8 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -41,6 +41,7 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise `bash` is required to execute the `install.sh` script. + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainers/features/blob/main/src/python/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ From 6a329957cfdd68bb03f84bd2110ccce86ace976e Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Tue, 10 Nov 2026 11:10:33 +0000 Subject: [PATCH 04/10] bump the minor update and add CI tests --- .github/workflows/test-pr.yaml | 10 ++++++++++ src/python/devcontainer-feature.json | 2 +- src/python/install.sh | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) 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/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 0a7314bd3..b8b843d5b 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -628,7 +628,7 @@ cosign_verification() { fi IFS=$'\t' read -r identity issuer <<< "${signer_policy}" - if ! curl -fsSL -o "${cosign_dir}/cosign" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then + if ! curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 -o "${cosign_dir}/cosign" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then echo "(!) Failed to download cosign v${cosign_version}" rm -rf "${cosign_dir}" return 1 From 3961cdb31945acb0eaddbbff659ce328e75d7f82 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 04:49:02 +0000 Subject: [PATCH 05/10] Add cosign cache to reduce time during rebuilds. --- src/python/NOTES.md | 2 +- src/python/install.sh | 65 +++++++++++++++-------- test/python/sigstore_verification_test.sh | 1 + 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/python/NOTES.md b/src/python/NOTES.md index 69f1f4c8a..2360bd2cf 100644 --- a/src/python/NOTES.md +++ b/src/python/NOTES.md @@ -12,4 +12,4 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise ## Source Verification -The feature verifies the authenticity and integrity of downloaded Python source archives. Python 3.14 and later use Sigstore (via Cosign), while earlier versions use GPG. For compatibility, 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 +The feature verifies the authenticity and integrity of downloaded Python source archives. Python 3.14 and later use Sigstore (via Cosign), instead of the GPG used in the. For 3.14 onwards 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/install.sh b/src/python/install.sh index b8b843d5b..35d3b80c2 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -37,6 +37,19 @@ KEYSERVER_PROXY="${HTTPPROXY:-"${HTTP_PROXY:-""}"}" set -e +COSIGN_CACHE_DIR="" + +cleanup_cosign_cache() { + if [ -n "${COSIGN_CACHE_DIR}" ] && [ -d "${COSIGN_CACHE_DIR}" ]; then + if ! rm -rf -- "${COSIGN_CACHE_DIR}"; then + echo "(!) WARNING: Failed to remove temporary Cosign cache directory: ${COSIGN_CACHE_DIR}" >&2 + fi + 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 @@ -616,32 +629,40 @@ cosign_verification() { # Pin Cosign so its binary checksum remains a reviewed trust input. local cosign_version="3.1.4" - local cosign_dir bundle_file - cosign_dir="$(mktemp -d)" || return 1 + local cosign_dir bundle_file cosign_binary + if [ -z "${COSIGN_CACHE_DIR:-}" ]; then + if ! COSIGN_CACHE_DIR="$(mktemp -d)"; then + echo "(!) Failed to create temporary Cosign cache directory." >&2 + return 1 + fi + fi + cosign_dir="${COSIGN_CACHE_DIR}" bundle_file="${cosign_dir}/python.sigstore" - + cosign_binary="${cosign_dir}/cosign-${cosign_architecture}" + # Resolve the signer policy before downloading Cosign or its verification bundle. if ! signer_policy="$(get_sigstore_signer_policy "${series}" "${cosign_dir}/policy.html")"; then echo "(!) Could not resolve a Sigstore signer policy for Python ${series}" - rm -rf "${cosign_dir}" return 1 fi IFS=$'\t' read -r identity issuer <<< "${signer_policy}" - if ! curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 -o "${cosign_dir}/cosign" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then - echo "(!) Failed to download cosign v${cosign_version}" - rm -rf "${cosign_dir}" - return 1 - fi - if ! echo "${checksum} ${cosign_dir}/cosign" | sha256sum -c -; then - echo "(!) cosign binary checksum verification failed" - rm -rf "${cosign_dir}" - return 1 - fi - if ! chmod 0755 "${cosign_dir}/cosign"; then - echo "(!) Failed to make cosign executable" - rm -rf "${cosign_dir}" - return 1 + if [ ! -x "${cosign_binary}" ]; then + if ! curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 -o "${cosign_binary}" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then + echo "(!) Failed to download cosign v${cosign_version}" + rm -f "${cosign_binary}" + return 1 + fi + if ! echo "${checksum} ${cosign_binary}" | sha256sum -c -; then + echo "(!) cosign binary checksum verification failed" + rm -f "${cosign_binary}" + return 1 + fi + if ! chmod 0755 "${cosign_binary}"; then + echo "(!) Failed to make cosign executable" + rm -f "${cosign_binary}" + return 1 + fi fi local bundle_attempt=1 while [ "${bundle_attempt}" -le 3 ]; do @@ -657,21 +678,21 @@ cosign_verification() { done if [ "${bundle_attempt}" -gt 3 ]; then echo "(!) Failed to download the Sigstore bundle for Python ${VERSION}" - rm -rf "${cosign_dir}" + rm -f "${bundle_file}" return 1 fi echo "(*) Verifying Python ${VERSION} with Sigstore signer ${identity}..." - if "${cosign_dir}/cosign" verify-blob \ + if "${cosign_binary}" verify-blob \ --bundle "${bundle_file}" \ --certificate-identity "${identity}" \ --certificate-oidc-issuer "${issuer}" \ "/tmp/python-src/${cpython_tgz_filename}"; then - rm -rf "${cosign_dir}" + rm -f "${bundle_file}" echo "(*) COSIGN signature verification successful" return 0 else - rm -rf "${cosign_dir}" + rm -f "${bundle_file}" echo "(!) COSIGN signature verification failed" return 1 fi diff --git a/test/python/sigstore_verification_test.sh b/test/python/sigstore_verification_test.sh index 698012fd9..a1f2a620b 100644 --- a/test/python/sigstore_verification_test.sh +++ b/test/python/sigstore_verification_test.sh @@ -13,6 +13,7 @@ mock_download_failure="" mock_checksum_failure="false" download_log="${work_dir}/downloads" checksum_log="${work_dir}/checksum" +COSIGN_CACHE_DIR="" cat > "${work_dir}/cosign" <<'EOF' #!/usr/bin/env bash From bd2f1c0c716da1c2c84e89cdd69cd73aa90d6ab4 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 08:58:35 +0000 Subject: [PATCH 06/10] add cosign fallback and update tests --- src/python/install.sh | 267 +++++++++++++--------- test/python/sigstore_verification_test.sh | 47 +++- 2 files changed, 206 insertions(+), 108 deletions(-) diff --git a/src/python/install.sh b/src/python/install.sh index 124c0c7b5..db36adf3a 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -16,7 +16,7 @@ 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" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} @@ -40,17 +40,20 @@ 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 - if ! rm -rf -- "${COSIGN_CACHE_DIR}"; then - echo "(!) WARNING: Failed to remove temporary Cosign cache directory: ${COSIGN_CACHE_DIR}" >&2 - fi + 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 @@ -525,10 +528,7 @@ get_architecture() { aarch64 | armv8*) architecture="arm64" ;; aarch32 | armv7* | armvhf*) architecture="armhf" ;; i?86) architecture="386" ;; - ppc64le) architecture="ppc64le" ;; - s390x) architecture="s390x" ;; - riscv64) architecture="riscv64" ;; - *) echo "(!) Architecture $(uname -m) unsupported" >&2; return 1 ;; + *) echo "(!) Architecture $(uname -m) unsupported"; exit 1 ;; esac echo ${architecture} } @@ -538,50 +538,22 @@ get_sigstore_signer_policy() { local series="$1" local policy_file="$2" local policy_attempt=1 - 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 -# Install cosign with multi-distro support -install_cosign() { - - COSIGN_VERSION="latest" - local cosign_url='https://github.com/sigstore/cosign' - local architecture=$(get_architecture) - - find_version_from_git_tags COSIGN_VERSION "${cosign_url}" "tags/v" "." "false" "" "${COSIGN_LAST_KNOWN_VERSION}" - # Remove 'v' prefix if present for download URL - local version_for_url="${COSIGN_VERSION#v}" + 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 - 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" - return 1 - 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 - if [ "${policy_attempt}" -gt 3 ]; then + if [ ! -s "${policy_file}" ]; then echo "(!) Failed to download Python.org's Sigstore signer policy" >&2 return 1 fi @@ -639,74 +611,149 @@ END { } ' "${policy_file}")"; then echo "(!) Could not parse Python.org's Sigstore signer policy for Python ${series}" >&2 - rm -f "${policy_file}" return 1 fi - rm -f "${policy_file}" printf '%s\n' "${policy}" } +# 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 + + 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_PINNED_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_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" ;; - ppc64le) cosign_architecture="ppc64le"; checksum="0887fca23319ea017cc827e6a845ba83722a36bf9e932f35c747ca83c14cec49" ;; - s390x) cosign_architecture="s390x"; checksum="777978d576af893a3daf1fcf3b0e9178cebcbc083a669f6125b6c97b8e1fe433" ;; - riscv64) cosign_architecture="riscv64"; checksum="24f3865df0fd9b055827b888d56e13583112ac7d4c87b45967af1f0febf35455" ;; - *) echo "(!) No cosign binary available for architecture ${architecture}"; return 1 ;; + *) echo "(!) No checksum-verified pinned Cosign binary available for architecture ${architecture}." >&2; return 1 ;; esac - # Pin Cosign so its binary checksum remains a reviewed trust input. - local cosign_version="3.1.4" - local cosign_dir bundle_file cosign_binary - if [ -z "${COSIGN_CACHE_DIR:-}" ]; then - if ! COSIGN_CACHE_DIR="$(mktemp -d)"; then - echo "(!) Failed to create temporary Cosign cache directory." >&2 - return 1 - fi + if [ -z "${COSIGN_CACHE_DIR}" ]; then + COSIGN_CACHE_DIR="$(mktemp -d)" || return 1 + chmod 700 "${COSIGN_CACHE_DIR}" fi - cosign_dir="${COSIGN_CACHE_DIR}" - bundle_file="${cosign_dir}/python.sigstore" - cosign_binary="${cosign_dir}/cosign-${cosign_architecture}" - - # Resolve the signer policy before downloading Cosign or its verification bundle. - if ! signer_policy="$(get_sigstore_signer_policy "${series}" "${cosign_dir}/policy.html")"; then - echo "(!) Could not resolve a Sigstore signer policy for Python ${series}" + + # 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 IFS=$'\t' read -r identity issuer <<< "${signer_policy}" - if [ ! -x "${cosign_binary}" ]; then - if ! curl -fsSL --connect-timeout 20 --max-time 300 --retry 2 --retry-delay 2 -o "${cosign_binary}" "https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-${cosign_architecture}"; then - echo "(!) Failed to download cosign v${cosign_version}" - rm -f "${cosign_binary}" - return 1 - fi - if ! echo "${checksum} ${cosign_binary}" | sha256sum -c -; then - echo "(!) cosign binary checksum verification failed" - rm -f "${cosign_binary}" - return 1 - fi - if ! chmod 0755 "${cosign_binary}"; then - echo "(!) Failed to make cosign executable" - rm -f "${cosign_binary}" - return 1 - fi + if ! prepare_cosign_binaries "${cosign_architecture}" "${checksum}"; then + return 1 fi - local bundle_attempt=1 + + bundle_file="${COSIGN_CACHE_DIR}/python-${VERSION}.sigstore" while [ "${bundle_attempt}" -le 3 ]; do - if curl -fsSL --connect-timeout 20 --max-time 60 -o "${bundle_file}" "${cpython_tgz_url}.sigstore"; then + 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" + 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 @@ -714,25 +761,39 @@ cosign_verification() { fi done if [ "${bundle_attempt}" -gt 3 ]; then - echo "(!) Failed to download the Sigstore bundle for Python ${VERSION}" + 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_binary}" verify-blob \ + if "${COSIGN_ACTIVE_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 "(*) COSIGN signature verification successful" + echo "(*) Python ${VERSION} Sigstore verification successful." return 0 - else - rm -f "${bundle_file}" - 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 @@ -805,8 +866,10 @@ install_from_source() { if cosign_verification "$VERSION"; then echo "(*) COSIGN verification successful." else - echo "(!) WARNING: Sigstore verification failed or could not be completed for Python ${VERSION}." - echo "(!) Continuing without verifying the source archive's authenticity or integrity." + 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" fi else echo "(*) Python < 3.14 detected. Using GPG signature verification..." @@ -1003,7 +1066,6 @@ case ${ADJUSTED_ID} in ca-certificates \ curl \ dirmngr \ - gawk \ gcc \ gnupg2 \ libbz2-dev \ @@ -1028,7 +1090,6 @@ case ${ADJUSTED_ID} in bzip2-devel \ ca-certificates \ findutils \ - gawk \ gcc \ gnupg2 \ libffi-devel \ diff --git a/test/python/sigstore_verification_test.sh b/test/python/sigstore_verification_test.sh index a1f2a620b..98164f71f 100644 --- a/test/python/sigstore_verification_test.sh +++ b/test/python/sigstore_verification_test.sh @@ -2,6 +2,7 @@ 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 "${work_dir}/tmp" @@ -13,18 +14,39 @@ mock_download_failure="" mock_checksum_failure="false" download_log="${work_dir}/downloads" checksum_log="${work_dir}/checksum" +COSIGN_PINNED_VERSION="3.1.4" COSIGN_CACHE_DIR="" +COSIGN_LATEST_VERSION="" +COSIGN_LATEST_READY="false" +COSIGN_PINNED_BINARY="" +COSIGN_LATEST_BINARY="" +COSIGN_ACTIVE_BINARY="" -cat > "${work_dir}/cosign" <<'EOF' +cat > "${work_dir}/cosign" <<'COSIGN' #!/usr/bin/env bash printf '%s\n' "$@" > "${MOCK_LOG}" [[ "${MOCK_REJECT}" != "true" ]] -EOF +COSIGN +chmod 0755 "${work_dir}/cosign" uname() { printf '%s\n' "${mock_architecture}" } +sleep() { :; } + +# Keep this test focused on the pinned-verifier path. The installer still +# calls the shared version resolver, so return the pinned version here. +find_version_from_git_tags() { + local variable_name="$1" + local requested_version="${!variable_name}" + local fallback_version="${7:-3.1.4}" + if [[ "${requested_version}" == "3" ]]; then + printf -v "${variable_name}" '%s' "${fallback_version}" + fi + printf '%s=%s\n' "${variable_name}" "${!variable_name}" +} + curl() { local destination="" url="" while [[ $# -gt 0 ]]; do @@ -37,7 +59,7 @@ curl() { fi done printf '%s\n' "${url}" >> "${download_log}" - if [[ -n "${mock_download_failure}" && "${url}" == *"${mock_download_failure}" ]]; then + if [[ -n "${mock_download_failure}" && "${url}" == *"${mock_download_failure}"* ]]; then return 22 fi if [[ "${url}" == */downloads/metadata/sigstore/ ]]; then @@ -63,12 +85,24 @@ sha256sum() { [[ "${mock_checksum_failure}" != "true" ]] } -source <(sed -n '/^get_architecture() {$/,/^# GPG verification for python versions "${download_log}" mock_architecture="unsupported" if cosign_verification 3.14.10; then exit 1; fi From 2a891fa7baa4c8675524be22d816be567ca44897 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 09:53:01 +0000 Subject: [PATCH 07/10] add cosign last known version --- src/python/install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/install.sh b/src/python/install.sh index db36adf3a..27a7f4060 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -17,6 +17,7 @@ OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" PYTHON_LAST_KNOWN_VERSION="3.14.7" OPENSSL3_LAST_KNOWN_VERSION="3.0.22" COSIGN_PINNED_VERSION="3.1.4" +COSIGN_LAST_KNOWN_VERSION="${COSIGN_PINNED_VERSION}" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} From 95cbd792240e2724dcf709e65aa40bd7a1da7a21 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 10:05:07 +0000 Subject: [PATCH 08/10] fix ci tests --- src/python/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/install.sh b/src/python/install.sh index 27a7f4060..449ec93ba 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -659,7 +659,7 @@ prepare_cosign_binaries() { 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_PINNED_VERSION}" ) 2>/dev/null )"; then + 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}" From 9bf60223519bf60db03de30f2c9b42d0e1f2a699 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 10:17:56 +0000 Subject: [PATCH 09/10] fix ci error --- src/python/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/install.sh b/src/python/install.sh index 449ec93ba..8a4fd9da6 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -17,7 +17,7 @@ OVERRIDE_DEFAULT_VERSION="${OVERRIDEDEFAULTVERSION:-"true"}" PYTHON_LAST_KNOWN_VERSION="3.14.7" OPENSSL3_LAST_KNOWN_VERSION="3.0.22" COSIGN_PINNED_VERSION="3.1.4" -COSIGN_LAST_KNOWN_VERSION="${COSIGN_PINNED_VERSION}" +COSIGN_LAST_KNOWN_VERSION="3.1.4" export PIPX_HOME=${PIPX_HOME:-"/usr/local/py-utils"} From 0e1bbf85cfbd64850f93ac7e295d5ed1365273b7 Mon Sep 17 00:00:00 2001 From: Venkumahanti Subhankar Date: Wed, 11 Nov 2026 12:04:57 +0000 Subject: [PATCH 10/10] Warn users about failed verification --- src/python/NOTES.md | 2 +- src/python/install.sh | 7 +- test/python/sigstore_verification_test.sh | 180 ++++++++++++++++------ 3 files changed, 136 insertions(+), 53 deletions(-) diff --git a/src/python/NOTES.md b/src/python/NOTES.md index 2360bd2cf..9e8d3a77c 100644 --- a/src/python/NOTES.md +++ b/src/python/NOTES.md @@ -12,4 +12,4 @@ This Feature should work on recent versions of Debian/Ubuntu, RedHat Enterprise ## 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 the GPG used in the. For 3.14 onwards 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 +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/install.sh b/src/python/install.sh index 8a4fd9da6..94a907edf 100755 --- a/src/python/install.sh +++ b/src/python/install.sh @@ -867,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 index 98164f71f..427899214 100644 --- a/test/python/sigstore_verification_test.sh +++ b/test/python/sigstore_verification_test.sh @@ -5,16 +5,22 @@ 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 "${work_dir}/tmp" +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" @@ -25,32 +31,31 @@ COSIGN_ACTIVE_BINARY="" cat > "${work_dir}/cosign" <<'COSIGN' #!/usr/bin/env bash printf '%s\n' "$@" > "${MOCK_LOG}" -[[ "${MOCK_REJECT}" != "true" ]] +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}" -} - +uname() { printf '%s\n' "${mock_architecture}"; } sleep() { :; } -# Keep this test focused on the pinned-verifier path. The installer still -# calls the shared version resolver, so return the pinned version here. find_version_from_git_tags() { local variable_name="$1" - local requested_version="${!variable_name}" - local fallback_version="${7:-3.1.4}" - if [[ "${requested_version}" == "3" ]]; then - printf -v "${variable_name}" '%s' "${fallback_version}" - fi - printf '%s=%s\n' "${variable_name}" "${!variable_name}" + [[ "${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 + if [[ "$1" == "-o" ]]; then destination="$2" shift 2 else @@ -73,7 +78,7 @@ curl() { POLICY - elif [[ "${url}" == *.sigstore ]]; then + elif [[ "${url}" == *.sigstore || "${url}" == *.sigstore.json ]]; then printf 'bundle\n' > "${destination}" else cp "${work_dir}/cosign" "${destination}" @@ -81,19 +86,29 @@ POLICY } sha256sum() { - cat > "${checksum_log}" + cat >> "${checksum_log}" [[ "${mock_checksum_failure}" != "true" ]] } -source <(sed -n '/^get_architecture() {$/,/^# GPG verification for python versions &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/" -# The updated installer intentionally keeps this cache between Python -# installations. Clear it between independent test cases, not each call. -assert_clean() { +reset_cache() { if [[ -n "${COSIGN_CACHE_DIR}" && -d "${COSIGN_CACHE_DIR}" ]]; then rm -rf -- "${COSIGN_CACHE_DIR}" fi @@ -105,62 +120,131 @@ assert_clean() { 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 -grep -Fx -- "${policy_url}" "${download_log}" -grep -Fx -- 'https://github.com/sigstore/cosign/releases/download/v3.1.4/cosign-linux-amd64' "${download_log}" -grep -Fx -- "${cpython_tgz_url}.sigstore" "${download_log}" -grep -F -- '424e2c97d0b45fd7a6b608a87f090f35633f4c23ece5f092d48bba11aaf4bbfa' "${checksum_log}" -grep -Fx -- '--bundle' "${MOCK_LOG}" -grep -Fx -- '--certificate-identity' "${MOCK_LOG}" -grep -Fx -- 'hugo@python.org' "${MOCK_LOG}" -grep -Fx -- '--certificate-oidc-issuer' "${MOCK_LOG}" -grep -Fx -- 'https://github.com/login/oauth' "${MOCK_LOG}" -grep -Fx -- "/tmp/python-src/${cpython_tgz_filename}" "${MOCK_LOG}" - +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 -Fx -- 'hugo@python.org' "${MOCK_LOG}" +grep -Fxq -- 'hugo@python.org' "${MOCK_LOG}" cosign_verification 3.16.0 -grep -Fx -- 'savannah@python.org' "${MOCK_LOG}" -assert_clean +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 -grep -Fx -- 'https://github.com/sigstore/cosign/releases/download/v3.1.4/cosign-linux-arm' "${download_log}" -grep -F -- '09e77a313910cf5c4774508d85d017d9c60983f41bf657f83ecbd3892f7952cb' "${checksum_log}" -assert_clean +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" -assert_clean +reset_cache mock_checksum_failure="true" if cosign_verification 3.14.10; then exit 1; fi mock_checksum_failure="false" -assert_clean +reset_cache mock_download_failure="/downloads/metadata/sigstore/" if cosign_verification 3.14.10; then exit 1; fi -assert_clean +reset_cache mock_download_failure=".sigstore" if cosign_verification 3.14.10; then exit 1; fi -assert_clean -mock_download_failure="cosign-linux-arm" +reset_cache +mock_download_failure="cosign-linux-amd64" if cosign_verification 3.14.10; then exit 1; fi mock_download_failure="" -assert_clean +reset_cache : > "${download_log}" if cosign_verification 3.99.0; then exit 1; fi -grep -Fx -- "${policy_url}" "${download_log}" -if grep -E 'cosign-linux-|\.sigstore$' "${download_log}"; then exit 1; fi -assert_clean +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}" ]] -assert_clean +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