From 8ca3ddad0946855f1e3096923ee7a0829ec518cc Mon Sep 17 00:00:00 2001 From: "Asad Iqbal (Saadi)" Date: Thu, 25 Jun 2026 16:28:34 +0500 Subject: [PATCH 01/23] Merge pull request #111 from tracebloc/sec/rfc-0001-r8-mandatory-verify fix(security): mandatory / fail-closed cosign verification in the CLI installer (RFC-0001 R8) --- .github/workflows/build.yml | 20 ++++ README.md | 9 ++ scripts/install.sh | 177 ++++++++++++++++++++++++++---- scripts/tests/install-verify.sh | 188 ++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 22 deletions(-) create mode 100755 scripts/tests/install-verify.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d927706..f0a63d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,26 @@ jobs: - name: scripts/sync-schema.sh --check run: ./scripts/sync-schema.sh --check + installer: + timeout-minutes: 10 + name: Installer (shell) + # The curl|sh installer is the most privileged code we ship (it places the + # binary on PATH) and had NO automated test until R8. shellcheck it under + # the POSIX sh dialect it actually runs as, parse it with dash, and run the + # functional harness that asserts cosign verification is mandatory / fails + # closed when cosign is absent (RFC-0001 R8, backend#889). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: shellcheck + dash parse + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck dash + shellcheck --shell=sh --severity=error scripts/install.sh + dash -n scripts/install.sh + bash -n scripts/tests/install-verify.sh + - name: Verification harness (mandatory cosign / fail-closed) + run: bash scripts/tests/install-verify.sh + test: timeout-minutes: 15 name: Test diff --git a/README.md b/README.md index 3d36102..4cf03df 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,15 @@ tracebloc dataset push ./my-data \ > **`tracebloc: command not found` after installing?** The binary installs to `~/.local/bin` when `/usr/local/bin` isn't writable, and an already-running shell won't see the new PATH entry until you open a new terminal (or `. ~/.bashrc`). See **[Troubleshooting installation](docs/troubleshooting.md)**. +> **Signature verification is mandatory.** The installer verifies the binary's +> SHA256 **and** its cosign signature before installing. If `cosign` isn't on +> PATH it bootstraps a pinned, checksum-verified copy; if it can't, the install +> **fails closed** rather than trusting the same-channel checksum alone (it no +> longer silently skips the signature). The one escape, for a genuinely +> constrained environment, is to re-run with `TRACEBLOC_ALLOW_UNVERIFIED=1` — +> which prints a loud warning. For the highest trust, pre-install `cosign` +> (`brew install cosign`, your package manager, or the [released binary](https://github.com/sigstore/cosign/releases)) before running the installer. (RFC-0001 R8.) + What that runs under the curtain: 1. Reads kubeconfig, discovers the parent `tracebloc/client` release in the cluster diff --git a/scripts/install.sh b/scripts/install.sh index 1af70f5..8814dc4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,7 +10,10 @@ # 2. Resolves the latest release tag (or honors --version) # 3. Downloads tracebloc--- from the GitHub Release # 4. Verifies SHA256 against the release's SHA256SUMS file -# 5. (Optional) Verifies cosign signature if cosign is on PATH +# 5. Verifies the cosign signature — MANDATORY (RFC-0001 R8). If cosign isn't +# installed it bootstraps a pinned, checksum-verified one; if it can't, it +# FAILS CLOSED (never silently skips, never trusts the same-channel SHA256 +# alone). Override only with TRACEBLOC_ALLOW_UNVERIFIED=1. # 6. Installs to /usr/local/bin/tracebloc (falls back to $HOME/.local/bin # with PATH advice if /usr/local/bin isn't writable) # @@ -29,6 +32,19 @@ RELEASE_VERSION="${RELEASE_VERSION:-latest}" GITHUB_REPO="tracebloc/cli" BINARY_NAME="tracebloc" +# Cosign signature verification is MANDATORY on the default path (RFC-0001 R8, +# backend#889). The previous build silently SKIPPED it when cosign was absent — +# the default on a fresh box — degrading to a SHA256 fetched over the same +# channel as the binary, which an on-path attacker also controls. We now require +# a signature: if cosign isn't present we bootstrap a pinned, checksum-verified +# one; if we can't, we FAIL CLOSED. This explicit opt-out is the only way past, +# for the genuinely-constrained operator, and it shouts. +ALLOW_UNVERIFIED="${TRACEBLOC_ALLOW_UNVERIFIED:-0}" +# Pin kept in lockstep with the release workflow's cosign-installer and the +# client installer's COSIGN_VERSION. +COSIGN_VERSION="${COSIGN_VERSION:-v2.4.1}" +COSIGN_BIN="" + usage() { cat </dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + return 1 + fi +} + +# -------------------------------------------------------------------- +# Resolve a usable cosign into $COSIGN_BIN. Prefer one already on PATH; +# otherwise download the pinned release binary for this OS/arch and verify it +# against cosign's own published checksums before trusting it (a cosign we +# can't vouch for is no better than none). Returns non-zero if cosign can be +# neither found nor safely bootstrapped — the caller then fails closed. +# -------------------------------------------------------------------- +ensure_cosign() { + if command -v cosign >/dev/null 2>&1; then + COSIGN_BIN="cosign" + return 0 + fi + + # cosign publishes assets named cosign-- (arch in amd64/arm64); + # 386/arm have no official cosign build, so bootstrapping isn't possible there. + cosign_arch="" + case "$ARCH" in + amd64) cosign_arch="amd64" ;; + arm64) cosign_arch="arm64" ;; + *) return 1 ;; + esac + + cbase="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}" + casset="cosign-${OS}-${cosign_arch}" + cbin="$TMP/cosign" + csums="$TMP/cosign_checksums.txt" + + echo " cosign not found — bootstrapping pinned ${COSIGN_VERSION} to verify the signature..." + # --tlsv1.2 floor for the cosign bootstrap fetch, matching the client + # installer's curls — never negotiate below TLS 1.2 to pull the verifier we + # then trust to authenticate the release. + if ! curl -fsSL --tlsv1.2 "$cbase/$casset" -o "$cbin" 2>/dev/null; then return 1; fi + if ! curl -fsSL --tlsv1.2 "$cbase/cosign_checksums.txt" -o "$csums" 2>/dev/null; then return 1; fi + + cwant="$(grep " ${casset}\$" "$csums" | awk '{print $1}' | head -1)" + [ -n "$cwant" ] || return 1 + cgot="$(sha256_of "$cbin")" || return 1 + if [ "$cwant" != "$cgot" ]; then + echo "Error: bootstrapped cosign failed its own checksum — not using it." >&2 + return 1 + fi + chmod +x "$cbin" + COSIGN_BIN="$cbin" + return 0 +} + # -------------------------------------------------------------------- # Resolve the release tag if "latest". # -------------------------------------------------------------------- @@ -133,7 +209,36 @@ resolve_tag() { basename "$redirect_url" } +# -------------------------------------------------------------------- +# Validate the resolved tag before it flows into a download URL. +# +# --version / RELEASE_VERSION is returned by resolve_tag verbatim and then +# interpolated into BASE_URL=.../releases/download/${TAG}. An unvalidated value +# such as 'v1.2.3-../../heads/main' would let curl collapse the '..' and fetch +# from a path other than the intended release — a path-traversal lever in the +# most security-sensitive download in the installer. Constrain it to a release +# tag shape and refuse any '/' or '..' (RFC-0001 R8, backend#889). Matches the +# client bootstrap's gate (^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$). +validate_tag() { + # Path-traversal belt: no separators, no parent-dir tokens. + case "$1" in + */*|*..*) + echo "Error: release tag '$1' contains a path separator or '..' —" >&2 + echo " refusing to build a download URL from it (RFC-0001 R8)." >&2 + exit 1 + ;; + esac + # Shape: vMAJOR.MINOR.PATCH with an optional [.-]alnum/dot suffix. grep -E is + # POSIX and already relied on elsewhere in this script; -q keeps it quiet. + if ! printf '%s\n' "$1" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$'; then + echo "Error: '$1' is not a valid release tag (expected vX.Y.Z, e.g. v0.1.0)." >&2 + echo " Pass --version with a published release tag." >&2 + exit 1 + fi +} + TAG="$(resolve_tag)" +validate_tag "$TAG" echo "Installing tracebloc CLI $TAG ($OS/$ARCH)..." # -------------------------------------------------------------------- @@ -167,7 +272,7 @@ if [ -z "$expected" ]; then echo " — release artifacts may be incomplete." >&2 exit 1 fi -# sha256sum (GNU coreutils) vs shasum -a 256 (macOS): detect which is on PATH. +# sha256sum (GNU coreutils) vs shasum -a 256 (macOS): sha256_of picks one. # If neither is available, refuse to install — running an unverified # binary from the internet is exactly what this script exists to # prevent. Bugbot PR #11 caught the previous "warn + continue + still @@ -177,11 +282,7 @@ fi # base Perl install. A host with neither is unusual enough that # erroring out is the right call — the customer can install coreutils # / xcode-select / similar and re-run. -if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$TMP/$BINARY_FILE" | awk '{print $1}')" -elif command -v shasum >/dev/null 2>&1; then - actual="$(shasum -a 256 "$TMP/$BINARY_FILE" | awk '{print $1}')" -else +if ! actual="$(sha256_of "$TMP/$BINARY_FILE")"; then echo "Error: neither sha256sum nor shasum is on PATH — can't verify the" >&2 echo " downloaded binary's integrity. Install one of:" >&2 echo " apt install coreutils # Debian/Ubuntu" >&2 @@ -201,31 +302,63 @@ fi echo " ✓ checksum matches" # -------------------------------------------------------------------- -# Verify cosign signature if cosign is on PATH (optional). +# Verify the cosign signature — MANDATORY on the default path (RFC-0001 R8). +# +# The SHA256 check above proves the binary matches SHA256SUMS, but SHA256SUMS is +# fetched over the SAME channel as the binary — an on-path attacker who can swap +# the binary can swap the sums too. The cosign signature is the independent, +# Sigstore-rooted proof that tracebloc's release workflow produced these bytes. +# So we no longer "skip when cosign is absent": we require a verifier, bootstrap +# a pinned+checksummed cosign if one isn't installed, and FAIL CLOSED otherwise. +# The only escape is an explicit, loud TRACEBLOC_ALLOW_UNVERIFIED=1. # -------------------------------------------------------------------- -if command -v cosign >/dev/null 2>&1; then +verify_cosign_signature() { + if ! ensure_cosign; then + if [ "$ALLOW_UNVERIFIED" = "1" ]; then + echo " WARNING: cosign unavailable and couldn't be bootstrapped —" >&2 + echo " signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1). The SHA256" >&2 + echo " above is same-channel only; do not use this path in production." >&2 + return 0 + fi + echo "Error: cosign is required to verify the binary's signature and could" >&2 + echo " not be found or bootstrapped — refusing to install on an" >&2 + echo " unauthenticated, same-channel checksum alone (RFC-0001 R8)." >&2 + echo " Fix: install cosign and re-run —" >&2 + echo " https://docs.sigstore.dev/cosign/system_config/installation/" >&2 + echo " (brew install cosign / apt / the released binary), or for a" >&2 + echo " constrained environment re-run with TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + exit 1 + fi + echo "Verifying cosign signature..." - if ! curl -fsSL "$BASE_URL/$BINARY_FILE.sig" -o "$TMP/$BINARY_FILE.sig"; then - echo " ⚠ couldn't download .sig — release may pre-date signing." >&2 - elif ! curl -fsSL "$BASE_URL/$BINARY_FILE.cert" -o "$TMP/$BINARY_FILE.cert"; then - echo " ⚠ couldn't download .cert — release may pre-date signing." >&2 - elif ! cosign verify-blob \ + if ! curl -fsSL "$BASE_URL/$BINARY_FILE.sig" -o "$TMP/$BINARY_FILE.sig" 2>/dev/null \ + || ! curl -fsSL "$BASE_URL/$BINARY_FILE.cert" -o "$TMP/$BINARY_FILE.cert" 2>/dev/null; then + if [ "$ALLOW_UNVERIFIED" = "1" ]; then + echo " WARNING: .sig/.cert not published for $TAG — signature NOT verified" >&2 + echo " (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + return 0 + fi + echo "Error: couldn't download $BINARY_FILE.sig / .cert for $TAG — the" >&2 + echo " release is unsigned or incomplete. Every supported release is" >&2 + echo " cosign-signed; refusing to install unverified (RFC-0001 R8)." >&2 + echo " Pin a signed --version, or re-run with TRACEBLOC_ALLOW_UNVERIFIED=1." >&2 + exit 1 + fi + + if "$COSIGN_BIN" verify-blob \ --certificate-identity-regexp \ "https://github.com/${GITHUB_REPO}/.github/workflows/release.yml@.*" \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ --certificate "$TMP/$BINARY_FILE.cert" \ --signature "$TMP/$BINARY_FILE.sig" \ - "$TMP/$BINARY_FILE" 2>/dev/null; then + "$TMP/$BINARY_FILE" >/dev/null 2>&1; then + echo " ✓ cosign signature valid" + else echo "Error: cosign signature verification FAILED — refusing to install." >&2 exit 1 - else - echo " ✓ cosign signature valid" fi -else - # Not having cosign isn't fatal — the SHA256 check above is the - # baseline. Recommend cosign for higher-trust installs. - echo " (cosign not installed; SHA256 verified, signature skipped)" -fi +} +verify_cosign_signature # -------------------------------------------------------------------- # Install to a writable prefix. diff --git a/scripts/tests/install-verify.sh b/scripts/tests/install-verify.sh new file mode 100755 index 0000000..37fb341 --- /dev/null +++ b/scripts/tests/install-verify.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# ============================================================================= +# install-verify.sh — assert the MANDATORY cosign verification in install.sh +# (RFC-0001 R8, backend#889). +# +# The property under test: the CLI installer must NOT silently skip signature +# verification when cosign is absent (the previous behavior, and the default on +# a fresh box). It must bootstrap a verifier or FAIL CLOSED — never degrade to +# the same-channel SHA256 alone. +# +# install.sh is a POSIX `curl | sh` entrypoint, so we drive it as a subprocess +# with curl + cosign + the sha tools replaced by PATH shims and a fake release +# served from a temp dir. No network, no real download. This harness is bash +# (for arrays/locals); the script under test stays POSIX sh. +# ============================================================================= +set -u + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALLER="$SELF_DIR/../install.sh" + +PASS=0 +FAIL=0 +ok() { printf ' ok %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL+1)); } + +# real sha helper for building the fake SHA256SUMS +_sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi; } + +# Build a sandbox release + mock bin. Args: COSIGN_PRESENT(yes/no) +make_sandbox() { + SBX="$(mktemp -d)" + BIN="$SBX/bin"; REL="$SBX/release"; DEST="$SBX/dest" + mkdir -p "$BIN" "$REL" "$DEST" + + # The "binary" and its SHA256SUMS, named exactly as resolve_tag/detect_* expect. + os="$(uname -s | tr '[:upper:]' '[:lower:]')"; [ "$os" = darwin ] || os=linux + case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) arch=amd64 ;; esac + TAG="v9.9.9" + BF="tracebloc-${TAG}-${os}-${arch}" + printf 'fake-binary\n' > "$REL/$BF" + printf '%s %s\n' "$(_sha "$REL/$BF")" "$BF" > "$REL/SHA256SUMS" + printf 'SIG\n' > "$REL/$BF.sig" + printf 'CERT\n' > "$REL/$BF.cert" + + # Mock curl: map release-asset URLs to the served files; everything else 404s + # (so a cosign-bootstrap download fails → exercises the fail-closed path). + cat > "$BIN/curl" <&2; exit 22; }; if [ -n "\$out" ]; then cp "\$rel/\$f" "\$out"; else cat "\$rel/\$f"; fi;; + *) echo "unmapped \$url" >&2; exit 22;; +esac +EOF + chmod +x "$BIN/curl" + + # coreutils the installer needs, so PATH=$BIN alone works (host cosign hidden). + for t in bash sh env mkdir mktemp cp cat awk grep sed head tr uname chmod mv rm sleep printf install dirname basename sha256sum shasum; do + p="$(command -v "$t" 2>/dev/null)" && ln -sf "$p" "$BIN/$t" + done + + if [ "$1" = "yes" ]; then + # cosign present and PASSES + cat > "$BIN/cosign" <<'EOF' +#!/usr/bin/env bash +exit "${COSIGN_RESULT:-0}" +EOF + chmod +x "$BIN/cosign" + fi +} + +drop_sandbox() { rm -rf "$SBX"; } + +# Run installer with PATH=$BIN only (host cosign can't shadow), into $DEST. +run_installer() { PATH="$BIN" "$BIN/bash" "$INSTALLER" --prefix "$DEST" "$@" >"$SBX/out" 2>&1; echo $?; } + +# ── 1. cosign present + valid signature → installs ────────────────────────── +make_sandbox yes +rc="$(COSIGN_RESULT=0 run_installer)" +if [ "$rc" = 0 ] && grep -q "cosign signature valid" "$SBX/out" && [ -x "$DEST/tracebloc" ]; then + ok "cosign present + valid sig installs" +else + bad "cosign present + valid sig installs (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 2. cosign present + FAILED signature → aborts ─────────────────────────── +make_sandbox yes +rc="$(COSIGN_RESULT=1 run_installer)" +if [ "$rc" != 0 ] && grep -q "signature verification FAILED" "$SBX/out" && [ ! -e "$DEST/tracebloc" ]; then + ok "cosign present + bad sig fails closed" +else + bad "cosign present + bad sig fails closed (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 3. cosign ABSENT, can't bootstrap → FAILS CLOSED (the core regression) ── +make_sandbox no +rc="$(run_installer)" +if [ "$rc" != 0 ] && grep -q "cosign is required" "$SBX/out" && [ ! -e "$DEST/tracebloc" ]; then + ok "cosign absent fails closed (no silent skip)" +else + bad "cosign absent fails closed (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +# The exact old-behavior string must NEVER appear. +if grep -q "signature skipped" "$SBX/out"; then bad "found the old 'signature skipped' path"; else ok "no 'signature skipped' degrade path"; fi +drop_sandbox + +# ── 4. cosign absent + explicit opt-in → installs with a loud warning ─────── +make_sandbox no +rc="$(TRACEBLOC_ALLOW_UNVERIFIED=1 run_installer)" +if [ "$rc" = 0 ] && grep -qi "signature NOT verified" "$SBX/out" && [ -x "$DEST/tracebloc" ]; then + ok "opt-in degrades with a warning" +else + bad "opt-in degrades with a warning (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 5. SHA256 mismatch → aborts before cosign even matters ────────────────── +make_sandbox yes +# Overwrite the served binary AFTER SHA256SUMS was computed, so its digest no +# longer matches → the SHA256 gate must abort even with a passing cosign. +bf="$(ls "$REL"/tracebloc-v9.9.9-* | grep -vE '\.(sig|cert)$' | grep -v 'SHA256SUMS' | head -1)" +printf 'TAMPERED-CONTENT\n' > "$bf" +rc="$(COSIGN_RESULT=0 run_installer)" +if [ "$rc" != 0 ] && grep -q "SHA256 mismatch" "$SBX/out" && [ ! -e "$DEST/tracebloc" ]; then + ok "sha256 mismatch fails closed" +else + bad "sha256 mismatch fails closed (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 6. path-traversal --version → rejected before any download (RFC-0001 R8) ─ +# 'v1.2.3-../../heads/main' once flowed verbatim into BASE_URL=.../download/$TAG; +# curl would collapse the '..' and fetch from a path other than the release. The +# new validate_tag must reject it: non-zero, nothing installed, no binary fetch. +make_sandbox yes +rc="$(COSIGN_RESULT=0 run_installer --version 'v1.2.3-../../heads/main')" +if [ "$rc" != 0 ] \ + && grep -Eq "path separator or '\.\.'|not a valid release tag" "$SBX/out" \ + && [ ! -e "$DEST/tracebloc" ] \ + && ! grep -q "Downloading binary" "$SBX/out"; then + ok "traversal --version rejected before download" +else + bad "traversal --version rejected before download (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 7. bare path separator in --version → rejected ────────────────────────── +make_sandbox yes +rc="$(COSIGN_RESULT=0 run_installer --version 'v1.2.3/heads/main')" +if [ "$rc" != 0 ] \ + && grep -Eq "path separator or '\.\.'|not a valid release tag" "$SBX/out" \ + && [ ! -e "$DEST/tracebloc" ]; then + ok "slash in --version rejected" +else + bad "slash in --version rejected (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 8. malformed (non-vX.Y.Z) --version → rejected ────────────────────────── +make_sandbox yes +rc="$(COSIGN_RESULT=0 run_installer --version 'not-a-tag')" +if [ "$rc" != 0 ] && grep -q "not a valid release tag" "$SBX/out" && [ ! -e "$DEST/tracebloc" ]; then + ok "malformed --version rejected" +else + bad "malformed --version rejected (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +# ── 9. a well-formed --version still installs (validator isn't over-tight) ── +# Guards against a regression where validate_tag rejects legitimate tags. The +# sandbox serves v9.9.9, so request exactly that explicitly via --version. +make_sandbox yes +rc="$(COSIGN_RESULT=0 run_installer --version 'v9.9.9')" +if [ "$rc" = 0 ] && grep -q "cosign signature valid" "$SBX/out" && [ -x "$DEST/tracebloc" ]; then + ok "well-formed --version passes validation and installs" +else + bad "well-formed --version passes validation and installs (rc=$rc)"; sed 's/^/ /' "$SBX/out" +fi +drop_sandbox + +echo +echo "install-verify: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From 81107445b152b23003e07c6a0e982a6007114b6f Mon Sep 17 00:00:00 2001 From: shujaat_tracebloc <153823837+shujaatTracebloc@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:36:54 +0200 Subject: [PATCH 02/23] fix: validate COSIGN_VERSION and enforce TLS 1.2 on installer downloads (#114) Bugbot (promotion PR #113), two findings in scripts/install.sh: - Unvalidated cosign version in URL: COSIGN_VERSION (env-overridable) was interpolated into the Sigstore download URL without the semver/path-traversal gate applied to RELEASE_VERSION. Generalized validate_tag into validate_version_tag and apply it to COSIGN_VERSION before building the URL. (scripts/install.sh:45) - Signature download curls omit TLS: the .sig/.cert fetches (and the binary, SHA256SUMS, and latest-redirect curls) lacked --tlsv1.2, unlike the new cosign bootstrap fetches. Add --tlsv1.2 to every security-sensitive download. (scripts/install.sh:335) Co-authored-by: shujaat hasan Co-authored-by: Claude Opus 4.8 --- scripts/install.sh | 36 +++++++++++++++++++++++---------- scripts/tests/install-verify.sh | 16 +++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 8814dc4..b76b8ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -161,6 +161,12 @@ ensure_cosign() { *) return 1 ;; esac + # COSIGN_VERSION is env-overridable and gets interpolated into the Sigstore + # download URL, so it needs the same semver + path-traversal gate as the + # release tag — a crafted value must not redirect which release path we fetch. + validate_version_tag "$COSIGN_VERSION" "cosign version" \ + "Set COSIGN_VERSION to a published cosign release tag (e.g. v2.4.1)." + cbase="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}" casset="cosign-${OS}-${cosign_arch}" cbin="$TMP/cosign" @@ -196,7 +202,7 @@ resolve_tag() { # Use the redirect-trail of /releases/latest to learn the tag — # avoids hitting the rate-limited /api/repos endpoint for the # zero-auth one-liner case. - redirect_url="$(curl -fsSI \ + redirect_url="$(curl -fsSI --tlsv1.2 \ "https://github.com/${GITHUB_REPO}/releases/latest" \ | awk '/^[Ll]ocation:/ { print $2 }' \ | tr -d '\r')" @@ -219,24 +225,32 @@ resolve_tag() { # most security-sensitive download in the installer. Constrain it to a release # tag shape and refuse any '/' or '..' (RFC-0001 R8, backend#889). Matches the # client bootstrap's gate (^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.]+)?$). -validate_tag() { +# validate_version_tag