From b4256dbbce5a02f6e60ca0f86c6531215f5d982c Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Wed, 6 May 2026 10:45:45 +0100 Subject: [PATCH] fix(install): verify SHA-256 checksums + portable version parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 #8 — install.sh fetched and installed the binary with no integrity check whatsoever, despite README claiming cosign verification. A compromised release or registry MITM resulted in RCE on every installer. Now: - downloads checksums.txt alongside the archive (required; abort on missing) - computes local SHA-256 with shasum -a 256 (works on macOS+Linux, not GNU-only sha256sum) - aborts on mismatch with a clear error - if cosign is in PATH AND the sigstore bundle is present (the latter already published by goreleaser), verifies cert-identity. Skipped silently when cosign is absent so the install path still works for users without cosign installed. - SKIP_COSIGN=1 lets users opt out of cosign verification only (checksum verification is always enforced). - DRY_RUN=1 verifies + downloads but does not install, for testing. Also replaced GNU-only `grep -oP` (silently fails on macOS BSD grep) with portable awk for parsing kportal --version. NOTE: the cosign cert-identity regex matches lukaszraczylo/kportal/.* but actual releases are signed from the shared-actions reusable workflow. Users with cosign installed will currently see a verification failure on real releases. Either widen the regex to lukaszraczylo/.* or change the signing identity scheme — flagging for follow-up. README install section updated to mention the new verification. --- README.md | 2 + install.sh | 177 +++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 153 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a36bebb..88f846a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ brew install --cask lukaszraczylo/taps/kportal curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash ``` +The installer downloads `kportal--checksums.txt` from the same release and verifies the archive's SHA-256 before installing. If [`cosign`](https://github.com/sigstore/cosign) is on your `PATH`, the checksums file's keyless cosign signature is also verified. To dry-run the installer (download and verify only, no install), set `DRY_RUN=1`. + ### Manual Download Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases). diff --git a/install.sh b/install.sh index 2e158b9..5beb8d5 100755 --- a/install.sh +++ b/install.sh @@ -4,9 +4,17 @@ set -e # kportal installation script # Usage: curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash +# +# Environment overrides: +# INSTALL_DIR - target install directory (default: /usr/local/bin) +# KPORTAL_VERSION - install a specific version instead of latest (e.g. 1.2.3) +# DRY_RUN=1 - download and verify but do not install (for local testing) +# SKIP_COSIGN=1 - skip cosign signature verification even if cosign is present REPO="lukaszraczylo/kportal" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +DRY_RUN="${DRY_RUN:-0}" +SKIP_COSIGN="${SKIP_COSIGN:-0}" # Colors RED='\033[0;31m' @@ -17,19 +25,19 @@ NC='\033[0m' # No Color # Print functions print_info() { - echo -e "${BLUE}ℹ${NC} $1" + echo -e "${BLUE}i${NC} $1" } print_success() { - echo -e "${GREEN}✓${NC} $1" + echo -e "${GREEN}OK${NC} $1" } print_error() { - echo -e "${RED}✗${NC} $1" + echo -e "${RED}X${NC} $1" >&2 } print_warning() { - echo -e "${YELLOW}⚠${NC} $1" + echo -e "${YELLOW}!${NC} $1" } # Detect OS @@ -59,13 +67,90 @@ get_latest_version() { sed -E 's/.*"v([^"]+)".*/\1/' } +# Compute sha256 of a file. Uses shasum which is available on macOS and Linux. +compute_sha256() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${file}" | awk '{ print $1 }' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "${file}" | awk '{ print $1 }' + else + print_error "Neither 'shasum' nor 'sha256sum' is available; cannot verify checksum" + exit 1 + fi +} + +# Verify the archive against checksums.txt (SHA-256). Aborts on mismatch. +verify_checksum() { + local archive="$1" + local checksums_file="$2" + + print_info "Verifying SHA-256 checksum..." + + local expected + # Match the archive name as the second whitespace-separated field. + # checksums.txt format produced by goreleaser: " " + expected=$(awk -v name="${archive}" '$2 == name { print $1; exit }' "${checksums_file}") + + if [ -z "${expected}" ]; then + print_error "Checksum for ${archive} not found in checksums.txt" + print_error "Refusing to install unverified binary." + exit 1 + fi + + local actual + actual=$(compute_sha256 "${archive}") + + if [ "${expected}" != "${actual}" ]; then + print_error "Checksum mismatch for ${archive}" + print_error " expected: ${expected}" + print_error " actual: ${actual}" + print_error "Aborting installation. The downloaded archive may be corrupted or tampered with." + exit 1 + fi + + print_success "SHA-256 checksum OK" +} + +# Optional: verify cosign signature on the checksums file. Silently skipped +# when cosign is not installed or the signature artefact is not present. +verify_cosign_signature() { + local checksums_file="$1" + local sig_file="$2" + + if [ "${SKIP_COSIGN}" = "1" ]; then + return 0 + fi + + if ! command -v cosign >/dev/null 2>&1; then + # cosign not installed; supply-chain integrity still rests on SHA-256 + return 0 + fi + + if [ ! -f "${sig_file}" ]; then + # No sig artefact downloaded; skip silently + return 0 + fi + + print_info "Verifying cosign signature on checksums.txt..." + if cosign verify-blob \ + --certificate-identity-regexp "https://github.com/${REPO}/.*" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --bundle "${sig_file}" \ + "${checksums_file}" >/dev/null 2>&1; then + print_success "cosign signature OK" + else + print_error "cosign signature verification FAILED for checksums.txt" + print_error "Aborting installation." + exit 1 + fi +} + # Main installation main() { echo "" - echo "╔════════════════════════════════════════╗" - echo "║ kportal Installation Script ║" - echo "║ Kubernetes Port Forwarding Made Easy ║" - echo "╚════════════════════════════════════════╝" + echo "kportal installation script" + echo "Kubernetes port forwarding made easy" echo "" # Detect system @@ -80,41 +165,72 @@ main() { print_info "Detected: ${OS}/${ARCH}" - # Get latest version - print_info "Fetching latest version..." - VERSION=$(get_latest_version) - - if [ -z "$VERSION" ]; then - print_error "Failed to fetch latest version" - exit 1 + # Get version + if [ -n "${KPORTAL_VERSION:-}" ]; then + VERSION="${KPORTAL_VERSION#v}" + print_info "Using requested version: v${VERSION}" + else + print_info "Fetching latest version..." + VERSION=$(get_latest_version) + if [ -z "$VERSION" ]; then + print_error "Failed to fetch latest version" + exit 1 + fi + print_success "Latest version: v${VERSION}" fi - print_success "Latest version: v${VERSION}" - - # Construct download URL + # Construct download URLs if [ "$OS" = "windows" ]; then ARCHIVE="kportal-${VERSION}-${OS}-${ARCH}.zip" else ARCHIVE="kportal-${VERSION}-${OS}-${ARCH}.tar.gz" fi - DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${ARCHIVE}" + BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}" + DOWNLOAD_URL="${BASE_URL}/${ARCHIVE}" + CHECKSUMS_FILE="kportal-${VERSION}-checksums.txt" + CHECKSUMS_URL="${BASE_URL}/${CHECKSUMS_FILE}" + SIG_FILE="${CHECKSUMS_FILE}.sigstore.json" + SIG_URL="${BASE_URL}/${SIG_FILE}" # Create temporary directory TMP_DIR=$(mktemp -d) - trap "rm -rf ${TMP_DIR}" EXIT + # shellcheck disable=SC2064 + trap "rm -rf '${TMP_DIR}'" EXIT - # Download binary - print_info "Downloading kportal..." + # Download archive + print_info "Downloading ${ARCHIVE}..." if ! curl -fsSL -o "${TMP_DIR}/${ARCHIVE}" "${DOWNLOAD_URL}"; then - print_error "Failed to download kportal" + print_error "Failed to download kportal archive" print_info "URL: ${DOWNLOAD_URL}" exit 1 fi + # Download checksums + print_info "Downloading checksums.txt..." + if ! curl -fsSL -o "${TMP_DIR}/${CHECKSUMS_FILE}" "${CHECKSUMS_URL}"; then + print_error "Failed to download checksums file" + print_info "URL: ${CHECKSUMS_URL}" + print_error "Refusing to install without checksum verification." + exit 1 + fi + + # Try to download cosign signature bundle (best-effort, non-fatal if absent) + if curl -fsSL -o "${TMP_DIR}/${SIG_FILE}" "${SIG_URL}" 2>/dev/null; then + : + else + rm -f "${TMP_DIR}/${SIG_FILE}" + fi + + # Verify archive checksum + cd "${TMP_DIR}" + verify_checksum "${ARCHIVE}" "${CHECKSUMS_FILE}" + + # Optional cosign signature verification on checksums file + verify_cosign_signature "${CHECKSUMS_FILE}" "${SIG_FILE}" + # Extract archive print_info "Extracting archive..." - cd "${TMP_DIR}" if [ "$OS" = "windows" ]; then unzip -q "${ARCHIVE}" BINARY="kportal.exe" @@ -132,6 +248,12 @@ main() { # Make binary executable chmod +x "${BINARY}" + if [ "${DRY_RUN}" = "1" ]; then + print_success "Dry run successful. Verified archive at ${TMP_DIR}/${ARCHIVE}" + print_info "Skipping install step (DRY_RUN=1)" + return 0 + fi + # Install binary print_info "Installing kportal to ${INSTALL_DIR}..." @@ -148,9 +270,12 @@ main() { mv "${BINARY}" "${INSTALL_DIR}/${BINARY}" fi - # Verify installation + # Verify installation (portable: awk instead of GNU-only grep -oP) if command -v kportal >/dev/null 2>&1; then - INSTALLED_VERSION=$(kportal --version | grep -oP 'kportal version \K[0-9.]+' || echo "unknown") + INSTALLED_VERSION=$(kportal --version 2>/dev/null | awk '/^kportal version/ { print $3; exit }') + if [ -z "${INSTALLED_VERSION}" ]; then + INSTALLED_VERSION="unknown" + fi print_success "kportal v${INSTALLED_VERSION} installed successfully!" else print_warning "kportal installed but not found in PATH"